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/__init__.py +52 -0
- succession/acp.py +522 -0
- succession/acp_cli.py +133 -0
- succession/agent.py +288 -0
- succession/audit.py +470 -0
- succession/canonical.py +103 -0
- succession/catalog.py +571 -0
- succession/certificate.py +113 -0
- succession/chain.py +678 -0
- succession/cli.py +895 -0
- succession/data/__init__.py +1 -0
- succession/data/artifacts.json +1789 -0
- succession/data/base-sepolia.json +11 -0
- succession/dataroom.py +275 -0
- succession/demo.py +200 -0
- succession/demokeys.py +33 -0
- succession/envelope.py +140 -0
- succession/erc8004.py +603 -0
- succession/evaluator.py +459 -0
- succession/export.py +237 -0
- succession/fulfil.py +165 -0
- succession/importer.py +243 -0
- succession/marketplace.py +159 -0
- succession/mcp_server/__init__.py +309 -0
- succession/memory/__init__.py +23 -0
- succession/memory/base.py +172 -0
- succession/memory/credentials.py +135 -0
- succession/memory/sibyl.py +451 -0
- succession/merkle.py +226 -0
- succession/provenance.py +187 -0
- succession/publish.py +361 -0
- succession/redaction.py +238 -0
- succession/reputation.py +404 -0
- succession/scope.py +344 -0
- succession/seal.py +309 -0
- succession/seed.py +309 -0
- succession/settlement.py +543 -0
- succession/smp.py +386 -0
- succession/transfer.py +322 -0
- succession/valuation.py +306 -0
- succession_cli-0.2.0.dist-info/METADATA +56 -0
- succession_cli-0.2.0.dist-info/RECORD +44 -0
- succession_cli-0.2.0.dist-info/WHEEL +4 -0
- succession_cli-0.2.0.dist-info/entry_points.txt +4 -0
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 + "."
|