@visual-z/xeno 0.1.3 → 0.2.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/CHANGELOG.md +19 -0
- package/README.md +143 -33
- package/docs/install.md +18 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +42 -14
- package/src/apps/commands.ts +32 -4
- package/src/apps/completions.ts +9 -6
- package/src/apps/export-cli.ts +24 -0
- package/src/apps/extension-ui.ts +330 -0
- package/src/apps/input.ts +16 -1
- package/src/apps/model-catalog.ts +42 -0
- package/src/apps/project-trust.ts +49 -0
- package/src/apps/render.ts +1 -0
- package/src/apps/resource-browser.ts +75 -0
- package/src/apps/resources.ts +25 -0
- package/src/apps/session-browser.ts +113 -0
- package/src/apps/session-picker.ts +55 -0
- package/src/apps/shell.ts +1177 -378
- package/src/apps/startup.ts +400 -268
- package/src/core/action-defaults.ts +306 -0
- package/src/core/attachment-input.ts +17 -0
- package/src/core/catalog-merge.ts +19 -0
- package/src/core/event-validation.ts +19 -2
- package/src/core/events.ts +45 -2
- package/src/core/export.ts +6 -2
- package/src/core/extension-flags.ts +14 -0
- package/src/core/images.ts +46 -0
- package/src/core/json-event.ts +2 -0
- package/src/core/keymap.ts +43 -119
- package/src/core/model-replay.ts +22 -0
- package/src/core/model-scope.ts +53 -0
- package/src/core/packages.ts +47 -0
- package/src/core/progress.ts +9 -0
- package/src/core/project.ts +21 -3
- package/src/core/prompts.ts +33 -5
- package/src/core/queue.ts +18 -0
- package/src/core/resource-selection.ts +36 -0
- package/src/{runtime → core}/resume.ts +15 -22
- package/src/core/session-archive.ts +4 -0
- package/src/core/session-browser.ts +82 -0
- package/src/core/session-entry.ts +56 -0
- package/src/core/session-position.ts +26 -0
- package/src/core/settings.ts +169 -154
- package/src/core/theme.ts +15 -6
- package/src/core/thinking.ts +26 -0
- package/src/core/tokens.ts +2 -0
- package/src/core/transcript.ts +15 -5
- package/src/runtime/auth.ts +116 -11
- package/src/runtime/catalog.ts +54 -151
- package/src/runtime/clipboard.ts +114 -0
- package/src/runtime/extension-loader.ts +37 -0
- package/src/runtime/extension-ui.ts +55 -0
- package/src/runtime/extensions.ts +273 -0
- package/src/runtime/images.ts +76 -0
- package/src/runtime/loop.ts +213 -83
- package/src/runtime/model-config.ts +232 -0
- package/src/runtime/model-data.LICENSE +27 -0
- package/src/runtime/model-data.ts +21 -0
- package/src/runtime/model-replay.ts +10 -0
- package/src/runtime/model-scope.ts +20 -0
- package/src/runtime/model-sources.ts +151 -0
- package/src/runtime/model.ts +74 -11
- package/src/runtime/models-fetch.ts +6 -104
- package/src/runtime/oauth.ts +4 -3
- package/src/runtime/openai-responses.ts +12 -4
- package/src/runtime/openai.ts +13 -4
- package/src/runtime/package-manager.ts +125 -0
- package/src/runtime/pi-provider.ts +235 -0
- package/src/runtime/progress.ts +26 -0
- package/src/runtime/provider-access.ts +21 -0
- package/src/runtime/provider-protocols.ts +29 -0
- package/src/runtime/provider-request.ts +73 -0
- package/src/runtime/provider.ts +26 -6
- package/src/runtime/rich-text.ts +36 -0
- package/src/runtime/tools.ts +41 -5
- package/src/store/command.ts +7 -1
- package/src/store/context-files.ts +1 -1
- package/src/store/log.ts +65 -0
- package/src/store/models.ts +28 -9
- package/src/store/observations.ts +4 -2
- package/src/store/packages.ts +98 -0
- package/src/store/recovery.ts +8 -0
- package/src/store/resource-index.ts +170 -0
- package/src/store/resources.ts +75 -142
- package/src/store/session-archive.ts +162 -0
- package/src/store/session-browser.ts +68 -0
- package/src/store/session-position.ts +27 -0
- package/src/store/sessions.ts +87 -66
- package/src/store/trust.ts +13 -0
- package/src/store/workspace.ts +6 -0
- package/src/tui/chat-viewport.ts +47 -0
- package/src/tui/editor.ts +62 -15
- package/src/tui/host.ts +50 -9
- package/src/tui/input.ts +8 -2
- package/src/tui/select.ts +24 -6
- package/src/tui/settings.ts +4 -0
- package/src/tui/system-theme.ts +700 -0
- package/src/tui/transcript.ts +63 -11
- package/src/tui/tree.ts +161 -95
- package/src/vendor/pi-rich/LICENSE.upstream +21 -0
- package/src/vendor/pi-rich/UPSTREAM.json +17 -0
- package/src/vendor/pi-rich/clipboard-command.ts +44 -0
- package/src/vendor/pi-rich/exif-orientation.ts +182 -0
- package/src/vendor/pi-rich/html.ts +51 -0
- package/src/vendor/pi-rich/image-convert.ts +49 -0
- package/src/vendor/pi-rich/image-resize-core.ts +164 -0
- package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
- package/src/vendor/pi-rich/image-resize.ts +123 -0
- package/src/vendor/pi-rich/mime.ts +116 -0
- package/src/vendor/pi-rich/photon.ts +139 -0
- package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
- package/src/vendor/pi-tui/UPSTREAM.json +50 -0
- package/src/vendor/pi-tui/VENDOR.md +7 -5
- package/src/vendor/pi-tui/autocomplete.ts +35 -10
- package/src/vendor/pi-tui/colors.ts +367 -0
- package/src/vendor/pi-tui/components/box.ts +8 -9
- package/src/vendor/pi-tui/components/editor.ts +4 -2
- package/src/vendor/pi-tui/components/markdown.ts +8 -2
- package/src/vendor/pi-tui/index.ts +31 -1
- package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
- package/src/vendor/pi-tui/native/clipboard.h +208 -0
- package/src/vendor/pi-tui/native/darwin/README.md +22 -0
- package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
- package/src/vendor/pi-tui/native/linux/README.md +23 -0
- package/src/vendor/pi-tui/native/linux/build.sh +65 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
- package/src/vendor/pi-tui/native/napi.h +91 -0
- package/src/vendor/pi-tui/native/win32/README.md +31 -0
- package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
- package/src/vendor/pi-tui/native-platform.ts +2 -0
- package/src/vendor/pi-tui/oklab.ts +233 -0
- package/src/vendor/pi-tui/terminal-colors.ts +26 -8
- package/src/vendor/pi-tui/terminal-image.ts +43 -8
- package/src/vendor/pi-tui/terminal.ts +16 -9
- package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
- package/src/vendor/pi-tui/tui.ts +112 -75
- package/src/vendor/pi-tui/utils.ts +89 -49
- package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
|
|
2
|
+
import { providerAccess } from "./provider-access.ts";
|
|
3
|
+
import type { ModelSpec } from "./model.ts";
|
|
4
|
+
import { XENO_VERSION } from "./oauth.ts";
|
|
5
|
+
|
|
6
|
+
const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
|
|
7
|
+
|
|
8
|
+
/** D18/D16: a protocol SDK may not silently identify xeno as a different product. */
|
|
9
|
+
function clientIdentity(headers: Headers): void {
|
|
10
|
+
headers.set("User-Agent", `xeno/${XENO_VERSION}`);
|
|
11
|
+
for (const name of SDK_IDENTITY_HEADERS) headers.delete(name);
|
|
12
|
+
if (headers.has("originator")) headers.set("originator", "xeno");
|
|
13
|
+
const beta = headers.get("anthropic-beta");
|
|
14
|
+
if (beta) headers.set("anthropic-beta", beta.split(",").filter(value => !value.trim().startsWith("claude-code-")).join(","));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function requestEffects(): ResolveEffects {
|
|
18
|
+
return { env: name => process.env[name], run: command => {
|
|
19
|
+
const result = Bun.spawnSync(["bash", "-lc", command]);
|
|
20
|
+
if (result.exitCode !== 0) throw new Error("credential command failed");
|
|
21
|
+
return new TextDecoder().decode(result.stdout);
|
|
22
|
+
} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEffects = requestEffects(), fetchImpl: typeof fetch = globalThis.fetch, oauth = false): {
|
|
26
|
+
baseUrl: string; env: Record<string, string>; headers: Record<string, string | null>; fetch: typeof fetch;
|
|
27
|
+
options: Record<string, unknown>; body: Record<string, unknown>; key?: string;
|
|
28
|
+
} {
|
|
29
|
+
const access = providerAccess(spec);
|
|
30
|
+
if (access.invalid) throw new Error(access.invalid);
|
|
31
|
+
const scoped = providerEnvironment(spec, effects);
|
|
32
|
+
const env = scoped.env;
|
|
33
|
+
const resolve = (value: string, field: string) => {
|
|
34
|
+
const resolved = resolveKeyValue(value, scoped.effects);
|
|
35
|
+
if (!resolved.ok) throw new Error(`${spec.provider} ${field}: could not resolve configured value`);
|
|
36
|
+
return resolved.key;
|
|
37
|
+
};
|
|
38
|
+
let baseUrl = spec.baseUrl ?? (spec.api === "anthropic-messages" ? "https://api.anthropic.com" : "");
|
|
39
|
+
if (baseUrl) baseUrl = resolve(baseUrl, "baseUrl");
|
|
40
|
+
if (baseUrl && !/^https?:\/\//.test(baseUrl)) throw new Error(`${spec.provider} baseUrl must use http or https`);
|
|
41
|
+
const headers: Record<string, string | null> = { ...spec.defaultHeaders };
|
|
42
|
+
for (const [name, value] of Object.entries(access.headers ?? {})) headers[name] = value === null ? null : resolve(value, `headers.${name}`);
|
|
43
|
+
const anthropicOAuth = spec.api === "anthropic-messages" && spec.provider !== "github-copilot" && (oauth || key.includes("sk-ant-oat"));
|
|
44
|
+
const noAutomaticAuth = access.auth === false || access.authHeader === false || typeof access.authHeader === "string" || anthropicOAuth;
|
|
45
|
+
if (noAutomaticAuth && (spec.api === "google-generative-ai" || spec.api === "google-vertex")) {
|
|
46
|
+
throw new Error(`${spec.api} owns API-key authentication; use apiKey or an OpenAI-compatible local endpoint`);
|
|
47
|
+
}
|
|
48
|
+
if (noAutomaticAuth && spec.api === "bedrock-converse-stream") env["AWS_BEDROCK_SKIP_AUTH"] = "1";
|
|
49
|
+
if (typeof access.authHeader === "string") headers[access.authHeader] = key;
|
|
50
|
+
if (anthropicOAuth && access.auth !== false && access.authHeader !== false) {
|
|
51
|
+
headers["authorization"] = `Bearer ${key}`;
|
|
52
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
53
|
+
}
|
|
54
|
+
// Apply the same identity to native SDKs which own their HTTP stack.
|
|
55
|
+
const identity = new Headers();
|
|
56
|
+
for (const [name, value] of Object.entries(headers)) if (value !== null) identity.set(name, value);
|
|
57
|
+
clientIdentity(identity);
|
|
58
|
+
for (const name of Object.keys(headers)) if (!identity.has(name)) headers[name] = null;
|
|
59
|
+
identity.forEach((value, name) => { headers[name] = value; });
|
|
60
|
+
delete headers["user-agent"];
|
|
61
|
+
headers["User-Agent"] = `xeno/${XENO_VERSION}`;
|
|
62
|
+
// SDKs still require a key to construct local clients. The placeholder never leaves this boundary.
|
|
63
|
+
const privateKey = noAutomaticAuth ? "xeno-keyless-local" : key || undefined;
|
|
64
|
+
const transport = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
65
|
+
const request = new Request(input, init);
|
|
66
|
+
const nextHeaders = new Headers(request.headers);
|
|
67
|
+
if (noAutomaticAuth) for (const name of ["authorization", "x-api-key", "api-key", "x-goog-api-key"]) nextHeaders.delete(name);
|
|
68
|
+
for (const [name, value] of Object.entries(headers)) { if (value === null) nextHeaders.delete(name); else nextHeaders.set(name, value); }
|
|
69
|
+
clientIdentity(nextHeaders);
|
|
70
|
+
return fetchImpl(new Request(request, { headers: nextHeaders }));
|
|
71
|
+
}) as typeof fetch;
|
|
72
|
+
return { baseUrl, env, headers, fetch: transport, options: access.options ?? {}, body: access.body ?? {}, ...(privateKey ? { key: privateKey } : {}) };
|
|
73
|
+
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import Anthropic from "@anthropic-ai/sdk";
|
|
34
34
|
import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
|
|
35
|
+
import { imageData } from "./images.ts";
|
|
35
36
|
import type { AssistantBlock } from "../core/events.ts";
|
|
36
37
|
import { assertNever } from "../core/exhaustive.ts";
|
|
37
38
|
import type { UsageSample } from "./usage.ts";
|
|
@@ -89,6 +90,8 @@ export const DEFAULT_PROVIDER: ProviderConfig = {
|
|
|
89
90
|
export type StreamDelta =
|
|
90
91
|
| { kind: "text"; text: string }
|
|
91
92
|
| { kind: "thinking"; text: string }
|
|
93
|
+
| { kind: "tool"; id: string; name: string; arguments: string }
|
|
94
|
+
| { kind: "retry"; attempt: number; delayMs: number }
|
|
92
95
|
| { kind: "discard" };
|
|
93
96
|
|
|
94
97
|
export type OnDelta = (delta: StreamDelta) => void;
|
|
@@ -156,13 +159,14 @@ export class RefusalError extends Error {
|
|
|
156
159
|
|
|
157
160
|
function buildAnthropicClient(
|
|
158
161
|
apiKey: string | undefined,
|
|
159
|
-
options: { fetch?: typeof fetch; oauth?: boolean },
|
|
162
|
+
options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
|
|
160
163
|
): AnthropicTransport {
|
|
161
164
|
if (options.oauth === true && apiKey) {
|
|
162
165
|
// authToken is Bearer. Passing apiKey here would send x-api-key, which
|
|
163
166
|
// an OAuth token is not accepted as.
|
|
164
167
|
return new Anthropic({
|
|
165
168
|
maxRetries: 0,
|
|
169
|
+
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
166
170
|
authToken: apiKey,
|
|
167
171
|
defaultHeaders: anthropicOAuthHeaders(),
|
|
168
172
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
@@ -170,6 +174,7 @@ function buildAnthropicClient(
|
|
|
170
174
|
}
|
|
171
175
|
return new Anthropic({
|
|
172
176
|
maxRetries: 0,
|
|
177
|
+
...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
|
|
173
178
|
...(apiKey ? { apiKey } : {}),
|
|
174
179
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
175
180
|
});
|
|
@@ -206,7 +211,7 @@ export class AnthropicProvider implements Provider {
|
|
|
206
211
|
// it, so the usage report would quietly stop adding up. The SDK timer
|
|
207
212
|
// also ignores AbortSignal, so an outer wait is the only one Escape
|
|
208
213
|
// can actually cancel.
|
|
209
|
-
this.client = options.client ?? buildAnthropicClient(apiKey, options);
|
|
214
|
+
this.client = options.client ?? buildAnthropicClient(apiKey, { ...options, ...(config.spec.baseUrl ? { baseUrl: config.spec.baseUrl } : {}) });
|
|
210
215
|
this.retry = options.retry ?? DEFAULT_RETRY;
|
|
211
216
|
this.onRetry = options.onRetry;
|
|
212
217
|
}
|
|
@@ -226,6 +231,7 @@ export class AnthropicProvider implements Provider {
|
|
|
226
231
|
// The next attempt starts from empty. Without this the UI concatenates
|
|
227
232
|
// two openings of the same sentence and has no sign a retry happened.
|
|
228
233
|
onDelta?.({ kind: "discard" });
|
|
234
|
+
onDelta?.({ kind: "retry", attempt, delayMs });
|
|
229
235
|
this.onRetry?.(attempt, delayMs, error);
|
|
230
236
|
},
|
|
231
237
|
signal,
|
|
@@ -262,8 +268,9 @@ export class AnthropicProvider implements Provider {
|
|
|
262
268
|
// Anthropic-compatible endpoint serving some other model, answers
|
|
263
269
|
// them with a 400 or silently ignores them.
|
|
264
270
|
...(this.config.spec.reasoning
|
|
265
|
-
? { thinking: { type: "
|
|
266
|
-
|
|
271
|
+
? this.config.effort === "off" ? { thinking: { type: "disabled" as const } }
|
|
272
|
+
: { thinking: { type: "adaptive" as const, display: this.config.thinkingDisplay },
|
|
273
|
+
output_config: { effort: anthropicEffort(this.config) } }
|
|
267
274
|
: {}),
|
|
268
275
|
messages: messages.map(toWireMessage),
|
|
269
276
|
},
|
|
@@ -304,6 +311,13 @@ export class AnthropicProvider implements Provider {
|
|
|
304
311
|
}
|
|
305
312
|
}
|
|
306
313
|
|
|
314
|
+
/** Pi maps minimal to low for adaptive thinking; off is a thinking switch, never an effort string. */
|
|
315
|
+
function anthropicEffort(config: ProviderConfig): "low" | "medium" | "high" | "xhigh" | "max" {
|
|
316
|
+
const level = config.spec.thinkingLevelMap?.[config.effort] ?? config.effort;
|
|
317
|
+
return level === "minimal" || level === "off" ? "low" :
|
|
318
|
+
level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max" ? level : "high";
|
|
319
|
+
}
|
|
320
|
+
|
|
307
321
|
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
308
322
|
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
309
323
|
}
|
|
@@ -402,7 +416,10 @@ async function consumeAnthropicStream(
|
|
|
402
416
|
const index = event.index ?? content.length;
|
|
403
417
|
if (event.content_block !== undefined) {
|
|
404
418
|
content[index] = { ...event.content_block } as Anthropic.ContentBlock;
|
|
405
|
-
if (event.content_block.type === "tool_use")
|
|
419
|
+
if (event.content_block.type === "tool_use") {
|
|
420
|
+
jsonBuf.set(index, "");
|
|
421
|
+
onDelta?.({ kind: "tool", id: event.content_block.id, name: event.content_block.name, arguments: "" });
|
|
422
|
+
}
|
|
406
423
|
}
|
|
407
424
|
break;
|
|
408
425
|
}
|
|
@@ -421,6 +438,7 @@ async function consumeAnthropicStream(
|
|
|
421
438
|
block.signature = (block.signature ?? "") + delta.signature;
|
|
422
439
|
} else if (delta.type === "input_json_delta" && delta.partial_json) {
|
|
423
440
|
jsonBuf.set(index, (jsonBuf.get(index) ?? "") + delta.partial_json);
|
|
441
|
+
if (block.type === "tool_use") onDelta?.({ kind: "tool", id: block.id, name: block.name, arguments: jsonBuf.get(index)! });
|
|
424
442
|
}
|
|
425
443
|
break;
|
|
426
444
|
}
|
|
@@ -452,12 +470,14 @@ async function consumeAnthropicStream(
|
|
|
452
470
|
function toWireMessage(m: Message): Anthropic.MessageParam {
|
|
453
471
|
return {
|
|
454
472
|
role: m.role,
|
|
455
|
-
|
|
473
|
+
// Anthropic requires all parallel tool results before any text or image in the user message.
|
|
474
|
+
content: [...m.content.filter(b => b.type === "tool_result"), ...m.content.filter(b => b.type !== "tool_result")].map(toWireBlock) as Anthropic.ContentBlockParam[],
|
|
456
475
|
};
|
|
457
476
|
}
|
|
458
477
|
|
|
459
478
|
function toWireBlock(b: ContentBlock): unknown {
|
|
460
479
|
switch (b.type) {
|
|
480
|
+
case "image": return { type: "image", source: { type: "base64", media_type: b.image.mimeType, data: imageData(b) } };
|
|
461
481
|
case "text":
|
|
462
482
|
return { type: "text", text: b.text };
|
|
463
483
|
case "thinking":
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Ported from Pi (MIT), theme.ts and components/mermaid.ts at 02eed88; only presentation, never log rewriting. */
|
|
2
|
+
import { Marked } from "marked";
|
|
3
|
+
import { render } from "grok-mermaid";
|
|
4
|
+
import { highlight, supportsLanguage, loadAllHighlightLanguages } from "../vendor/pi-rich/syntax-highlight.ts";
|
|
5
|
+
|
|
6
|
+
type Paint = (token: string, text: string) => string;
|
|
7
|
+
export const warmSyntaxLanguages = loadAllHighlightLanguages;
|
|
8
|
+
export function highlightCode(code: string, language: string | undefined, paint: Paint): string[] {
|
|
9
|
+
if (!language || !supportsLanguage(language)) return code.split("\n").map(line => paint("mdCodeBlock", line));
|
|
10
|
+
const tokens: Record<string, string> = { keyword: "Keyword", built_in: "Function", title: "Function", function: "Function",
|
|
11
|
+
string: "String", regexp: "String", number: "Number", literal: "Number", type: "Type", class: "Type",
|
|
12
|
+
variable: "Variable", params: "Variable", attr: "Variable", attribute: "Variable", symbol: "Variable",
|
|
13
|
+
comment: "Comment", meta: "Comment", operator: "Operator", punctuation: "Punctuation" };
|
|
14
|
+
try { return highlight(code, { language, ignoreIllegals: true,
|
|
15
|
+
theme: Object.fromEntries(Object.entries(tokens).map(([scope, token]) => [scope, (text: string) => paint("syntax" + token, text)])),
|
|
16
|
+
}).split("\n"); } catch { return code.split("\n"); }
|
|
17
|
+
}
|
|
18
|
+
const parser = new Marked();
|
|
19
|
+
function codeSpan(line: string): string {
|
|
20
|
+
const content = line || "\u00a0";
|
|
21
|
+
const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), match => match[0].length));
|
|
22
|
+
const fence = "`".repeat(longest + 1), padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
|
|
23
|
+
return `${fence}${padding}${content}${padding}${fence}`;
|
|
24
|
+
}
|
|
25
|
+
export function transformMermaid(markdown: string, width: number, mode: "off" | "final" | "streaming", streaming: boolean, paint: Paint): string {
|
|
26
|
+
if (mode === "off" || streaming && mode !== "streaming") return markdown;
|
|
27
|
+
try { return parser.lexer(markdown).map(token => {
|
|
28
|
+
if (token.type !== "code" || token.lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() !== "mermaid") return token.raw;
|
|
29
|
+
// Incomplete stream fences remain source; rendering half a graph would flicker and lose error context.
|
|
30
|
+
if (streaming && !/\n\s*(`{3,}|~{3,})\s*$/.test(token.raw.trimEnd())) return token.raw;
|
|
31
|
+
const art = render(token.text);
|
|
32
|
+
if (!art || art.width > width || art.warnings.length) return token.raw;
|
|
33
|
+
const tokens: Record<string, string> = { border: "borderMuted", text: "text", edge: "accent", edgeLabel: "muted", title: "accent" };
|
|
34
|
+
return art.styled.map(row => codeSpan(row.map(span => paint(tokens[span.cls] ?? "text", span.text)).join(""))).join(" \n") + "\n";
|
|
35
|
+
}).join(""); } catch { return markdown; }
|
|
36
|
+
}
|
package/src/runtime/tools.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { createHash } from "node:crypto";
|
|
|
25
25
|
import { dirname, resolve, relative, isAbsolute, join, sep } from "node:path";
|
|
26
26
|
import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
|
|
27
27
|
import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
28
|
+
import { blobAddress } from "../store/blobs.ts";
|
|
28
29
|
import { commandHeader, headTailView } from "../core/observation-view.ts";
|
|
29
30
|
import {
|
|
30
31
|
DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, runningSessions, startSession, takeOutput, waitSession,
|
|
@@ -51,10 +52,15 @@ import {
|
|
|
51
52
|
type PermissionRule,
|
|
52
53
|
} from "../core/permissions.ts";
|
|
53
54
|
import type { ToolSchema } from "./provider.ts";
|
|
55
|
+
import type { ImageRef } from "../core/images.ts";
|
|
56
|
+
import { imageDescription } from "../core/images.ts";
|
|
57
|
+
import { attachImageBytes } from "./images.ts";
|
|
58
|
+
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
54
59
|
import { find as findFiles, grep, ls } from "../store/search.ts";
|
|
55
60
|
import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
|
|
56
61
|
|
|
57
62
|
export interface ToolResult {
|
|
63
|
+
images?: readonly ImageRef[];
|
|
58
64
|
/** What the model sees. */
|
|
59
65
|
content: string;
|
|
60
66
|
ok: boolean;
|
|
@@ -70,6 +76,16 @@ export interface ToolResult {
|
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
export interface ToolContext {
|
|
79
|
+
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
80
|
+
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
81
|
+
callId?: string;
|
|
82
|
+
extensionDepth?: number;
|
|
83
|
+
extensionHooks?: {
|
|
84
|
+
before(name: string, input: unknown, id: string | undefined): Promise<string | undefined>;
|
|
85
|
+
event(type: string, fields: Record<string, unknown>): Promise<void>;
|
|
86
|
+
};
|
|
87
|
+
imageStore?: string;
|
|
88
|
+
imageAutoResize?: boolean;
|
|
73
89
|
/** O.1: per-execution copy; never mutate the session's shared tool context with a live signal. */
|
|
74
90
|
signal?: AbortSignal;
|
|
75
91
|
/** Absolute path. Every tool path is confined to this subtree. */
|
|
@@ -99,6 +115,7 @@ export interface ToolContext {
|
|
|
99
115
|
/** Tool output above this is truncated; the marker says how much was cut. */
|
|
100
116
|
maxOutputBytes: number;
|
|
101
117
|
outputStore?: string;
|
|
118
|
+
onOutput?: (chunk: Uint8Array) => void;
|
|
102
119
|
/**
|
|
103
120
|
* First-match permission rules. Absent means every call is allowed, which
|
|
104
121
|
* is what the existing tests rely on: they exercise the tool, not the gate.
|
|
@@ -193,6 +210,7 @@ export interface ToolDefinition extends ToolSchema {
|
|
|
193
210
|
branch: "read" | "write" | "none";
|
|
194
211
|
/** Accepted by admission but not advertised to the model: harness and test switches. */
|
|
195
212
|
hiddenInputs?: readonly string[];
|
|
213
|
+
prepareArguments?: (input: unknown) => unknown;
|
|
196
214
|
run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
|
|
197
215
|
}
|
|
198
216
|
|
|
@@ -525,8 +543,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
525
543
|
/** Runtime policy metadata never leaks into provider-specific wire schemas. */
|
|
526
544
|
export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(({ name, description, input_schema }) => ({ name, description, input_schema }));
|
|
527
545
|
|
|
528
|
-
export function toolDefinition(name: string): ToolDefinition | undefined {
|
|
529
|
-
return TOOL_DEFINITIONS.find(tool => tool.name === name);
|
|
546
|
+
export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition | undefined {
|
|
547
|
+
return ctx?.extensions?.get(name) ?? TOOL_DEFINITIONS.find(tool => tool.name === name);
|
|
530
548
|
}
|
|
531
549
|
|
|
532
550
|
// ---------------------------------------------------------------------------
|
|
@@ -560,6 +578,10 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
560
578
|
return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
|
|
561
579
|
}
|
|
562
580
|
|
|
581
|
+
if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
|
|
582
|
+
const image = await attachImageBytes(bytes, path, ctx.imageStore, ctx.imageAutoResize ?? true);
|
|
583
|
+
return { ok: true, content: imageDescription(image), images: [image], effect: { kind: "read", path, sha256: blobAddress(bytes) } };
|
|
584
|
+
}
|
|
563
585
|
if (bytes.subarray(0, 8_192).includes(0)) {
|
|
564
586
|
return { ok: false, content: `${path} looks binary (${bytes.length} bytes, contains NUL); inspect it with bash, e.g. \`file ${path}\` or \`xxd ${path} | head\`` };
|
|
565
587
|
}
|
|
@@ -695,6 +717,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
695
717
|
outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
|
|
696
718
|
maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
|
|
697
719
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
720
|
+
...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}),
|
|
698
721
|
});
|
|
699
722
|
|
|
700
723
|
const gate = matchGate(ctx, command);
|
|
@@ -933,7 +956,7 @@ export async function gateTool(
|
|
|
933
956
|
ok: false,
|
|
934
957
|
};
|
|
935
958
|
}
|
|
936
|
-
const definition = toolDefinition(name);
|
|
959
|
+
const definition = toolDefinition(name, ctx);
|
|
937
960
|
input = repairToolInput(name, input);
|
|
938
961
|
const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
|
|
939
962
|
if (invalid) return { ok: false, content: invalid, diagnostic: { code: "invalid_input", message: invalid } };
|
|
@@ -995,14 +1018,27 @@ export async function runTool(
|
|
|
995
1018
|
|
|
996
1019
|
/** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
|
|
997
1020
|
export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1021
|
+
try { input = toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input; }
|
|
1022
|
+
catch (error) { return finishToolResult({ ok: false, content: `Invalid ${name} arguments: ${String(error)}` }); }
|
|
998
1023
|
const gated = await gateTool(ctx, name, input);
|
|
999
1024
|
if (gated) return finishToolResult(gated);
|
|
1000
|
-
|
|
1025
|
+
const veto = await ctx.extensionHooks?.before(name, input, ctx.callId);
|
|
1026
|
+
if (veto) return finishToolResult({ ok: false, content: veto });
|
|
1027
|
+
return async () => {
|
|
1028
|
+
await ctx.extensionHooks?.event("tool_execution_start", { toolName: name, toolCallId: ctx.callId, args: input });
|
|
1029
|
+
const result = await dispatchTool({ ...ctx, onOutput: chunk => {
|
|
1030
|
+
ctx.onOutput?.(chunk);
|
|
1031
|
+
void ctx.extensionHooks?.event("tool_execution_update", { toolName: name, toolCallId: ctx.callId, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } });
|
|
1032
|
+
} }, name, input);
|
|
1033
|
+
await ctx.extensionHooks?.event("tool_result", { toolName: name, toolCallId: ctx.callId, input, content: [{ type: "text", text: result.content }], isError: !result.ok, details: result.data });
|
|
1034
|
+
await ctx.extensionHooks?.event("tool_execution_end", { toolName: name, toolCallId: ctx.callId, result, isError: !result.ok });
|
|
1035
|
+
return result;
|
|
1036
|
+
};
|
|
1001
1037
|
}
|
|
1002
1038
|
|
|
1003
1039
|
async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult> {
|
|
1004
1040
|
if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
|
|
1005
|
-
const impl = toolDefinition(name)?.run;
|
|
1041
|
+
const impl = toolDefinition(name, ctx)?.run;
|
|
1006
1042
|
if (!impl) {
|
|
1007
1043
|
const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined).map((tool) => tool.name));
|
|
1008
1044
|
return finishToolResult({ content: `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`), ok: false });
|
package/src/store/command.ts
CHANGED
|
@@ -31,7 +31,13 @@ export async function observeCommand(input: {
|
|
|
31
31
|
maxOutputBytes: number;
|
|
32
32
|
timeoutMs: number;
|
|
33
33
|
signal?: AbortSignal;
|
|
34
|
+
onOutput?: (chunk: Uint8Array) => void;
|
|
34
35
|
}) {
|
|
36
|
+
if (input.signal?.aborted) {
|
|
37
|
+
const empty = () => new ReadableStream<Uint8Array>({ start: controller => controller.close() });
|
|
38
|
+
const captured = await captureCommandObservation(input.outputStore, empty(), empty(), Promise.resolve(130), input.maxOutputBytes);
|
|
39
|
+
return { ...captured, wallMs: 0, stopped: "cancelled" as const };
|
|
40
|
+
}
|
|
35
41
|
const started = performance.now();
|
|
36
42
|
const proc = Bun.spawn(["bash", "-o", "pipefail", "-lc", input.command], {
|
|
37
43
|
cwd: input.cwd, stdout: "pipe", stderr: "pipe", detached: true, env: commandEnvironment(process.env),
|
|
@@ -59,7 +65,7 @@ export async function observeCommand(input: {
|
|
|
59
65
|
if (input.signal?.aborted) abort();
|
|
60
66
|
try {
|
|
61
67
|
const captured = await captureCommandObservation(input.outputStore, proc.stdout, proc.stderr,
|
|
62
|
-
proc.exited, input.maxOutputBytes);
|
|
68
|
+
proc.exited, input.maxOutputBytes, input.onOutput);
|
|
63
69
|
return { ...captured, wallMs: performance.now() - started, ...(stopped ? { stopped } : {}) };
|
|
64
70
|
} catch (error) {
|
|
65
71
|
kill("SIGKILL");
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Re-reading these files at a node boundary would change `system` and
|
|
17
17
|
* invalidate the prefix cache for the whole conversation (D6). They are
|
|
18
|
-
* frozen at startup;
|
|
18
|
+
* frozen at startup; an explicit reload checkpoints a changed prefix.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { readFile, stat } from "node:fs/promises";
|
package/src/store/log.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { isLogEvent, isRecord, validNodeRef } from "../core/event-validation.ts"
|
|
|
36
36
|
import { emitHandoff } from "../core/handoff.ts";
|
|
37
37
|
import { atomicWrite, durableAppend } from "./atomic.ts";
|
|
38
38
|
import { serialized } from "./serial.ts";
|
|
39
|
+
import { stateAtPosition } from "../core/session-position.ts";
|
|
39
40
|
|
|
40
41
|
/** Allocation number. Not an ordering over the tree. */
|
|
41
42
|
export type NodeId = number;
|
|
@@ -110,6 +111,14 @@ export interface Cursor {
|
|
|
110
111
|
* this rather than discovering it on the wire.
|
|
111
112
|
*/
|
|
112
113
|
model: string;
|
|
114
|
+
/** New cursors disambiguate identical model IDs served by different providers. */
|
|
115
|
+
provider?: string;
|
|
116
|
+
/** Configuration takes effect after this committed event; absent means node start. */
|
|
117
|
+
at_event?: string;
|
|
118
|
+
thinking?: string;
|
|
119
|
+
blob_dir?: string;
|
|
120
|
+
/** Legacy historical positions with unknown model provenance must cut before dispatch. */
|
|
121
|
+
needs_model_checkpoint?: boolean;
|
|
113
122
|
updated_at: IsoTime;
|
|
114
123
|
/** Active checkout, which changes when a writing branch becomes mainline. */
|
|
115
124
|
workspace?: string;
|
|
@@ -126,8 +135,12 @@ export interface Cursor {
|
|
|
126
135
|
export interface Manifest {
|
|
127
136
|
version: 1;
|
|
128
137
|
nodes: NodeRecord[];
|
|
138
|
+
/** An import keeps historical bytes and relocates their storage through journal metadata. */
|
|
139
|
+
relocated?: true;
|
|
129
140
|
/** Absent in a log written before resume existed, and in an empty one. */
|
|
130
141
|
cursor?: Cursor;
|
|
142
|
+
parent_session?: string;
|
|
143
|
+
labels?: Record<string, { text: string; at: string }>;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/**
|
|
@@ -500,7 +513,10 @@ async function readLegacyManifest(dir: string): Promise<Manifest> {
|
|
|
500
513
|
|
|
501
514
|
type MetadataRecord =
|
|
502
515
|
| { kind: "seed"; manifest: Manifest }
|
|
516
|
+
| { kind: "relocate"; workspace: string }
|
|
503
517
|
| { kind: "cursor"; cursor: Cursor }
|
|
518
|
+
| { kind: "parent_session"; path: string }
|
|
519
|
+
| { kind: "label"; target: string; text: string; at: string }
|
|
504
520
|
| { kind: "node"; record: NodeRecord };
|
|
505
521
|
|
|
506
522
|
function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
|
|
@@ -521,6 +537,22 @@ async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
|
|
|
521
537
|
});
|
|
522
538
|
}
|
|
523
539
|
|
|
540
|
+
/** The metadata journal already owns configuration history; no second state log is needed. */
|
|
541
|
+
export async function readCursorAt(dir: string, node: number, atEvent?: string): Promise<Cursor | null> {
|
|
542
|
+
const records = await readMetadata(dir);
|
|
543
|
+
const legacy = records === null ? await readLegacyManifest(dir) : undefined;
|
|
544
|
+
const states: Cursor[] = legacy?.cursor ? [legacy.cursor] : [];
|
|
545
|
+
let relocated = legacy?.relocated === true;
|
|
546
|
+
for (const record of records ?? []) {
|
|
547
|
+
if (record.kind === "cursor") states.push({ ...record.cursor });
|
|
548
|
+
else if (record.kind === "seed") { if (record.manifest.cursor) states.push({ ...record.manifest.cursor }); relocated ||= record.manifest.relocated === true; }
|
|
549
|
+
else if (record.kind === "relocate") { relocated = true; for (const state of states) state.workspace = record.workspace; }
|
|
550
|
+
}
|
|
551
|
+
if (relocated) for (const state of states) state.blob_dir = join(dir, "blobs");
|
|
552
|
+
const { events } = await readNode(dir, node);
|
|
553
|
+
return stateAtPosition(states, node, events.map(event => event.id), atEvent);
|
|
554
|
+
}
|
|
555
|
+
|
|
524
556
|
/** D3: the replaceable manifest is a projection of committed metadata events. */
|
|
525
557
|
export async function readManifest(dir: string): Promise<Manifest> {
|
|
526
558
|
return serialized(dir, () => readManifestUnlocked(dir));
|
|
@@ -534,6 +566,16 @@ async function readManifestUnlocked(dir: string): Promise<Manifest> {
|
|
|
534
566
|
switch (event.kind) {
|
|
535
567
|
case "seed": manifest = structuredClone(event.manifest); break;
|
|
536
568
|
case "cursor": manifest.cursor = event.cursor; break;
|
|
569
|
+
case "relocate":
|
|
570
|
+
manifest.relocated = true;
|
|
571
|
+
if (manifest.cursor) manifest.cursor = { ...manifest.cursor, workspace: event.workspace };
|
|
572
|
+
break;
|
|
573
|
+
case "parent_session": manifest.parent_session = event.path; break;
|
|
574
|
+
case "label":
|
|
575
|
+
manifest.labels ??= {};
|
|
576
|
+
if (event.text) Object.defineProperty(manifest.labels, event.target, { value: { text: event.text, at: event.at }, enumerable: true, configurable: true, writable: true });
|
|
577
|
+
else delete manifest.labels[event.target];
|
|
578
|
+
break;
|
|
537
579
|
case "node": {
|
|
538
580
|
const i = manifest.nodes.findIndex((n) => n.node === event.record.node);
|
|
539
581
|
if (i < 0) manifest.nodes.push(event.record);
|
|
@@ -544,9 +586,18 @@ async function readManifestUnlocked(dir: string): Promise<Manifest> {
|
|
|
544
586
|
}
|
|
545
587
|
}
|
|
546
588
|
manifest.nodes.sort((a, b) => a.node - b.node);
|
|
589
|
+
if (manifest.relocated && manifest.cursor) manifest.cursor = { ...manifest.cursor, blob_dir: join(dir, "blobs") };
|
|
547
590
|
return manifest;
|
|
548
591
|
}
|
|
549
592
|
|
|
593
|
+
/** Relocation changes recovery locations, not historical events or evidence (D3/D8). */
|
|
594
|
+
export async function relocateSession(dir: string, workspace: string): Promise<void> {
|
|
595
|
+
return serialized(dir, async () => {
|
|
596
|
+
await appendMetadata(dir, { kind: "relocate", workspace });
|
|
597
|
+
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
550
601
|
async function appendMetadata(dir: string, event: MetadataRecord): Promise<void> {
|
|
551
602
|
await mkdir(dir, { recursive: true });
|
|
552
603
|
const encode = (record: MetadataRecord) => JSON.stringify({
|
|
@@ -589,6 +640,20 @@ export async function readCursor(dir: string): Promise<Cursor | null> {
|
|
|
589
640
|
return (await readManifest(dir)).cursor ?? null;
|
|
590
641
|
}
|
|
591
642
|
|
|
643
|
+
/** Labels are display metadata, never model evidence or rewritten conversation events. */
|
|
644
|
+
export async function setSessionLabel(dir: string, target: string, text: string, at: string): Promise<void> {
|
|
645
|
+
return serialized(dir, async () => {
|
|
646
|
+
await appendMetadata(dir, { kind: "label", target, text: text.trim(), at });
|
|
647
|
+
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
export async function setParentSession(dir: string, path: string): Promise<void> {
|
|
651
|
+
return serialized(dir, async () => {
|
|
652
|
+
await appendMetadata(dir, { kind: "parent_session", path });
|
|
653
|
+
await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
592
657
|
/** Direct children of `node`, in allocation order. */
|
|
593
658
|
export function childrenOf(manifest: Manifest, node: NodeId): NodeRecord[] {
|
|
594
659
|
return manifest.nodes.filter((r) => r.parent?.node === node);
|
package/src/store/models.ts
CHANGED
|
@@ -8,23 +8,35 @@
|
|
|
8
8
|
* forbids.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { configDir } from "./config.ts";
|
|
14
15
|
|
|
15
16
|
export function modelsPath(): string {
|
|
16
17
|
return join(configDir(), "models.json");
|
|
17
18
|
}
|
|
19
|
+
export function catalogCachePath(): string { return join(configDir(), "models-cache.json"); }
|
|
18
20
|
|
|
19
21
|
export async function readModelsText(): Promise<{ text: string | null; error: string | null }> {
|
|
22
|
+
return readModelFile(modelsPath());
|
|
23
|
+
}
|
|
24
|
+
export async function readCatalogCacheText(): Promise<{ text: string | null; error: string | null }> {
|
|
25
|
+
return readModelFile(catalogCachePath());
|
|
26
|
+
}
|
|
27
|
+
async function readModelFile(path: string): Promise<{ text: string | null; error: string | null }> {
|
|
20
28
|
try {
|
|
21
|
-
const text = await readFile(
|
|
29
|
+
const text = await readFile(path, "utf8");
|
|
22
30
|
return { text, error: null };
|
|
23
|
-
} catch {
|
|
24
|
-
|
|
25
|
-
return { text: null, error: null };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const absent = typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
33
|
+
return { text: null, error: absent ? null : `cannot read ${path}` };
|
|
26
34
|
}
|
|
27
35
|
}
|
|
36
|
+
export async function readModelSources(): Promise<{ userText: string | null; cacheText: string | null; readWarnings: string[] }> {
|
|
37
|
+
const [user, cache] = await Promise.all([readModelsText(), readCatalogCacheText()]);
|
|
38
|
+
return { userText: user.text, cacheText: cache.text, readWarnings: [user.error, cache.error].filter((value): value is string => value !== null) };
|
|
39
|
+
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
30
42
|
* Replace, don't truncate. writeFile on the live path empties it first, so a
|
|
@@ -33,9 +45,16 @@ export async function readModelsText(): Promise<{ text: string | null; error: st
|
|
|
33
45
|
* existing bytes untouched (feat-json-mode-and-header).
|
|
34
46
|
*/
|
|
35
47
|
export async function writeModelsFile(value: unknown): Promise<void> {
|
|
48
|
+
return writeModelFile(modelsPath(), value);
|
|
49
|
+
}
|
|
50
|
+
export async function writeCatalogCache(value: unknown): Promise<void> {
|
|
51
|
+
return writeModelFile(catalogCachePath(), value);
|
|
52
|
+
}
|
|
53
|
+
async function writeModelFile(dest: string, value: unknown): Promise<void> {
|
|
36
54
|
await mkdir(configDir(), { recursive: true });
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
const tmp = `${dest}.${randomUUID()}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
58
|
+
await rename(tmp, dest);
|
|
59
|
+
} finally { await rm(tmp, { force: true }); }
|
|
41
60
|
}
|
|
@@ -52,6 +52,7 @@ const EXIT_DRAIN_MS = 1_000;
|
|
|
52
52
|
*/
|
|
53
53
|
async function spoolMerged(
|
|
54
54
|
dir: string, streams: readonly ReadableStream<Uint8Array>[], exited: Promise<number>,
|
|
55
|
+
onOutput?: (chunk: Uint8Array) => void,
|
|
55
56
|
): Promise<string> {
|
|
56
57
|
await mkdir(dir, { recursive: true });
|
|
57
58
|
const path = join(dir, `.${randomUUID()}.stream`);
|
|
@@ -74,7 +75,7 @@ async function spoolMerged(
|
|
|
74
75
|
const result = await reader.read();
|
|
75
76
|
if (result.done) return;
|
|
76
77
|
armIdle();
|
|
77
|
-
queue = queue.then(() => file.writeFile(result.value));
|
|
78
|
+
queue = queue.then(async () => { await file.writeFile(result.value); onOutput?.(result.value); });
|
|
78
79
|
await queue;
|
|
79
80
|
}
|
|
80
81
|
}));
|
|
@@ -165,8 +166,9 @@ export async function archiveObservation(
|
|
|
165
166
|
export async function captureCommandObservation(
|
|
166
167
|
dir: string, stdout: ReadableStream<Uint8Array>, stderr: ReadableStream<Uint8Array>,
|
|
167
168
|
exited: Promise<number>, previewBytes: number,
|
|
169
|
+
onOutput?: (chunk: Uint8Array) => void,
|
|
168
170
|
): Promise<ArchivedObservation & { exit: number; prefixBytes: number }> {
|
|
169
|
-
const merged = await spoolMerged(dir, [stdout, stderr], exited);
|
|
171
|
+
const merged = await spoolMerged(dir, [stdout, stderr], exited, onOutput);
|
|
170
172
|
try {
|
|
171
173
|
const exit = await exited;
|
|
172
174
|
const prefix = new TextEncoder().encode(`exit ${exit}\n`);
|