@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.
- package/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** Store complete observations while keeping only a bounded preview in memory. */
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open, mkdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import type { ArtifactRef } from "../core/events.ts";
|
|
7
|
+
import { blobFilePath, verifyBlob } from "./blobs.ts";
|
|
8
|
+
|
|
9
|
+
/** Byte pages keep very large single-line observations retrievable as well. */
|
|
10
|
+
export async function readObservation(dir: string, sha256: string, offset: number, limit: number): Promise<{
|
|
11
|
+
sha256: string; bytes: number; offset: number; next: number | null; text: string; base64: string;
|
|
12
|
+
}> {
|
|
13
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1) throw new Error("invalid observation page");
|
|
14
|
+
if (!await verifyBlob(dir, sha256)) throw new Error(`missing observation ${sha256}`);
|
|
15
|
+
const file = await open(blobFilePath(dir, sha256), "r");
|
|
16
|
+
try {
|
|
17
|
+
const bytes = (await file.stat()).size;
|
|
18
|
+
const buffer = Buffer.alloc(Math.min(limit, Math.max(0, bytes - offset)));
|
|
19
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, offset);
|
|
20
|
+
const data = buffer.subarray(0, bytesRead);
|
|
21
|
+
return { sha256, bytes, offset, next: offset + bytesRead < bytes ? offset + bytesRead : null,
|
|
22
|
+
text: data.toString("utf8"), base64: data.toString("base64") };
|
|
23
|
+
} finally { await file.close(); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* After the shell exits, how long silence must last before capture stops.
|
|
28
|
+
*
|
|
29
|
+
* A background child (`server &`) inherits the pipes, so waiting for EOF
|
|
30
|
+
* waits for the server -- the call then blocks until the timeout and the
|
|
31
|
+
* timeout kills the server with the group. Operational choice, not measured:
|
|
32
|
+
* long enough for buffered output of the exited shell to arrive, short
|
|
33
|
+
* enough that a background start costs nothing noticeable. pi uses the same
|
|
34
|
+
* idle-grace shape (`utils/child-process.ts`).
|
|
35
|
+
*/
|
|
36
|
+
const EXIT_IDLE_MS = 100;
|
|
37
|
+
/** Hard bound after exit for a background child that never goes quiet. */
|
|
38
|
+
const EXIT_DRAIN_MS = 1_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Both streams into one file in arrival order.
|
|
42
|
+
*
|
|
43
|
+
* A build that prints progress on stdout and the error on stderr only makes
|
|
44
|
+
* sense interleaved; appending all of stderr after all of stdout (the previous
|
|
45
|
+
* shape) separates each error from the step that produced it. Writes are
|
|
46
|
+
* serialized on one handle, so interleaving happens at chunk granularity --
|
|
47
|
+
* the same granularity a terminal shows.
|
|
48
|
+
*
|
|
49
|
+
* Capture ends at EOF on both streams, or once `exited` has resolved and the
|
|
50
|
+
* streams have gone idle. Ending early cancels the readers only; it signals
|
|
51
|
+
* no process, so a detached background child keeps running.
|
|
52
|
+
*/
|
|
53
|
+
async function spoolMerged(
|
|
54
|
+
dir: string, streams: readonly ReadableStream<Uint8Array>[], exited: Promise<number>,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
const path = join(dir, `.${randomUUID()}.stream`);
|
|
58
|
+
const file = await open(path, "wx", 0o600);
|
|
59
|
+
const readers = streams.map((stream) => stream.getReader());
|
|
60
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
61
|
+
let exitedAt: number | undefined;
|
|
62
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let drain: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
const stop = () => { for (const reader of readers) reader.cancel().catch(() => {}); };
|
|
65
|
+
const armIdle = () => {
|
|
66
|
+
if (exitedAt === undefined) return;
|
|
67
|
+
clearTimeout(idle);
|
|
68
|
+
idle = setTimeout(stop, EXIT_IDLE_MS);
|
|
69
|
+
};
|
|
70
|
+
void exited.then(() => { exitedAt = performance.now(); armIdle(); drain = setTimeout(stop, EXIT_DRAIN_MS); }, () => {});
|
|
71
|
+
try {
|
|
72
|
+
const drained = await Promise.allSettled(readers.map(async (reader) => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const result = await reader.read();
|
|
75
|
+
if (result.done) return;
|
|
76
|
+
armIdle();
|
|
77
|
+
queue = queue.then(() => file.writeFile(result.value));
|
|
78
|
+
await queue;
|
|
79
|
+
}
|
|
80
|
+
}));
|
|
81
|
+
await queue;
|
|
82
|
+
for (const result of drained) if (result.status === "rejected") throw result.reason;
|
|
83
|
+
} catch (error) { await file.close(); await rm(path, { force: true }); throw error; }
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(idle); clearTimeout(drain);
|
|
86
|
+
for (const reader of readers) reader.releaseLock();
|
|
87
|
+
}
|
|
88
|
+
await file.close();
|
|
89
|
+
return path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Last `limit` bytes of a stream, in O(limit) memory however long it runs. */
|
|
93
|
+
class TailWindow {
|
|
94
|
+
private readonly parts: Uint8Array[] = [];
|
|
95
|
+
private size = 0;
|
|
96
|
+
private readonly limit: number;
|
|
97
|
+
constructor(limit: number) { this.limit = limit; }
|
|
98
|
+
push(chunk: Uint8Array): void {
|
|
99
|
+
if (this.limit === 0) return;
|
|
100
|
+
const part = chunk.byteLength > this.limit ? chunk.slice(chunk.byteLength - this.limit) : chunk.slice();
|
|
101
|
+
this.parts.push(part);
|
|
102
|
+
this.size += part.byteLength;
|
|
103
|
+
while (this.size - this.parts[0]!.byteLength >= this.limit) this.size -= this.parts.shift()!.byteLength;
|
|
104
|
+
}
|
|
105
|
+
bytes(): Uint8Array {
|
|
106
|
+
const all = Buffer.concat(this.parts);
|
|
107
|
+
return all.subarray(Math.max(0, all.byteLength - this.limit));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ArchivedObservation {
|
|
112
|
+
ref: ArtifactRef;
|
|
113
|
+
/** First `previewBytes` bytes, decoded; kept for callers that only page from the start. */
|
|
114
|
+
preview: string;
|
|
115
|
+
/** First and last `previewBytes` bytes, for a head-and-tail view. */
|
|
116
|
+
head: Uint8Array;
|
|
117
|
+
tail: Uint8Array;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function archiveObservation(
|
|
121
|
+
dir: string, source: AsyncIterable<Uint8Array>, previewBytes: number,
|
|
122
|
+
): Promise<ArchivedObservation> {
|
|
123
|
+
await mkdir(dir, { recursive: true });
|
|
124
|
+
const path = join(dir, `.${randomUUID()}.observation`);
|
|
125
|
+
const file = await open(path, "wx", 0o600);
|
|
126
|
+
const hash = createHash("sha256");
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
let kept = 0;
|
|
129
|
+
const preview: Uint8Array[] = [];
|
|
130
|
+
const tail = new TailWindow(previewBytes);
|
|
131
|
+
try {
|
|
132
|
+
try {
|
|
133
|
+
for await (const chunk of source) {
|
|
134
|
+
hash.update(chunk);
|
|
135
|
+
bytes += chunk.byteLength;
|
|
136
|
+
if (kept < previewBytes) {
|
|
137
|
+
const part = chunk.slice(0, previewBytes - kept);
|
|
138
|
+
preview.push(part); kept += part.byteLength;
|
|
139
|
+
}
|
|
140
|
+
tail.push(chunk);
|
|
141
|
+
await file.writeFile(chunk);
|
|
142
|
+
}
|
|
143
|
+
await file.sync();
|
|
144
|
+
} finally { await file.close(); }
|
|
145
|
+
const sha256 = hash.digest("hex");
|
|
146
|
+
const destination = blobFilePath(dir, sha256);
|
|
147
|
+
await mkdir(join(dir, sha256.slice(0, 2)), { recursive: true });
|
|
148
|
+
await rename(path, destination);
|
|
149
|
+
const shard = await open(join(dir, sha256.slice(0, 2)), "r");
|
|
150
|
+
try { await shard.sync(); } finally { await shard.close(); }
|
|
151
|
+
const head = Buffer.concat(preview);
|
|
152
|
+
return {
|
|
153
|
+
ref: { sha256, path: destination, bytes },
|
|
154
|
+
preview: new TextDecoder().decode(head, { stream: kept < bytes }),
|
|
155
|
+
head, tail: tail.bytes(),
|
|
156
|
+
};
|
|
157
|
+
} finally { await rm(path, { force: true }); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The artifact is `exit N\n` followed by the merged output. Nothing that varies
|
|
162
|
+
* run to run (wall time) belongs here: the artifact digest is the D9 novelty
|
|
163
|
+
* key, and a timestamp in it would make every rerun look like new evidence.
|
|
164
|
+
*/
|
|
165
|
+
export async function captureCommandObservation(
|
|
166
|
+
dir: string, stdout: ReadableStream<Uint8Array>, stderr: ReadableStream<Uint8Array>,
|
|
167
|
+
exited: Promise<number>, previewBytes: number,
|
|
168
|
+
): Promise<ArchivedObservation & { exit: number; prefixBytes: number }> {
|
|
169
|
+
const merged = await spoolMerged(dir, [stdout, stderr], exited);
|
|
170
|
+
try {
|
|
171
|
+
const exit = await exited;
|
|
172
|
+
const prefix = new TextEncoder().encode(`exit ${exit}\n`);
|
|
173
|
+
async function* combined(): AsyncGenerator<Uint8Array> {
|
|
174
|
+
yield prefix;
|
|
175
|
+
for await (const chunk of createReadStream(merged)) yield chunk as Uint8Array;
|
|
176
|
+
}
|
|
177
|
+
return { ...await archiveObservation(dir, combined(), previewBytes), exit, prefixBytes: prefix.byteLength };
|
|
178
|
+
} finally {
|
|
179
|
+
await rm(merged, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** N.3: bind a complete calculation input to confined files and content digests. */
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// Assumed working-memory safety budget for the synchronous pure query engine.
|
|
8
|
+
// Exceeding it is an explicit refusal, never a truncated calculation.
|
|
9
|
+
export const MAX_QUERY_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
export async function readQuerySources(root: string, paths: unknown): Promise<{
|
|
11
|
+
text: string; sources: { path: string; sha256: string; bytes: number }[];
|
|
12
|
+
}> {
|
|
13
|
+
if (!Array.isArray(paths) || paths.length === 0 || paths.length > 1024 || paths.some((p) => typeof p !== "string" || !p)) throw new Error("query paths must contain 1..1024 file paths in concatenation order");
|
|
14
|
+
const sources: { path: string; sha256: string; bytes: number }[] = [];
|
|
15
|
+
const chunks: Uint8Array[] = [];
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const path of paths as string[]) {
|
|
18
|
+
const absolute = resolve(root, path), rel = relative(root, absolute);
|
|
19
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("query path escapes workspace");
|
|
20
|
+
let current = resolve(root);
|
|
21
|
+
for (const part of rel.split(sep)) {
|
|
22
|
+
current = resolve(current, part);
|
|
23
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error("query does not follow symbolic links");
|
|
24
|
+
}
|
|
25
|
+
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
try {
|
|
27
|
+
const info = await file.stat();
|
|
28
|
+
if (!info.isFile()) throw new Error("query source must be a regular file");
|
|
29
|
+
if (bytes + info.size > MAX_QUERY_SOURCE_BYTES) throw new Error("query source exceeds 32 MiB resource budget; use a smaller input");
|
|
30
|
+
const hash = createHash("sha256");
|
|
31
|
+
let sourceBytes = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const buffer = Buffer.alloc(Math.min(65536, MAX_QUERY_SOURCE_BYTES - bytes + 1));
|
|
34
|
+
const result = await file.read(buffer, 0, buffer.length, null);
|
|
35
|
+
if (result.bytesRead === 0) break;
|
|
36
|
+
bytes += result.bytesRead; sourceBytes += result.bytesRead;
|
|
37
|
+
if (bytes > MAX_QUERY_SOURCE_BYTES) throw new Error("query source grew past resource budget");
|
|
38
|
+
const data = buffer.subarray(0, result.bytesRead); hash.update(data); chunks.push(data);
|
|
39
|
+
}
|
|
40
|
+
sources.push({ path: rel.split(sep).join("/"), sha256: hash.digest("hex"), bytes: sourceBytes });
|
|
41
|
+
} finally { await file.close(); }
|
|
42
|
+
}
|
|
43
|
+
return { text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)), sources };
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Complete an explicitly logged cut; never infer a user's chosen branch. */
|
|
2
|
+
import type { Cursor } from "./log.ts";
|
|
3
|
+
import { closeNode, digestNode, initNode, listNodes, nodePath, readNode, readManifest, writeCursor } from "./log.ts";
|
|
4
|
+
import { replayContext } from "./replay.ts";
|
|
5
|
+
import { deriveHandoff } from "../core/derive.ts";
|
|
6
|
+
import { boundHandoff } from "../core/history.ts";
|
|
7
|
+
|
|
8
|
+
export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
|
|
9
|
+
const source = await readNode(dir, cursor.node);
|
|
10
|
+
const cut = source.events.at(-1);
|
|
11
|
+
if (cut?.t !== "cut" || cut.next_node === undefined) return cursor;
|
|
12
|
+
const rebuilt = await replayContext(dir, cursor.node);
|
|
13
|
+
const manifest = await readManifest(dir);
|
|
14
|
+
if (!manifest.nodes.some((n) => n.node === cursor.node)) {
|
|
15
|
+
const handoff = deriveHandoff({
|
|
16
|
+
prev: rebuilt.fullHandoff, depth: rebuilt.depth, events: rebuilt.events,
|
|
17
|
+
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
18
|
+
self: { node: cursor.node, log_ref: nodePath(dir, cursor.node), sha256: await digestNode(dir, cursor.node) },
|
|
19
|
+
});
|
|
20
|
+
await closeNode(dir, cursor.node,
|
|
21
|
+
cut.handoff_tokens === undefined ? handoff : boundHandoff(handoff, cut.handoff_tokens), source.events.length, cut.at);
|
|
22
|
+
}
|
|
23
|
+
const successor = await readNode(dir, cut.next_node);
|
|
24
|
+
if (successor.header === null) {
|
|
25
|
+
await initNode(dir, cut.next_node, {
|
|
26
|
+
node: cursor.node, log_ref: nodePath(dir, cursor.node), sha256: await digestNode(dir, cursor.node),
|
|
27
|
+
}, cut.at, "handoff");
|
|
28
|
+
} else if (successor.header.parent?.node !== cursor.node || successor.header.basis !== "handoff") {
|
|
29
|
+
throw new Error(`INTEGRITY: cut successor ${cut.next_node} belongs to another transition`);
|
|
30
|
+
}
|
|
31
|
+
const nodes = await listNodes(dir);
|
|
32
|
+
const next: Cursor = {
|
|
33
|
+
...cursor, node: cut.next_node,
|
|
34
|
+
next_node: Math.max(cursor.next_node, cut.next_node + 1, ...nodes.map((n) => n + 1)),
|
|
35
|
+
model: cut.next_model ?? cursor.model, updated_at: cut.at,
|
|
36
|
+
};
|
|
37
|
+
await writeCursor(dir, next);
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuild the effective context, including every preceding handoff boundary.
|
|
3
|
+
*
|
|
4
|
+
* D3/G.5: physical node files are storage units, not context units. Resume,
|
|
5
|
+
* rewind and cut must agree on inherited events and accumulated state.
|
|
6
|
+
*/
|
|
7
|
+
import { deriveHandoff } from "../core/derive.ts";
|
|
8
|
+
import type { Handoff } from "../core/handoff.ts";
|
|
9
|
+
import type { LogEvent } from "../core/events.ts";
|
|
10
|
+
import { boundHandoff } from "../core/history.ts";
|
|
11
|
+
import { retainedContext } from "../core/context-window.ts";
|
|
12
|
+
import { digestNode, nodePath, readAncestry, readManifest, type Manifest } from "./log.ts";
|
|
13
|
+
|
|
14
|
+
export interface ReplayedContext {
|
|
15
|
+
/** Events not already included in fullHandoff; use these for derivation. */
|
|
16
|
+
events: LogEvent[];
|
|
17
|
+
/** Model view also includes explicitly retained original exchanges. */
|
|
18
|
+
contextEvents: LogEvent[];
|
|
19
|
+
handoff: Handoff | null;
|
|
20
|
+
/** Full derivation state; only the bounded handoff is sent to the model. */
|
|
21
|
+
fullHandoff: Handoff | null;
|
|
22
|
+
depth: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function replayContext(
|
|
26
|
+
dir: string, node: number, manifest?: Manifest,
|
|
27
|
+
): Promise<ReplayedContext> {
|
|
28
|
+
const index = manifest ?? await readManifest(dir);
|
|
29
|
+
const seen = new Set<number>();
|
|
30
|
+
// Iterative even across handoff boundaries: a valid long session must not
|
|
31
|
+
// consume the JS call stack just because it has many compacted segments.
|
|
32
|
+
const parts: { node: number; events: LogEvent[]; depth: number }[] = [];
|
|
33
|
+
let current: number | null = node;
|
|
34
|
+
while (current !== null) {
|
|
35
|
+
if (seen.has(current)) throw new Error(`CYCLE: handoff ancestry revisits node ${current}`);
|
|
36
|
+
seen.add(current);
|
|
37
|
+
const walk = await readAncestry(dir, current, index);
|
|
38
|
+
if (!walk.ok) throw new Error(`${walk.error.code}: ${walk.error.detail}`);
|
|
39
|
+
parts.push({ node: current, events: walk.events, depth: walk.visited.length });
|
|
40
|
+
current = walk.basis?.node ?? null;
|
|
41
|
+
}
|
|
42
|
+
let handoff: Handoff | null = null;
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let carried: Handoff | null = null;
|
|
45
|
+
let retained: LogEvent[] = [];
|
|
46
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
47
|
+
const part = parts[i]!;
|
|
48
|
+
depth += part.depth;
|
|
49
|
+
const contextEvents = [...retained, ...part.events];
|
|
50
|
+
if (i === 0) return { handoff: carried, fullHandoff: handoff, depth, events: part.events, contextEvents };
|
|
51
|
+
handoff = deriveHandoff({
|
|
52
|
+
prev: handoff, depth, events: part.events,
|
|
53
|
+
config: { maxUserTurns: Infinity, maxUserTurnBytes: Infinity },
|
|
54
|
+
self: { node: part.node, log_ref: nodePath(dir, part.node), sha256: await digestNode(dir, part.node) },
|
|
55
|
+
});
|
|
56
|
+
const tail = part.events.at(-1);
|
|
57
|
+
carried = tail?.t === "cut" && tail.handoff_tokens !== undefined
|
|
58
|
+
? boundHandoff(handoff, tail.handoff_tokens) : handoff;
|
|
59
|
+
retained = tail?.t === "cut" && tail.retained_from !== undefined
|
|
60
|
+
? retainedContext(contextEvents, tail.retained_from) : [];
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`MISSING_HEADER: no context for node ${node}`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover and read UI resources: settings, theme, keybindings, skills,
|
|
3
|
+
* prompt templates.
|
|
4
|
+
*
|
|
5
|
+
* I/O lives here; validation is core. A corrupt file is a diagnostic plus
|
|
6
|
+
* a fallback, never a throw -- startup has to survive a missing comma.
|
|
7
|
+
*
|
|
8
|
+
* Project-level files follow the same trust rule as `.xeno/settings.json`.
|
|
9
|
+
* Theme and keymap are UI state and are not logged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
13
|
+
import type { Dirent } from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { configDir, readJsonFile, writeJsonFile, settingsPath } from "./config.ts";
|
|
16
|
+
import { projectSettingsPath } from "./trust.ts";
|
|
17
|
+
import { parseSkill, type Skill } from "../core/skills.ts";
|
|
18
|
+
import { parsePromptTemplate, type PromptTemplate } from "../core/prompts.ts";
|
|
19
|
+
import {
|
|
20
|
+
builtinTheme,
|
|
21
|
+
parseTheme,
|
|
22
|
+
type Theme,
|
|
23
|
+
} from "../core/theme.ts";
|
|
24
|
+
import {
|
|
25
|
+
defaultKeymap,
|
|
26
|
+
parseKeymap,
|
|
27
|
+
type Keymap,
|
|
28
|
+
} from "../core/keymap.ts";
|
|
29
|
+
import {
|
|
30
|
+
defaultSettings,
|
|
31
|
+
explicitSettings,
|
|
32
|
+
mergeSettings,
|
|
33
|
+
parseSettings,
|
|
34
|
+
settingsToWrite,
|
|
35
|
+
type Settings,
|
|
36
|
+
} from "../core/settings.ts";
|
|
37
|
+
|
|
38
|
+
export interface LoadUiOptions {
|
|
39
|
+
readonly workspace: string;
|
|
40
|
+
readonly trusted: boolean;
|
|
41
|
+
/** `--theme <name|path>`, wins over settings. */
|
|
42
|
+
readonly theme?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UiResources {
|
|
46
|
+
readonly settings: Settings;
|
|
47
|
+
readonly theme: Theme;
|
|
48
|
+
readonly keymap: Keymap;
|
|
49
|
+
readonly diagnostics: readonly string[];
|
|
50
|
+
readonly userOverrides: Partial<Settings>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function keybindingsPath(): string {
|
|
54
|
+
return join(configDir(), "keybindings.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectKeybindingsPath(workspace: string): string {
|
|
58
|
+
return join(workspace, ".xeno", "keybindings.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function userThemePath(name: string): string {
|
|
62
|
+
return join(configDir(), "themes", `${name}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function loadUiResources(opts: LoadUiOptions): Promise<UiResources> {
|
|
66
|
+
const diagnostics: string[] = [];
|
|
67
|
+
|
|
68
|
+
const userRead = await readJsonFile<unknown>(settingsPath());
|
|
69
|
+
if (userRead.error) diagnostics.push(userRead.error);
|
|
70
|
+
const userParsed = parseSettings(userRead.value);
|
|
71
|
+
diagnostics.push(...userParsed.diagnostics);
|
|
72
|
+
const userOverrides = explicitSettings(userRead.value, userParsed.settings);
|
|
73
|
+
|
|
74
|
+
let projectSettings: Settings = {};
|
|
75
|
+
if (opts.trusted) {
|
|
76
|
+
const projectRead = await readJsonFile<unknown>(projectSettingsPath(opts.workspace));
|
|
77
|
+
if (projectRead.error) diagnostics.push(projectRead.error);
|
|
78
|
+
if (projectRead.value !== null) {
|
|
79
|
+
const parsed = parseSettings(projectRead.value);
|
|
80
|
+
diagnostics.push(...parsed.diagnostics);
|
|
81
|
+
projectSettings = parsed.settings;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cli: Settings = opts.theme !== undefined && opts.theme.length > 0 ? { theme: opts.theme } : {};
|
|
86
|
+
const settings = mergeSettings({
|
|
87
|
+
builtin: defaultSettings(),
|
|
88
|
+
user: userParsed.settings,
|
|
89
|
+
project: projectSettings,
|
|
90
|
+
cli,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const theme = await resolveTheme(settings.theme ?? "dark", diagnostics);
|
|
94
|
+
const keymap = await resolveKeymap(opts.workspace, opts.trusted, diagnostics);
|
|
95
|
+
|
|
96
|
+
return { settings, theme, keymap, diagnostics, userOverrides };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function writeUserSettings(overrides: Partial<Settings>): Promise<void> {
|
|
100
|
+
const { value, error } = await readJsonFile<unknown>(settingsPath());
|
|
101
|
+
const prev =
|
|
102
|
+
error === null && typeof value === "object" && value !== null && !Array.isArray(value)
|
|
103
|
+
? (value as { readonly [k: string]: unknown })
|
|
104
|
+
: {};
|
|
105
|
+
const next = { ...prev, ...settingsToWrite(overrides) };
|
|
106
|
+
await writeJsonFile(settingsPath(), next, false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveTheme(spec: string, diagnostics: string[]): Promise<Theme> {
|
|
110
|
+
if (spec === "dark" || spec === "light") return builtinTheme(spec);
|
|
111
|
+
|
|
112
|
+
const path = looksLikePath(spec) ? spec : userThemePath(spec);
|
|
113
|
+
const { value, error } = await readJsonFile<unknown>(path);
|
|
114
|
+
if (error) {
|
|
115
|
+
diagnostics.push(error);
|
|
116
|
+
return builtinTheme("dark");
|
|
117
|
+
}
|
|
118
|
+
if (value === null) {
|
|
119
|
+
diagnostics.push(`theme "${spec}" not found at ${path}; using dark`);
|
|
120
|
+
return builtinTheme("dark");
|
|
121
|
+
}
|
|
122
|
+
const parsed = parseTheme(value);
|
|
123
|
+
diagnostics.push(...parsed.diagnostics);
|
|
124
|
+
return parsed.theme;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function resolveKeymap(workspace: string, trusted: boolean, diagnostics: string[]): Promise<Keymap> {
|
|
128
|
+
let map = defaultKeymap();
|
|
129
|
+
|
|
130
|
+
const userRead = await readJsonFile<unknown>(keybindingsPath());
|
|
131
|
+
if (userRead.error) diagnostics.push(userRead.error);
|
|
132
|
+
if (userRead.value !== null) {
|
|
133
|
+
map = parseKeymap(userRead.value, map);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (trusted) {
|
|
137
|
+
const projectRead = await readJsonFile<unknown>(projectKeybindingsPath(workspace));
|
|
138
|
+
if (projectRead.error) diagnostics.push(projectRead.error);
|
|
139
|
+
if (projectRead.value !== null) {
|
|
140
|
+
map = parseKeymap(projectRead.value, map);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
diagnostics.push(...map.diagnostics);
|
|
145
|
+
return map;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function looksLikePath(spec: string): boolean {
|
|
149
|
+
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface LoadTextOptions {
|
|
153
|
+
readonly workspace: string;
|
|
154
|
+
readonly configDir: string;
|
|
155
|
+
readonly trusted: boolean;
|
|
156
|
+
readonly noSkills?: boolean;
|
|
157
|
+
readonly noPromptTemplates?: boolean;
|
|
158
|
+
/** `--skill <path>`, loaded even when `noSkills` is set. */
|
|
159
|
+
readonly skillPaths?: readonly string[];
|
|
160
|
+
/** `--prompt-template <path>`, loaded even when discovery is off. */
|
|
161
|
+
readonly promptTemplatePaths?: readonly string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface LoadedTextResources {
|
|
165
|
+
readonly skills: readonly Skill[];
|
|
166
|
+
readonly prompts: readonly PromptTemplate[];
|
|
167
|
+
readonly diagnostics: readonly string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Discover skills and prompt templates. Project directories load only when
|
|
172
|
+
* trusted, matching `.xeno/settings.json`. A bad file is named and skipped.
|
|
173
|
+
*/
|
|
174
|
+
export async function loadTextResources(opts: LoadTextOptions): Promise<LoadedTextResources> {
|
|
175
|
+
const diagnostics: string[] = [];
|
|
176
|
+
const skills = new Map<string, Skill>();
|
|
177
|
+
const prompts = new Map<string, PromptTemplate>();
|
|
178
|
+
|
|
179
|
+
const addSkill = (skill: Skill): void => {
|
|
180
|
+
const prev = skills.get(skill.name);
|
|
181
|
+
if (prev !== undefined) {
|
|
182
|
+
diagnostics.push(
|
|
183
|
+
`skill "${skill.name}" from ${skill.filePath} replaces ${prev.filePath}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
skills.set(skill.name, skill);
|
|
187
|
+
};
|
|
188
|
+
const addPrompt = (prompt: PromptTemplate): void => {
|
|
189
|
+
const prev = prompts.get(prompt.name);
|
|
190
|
+
if (prev !== undefined) {
|
|
191
|
+
diagnostics.push(
|
|
192
|
+
`prompt template "${prompt.name}" from ${prompt.filePath} replaces ${prev.filePath}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
prompts.set(prompt.name, prompt);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (opts.noSkills !== true) {
|
|
199
|
+
await loadSkillsFromDir(join(opts.configDir, "skills"), addSkill, diagnostics);
|
|
200
|
+
if (opts.trusted) {
|
|
201
|
+
await loadSkillsFromDir(join(opts.workspace, ".xeno", "skills"), addSkill, diagnostics);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const path of opts.skillPaths ?? []) {
|
|
205
|
+
await loadSkillsFromPath(path, addSkill, diagnostics);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (opts.noPromptTemplates !== true) {
|
|
209
|
+
await loadPromptsFromDir(join(opts.configDir, "prompts"), addPrompt, diagnostics);
|
|
210
|
+
if (opts.trusted) {
|
|
211
|
+
await loadPromptsFromDir(join(opts.workspace, ".xeno", "prompts"), addPrompt, diagnostics);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const path of opts.promptTemplatePaths ?? []) {
|
|
215
|
+
await loadPromptsFromPath(path, addPrompt, diagnostics);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
skills: [...skills.values()],
|
|
220
|
+
prompts: [...prompts.values()],
|
|
221
|
+
diagnostics,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function loadSkillsFromDir(
|
|
226
|
+
dir: string,
|
|
227
|
+
add: (skill: Skill) => void,
|
|
228
|
+
diagnostics: string[],
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const files: string[] = [];
|
|
231
|
+
await collectSkillFiles(dir, files, diagnostics);
|
|
232
|
+
for (const file of files) await absorbSkill(file, add, diagnostics);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function loadSkillsFromPath(
|
|
236
|
+
path: string,
|
|
237
|
+
add: (skill: Skill) => void,
|
|
238
|
+
diagnostics: string[],
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const info = await statKind(path);
|
|
241
|
+
if (info === "missing") {
|
|
242
|
+
diagnostics.push(`skill path not found: ${path}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (info === "file") {
|
|
246
|
+
await absorbSkill(path, add, diagnostics);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await loadSkillsFromDir(path, add, diagnostics);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function loadPromptsFromDir(
|
|
253
|
+
dir: string,
|
|
254
|
+
add: (prompt: PromptTemplate) => void,
|
|
255
|
+
diagnostics: string[],
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
let entries: Dirent[];
|
|
258
|
+
try {
|
|
259
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (isNotFound(err)) return;
|
|
262
|
+
diagnostics.push(`${dir}: ${errMessage(err)}`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
|
|
267
|
+
await absorbPrompt(join(dir, entry.name), add, diagnostics);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function loadPromptsFromPath(
|
|
272
|
+
path: string,
|
|
273
|
+
add: (prompt: PromptTemplate) => void,
|
|
274
|
+
diagnostics: string[],
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const info = await statKind(path);
|
|
277
|
+
if (info === "missing") {
|
|
278
|
+
diagnostics.push(`prompt template not found: ${path}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (info === "dir") {
|
|
282
|
+
await loadPromptsFromDir(path, add, diagnostics);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await absorbPrompt(path, add, diagnostics);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function collectSkillFiles(dir: string, out: string[], diagnostics: string[]): Promise<void> {
|
|
289
|
+
let entries: Dirent[];
|
|
290
|
+
try {
|
|
291
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (isNotFound(err)) return;
|
|
294
|
+
diagnostics.push(`${dir}: ${errMessage(err)}`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
const full = join(dir, entry.name);
|
|
299
|
+
if (entry.isDirectory()) await collectSkillFiles(full, out, diagnostics);
|
|
300
|
+
else if (entry.isFile() && entry.name === "SKILL.md") out.push(full);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function absorbSkill(
|
|
305
|
+
filePath: string,
|
|
306
|
+
add: (skill: Skill) => void,
|
|
307
|
+
diagnostics: string[],
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
const text = await readUtf8(filePath);
|
|
310
|
+
if (text === null) {
|
|
311
|
+
diagnostics.push(`${filePath}: could not read`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const parsed = parseSkill({
|
|
315
|
+
filePath,
|
|
316
|
+
parentDirName: basename(dirname(filePath)),
|
|
317
|
+
text,
|
|
318
|
+
});
|
|
319
|
+
diagnostics.push(...parsed.diagnostics);
|
|
320
|
+
if (parsed.skill) add(parsed.skill);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function absorbPrompt(
|
|
324
|
+
filePath: string,
|
|
325
|
+
add: (prompt: PromptTemplate) => void,
|
|
326
|
+
diagnostics: string[],
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const text = await readUtf8(filePath);
|
|
329
|
+
if (text === null) {
|
|
330
|
+
diagnostics.push(`${filePath}: could not read`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const parsed = parsePromptTemplate({ filePath, text });
|
|
334
|
+
diagnostics.push(...parsed.diagnostics);
|
|
335
|
+
if (parsed.template) add(parsed.template);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readUtf8(path: string): Promise<string | null> {
|
|
339
|
+
try {
|
|
340
|
+
return await readFile(path, "utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function statKind(path: string): Promise<"file" | "dir" | "missing"> {
|
|
347
|
+
try {
|
|
348
|
+
const info = await stat(path);
|
|
349
|
+
return info.isDirectory() ? "dir" : "file";
|
|
350
|
+
} catch {
|
|
351
|
+
return "missing";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isNotFound(err: unknown): boolean {
|
|
356
|
+
return (err as { code?: string }).code === "ENOENT";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function errMessage(err: unknown): string {
|
|
360
|
+
return err instanceof Error ? err.message : String(err);
|
|
361
|
+
}
|