strata-agent-memory 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {strata_agent_memory-0.2.2/strata_agent_memory.egg-info → strata_agent_memory-0.2.3}/PKG-INFO +1 -1
  2. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/__init__.py +1 -1
  3. strata_agent_memory-0.2.3/agent_memory/approvals.py +440 -0
  4. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/audit.py +1 -1
  5. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/cli.py +292 -17
  6. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/compiler.py +31 -9
  7. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/contracts.py +264 -76
  8. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/evidence.py +43 -2
  9. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/hooks.py +52 -19
  10. strata_agent_memory-0.2.3/agent_memory/instances.py +134 -0
  11. strata_agent_memory-0.2.3/agent_memory/kit.py +94 -0
  12. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/ledger.py +106 -13
  13. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/mcp_server.py +37 -1
  14. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/recheck.py +3 -0
  15. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/adapters.py +9 -2
  16. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/client.py +287 -12
  17. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/core.py +544 -23
  18. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/reliability.py +4 -4
  19. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/remote.py +114 -11
  20. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/render.py +18 -2
  21. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/serve.py +204 -12
  22. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/signing.py +14 -4
  23. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/trust.py +15 -7
  24. strata_agent_memory-0.2.3/deploy/mac/README.md +190 -0
  25. strata_agent_memory-0.2.3/deploy/mac/agent-keys.sh +279 -0
  26. strata_agent_memory-0.2.3/deploy/mac/agent-session.sh +398 -0
  27. strata_agent_memory-0.2.3/deploy/mac/backup-tokens.sh +136 -0
  28. strata_agent_memory-0.2.3/deploy/mac/install-hooks-mac.sh +301 -0
  29. strata_agent_memory-0.2.3/deploy/mac/install-relay-driver.sh +367 -0
  30. strata_agent_memory-0.2.3/deploy/mac/install-replica-driver.sh +575 -0
  31. strata_agent_memory-0.2.3/deploy/mac/install-runner-driver.sh +224 -0
  32. strata_agent_memory-0.2.3/deploy/mac/instances.example.json +27 -0
  33. strata_agent_memory-0.2.3/deploy/mac/onboard-agent.sh +599 -0
  34. strata_agent_memory-0.2.3/deploy/mac/relay-agent.sh +363 -0
  35. strata_agent_memory-0.2.3/deploy/mac/seed-board.sh +256 -0
  36. strata_agent_memory-0.2.3/deploy/mac/seed-mail-context.sh +212 -0
  37. strata_agent_memory-0.2.3/deploy/mac/seed-topics.sh +182 -0
  38. strata_agent_memory-0.2.3/deploy/mac/upgrade-driver.sh +319 -0
  39. strata_agent_memory-0.2.3/deploy/relay/README.md +68 -0
  40. strata_agent_memory-0.2.3/deploy/relay/agent-memory-relay@.service +46 -0
  41. strata_agent_memory-0.2.3/deploy/relay/install-relay-mac.sh +394 -0
  42. strata_agent_memory-0.2.3/deploy/relay/install-relay.sh +589 -0
  43. strata_agent_memory-0.2.3/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +67 -0
  44. strata_agent_memory-0.2.3/deploy/relay/relay-launch.sh +98 -0
  45. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/pyproject.toml +17 -2
  46. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3/strata_agent_memory.egg-info}/PKG-INFO +1 -1
  47. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/SOURCES.txt +26 -0
  48. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_audit.py +2 -1
  49. strata_agent_memory-0.2.3/tests/test_backlog_check.py +221 -0
  50. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_cli.py +62 -0
  51. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_compiler_render.py +189 -0
  52. strata_agent_memory-0.2.3/tests/test_contracts.py +1020 -0
  53. strata_agent_memory-0.2.3/tests/test_deploy_mac.py +2955 -0
  54. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_evidence.py +43 -0
  55. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_hooks.py +159 -0
  56. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_issue_bridge.py +1 -2
  57. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_ledger.py +90 -2
  58. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_packaging.py +25 -0
  59. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_phase2.py +40 -1
  60. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_client.py +856 -0
  61. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_core.py +409 -8
  62. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_remote.py +447 -14
  63. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review_fixes.py +18 -13
  64. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_serve.py +474 -21
  65. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_trust.py +44 -1
  66. strata_agent_memory-0.2.2/tests/test_contracts.py +0 -511
  67. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/LICENSE +0 -0
  68. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/README.md +0 -0
  69. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/__main__.py +0 -0
  70. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/compact.py +0 -0
  71. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/export.py +0 -0
  72. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/identity.py +0 -0
  73. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/memorizer.py +0 -0
  74. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/pathmap.py +0 -0
  75. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/records.py +0 -0
  76. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/__init__.py +0 -0
  77. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/cli.py +0 -0
  78. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/stats.py +0 -0
  79. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/setup.cfg +0 -0
  80. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
  81. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/entry_points.txt +0 -0
  82. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/requires.txt +0 -0
  83. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/top_level.txt +0 -0
  84. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_compact.py +0 -0
  85. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_relay.py +0 -0
  86. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_replica.py +0 -0
  87. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_tokens.py +0 -0
  88. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_identity.py +0 -0
  89. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_ledger_index.py +0 -0
  90. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_memorizer.py +0 -0
  91. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_pathmap.py +0 -0
  92. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_recheck.py +0 -0
  93. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_records.py +0 -0
  94. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_redaction.py +0 -0
  95. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_codex.py +0 -0
  96. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_reliability.py +0 -0
  97. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_remote_signing.py +0 -0
  98. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review2_fixes.py +0 -0
  99. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review3_fixes.py +0 -0
  100. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_scale.py +0 -0
  101. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_signing.py +0 -0
  102. {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strata-agent-memory
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Portable, auditable, cross-vendor memory for LLM agents: evidence-bound claims, trust tiers, and compiled context views.
5
5
  Author: Strata Intelligence
6
6
  License-Expression: Apache-2.0
@@ -9,4 +9,4 @@ see, what did it claim, and can the proof be re-run.
9
9
  The core is dependency-free (Python 3.10+ standard library only).
10
10
  """
11
11
 
12
- __version__ = "0.2.2"
12
+ __version__ = "0.2.3"
@@ -0,0 +1,440 @@
1
+ """The human-only acts on a ledger, one decision each (docs/PROTOCOL.md section 10).
2
+
3
+ Agents verify and a PM closes (section 4.1). What is left for a person is small and
4
+ should cost a person one read and one word each: ``pending`` lists it, and ``answer``
5
+ files the person's record for it. An item is one of
6
+
7
+ close a submitted contract that is verified by derivation and that no PM may
8
+ close (``pm_may_close`` off, or ``config.pm`` names nobody). A gate.
9
+ pm-close a close a PM filed. Information, not a gate: the close stands; the
10
+ person acknowledges it, or objects in a note.
11
+ retraction a note tagged ``approval:retraction`` asking that a claim be withdrawn:
12
+ the claim is the note's ``derived_from`` (or the first claim id its
13
+ statement names), the reason is the note.
14
+ reopen a note tagged ``approval:reopen --contract <id>``: back to ``accepted``,
15
+ or to the status a ``status:<s>`` tag names.
16
+ reject a note tagged ``approval:reject --contract <id>``: the work will not be
17
+ done.
18
+ constraint a note tagged ``approval:constraint``: its statement becomes a
19
+ human-filed constraint.
20
+ needs-human a relay's breaker stopped a thread (section 11); the person's answer is
21
+ a human record on that thread, which is what releases it.
22
+
23
+ ``approval_mode`` decides who is asked for what. human (the default): all of the above.
24
+ auto: the agents close among themselves, so only constraints and retractions -- the acts
25
+ that change what every reader is told is true -- reach a person, and a stopped thread,
26
+ which is an alarm rather than an approval.
27
+
28
+ A person's answer carries the tags ``approval:approved`` or ``approval:rejected`` and
29
+ ``answers:<item id>``, and an item with such an answer from a human is no longer
30
+ pending. Every item carries five lines -- the title and the PR, what was done, the
31
+ evidence (receipt, who re-ran it, the result), the review verdict with its record id, the
32
+ risk -- and the expected answer: approve, reject or hold.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from typing import Iterable, List, Optional
38
+
39
+ from . import contracts
40
+ from .records import ValidationError, new_record, scope_for_target, sort_key
41
+
42
+ GATE_KINDS = ("close", "retraction", "reopen", "reject", "constraint", "needs-human")
43
+ PROPOSAL_KINDS = ("retraction", "reopen", "reject", "constraint")
44
+ AUTO_KINDS = ("retraction", "constraint")
45
+ APPROVED, REJECTED = "approval:approved", "approval:rejected"
46
+ PR_URL = re.compile(r"https://github\.com/[\w.-]+/[\w.-]+/pull/(\d+)")
47
+ PR_REF = re.compile(r"\bPR\s*#?(\d+)\b", re.I)
48
+ REOPEN_STATUSES = ("proposed", "accepted", "in_progress")
49
+ LINE = 160 # a summary line a person reads at a glance
50
+ # Every summary is text an agent wrote, shown to the person whose answer it asks for: C0
51
+ # and C1 controls (ESC, CSI) would let it move the cursor and rewrite what the person
52
+ # reads, and bidirectional overrides would reorder it. They become spaces.
53
+ CONTROL = re.compile("[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069]")
54
+
55
+
56
+ def _plain(text) -> str:
57
+ return " ".join(CONTROL.sub(" ", str(text or "")).split())
58
+
59
+
60
+ def _short(text: Optional[str], limit: int = LINE) -> str:
61
+ text = _plain(text)
62
+ return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
63
+
64
+
65
+ def _first_sentence(text: Optional[str]) -> str:
66
+ text = _plain(text)
67
+ match = re.search(r"[.;:](\s|$)", text)
68
+ return _short(text[:match.start() + 1] if match and match.start() > 20 else text)
69
+
70
+
71
+ def _tags(rec: dict) -> List[str]:
72
+ return [t for t in (rec.get("tags") or []) if isinstance(t, str)]
73
+
74
+
75
+ def _human(rec: dict) -> bool:
76
+ return (rec.get("author") or {}).get("kind") == "human"
77
+
78
+
79
+ def _who(author: dict) -> str:
80
+ return f"{author.get('harness') or author.get('kind') or '?'}/{author.get('user') or '?'}"
81
+
82
+
83
+ def _answered(ledger) -> tuple:
84
+ """({item id: the newest human answer's ts}, {thread: the newest human record's ts}).
85
+
86
+ An answer is explicit: a human record tagged ``answers:<id>``; an agent's record
87
+ carrying the tag answers nothing. A person who replied on a stopped thread some other
88
+ way has answered it too -- that reply is what released it (section 11) -- so the
89
+ newest human ``thread:`` record per thread is kept as well. Nothing else counts: a
90
+ human's unrelated update on a contract is not an answer to whether to close it.
91
+ """
92
+ ids, threads = {}, {}
93
+ for rec in ledger.all():
94
+ if not _human(rec):
95
+ continue
96
+ ts = rec.get("ts") or ""
97
+ for tag in _tags(rec):
98
+ if tag.startswith("answers:"):
99
+ key = tag[len("answers:"):]
100
+ ids[key] = max(ids.get(key, ""), ts)
101
+ elif tag.startswith("thread:"):
102
+ thread = tag[len("thread:"):]
103
+ threads[thread] = max(threads.get(thread, ""), ts)
104
+ return ids, threads
105
+
106
+
107
+ def _evidence_moved(ledger, contract_id: str, basis: dict) -> str:
108
+ """The newest ts among the records a close rests on: the latest acceptance run, the
109
+ standing outcome claims, their verifications, the contract's transitions. A person's
110
+ answer older than this was an answer to other evidence, so the item is asked again."""
111
+ stamps = [u.get("ts") or "" for u in ledger.contract_updates(contract_id)]
112
+ for cid in basis.get("outcomes") or []:
113
+ stamps.append((ledger.get(cid) or {}).get("ts") or "")
114
+ stamps += [v.get("ts") or "" for v in ledger.verifications_for(cid)]
115
+ return max(stamps, default="")
116
+
117
+
118
+ # --------------------------------------------------------------------------- the lines
119
+
120
+ def _pr(ledger, contract_id: Optional[str]) -> Optional[str]:
121
+ """The newest PR a contract's own records name: its updates' notes, then its claims."""
122
+ if not contract_id:
123
+ return None
124
+ texts = [(u["ts"], u["body"].get("note") or "") for u in ledger.contract_updates(contract_id)]
125
+ texts += [(c["ts"], c["body"].get("statement") or "") for c in ledger.claims_for_contract(contract_id)]
126
+ for _, text in sorted(texts, reverse=True):
127
+ url = PR_URL.search(text)
128
+ if url:
129
+ return f"PR #{url.group(1)} ({url.group(0)})"
130
+ ref = PR_REF.search(text)
131
+ if ref:
132
+ return f"PR #{ref.group(1)}"
133
+ return None
134
+
135
+
136
+ def _contract_notes(ledger, contract_id: Optional[str], tag: str) -> List[dict]:
137
+ """Claims about a contract carrying ``tag`` (or a ``tag:...`` form), newest first."""
138
+ if not contract_id:
139
+ return []
140
+ found = [c for c in ledger.claims_for_contract(contract_id)
141
+ if any(t == tag or t.startswith(tag + ":") for t in _tags(c))
142
+ and not ledger.retractions_for(c["id"])]
143
+ return sorted(found, key=sort_key, reverse=True)
144
+
145
+
146
+ def _review(ledger, contract_id: Optional[str]) -> dict:
147
+ """The newest review note on the contract: tag ``review``, verdict from a ``verdict:``
148
+ tag (approve, approve-with-nits, changes, reject) or the review's first words."""
149
+ notes = _contract_notes(ledger, contract_id, "review")
150
+ if not notes:
151
+ return {"verdict": None, "id": None, "line": "review: none on file"}
152
+ note = notes[0]
153
+ verdict = next((t[len("verdict:"):] for t in _tags(note) if t.startswith("verdict:")), None)
154
+ shown = verdict or _short(note["body"].get("statement"), 60)
155
+ return {"verdict": verdict, "id": note["id"],
156
+ "line": f"review: {shown} ({note['id']}, {_who(note['author'])})"}
157
+
158
+
159
+ def _risk(ledger, contract_id: Optional[str]) -> dict:
160
+ """The newest open risk or finding filed against the contract."""
161
+ notes = _contract_notes(ledger, contract_id, "risk") + _contract_notes(ledger, contract_id, "finding")
162
+ if not notes:
163
+ return {"id": None, "line": "risk: none recorded"}
164
+ note = sorted(notes, key=sort_key, reverse=True)[0]
165
+ return {"id": note["id"], "line": f"risk: {_short(note['body'].get('statement'), LINE - 20)} ({note['id']})"}
166
+
167
+
168
+ def _evidence(ledger, basis: Optional[dict]) -> str:
169
+ """Receipt, who re-ran it, the result -- for each outcome the contract rests on."""
170
+ if not basis or not basis.get("outcomes"):
171
+ return "evidence: none on file"
172
+ parts = []
173
+ for cid in basis["outcomes"]:
174
+ claim = ledger.get(cid) or {}
175
+ receipts = [e for e in (claim.get("body") or {}).get("evidence") or [] if isinstance(e, str)]
176
+ checks = [ledger.get(v) for v in basis["verifications"].get(cid, [])]
177
+ checks = [v for v in checks if v]
178
+ if checks:
179
+ v = checks[-1]
180
+ results = [r.get("outcome") for r in (v.get("body") or {}).get("results") or [] if isinstance(r, dict)]
181
+ parts.append(f"{', '.join(receipts) or cid} re-run by {_who(v['author'])} ({v['id']}): "
182
+ f"{(v.get('body') or {}).get('verdict')}{', ' + ', '.join(results) if results else ''}")
183
+ else:
184
+ parts.append(f"{', '.join(receipts) or cid}: not re-checked by another identity")
185
+ if basis.get("run"):
186
+ parts.append(f"acceptance {basis['run']}: " + ("passed" if basis.get("ok") else _short(basis.get("reason"), 80)))
187
+ return _short("evidence: " + "; ".join(parts), LINE * 2)
188
+
189
+
190
+ def _expected_for_close(review: dict, risk: dict) -> str:
191
+ if review["verdict"] in ("changes", "reject", "request-changes", "blocked"):
192
+ return "hold"
193
+ if review["id"] is None or risk["id"] is not None:
194
+ return "hold"
195
+ return "approve"
196
+
197
+
198
+ def _item(ledger, kind: str, rid: str, contract_id: Optional[str], title: str, done: str, evidence: str,
199
+ review: dict, risk: dict, expected: str, gate: bool, **extra) -> dict:
200
+ pr = _pr(ledger, contract_id)
201
+ # every field an agent wrote is cleaned here, in the item GET /approvals, memory_approvals
202
+ # and --json hand on, not only in the summary a person reads
203
+ title, done, evidence = _plain(title), _plain(done), _plain(evidence)
204
+ review_line, risk_line = _plain(review["line"]), _plain(risk["line"])
205
+ extra = {key: (_plain(value) if isinstance(value, str) else value) for key, value in extra.items()}
206
+ lines = [f"{title} -- {pr or 'no PR named'}", done, evidence, review_line, risk_line]
207
+ return {"id": rid, "kind": kind, "gate": gate, "contract": contract_id, "title": title, "pr": pr,
208
+ "done": done, "evidence": evidence, "review": review_line, "review_id": review["id"],
209
+ "risk": risk_line, "risk_id": risk["id"], "expected": expected,
210
+ "summary": [_short(line, LINE * 2) for line in lines], **extra}
211
+
212
+
213
+ # --------------------------------------------------------------------------- the list
214
+
215
+ def pending(ledger, assessment=None, reader: Optional[str] = None) -> dict:
216
+ """What waits for a person on this ledger, with the policy that put it there.
217
+
218
+ ``assessment`` is the trust assessment to read tiers from (computed once when
219
+ omitted). ``reader`` is the user asking: a user-scoped proposal is listed only to
220
+ its owner, as every other user-scoped record is.
221
+ """
222
+ config = ledger.config
223
+ mode = contracts.approval_mode(config)
224
+ tiers = assessment if assessment is not None else contracts._Lazy(ledger)
225
+ answered, released = _answered(ledger)
226
+ items: List[dict] = []
227
+
228
+ def visible(rec: dict) -> bool:
229
+ scope = rec.get("scope") or {}
230
+ return scope.get("level") != "user" or (reader is not None and scope.get("user") == reader)
231
+
232
+ no_pm = not contracts.pm_may_close(config) or not contracts.pm_users(config)
233
+ for rec in sorted(ledger.by_kind("contract"), key=sort_key):
234
+ cid = rec["id"]
235
+ filed = contracts.filed_status(ledger, cid)
236
+ title = _short(rec["body"].get("title"), 100)
237
+ if mode == "human" and filed in ("submitted", "verified") and no_pm:
238
+ # a `verified` filed before the rule is history the old machine judged: its
239
+ # close is still a person's, and the lines show what it rests on today. A
240
+ # person who declined it is asked again once the evidence moves.
241
+ basis = contracts.verification_basis(ledger, cid, tiers)
242
+ fresh = cid not in answered or answered[cid] < _evidence_moved(ledger, cid, basis)
243
+ if fresh and (basis["ok"] or filed == "verified"):
244
+ outcome = (ledger.get(basis["outcomes"][0]) if basis["outcomes"] else None) or {}
245
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
246
+ items.append(_item(ledger, "close", cid, cid, title,
247
+ _first_sentence((outcome.get("body") or {}).get("statement")),
248
+ _evidence(ledger, basis), review, risk,
249
+ _expected_for_close(review, risk), gate=True))
250
+ if mode == "human" and filed == "closed":
251
+ close = contracts.transitions(ledger, cid)[-1]
252
+ # a PM's close, listed so a person sees it; an agent that closed before there
253
+ # was a PM rule is history, not something to acknowledge now
254
+ if contracts.is_pm(config, close["author"]) and close["id"] not in answered:
255
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
256
+ basis = contracts.verification_basis(ledger, cid, tiers)
257
+ items.append(_item(ledger, "pm-close", close["id"], cid, title,
258
+ _short(f"closed by {_who(close['author'])}: {close['body'].get('note') or ''}"),
259
+ _evidence(ledger, basis), review, risk, "approve", gate=False))
260
+
261
+ kinds = AUTO_KINDS if mode == "auto" else PROPOSAL_KINDS
262
+ for note in sorted(ledger.by_kind("claim"), key=sort_key):
263
+ if note["id"] in answered or not visible(note) or ledger.retractions_for(note["id"]):
264
+ continue
265
+ tags = _tags(note)
266
+ if "needs-human" in tags: # an alarm in either mode: auto removes approvals, not alarms
267
+ thread = next((t[len("thread:"):] for t in tags if t.startswith("thread:")), None)
268
+ if thread and released.get(thread, "") <= (note.get("ts") or ""):
269
+ items.append(_item(ledger, "needs-human", note["id"], (note.get("body") or {}).get("contract"),
270
+ f"thread {thread}", _short(note["body"].get("statement")),
271
+ f"evidence: the last message on it is {', '.join(note.get('derived_from') or []) or '?'}",
272
+ {"verdict": None, "id": None, "line": "review: none (a breaker, not work)"},
273
+ {"id": None, "line": "risk: the thread stays stopped until a person answers on it"},
274
+ "approve", gate=True, thread=thread))
275
+ continue
276
+ kind = next((t[len("approval:"):] for t in tags if t.startswith("approval:")), None)
277
+ if kind not in kinds:
278
+ continue
279
+ item = _proposal(ledger, note, kind, tiers)
280
+ if item is not None:
281
+ items.append(item)
282
+ return {"approval_mode": mode, "pm_may_close": contracts.pm_may_close(config),
283
+ "pm": contracts.pm_users(config), "items": items,
284
+ "waiting": sum(1 for i in items if i["gate"]), "information": sum(1 for i in items if not i["gate"])}
285
+
286
+
287
+ def _target_claim(ledger, note: dict) -> Optional[dict]:
288
+ """The one claim a retraction proposal names: its derived_from when that names any
289
+ record, else the claim ids in its statement -- and only when exactly one claim is
290
+ named. A note that names two would otherwise have the person retract whichever came
291
+ first (review, story 1); it waits, saying why, until it names one."""
292
+ named = [rid for rid in (note.get("derived_from") or []) if rid != note["id"]]
293
+ if not named:
294
+ named = [rid for rid in contracts.CLAIM_ID.findall(note["body"].get("statement") or "") if rid != note["id"]]
295
+ named = list(dict.fromkeys(named))
296
+ if len(named) != 1:
297
+ return None
298
+ rec = ledger.get(named[0])
299
+ return rec if rec is not None and rec.get("kind") == "claim" else None
300
+
301
+
302
+ def _proposal(ledger, note: dict, kind: str, tiers) -> Optional[dict]:
303
+ """A PM's note asking for a human-only act, as an item -- or None once the act has
304
+ happened by some other road (the claim already withdrawn, the contract already there)."""
305
+ body = note.get("body") or {}
306
+ cid = body.get("contract")
307
+ title = _short((ledger.get(cid) or {}).get("body", {}).get("title"), 100) if cid else "(no contract)"
308
+ review, risk = _review(ledger, cid), _risk(ledger, cid)
309
+ asked = f"{_who(note['author'])} asks: {_short(body.get('statement'), LINE - 30)}"
310
+ if kind == "retraction":
311
+ target = _target_claim(ledger, note)
312
+ if target is None:
313
+ return _item(ledger, kind, note["id"], cid, title if cid else "retract (no single claim named)", asked,
314
+ "evidence: the note names no single claim to retract; name exactly one in derived_from",
315
+ review, risk, "reject", gate=True, target=None)
316
+ if ledger.retractions_for(target["id"]):
317
+ return None
318
+ tier = tiers.get(target["id"], {}).get("tier", "?")
319
+ evidence = f"evidence: {target['id']} by {_who(target['author'])} is {tier}"
320
+ return _item(ledger, kind, note["id"], cid, title if cid else f"retract {target['id']}", asked, evidence,
321
+ review, risk, "approve", gate=True, target=target["id"])
322
+ if kind in ("reopen", "reject"):
323
+ if not cid or ledger.get(cid) is None:
324
+ return None
325
+ filed = contracts.filed_status(ledger, cid)
326
+ if kind == "reject" and filed == "rejected":
327
+ return None
328
+ if kind == "reopen" and filed not in contracts.TERMINAL_STATUSES:
329
+ return None
330
+ status = "rejected" if kind == "reject" else next(
331
+ (t[len("status:"):] for t in _tags(note) if t.startswith("status:")
332
+ and t[len("status:"):] in REOPEN_STATUSES), "accepted")
333
+ return _item(ledger, kind, note["id"], cid, title, asked,
334
+ f"evidence: the contract is {filed}; this moves it to {status}",
335
+ review, risk, "approve", gate=True, to_status=status)
336
+ if kind == "constraint":
337
+ # the whole statement is what becomes the constraint, so the whole of it is shown:
338
+ # a summary cut at 160 characters would let a tail nobody read be approved
339
+ return _item(ledger, kind, note["id"], cid, title if cid else "a constraint for every reader", asked,
340
+ "evidence: a constraint is what a person says always applies; nothing to re-run",
341
+ review, risk, "approve", gate=True, text=_plain(body.get("statement")))
342
+ return None
343
+
344
+
345
+ # --------------------------------------------------------------------------- the answer
346
+
347
+ def answer(ledger, author: dict, scope: dict, item_id: str, verdict: str = "approve",
348
+ note: Optional[str] = None, assessment=None) -> List[dict]:
349
+ """File a person's answer to one pending item; returns the records filed.
350
+
351
+ Refused for anything but a human identity: an agent may explain and recommend, and
352
+ the record this files is the one thing it may not. ``reject`` needs a note (the reason
353
+ is the record); ``approve`` takes one. Raises ValidationError with the refusal, or
354
+ KeyError when nothing is pending under that id.
355
+ """
356
+ if author.get("kind") != "human":
357
+ raise ValidationError(
358
+ f"approving is a person's act: this identity is an agent's ({_who(author)}); file the answer under "
359
+ f"a human token (agent-memory approve --as-human locally, or a human's token over --remote)")
360
+ if verdict not in ("approve", "reject"):
361
+ raise ValidationError("an answer is approve or reject; hold is filing nothing")
362
+ note = (note or "").strip() or None
363
+ if verdict == "reject" and not note:
364
+ raise ValidationError("a rejection needs a note: the reason is the record")
365
+ # one assessment for the list and the close's basis: each would otherwise compute its own
366
+ assessment = assessment if assessment is not None else contracts._Lazy(ledger)
367
+ view = pending(ledger, assessment)
368
+ item = next((i for i in view["items"] if i["id"] == item_id), None)
369
+ if item is None:
370
+ raise KeyError(f"nothing pending under {item_id} (agent-memory approvals lists what is)")
371
+ tags = [APPROVED if verdict == "approve" else REJECTED, f"answers:{item_id}"]
372
+ said = f": {note}" if note else ""
373
+ if verdict == "reject" or item["kind"] == "pm-close":
374
+ return [_answer_note(ledger, author, scope, item, verdict, note, tags)]
375
+ kind = item["kind"]
376
+ if kind == "close":
377
+ basis = contracts.verification_basis(ledger, item["contract"], assessment)
378
+ relied = [f"{c} re-checked in {', '.join(basis['verifications'].get(c, [])) or '-'}" for c in basis["outcomes"]]
379
+ return [contracts.update(ledger, author, scope, item["contract"], "closed",
380
+ note=f"approved: {'; '.join(relied)}{said}", tags=tags)]
381
+ if kind in ("reopen", "reject"):
382
+ proposal = ledger.get(item_id)
383
+ reason = note or (proposal or {}).get("body", {}).get("statement")
384
+ return [contracts.update(ledger, author, scope, item["contract"], item["to_status"],
385
+ note=f"approved {item_id}: {_short(reason, 400)}", tags=tags)]
386
+ if kind == "retraction":
387
+ if not item.get("target"):
388
+ raise ValidationError(f"{item_id} names no single claim to retract: reject it, and ask for a note "
389
+ f"that names exactly one in derived_from")
390
+ proposal = ledger.get(item_id) or {}
391
+ target = ledger.get(item["target"])
392
+ reason = f"approved {item_id}: {proposal.get('body', {}).get('statement')}{said}"
393
+ rec = new_record("retraction", author, scope_for_target(target, scope),
394
+ {"claim": target["id"], "reason": _short(reason, 1900)},
395
+ derived_from=[target["id"]], tags=tags)
396
+ return [ledger.append(rec)]
397
+ if kind == "constraint":
398
+ proposal = ledger.get(item_id) or {}
399
+ body = {"type": "constraint", "statement": proposal["body"]["statement"]}
400
+ if proposal["body"].get("contract"):
401
+ body["contract"] = proposal["body"]["contract"]
402
+ return [ledger.append(new_record("claim", author, scope, body, derived_from=[item_id], tags=tags))]
403
+ if kind == "needs-human":
404
+ tags.append(f"thread:{item['thread']}")
405
+ body = {"type": "note", "statement": f"released thread {item['thread']}{said or ': read and answered'}"}
406
+ return [ledger.append(new_record("claim", author, scope, body, derived_from=[item_id], tags=tags))]
407
+ raise ValidationError(f"no answer is defined for a {kind} item")
408
+
409
+
410
+ def _answer_note(ledger, author: dict, scope: dict, item: dict, verdict: str, note: Optional[str],
411
+ tags: Iterable[str]) -> dict:
412
+ """The record for an acknowledgement or a rejection: a note, on the contract when the
413
+ item has one, deriving from the item when the item is a claim (rule 10)."""
414
+ word = "acknowledged" if verdict == "approve" else "declined"
415
+ body = {"type": "note", "statement": _short(f"{word} {item['kind']} {item['id']}{': ' + note if note else ''}", 1900)}
416
+ if item.get("contract"):
417
+ body["contract"] = item["contract"]
418
+ target = ledger.get(item["id"])
419
+ derived = [item["id"]] if target is not None and target.get("kind") == "claim" else []
420
+ return ledger.append(new_record("claim", author, scope, body, derived_from=derived, tags=list(tags)))
421
+
422
+
423
+ def render(view: dict) -> str:
424
+ """The text a person reads: a header, then five lines and the expected answer per item."""
425
+ lines = [f"approvals: approval_mode {view['approval_mode']}, pm_may_close "
426
+ f"{'on' if view['pm_may_close'] else 'off'}, PM {', '.join(view['pm']) or 'none'}; "
427
+ f"{view['waiting']} waiting, {view['information']} for information"]
428
+ for item in view["items"]:
429
+ lines.append("")
430
+ lines.append(f"[{item['kind']}] {item['id']}" + ("" if item["gate"] else " (information: the close stands)"))
431
+ for n, text in enumerate(item["summary"], 1):
432
+ lines.append(f" {n} {text}")
433
+ if item.get("text"):
434
+ lines.append(f" the constraint, in full: {item['text']}")
435
+ lines.append(f" -> {item['expected']}: agent-memory approve {item['id']} | "
436
+ f"agent-memory reject {item['id']} --note \"...\"")
437
+ if not view["items"]:
438
+ lines.append("nothing waits for a person")
439
+ # the view may come from a replica (GET /approvals): the text is cleaned here as well
440
+ return "\n".join(CONTROL.sub(" ", line) for line in lines)
@@ -413,7 +413,7 @@ def format_report(rep: dict) -> str:
413
413
  lambda r: f"{r['id']} [{r['vendor']} verified by {', '.join(r['verified_by'])}; {r.get('tier','')}] {r['statement']}")
414
414
  section("Verified only by the claim's own author", rep["self_verified_by_author"],
415
415
  lambda r: f"{r['id']} [{r['by']}] {r['statement']}")
416
- section("Verified from the claim's own session under another harness (collusion risk)",
416
+ section("Verified from the claim's own session under another harness (an attempt; never a tier reached)",
417
417
  rep.get("same_session_different_harness", []),
418
418
  lambda r: f"{r['id']} [{r['by']} -> {r['verified_by']}, {r['user']} session {r['session']}] "
419
419
  f"{r['statement']}")