@aeondave/pi-persona 1.17.3 → 1.17.4

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 (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -29,6 +29,7 @@ import { createHash } from "node:crypto";
29
29
 
30
30
  import { memberBaseLabel, parseRosterMember, type RosterMember, type RosterSpec } from "../orchestration/roster.ts";
31
31
  import { knownParams } from "../orchestration/strategy.ts";
32
+ import type { ThinkingLevel } from "../core/types.ts";
32
33
  import type { StrategyParam } from "../orchestration/sdk.ts";
33
34
 
34
35
  /** Where a participant's effective model came from — in precedence order. */
@@ -58,6 +59,12 @@ export interface ModelParticipant {
58
59
  model?: string;
59
60
  /** Which step of the precedence supplied `model`. */
60
61
  source: ModelSource;
62
+ /** The member's SPEC-level effort — exactly the value that lands in `AgentRunSpec.thinking`
63
+ * when a strategy spreads `rosterSpec(member)`. Never resolved against agent defaults or the
64
+ * configured child effort (the engines own that precedence): the row shows what the launch
65
+ * spec carried, the same rule the delegate launch rows follow. Absent when none was declared
66
+ * (absence renders nothing); an explicit `off` is a declared value and is kept. */
67
+ thinking?: ThinkingLevel;
61
68
  /** Someone already chose this participant's model (inline, a saved assignment, or the agent's own
62
69
  * frontmatter) ⇒ never a picker candidate. The session default is deliberately NOT counted. */
63
70
  assigned: boolean;
@@ -135,6 +142,7 @@ function fromSpec(spec: RosterSpec, origin: "roster" | "param", param: string |
135
142
  ...(param ? { param } : {}),
136
143
  ...(model ? { model } : {}),
137
144
  source,
145
+ ...(spec.thinking ? { thinking: spec.thinking } : {}),
138
146
  assigned: source === "inline" || source === "assignment" || source === "agent",
139
147
  };
140
148
  }
@@ -191,16 +199,13 @@ function isAgentName(value: unknown): boolean {
191
199
  return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value.trim());
192
200
  }
193
201
 
194
- /** Every participant a run really spawns: the roster first (in run order), then auxiliary actors. */
195
- export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
202
+ /** Every declared execution occurrence, including repeated assignment keys with different pins. */
203
+ export function collectModelParticipantOccurrences(input: ParticipantInput): ModelParticipant[] {
196
204
  const out: ModelParticipant[] = [];
197
- const seen = new Set<string>();
198
205
  for (const raw of input.members ?? []) {
199
206
  const spec = toSpec(raw);
200
207
  if (!spec) continue;
201
208
  const p = fromSpec(spec, "roster", undefined, input);
202
- if (seen.has(p.key)) continue;
203
- seen.add(p.key);
204
209
  out.push(p);
205
210
  }
206
211
  const schema = input.schema ?? (input.strategy ? knownParams(input.strategy) : undefined);
@@ -210,14 +215,22 @@ export function collectModelParticipants(input: ParticipantInput): ModelParticip
210
215
  const spec = auxActorSpec(input, paramName, param);
211
216
  if (!spec) continue;
212
217
  const p = fromSpec(spec, "param", paramName, input);
213
- if (seen.has(p.key)) continue;
214
- seen.add(p.key);
215
218
  out.push(p);
216
219
  }
217
220
  }
218
221
  return out;
219
222
  }
220
223
 
224
+ /** One row per assignment key for model setup. Repeated execution occurrences share that choice. */
225
+ export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
226
+ const seen = new Set<string>();
227
+ return collectModelParticipantOccurrences(input).filter((p) => {
228
+ if (seen.has(p.key)) return false;
229
+ seen.add(p.key);
230
+ return true;
231
+ });
232
+ }
233
+
221
234
  /** The participants a picker may still ask about: nobody has chosen a model for them, and this
222
235
  * session has not already asked (and been dismissed) for that key. The caller records the keys it
223
236
  * asks about BEFORE asking, so a dismissal is remembered too. */
