succession-cli 0.2.0__py3-none-any.whl

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.
succession/acp_cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """ACP registration, sync, and snapshot — the operator-side commands.
2
+
3
+ Registration itself happens in the Virtuals app, not here: the ACP Tech
4
+ Playbook issues a whitelisted wallet and an entity id, and no SDK call
5
+ substitutes for that. What these commands do is everything after it — confirm
6
+ the registration is live, pull the agent's job history, mirror it into memory so
7
+ it becomes part of the transferable asset, and snapshot it for offline use.
8
+
9
+ export WHITELISTED_WALLET_PRIVATE_KEY=0x...
10
+ export AGENT_WALLET_ADDRESS=0x...
11
+ export ACP_ENTITY_ID=...
12
+
13
+ succession-acp status
14
+ succession-acp sync --db seller.db --tenant tenant-seller
15
+ succession-acp snapshot --out web/public/acp-snapshot.json
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from .acp import (
26
+ ACPNotConfigured,
27
+ AgentNotRegistered,
28
+ LiveACP,
29
+ RecordedACP,
30
+ fetch_history,
31
+ job_history_from_memory,
32
+ require_registered,
33
+ sync_job_history,
34
+ )
35
+ from .memory.sibyl import open_tenant
36
+
37
+
38
+ def _history(args: argparse.Namespace):
39
+ if getattr(args, "snapshot", None):
40
+ return fetch_history(RecordedACP.from_file(args.snapshot))
41
+ return fetch_history(LiveACP())
42
+
43
+
44
+ def cmd_status(args: argparse.Namespace) -> int:
45
+ history = _history(args)
46
+ try:
47
+ require_registered(history)
48
+ except AgentNotRegistered as exc:
49
+ print(f"NOT REGISTERED: {exc}", file=sys.stderr)
50
+ return 1
51
+ print(json.dumps(history.to_dict(), indent=2))
52
+ return 0
53
+
54
+
55
+ def cmd_sync(args: argparse.Namespace) -> int:
56
+ """Pull job history and mirror it into the tenant.
57
+
58
+ Gated on registration: syncing an unregistered agent would write an empty
59
+ earnings record that later reads as "this agent earned nothing" rather than
60
+ "this agent was never registered".
61
+ """
62
+ history = _history(args)
63
+ require_registered(history)
64
+ memory = open_tenant(args.db, args.tenant)
65
+ written = sync_job_history(memory, history)
66
+ print(f"synced {written} ACP jobs into {args.tenant}")
67
+ print(f"completed: {len(history.completed)} failed: {len(history.failed)}")
68
+ print(f"gross volume: {history.gross_volume}")
69
+ rate = history.success_rate()
70
+ print(f"success rate: {rate if rate is not None else 'sample too small'}")
71
+ return 0
72
+
73
+
74
+ def cmd_snapshot(args: argparse.Namespace) -> int:
75
+ """Capture live history to a file, for offline development and the hosted UI."""
76
+ source = LiveACP()
77
+ payload = {"agent": source.agent(), "jobs": [j.to_dict() for j in source.jobs()]}
78
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
79
+ Path(args.out).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
80
+ print(f"wrote {len(payload['jobs'])} jobs to {args.out}")
81
+ return 0
82
+
83
+
84
+ def cmd_show(args: argparse.Namespace) -> int:
85
+ """Read back the history a tenant already carries. Needs no credentials."""
86
+ memory = open_tenant(args.db, args.tenant)
87
+ print(json.dumps(job_history_from_memory(memory).to_dict(), indent=2))
88
+ return 0
89
+
90
+
91
+ def main(argv: list[str] | None = None) -> int:
92
+ parser = argparse.ArgumentParser(
93
+ prog="succession-acp", description="Virtuals ACP job history."
94
+ )
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ def snapshot_opt(p: argparse.ArgumentParser) -> None:
98
+ p.add_argument(
99
+ "--snapshot",
100
+ help="read from a captured snapshot instead of the live API",
101
+ )
102
+
103
+ def tenant_opts(p: argparse.ArgumentParser) -> None:
104
+ p.add_argument("--db", type=Path, required=True)
105
+ p.add_argument("--tenant", required=True)
106
+
107
+ p = sub.add_parser("status", help="confirm registration and show job history")
108
+ snapshot_opt(p)
109
+ p.set_defaults(func=cmd_status)
110
+
111
+ p = sub.add_parser("sync", help="mirror ACP job history into a tenant's memory")
112
+ tenant_opts(p)
113
+ snapshot_opt(p)
114
+ p.set_defaults(func=cmd_sync)
115
+
116
+ p = sub.add_parser("snapshot", help="capture live job history to a file")
117
+ p.add_argument("--out", required=True)
118
+ p.set_defaults(func=cmd_snapshot)
119
+
120
+ p = sub.add_parser("show", help="show the job history a tenant already carries")
121
+ tenant_opts(p)
122
+ p.set_defaults(func=cmd_show)
123
+
124
+ args = parser.parse_args(argv)
125
+ try:
126
+ return args.func(args)
127
+ except (ACPNotConfigured, AgentNotRegistered) as exc:
128
+ print(f"FAILED: {exc}", file=sys.stderr)
129
+ return 1
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
succession/agent.py ADDED
@@ -0,0 +1,288 @@
1
+ """A minimal agent that answers strictly out of Sibyl Memory.
2
+
3
+ This backs the demo's final beat — the buyer's agent, cold-booted on new
4
+ infrastructure, continuing a live customer relationship it has never seen.
5
+
6
+ It is retrieval over the tenant, not a language model, and that is a deliberate
7
+ choice rather than a shortcut. The claim being demonstrated is "the memory
8
+ transferred and is usable", and a deterministic responder demonstrates exactly
9
+ that: every line it produces is traceable to a specific record, with the
10
+ category and name it came from. A model in this position would produce a more
11
+ fluent answer and a weaker demonstration, because a fluent answer is exactly
12
+ what a model can produce with no memory at all.
13
+
14
+ Swapping in a real model is a small change and the right one for production —
15
+ :meth:`Agent.context_for` already returns the grounded context a prompt would
16
+ carry. The demo keeps the deterministic path so what is on screen is evidence.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ __all__ = ["Agent", "Reply", "Citation"]
25
+
26
+ #: Words too common to retrieve anything useful. Kept short on purpose: an
27
+ #: aggressive list would strip the domain terms ("reefer", "flatbed", "lane")
28
+ #: that are exactly what makes a message findable.
29
+ _STOPWORDS = frozenset(
30
+ """a an and are as at be but by can could do for from has have hi hello
31
+ how i if in is it me my need of on or our out still that the their them
32
+ then there this to us we what when where which will with would you your
33
+ again about any anything back get got just like more not now one same
34
+ still than too very was were yes""".split()
35
+ )
36
+
37
+
38
+ def _terms(text: str) -> list[str]:
39
+ """The distinctive words in a message, in order, deduplicated."""
40
+ import re
41
+
42
+ seen: set[str] = set()
43
+ out: list[str] = []
44
+ for raw in re.findall(r"[A-Za-z][A-Za-z'-]+", text):
45
+ word = raw.lower().strip("'-")
46
+ if len(word) < 4 or word in _STOPWORDS or word in seen:
47
+ continue
48
+ seen.add(word)
49
+ out.append(raw)
50
+ return out[:8]
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Citation:
55
+ """Where a line of the answer came from. Every claim carries one."""
56
+
57
+ tier: str
58
+ key: str
59
+
60
+ def to_dict(self) -> dict[str, str]:
61
+ return {"tier": self.tier, "key": self.key}
62
+
63
+
64
+ @dataclass
65
+ class Reply:
66
+ text: str
67
+ citations: list[Citation] = field(default_factory=list)
68
+ recalled: bool = True
69
+
70
+ def to_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "text": self.text,
73
+ "recalled": self.recalled,
74
+ "citations": [c.to_dict() for c in self.citations],
75
+ }
76
+
77
+
78
+ class Agent:
79
+ """Answers about a counterparty from whatever the tenant actually holds."""
80
+
81
+ def __init__(self, memory: Any) -> None:
82
+ self.memory = memory
83
+
84
+ # -- retrieval -----------------------------------------------------
85
+
86
+ def find_counterparty(self, text: str) -> dict[str, Any] | None:
87
+ """Match a message to a known counterparty.
88
+
89
+ Goes through Sibyl's FTS5 index (``search_entities``, anchored to the
90
+ ``relationship`` category) rather than scanning every entity in Python.
91
+ That matters for more than speed: the index covers the entity's name,
92
+ category and body, so a customer who mentions their lane or a detail
93
+ from the notes is matched even when they never say the company name —
94
+ which is the realistic case for a returning customer.
95
+
96
+ The scan is kept as a fallback. FTS returns nothing for a query whose
97
+ every token is a stopword or punctuation, and "recall failed" is the
98
+ wrong answer when the company name is sitting right there in the text.
99
+ """
100
+ hits = self._search_relationships(text)
101
+
102
+ needle = text.lower()
103
+ best: tuple[int, dict[str, Any]] | None = None
104
+ for entity in hits or self._all_relationships():
105
+ company = str(entity["body"].get("company", ""))
106
+ for candidate in (company.lower(), entity["name"].replace("-", " ")):
107
+ if candidate and candidate in needle:
108
+ # Longest literal match wins, so "Northwind Mills" beats "mills".
109
+ if best is None or len(candidate) > best[0]:
110
+ best = (len(candidate), entity)
111
+ if best is not None:
112
+ return best[1]
113
+
114
+ # No literal name in the message, but FTS matched something on the body
115
+ # — the lane, or a detail from the notes. Take its top-ranked hit.
116
+ return hits[0] if hits else None
117
+
118
+ def _search_relationships(self, text: str) -> list[dict[str, Any]]:
119
+ """FTS over the relationship category, one distinctive term at a time.
120
+
121
+ The SDK sanitizes a query into a single FTS5 *phrase*, so passing a
122
+ whole sentence asks the index for that exact sentence and finds
123
+ nothing. Searching the message's distinctive terms individually and
124
+ merging by how many of them hit is what actually retrieves a
125
+ counterparty from "we need a reefer from Yakima to Denver" — where the
126
+ company is never named and the match has to come from the lane and the
127
+ notes.
128
+ """
129
+ scored: dict[tuple[str, str], tuple[int, dict[str, Any]]] = {}
130
+ for term in _terms(text):
131
+ try:
132
+ results = self.memory.client.search_entities(
133
+ term, limit=5, category="relationship"
134
+ )
135
+ except Exception: # noqa: BLE001 - a search that errors is skipped
136
+ continue
137
+ for entity in results:
138
+ key = (entity["category"], entity["name"])
139
+ count, _ = scored.get(key, (0, entity))
140
+ scored[key] = (count + 1, entity)
141
+
142
+ return [
143
+ entity
144
+ for _, entity in sorted(scored.values(), key=lambda p: -p[0])
145
+ ]
146
+
147
+ def _all_relationships(self) -> list[dict[str, Any]]:
148
+ return [e for e in self.memory.entities() if e["category"] == "relationship"]
149
+
150
+ def open_commitment_for(self, company: str) -> dict[str, Any] | None:
151
+ for entity in self.memory.entities():
152
+ if entity["category"] != "commitment":
153
+ continue
154
+ if entity["body"].get("counterparty") == company:
155
+ return entity
156
+ return None
157
+
158
+ def behaviour_for(self, company: str) -> dict[str, Any] | None:
159
+ for entity in self.memory.entities():
160
+ if entity["category"] != "learned-behavior":
161
+ continue
162
+ if company.lower() in str(entity["body"].get("pattern", "")).lower():
163
+ return entity
164
+ return None
165
+
166
+ def working_state(self) -> dict[str, Any] | None:
167
+ state = self.memory.client.get_state("current-negotiation")
168
+ return state["body"] if state else None
169
+
170
+ def context_for(self, message: str) -> dict[str, Any]:
171
+ """The grounded context behind an answer — what a prompt would carry."""
172
+ counterparty = self.find_counterparty(message)
173
+ if counterparty is None:
174
+ return {}
175
+ company = counterparty["body"].get("company", counterparty["name"])
176
+ return {
177
+ "counterparty": counterparty,
178
+ "commitment": self.open_commitment_for(company),
179
+ "behaviour": self.behaviour_for(company),
180
+ "state": self.working_state(),
181
+ "history": self._history_for(counterparty),
182
+ }
183
+
184
+ def _history_for(self, counterparty: dict[str, Any]) -> list[str]:
185
+ """Journal lines about this counterparty.
186
+
187
+ Matching on the company name alone misses most of them: the journal
188
+ records work by lane ("quoted Duluth to Kansas City at 2380; accepted"),
189
+ because that is how the agent thinks about a booking. The lane is
190
+ therefore part of the needle, not just the name.
191
+ """
192
+ body = counterparty["body"]
193
+ needles = {str(body.get("company", "")).lower(), counterparty["name"].replace("-", " ")}
194
+ lane = str(body.get("primary_lane", "")).lower()
195
+ if lane and lane != "withheld":
196
+ needles.add(lane)
197
+ needles = {n for n in needles if n}
198
+
199
+ return [
200
+ line
201
+ for event in self.memory.events()
202
+ for line in (event.get("acted") or [])
203
+ if any(n in line.lower() for n in needles)
204
+ ]
205
+
206
+ # -- response ------------------------------------------------------
207
+
208
+ def respond(self, message: str) -> Reply:
209
+ context = self.context_for(message)
210
+ if not context:
211
+ return Reply(
212
+ text=(
213
+ "I don't have a record of this counterparty. Could you tell me "
214
+ "the company and the lane, and I'll quote it fresh?"
215
+ ),
216
+ recalled=False,
217
+ )
218
+
219
+ counterparty = context["counterparty"]
220
+ company = counterparty["body"].get("company", counterparty["name"])
221
+ citations = [Citation("relationship", counterparty["name"])]
222
+
223
+ parts = [f"Yes — {company}."]
224
+
225
+ commitment = context.get("commitment")
226
+ state = context.get("state")
227
+ if commitment is not None:
228
+ parts.append(_describe_commitment(commitment))
229
+ citations.append(Citation("commitment", commitment["name"]))
230
+
231
+ if state and state.get("counterparty") == company:
232
+ parts.append(state["our_position"])
233
+ citations.append(Citation("state", "current-negotiation"))
234
+
235
+ behaviour = context.get("behaviour")
236
+ if behaviour is not None:
237
+ parts.append(behaviour["body"]["pattern"])
238
+ citations.append(Citation("learned-behavior", behaviour["name"]))
239
+
240
+ history = context.get("history") or []
241
+ if history:
242
+ n = len(history)
243
+ parts.append(
244
+ f"That's {n} prior interaction{'' if n == 1 else 's'} on record."
245
+ )
246
+ citations.append(
247
+ Citation("history", f"{n} journal event{'' if n == 1 else 's'}")
248
+ )
249
+
250
+ return Reply(text=" ".join(parts), citations=citations)
251
+
252
+
253
+ #: Fields that name *what* a commitment is about, in order of preference. An
254
+ #: agent's domain decides which of these its commitments carry, so the
255
+ #: description composes from whichever are present rather than assuming one
256
+ #: vocabulary — the first version of this hardcoded freight and produced a
257
+ #: KeyError the moment a recruiting agent was seeded.
258
+ _SUBJECT_FIELDS = (
259
+ "lane", "role", "line", "deliverable", "offer", "counterparty", "units",
260
+ )
261
+
262
+ #: Fields carrying a monetary amount, again in preference order.
263
+ _VALUE_FIELDS = (
264
+ "quoted_rate_usd", "proposed_standing_rate_usd", "fee_usd", "value_usd",
265
+ )
266
+
267
+
268
+ def _describe_commitment(commitment: dict[str, Any]) -> str:
269
+ """One sentence about an open commitment, from whatever fields it has."""
270
+ body = commitment.get("body") or {}
271
+
272
+ subject = next(
273
+ (str(body[f]) for f in _SUBJECT_FIELDS if body.get(f) not in (None, "")),
274
+ None,
275
+ )
276
+ amount = next(
277
+ (body[f] for f in _VALUE_FIELDS if isinstance(body.get(f), (int, float))),
278
+ None,
279
+ )
280
+ status = body.get("status")
281
+
282
+ clause = "We have " + (f"{subject} open" if subject else "an open item")
283
+ if amount is not None:
284
+ clause += f" at ${amount:,}"
285
+ clause += f", recorded as {commitment['name']}"
286
+ if status:
287
+ clause += f" and still {status}"
288
+ return clause + "."