@visual-z/xeno 0.1.4 → 0.2.1
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 +25 -0
- package/README.md +143 -33
- package/docs/install.md +23 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +45 -18
- 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-input.ts +94 -0
- package/src/apps/startup.ts +413 -295
- 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/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/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,37 @@
|
|
|
1
|
+
/** Reload local dependency graphs without clearing unrelated process modules. */
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
export async function loadExtensionFactory(path: string, generation: number): Promise<unknown> {
|
|
8
|
+
// Pi uses a fresh jiti loader with moduleCache:false. Bun can rebuild the
|
|
9
|
+
// local graph itself, so this adds no production loader dependency (D13).
|
|
10
|
+
const result = await Bun.build({ entrypoints: [path], target: "bun", format: "esm",
|
|
11
|
+
plugins: [{ name: "extension-local-graph", setup(build) {
|
|
12
|
+
build.onResolve({ filter: /^[^./]/ }, args => {
|
|
13
|
+
if (isAbsolute(args.path) || args.path.startsWith("node:") || args.path.startsWith("bun:")) return undefined;
|
|
14
|
+
// A package belongs to the extension's installation. Never auto-install
|
|
15
|
+
// dependencies or resolve them against the current workspace.
|
|
16
|
+
return { path: Bun.resolveSync(args.path, dirname(args.importer || path)), external: true };
|
|
17
|
+
});
|
|
18
|
+
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async args => {
|
|
19
|
+
const url = pathToFileURL(args.path).href;
|
|
20
|
+
const transpiler = new Bun.Transpiler({ loader: /tsx$/.test(args.path) ? "tsx" : /jsx$/.test(args.path) ? "jsx" : "ts",
|
|
21
|
+
define: { "import.meta.url": JSON.stringify(url), "import.meta.dir": JSON.stringify(dirname(args.path)),
|
|
22
|
+
"import.meta.dirname": JSON.stringify(dirname(args.path)), "import.meta.path": JSON.stringify(args.path),
|
|
23
|
+
"import.meta.filename": JSON.stringify(args.path), "import.meta.main": "false" } });
|
|
24
|
+
return { contents: transpiler.transformSync(await Bun.file(args.path).text()), loader: "js" };
|
|
25
|
+
});
|
|
26
|
+
} }] });
|
|
27
|
+
if (!result.success || !result.outputs[0]) throw new Error(result.logs.map(log => log.message).join("\n") || "Extension build failed");
|
|
28
|
+
const code = await result.outputs[0].text() + `\n//# sourceURL=${pathToFileURL(path).href}?generation=${generation}\n`;
|
|
29
|
+
// A fresh file gives Bun an ordinary module identity without its data-URL
|
|
30
|
+
// path-length limit. The original import.meta locations were frozen above.
|
|
31
|
+
const staging = await mkdtemp(join(tmpdir(), "xeno-extension-module-"));
|
|
32
|
+
try {
|
|
33
|
+
const entry = join(staging, "extension.mjs"); await Bun.write(entry, code);
|
|
34
|
+
const module: { default?: unknown } = await import(pathToFileURL(entry).href);
|
|
35
|
+
return module.default;
|
|
36
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
37
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Structural Pi UI contract; the runtime never constructs or owns a terminal (D19). */
|
|
2
|
+
import type { TUI, OverlayHandle, OverlayOptions } from "../vendor/pi-tui/tui.ts";
|
|
3
|
+
import type { EditorComponent } from "../vendor/pi-tui/editor-component.ts";
|
|
4
|
+
import type { EditorTheme } from "../vendor/pi-tui/components/editor.ts";
|
|
5
|
+
import type { AutocompleteProvider } from "../vendor/pi-tui/autocomplete.ts";
|
|
6
|
+
import type { KeybindingsManager } from "../vendor/pi-tui/keybindings.ts";
|
|
7
|
+
import type { Theme } from "../core/theme.ts";
|
|
8
|
+
|
|
9
|
+
export interface ExtensionComponent { render(width: number): string[]; invalidate?(): void; handleInput?(data: string): void; dispose?(): void; focused?: boolean }
|
|
10
|
+
export interface ExtensionTheme extends Theme {
|
|
11
|
+
fg(color: string, text: string): string; bg(color: string, text: string): string;
|
|
12
|
+
bold(text: string): string; italic(text: string): string; underline(text: string): string; dim(text: string): string; inverse(text: string): string; strikethrough(text: string): string;
|
|
13
|
+
getFgAnsi(color: string): string; getBgAnsi(color: string): string;
|
|
14
|
+
getColorMode(): "truecolor"; getThinkingBorderColor(level: string): (text: string) => string; getBashModeBorderColor(): (text: string) => string;
|
|
15
|
+
}
|
|
16
|
+
export type WidgetFactory = (tui: TUI, theme: ExtensionTheme) => ExtensionComponent;
|
|
17
|
+
export type EditorFactory = (tui: TUI, theme: EditorTheme, bindings: KeybindingsManager) => EditorComponent & { dispose?(): void; focused?: boolean };
|
|
18
|
+
export type AutocompleteFactory = (base: AutocompleteProvider) => AutocompleteProvider;
|
|
19
|
+
export interface FooterData { getGitBranch(): string | undefined; getExtensionStatuses(): ReadonlyMap<string, string>; onBranchChange(callback: () => void): () => void }
|
|
20
|
+
export type FooterFactory = (tui: TUI, theme: ExtensionTheme, data: FooterData) => ExtensionComponent;
|
|
21
|
+
export interface DialogOptions { signal?: AbortSignal; timeout?: number }
|
|
22
|
+
export interface CustomOptions extends DialogOptions { overlay?: boolean; overlayOptions?: OverlayOptions | (() => OverlayOptions); onHandle?: (handle: OverlayHandle) => void }
|
|
23
|
+
export type InputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
|
|
24
|
+
export interface ExtensionUI {
|
|
25
|
+
select(title: string, options: string[], opts?: DialogOptions): Promise<string | undefined>;
|
|
26
|
+
confirm(title: string, message: string, opts?: DialogOptions): Promise<boolean>;
|
|
27
|
+
input(title: string, placeholder?: string, opts?: DialogOptions): Promise<string | undefined>;
|
|
28
|
+
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
29
|
+
custom<T>(factory: (tui: TUI, theme: ExtensionTheme, bindings: KeybindingsManager, done: (result: T) => void) => ExtensionComponent | Promise<ExtensionComponent>, options?: CustomOptions): Promise<T>;
|
|
30
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
31
|
+
onTerminalInput(handler: InputHandler): () => void;
|
|
32
|
+
setStatus(key: string, text: string | undefined): void;
|
|
33
|
+
setWorkingMessage(message?: string): void; setWorkingVisible(visible: boolean): void;
|
|
34
|
+
setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
|
|
35
|
+
setHiddenThinkingLabel(label?: string): void;
|
|
36
|
+
setWidget(key: string, content: string[] | WidgetFactory | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
|
|
37
|
+
setFooter(factory: FooterFactory | undefined): void; setHeader(factory: WidgetFactory | undefined): void;
|
|
38
|
+
setTitle(title: string): void;
|
|
39
|
+
pasteToEditor(text: string): void; setEditorText(text: string): void; getEditorText(): string;
|
|
40
|
+
addAutocompleteProvider(factory: AutocompleteFactory): void;
|
|
41
|
+
setEditorComponent(factory: EditorFactory | undefined): void; getEditorComponent(): EditorFactory | undefined;
|
|
42
|
+
readonly theme: ExtensionTheme;
|
|
43
|
+
getAllThemes(): { name: string; path: string | undefined }[];
|
|
44
|
+
getTheme(name: string): ExtensionTheme | undefined;
|
|
45
|
+
setTheme(theme: string | ExtensionTheme): { success: boolean; error?: string };
|
|
46
|
+
getToolsExpanded(): boolean; setToolsExpanded(expanded: boolean): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Headless extensions can notify and inspect capability; interactive requests fail explicitly. */
|
|
50
|
+
export function headlessUI(notify: ExtensionUI["notify"]): ExtensionUI {
|
|
51
|
+
return new Proxy({ notify }, { get(target, key) {
|
|
52
|
+
if (key === "notify") return target.notify;
|
|
53
|
+
return () => { throw new Error("Extension UI requires an interactive terminal; check ctx.hasUI"); };
|
|
54
|
+
} }) as ExtensionUI;
|
|
55
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/** Pi's factory/registration protocol, attached to xeno's session rather than another AgentSession. */
|
|
2
|
+
import { loadExtensionFactory } from "./extension-loader.ts";
|
|
3
|
+
import type { ResourceIndex } from "../store/resource-index.ts";
|
|
4
|
+
import type { ExtensionEvent, LogEvent } from "../core/events.ts";
|
|
5
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
6
|
+
import { runTool, type ToolContext, type ToolDefinition } from "./tools.ts";
|
|
7
|
+
import { attachImageBytes } from "./images.ts";
|
|
8
|
+
import type { ExtensionComponent, ExtensionUI, ExtensionTheme } from "./extension-ui.ts";
|
|
9
|
+
|
|
10
|
+
export interface ExtensionContext {
|
|
11
|
+
ui: ExtensionUI; cwd: string; mode: "tui" | "json" | "print"; hasUI: boolean;
|
|
12
|
+
model: unknown; thinkingLevel: string; signal: AbortSignal | undefined;
|
|
13
|
+
isIdle(): boolean; isProjectTrusted(): boolean; hasPendingMessages(): boolean;
|
|
14
|
+
abort(): void; shutdown(): void; getSystemPrompt(): string;
|
|
15
|
+
sessionManager: {
|
|
16
|
+
getEntries(): readonly LogEvent[]; getBranch(): readonly LogEvent[];
|
|
17
|
+
getSessionId(): string; getSessionFile(): string; getCwd(): string;
|
|
18
|
+
};
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
export type ExtensionHandler = (event: Record<string, unknown>, ctx: ExtensionContext) => unknown | Promise<unknown>;
|
|
22
|
+
export interface ExtensionCommand {
|
|
23
|
+
description?: string;
|
|
24
|
+
handler(args: string, ctx: ExtensionContext): void | Promise<void>;
|
|
25
|
+
getArgumentCompletions?(prefix: string): { value: string; label: string; description?: string }[] | null | Promise<{ value: string; label: string; description?: string }[] | null>;
|
|
26
|
+
}
|
|
27
|
+
export interface ExtensionToolResult {
|
|
28
|
+
content: ({ type: "text"; text: string } | { type: "image"; data: string; mimeType: string })[];
|
|
29
|
+
details?: JsonValue; isError?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ExtensionToolRenderContext {
|
|
32
|
+
args: unknown; toolCallId: string; invalidate(): void;
|
|
33
|
+
lastComponent: ExtensionComponent | undefined; state: Record<string, unknown>; cwd: string;
|
|
34
|
+
executionStarted: boolean; argsComplete: boolean; isPartial: boolean; expanded: boolean; showImages: boolean; isError: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface ExtensionTool {
|
|
37
|
+
name: string; label?: string; description: string; parameters: unknown;
|
|
38
|
+
promptSnippet?: string; promptGuidelines?: string[]; executionMode?: "parallel" | "sequential";
|
|
39
|
+
prepareArguments?(input: unknown): unknown;
|
|
40
|
+
execute(id: string, args: Record<string, unknown>, signal: AbortSignal | undefined,
|
|
41
|
+
onUpdate: (result: ExtensionToolResult) => void, ctx: ExtensionContext): Promise<ExtensionToolResult>;
|
|
42
|
+
renderCall?(args: unknown, theme: ExtensionTheme, context: ExtensionToolRenderContext): ExtensionComponent;
|
|
43
|
+
renderResult?(result: ExtensionToolResult, options: { expanded: boolean; isPartial: boolean }, theme: ExtensionTheme, context: ExtensionToolRenderContext): ExtensionComponent;
|
|
44
|
+
}
|
|
45
|
+
export type ExtensionRenderer = (message: Record<string, unknown>, options: { expanded: boolean; outputPad: number }, theme: ExtensionTheme) => ExtensionComponent | undefined;
|
|
46
|
+
type Flag = { type: "string" | "boolean"; description?: string; default?: string | boolean };
|
|
47
|
+
export interface ExtensionActions {
|
|
48
|
+
context(): ExtensionContext;
|
|
49
|
+
append(entry: Omit<ExtensionEvent, "t" | "id" | "at">, options?: { triggerTurn?: boolean; deliverAs?: string }): Promise<void>;
|
|
50
|
+
user(text: string, options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }): Promise<void>;
|
|
51
|
+
setName(name: string): Promise<void>;
|
|
52
|
+
setLabel(id: string, label: string | undefined): Promise<void>;
|
|
53
|
+
setTools(names: string[]): Promise<void>;
|
|
54
|
+
getTools(): { name: string; description: string; parameters: unknown }[];
|
|
55
|
+
setModel(model: unknown): Promise<boolean>;
|
|
56
|
+
setThinking(level: string): Promise<void>;
|
|
57
|
+
exec(command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
|
|
58
|
+
}
|
|
59
|
+
export interface ExtensionAPI {
|
|
60
|
+
on(event: string, handler: ExtensionHandler): () => void;
|
|
61
|
+
registerCommand(name: string, command: ExtensionCommand): void;
|
|
62
|
+
registerTool(tool: ExtensionTool): void;
|
|
63
|
+
registerShortcut(key: string, shortcut: { description?: string; handler(ctx: ExtensionContext): void | Promise<void> }): void;
|
|
64
|
+
registerFlag(name: string, options: Flag): void;
|
|
65
|
+
getFlag(name: string): string | boolean | undefined;
|
|
66
|
+
registerMessageRenderer(name: string, renderer: ExtensionRenderer): void;
|
|
67
|
+
registerEntryRenderer(name: string, renderer: ExtensionRenderer): void;
|
|
68
|
+
registerMarkdownTransformer(transform: (text: string, context: Record<string, unknown>) => string | undefined): void;
|
|
69
|
+
sendMessage(message: { customType: string; content: string | { type: "text"; text: string }[]; display?: boolean; details?: JsonValue }, options?: { triggerTurn?: boolean; deliverAs?: string }): void;
|
|
70
|
+
sendUserMessage(text: string, options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }): void;
|
|
71
|
+
appendEntry(name: string, data?: JsonValue): void;
|
|
72
|
+
setSessionName(name: string): void;
|
|
73
|
+
setLabel(id: string, label: string | undefined): void;
|
|
74
|
+
getActiveTools(): string[];
|
|
75
|
+
getAllTools(): { name: string; description: string; parameters: unknown }[];
|
|
76
|
+
setActiveTools(names: string[]): void;
|
|
77
|
+
setModel(model: unknown): Promise<boolean>;
|
|
78
|
+
getThinkingLevel(): string;
|
|
79
|
+
setThinkingLevel(level: string): void;
|
|
80
|
+
getCommands(): { name: string; description?: string }[];
|
|
81
|
+
exec: ExtensionActions["exec"];
|
|
82
|
+
events: { on(name: string, handler: (data: unknown) => void): () => void; emit(name: string, data?: unknown): void };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const prohibited = new Set(["context", "context_with_system", "session_before_compact", "session_compact", "session_compact_failed"]);
|
|
86
|
+
let generation = 0;
|
|
87
|
+
|
|
88
|
+
interface ExtensionRegistryOptions {
|
|
89
|
+
reserved?: readonly string[]; reservedFlags?: readonly string[];
|
|
90
|
+
flags?: Readonly<Record<string, string | boolean>>;
|
|
91
|
+
flagValue?: (name: string, definition: Flag) => string | boolean | undefined;
|
|
92
|
+
report?: (message: string) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Registrations are staged before activation, and disposed as one generation on reload. */
|
|
96
|
+
export class ExtensionRegistry {
|
|
97
|
+
readonly commands = new Map<string, ExtensionCommand>();
|
|
98
|
+
readonly tools = new Map<string, ExtensionTool>();
|
|
99
|
+
readonly shortcuts = new Map<string, { description?: string; handler(ctx: ExtensionContext): void | Promise<void> }>();
|
|
100
|
+
readonly flags = new Map<string, Flag>();
|
|
101
|
+
readonly renderers = new Map<string, ExtensionRenderer>();
|
|
102
|
+
readonly entryRenderers = new Map<string, ExtensionRenderer>();
|
|
103
|
+
readonly markdown: ((text: string, context: Record<string, unknown>) => string | undefined)[] = [];
|
|
104
|
+
readonly diagnostics: string[] = [];
|
|
105
|
+
readonly loaded: string[] = [];
|
|
106
|
+
private handlers = new Map<string, Set<ExtensionHandler>>();
|
|
107
|
+
private bus = new Map<string, Set<(data: unknown) => void>>();
|
|
108
|
+
private pending = new Set<Promise<void>>();
|
|
109
|
+
private disposers: (() => void)[] = [];
|
|
110
|
+
private detachListeners = new Set<() => void>();
|
|
111
|
+
private toolUpdateListeners = new Set<(name: string, id: string, input: unknown, result: ExtensionToolResult) => void>();
|
|
112
|
+
private actions: ExtensionActions | undefined;
|
|
113
|
+
private disposed = false;
|
|
114
|
+
private readonly options: ExtensionRegistryOptions;
|
|
115
|
+
constructor(options: ExtensionRegistryOptions = {}) { this.options = options; }
|
|
116
|
+
|
|
117
|
+
bind(actions: ExtensionActions): void { this.actions = actions; }
|
|
118
|
+
onDispose(detach: () => void): () => void { this.detachListeners.add(detach); return () => this.detachListeners.delete(detach); }
|
|
119
|
+
onToolUpdate(listener: (name: string, id: string, input: unknown, result: ExtensionToolResult) => void): () => void {
|
|
120
|
+
this.toolUpdateListeners.add(listener); return () => this.toolUpdateListeners.delete(listener);
|
|
121
|
+
}
|
|
122
|
+
private active(): ExtensionActions {
|
|
123
|
+
if (this.disposed || !this.actions) throw new Error("Extension session is not active");
|
|
124
|
+
return this.actions;
|
|
125
|
+
}
|
|
126
|
+
private enqueue(operation: () => Promise<void>): void {
|
|
127
|
+
const pending = Promise.resolve().then(operation).catch(error => this.report(String(error))).finally(() => this.pending.delete(pending));
|
|
128
|
+
this.pending.add(pending);
|
|
129
|
+
}
|
|
130
|
+
async settle(): Promise<void> { while (this.pending.size) await Promise.all([...this.pending]); }
|
|
131
|
+
private report(message: string): void { this.diagnostics.push(message); this.options.report?.(`Extension: ${message}`); }
|
|
132
|
+
private insert<T>(map: Map<string, T>, name: string, value: T, source: string): void {
|
|
133
|
+
if (!name || map.has(name)) throw new Error(`${source}: duplicate or empty registration: ${name}`);
|
|
134
|
+
map.set(name, value);
|
|
135
|
+
}
|
|
136
|
+
api(source: string): ExtensionAPI {
|
|
137
|
+
return {
|
|
138
|
+
on: (event, handler) => {
|
|
139
|
+
if (prohibited.has(event)) throw new Error(`${event} rewrites model context or summarizes history; xeno requires append-only events and mechanical checkpoints`);
|
|
140
|
+
const handlers = this.handlers.get(event) ?? new Set<ExtensionHandler>(); this.handlers.set(event, handlers); handlers.add(handler);
|
|
141
|
+
return () => { handlers.delete(handler); };
|
|
142
|
+
},
|
|
143
|
+
registerCommand: (name, command) => {
|
|
144
|
+
if (this.options.reserved?.includes(name)) throw new Error(`Command /${name} is reserved`);
|
|
145
|
+
this.insert(this.commands, name, command, source);
|
|
146
|
+
},
|
|
147
|
+
registerTool: tool => {
|
|
148
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(tool.name) || typeof tool.execute !== "function") throw new Error("Invalid tool definition");
|
|
149
|
+
if (["note", "history", "branch", "resolve"].includes(tool.name)) throw new Error(`${tool.name} owns the append-only harness and cannot be replaced`);
|
|
150
|
+
extensionSchema(tool.parameters); this.insert(this.tools, tool.name, tool, source);
|
|
151
|
+
},
|
|
152
|
+
registerShortcut: (name, value) => this.insert(this.shortcuts, name, value, source),
|
|
153
|
+
registerFlag: (name, value) => {
|
|
154
|
+
if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(name) || this.options.reservedFlags?.includes(name)) throw new Error(`CLI flag --${name} is reserved or invalid`);
|
|
155
|
+
this.insert(this.flags, name, value, source);
|
|
156
|
+
},
|
|
157
|
+
getFlag: name => { const flag = this.flags.get(name); return this.options.flags && Object.hasOwn(this.options.flags, name) ? this.options.flags[name] : (flag ? this.options.flagValue?.(name, flag) ?? flag.default : undefined); },
|
|
158
|
+
registerMessageRenderer: (name, value) => this.insert(this.renderers, name, value, source),
|
|
159
|
+
registerEntryRenderer: (name, value) => this.insert(this.entryRenderers, name, value, source),
|
|
160
|
+
registerMarkdownTransformer: transform => { this.markdown.push(transform); },
|
|
161
|
+
sendMessage: (message, options) => this.enqueue(() => this.active().append({ customType: message.customType,
|
|
162
|
+
content: typeof message.content === "string" ? message.content : message.content.map(c => c.text).join("\n"),
|
|
163
|
+
display: message.display !== false, inContext: true, ...(message.details === undefined ? {} : { data: message.details }) }, options)),
|
|
164
|
+
appendEntry: (name, data) => this.enqueue(() => this.active().append({ customType: name, content: "", display: this.entryRenderers.has(name), inContext: false, ...(data === undefined ? {} : { data }) })),
|
|
165
|
+
sendUserMessage: (text, options) => this.enqueue(() => this.active().user(text, options)),
|
|
166
|
+
setSessionName: name => this.enqueue(() => this.active().setName(name)),
|
|
167
|
+
setLabel: (id, label) => this.enqueue(() => this.active().setLabel(id, label)),
|
|
168
|
+
getActiveTools: () => this.active().getTools().map(tool => tool.name),
|
|
169
|
+
getAllTools: () => this.active().getTools(),
|
|
170
|
+
setActiveTools: names => this.enqueue(() => this.active().setTools(names)),
|
|
171
|
+
setModel: model => this.active().setModel(model),
|
|
172
|
+
getThinkingLevel: () => this.active().context().thinkingLevel,
|
|
173
|
+
setThinkingLevel: level => this.enqueue(() => this.active().setThinking(level)),
|
|
174
|
+
getCommands: () => [...this.commands].map(([name, command]) => ({ name, ...(command.description ? { description: command.description } : {}) })),
|
|
175
|
+
exec: (command, args, options) => this.active().exec(command, args, options),
|
|
176
|
+
events: {
|
|
177
|
+
on: (name, handler) => { const listeners = this.bus.get(name) ?? new Set(); this.bus.set(name, listeners); listeners.add(handler); return () => { listeners.delete(handler); }; },
|
|
178
|
+
emit: (name, data) => { for (const handler of this.bus.get(name) ?? []) try { handler(data); } catch (error) { this.report(String(error)); } },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async load(index: ResourceIndex): Promise<void> {
|
|
184
|
+
const version = ++generation;
|
|
185
|
+
for (const resource of index.entries.filter(r => r.kind === "extensions" && r.enabled)) {
|
|
186
|
+
const maps = [this.commands, this.tools, this.shortcuts, this.flags, this.renderers, this.entryRenderers];
|
|
187
|
+
const before = maps.map(map => new Set(map.keys()));
|
|
188
|
+
const oldHandlers = new Map([...this.handlers].map(([name, handlers]) => [name, new Set(handlers)]));
|
|
189
|
+
const oldBus = new Map([...this.bus].map(([name, handlers]) => [name, new Set(handlers)]));
|
|
190
|
+
const transforms = this.markdown.length;
|
|
191
|
+
try {
|
|
192
|
+
// External entry paths are runtime resources, never internal module aliases.
|
|
193
|
+
const factory = await loadExtensionFactory(resource.path, version);
|
|
194
|
+
if (typeof factory !== "function") throw new Error("Expected a default extension factory");
|
|
195
|
+
const dispose: unknown = await factory(this.api(resource.path));
|
|
196
|
+
if (typeof dispose === "function") this.disposers.push(dispose as () => void);
|
|
197
|
+
this.loaded.push(resource.path);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
maps.forEach((map, i) => { for (const key of map.keys()) if (!before[i]!.has(key)) map.delete(key); });
|
|
200
|
+
this.handlers = oldHandlers; this.bus = oldBus; this.markdown.length = transforms;
|
|
201
|
+
this.report(`${resource.path}: ${String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async emit(type: string, event: Record<string, unknown> = {}): Promise<Record<string, unknown>[]> {
|
|
207
|
+
if (!this.actions || this.disposed) return [];
|
|
208
|
+
const results: Record<string, unknown>[] = [];
|
|
209
|
+
for (const handler of [...this.handlers.get(type) ?? []]) {
|
|
210
|
+
try { const value = await handler({ ...event, type }, this.active().context()); if (typeof value === "object" && value !== null) results.push(value as Record<string, unknown>); }
|
|
211
|
+
catch (error) { this.report(`${type}: ${String(error)}`); }
|
|
212
|
+
}
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
async command(name: string, args: string): Promise<boolean> {
|
|
216
|
+
const command = this.commands.get(name); if (!command) return false;
|
|
217
|
+
try { await command.handler(args, this.active().context()); await this.settle(); } catch (error) { this.report(`/${name}: ${String(error)}`); }
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
definitions(): ReadonlyMap<string, ToolDefinition> {
|
|
221
|
+
return new Map<string, ToolDefinition>([...this.tools].map(([name, tool]) => [name, {
|
|
222
|
+
name, description: tool.description, input_schema: extensionSchema(tool.parameters),
|
|
223
|
+
// Arbitrary local code is not provably read-only. Keep admission and effects
|
|
224
|
+
// serial; an extension's annotation cannot grant a parallel write capability.
|
|
225
|
+
access: "write", execution: "exclusive", branch: "none",
|
|
226
|
+
...(tool.prepareArguments ? { prepareArguments: tool.prepareArguments } : {}),
|
|
227
|
+
run: async (ctx: ToolContext, raw: Record<string, unknown>) => {
|
|
228
|
+
let nested = 0;
|
|
229
|
+
const context = { ...this.active().context(), signal: ctx.signal, tools: this.active().getTools(),
|
|
230
|
+
executeTool: async (target: string, input: unknown) => {
|
|
231
|
+
if (++nested > 32 || (ctx.extensionDepth ?? 0) >= 32 || target === name) return { isError: true, content: [{ type: "text", text: "Nested tool recursion refused" }] };
|
|
232
|
+
const out = await runTool({ ...ctx, extensionDepth: (ctx.extensionDepth ?? 0) + 1, callId: `${ctx.callId ?? name}/${nested}` }, target, input);
|
|
233
|
+
return { isError: !out.ok, content: [{ type: "text", text: out.content }], details: out.data };
|
|
234
|
+
} };
|
|
235
|
+
const result = await tool.execute(ctx.callId ?? name, raw, ctx.signal, update => {
|
|
236
|
+
for (const listener of this.toolUpdateListeners) try { listener(name, ctx.callId ?? name, raw, update); } catch (error) { this.report(String(error)); }
|
|
237
|
+
ctx.onOutput?.(new TextEncoder().encode(update.content.filter(c => c.type === "text").map(c => c.text).join("\n")));
|
|
238
|
+
}, context);
|
|
239
|
+
const text = result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
|
240
|
+
const images = [];
|
|
241
|
+
for (const item of result.content) if (item.type === "image" && ctx.imageStore) images.push(await attachImageBytes(Buffer.from(item.data, "base64"), name + ".png", ctx.imageStore, ctx.imageAutoResize));
|
|
242
|
+
return { ok: result.isError !== true && !ctx.signal?.aborted, content: text,
|
|
243
|
+
...(isJsonValue(result.details) ? { data: result.details } : {}), ...(images.length ? { images } : {}) };
|
|
244
|
+
},
|
|
245
|
+
}]));
|
|
246
|
+
}
|
|
247
|
+
toolHooks(): NonNullable<ToolContext["extensionHooks"]> {
|
|
248
|
+
return {
|
|
249
|
+
before: async (toolName, input, toolCallId) => {
|
|
250
|
+
const blocked = (await this.emit("tool_call", { toolName, input, toolCallId })).find(result => result["block"] === true);
|
|
251
|
+
return blocked ? String(blocked["reason"] ?? "Blocked by extension") : undefined;
|
|
252
|
+
},
|
|
253
|
+
event: async (type, fields) => { await this.emit(type, fields); },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
async dispose(): Promise<void> {
|
|
257
|
+
if (this.disposed) return;
|
|
258
|
+
for (const detach of this.detachListeners) try { detach(); } catch (error) { this.report(String(error)); }
|
|
259
|
+
this.detachListeners.clear(); this.toolUpdateListeners.clear();
|
|
260
|
+
await this.emit("session_shutdown"); await this.settle(); this.disposed = true;
|
|
261
|
+
for (const dispose of this.disposers.reverse()) try { dispose(); } catch (error) { this.report(String(error)); }
|
|
262
|
+
this.handlers.clear(); this.bus.clear(); this.actions = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function extensionSchema(parameters: unknown): ToolDefinition["input_schema"] {
|
|
267
|
+
if (typeof parameters !== "object" || parameters === null) throw new Error("Tool parameters must be an object schema");
|
|
268
|
+
const schema = parameters as Record<string, unknown>;
|
|
269
|
+
if (schema["type"] !== "object" || typeof schema["properties"] !== "object" || schema["properties"] === null) throw new Error("Tool parameters need object properties");
|
|
270
|
+
const required = schema["required"] ?? [];
|
|
271
|
+
if (!Array.isArray(required) || required.some(value => typeof value !== "string")) throw new Error("Tool required fields must be names");
|
|
272
|
+
return { ...schema, type: "object", properties: schema["properties"] as Record<string, unknown>, required: required as string[] };
|
|
273
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Pi (MIT) processing, xeno (D8) immutable storage; decoding stays outside core and off the TUI thread. */
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { resizeImage } from "../vendor/pi-rich/image-resize.ts";
|
|
5
|
+
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
6
|
+
import { convertImageBytesToPng, convertToPng } from "../vendor/pi-rich/image-convert.ts";
|
|
7
|
+
import { putBlob, getBlobBytes } from "../store/blobs.ts";
|
|
8
|
+
import { imageDescription, validImageRef, type ImageRef } from "../core/images.ts";
|
|
9
|
+
import type { ContentBlock, Message } from "../core/project.ts";
|
|
10
|
+
|
|
11
|
+
// Admission limit, not a context threshold: avoid reading arbitrarily large local binaries into WASM.
|
|
12
|
+
const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
export async function attachImageBytes(bytes: Uint8Array, name: string, blobDir: string, autoResize = true): Promise<ImageRef> {
|
|
14
|
+
if (bytes.byteLength > MAX_IMAGE_SOURCE_BYTES) throw new Error("Image exceeds the 64 MiB attachment limit");
|
|
15
|
+
let mime = detectSupportedImageMimeType(bytes);
|
|
16
|
+
if (!mime) throw new Error("Unsupported or malformed image: " + name);
|
|
17
|
+
const originalBytes = bytes;
|
|
18
|
+
if (mime === "image/bmp") {
|
|
19
|
+
const converted = await convertImageBytesToPng(bytes);
|
|
20
|
+
if (!converted) throw new Error("Cannot decode image: " + name);
|
|
21
|
+
bytes = converted; mime = "image/png";
|
|
22
|
+
}
|
|
23
|
+
// Pi's disabled auto-resize setting preserves the input; decoding still verifies its dimensions.
|
|
24
|
+
const result = await resizeImage(bytes, mime, autoResize ? undefined : { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER });
|
|
25
|
+
if (!result) throw new Error("Cannot decode or resize image: " + name);
|
|
26
|
+
if (result.mimeType !== "image/png" && result.mimeType !== "image/jpeg" && result.mimeType !== "image/gif" && result.mimeType !== "image/webp") {
|
|
27
|
+
throw new Error("Unsupported image encoding: " + result.mimeType);
|
|
28
|
+
}
|
|
29
|
+
const original = await putBlob(blobDir, originalBytes);
|
|
30
|
+
const encoded = Buffer.from(result.data, "base64");
|
|
31
|
+
const sha256 = await putBlob(blobDir, encoded);
|
|
32
|
+
return { sha256, mimeType: result.mimeType, width: result.width, height: result.height, bytes: encoded.length, name,
|
|
33
|
+
...(sha256 !== original ? { original: { sha256: original, width: result.originalWidth, height: result.originalHeight } } : {}) };
|
|
34
|
+
}
|
|
35
|
+
export async function attachImageFile(path: string, blobDir: string, autoResize = true): Promise<ImageRef | null> {
|
|
36
|
+
const size = (await stat(path)).size;
|
|
37
|
+
if (size > MAX_IMAGE_SOURCE_BYTES) {
|
|
38
|
+
if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(path)) throw new Error("Image exceeds the 64 MiB attachment limit");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const bytes = await readFile(path);
|
|
42
|
+
if (!detectSupportedImageMimeType(bytes)) {
|
|
43
|
+
if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(path)) throw new Error("Unsupported or malformed image: " + path);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return attachImageBytes(bytes, basename(path), blobDir, autoResize);
|
|
47
|
+
}
|
|
48
|
+
export async function loadImage(image: ImageRef, blobDir: string): Promise<string> {
|
|
49
|
+
if (!validImageRef(image)) throw new Error("Invalid image reference");
|
|
50
|
+
const bytes = await getBlobBytes(blobDir, image.sha256);
|
|
51
|
+
if (!bytes) throw new Error("Image blob is missing: " + image.name);
|
|
52
|
+
if (bytes.length !== image.bytes) throw new Error("Image blob size mismatch: " + image.name);
|
|
53
|
+
return Buffer.from(bytes).toString("base64");
|
|
54
|
+
}
|
|
55
|
+
/** Kitty accepts PNG; conversion is display-only and never replaces durable provider bytes. */
|
|
56
|
+
export async function loadDisplayImage(image: ImageRef, blobDir: string): Promise<string> {
|
|
57
|
+
const png = await convertToPng(await loadImage(image, blobDir), image.mimeType);
|
|
58
|
+
if (!png) throw new Error("Cannot display image: " + image.name);
|
|
59
|
+
return png.data;
|
|
60
|
+
}
|
|
61
|
+
export async function hydrateImages(messages: readonly Message[], blobDir: string, acceptsImages: boolean, blocked = false): Promise<Message[]> {
|
|
62
|
+
const loaded = new Map<string, Promise<string>>();
|
|
63
|
+
return Promise.all(messages.map(async message => ({ ...message, content: await Promise.all(message.content.map(async (block): Promise<ContentBlock> => {
|
|
64
|
+
if (block.type !== "image") return block;
|
|
65
|
+
if (blocked || block.image.blocked || !acceptsImages) return { type: "text", text: imageDescription(block.image) + (blocked || block.image.blocked ? " [image transmission blocked by settings]" : " [this model does not support image input]") };
|
|
66
|
+
let data = loaded.get(block.image.sha256);
|
|
67
|
+
if (!data) { data = loadImage(block.image, blobDir); loaded.set(block.image.sha256, data); }
|
|
68
|
+
return { ...block, data: await data };
|
|
69
|
+
})) })));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Provider boundary fails closed if storage hydration was accidentally skipped. */
|
|
73
|
+
export function imageData(block: Extract<ContentBlock, { type: "image" }>): string {
|
|
74
|
+
if (!block.data) throw new Error("Image was not loaded before provider encoding: " + block.image.name);
|
|
75
|
+
return block.data;
|
|
76
|
+
}
|