@@ -73,6 +73,7 @@ export interface RunStrategyDeps {
73
73
  /** Per-agent lifecycle, for live UI (which roster agent is running/done + its result).
74
74
  * `key` is a run-unique display id (disambiguates same-agent roster-role members). */
75
75
  onAgentStatus?: (agent: string, status: AgentStatus, result?: AgentResult, key?: string) => void;
76
+ onAgentResult?: SDKDeps["onAgentResult"];
76
77
  /** Per-agent streaming progress (rolling output), for live UI. */
77
78
  onAgentProgress?: (agent: string, progress: AgentProgress, key?: string) => void;
78
79
  /** Called as each agent starts with a handle to abort just that agent (UI stop). */
@@ -110,6 +111,7 @@ export async function runPersonaStrategy(
110
111
  if (deps.onModelFallback) sdkDeps.onModelFallback = deps.onModelFallback;
111
112
  if (deps.log) sdkDeps.log = deps.log;
112
113
  if (deps.onAgentStatus) sdkDeps.onAgentStatus = deps.onAgentStatus;
114
+ if (deps.onAgentResult) sdkDeps.onAgentResult = deps.onAgentResult;
113
115
  if (deps.onAgentProgress) sdkDeps.onAgentProgress = deps.onAgentProgress;
114
116
  if (deps.onAgentStart) sdkDeps.onAgentStart = deps.onAgentStart;
115
117
  if (deps.onAgentSteerable) sdkDeps.onAgentSteerable = deps.onAgentSteerable;
@@ -244,7 +244,7 @@ export function expandCouncilPreset(draft: CouncilDraft, presets: Record<string,
244
244
  /** Per-call council selection. `persona` borrows only that persona's expanded
245
245
  * council declaration; it never activates the persona or imports its prompt,
246
246
  * model, tools, or permissions. Explicit strategy/roster/params remain local
247
- * overrides for backward compatibility. */
247
+ * overrides of those defaults. */
248
248
  export interface CouncilInvocation {
249
249
  persona?: string | undefined;
250
250
  strategy?: string | undefined;
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ createTerminalResultRecord, isResultRef, pageStoredBody, parseTerminalResultInput,
4
+ parseTerminalResultRecord, payloadBytes,
5
+ } from "./record.ts";
6
+ import {
7
+ TERMINAL_RESULT_ENTRY, RESULT_MAX_ENTRIES, RESULT_MAX_PAYLOAD_BYTES, RESULT_LIST_DEFAULT, RESULT_LIST_MAX,
8
+ type ArchiveEvidence, type ArchiveFailure, type ResultListRequest, type ResultPage, type ResultReadRequest,
9
+ type ResultStorage, type ResultSummary, type ResultSummaryPage, type ResultWarning, type SaveOutcome,
10
+ type TerminalResultArchiveDeps, type TerminalResultInput, type TerminalResultRecord,
11
+ } from "./types.ts";
12
+
13
+ interface RetainedResult {
14
+ record: TerminalResultRecord;
15
+ storage: ResultStorage;
16
+ storageError?: string;
17
+ }
18
+ interface SourceResult { data: unknown; index: number; retained?: RetainedResult }
19
+ type Lookup = { kind: "missing" } | { kind: "ambiguous" | "corrupt" } | {
20
+ kind: "found"; record: TerminalResultRecord; index: number; retained?: RetainedResult;
21
+ };
22
+
23
+ function identity(data: unknown): { ref?: string; originKey?: string } {
24
+ try {
25
+ if (data === null || typeof data !== "object") return {};
26
+ const value = data as Record<string, unknown>;
27
+ return {
28
+ ...(isResultRef(value.ref) ? { ref: value.ref } : {}),
29
+ ...(typeof value.originKey === "string" && value.originKey.length <= 1024 ? { originKey: value.originKey } : {}),
30
+ };
31
+ } catch { return {}; }
32
+ }
33
+
34
+ function summary(record: TerminalResultRecord, storage: ResultStorage): ResultSummary {
35
+ // Parse again to detach optional usage too; callers never receive our mutable state.
36
+ const copy = parseTerminalResultRecord(record)!;
37
+ const { body: _body, payloadHash: _hash, ...metadata } = copy;
38
+ return { ...metadata, storage };
39
+ }
40
+
41
+ function validQuery(value: unknown, keys: readonly string[]): boolean {
42
+ try {
43
+ if (value === null || typeof value !== "object") return false;
44
+ const proto: unknown = Object.getPrototypeOf(value);
45
+ if (proto !== Object.prototype && proto !== null) return false;
46
+ return Reflect.ownKeys(value).every((key) => {
47
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
48
+ return typeof key === "string" && keys.includes(key) && descriptor?.enumerable === true && "value" in descriptor;
49
+ });
50
+ } catch { return false; }
51
+ }
52
+
53
+ function errorDetail(error: unknown): string {
54
+ try {
55
+ const text = error instanceof Error ? error.message : String(error);
56
+ return text.slice(0, 2048);
57
+ } catch { return "Storage callback threw an unreadable error"; }
58
+ }
59
+
60
+ /** One session-file owner, fixed at construction by the adapter. No filesystem, Pi imports,
61
+ * replay, wake or worker lifecycle. Native history is scanned without caching report bodies;
62
+ * even over-quota/corrupt histories leave valid unrelated reports queryable. Local accepted
63
+ * records and uncertain append reservations are bounded by the SAME count/byte ceilings.
64
+ * The native entry array is a host-owned immutable snapshot: its initial length is pinned so
65
+ * a host that appends to that same array cannot double-charge our own reserved writes.
66
+ */
67
+ export class TerminalResultArchive {
68
+ private readonly deps: TerminalResultArchiveDeps;
69
+ private hydrated = false;
70
+ private hydrating = false;
71
+ private history: readonly unknown[] = [];
72
+ private historyLength = 0;
73
+ private historyCount = 0;
74
+ private chargedEntries = 0;
75
+ private chargedPayloadBytes: number | null = 0;
76
+ private localBytes = 0;
77
+ private readonly retained: RetainedResult[] = [];
78
+ private readonly warnings = new Set<ResultWarning>();
79
+ private saving = false;
80
+
81
+ constructor(deps: TerminalResultArchiveDeps) {
82
+ // Copy callbacks/flags: mutating a caller's dependency object cannot retarget this owner.
83
+ this.deps = { ...deps };
84
+ }
85
+
86
+ save(input: TerminalResultInput): SaveOutcome {
87
+ if (this.saving || this.hydrating) return this.failure("busy", "A terminal result save or history accounting is already in progress");
88
+ this.saving = true;
89
+ try { this.hydrate(); return this.saveInput(input); }
90
+ finally { this.saving = false; }
91
+ }
92
+
93
+ read(request: ResultReadRequest): ResultPage {
94
+ if (this.hydrating) return this.failure("busy", "Terminal result history accounting is in progress");
95
+ this.hydrate();
96
+ if (!validQuery(request, ["ref", "offset", "limit"]) || !isResultRef(request.ref)) {
97
+ return this.failure("invalid", "Expected result:<UUID> and an optional stored-body offset/limit");
98
+ }
99
+ const found = this.lookup("ref", request.ref);
100
+ if (found.kind === "missing") return this.failure("unavailable", "Result reference is unavailable in this owning session");
101
+ if (found.kind !== "found") return this.failure(found.kind, "Result identity is ambiguous or corrupt in this owning session");
102
+ const page = pageStoredBody(found.record, request.offset, request.limit);
103
+ if (!page) return this.failure("invalid", "Invalid stored-body UTF-16 offset or read limit (2..16000); surrogate pairs cannot be split");
104
+ return {
105
+ ok: true, summary: summary(found.record, this.storage(found.retained)), ...page,
106
+ ...this.evidence(found.record, found.retained),
107
+ };
108
+ }
109
+
110
+ list(request: ResultListRequest = {}): ResultSummaryPage {
111
+ if (this.hydrating) return this.failure("busy", "Terminal result history accounting is in progress");
112
+ this.hydrate();
113
+ if (!validQuery(request, ["cursor", "limit"])) return this.failure("invalid", "Expected a result-list query");
114
+ const cursor = request.cursor === undefined ? 0 : request.cursor;
115
+ const limit = request.limit === undefined ? RESULT_LIST_DEFAULT : request.limit;
116
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > this.historyCount + this.retained.length
117
+ || !Number.isInteger(limit) || limit < 1 || limit > RESULT_LIST_MAX) {
118
+ return this.failure("invalid", "Invalid result list cursor or limit (1..50)");
119
+ }
120
+ const results: ResultSummary[] = [];
121
+ let nextCursor: number | undefined;
122
+ for (const source of this.sources()) {
123
+ if (source.index < cursor) continue;
124
+ const record = parseTerminalResultRecord(source.data);
125
+ if (!record) continue;
126
+ const found = this.lookup("ref", record.ref);
127
+ if (found.kind !== "found" || found.index !== source.index) continue;
128
+ if (results.length === limit) { nextCursor = source.index; break; }
129
+ results.push(summary(record, this.storage(found.retained)));
130
+ }
131
+ return { ok: true, results, ...(nextCursor === undefined ? {} : { nextCursor }), ...this.evidence() };
132
+ }
133
+
134
+ private saveInput(value: unknown): SaveOutcome {
135
+ const input = parseTerminalResultInput(value);
136
+ if (!input) return this.failure("invalid", "Invalid terminal result projection or metadata limits");
137
+ const existing = this.lookup("originKey", input.originKey);
138
+ if (existing.kind === "ambiguous" || existing.kind === "corrupt") {
139
+ return this.failure(existing.kind, "The origin already has an ambiguous or corrupt result");
140
+ }
141
+ if (existing.kind === "found") {
142
+ const candidate = createTerminalResultRecord(input, existing.record.ref, input.createdAt ?? existing.record.createdAt);
143
+ if (JSON.stringify(parseTerminalResultRecord(candidate)) !== JSON.stringify(existing.record)) {
144
+ return this.failure("conflict", "The immutable origin already belongs to a different terminal payload");
145
+ }
146
+ return this.receipt(existing.record, existing.retained, true);
147
+ }
148
+ let record: TerminalResultRecord;
149
+ try {
150
+ const ref = this.deps.makeRef === undefined ? `result:${randomUUID()}` : this.deps.makeRef();
151
+ if (!isResultRef(ref)) return this.failure("invalid", "Reference factory must return result:<UUID>");
152
+ const collision = this.lookup("ref", ref);
153
+ if (collision.kind !== "missing") return this.failure(collision.kind === "found" ? "conflict" : collision.kind, "Generated result reference is already occupied");
154
+ record = createTerminalResultRecord(input, ref, input.createdAt ?? (this.deps.clock === undefined ? Date.now() : this.deps.clock()));
155
+ } catch (error) { return this.failure("invalid", errorDetail(error)); }
156
+ const cost = payloadBytes(record);
157
+ if (this.chargedEntries >= RESULT_MAX_ENTRIES || this.localBytes + cost > RESULT_MAX_PAYLOAD_BYTES
158
+ || (this.chargedPayloadBytes !== null && this.chargedPayloadBytes + cost > RESULT_MAX_PAYLOAD_BYTES)) {
159
+ return this.failure("quota", "Terminal result lifetime payload/count quota exhausted; append-only storage is not reclaimed");
160
+ }
161
+ // Reserve before invoking host code. A throw may occur AFTER a physical/session commit.
162
+ // Never retry this origin into another entry or treat memory retention as proof of durability.
163
+ this.chargedEntries++;
164
+ if (this.chargedPayloadBytes !== null) this.chargedPayloadBytes += cost;
165
+ this.localBytes += cost;
166
+ const retained: RetainedResult = { record, storage: "memory-only" };
167
+ this.retained.push(retained);
168
+ if (this.deps.persistent && this.chargedPayloadBytes !== null) {
169
+ try {
170
+ this.deps.append(TERMINAL_RESULT_ENTRY, parseTerminalResultRecord(record)!);
171
+ retained.storage = "session-entry";
172
+ } catch (error) { retained.storageError = errorDetail(error); }
173
+ }
174
+ return this.receipt(record, retained, false);
175
+ }
176
+
177
+ private storage(retained: RetainedResult | undefined): ResultStorage {
178
+ return retained?.storage ?? (this.deps.persistent ? "session-entry" : "memory-only");
179
+ }
180
+
181
+ private receipt(record: TerminalResultRecord, retained: RetainedResult | undefined, idempotent: boolean): SaveOutcome {
182
+ const storage = this.storage(retained);
183
+ return {
184
+ ok: true, ref: record.ref, storage, summary: summary(record, storage), idempotent,
185
+ ...(retained?.storageError === undefined ? {} : { storageError: retained.storageError }),
186
+ ...this.evidence(record, retained),
187
+ };
188
+ }
189
+
190
+ private hydrate(): void {
191
+ if (this.hydrated) return;
192
+ this.hydrated = true;
193
+ this.hydrating = true;
194
+ if (!this.deps.persistent) this.warnings.add("no-session-storage");
195
+ try {
196
+ const entries = this.deps.entries();
197
+ if (!Array.isArray(entries)) throw new Error("Invalid owning-session entry list");
198
+ this.history = entries;
199
+ this.historyLength = entries.length;
200
+ for (const source of this.sources()) {
201
+ this.chargedEntries++;
202
+ try {
203
+ const cost = payloadBytes(source.data);
204
+ if (this.chargedPayloadBytes !== null) this.chargedPayloadBytes += cost;
205
+ if (!Number.isSafeInteger(this.chargedPayloadBytes)) this.disablePersistence();
206
+ } catch { this.disablePersistence(); }
207
+ if (!parseTerminalResultRecord(source.data)) this.warnings.add("invalid-history");
208
+ }
209
+ this.historyCount = this.chargedEntries;
210
+ } catch { this.disablePersistence(); }
211
+ finally { this.hydrating = false; }
212
+ }
213
+
214
+ private *sources(): Generator<SourceResult> {
215
+ let index = 0;
216
+ for (let i = 0; i < this.historyLength; i++) {
217
+ try {
218
+ const raw = this.history[i];
219
+ if (raw === null || typeof raw !== "object") continue;
220
+ const entry = raw as Record<string, unknown>;
221
+ if (entry.type !== "custom" || entry.customType !== TERMINAL_RESULT_ENTRY) continue;
222
+ // Count the entry even when reading its data throws.
223
+ let data: unknown;
224
+ try { data = entry.data; } catch { this.disablePersistence(); }
225
+ yield { data, index: index++ };
226
+ } catch { this.disablePersistence(); }
227
+ }
228
+ for (const retained of this.retained) yield { data: retained.record, index: index++, retained };
229
+ }
230
+
231
+ /** Two bounded-memory scans, not a report cache or an unbounded identity index. */
232
+ private lookup(key: "ref" | "originKey", value: string): Lookup {
233
+ let first: { record: TerminalResultRecord; source: SourceResult } | undefined;
234
+ for (const source of this.sources()) {
235
+ if (identity(source.data)[key] !== value) continue;
236
+ const record = parseTerminalResultRecord(source.data);
237
+ if (!record) return { kind: "corrupt" };
238
+ first = { record, source };
239
+ break;
240
+ }
241
+ if (!first) return { kind: "missing" };
242
+ const canonical = JSON.stringify(first.record);
243
+ for (const source of this.sources()) {
244
+ if (source.index === first.source.index) continue;
245
+ const id = identity(source.data);
246
+ if (id.ref !== first.record.ref && id.originKey !== first.record.originKey) continue;
247
+ const record = parseTerminalResultRecord(source.data);
248
+ if (!record) return { kind: "corrupt" };
249
+ if (JSON.stringify(record) !== canonical) {
250
+ this.warnings.add("ambiguous-history");
251
+ return { kind: "ambiguous" };
252
+ }
253
+ this.warnings.add("duplicate-history");
254
+ }
255
+ return {
256
+ kind: "found", record: first.record, index: first.source.index,
257
+ ...(first.source.retained === undefined ? {} : { retained: first.source.retained }),
258
+ };
259
+ }
260
+
261
+ private disablePersistence(): void {
262
+ this.chargedPayloadBytes = null;
263
+ this.warnings.add("accounting-unavailable");
264
+ }
265
+
266
+ private evidence(record?: TerminalResultRecord, retained?: RetainedResult): ArchiveEvidence {
267
+ const warnings = new Set(this.warnings);
268
+ if (record?.truncated) warnings.add("body-truncated");
269
+ if (retained?.storageError !== undefined) warnings.add("append-failed");
270
+ return {
271
+ accounting: {
272
+ chargedEntries: this.chargedEntries, chargedPayloadBytes: this.chargedPayloadBytes,
273
+ persistenceDisabled: !this.deps.persistent || this.chargedPayloadBytes === null,
274
+ }, warnings: [...warnings],
275
+ };
276
+ }
277
+
278
+ private failure(reason: ArchiveFailure["reason"], message: string): ArchiveFailure {
279
+ return { ok: false, reason, message, ...this.evidence() };
280
+ }
281
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isThinkingLevel } from "../core/types.ts";
3
+ import {
4
+ RESULT_MAX_BODY_CHARS, RESULT_MAX_RECORD_BYTES, RESULT_READ_DEFAULT, RESULT_READ_MAX,
5
+ type TerminalResultInput, type TerminalResultRecord, type TerminalUsage,
6
+ } from "./types.ts";
7
+
8
+ const INPUT_KEYS = ["originKey", "originRunId", "status", "agent", "label", "model", "thinkingRequested",
9
+ "thinkingEffective", "usage", "createdAt", "body", "provenance", "error"];
10
+ const RECORD_KEYS = [...INPUT_KEYS, "version", "ref", "payloadHash", "originalChars", "storedChars", "truncated"];
11
+ const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cost", "turns"];
12
+ const REF_PATTERN = /^result:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
13
+
14
+ export function isResultRef(value: unknown): value is string {
15
+ return typeof value === "string" && REF_PATTERN.test(value);
16
+ }
17
+
18
+ function object(value: unknown): value is Record<string, unknown> {
19
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
20
+ const proto: unknown = Object.getPrototypeOf(value);
21
+ return proto === Object.prototype || proto === null;
22
+ }
23
+
24
+ function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
25
+ return Reflect.ownKeys(value).every((key) => {
26
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
27
+ return typeof key === "string" && keys.includes(key) && descriptor?.enumerable === true && "value" in descriptor;
28
+ });
29
+ }
30
+
31
+ function text(value: unknown, max: number): value is string {
32
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !value.includes("\0");
33
+ }
34
+
35
+ function counter(value: unknown): value is number {
36
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
37
+ }
38
+
39
+ function usage(value: unknown): value is TerminalUsage {
40
+ return object(value) && onlyKeys(value, USAGE_KEYS) && counter(value.input) && counter(value.output)
41
+ && ["cacheRead", "cacheWrite", "turns"].every((key) => value[key] === undefined || counter(value[key]))
42
+ && (value.cost === undefined || (typeof value.cost === "number" && Number.isFinite(value.cost)
43
+ && value.cost >= 0 && value.cost <= Number.MAX_SAFE_INTEGER));
44
+ }
45
+
46
+ function validFields(value: Record<string, unknown>): boolean {
47
+ return text(value.originKey, 1024) && text(value.agent, 128)
48
+ && (value.status === "done" || value.status === "failed" || value.status === "stopped")
49
+ && (value.provenance === "result-output" || value.provenance === "partial-output" || value.provenance === "error")
50
+ && typeof value.body === "string"
51
+ && (value.originRunId === undefined || text(value.originRunId, 128))
52
+ && (value.label === undefined || text(value.label, 256))
53
+ && (value.model === undefined || text(value.model, 512))
54
+ && (value.error === undefined || (typeof value.error === "string" && value.error.length <= 2048))
55
+ && (value.createdAt === undefined || counter(value.createdAt))
56
+ && (value.thinkingRequested === undefined || isThinkingLevel(value.thinkingRequested))
57
+ && (value.thinkingEffective === undefined || isThinkingLevel(value.thinkingEffective))
58
+ && (value.usage === undefined || usage(value.usage));
59
+ }
60
+
61
+ /** Fixed key order and detached metadata for canonical equality and caller immutability. */
62
+ function fields(value: TerminalResultInput): TerminalResultInput {
63
+ const u = value.usage;
64
+ return {
65
+ originKey: value.originKey, status: value.status, agent: value.agent,
66
+ ...(value.originRunId === undefined ? {} : { originRunId: value.originRunId }),
67
+ ...(value.label === undefined ? {} : { label: value.label }),
68
+ ...(value.model === undefined ? {} : { model: value.model }),
69
+ ...(value.thinkingRequested === undefined ? {} : { thinkingRequested: value.thinkingRequested }),
70
+ ...(value.thinkingEffective === undefined ? {} : { thinkingEffective: value.thinkingEffective }),
71
+ ...(u === undefined ? {} : { usage: {
72
+ input: u.input, output: u.output,
73
+ ...(u.cacheRead === undefined ? {} : { cacheRead: u.cacheRead }),
74
+ ...(u.cacheWrite === undefined ? {} : { cacheWrite: u.cacheWrite }),
75
+ ...(u.cost === undefined ? {} : { cost: u.cost }),
76
+ ...(u.turns === undefined ? {} : { turns: u.turns }),
77
+ } }),
78
+ ...(value.createdAt === undefined ? {} : { createdAt: value.createdAt }),
79
+ body: value.body, provenance: value.provenance,
80
+ ...(value.error === undefined ? {} : { error: value.error }),
81
+ };
82
+ }
83
+
84
+ export function parseTerminalResultInput(value: unknown): TerminalResultInput | undefined {
85
+ try {
86
+ if (!object(value) || !onlyKeys(value, INPUT_KEYS) || !validFields(value)) return undefined;
87
+ return fields(value as unknown as TerminalResultInput);
88
+ } catch { return undefined; }
89
+ }
90
+
91
+ /** Actual serialized DATA bytes. Undefined/circular/unserializable data has no safe cost. */
92
+ export function payloadBytes(value: unknown): number {
93
+ const json = JSON.stringify(value);
94
+ if (json === undefined) throw new Error("Terminal result payload is not JSON serializable");
95
+ return Buffer.byteLength(json, "utf8");
96
+ }
97
+
98
+ /** Cut positions are UTF-16 offsets; preserve an existing high/low surrogate pair. */
99
+ function safeEnd(body: string, end: number): number {
100
+ const before = body.charCodeAt(end - 1);
101
+ const after = body.charCodeAt(end);
102
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff ? end - 1 : end;
103
+ }
104
+
105
+ export function createTerminalResultRecord(input: TerminalResultInput, ref: string, createdAt: number): TerminalResultRecord {
106
+ if (!isResultRef(ref) || !counter(createdAt)) throw new Error("Invalid terminal result reference or timestamp");
107
+ const canonical = fields(input);
108
+ // Hash the original body separately so canonicalization need not allocate a JSON-escaped
109
+ // copy of an arbitrarily large input. Hash UTF-16LE: distinct lone surrogates stay distinct.
110
+ const bodyHash = createHash("sha256").update(canonical.body, "utf16le").digest("hex");
111
+ // Timestamp equality is checked on the canonical record itself: explicitly supplying
112
+ // the original default timestamp must not change content identity on a repeat save.
113
+ const { createdAt: _timestamp, ...content } = canonical;
114
+ const payloadHash = createHash("sha256").update(JSON.stringify({ ...content, body: bodyHash })).digest("hex");
115
+ const record: TerminalResultRecord = {
116
+ ...canonical, version: 1, ref, createdAt, payloadHash,
117
+ body: "", originalChars: input.body.length, storedChars: 0, truncated: input.body.length > 0,
118
+ };
119
+ const setLength = (length: number): void => {
120
+ record.body = input.body.slice(0, safeEnd(input.body, length));
121
+ record.storedChars = record.body.length;
122
+ record.truncated = record.storedChars < record.originalChars;
123
+ };
124
+ let low = 0;
125
+ let high = Math.min(input.body.length, RESULT_MAX_BODY_CHARS);
126
+ while (low < high) {
127
+ const mid = Math.ceil((low + high) / 2);
128
+ setLength(mid);
129
+ if (payloadBytes(record) <= RESULT_MAX_RECORD_BYTES) low = mid;
130
+ else high = mid - 1;
131
+ }
132
+ setLength(low);
133
+ if (payloadBytes(record) > RESULT_MAX_RECORD_BYTES) throw new Error("Terminal result metadata exceeds record quota");
134
+ return record;
135
+ }
136
+
137
+ /** Strict, detached version-1 DATA. No rendering, instructions, callbacks or AgentResult blobs. */
138
+ export function parseTerminalResultRecord(value: unknown): TerminalResultRecord | undefined {
139
+ try {
140
+ if (!object(value) || !onlyKeys(value, RECORD_KEYS) || !validFields(value)
141
+ || value.version !== 1 || !isResultRef(value.ref) || !counter(value.createdAt)
142
+ || typeof value.payloadHash !== "string" || !/^[0-9a-f]{64}$/.test(value.payloadHash)
143
+ || !counter(value.originalChars) || !counter(value.storedChars) || typeof value.truncated !== "boolean"
144
+ || typeof value.body !== "string" || value.body.length > RESULT_MAX_BODY_CHARS
145
+ || value.storedChars !== value.body.length || value.originalChars < value.storedChars
146
+ || value.truncated !== (value.originalChars > value.storedChars)
147
+ || payloadBytes(value) > RESULT_MAX_RECORD_BYTES) return undefined;
148
+ return {
149
+ ...fields(value as unknown as TerminalResultInput), version: 1, ref: value.ref, createdAt: value.createdAt,
150
+ payloadHash: value.payloadHash, originalChars: value.originalChars,
151
+ storedChars: value.storedChars, truncated: value.truncated,
152
+ };
153
+ } catch { return undefined; }
154
+ }
155
+
156
+ export interface StoredBodyPage { body: string; offset: number; nextOffset?: number; endOfStoredBody: boolean }
157
+
158
+ export function pageStoredBody(record: TerminalResultRecord, offset = 0, limit = RESULT_READ_DEFAULT): StoredBodyPage | undefined {
159
+ if (!counter(offset) || offset > record.body.length || safeEnd(record.body, offset) !== offset
160
+ || !Number.isInteger(limit) || limit < 2 || limit > RESULT_READ_MAX) return undefined;
161
+ const end = safeEnd(record.body, Math.min(record.body.length, offset + limit));
162
+ return {
163
+ body: record.body.slice(offset, end), offset, endOfStoredBody: end === record.body.length,
164
+ ...(end < record.body.length ? { nextOffset: end } : {}),
165
+ };
166
+ }