Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/echo_memory/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pathlib import Path

from echo_memory.audit.get_audit_log import get_fact_history
from echo_memory.cli import adopt, health, initdb, reattribute_cmd, stop_gate
from echo_memory.cli import adopt, health, initdb, reattribute_cmd, stop_gate, unmerge
from echo_memory.cli import analyse as analyse_cmd
from echo_memory.cli import dashboard as dashboard_cmd
from echo_memory.cli import hooks as hooks_cmd
Expand Down Expand Up @@ -102,6 +102,26 @@ def _add_project_parsers(sub) -> None:
)
reattr.add_argument("--session", metavar="ID", help="session whose facts to reattribute")
reattr.add_argument("--project", metavar="NAME", help="project to attribute them to")
reattr.add_argument(
"--agent", action="store_true",
help=(
"work on authorship instead of project: recover the agent_id of facts "
"whose session evidences one. Never guesses - a session with no "
"attributed fact, or two, is reported as unrecoverable"
),
)

un = sub.add_parser(
"unmerge",
help="take back an alias a node absorbed from a different entity",
)
un.add_argument(
"--list", action="store_true", dest="list_aliases",
help="show every node answering to another node's name",
)
un.add_argument("--node", metavar="ID", help="the node holding the wrong alias")
un.add_argument("--alias", metavar="NAME", help="the alias to take back")
un.add_argument("--session-id", metavar="ID", help="session to record in the audit log")

notice = sub.add_parser(
"notice", help="queue a memory file for ingestion (called by the capture hook)"
Expand Down Expand Up @@ -374,6 +394,7 @@ def _add_trial_parser(sub) -> None:
_PROJECT_COMMANDS = {
"dashboard": dashboard_cmd.run,
"reattribute": reattribute_cmd.run,
"unmerge": unmerge.run,
"notice": queue_cmd.run_notice,
"pending": queue_cmd.run_pending,
}
Expand Down
126 changes: 126 additions & 0 deletions src/echo_memory/cli/reattribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@
import json

from echo_memory.infra.db import GRAPH_NAME as GRAPH
from echo_memory.infra.project import UNKNOWN as UNATTRIBUTED
from echo_memory.infra.project import normalize


class ReattributionError(Exception):
pass


def sessions_by_project(conn, group_id: str) -> list[dict]:
"""Every session that has written to this scope, with its current project
attribution and fact count, so the operator can see what needs saying."""
Expand Down Expand Up @@ -72,3 +77,124 @@ def render_sessions(scope: str, sessions: list[dict]) -> str:
f" echo-memory --scope {scope} reattribute --session <id> --project <name>",
]
return "\n".join(lines) + "\n"


# --- agent attribution, recovered rather than asserted -------------------------
#
# Project attribution above is operator knowledge: only a human knows which
# project a session belonged to, so a human says so. Agent attribution is not
# like that. Guessing it is precisely what migration 0011 refused to do when it
# backfilled absent agent_ids to 'unknown' instead of to 'claude-code', and that
# refusal was right: a fact claiming an author it cannot support is worse than
# one admitting it has none.
#
# There is one case where it does not have to be a guess. A session is one
# tool's conversation, so if some facts from a session carry a real agent_id and
# others carry none, the others were written by that same tool - evidence from
# inside the store, not recollection from outside it. Where a session offers no
# such evidence, or offers two different agents, this refuses rather than
# picking one.


def agent_evidence(conn, group_id: str) -> list[dict]:
"""Per session: how many facts lack an author, and what the rest of that
session says about who wrote them."""
rows = conn.execute(
f"""SELECT * FROM cypher('{GRAPH}', $$
MATCH ()-[e:FACT {{group_id: $gid}}]->()
RETURN e.provenance.session_id, e.agent_id, count(e)
$$, %s) AS (session_id agtype, agent_id agtype, n agtype)""",
(json.dumps({"gid": group_id}),),
).fetchall()

by_session: dict[str, dict[str, int]] = {}
for session, agent, n in rows:
# An absent agent_id and the literal 'unknown' are the same condition
# wearing two hats: AGE drops a property whose value is null at CREATE,
# so a writer that passed None left no key, and migration 0011 turned
# what it could find into 'unknown'.
key = str(agent).strip('"') if agent is not None else UNATTRIBUTED
by_session.setdefault(str(session).strip('"'), {})[key] = int(str(n))

evidence = []
for session, tally in by_session.items():
missing = tally.get(UNATTRIBUTED, 0)
if not missing:
continue
attributed = {a: n for a, n in tally.items() if a != UNATTRIBUTED}
evidence.append({
"session_id": session,
"missing": missing,
"attributed": attributed,
# One agent and only one. Two would mean the session id is shared
# by two tools, and then nothing here can say which wrote what.
"recoverable_as": next(iter(attributed)) if len(attributed) == 1 else None,
})
return sorted(evidence, key=lambda e: (e["recoverable_as"] is None, e["session_id"]))


def reattribute_agent(conn, group_id: str, session_id: str) -> int:
"""Give one session's unattributed facts the author the rest of that
session already evidences. Refuses when the session does not evidence one,
because the alternative is inventing provenance."""
match = next(
(e for e in agent_evidence(conn, group_id) if e["session_id"] == session_id), None
)
if match is None:
raise ReattributionError(f"session {session_id} has no unattributed facts")
if match["recoverable_as"] is None:
found = ", ".join(sorted(match["attributed"])) or "nothing"
raise ReattributionError(
f"session {session_id} does not evidence one author ({found}), so its "
f"{match['missing']} unattributed fact(s) stay unattributed. They predate "
"attribution and there is nothing in the store that can recover them."
)

