@aeondave/pi-persona 1.17.1 → 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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -5,9 +5,8 @@
5
5
  * `customTool` by the engine, bound to the shared {@link InProcessBus} + the child's
6
6
  * handle.
7
7
  *
8
- * - `progress_update` (one-way): post and return immediately — the supervisor reads it
9
- * on its next peek (or it rides back on the run's result in sync mode).
10
- * - `need_decision` / `interview_request` (blocking): `bus.ask` — the child waits for
8
+ * - `progress` (one-way): post and return immediately.
9
+ * - `decision` / `interview` (blocking): `bus.ask` — the child waits for
11
10
  * the supervisor's reply. This only makes sense in **async** runs where the supervisor
12
11
  * is free to answer; the tool aborts cleanly on the run's signal so it never hangs a
13
12
  * sync run with no one listening.
@@ -18,27 +17,68 @@
18
17
  import { type Static, Type } from "typebox";
19
18
 
20
19
  import { defineTool } from "@earendil-works/pi-coding-agent";
20
+ import { Text } from "@earendil-works/pi-tui";
21
21
 
22
+ import { compactInlineText } from "../ui/presentation.ts";
23
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
22
24
  import type { InProcessBus, MsgKind } from "./inproc.ts";
23
25
 
24
26
  const ContactParams = Type.Object({
25
27
  kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
26
28
  description:
27
- "progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. For decision/interview, async runs wait for a reply; synchronous runs post one-way and continue.",
29
+ "progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. Async decision/interview requests wait for a reply; synchronous requests post one-way with no possible answer, so the request remains unresolved.",
28
30
  }),
29
31
  message: Type.String({ description: "What to tell or ask the supervisor — specific and self-contained." }),
30
32
  });
31
33
 
32
- type ContactDetails = { kind: MsgKind; delivered: boolean };
34
+ type ContactOutcome = "reported" | "answered" | "unavailable" | "timed-out" | "cancelled" | "failed";
35
+
36
+ type ContactDetails = {
37
+ kind: MsgKind;
38
+ /** Delivery evidence only, never evidence of a reply or approval. False means delivery was not confirmed. */
39
+ delivered: boolean;
40
+ outcome: ContactOutcome;
41
+ };
33
42
 
34
43
  type AskFailure = "aborted" | "timeout" | "inbox-full" | "unavailable" | "generic";
35
44
 
45
+ const UNRESOLVED_NEXT_STEP = "Continue only with independent work already authorized; otherwise return a blocked/partial result.";
46
+
47
+ function unresolvedGuidance(kind: "decision" | "interview"): string {
48
+ return `The ${kind} remains unresolved. ${UNRESOLVED_NEXT_STEP}`;
49
+ }
50
+
51
+ function outcomeForAskFailure(failure: AskFailure): ContactOutcome {
52
+ switch (failure) {
53
+ case "aborted": return "cancelled";
54
+ case "timeout": return "timed-out";
55
+ case "inbox-full":
56
+ case "unavailable": return "unavailable";
57
+ case "generic": return "failed";
58
+ }
59
+ }
60
+
61
+ function askFailureText(failure: AskFailure): string {
62
+ switch (failure) {
63
+ case "aborted": return "The request was cancelled before the supervisor replied.";
64
+ case "timeout": return "The supervisor did not reply in time.";
65
+ case "inbox-full": return "The supervisor inbox is full; this request was not delivered. Retry later or consolidate the question.";
66
+ case "unavailable": return "The supervisor is unavailable; delivery could not be confirmed.";
67
+ case "generic": return "The supervisor request failed; delivery could not be confirmed.";
68
+ }
69
+ }
70
+
36
71
  function classifyAskFailure(error: unknown, signal: AbortSignal | undefined): AskFailure {
37
72
  if (signal?.aborted) return "aborted";
38
73
  const message = error instanceof Error ? error.message : "";
39
74
  if (/^ask timeout(?:\s|$)/i.test(message)) return "timeout";
40
75
  if (/^inbox full(?:\s|:|$)/i.test(message)) return "inbox-full";
41
- if (/^ask unregistered(?:\s|$)/i.test(message) || /^broker (?:connection|client) closed(?:\s|$)/i.test(message)) return "unavailable";
76
+ if (
77
+ /^ask unregistered(?:\s|$)/i.test(message) ||
78
+ /^unknown peer(?:\s|:|$)/i.test(message) ||
79
+ /^bridge: not connected(?:\s|$)/i.test(message) ||
80
+ /^broker (?:connection|client) closed(?:\s|$)/i.test(message)
81
+ ) return "unavailable";
42
82
  return "generic";
43
83
  }
44
84
 
