@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,642 @@
1
+ /**
2
+ * The session tree on disk (card: refactor-log-tree).
3
+ *
4
+ * The only module in the project that does I/O, so ordering, durability and
5
+ * corruption are handled here and nowhere else.
6
+ *
7
+ * Layout:
8
+ * log/node-0000.jsonl header line, then one JSON event per line
9
+ * log/handoff-0000.yaml the handoff derived when that node closed
10
+ * log/manifest.json the index: one fixed-size record per closed node
11
+ *
12
+ * NNNN is the node's ALLOCATION order, not its position in the tree. Structure
13
+ * lives only in parent pointers -- the same split git makes between a commit's
14
+ * identity and its shape. A linear session is the degenerate tree where every
15
+ * node has one child, which is why this refactor leaves behaviour unchanged.
16
+ *
17
+ * One file per node, one writer per file. That is what makes parallel branches
18
+ * free of contention: no locks, no interleaved half-written lines, and D14's
19
+ * "a subagent writes its own branch file" becomes automatic rather than an
20
+ * extra mechanism.
21
+ *
22
+ * JSONL rather than SQLite so the agent can recover anything a handoff omits
23
+ * with the tools it already has -- grep, sed, jq -- and no recall tool has to
24
+ * exist. What that gives up is a real index, so cross-node queries by resource
25
+ * are linear scans; per-node files keep each one small enough for that to be
26
+ * fine and, unlike a single rollout file, nothing grows without bound.
27
+ */
28
+
29
+ import { mkdir, readdir, readFile, open } from "node:fs/promises";
30
+ import { createHash } from "node:crypto";
31
+ import { join } from "node:path";
32
+ import type { LogEvent, NodeRef, IsoTime, EventId } from "../core/events.ts";
33
+ import { assertNever } from "../core/exhaustive.ts";
34
+ import type { Handoff } from "../core/handoff.ts";
35
+ import { isLogEvent, isRecord, validNodeRef } from "../core/event-validation.ts";
36
+ import { emitHandoff } from "../core/handoff.ts";
37
+ import { atomicWrite, durableAppend } from "./atomic.ts";
38
+ import { serialized } from "./serial.ts";
39
+
40
+ /** Allocation number. Not an ordering over the tree. */
41
+ export type NodeId = number;
42
+
43
+ /**
44
+ * The first line of every node file.
45
+ *
46
+ * It shares the file with events and is shaped like one, but it is not an
47
+ * event: it describes the node rather than something that happened inside it,
48
+ * so readNode returns it separately and it never reaches the projection.
49
+ */
50
+ /**
51
+ * What a node takes from its parent.
52
+ *
53
+ * inherit the parent's events, so the prefix is shared and the cache with
54
+ * it -- a fork exploring an alternative
55
+ * handoff the parent's derived handoff instead of its events -- a cut,
56
+ * taken when the context filled
57
+ * none nothing; the parent pointer records provenance only -- a
58
+ * subagent working in isolation
59
+ *
60
+ * Declared by the child rather than inferred from the parent, because the
61
+ * same parent can have children of every kind at once. Inferring it from
62
+ * whether the parent had a handoff is right for a linear session and wrong
63
+ * for a fork, whose parent is almost always closed.
64
+ */
65
+ export type Basis = "inherit" | "handoff" | "none";
66
+
67
+ export interface NodeHeader {
68
+ t: "header";
69
+ node: NodeId;
70
+ parent: NodeRef | null;
71
+ basis: Basis;
72
+ created_at: IsoTime;
73
+ adopted_from?: NodeRef;
74
+ }
75
+
76
+ export interface NodeRecord {
77
+ node: NodeId;
78
+ parent: NodeRef | null;
79
+ log_ref: string;
80
+ sha256: string;
81
+ handoff_ref: string | null;
82
+ events: number;
83
+ closed_at: IsoTime;
84
+ }
85
+
86
+ /**
87
+ * Where the session was when it last stopped.
88
+ *
89
+ * Written down rather than inferred. The manifest indexes only CLOSED nodes,
90
+ * so the open one is not in it -- and the tree shape cannot be read backwards
91
+ * to find the cursor, because three legal shapes are indistinguishable: a
92
+ * node abandoned by a rewind stays open, a resolved branch holds the highest
93
+ * id while the cursor sits in its parent, and both can be true at once. This
94
+ * project already settled the same question once, for `basis`, and settled it
95
+ * the same way: declare it instead of deducing it.
96
+ */
97
+ export interface Cursor {
98
+ /** The open node the session was working in. */
99
+ node: NodeId;
100
+ /**
101
+ * The allocator's state, not `max(id) + 1`. Dead limbs and resolved
102
+ * branches both hold ids, and recomputing the next one is exactly the
103
+ * shape of the bug that once had a cut and a branch agree on id 1 and
104
+ * silently truncate a node's whole log.
105
+ */
106
+ next_node: NodeId;
107
+ /**
108
+ * The model this session ran under. Its thinking blocks are that model's:
109
+ * replaying them to another provider is a bad request, so resume checks
110
+ * this rather than discovering it on the wire.
111
+ */
112
+ model: string;
113
+ updated_at: IsoTime;
114
+ /** Active checkout, which changes when a writing branch becomes mainline. */
115
+ workspace?: string;
116
+ /** Content-addressed frozen context; credentials and approvals are excluded. */
117
+ context_ref?: string;
118
+ /**
119
+ * A human name. Optional because most sessions never get one, and a log
120
+ * written before names existed has none. Absent rather than empty: an
121
+ * empty string would still show up in a listing as a name that isn't.
122
+ */
123
+ name?: string;
124
+ }
125
+
126
+ export interface Manifest {
127
+ version: 1;
128
+ nodes: NodeRecord[];
129
+ /** Absent in a log written before resume existed, and in an empty one. */
130
+ cursor?: Cursor;
131
+ }
132
+
133
+ /**
134
+ * A chain longer than this is a bug rather than a deep session: it would mean
135
+ * thousands of boundaries with no progress, which the stop-and-ask rule (D9)
136
+ * catches long before. The cap exists so a malformed tree fails fast instead
137
+ * of consuming the process -- unbounded traversal is not allowed here any more
138
+ * than an unbounded buffer would be.
139
+ */
140
+ export const MAX_ANCESTRY_DEPTH = 1000;
141
+
142
+ const pad = (n: number) => String(n).padStart(4, "0");
143
+
144
+ export function nodePath(dir: string, node: NodeId): string {
145
+ return join(dir, `node-${pad(node)}.jsonl`);
146
+ }
147
+
148
+ export function handoffPath(dir: string, node: NodeId): string {
149
+ return join(dir, `handoff-${pad(node)}.yaml`);
150
+ }
151
+
152
+ export function manifestPath(dir: string): string {
153
+ return join(dir, "manifest.json");
154
+ }
155
+
156
+ export async function initLog(dir: string): Promise<void> {
157
+ await mkdir(dir, { recursive: true });
158
+ }
159
+
160
+ /**
161
+ * Every node in the log, in allocation order.
162
+ *
163
+ * From the directory rather than the manifest: the manifest holds only CLOSED
164
+ * nodes, and an open node -- including the one being written right now -- is
165
+ * exactly the one a reader is most likely to be asking about.
166
+ */
167
+ export async function listNodes(dir: string): Promise<NodeId[]> {
168
+ let names: string[];
169
+ try {
170
+ names = await readdir(dir);
171
+ } catch (err) {
172
+ if ((err as { code?: string }).code === "ENOENT") return [];
173
+ throw err;
174
+ }
175
+ return names
176
+ .map((n) => /^node-(\d+)\.jsonl$/.exec(n))
177
+ .filter((m): m is RegExpExecArray => m !== null)
178
+ .map((m) => Number(m[1]))
179
+ .sort((a, b) => a - b);
180
+ }
181
+
182
+ /** Creates a node file by writing its header. Must precede any appendEvent. */
183
+ export async function initNode(
184
+ dir: string,
185
+ node: NodeId,
186
+ parent: NodeRef | null,
187
+ createdAt: IsoTime,
188
+ // Defaults to inherit, which is the only safe choice for an unstated one:
189
+ // inheriting too much shows the model work it has already seen, while
190
+ // truncating too early silently hides work it needed. A caller that means
191
+ // to cut says so.
192
+ basis: Basis = parent === null ? "none" : "inherit",
193
+ metadata: Pick<NodeHeader, "adopted_from"> = {},
194
+ ): Promise<NodeHeader> {
195
+ const header: NodeHeader = { t: "header", node, parent, basis, created_at: createdAt, ...metadata };
196
+ try {
197
+ // Exclusive create. A node log is append-only, so a second init is always
198
+ // a bug -- and writeFile would truncate, taking every event in the node
199
+ // with it while every ref pointing at it still resolves. Two allocators
200
+ // over one id namespace once did exactly that here, silently.
201
+ const file = await open(nodePath(dir, node), "wx", 0o600);
202
+ try { await file.writeFile(JSON.stringify(header) + "\n"); await file.sync(); }
203
+ finally { await file.close(); }
204
+ const directory = await open(dir, "r");
205
+ try { await directory.sync(); } finally { await directory.close(); }
206
+ } catch (err) {
207
+ if ((err as { code?: string }).code === "EEXIST") {
208
+ throw new Error(`node ${node} already exists at ${nodePath(dir, node)} -- refusing to overwrite it`);
209
+ }
210
+ throw err;
211
+ }
212
+ return header;
213
+ }
214
+
215
+ /**
216
+ * Appends one event as a single line.
217
+ *
218
+ * JSON.stringify never emits a raw newline inside a string, so one event is
219
+ * always exactly one line and a partially written tail can only ever damage
220
+ * the last line -- which readNode reports rather than silently dropping.
221
+ */
222
+ export async function appendEvent(
223
+ dir: string,
224
+ node: NodeId,
225
+ event: LogEvent,
226
+ ): Promise<void> {
227
+ if ((await readManifest(dir)).nodes.some((entry) => entry.node === node)) {
228
+ throw new Error(`INTEGRITY: cannot append to sealed node ${node}`);
229
+ }
230
+ await durableAppend(nodePath(dir, node), JSON.stringify(event) + "\n");
231
+ }
232
+
233
+ export interface ReadResult {
234
+ /** Null when the file has no header line, which is itself reported as corrupt. */
235
+ header: NodeHeader | null;
236
+ events: LogEvent[];
237
+ /** Lines that would not parse. Surfaced, never skipped quietly. */
238
+ corrupt: { line: number; text: string }[];
239
+ }
240
+
241
+ export async function readNode(dir: string, node: NodeId): Promise<ReadResult> {
242
+ let text: string;
243
+ try {
244
+ text = await readFile(nodePath(dir, node), "utf8");
245
+ } catch (err) {
246
+ if ((err as { code?: string }).code === "ENOENT") {
247
+ return { header: null, events: [], corrupt: [] };
248
+ }
249
+ throw err;
250
+ }
251
+
252
+ let header: NodeHeader | null = null;
253
+ const events: LogEvent[] = [];
254
+ const corrupt: ReadResult["corrupt"] = [];
255
+ const lines = text.split("\n");
256
+ const ids = new Set<string>();
257
+
258
+ for (let i = 0; i < lines.length; i++) {
259
+ const line = lines[i]!;
260
+ if (line.length === 0) continue;
261
+ let parsed: unknown;
262
+ try {
263
+ parsed = JSON.parse(line);
264
+ } catch {
265
+ corrupt.push({ line: i + 1, text: line.slice(0, 200) });
266
+ continue;
267
+ }
268
+ // The header describes the node; it is not something that happened in it.
269
+ if (isRecord(parsed) && parsed["t"] === "header") {
270
+ if (i !== 0 || header !== null || parsed["node"] !== node || typeof parsed["created_at"] !== "string" ||
271
+ !(parsed["parent"] === null || validNodeRef(parsed["parent"])) ||
272
+ !(parsed["basis"] === undefined || ["inherit", "none", "handoff"].includes(String(parsed["basis"])))) {
273
+ corrupt.push({ line: i + 1, text: line.slice(0, 200) }); continue;
274
+ }
275
+ header = {
276
+ t: "header", node, parent: parsed["parent"], created_at: parsed["created_at"],
277
+ basis: parsed["basis"] === undefined ? (parsed["parent"] === null ? "none" : "inherit") : parsed["basis"] as Basis,
278
+ ...(validNodeRef(parsed["adopted_from"]) ? { adopted_from: parsed["adopted_from"] } : {}),
279
+ };
280
+ continue;
281
+ }
282
+ if (!isLogEvent(parsed) || ids.has(parsed.id) || i === lines.length - 1) {
283
+ corrupt.push({ line: i + 1, text: line.slice(0, 200) }); continue;
284
+ }
285
+ ids.add(parsed.id);
286
+ events.push(parsed);
287
+ }
288
+
289
+ return { header, events, corrupt };
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Ancestry
294
+ // ---------------------------------------------------------------------------
295
+
296
+ export interface AncestryError {
297
+ code:
298
+ | "CYCLE"
299
+ | "MISSING_PARENT"
300
+ | "TOO_DEEP"
301
+ | "MISSING_HEADER"
302
+ | "CORRUPT"
303
+ | "INTEGRITY"
304
+ | "MISSING_BASIS_HANDOFF"
305
+ | "MISSING_FORK_POINT";
306
+ detail: string;
307
+ }
308
+
309
+ export type AncestryResult =
310
+ | {
311
+ ok: true;
312
+ /**
313
+ * The ancestor whose handoff is the context basis, set when a node on
314
+ * the path declared `basis: "handoff"`. Its events do not replay. Null
315
+ * when the walk reached a root or a `none` boundary instead.
316
+ */
317
+ basis: { node: NodeId; handoff_ref: string } | null;
318
+ /** Root-to-leaf order, ready to hand to projectContext. */
319
+ events: LogEvent[];
320
+ /** Nodes contributing events, root-to-leaf. Excludes the basis node. */
321
+ visited: NodeId[];
322
+ }
323
+ | { ok: false; error: AncestryError };
324
+
325
+ /**
326
+ * Walks the parent chain from `node` upward, collecting events, and stops at
327
+ * the first ancestor that closed with a handoff.
328
+ *
329
+ * Returns an error rather than partial results: a chain that cannot be walked
330
+ * means the caller's picture of the tree is wrong, and half an ancestry would
331
+ * project a context that silently omits work.
332
+ */
333
+ export async function readAncestry(
334
+ dir: string,
335
+ node: NodeId,
336
+ manifest?: Manifest,
337
+ ): Promise<AncestryResult> {
338
+ const index = new Map(((manifest ?? await readManifest(dir)).nodes).map((r) => [r.node, r]));
339
+ const chain: { node: NodeId; events: LogEvent[] }[] = [];
340
+ // Revisiting a node means the parent pointers form a loop. The depth cap
341
+ // alone would stop the walk, but it would report exhaustion -- a reader
342
+ // needs to know the tree is malformed, not that the chain was long.
343
+ const seen = new Set<NodeId>();
344
+
345
+ let current: NodeId | null = node;
346
+ // Set when a child declared a fork point: the parent contributes only the
347
+ // events up to and including that one.
348
+ let cutoff: EventId | undefined;
349
+ let depth = 0;
350
+ let basis: { node: NodeId; handoff_ref: string } | null = null;
351
+
352
+ while (current !== null) {
353
+ if (seen.has(current)) {
354
+ return {
355
+ ok: false,
356
+ error: {
357
+ code: "CYCLE",
358
+ detail:
359
+ `parent chain from node ${node} revisits node ${current} ` +
360
+ `(walked ${[...seen].join(" -> ")})`,
361
+ },
362
+ };
363
+ }
364
+ seen.add(current);
365
+
366
+ if (depth++ >= MAX_ANCESTRY_DEPTH) {
367
+ return {
368
+ ok: false,
369
+ error: {
370
+ code: "TOO_DEEP",
371
+ detail: `ancestry of node ${node} exceeded ${MAX_ANCESTRY_DEPTH} nodes`,
372
+ },
373
+ };
374
+ }
375
+
376
+ const { header, events, corrupt } = await readNode(dir, current);
377
+ const sealed = index.get(current);
378
+ if (sealed && sealed.sha256 !== await digestNode(dir, current)) {
379
+ return { ok: false, error: { code: "INTEGRITY", detail: `node ${current} content digest does not match its sealed record` } };
380
+ }
381
+ if (header === null) {
382
+ return {
383
+ ok: false,
384
+ error: {
385
+ code: "MISSING_HEADER",
386
+ detail: `node ${current} has no header line (reached from ${node})`,
387
+ },
388
+ };
389
+ }
390
+ if (corrupt.length > 0) {
391
+ return {
392
+ ok: false,
393
+ error: {
394
+ code: "CORRUPT",
395
+ detail: `node ${current} has ${corrupt.length} unparsable line(s), first at ${corrupt[0]!.line}`,
396
+ },
397
+ };
398
+ }
399
+
400
+ // A fork point truncates this node's contribution. Naming an event the
401
+ // node does not have is an error rather than a silent fall back to all of
402
+ // them: the caller's picture of the tree is wrong either way, and half an
403
+ // ancestry projects a context that quietly omits work.
404
+ let contributed = events;
405
+ if (cutoff !== undefined) {
406
+ const at = events.findIndex((e) => e.id === cutoff);
407
+ if (at < 0) {
408
+ return {
409
+ ok: false,
410
+ error: {
411
+ code: "MISSING_FORK_POINT",
412
+ detail: `node ${current} has no event ${cutoff} (fork point reached from ${node})`,
413
+ },
414
+ };
415
+ }
416
+ contributed = events.slice(0, at + 1);
417
+ }
418
+ chain.push({ node: current, events: contributed });
419
+
420
+ const parent: NodeRef | null = header.parent;
421
+ if (parent === null) break;
422
+
423
+ // What this node takes from its parent is declared here, not inferred
424
+ // from the parent. The same parent can have a fork, a cut and a subagent
425
+ // as children at once, so no property of the parent could decide it.
426
+ switch (header.basis) {
427
+ case "none":
428
+ // The parent pointer is provenance; it contributes no context.
429
+ current = null;
430
+ break;
431
+ case "handoff": {
432
+ const record = index.get(parent.node);
433
+ if (!record?.handoff_ref) {
434
+ return {
435
+ ok: false,
436
+ error: {
437
+ code: "MISSING_BASIS_HANDOFF",
438
+ detail:
439
+ `node ${current} declares a handoff basis but node ${parent.node} has none ` +
440
+ `(it may not be closed yet, or the manifest was not passed)`,
441
+ },
442
+ };
443
+ }
444
+ basis = { node: parent.node, handoff_ref: record.handoff_ref };
445
+ current = null;
446
+ break;
447
+ }
448
+ case "inherit":
449
+ cutoff = parent.at_event;
450
+ current = parent.node;
451
+ break;
452
+ default:
453
+ return assertNever(header.basis, "readAncestry");
454
+ }
455
+ }
456
+
457
+ chain.reverse();
458
+ return {
459
+ ok: true,
460
+ basis,
461
+ events: chain.flatMap((c) => c.events),
462
+ visited: chain.map((c) => c.node),
463
+ };
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Digest, handoff, manifest
468
+ // ---------------------------------------------------------------------------
469
+
470
+ export async function digestNode(dir: string, node: NodeId): Promise<string> {
471
+ try {
472
+ const buf = await readFile(nodePath(dir, node));
473
+ return createHash("sha256").update(buf).digest("hex");
474
+ } catch (err) {
475
+ if ((err as { code?: string }).code === "ENOENT") {
476
+ return createHash("sha256").update("").digest("hex");
477
+ }
478
+ throw err;
479
+ }
480
+ }
481
+
482
+ export async function writeHandoff(
483
+ dir: string,
484
+ node: NodeId,
485
+ handoff: Handoff,
486
+ ): Promise<string> {
487
+ const text = emitHandoff(handoff);
488
+ await atomicWrite(handoffPath(dir, node), text);
489
+ return text;
490
+ }
491
+
492
+ async function readLegacyManifest(dir: string): Promise<Manifest> {
493
+ try {
494
+ return JSON.parse(await readFile(manifestPath(dir), "utf8")) as Manifest;
495
+ } catch (err) {
496
+ if ((err as { code?: string }).code === "ENOENT") return { version: 1, nodes: [] };
497
+ throw err;
498
+ }
499
+ }
500
+
501
+ type MetadataRecord =
502
+ | { kind: "seed"; manifest: Manifest }
503
+ | { kind: "cursor"; cursor: Cursor }
504
+ | { kind: "node"; record: NodeRecord };
505
+
506
+ function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
507
+
508
+ async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
509
+ let text: string;
510
+ try { text = await readFile(metadataPath(dir), "utf8"); }
511
+ catch (error) {
512
+ if ((error as { code?: string }).code === "ENOENT") return null;
513
+ throw error;
514
+ }
515
+ if (text.length > 0 && !text.endsWith("\n")) throw new Error("INTEGRITY: incomplete metadata journal tail; no cursor is guessed");
516
+ return text.split("\n").filter(Boolean).map((line) => {
517
+ const envelope = JSON.parse(line) as { record: MetadataRecord; sha256: string };
518
+ const digest = createHash("sha256").update(JSON.stringify(envelope.record)).digest("hex");
519
+ if (envelope.sha256 !== digest) throw new Error("INTEGRITY: metadata journal digest mismatch");
520
+ return envelope.record;
521
+ });
522
+ }
523
+
524
+ /** D3: the replaceable manifest is a projection of committed metadata events. */
525
+ export async function readManifest(dir: string): Promise<Manifest> {
526
+ return serialized(dir, () => readManifestUnlocked(dir));
527
+ }
528
+
529
+ async function readManifestUnlocked(dir: string): Promise<Manifest> {
530
+ const records = await readMetadata(dir);
531
+ if (records === null) return readLegacyManifest(dir);
532
+ let manifest: Manifest = { version: 1, nodes: [] };
533
+ for (const event of records) {
534
+ switch (event.kind) {
535
+ case "seed": manifest = structuredClone(event.manifest); break;
536
+ case "cursor": manifest.cursor = event.cursor; break;
537
+ case "node": {
538
+ const i = manifest.nodes.findIndex((n) => n.node === event.record.node);
539
+ if (i < 0) manifest.nodes.push(event.record);
540
+ else manifest.nodes[i] = event.record;
541
+ break;
542
+ }
543
+ default: return assertNever(event, "metadata journal");
544
+ }
545
+ }
546
+ manifest.nodes.sort((a, b) => a.node - b.node);
547
+ return manifest;
548
+ }
549
+
550
+ async function appendMetadata(dir: string, event: MetadataRecord): Promise<void> {
551
+ await mkdir(dir, { recursive: true });
552
+ const encode = (record: MetadataRecord) => JSON.stringify({
553
+ record, sha256: createHash("sha256").update(JSON.stringify(record)).digest("hex"),
554
+ }) + "\n";
555
+ if (await readMetadata(dir) === null) {
556
+ // Preserve old manifests once, then make every subsequent move append-only.
557
+ const seed: MetadataRecord = { kind: "seed", manifest: await readLegacyManifest(dir) };
558
+ await durableAppend(metadataPath(dir), encode(seed));
559
+ }
560
+ await durableAppend(metadataPath(dir), encode(event));
561
+ }
562
+
563
+ /**
564
+ * Moves the cursor. Called at every point the session changes node, so that
565
+ * "where was I" is a recorded fact rather than a reconstruction.
566
+ *
567
+ * Rewrites the whole manifest, like `recordNode`, so the index and the cursor
568
+ * cannot disagree -- two files could say node 7 is both closed and current.
569
+ *
570
+ * A name already on the cursor is kept unless the caller set one. The loop
571
+ * writes the cursor at every move and does not know about names; replacing
572
+ * the whole record would drop a name the moment a session cuts or starts,
573
+ * making `/name` a lie.
574
+ */
575
+ export async function writeCursor(dir: string, cursor: Cursor): Promise<void> {
576
+ return serialized(dir, async () => {
577
+ const manifest = await readManifestUnlocked(dir);
578
+ const previousName = manifest.cursor?.name;
579
+ manifest.cursor =
580
+ previousName !== undefined && !("name" in cursor)
581
+ ? { ...cursor, name: previousName }
582
+ : cursor;
583
+ await appendMetadata(dir, { kind: "cursor", cursor: manifest.cursor });
584
+ await atomicWrite(manifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
585
+ });
586
+ }
587
+
588
+ export async function readCursor(dir: string): Promise<Cursor | null> {
589
+ return (await readManifest(dir)).cursor ?? null;
590
+ }
591
+
592
+ /** Direct children of `node`, in allocation order. */
593
+ export function childrenOf(manifest: Manifest, node: NodeId): NodeRecord[] {
594
+ return manifest.nodes.filter((r) => r.parent?.node === node);
595
+ }
596
+
597
+ /** Records with no parent. A well-formed tree has exactly one. */
598
+ export function rootsOf(manifest: Manifest): NodeRecord[] {
599
+ return manifest.nodes.filter((r) => r.parent === null);
600
+ }
601
+
602
+ /**
603
+ * Records a closed node. Rewrites the manifest whole, which is fine because it
604
+ * holds one small record per node -- and makes the file atomically consistent
605
+ * rather than an append log that can end mid-record.
606
+ */
607
+ export async function recordNode(dir: string, record: NodeRecord): Promise<Manifest> {
608
+ return serialized(dir, async () => {
609
+ const manifest = await readManifestUnlocked(dir);
610
+ const existing = manifest.nodes.findIndex((r) => r.node === record.node);
611
+ if (existing >= 0) manifest.nodes[existing] = record;
612
+ else manifest.nodes.push(record);
613
+ manifest.nodes.sort((a, b) => a.node - b.node);
614
+ await appendMetadata(dir, { kind: "node", record });
615
+ await atomicWrite(manifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
616
+ return manifest;
617
+ });
618
+ }
619
+
620
+ /** Closes a node: fingerprint it, write its handoff, index it. */
621
+ export async function closeNode(
622
+ dir: string,
623
+ node: NodeId,
624
+ handoff: Handoff,
625
+ eventCount: number,
626
+ closedAt: IsoTime,
627
+ ): Promise<NodeRecord> {
628
+ const { header } = await readNode(dir, node);
629
+ const sha256 = await digestNode(dir, node);
630
+ await writeHandoff(dir, node, handoff);
631
+ const record: NodeRecord = {
632
+ node,
633
+ parent: header?.parent ?? null,
634
+ log_ref: nodePath(dir, node),
635
+ sha256,
636
+ handoff_ref: handoffPath(dir, node),
637
+ events: eventCount,
638
+ closed_at: closedAt,
639
+ };
640
+ await recordNode(dir, record);
641
+ return record;
642
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The on-disk catalog overlay. `~/.xeno/models.json`, next to auth.json.
3
+ *
4
+ * This layer only reads and writes bytes. It does not know what a ModelSpec
5
+ * is — that lives in runtime — because store may not import runtime. A
6
+ * corrupt file is returned as text plus no error: the parser names the bad
7
+ * entry, and discarding the whole file here would be the thing the card
8
+ * forbids.
9
+ */
10
+
11
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { configDir } from "./config.ts";
14
+
15
+ export function modelsPath(): string {
16
+ return join(configDir(), "models.json");
17
+ }
18
+
19
+ export async function readModelsText(): Promise<{ text: string | null; error: string | null }> {
20
+ try {
21
+ const text = await readFile(modelsPath(), "utf8");
22
+ return { text, error: null };
23
+ } catch {
24
+ // Absent is the normal first-run state, not an error.
25
+ return { text: null, error: null };
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Replace, don't truncate. writeFile on the live path empties it first, so a
31
+ * crash or a thrown write would leave the user with no catalog. Sidecar then
32
+ * rename is atomic on the same filesystem; a failed refresh must leave the
33
+ * existing bytes untouched (feat-json-mode-and-header).
34
+ */
35
+ export async function writeModelsFile(value: unknown): Promise<void> {
36
+ await mkdir(configDir(), { recursive: true });
37
+ const dest = modelsPath();
38
+ const tmp = `${dest}.tmp`;
39
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
40
+ await rename(tmp, dest);
41
+ }