agent = match["recoverable_as"]
rows = conn.execute(
f"""SELECT * FROM cypher('{GRAPH}', $$
MATCH ()-[e:FACT {{group_id: $gid}}]->()
WHERE e.provenance.session_id = $sid
AND (e.agent_id = $unknown OR e.agent_id IS NULL)
SET e.agent_id = $agent
RETURN id(e)
$$, %s) AS (edge_id agtype)""",
(json.dumps(
{"gid": group_id, "sid": session_id, "agent": agent, "unknown": UNATTRIBUTED}
),),
).fetchall()
return len(rows)


def render_agent_evidence(scope: str, evidence: list[dict]) -> str:
if not evidence:
return f"Every fact in {scope} records who wrote it.\n"

lines = [f"Facts in {scope} with no recorded author:", ""]
for e in evidence:
if e["recoverable_as"]:
lines.append(
f" {e['missing']:>3} fact(s) session {e['session_id']} "
f"-> {e['recoverable_as']} (the rest of the session says so)"
)
else:
found = ", ".join(f"{a} x{n}" for a, n in sorted(e["attributed"].items()))
lines.append(
f"! {e['missing']:>3} fact(s) session {e['session_id']} "
f"-> unrecoverable ({found or 'no attributed fact in this session'})"
)

if any(e["recoverable_as"] for e in evidence):
lines += [
"",
"Recover the evidenced ones with:",
f" echo-memory --scope {scope} reattribute --agent --session <id>",
]
if any(not e["recoverable_as"] for e in evidence):
lines += [
"",
"The rest stay unattributed. Nothing in the store says who wrote them, and",
"a fact claiming an author it cannot support is worse than one admitting",
"it has none.",
]
return "\n".join(lines) + "\n"
30 changes: 28 additions & 2 deletions src/echo_memory/cli/reattribute_cmd.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
"""echo-memory reattribute: the command surface for pointing historical facts
at the project they came from. The queries live in reattribute.py."""
at the project they came from, and for recovering the author of facts written
before agent_id was recorded. The queries live in reattribute.py."""

from echo_memory.cli.reattribute import reattribute, render_sessions, sessions_by_project
from echo_memory.cli.reattribute import (
ReattributionError,
agent_evidence,
reattribute,
reattribute_agent,
render_agent_evidence,
render_sessions,
sessions_by_project,
)


def _run_agent(args, group_id, conn) -> int:
if not args.session:
print(render_agent_evidence(args.scope, agent_evidence(conn, group_id)), end="")
# Listing is the helpful response to `--agent` alone, and it is also
# not a completed instruction. Same convention as the project half.
return 0 if args.list_sessions else 1
try:
changed = reattribute_agent(conn, group_id, args.session)
except ReattributionError as e:
print(f"error: {e}")
return 1
print(f"Recovered the author of {changed} fact(s) from session {args.session}.")
return 0


def run(args, config, conn) -> int:
group_id = config.group_id(args.scope)
if getattr(args, "agent", False):
return _run_agent(args, group_id, conn)
if args.list_sessions or not (args.session and args.project):
print(render_sessions(args.scope, sessions_by_project(conn, group_id)), end="")
# Without both --session and --project there is nothing to do, so
Expand Down
22 changes: 13 additions & 9 deletions src/echo_memory/cli/trial.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,27 @@ def render_criterion_six(report: dict, indent: str = " ", show_hint: bool = Tru
f"(started {trial['started_on']}, {trial['days_left']} left)"
)

saves_note = ""
uncounted = []
if counts["same_tool_saves"]:
saves_note = (
f" (+{counts['same_tool_saves']} same-tool, which the criterion doesn't count)"
)
uncounted.append(f"{counts['same_tool_saves']} same-tool")
if counts.get("unattributed_saves"):
uncounted.append(f"{counts['unattributed_saves']} with no recorded author")
saves_note = (
f" (+{', '.join(uncounted)}, which the criterion doesn't count)" if uncounted else ""
)
lines.append(
f"{indent}[{'x' if met['saves'] else ' '}] {counts['cross_tool_saves']}"
f"/{observations.REQUIRED_SAVES} recall saves to a different tool{saves_note}"
)
# An unmet bar has to name its cause. A reader who sees 3/3 saves and an
# unticked box would otherwise assume a display bug rather than a store
# that cannot yet evidence what the bar measures.
# Reported, not blocking. A save is excluded when its own evidence is
# missing (see observations.counts); facts elsewhere in the store that lost
# their author are a health problem worth seeing and say nothing about
# whether these particular saves happened.
if report.get("unattributed_facts"):
lines.append(
f"{indent} ! {report['unattributed_facts']} fact(s) still carry "
f"agent_id '{UNKNOWN_PROJECT}', so a cross-tool save cannot be evidenced "
"- they predate attribution and cannot be recovered"
f"agent_id '{UNKNOWN_PROJECT}' and cannot evidence a save - recover the "
"ones whose session says who wrote them with `reattribute --agent`"
)
lines.append(
f"{indent}[{'x' if met['duplicates'] else ' '}] {counts['duplicates']} "
Expand Down
Loading
Loading