@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,226 @@
1
+ /**
2
+ * F2 delayed recall across three harnesses behind one metered gateway.
3
+ *
4
+ * Each page is deleted when its stage ends, so a later question can only be
5
+ * answered from what the harness itself retained. The arms differ in exactly
6
+ * that: xeno keeps a lossless log with a `history` reader; the ablation keeps
7
+ * the same windowed projection without the reader; Pi keeps its own compaction
8
+ * summary. See docs/tasks/bench-recall-runner.md.
9
+ */
10
+ import { appendFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join, relative } from "node:path";
12
+ import { createHash } from "node:crypto";
13
+ import { recallCodes, recallPrompt, verifyRecallAnswer, type RecallFixture, type RecallKind, type RecallVerdict } from "../core/recall-trial.ts";
14
+ import type { BenchmarkPrice, BenchmarkUsage } from "../core/benchmark.ts";
15
+ import { Session } from "./loop.ts";
16
+ import { OpenAICompletionsProvider } from "./openai.ts";
17
+ import type { ProviderConfig, ToolSchema } from "./provider.ts";
18
+ import { COMPARISON_SYSTEM, COMPARISON_TOOLS, comparisonGateway, runPiPrompt, type ComparisonBackend, type ComparisonLedger, type GatewayRecord, type PiIdentity } from "./harness-comparison.ts";
19
+ import { DEFAULT_TOOL_CONTEXT, TOOL_SCHEMAS, runTool } from "./tools.ts";
20
+
21
+ export const RECALL_ARMS = ["xeno", "xeno-nohistory", "pi"] as const;
22
+ export type RecallArm = typeof RECALL_ARMS[number];
23
+
24
+ /** Native memory tools are the independent variable; everything else is shared. */
25
+ export function recallToolNames(arm: RecallArm): readonly string[] {
26
+ return arm === "xeno" ? [...COMPARISON_TOOLS, "history", "note"] : COMPARISON_TOOLS;
27
+ }
28
+ export const recallTools = (arm: RecallArm): ToolSchema[] =>
29
+ recallToolNames(arm).map((name) => TOOL_SCHEMAS.find((tool) => tool.name === name)!);
30
+
31
+ export interface RecallStageResult {
32
+ stage: number; kind: RecallKind; source: number; distance: number;
33
+ /** A context boundary (xeno cut / Pi compaction) separated the source page from the answer. */
34
+ crossed: boolean;
35
+ /**
36
+ * The expected code was in the first request of this stage. A crossed
37
+ * boundary does not imply loss (xeno retains recent exchanges, a Pi summary
38
+ * may copy a code); this measures loss on the wire, identically per arm.
39
+ */
40
+ visibleAtAsk: boolean | null;
41
+ /** Visible in every task request of the stage; a mid-stage boundary can evict it after the ask. Null for recent. */
42
+ visibleThroughout: boolean | null;
43
+ status: "answered" | "stopped" | "error"; reason: string; verdict: RecallVerdict;
44
+ requests: number; pageReads: number; historyCalls: number; boundariesAfter: number;
45
+ }
46
+ export interface RecallTrialResult {
47
+ arm: RecallArm; planned: number; unrun: number; stages: RecallStageResult[];
48
+ toolNames: string[]; boundaries: number; requests: number;
49
+ knownCost: number; reservedUnknown: number; usage: BenchmarkUsage; peakPrompt: number;
50
+ /** Files other than answers the model created: its own notes, reported separately. */
51
+ modelFiles: string[];
52
+ /** Workspace copies of deleted pages; must be empty for the stage to count as observed-once. */
53
+ leakedPages: string[];
54
+ stopped: "budget" | "balance" | "error" | "limit" | null; wallMs: number;
55
+ }
56
+
57
+ const sha = (body: string) => createHash("sha256").update(body).digest("hex");
58
+
59
+ async function listFiles(root: string): Promise<string[]> {
60
+ const out: string[] = [];
61
+ for (const entry of await readdir(root, { withFileTypes: true, recursive: true })) {
62
+ if (entry.isFile()) out.push(relative(root, join(entry.parentPath, entry.name)));
63
+ }
64
+ return out.sort();
65
+ }
66
+
67
+ export async function runRecallTrial(options: {
68
+ directory: string; fixture: RecallFixture; arm: RecallArm;
69
+ config: ProviderConfig; price: BenchmarkPrice; ledger: ComparisonLedger; backend: ComparisonBackend;
70
+ maxTurnsPerStage: number; stageTimeoutMs: number;
71
+ pi: PiIdentity | null; extension: string;
72
+ /** Synthetic pressure only; live trials keep Pi's default compaction. */
73
+ piCompaction?: { reserveTokens: number; keepRecentTokens: number };
74
+ onProgress?: (event: Record<string, unknown>) => Promise<void>;
75
+ }): Promise<RecallTrialResult> {
76
+ const { fixture, arm } = options, started = performance.now();
77
+ const workspace = join(options.directory, "workspace"), outputStore = join(options.directory, "observations");
78
+ await mkdir(workspace, { recursive: true }); await mkdir(outputStore, { recursive: true });
79
+ const tools = recallTools(arm), allow = recallToolNames(arm);
80
+ const result: RecallTrialResult = { arm, planned: fixture.pages.length, unrun: fixture.pages.length, stages: [],
81
+ toolNames: [...allow], boundaries: 0, requests: 0, knownCost: 0, reservedUnknown: 0,
82
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, peakPrompt: 0,
83
+ modelFiles: [], leakedPages: [], stopped: null, wallMs: 0 };
84
+ let stage = 0, pageReads = 0, historyCalls = 0, stageTaskRequests = 0;
85
+ let seen: boolean[] = [];
86
+ const page = () => fixture.pages[stage]!;
87
+ const countCall = (name: string, input: unknown) => {
88
+ if (name === "history") historyCalls++;
89
+ if (name === "read" && input !== null && typeof input === "object" && (input as Record<string, unknown>)["path"] === page().path) pageReads++;
90
+ };
91
+ const toolContext = { ...DEFAULT_TOOL_CONTEXT, root: workspace, allow, outputStore };
92
+ const gateway = comparisonGateway({ config: options.config, price: options.price, ledger: options.ledger, tools,
93
+ // Pi's summary calls share this ceiling; the slack keeps them from starving task turns.
94
+ maxRequests: (options.maxTurnsPerStage + 2) * fixture.pages.length,
95
+ // The same per-stage cap for every arm: Session enforces maxTurns itself,
96
+ // Pi has no such flag, so the gateway refuses its excess task requests.
97
+ admit: (hasTools) => !hasTools || stageTaskRequests++ < options.maxTurnsPerStage,
98
+ backend: (payload, signal) => {
99
+ // Summary calls carry no tools; only a task turn shows what the model sees.
100
+ if (Array.isArray(payload["tools"])) seen.push(JSON.stringify(payload["messages"]).replaceAll("\\", "").includes(page().question.expected));
101
+ return options.backend(payload, signal);
102
+ },
103
+ executeTool: (name, input) => { countCall(name, input); return runTool(toolContext, name, input); },
104
+ record: async (record: GatewayRecord) => {
105
+ await appendFile(join(options.directory, "requests.jsonl"), JSON.stringify({ stage, ...record }) + "\n");
106
+ if (record.kind === "settled") {
107
+ result.requests++;
108
+ if (record.measured === null) result.reservedUnknown += record.charge; else result.knownCost += record.measured;
109
+ if (record.usage) {
110
+ for (const key of Object.keys(result.usage) as (keyof BenchmarkUsage)[]) result.usage[key] += record.usage[key];
111
+ result.peakPrompt = Math.max(result.peakPrompt, record.usage.input_tokens + record.usage.cache_read_input_tokens);
112
+ }
113
+ }
114
+ await options.onProgress?.({ phase: record.kind, arm, stage, request: record.request, measured: record.measured, failure: record.failure });
115
+ } });
116
+ const config = { ...options.config, spec: { ...options.config.spec, baseUrl: gateway.url } };
117
+ const codes = recallCodes(fixture), boundaryAfter: number[] = [];
118
+ let session: Session | null = null;
119
+ try {
120
+ if (arm !== "pi") {
121
+ await mkdir(join(options.directory, "log"));
122
+ const provider = new OpenAICompletionsProvider(config, gateway.token, { retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
123
+ session = new Session({ logDir: join(options.directory, "log"), frozen: { system: COMPARISON_SYSTEM, tools },
124
+ tools: toolContext, contextPolicy: "windowed", maxTurns: options.maxTurnsPerStage, harnessReviews: false }, provider);
125
+ await session.start();
126
+ } else if (!options.pi) throw new Error("Pi identity required");
127
+ for (const current of fixture.pages) {
128
+ if (options.ledger.halted || gateway.stopped) break;
129
+ stage = current.stage; pageReads = 0; historyCalls = 0; seen = []; stageTaskRequests = 0;
130
+ const file = join(workspace, current.path);
131
+ await mkdir(dirname(file), { recursive: true }); await mkdir(join(workspace, "answers"), { recursive: true });
132
+ await writeFile(file, current.body, { flag: "wx" });
133
+ const requestsBefore = result.requests, signal = AbortSignal.timeout(options.stageTimeoutMs);
134
+ let status: RecallStageResult["status"] = "stopped", reason = "no completion";
135
+ try {
136
+ if (session) {
137
+ for await (const event of session.send(recallPrompt(fixture, stage), signal)) {
138
+ if (event.kind === "cut") result.boundaries++;
139
+ if (event.kind === "tool_call" && event.tool) countCall(event.tool.name, event.tool.input);
140
+ if (event.kind === "done") reason = event.reason ?? "unknown completion";
141
+ }
142
+ if (["stop", "end_turn"].includes(reason)) status = "answered";
143
+ } else {
144
+ const run = await runPiPrompt({ directory: options.directory, workspace, prompt: recallPrompt(fixture, stage), config,
145
+ gateway, price: options.price, pi: options.pi!, extension: options.extension, signal, resume: stage > 0, tools,
146
+ ...(options.piCompaction ? { piCompaction: options.piCompaction } : {}) });
147
+ result.boundaries += run.cuts; reason = run.reason; status = run.answered ? "answered" : "error";
148
+ }
149
+ } catch {
150
+ status = "error"; reason = signal.aborted ? "stage deadline" : gateway.stopped ? `model gateway: ${gateway.stopped}` : "stage failed";
151
+ }
152
+ boundaryAfter[stage] = result.boundaries;
153
+ const answer = await readFile(join(workspace, current.answerPath), "utf8").catch(() => null);
154
+ const verdict = verifyRecallAnswer(answer, current.question, codes);
155
+ await rm(file);
156
+ const { question } = current;
157
+ const row: RecallStageResult = { stage, kind: question.kind, source: question.source, distance: stage - question.source,
158
+ crossed: question.source < stage && result.boundaries > boundaryAfter[question.source]!,
159
+ visibleAtAsk: question.source === stage || seen.length === 0 ? null : seen[0]!,
160
+ visibleThroughout: question.source === stage || seen.length === 0 ? null : seen.every(Boolean),
161
+ status, reason, verdict, requests: result.requests - requestsBefore, pageReads, historyCalls, boundariesAfter: result.boundaries };
162
+ result.stages.push(row); result.unrun = result.planned - result.stages.length;
163
+ await writeFile(join(options.directory, `stage-${String(stage).padStart(3, "0")}.json`),
164
+ JSON.stringify({ ...row, answer }, null, 2) + "\n", { flag: "wx" });
165
+ await options.onProgress?.({ phase: "stage", arm, stage, verdict: verdict.reason, crossed: row.crossed, boundaries: result.boundaries });
166
+ }
167
+ } finally { await gateway.close(); }
168
+ const files = await listFiles(workspace), bodies = new Set(fixture.pages.map((p) => sha(p.body)));
169
+ result.modelFiles = files.filter((path) => !path.startsWith("answers/") && !path.startsWith("incoming/"));
170
+ for (const path of files) if (bodies.has(sha(await readFile(join(workspace, path), "utf8")))) result.leakedPages.push(path);
171
+ result.stopped = gateway.stopped; result.wallMs = performance.now() - started;
172
+ await writeFile(join(options.directory, "result.json"), JSON.stringify(result, null, 2) + "\n", { flag: "wx" });
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * Mechanical backend: answers only from what the request actually shows.
178
+ *
179
+ * It cannot remember across requests, so a code it finds proves the
180
+ * information was in that projection (or recovered by history). Its summary
181
+ * text for Pi is invented here, which is why nothing it produces is a score.
182
+ */
183
+ export function recallMechanicalBackend(): ComparisonBackend {
184
+ let id = 0;
185
+ return async (payload) => {
186
+ const messages = payload["messages"] as Record<string, unknown>[], tools = payload["tools"] as { function: { name: string } }[] | undefined;
187
+ const reply = (content: string | null, call?: { name: string; input: unknown }) => ({
188
+ id: `recall-mock-${++id}`, object: "chat.completion", created: 1, model: "recall-mechanical",
189
+ choices: [{ index: 0, finish_reason: call ? "tool_calls" : "stop", message: { role: "assistant", content,
190
+ ...(call ? { tool_calls: [{ id: `call-${id}`, type: "function", function: { name: call.name, arguments: JSON.stringify(call.input) } }] } : {}) } }],
191
+ usage: { prompt_tokens: Math.ceil(JSON.stringify([tools ?? [], messages]).length / 3.6), completion_tokens: 64, prompt_cache_hit_tokens: 0 },
192
+ });
193
+ if (!tools) return reply("Checkpoint: staged pages processed and answers written.");
194
+ const text = (m: Record<string, unknown>) => typeof m["content"] === "string" ? m["content"] : JSON.stringify(m["content"] ?? "");
195
+ const at = messages.findLastIndex((m) => m["role"] === "user" && /Stage \d+: read /.test(text(m)));
196
+ // After a cut the current request can sit inside a handoff that also quotes
197
+ // earlier stages; the latest quoted stage is the active one.
198
+ const asks = [...text(messages[at]!).matchAll(/read (\S+) in full\. Question: what is the current code of ticket (T-\d{5})\? Write only the code to (\S+),/g)];
199
+ const [, path, ticket, answerPath] = asks.at(-1)!;
200
+ const calls = messages.slice(at + 1).flatMap((m) => (m["tool_calls"] as { function: { name: string; arguments: string } }[] | undefined) ?? [])
201
+ .map((c) => ({ name: c.function.name, input: JSON.parse(c.function.arguments) as Record<string, unknown> }));
202
+ if (calls.some((c) => c.name === "write")) return reply("Stage done.");
203
+ if (!calls.some((c) => c.name === "read" && c.input["path"] === path)) return reply(null, { name: "read", input: { path } });
204
+ // Backslashes vanish so nested JSON (history results) matches the same way.
205
+ const visible = JSON.stringify(messages).replaceAll("\\", "");
206
+ const pattern = new RegExp(`"ticket":"${ticket}","code":"(K-[0-9a-f]{12})"`, "g");
207
+ // Search results are newest first, so the first hit there is the latest code;
208
+ // in the conversation itself the last occurrence is the latest.
209
+ const search = messages.slice(at + 1).findLast((m) => m["role"] === "tool" && text(m).includes('"matches"'));
210
+ const found = (search ? [...text(search).replaceAll("\\", "").matchAll(pattern)][0]?.[1] : undefined)
211
+ ?? [...visible.matchAll(pattern)].at(-1)?.[1];
212
+ if (found) return reply(null, { name: "write", input: { path: answerPath, content: found } });
213
+ const names = new Set(tools.map((t) => t.function.name));
214
+ const history = calls.filter((c) => c.name === "history");
215
+ if (names.has("history") && history.length === 0) return reply(null, { name: "history", input: { query: ticket } });
216
+ if (names.has("history") && history.length === 1) {
217
+ // Pick an observation that read a page; the question prompt also matches the ticket.
218
+ const result = messages.slice(at + 1).findLast((m) => m["role"] === "tool" && text(m).includes('"matches"'));
219
+ let items: { event: string; tool?: string }[] = [];
220
+ try { items = (JSON.parse(text(result ?? {})) as { items: typeof items }).items; } catch { /* not a search page */ }
221
+ const event = items.find((item) => item.tool === "read")?.event;
222
+ if (event) return reply(null, { name: "history", input: { event } });
223
+ }
224
+ return reply(null, { name: "write", input: { path: answerPath, content: "UNKNOWN" } });
225
+ };
226
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Picking a session back up where it stopped.
3
+ *
4
+ * Pure: it decides, given a recorded cursor and the model about to run, what
5
+ * should happen. Doing the reading and the refusing in separate places keeps
6
+ * the one interesting rule -- when a resume is unsafe -- testable without a
7
+ * filesystem.
8
+ */
9
+
10
+ import type { Cursor } from "../store/log.ts";
11
+
12
+ export type ResumePlan =
13
+ | { kind: "fresh" }
14
+ | { kind: "resume"; cursor: Cursor }
15
+ | { kind: "refuse"; reason: string };
16
+
17
+ /**
18
+ * A session's thinking blocks belong to the model that produced them.
19
+ *
20
+ * Every provider this harness talks to requires them replayed unchanged, and
21
+ * the two do not use the same representation at all: DeepSeek carries
22
+ * `reasoning_content`, Anthropic carries a signed block. Handing one to the
23
+ * other is a bad request, and it would surface on the first turn of the
24
+ * resumed session rather than here.
25
+ *
26
+ * Refusing rather than converting is the point. pi converts, wrapping a
27
+ * foreign thinking trace in `<thinking>` tags inside assistant content -- a
28
+ * lossy, opinionated rewrite of what the model is told happened. This
29
+ * project's position is that the context is a recomputable projection of the
30
+ * log, so a resume that silently rewrites history breaks the one property
31
+ * everything else rests on. The log is intact; switching back to the model
32
+ * that wrote it picks up exactly where it left off.
33
+ */
34
+ export function planResume(
35
+ cursor: Cursor | null,
36
+ model: string,
37
+ hasNodes: boolean,
38
+ ): ResumePlan {
39
+ if (cursor === null) {
40
+ // No cursor and no nodes is simply a new session. No cursor but nodes on
41
+ // disk is a log this build cannot place itself in -- written before the
42
+ // cursor existed, or with a manifest that was lost. Refused rather than
43
+ // guessed: the whole reason the cursor is recorded is that the tree shape
44
+ // cannot be read backwards to find it, and inventing an answer here would
45
+ // reintroduce exactly the ambiguity it exists to remove.
46
+ return hasNodes
47
+ ? {
48
+ kind: "refuse",
49
+ reason:
50
+ "this log has nodes but no recorded cursor, so there is no way to " +
51
+ "know which one the session was in. It is intact and nothing will " +
52
+ "be touched -- start a separate session with --fresh",
53
+ }
54
+ : { kind: "fresh" };
55
+ }
56
+ if (cursor.model !== model) {
57
+ return {
58
+ kind: "refuse",
59
+ reason:
60
+ `this session ran under ${cursor.model}, and ${model} cannot replay its ` +
61
+ `thinking blocks. Resume it with --model ${cursor.model}, or start a ` +
62
+ `separate session with --fresh`,
63
+ };
64
+ }
65
+ return { kind: "resume", cursor };
66
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * When a failed request is worth making again.
3
+ *
4
+ * Split into a pure decision and a thin loop, because the decision is where
5
+ * all the rules are and none of them need a clock. What to retry, how long to
6
+ * wait, when to stop -- all of it is testable in microseconds, which is the
7
+ * difference between pinning the policy and hoping it works.
8
+ *
9
+ * The rules mirror the OpenAI and Anthropic SDKs' own, by way of pi, which
10
+ * notes the same lineage. That is deliberate: the failure modes were found by
11
+ * people running far more traffic than this, and inventing a different policy
12
+ * would mean rediscovering them.
13
+ *
14
+ * Retry lives at the request, inside the adapter, and not in the loop. A retry
15
+ * in the loop would straddle a log append -- the failed attempt would either
16
+ * be recorded, polluting an append-only history with something that did not
17
+ * happen, or dropped, leaving usage that does not add up. The loop should see
18
+ * a result that has already been retried, or a definite failure.
19
+ */
20
+
21
+ import { isContextOverflow } from "../core/overflow.ts";
22
+
23
+ /**
24
+ * Words providers and relays use for "try again": timeouts, unavailable or
25
+ * overloaded upstreams, rate limits, dropped connections, 5xx codes. A
26
+ * pattern, not a list of known providers, because relays forward their
27
+ * upstream's wording.
28
+ */
29
+ const TRANSIENT_STREAM = /time[d ]?out|timed out|unavailable|overload|rate.?limit|too many requests|connection (?:reset|closed|refused)|ECONNRESET|temporar|\b5\d\d\b|internal.?server|bad gateway|service.?unavailable/i;
30
+
31
+ /** Structural, so retry.ts need not import an adapter to recognise its error. */
32
+ function isStreamFailure(error: unknown): error is Error & { code?: string } {
33
+ return error instanceof Error && error.name === "StreamError";
34
+ }
35
+
36
+ export interface RetryPolicy {
37
+ /**
38
+ * Attempts after the first. Four means a request can take about 15s of
39
+ * backoff before failing, which covers a rate-limit window without making a
40
+ * broken request take a minute to report itself.
41
+ */
42
+ maxRetries: number;
43
+ /**
44
+ * Longest server-requested delay worth honouring. Beyond this, fail. An
45
+ * agent that silently sleeps for twenty minutes cannot be told apart from
46
+ * one that has died, and is harder to diagnose than an error.
47
+ */
48
+ maxRetryDelayMs: number;
49
+ }
50
+
51
+ export const DEFAULT_RETRY: RetryPolicy = { maxRetries: 4, maxRetryDelayMs: 60_000 };
52
+
53
+ export type RetryDecision =
54
+ | { retry: true; delayMs: number }
55
+ | { retry: false; reason: string };
56
+
57
+ /** What this module needs from an error: a status and headers, if it has them. */
58
+ interface HasResponse {
59
+ status?: unknown;
60
+ headers?: unknown;
61
+ }
62
+
63
+ export function statusOf(error: unknown): number | undefined {
64
+ if (typeof error !== "object" || error === null) return undefined;
65
+ const status = (error as HasResponse).status;
66
+ return typeof status === "number" ? status : undefined;
67
+ }
68
+
69
+ function headersOf(error: unknown): Headers | undefined {
70
+ if (typeof error !== "object" || error === null) return undefined;
71
+ const headers = (error as HasResponse).headers;
72
+ return headers instanceof Headers ? headers : undefined;
73
+ }
74
+
75
+ /**
76
+ * Whether this status is about timing rather than about the request.
77
+ *
78
+ * 400 and 401 are the two worth naming: a malformed request and a bad key are
79
+ * deterministic, so retrying converts one clear failure into several slow ones
80
+ * and delays the only message that would have helped.
81
+ */
82
+ function statusIsTransient(status: number): boolean {
83
+ return status === 408 || status === 409 || status === 429 || status >= 500;
84
+ }
85
+
86
+ function serverDelayMs(headers: Headers | undefined): number | undefined {
87
+ if (headers === undefined) return undefined;
88
+
89
+ // Milliseconds first: it is the more precise of the two, and a provider
90
+ // that sends both means the finer one.
91
+ const ms = headers.get("retry-after-ms");
92
+ if (ms !== null) {
93
+ const value = Number.parseFloat(ms);
94
+ if (!Number.isNaN(value)) return value;
95
+ }
96
+
97
+ const after = headers.get("retry-after");
98
+ if (after !== null) {
99
+ const seconds = Number.parseFloat(after);
100
+ if (!Number.isNaN(seconds)) return seconds * 1000;
101
+ // The header is also allowed to be an HTTP date.
102
+ const at = Date.parse(after);
103
+ if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
104
+ }
105
+
106
+ return undefined;
107
+ }
108
+
109
+ /**
110
+ * Whether to try again, and how long to wait first.
111
+ *
112
+ * `jitter` is a value in [0, 1) supplied by the caller rather than drawn here,
113
+ * so the backoff arithmetic is deterministic under test. It only ever
114
+ * shortens the wait: lengthening it could push past the window the server
115
+ * implied, while shortening is what breaks up the thundering herd of clients
116
+ * that were all rate-limited at the same instant and would otherwise wake
117
+ * together.
118
+ */
119
+ export function retryDecision(
120
+ error: unknown,
121
+ attempt: number,
122
+ policy: RetryPolicy,
123
+ jitter: number,
124
+ ): RetryDecision {
125
+ if (attempt >= policy.maxRetries) {
126
+ return { retry: false, reason: `giving up after ${policy.maxRetries} retries` };
127
+ }
128
+
129
+ const headers = headersOf(error);
130
+ const status = statusOf(error);
131
+
132
+ // The same prompt overflows the same way however often it is sent, even
133
+ // when a relay dresses the rejection up as a 5xx. The loop owns the remedy.
134
+ if (error instanceof Error && isContextOverflow(error.message, status)) {
135
+ return { retry: false, reason: "the prompt exceeds the model's context window" };
136
+ }
137
+
138
+ // A provider that knows whether its own error is worth retrying gets the
139
+ // last word, in both directions.
140
+ // A failure reported inside a 200 stream carries no status. Relays and
141
+ // Anthropic's own `overloaded_error` use it for exactly the conditions a
142
+ // 5xx would signal; anything else in a stream is a real rejection.
143
+ if (status === undefined && isStreamFailure(error)) {
144
+ if (!TRANSIENT_STREAM.test(`${error.code ?? ""} ${error.message}`)) {
145
+ return { retry: false, reason: "the stream reported a non-transient error" };
146
+ }
147
+ const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
148
+ return { retry: true, delayMs: base * (1 - jitter * 0.25) };
149
+ }
150
+
151
+ const advice = headers?.get("x-should-retry");
152
+ let worthRetrying: boolean;
153
+ if (advice === "true") worthRetrying = true;
154
+ else if (advice === "false") worthRetrying = false;
155
+ else if (status !== undefined) worthRetrying = statusIsTransient(status);
156
+ else {
157
+ // No status at all. fetch reports a refused, reset or dropped connection
158
+ // as a TypeError, which is exactly the transient case. Anything else --
159
+ // a TypeError from our own code, a RangeError -- is a bug, and retrying a
160
+ // bug just runs it again.
161
+ worthRetrying = error instanceof TypeError;
162
+ }
163
+
164
+ if (!worthRetrying) {
165
+ return {
166
+ retry: false,
167
+ reason: status === undefined ? "not a retryable failure" : `HTTP ${status} is not retryable`,
168
+ };
169
+ }
170
+
171
+ const requested = serverDelayMs(headers);
172
+ if (requested !== undefined) {
173
+ if (requested > policy.maxRetryDelayMs) {
174
+ return {
175
+ retry: false,
176
+ reason:
177
+ `server asked to wait ${Math.ceil(requested / 1000)}s, ` +
178
+ `longer than the ${Math.ceil(policy.maxRetryDelayMs / 1000)}s cap`,
179
+ };
180
+ }
181
+ return { retry: true, delayMs: requested };
182
+ }
183
+
184
+ // 0.5s doubling to a ceiling of 8s, then flat.
185
+ const base = Math.min(0.5 * 2 ** attempt, 8) * 1000;
186
+ return { retry: true, delayMs: base * (1 - jitter * 0.25) };
187
+ }
188
+
189
+ export interface RetryEffects {
190
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
191
+ jitter(): number;
192
+ }
193
+
194
+ function abortReason(signal: AbortSignal): Error {
195
+ if (signal.reason instanceof Error) return signal.reason;
196
+ return new DOMException("The operation was aborted.", "AbortError");
197
+ }
198
+
199
+ /**
200
+ * A wait the caller can cancel.
201
+ *
202
+ * The SDK's own retry timers ignore AbortSignal, which is why the adapters
203
+ * retry with `maxRetries: 0` and sleep here. A sleep that runs to the end
204
+ * after Escape is the hang the user pressed it to get out of.
205
+ */
206
+ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise<void> {
207
+ if (signal === undefined) {
208
+ return new Promise((resolve) => setTimeout(resolve, ms));
209
+ }
210
+ return new Promise((resolve, reject) => {
211
+ if (signal.aborted) {
212
+ reject(abortReason(signal));
213
+ return;
214
+ }
215
+ const timer = setTimeout(() => {
216
+ signal.removeEventListener("abort", onAbort);
217
+ resolve();
218
+ }, ms);
219
+ const onAbort = () => {
220
+ clearTimeout(timer);
221
+ reject(abortReason(signal));
222
+ };
223
+ signal.addEventListener("abort", onAbort, { once: true });
224
+ });
225
+ }
226
+
227
+ export const DEFAULT_RETRY_EFFECTS: RetryEffects = {
228
+ sleep: interruptibleSleep,
229
+ jitter: () => Math.random(),
230
+ };
231
+
232
+ /**
233
+ * Resolves when `waiting` does, or rejects as soon as the signal fires.
234
+ *
235
+ * Injected sleeps are allowed to ignore the signal -- tests do, to prove
236
+ * this wrapper is what stops the wait. Swallowing the abandoned sleep
237
+ * avoids an unhandled rejection when it later settles.
238
+ */
239
+ function waitOrAbort(waiting: Promise<void>, signal?: AbortSignal): Promise<void> {
240
+ if (signal === undefined) return waiting;
241
+ if (signal.aborted) {
242
+ void waiting.catch(() => {});
243
+ return Promise.reject(abortReason(signal));
244
+ }
245
+ return new Promise<void>((resolve, reject) => {
246
+ const onAbort = () => {
247
+ cleanup();
248
+ void waiting.catch(() => {});
249
+ reject(abortReason(signal));
250
+ };
251
+ const onSettled = (error?: unknown) => {
252
+ cleanup();
253
+ if (error === undefined) resolve();
254
+ else reject(error);
255
+ };
256
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
257
+ signal.addEventListener("abort", onAbort, { once: true });
258
+ waiting.then(() => onSettled(), (error: unknown) => onSettled(error));
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Runs a request, retrying per the policy.
264
+ *
265
+ * `onRetry` exists so the caller can say something. A retry that produces no
266
+ * output is indistinguishable from a hang, which is the problem this was
267
+ * supposed to solve rather than one to introduce.
268
+ *
269
+ * `signal` is per-request, not per-policy: aborting one send must not
270
+ * poison the adapter. An already-aborted signal does not start an attempt,
271
+ * and an abort during backoff does not wait out the delay -- both would
272
+ * turn Escape into "wait, then maybe stop".
273
+ */
274
+ export async function withRetry<T>(
275
+ request: () => Promise<T>,
276
+ policy: RetryPolicy = DEFAULT_RETRY,
277
+ fx: RetryEffects = DEFAULT_RETRY_EFFECTS,
278
+ onRetry?: (attempt: number, delayMs: number, error: unknown) => void,
279
+ signal?: AbortSignal,
280
+ ): Promise<T> {
281
+ const effects = fx ?? DEFAULT_RETRY_EFFECTS;
282
+ for (let attempt = 0; ; attempt++) {
283
+ if (signal?.aborted) throw abortReason(signal);
284
+ try {
285
+ return await request();
286
+ } catch (error) {
287
+ // Checked here rather than inside retryDecision: abort is a user
288
+ // decision, not a status code, and a fetch abort that surfaces as
289
+ // TypeError must not start another wait.
290
+ if (signal?.aborted) throw abortReason(signal);
291
+ const decision = retryDecision(error, attempt, policy, effects.jitter());
292
+ if (!decision.retry) throw error;
293
+ onRetry?.(attempt + 1, decision.delayMs, error);
294
+ await waitOrAbort(effects.sleep(decision.delayMs, signal), signal);
295
+ }
296
+ }
297
+ }