@visual-z/xeno 0.1.0

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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * projectContext: log -> what the model sees.
3
+ *
4
+ * Pure. The whole architecture rests on this being a function rather than a
5
+ * mutable array: the log is the truth, the context is one recomputable projection
6
+ * of it, so a bad projection is a bug you fix and re-run rather than information
7
+ * you destroyed.
8
+ *
9
+ * Two properties worth stating because tests pin them:
10
+ *
11
+ * IDENTITY. With no handoff, the projection is the identity mapping of harness
12
+ * events onto messages. Segment 0 therefore behaves exactly like a harness with
13
+ * no context management at all, which is what makes the design adoptable in
14
+ * stages -- nothing is paid for until a boundary actually fires.
15
+ *
16
+ * APPEND-ONLY WITHIN A SEGMENT. Appending an event only appends to the
17
+ * projection; it never rewrites an earlier message. That is what keeps the
18
+ * prompt prefix byte-stable, so every turn reads the whole accumulated prefix
19
+ * from cache and writes only the delta. In-place eviction would invalidate the
20
+ * prefix on every request and never amortise the write cost -- the failure mode
21
+ * Beyond Compaction (arXiv:2606.11213) documents for incremental eviction.
22
+ *
23
+ * The message shape below is structurally the Anthropic Messages shape, which is
24
+ * the target wire format. It is declared locally rather than imported so the core
25
+ * carries no SDK dependency; mapping to other providers belongs in an adapter,
26
+ * not here.
27
+ */
28
+
29
+ import type { LogEvent, AssistantBlock } from "./events.ts";
30
+ import { assertNever } from "./exhaustive.ts";
31
+ import { type Handoff, emitHandoff } from "./handoff.ts";
32
+
33
+ export type ContentBlock =
34
+ | { type: "text"; text: string }
35
+ /**
36
+ * The provider's own thinking block, replayed verbatim. It is carried as
37
+ * opaque rather than reconstructed because the API rejects modified blocks,
38
+ * and a reconstructed one would lose the signature the next request needs.
39
+ */
40
+ | { type: "thinking"; raw: unknown }
41
+ | { type: "tool_use"; id: string; name: string; input: unknown; wire?: unknown }
42
+ | { type: "tool_result"; tool_use_id: string; content: string; is_error?: boolean };
43
+
44
+ export interface Message {
45
+ role: "user" | "assistant";
46
+ content: ContentBlock[];
47
+ }
48
+
49
+ /**
50
+ * System prompt and tool definitions. Must be byte-identical across every segment
51
+ * (decision D6): they render at the front of the prefix, so any change there
52
+ * invalidates the cache for the entire conversation. The non-obvious consequence
53
+ * is that project context files must NOT be re-read into `system` at a boundary --
54
+ * they belong in the handoff instead.
55
+ */
56
+ export interface FrozenPrefix {
57
+ system: string;
58
+ tools: readonly unknown[];
59
+ }
60
+
61
+ export interface ProjectInput {
62
+ frozen: FrozenPrefix;
63
+ /** Previous segment's handoff, or null for segment 0. */
64
+ handoff: Handoff | null;
65
+ events: readonly LogEvent[];
66
+ /** Log location given to the model so it can grep for anything the handoff omits. */
67
+ logHint?: { manifestPath: string; segmentPath: string };
68
+ }
69
+
70
+ export interface ProjectResult {
71
+ system: string;
72
+ tools: readonly unknown[];
73
+ messages: Message[];
74
+ /**
75
+ * Pairing violations. A tool_use with no matching tool_result is a 400 from the
76
+ * API, so it is reported here -- at the pure boundary, where it is debuggable --
77
+ * rather than being discovered as a request failure.
78
+ */
79
+ violations: ProjectViolation[];
80
+ }
81
+
82
+ export interface ProjectViolation {
83
+ code: "UNPAIRED_TOOL_USE" | "ORPHAN_TOOL_RESULT" | "EMPTY_PROJECTION";
84
+ detail: string;
85
+ }
86
+
87
+ /**
88
+ * The only event types that replay as messages.
89
+ *
90
+ * Model-annotated events (claim, decision, pitfall, ...) are deliberately absent.
91
+ * They are a sidecar index over tool calls the model already made, so the
92
+ * tool_use/tool_result pair is what replays; including the annotation too would
93
+ * show the model its own notes twice. Within a segment the model sees its raw tool
94
+ * calls; across a boundary it sees them distilled into the handoff.
95
+ *
96
+ * `cut` is absent for a different reason: it is a boundary marker about the log,
97
+ * not a thing that happened in the conversation.
98
+ *
99
+ * `rewind` is here conditionally, and it is the one event whose replayability
100
+ * is a property of the event rather than of its type. After a "both" rewind
101
+ * the context and the disk agree, so saying anything would carry the undone
102
+ * work back in another form. After a partial one they disagree, and a model
103
+ * planning against the wrong picture is the whole failure mode -- so it is
104
+ * told, with paths.
105
+ *
106
+ * `interrupt` is a one-line harness note after the cancelled user message,
107
+ * not a new role: the model has to tell "asked and withdrew" from "asked
108
+ * two things". It is a following message rather than a mutation of the user
109
+ * turn so appending the event only appends to the projection (P7).
110
+ */
111
+ type ReplayEvent = Extract<LogEvent, { t: "user" | "guidance" | "interrupt" | "assistant" | "tool_result" | "rewind" | "snapshot" }>;
112
+
113
+ function replayable(e: LogEvent): e is ReplayEvent {
114
+ if (e.t === "rewind") return e.mode !== "both";
115
+ if (e.t === "snapshot") return e.undeclared.length > 0;
116
+ return e.t === "user" || e.t === "guidance" || e.t === "interrupt" || e.t === "assistant" || e.t === "tool_result";
117
+ }
118
+
119
+ /**
120
+ * The one thing a divergence message must produce.
121
+ *
122
+ * Named because it is the contract, not decoration: it is the automated form
123
+ * of the remedy Claude Code leaves to the person to remember after a partial
124
+ * restore, and a test asserting the message happened has to assert something
125
+ * only the message can say.
126
+ */
127
+ export const REREAD_INSTRUCTION =
128
+ "Re-read any file before editing it. Do not rely on what earlier messages say its contents are.";
129
+
130
+ /**
131
+ * What an interrupt has to tell the model, as one line.
132
+ *
133
+ * A cancelled generation was never delivered, so the projection must not
134
+ * invent assistant text; the note is the whole of what the harness observed.
135
+ * Same role as the user turn so we do not mint a role the wire format lacks.
136
+ */
137
+ export const INTERRUPT_NOTE =
138
+ "The user cancelled this turn before a reply was delivered.";
139
+
140
+ /**
141
+ * What a partial rewind has to tell the model, as plain prose.
142
+ *
143
+ * Prose rather than a structured block because it is addressed to the model,
144
+ * and the one thing it must produce is a re-read before an edit. That
145
+ * instruction is Claude Code's documented remedy for the same desync, left
146
+ * there to the person to remember; the paths make it actionable.
147
+ */
148
+ export function renderRewindMessage(e: Extract<LogEvent, { t: "rewind" }>): Message {
149
+ const where = e.prompt ? `the point where you were asked: ${e.prompt}` : "an earlier point";
150
+ let lines: string[];
151
+ switch (e.mode) {
152
+ case "files":
153
+ lines = [
154
+ `The workspace was moved back to ${where}.`,
155
+ "",
156
+ "Your messages above still describe edits that are no longer on disk.",
157
+ "The conversation was not moved; only the files were.",
158
+ ];
159
+ break;
160
+ case "context":
161
+ lines = [
162
+ `This conversation was moved back to ${where}.`,
163
+ "",
164
+ "The workspace was NOT moved. Files exist that nothing above accounts",
165
+ "for, because the turns that made them are no longer in this context.",
166
+ ];
167
+ break;
168
+ case "both":
169
+ // Unreachable through the projection, which filters "both" out before
170
+ // getting here. Spelled out rather than folded into the other branch:
171
+ // falling through would have produced a message telling the model the
172
+ // two had diverged when the entire point of "both" is that they did
173
+ // not, and nothing would have failed.
174
+ throw new Error("a \"both\" rewind has nothing to tell the model: context and disk agree");
175
+ default:
176
+ return assertNever(e.mode, "renderRewindMessage");
177
+ }
178
+
179
+ if (e.divergence.length > 0) {
180
+ lines.push("", "Where they disagree:");
181
+ for (const path of e.divergence) lines.push(` ${path}`);
182
+ }
183
+ lines.push("", REREAD_INSTRUCTION);
184
+
185
+ return { role: "user", content: [{ type: "text", text: lines.join("\n") }] };
186
+ }
187
+
188
+ /**
189
+ * Render the handoff as the opening user message of a new segment.
190
+ *
191
+ * It is a user message rather than part of `system` for two reasons: `system` has
192
+ * to stay byte-stable (D6), and a handoff is untrusted-ish derived content, not an
193
+ * operator instruction -- it does not belong in the operator channel.
194
+ */
195
+ export function renderHandoffMessage(
196
+ handoff: Handoff,
197
+ logHint?: { manifestPath: string; segmentPath: string },
198
+ ): Message {
199
+ const yaml = emitHandoff(handoff);
200
+
201
+ const parts = [
202
+ "You are continuing earlier work in a new context window.",
203
+ "",
204
+ "The state below was derived mechanically from the session log -- it is not a",
205
+ "summary and nothing was paraphrased. Fields that are absent were absent from",
206
+ "the log, not judged unimportant.",
207
+ "",
208
+ "```yaml",
209
+ yaml.trimEnd(),
210
+ "```",
211
+ ];
212
+
213
+ if (logHint) {
214
+ parts.push(
215
+ "",
216
+ "The full log is on disk and nothing has been deleted from it. When you need a",
217
+ "detail this state does not carry, read it rather than guessing:",
218
+ ` - segment index: ${logHint.manifestPath}`,
219
+ ` - previous segment: ${logHint.segmentPath}`,
220
+ "Each line is one JSON event. Search it with history; read a line range with read,",
221
+ "or filter with bash and jq. The grep tool skips the log directory.",
222
+ );
223
+ }
224
+
225
+ const unverified = handoff.state.unverified_claims.length;
226
+ if (unverified > 0) {
227
+ parts.push(
228
+ "",
229
+ `${unverified} earlier completion ${unverified === 1 ? "claim was" : "claims were"} not matched`,
230
+ "to a passing command in the log. They are listed under state.unverified_claims",
231
+ "and must be treated as NOT done.",
232
+ );
233
+ }
234
+
235
+ if (handoff.file_anchors.length > 0) {
236
+ parts.push(
237
+ "",
238
+ "file_anchors records the digest each file had when it was last touched. Re-read",
239
+ "a file before relying on it; if the digest differs, the environment changed",
240
+ "underneath this state.",
241
+ );
242
+ }
243
+
244
+ return { role: "user", content: [{ type: "text", text: parts.join("\n") }] };
245
+ }
246
+
247
+ export function projectContext(input: ProjectInput): ProjectResult {
248
+ const violations: ProjectViolation[] = [];
249
+ const messages: Message[] = [];
250
+
251
+ if (input.handoff) {
252
+ messages.push(renderHandoffMessage(input.handoff, input.logHint));
253
+ }
254
+
255
+ const events = input.events.filter(replayable);
256
+
257
+ // Tool results are buffered so that every result for one assistant turn lands in
258
+ // a SINGLE user message. Splitting them across messages teaches the model to stop
259
+ // issuing parallel calls, which is a silent, permanent capability regression.
260
+ let pending: ContentBlock[] = [];
261
+ const flushPending = () => {
262
+ if (pending.length > 0) {
263
+ messages.push({ role: "user", content: pending });
264
+ pending = [];
265
+ }
266
+ };
267
+
268
+ /** tool_use ids emitted by the most recent assistant turn and not yet answered. */
269
+ let awaiting = new Set<string>();
270
+
271
+ for (const e of events) {
272
+ if (e.t === "snapshot") {
273
+ flushPending();
274
+ messages.push({ role: "user", content: [{ type: "text",
275
+ text: `Workspace observation: changes outside declared file edits: ${e.undeclared.join(", ")}. ${REREAD_INSTRUCTION}`,
276
+ }] });
277
+ continue;
278
+ }
279
+ if (e.t === "rewind") {
280
+ // Flushed first for the same reason a user turn is: it opens a user
281
+ // message, and a pending tool_result batch has to close before it.
282
+ flushPending();
283
+ messages.push(renderRewindMessage(e));
284
+ continue;
285
+ }
286
+ if (e.t === "user" || e.t === "guidance") {
287
+ flushPending();
288
+ if (awaiting.size > 0) {
289
+ violations.push({
290
+ code: "UNPAIRED_TOOL_USE",
291
+ detail: `user turn ${e.id} follows unanswered tool_use ${[...awaiting].join(", ")}`,
292
+ });
293
+ awaiting = new Set();
294
+ }
295
+ messages.push({ role: "user", content: [{ type: "text", text: e.text }] });
296
+ continue;
297
+ }
298
+ if (e.t === "interrupt") {
299
+ // Own message so the preceding user turn stays byte-stable (P7). A
300
+ // look-ahead mutation of that turn would rewrite it once a later
301
+ // event (tool_use, another user) was already projected after it.
302
+ flushPending();
303
+ messages.push({ role: "user", content: [{ type: "text", text: INTERRUPT_NOTE }] });
304
+ continue;
305
+ }
306
+
307
+ if (e.t === "assistant") {
308
+ flushPending();
309
+ if (awaiting.size > 0) {
310
+ violations.push({
311
+ code: "UNPAIRED_TOOL_USE",
312
+ detail: `assistant turn ${e.id} follows unanswered tool_use ${[...awaiting].join(", ")}`,
313
+ });
314
+ }
315
+ messages.push({ role: "assistant", content: e.blocks.map(toContentBlock) });
316
+ awaiting = new Set(
317
+ e.blocks.filter((b) => b.type === "tool_use").map((b) => (b as { call_id: string }).call_id),
318
+ );
319
+ continue;
320
+ }
321
+
322
+ // tool_result
323
+ if (!awaiting.has(e.call_id)) {
324
+ violations.push({
325
+ code: "ORPHAN_TOOL_RESULT",
326
+ detail: `tool_result ${e.id} answers unknown call ${e.call_id}`,
327
+ });
328
+ continue;
329
+ }
330
+ awaiting.delete(e.call_id);
331
+ pending.push({
332
+ type: "tool_result",
333
+ tool_use_id: e.call_id,
334
+ content: e.content,
335
+ ...(e.ok ? {} : { is_error: true }),
336
+ });
337
+ }
338
+
339
+ flushPending();
340
+
341
+ if (awaiting.size > 0) {
342
+ violations.push({
343
+ code: "UNPAIRED_TOOL_USE",
344
+ detail: `segment ends with unanswered tool_use ${[...awaiting].join(", ")}`,
345
+ });
346
+ }
347
+
348
+ if (messages.length === 0) {
349
+ violations.push({ code: "EMPTY_PROJECTION", detail: "no replayable events" });
350
+ }
351
+
352
+ return {
353
+ system: input.frozen.system,
354
+ tools: input.frozen.tools,
355
+ messages,
356
+ violations,
357
+ };
358
+ }
359
+
360
+ function toContentBlock(b: AssistantBlock): ContentBlock {
361
+ switch (b.type) {
362
+ case "tool_use":
363
+ return { type: "tool_use", id: b.call_id, name: b.name, input: b.input, ...(b.wire === undefined ? {} : { wire: b.wire }) };
364
+ case "thinking":
365
+ // Only the raw block crosses back to the API; `text` exists for display.
366
+ return { type: "thinking", raw: b.raw };
367
+ case "text":
368
+ return b;
369
+ default:
370
+ return assertNever(b, "toContentBlock");
371
+ }
372
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Prompt-template expansion. Pure: the file is read in store.
3
+ *
4
+ * `{{name}}` is filled with the remainder of `/template rest`. An empty
5
+ * remainder keeps the placeholder and names it — emptying it would drop a
6
+ * sentence from the prompt with no trace (feat-skills-and-prompts design).
7
+ */
8
+
9
+ import { parseFrontmatter } from "./frontmatter.ts";
10
+
11
+ export interface PromptTemplate {
12
+ readonly name: string;
13
+ readonly description: string;
14
+ readonly argumentHint?: string;
15
+ readonly content: string;
16
+ readonly filePath: string;
17
+ }
18
+
19
+ const PLACEHOLDER = /\{\{([A-Za-z_][A-Za-z0-9_-]*)\}\}/g;
20
+
21
+ export function expandTemplate(
22
+ content: string,
23
+ arg: string,
24
+ ): { text: string; diagnostics: readonly string[] } {
25
+ const names: string[] = [];
26
+ const seen = new Set<string>();
27
+ for (const match of content.matchAll(PLACEHOLDER)) {
28
+ const name = match[1]!;
29
+ if (!seen.has(name)) {
30
+ seen.add(name);
31
+ names.push(name);
32
+ }
33
+ }
34
+ if (arg.length > 0) {
35
+ return { text: content.replace(PLACEHOLDER, arg), diagnostics: [] };
36
+ }
37
+ return {
38
+ text: content,
39
+ diagnostics: names.map((name) => `unfilled placeholder: ${name}`),
40
+ };
41
+ }
42
+
43
+ export function parsePromptTemplate(input: {
44
+ readonly filePath: string;
45
+ readonly text: string;
46
+ }): { template: PromptTemplate | null; diagnostics: readonly string[] } {
47
+ const parsed = parseFrontmatter(input.text);
48
+ const diagnostics = [...parsed.diagnostics];
49
+ const base = fileName(input.filePath);
50
+ const name = base.replace(/\.md$/i, "").toLowerCase();
51
+ if (name.length === 0) {
52
+ diagnostics.push(`${input.filePath}: prompt template has no name`);
53
+ return { template: null, diagnostics };
54
+ }
55
+
56
+ const descField = parsed.fields["description"];
57
+ let description = typeof descField === "string" ? descField.trim() : "";
58
+ if (description.length === 0) {
59
+ const first = parsed.body.split("\n").find((line) => line.trim().length > 0);
60
+ description = first === undefined ? "" : first.trim();
61
+ }
62
+
63
+ const hintField = parsed.fields["argument-hint"];
64
+ const argumentHint = typeof hintField === "string" ? hintField : undefined;
65
+
66
+ return {
67
+ template: {
68
+ name,
69
+ description,
70
+ content: parsed.body,
71
+ filePath: input.filePath,
72
+ ...(argumentHint !== undefined ? { argumentHint } : {}),
73
+ },
74
+ diagnostics,
75
+ };
76
+ }
77
+
78
+ /** Path basename without node:path, so this module stays core-pure. */
79
+ function fileName(path: string): string {
80
+ const trimmed = path.replace(/[\\/]+$/, "");
81
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
82
+ return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
83
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * When a typed message may enter the log.
3
+ *
4
+ * The queue itself is UI state: a line the user may still retract. The log
5
+ * is what happened. nextDelivery is the only decision — it says which
6
+ * queued line is legal to deliver at this phase, or none. The caller
7
+ * records the user event; this file never does.
8
+ *
9
+ * after_tools is the first legal moment to insert a user message, for the
10
+ * same pairing reason switchModel already refuses a half-round: a tool_use
11
+ * without its tool_result is a 400 (project.ts UNPAIRED_TOOL_USE).
12
+ */
13
+
14
+ import { assertNever } from "./exhaustive.ts";
15
+
16
+ export type QueueKind = "steering" | "followup";
17
+
18
+ export interface QueuedMessage {
19
+ readonly kind: QueueKind;
20
+ readonly text: string;
21
+ }
22
+
23
+ /**
24
+ * Where the turn is. after_tools means every tool_use of this assistant
25
+ * round already has a result. turn_end means send() has returned. idle
26
+ * is the editor waiting — leftover lines still deliver, steering first.
27
+ */
28
+ export type Phase = "after_tools" | "turn_end" | "idle";
29
+
30
+ /**
31
+ * Which queued message may be written to the log right now.
32
+ *
33
+ * Same kind stays in enqueue order. Steering as a group always precedes
34
+ * follow-up: a follow-up is defined as "after the whole send", so it
35
+ * cannot win while a steering line is still waiting.
36
+ */
37
+ export function nextDelivery(
38
+ queue: readonly QueuedMessage[],
39
+ phase: Phase,
40
+ ): QueuedMessage | null {
41
+ switch (phase) {
42
+ case "after_tools":
43
+ return firstOfKind(queue, "steering");
44
+ case "turn_end":
45
+ case "idle":
46
+ return firstOfKind(queue, "steering") ?? firstOfKind(queue, "followup");
47
+ default:
48
+ return assertNever(phase, "nextDelivery");
49
+ }
50
+ }
51
+
52
+ function firstOfKind(
53
+ queue: readonly QueuedMessage[],
54
+ kind: QueueKind,
55
+ ): QueuedMessage | null {
56
+ for (const item of queue) {
57
+ if (item.kind === kind) return item;
58
+ }
59
+ return null;
60
+ }
@@ -0,0 +1,15 @@
1
+ /** N.12: recoverable file bounds are observation metadata, not model guesses. */
2
+ export function readBounds(total: number, first: unknown, last: unknown):
3
+ { ok: true; start: number; end: number; notice: string } | { ok: false; message: string } {
4
+ if (!Number.isSafeInteger(total) || total < 1) throw new RangeError("split text must contain at least one line");
5
+ const start = first === undefined ? 1 : first, end = last === undefined ? total : last;
6
+ const bounds = `total lines: ${total}; valid range: 1-${total}`;
7
+ if (typeof start !== "number" || typeof end !== "number" || !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < 1) {
8
+ return { ok: false, message: `read start and end must be positive safe integers (${bounds})` };
9
+ }
10
+ if (start > total) return { ok: false, message: `read start ${start} is beyond end of file (${bounds}); choose a start in that range` };
11
+ if (end < start) return { ok: false, message: `read end ${end} precedes start ${start} (${bounds})` };
12
+ const actualEnd = Math.min(end, total);
13
+ return { ok: true, start, end: actualEnd,
14
+ notice: `[Read selection: lines ${start}-${actualEnd}; total lines: ${total}; ${actualEnd < total ? `next start: ${actualEnd + 1}` : "end of file"}.]` };
15
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pre-registered F2 analysis (docs/tasks/bench-recall-pilot.md). Pure.
3
+ *
4
+ * The unit is a (seed, ratio) block: arms in one block face byte-identical
5
+ * pages and questions, blocks are independent, stages within a run are not.
6
+ */
7
+ export interface RecallStageRow {
8
+ stage: number; kind: "recent" | "recall" | "latest"; distance: number;
9
+ visibleThroughout: boolean | null; verdict: { ok: boolean; reason: string }; status: string;
10
+ }
11
+ export interface RecallRunRow {
12
+ arm: string; ratio: number; seed: number; planned: number; unrun: number; boundaries: number;
13
+ knownCost: number; reservedUnknown: number; stages: RecallStageRow[]; modelFiles: string[];
14
+ }
15
+
16
+ /** Two-sided exact sign test; ties are dropped before counting. */
17
+ export function signTest(differences: readonly number[]): { positive: number; negative: number; ties: number; p: number } {
18
+ const positive = differences.filter((d) => d > 0).length, negative = differences.filter((d) => d < 0).length;
19
+ const n = positive + negative, k = Math.min(positive, negative);
20
+ let tail = 0;
21
+ for (let i = 0; i <= k; i++) tail += binomial(n, i) / 2 ** n;
22
+ return { positive, negative, ties: differences.length - n, p: n === 0 ? 1 : Math.min(1, 2 * tail) };
23
+ }
24
+
25
+ function binomial(n: number, k: number): number {
26
+ let out = 1;
27
+ for (let i = 1; i <= k; i++) out = out * (n - k + i) / i;
28
+ return out;
29
+ }
30
+
31
+ export function median(values: readonly number[]): number | null {
32
+ if (values.length === 0) return null;
33
+ const sorted = [...values].sort((a, b) => a - b), mid = Math.floor(sorted.length / 2);
34
+ return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
35
+ }
36
+
37
+ /** Non-recent accuracy over planned questions: an unrun stage counts as wrong, never as missing. */
38
+ export function memoryAccuracy(run: RecallRunRow, planned: readonly RecallStageRow["kind"][]): number | null {
39
+ const asked = planned.filter((kind) => kind !== "recent").length;
40
+ if (asked === 0) return null;
41
+ return run.stages.filter((row) => row.kind !== "recent" && row.verdict.ok).length / asked;
42
+ }
43
+
44
+ export interface BlockDifference { seed: number; ratio: number; a: number | null; b: number | null; difference: number | null }
45
+
46
+ export function pairedBlocks(runs: readonly RecallRunRow[], a: string, b: string, ratios: readonly number[],
47
+ plannedKinds: (seed: number, ratio: number) => readonly RecallStageRow["kind"][]): BlockDifference[] {
48
+ const blocks = [...new Set(runs.filter((run) => ratios.includes(run.ratio)).map((run) => `${run.seed}/${run.ratio}`))].sort();
49
+ return blocks.map((key) => {
50
+ const [seed, ratio] = key.split("/").map(Number) as [number, number];
51
+ const kinds = plannedKinds(seed, ratio);
52
+ const find = (arm: string) => runs.find((run) => run.arm === arm && run.seed === seed && run.ratio === ratio);
53
+ const ra = find(a), rb = find(b);
54
+ const accA = ra ? memoryAccuracy(ra, kinds) : null, accB = rb ? memoryAccuracy(rb, kinds) : null;
55
+ return { seed, ratio, a: accA, b: accB, difference: accA === null || accB === null ? null : accA - accB };
56
+ });
57
+ }
58
+
59
+ export function compare(blocks: readonly BlockDifference[]) {
60
+ // A block missing either arm is reported, never silently imputed.
61
+ const complete = blocks.filter((block) => block.difference !== null).map((block) => block.difference!);
62
+ return { blocks: blocks.length, complete: complete.length, medianDifference: median(complete), ...signTest(complete) };
63
+ }
64
+
65
+ /** Pre-registered decision: p < 0.05 and median difference ≥ 10 points. */
66
+ export function decide(primary: ReturnType<typeof compare>): "advantage" | "no_advantage_measured" {
67
+ return primary.complete > 0 && primary.p < 0.05 && (primary.medianDifference ?? 0) >= 0.10 ? "advantage" : "no_advantage_measured";
68
+ }
69
+
70
+ export function describeArm(runs: readonly RecallRunRow[], arm: string, ratio: number) {
71
+ const rows = runs.filter((run) => run.arm === arm && run.ratio === ratio);
72
+ const stages = rows.flatMap((run) => run.stages), memory = stages.filter((row) => row.kind !== "recent");
73
+ const evicted = memory.filter((row) => row.visibleThroughout === false);
74
+ const latest = stages.filter((row) => row.kind === "latest");
75
+ const correct = stages.filter((row) => row.verdict.ok).length;
76
+ const cost = rows.reduce((sum, run) => sum + run.knownCost, 0);
77
+ const rate = (n: number, d: number) => d === 0 ? null : n / d;
78
+ return {
79
+ arm, ratio, runs: rows.length, stages: stages.length, unrun: rows.reduce((n, run) => n + run.unrun, 0),
80
+ recentAccuracy: rate(stages.filter((row) => row.kind === "recent" && row.verdict.ok).length, stages.filter((row) => row.kind === "recent").length),
81
+ memoryAccuracy: rate(memory.filter((row) => row.verdict.ok).length, memory.length),
82
+ evictionRate: rate(evicted.length, memory.length),
83
+ evictedAccuracy: rate(evicted.filter((row) => row.verdict.ok).length, evicted.length),
84
+ staleLatest: rate(latest.filter((row) => row.verdict.reason === "stale").length, latest.length),
85
+ boundaries: rows.reduce((n, run) => n + run.boundaries, 0),
86
+ cost, costPerCorrect: correct === 0 ? null : cost / correct,
87
+ reservedUnknown: rows.reduce((n, run) => n + run.reservedUnknown, 0),
88
+ runsWithModelFiles: rows.filter((run) => run.modelFiles.length > 0).length,
89
+ };
90
+ }