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.
- {strata_agent_memory-0.2.2/strata_agent_memory.egg-info → strata_agent_memory-0.2.3}/PKG-INFO +1 -1
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/__init__.py +1 -1
- strata_agent_memory-0.2.3/agent_memory/approvals.py +440 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/audit.py +1 -1
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/cli.py +292 -17
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/compiler.py +31 -9
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/contracts.py +264 -76
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/evidence.py +43 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/hooks.py +52 -19
- strata_agent_memory-0.2.3/agent_memory/instances.py +134 -0
- strata_agent_memory-0.2.3/agent_memory/kit.py +94 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/ledger.py +106 -13
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/mcp_server.py +37 -1
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/recheck.py +3 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/adapters.py +9 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/client.py +287 -12
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/core.py +544 -23
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/reliability.py +4 -4
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/remote.py +114 -11
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/render.py +18 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/serve.py +204 -12
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/signing.py +14 -4
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/trust.py +15 -7
- strata_agent_memory-0.2.3/deploy/mac/README.md +190 -0
- strata_agent_memory-0.2.3/deploy/mac/agent-keys.sh +279 -0
- strata_agent_memory-0.2.3/deploy/mac/agent-session.sh +398 -0
- strata_agent_memory-0.2.3/deploy/mac/backup-tokens.sh +136 -0
- strata_agent_memory-0.2.3/deploy/mac/install-hooks-mac.sh +301 -0
- strata_agent_memory-0.2.3/deploy/mac/install-relay-driver.sh +367 -0
- strata_agent_memory-0.2.3/deploy/mac/install-replica-driver.sh +575 -0
- strata_agent_memory-0.2.3/deploy/mac/install-runner-driver.sh +224 -0
- strata_agent_memory-0.2.3/deploy/mac/instances.example.json +27 -0
- strata_agent_memory-0.2.3/deploy/mac/onboard-agent.sh +599 -0
- strata_agent_memory-0.2.3/deploy/mac/relay-agent.sh +363 -0
- strata_agent_memory-0.2.3/deploy/mac/seed-board.sh +256 -0
- strata_agent_memory-0.2.3/deploy/mac/seed-mail-context.sh +212 -0
- strata_agent_memory-0.2.3/deploy/mac/seed-topics.sh +182 -0
- strata_agent_memory-0.2.3/deploy/mac/upgrade-driver.sh +319 -0
- strata_agent_memory-0.2.3/deploy/relay/README.md +68 -0
- strata_agent_memory-0.2.3/deploy/relay/agent-memory-relay@.service +46 -0
- strata_agent_memory-0.2.3/deploy/relay/install-relay-mac.sh +394 -0
- strata_agent_memory-0.2.3/deploy/relay/install-relay.sh +589 -0
- strata_agent_memory-0.2.3/deploy/relay/macos/com.strata.agent-memory.relay.plist.template +67 -0
- strata_agent_memory-0.2.3/deploy/relay/relay-launch.sh +98 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/pyproject.toml +17 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3/strata_agent_memory.egg-info}/PKG-INFO +1 -1
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/SOURCES.txt +26 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_audit.py +2 -1
- strata_agent_memory-0.2.3/tests/test_backlog_check.py +221 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_cli.py +62 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_compiler_render.py +189 -0
- strata_agent_memory-0.2.3/tests/test_contracts.py +1020 -0
- strata_agent_memory-0.2.3/tests/test_deploy_mac.py +2955 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_evidence.py +43 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_hooks.py +159 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_issue_bridge.py +1 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_ledger.py +90 -2
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_packaging.py +25 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_phase2.py +40 -1
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_client.py +856 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_core.py +409 -8
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_remote.py +447 -14
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review_fixes.py +18 -13
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_serve.py +474 -21
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_trust.py +44 -1
- strata_agent_memory-0.2.2/tests/test_contracts.py +0 -511
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/LICENSE +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/README.md +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/__main__.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/compact.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/export.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/identity.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/memorizer.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/pathmap.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/records.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/__init__.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/relay/cli.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/agent_memory/stats.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/setup.cfg +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/dependency_links.txt +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/entry_points.txt +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/requires.txt +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/strata_agent_memory.egg-info/top_level.txt +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_compact.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_relay.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_replica.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_deploy_tokens.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_identity.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_ledger_index.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_memorizer.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_pathmap.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_recheck.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_records.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_redaction.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_relay_codex.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_reliability.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_remote_signing.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review2_fixes.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_review3_fixes.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_scale.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_signing.py +0 -0
- {strata_agent_memory-0.2.2 → strata_agent_memory-0.2.3}/tests/test_stats.py +0 -0
{strata_agent_memory-0.2.2/strata_agent_memory.egg-info → strata_agent_memory-0.2.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: strata-agent-memory
|
|
3
|
-
Version: 0.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
|
|
@@ -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 (
|
|
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']}")
|