@@ -52,7 +92,7 @@ function result(t: string, details: ContactDetails): { content: [{ type: "text";
52
92
  *
53
93
  * `allowBlocking` (async runs only): when false, a `decision`/`interview` does NOT block —
54
94
  * the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
55
- * the child proceeds. This prevents a sync delegate/council from deadlocking on a child ask. */
95
+ * returns unresolved. This prevents a sync delegate/council from deadlocking on a child ask. */
56
96
  export function makeContactSupervisorTool(
57
97
  bus: InProcessBus,
58
98
  fromHandle: string,
@@ -68,32 +108,49 @@ export function makeContactSupervisorTool(
68
108
  "Use `progress` to report a milestone (one-way, don't wait).",
69
109
  "Use `decision` when you hit a choice only the supervisor should make, or `interview` to ask a clarifying question.",
70
110
  allowBlocking
71
- ? "This async run waits for the supervisor's reply before continuing."
72
- : "This synchronous run cannot block: decision/interview are posted one-way and return immediately; proceed using your best judgement.",
111
+ ? "This async run waits for the supervisor's reply before continuing. If there is no reply, the request remains unresolved: continue only with independent work already authorized, otherwise return a blocked/partial result."
112
+ : "This synchronous run cannot block: decision/interview are posted one-way and return immediately; no answer is possible in this path, so the request remains unresolved. Continue only with independent work already authorized, otherwise return a blocked/partial result.",
73
113
  `Messages longer than ${MAX_CONTACT_MESSAGE_CHARS} characters are shortened before delivery; use smaller messages to preserve full detail.`,
74
114
  ].join(" "),
75
115
  promptSnippet: "contact_supervisor — report progress or ask the supervisor a question",
76
116
  parameters: ContactParams,
77
117
  async execute(_toolCallId, params: Static<typeof ContactParams>, signal, _onUpdate, _ctx) {
78
118
  const kind = params.kind as MsgKind;
119
+ if (signal?.aborted) {
120
+ const text = kind === "progress"
121
+ ? "The progress update was cancelled before delivery."
122
+ : `The request was cancelled before the supervisor was contacted. ${unresolvedGuidance(kind)}`;
123
+ return result(text, { kind, delivered: false, outcome: "cancelled" });
124
+ }
79
125
  const message = boundContactMessage(params.message);
80
126
  if (kind === "progress") {
81
- const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
82
- return result(
83
- delivered ? "Progress reported to the supervisor." : "(no supervisor listening; progress dropped)",
84
- { kind, delivered },
85
- );
127
+ try {
128
+ const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
129
+ return result(
130
+ delivered ? "Progress reported to the supervisor." : "(no supervisor listening or progress delivery could not be confirmed; progress dropped)",
131
+ { kind, delivered, outcome: delivered ? "reported" : "unavailable" },
132
+ );
133
+ } catch {
134
+ return result("(progress could not be sent; delivery could not be confirmed)", { kind, delivered: false, outcome: "failed" });
135
+ }
86
136
  }
87
137
 
88
138
  // In a sync run the supervisor is blocked holding the turn → it cannot answer. Post the
89
- // question one-way and let the child proceed, rather than deadlock until timeout.
139
+ // question one-way, but return unresolved; the send is not authorization to proceed on it.
90
140
  if (!allowBlocking) {
91
- const delivered = bus.send(fromHandle, supervisorHandle, message, kind);
141
+ let delivered: boolean;
142
+ try {
143
+ delivered = bus.send(fromHandle, supervisorHandle, message, kind);
144
+ } catch {
145
+ return result(
146
+ `The question could not be sent; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
147
+ { kind, delivered: false, outcome: "failed" },
148
+ );
149
+ }
150
+ const delivery = delivered ? "question sent one-way" : "question delivery could not be confirmed";
92
151
  return result(
93
- delivered
94
- ? "The supervisor is busy right now; your question was noted — proceed using your best judgement."
95
- : "(no supervisor reachable — proceed using your best judgement)",
96
- { kind, delivered },
152
+ `The supervisor is busy right now; ${delivery}, and no answer is possible in this path. ${unresolvedGuidance(kind)}`,
153
+ { kind, delivered, outcome: "unavailable" },
97
154
  );
98
155
  }
99
156
 
@@ -103,30 +160,48 @@ export function makeContactSupervisorTool(
103
160
  let answer: Promise<string>;
104
161
  try {
105
162
  answer = bus.ask(fromHandle, supervisorHandle, message, signal ? { kind, signal } : { kind });
106
- } catch {
107
- return result("(no supervisor reachable — proceed using your best judgement)", { kind, delivered: false });
163
+ } catch (error) {
164
+ const failure = classifyAskFailure(error, signal);
165
+ return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
166
+ kind,
167
+ delivered: false,
168
+ outcome: outcomeForAskFailure(failure),
169
+ });
108
170
  }
109
171
  try {
110
172
  const reply = await answer;
111
- return result(`Supervisor: ${reply}`, { kind, delivered: true });
173
+ // A response that settles concurrently with cancellation is stale for this call:
174
+ // never report it as an answer once the caller's signal has aborted.
175
+ if (signal?.aborted) {
176
+ return result(`${askFailureText("aborted")} ${unresolvedGuidance(kind)}`, {
177
+ kind,
178
+ delivered: false,
179
+ outcome: "cancelled",
180
+ });
181
+ }
182
+ return result(`Supervisor: ${reply}`, { kind, delivered: true, outcome: "answered" });
112
183
  } catch (error) {
113
- const msg = (() => {
114
- switch (classifyAskFailure(error, signal)) {
115
- case "aborted":
116
- return "(the request was cancelled before the supervisor replied)";
117
- case "timeout":
118
- return "(the supervisor did not reply in time — proceed using your best judgement)";
119
- case "inbox-full":
120
- return "(the supervisor inbox is full — this request was not delivered; retry later or consolidate the question)";
121
- case "unavailable":
122
- return "(the supervisor is no longer available — this request was not delivered; proceed using your best judgement)";
123
- default:
124
- return "(the supervisor request could not be completed — proceed using your best judgement)";
125
- }
126
- })();
127
- return result(msg, { kind, delivered: false });
184
+ const failure = classifyAskFailure(error, signal);
185
+ return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
186
+ kind,
187
+ delivered: false,
188
+ outcome: outcomeForAskFailure(failure),
189
+ });
128
190
  }
129
191
  },
192
+ // Compact purpose line only: the message is the child's own prose, and echoing it in the
193
+ // call card would overflow the transcript the way the host fallback's raw-args echo does.
194
+ renderCall(args, theme) {
195
+ const kind = compactInlineText(args.kind ?? "", { maxChars: 12 });
196
+ return new Text(theme.fg("toolTitle", theme.bold(kind ? `contact ${kind}` : "contact")), 0, 0);
197
+ },
198
+ // One shared literal receipt. An answered ask is bounded collapsed and expands to the full
199
+ // reply; progress receipts are naturally one compact line. Unresolved outcomes
200
+ // (unavailable/timed-out/cancelled/failed) are SUCCESSFUL tool results carrying an
201
+ // unresolved outcome — the error heading appears only when the host context says isError.
202
+ renderResult(result, { expanded }, theme, context) {
203
+ return renderLiteralToolResult(result, { expanded }, theme, context);
204
+ },
130
205
  });
131
206
  }
132
207
  /** Maximum body accepted by contact_supervisor's message action. Full reports should use the
package/src/bus/peers.ts CHANGED
@@ -20,7 +20,10 @@
20
20
  import { type Static, Type } from "typebox";
21
21
 
22
22
  import { defineTool } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
23
24
 
25
+ import { compactInlineText } from "../ui/presentation.ts";
26
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
24
27
  import type { InProcessBus } from "./inproc.ts";
25
28
 
26
29
  /** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
@@ -110,5 +113,17 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
110
113
  { action: "send", delivered },
111
114
  );
112
115
  },
116
+ // Compact purpose line only — same rule as contact_supervisor: no note body in the call
117
+ // card (the host fallback would echo raw args).
118
+ renderCall(args, theme) {
119
+ const action = compactInlineText(args.action ?? "", { maxChars: 12 });
120
+ return new Text(theme.fg("toolTitle", theme.bold(action ? `peer ${action}` : "peer")), 0, 0);
121
+ },
122
+ // One shared literal receipt: a send receipt is one compact line; a long peer list stays
123
+ // bounded collapsed (expansion hint) and shows every peer expanded. The error heading comes
124
+ // only from the host's isError flag, never from the semantic content.
125
+ renderResult(result, { expanded }, theme, context) {
126
+ return renderLiteralToolResult(result, { expanded }, theme, context);
127
+ },
113
128
  });
114
129
  }
@@ -0,0 +1,88 @@
1
+ /** Narrow result-read boundary. No archive ownership, discovery, paths or session identity.
2
+ * The parent supplies a reader that checks CURRENT capabilities/owner and its explicit ref
3
+ * grant on every call. The broker owns attachment, cancellation and wire validation only. */
4
+ import { isResultRef } from "../results/record.ts";
5
+ import { RESULT_READ_DEFAULT, RESULT_READ_MAX, type ResultReadRequest, type ResultProvenance,
6
+ type TerminalStatus } from "../results/types.ts";
7
+
8
+ export type { ResultReadRequest } from "../results/types.ts";
9
+ export const RESULT_READ_TIMEOUT_MS = 10_000;
10
+ export const RESULT_READ_MAX_PENDING = 8;
11
+ export const RESULT_READ_MAX_FRAME_BYTES = 128 * 1024;
12
+ /** Bound replay tracking without ever evicting an id and accepting its replay. Exhaustion
13
+ * ends the reader connection; it does not reconnect or retry. */
14
+ export const RESULT_READ_MAX_REQUESTS = 4096;
15
+
16
+ const ERROR_MESSAGES = {
17
+ invalid: "Invalid result read request.",
18
+ denied: "Result read is not authorized.",
19
+ revoked: "Result reader was revoked.",
20
+ unavailable: "Result reader is unavailable.",
21
+ cancelled: "Result read was cancelled.",
22
+ timeout: "Result read deadline exceeded.",
23
+ busy: "Too many pending result reads.",
24
+ protocol: "Invalid result reader protocol.",
25
+ } as const;
26
+ export type ReadErrorCode = keyof typeof ERROR_MESSAGES;
27
+ export type ReadReply = {
28
+ ok: true; ref: string; body: string; offset: number; nextOffset?: number;
29
+ storedChars: number; originalChars: number; truncated: boolean; endOfStoredBody: boolean;
30
+ status: TerminalStatus; provenance: ResultProvenance;
31
+ } | { ok: false; code: ReadErrorCode; message: string };
32
+
33
+ export interface ScopedReader {
34
+ read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
35
+ revoke(): void;
36
+ }
37
+
38
+ export interface PreparedResultReader {
39
+ /** Private spawn environment only. Never put in tasks, receipts or diagnostics. */
40
+ token: string;
41
+ revoke(): void;
42
+ /** Historical attachment evidence, not a promise of continued connectivity. */
43
+ wasBound(): boolean;
44
+ }
45
+
46
+ export function readFailure(code: ReadErrorCode): ReadReply & { ok: false } {
47
+ return { ok: false, code, message: ERROR_MESSAGES[code] };
48
+ }
49
+
50
+ /** Reject hidden/accessor fields too: this validator is also used on parent callback data. */
51
+ export function closedRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
53
+ const proto: unknown = Object.getPrototypeOf(value);
54
+ return (proto === Object.prototype || proto === null) && Reflect.ownKeys(value).every((key) => {
55
+ const d = Object.getOwnPropertyDescriptor(value, key);
56
+ return typeof key === "string" && keys.includes(key) && d?.enumerable === true && "value" in d;
57
+ });
58
+ }
59
+ const counter = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
60
+
61
+ export function isReadRequest(value: unknown): value is ResultReadRequest {
62
+ return closedRecord(value, ["ref", "offset", "limit"]) && isResultRef(value.ref)
63
+ && (!("offset" in value) || counter(value.offset))
64
+ && (!("limit" in value) || (counter(value.limit) && value.limit >= 2 && value.limit <= RESULT_READ_MAX));
65
+ }
66
+
67
+ export function isReadReply(value: unknown): value is ReadReply {
68
+ if (!closedRecord(value, ["ok", "ref", "body", "offset", "nextOffset", "storedChars", "originalChars",
69
+ "truncated", "endOfStoredBody", "status", "provenance", "code", "message"])) return false;
70
+ if (value.ok === false) return closedRecord(value, ["ok", "code", "message"])
71
+ && typeof value.code === "string" && Object.hasOwn(ERROR_MESSAGES, value.code)
72
+ && value.message === ERROR_MESSAGES[value.code as ReadErrorCode];
73
+ if (value.ok !== true || "code" in value || "message" in value || !isResultRef(value.ref)
74
+ || typeof value.body !== "string" || value.body.length > RESULT_READ_MAX || !counter(value.offset)
75
+ || !counter(value.storedChars) || !counter(value.originalChars) || value.originalChars < value.storedChars
76
+ || value.truncated !== (value.originalChars > value.storedChars)
77
+ || (value.status !== "done" && value.status !== "failed" && value.status !== "stopped")
78
+ || (value.provenance !== "result-output" && value.provenance !== "partial-output" && value.provenance !== "error")) return false;
79
+ const end = value.offset + value.body.length;
80
+ if (!Number.isSafeInteger(end) || end > value.storedChars || value.endOfStoredBody !== (end === value.storedChars)) return false;
81
+ return value.endOfStoredBody ? !("nextOffset" in value) : value.body.length > 0 && value.nextOffset === end;
82
+ }
83
+
84
+ /** Page offsets are UTF-16; the archive, not transport, owns surrogate-boundary slicing. */
85
+ export function isReadReplyFor(value: unknown, request: ResultReadRequest): value is ReadReply {
86
+ return isReadReply(value) && (!value.ok || (value.ref === request.ref && value.offset === (request.offset ?? 0)
87
+ && value.body.length <= (request.limit ?? RESULT_READ_DEFAULT)));
88
+ }