@visual-z/xeno 0.2.1 → 0.3.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 +9 -0
- package/README.md +106 -26
- package/docs/install.md +51 -13
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +128 -86
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- 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 +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- 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 +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- 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 +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** R2: scan completeness and preview completeness answer different questions. */
|
|
2
|
+
import { headTailSelection, isObservationArtifact, isBytePreview, type BytePreview } from "./observation-view.ts";
|
|
3
|
+
import type { ArtifactRef } from "./events.ts";
|
|
4
|
+
import { artifactRecovery } from "./prompt-policy.ts";
|
|
5
|
+
|
|
6
|
+
export interface SearchRow { prefix: string; text: string; }
|
|
7
|
+
export interface SearchScan {
|
|
8
|
+
status: "complete" | "partial" | "unknown";
|
|
9
|
+
reasons: { code: string; path?: string }[];
|
|
10
|
+
examined_files: number | null;
|
|
11
|
+
source_versions: "recorded" | "unknown";
|
|
12
|
+
}
|
|
13
|
+
export interface SearchObservation {
|
|
14
|
+
engine: "walk" | "ripgrep" | "fd" | "directory";
|
|
15
|
+
rows: SearchRow[];
|
|
16
|
+
scan: SearchScan;
|
|
17
|
+
sources: { path: string; sha256: string }[];
|
|
18
|
+
raw?: Uint8Array;
|
|
19
|
+
rawErrors?: Uint8Array;
|
|
20
|
+
limit: number;
|
|
21
|
+
empty: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchReceipt {
|
|
25
|
+
kind: "search";
|
|
26
|
+
version: 1;
|
|
27
|
+
source: { host: string; workspace: string; resource: string };
|
|
28
|
+
engine: SearchObservation["engine"];
|
|
29
|
+
scan: { status: SearchScan["status"]; reasons: string[]; examined_files: number | null; source_versions: SearchScan["source_versions"] };
|
|
30
|
+
matches: number;
|
|
31
|
+
artifact: ArtifactRef;
|
|
32
|
+
manifest: ArtifactRef;
|
|
33
|
+
raw?: ArtifactRef;
|
|
34
|
+
raw_errors?: ArtifactRef;
|
|
35
|
+
preview: BytePreview;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
39
|
+
const natural = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
40
|
+
export function isSearchReceipt(value: unknown): value is SearchReceipt {
|
|
41
|
+
if (!object(value) || value["kind"] !== "search" || value["version"] !== 1 || !isObservationArtifact(value["artifact"]) || !isObservationArtifact(value["manifest"]) || !natural(value["matches"])) return false;
|
|
42
|
+
if (!["walk", "ripgrep", "fd", "directory"].includes(String(value["engine"])) || value["raw"] !== undefined && !isObservationArtifact(value["raw"])) return false;
|
|
43
|
+
if (value["raw_errors"] !== undefined && !isObservationArtifact(value["raw_errors"])) return false;
|
|
44
|
+
const source = value["source"], scan = value["scan"];
|
|
45
|
+
if (!object(source) || !["host", "workspace", "resource"].every(key => typeof source[key] === "string" && source[key] !== "")) return false;
|
|
46
|
+
if (!object(scan) || !["complete", "partial", "unknown"].includes(String(scan["status"])) || !["recorded", "unknown"].includes(String(scan["source_versions"])) ||
|
|
47
|
+
!Array.isArray(scan["reasons"]) || !scan["reasons"].every(reason => typeof reason === "string") || scan["examined_files"] !== null && !natural(scan["examined_files"])) return false;
|
|
48
|
+
if (scan["status"] === "complete" && scan["reasons"].length !== 0 || scan["status"] !== "complete" && scan["reasons"].length === 0) return false;
|
|
49
|
+
return isBytePreview(value["preview"], value["artifact"].bytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
const bytes = (text: string) => encoder.encode(text).length;
|
|
54
|
+
export const searchObservationText = (rows: readonly SearchRow[]): string => rows.map(row => row.prefix + row.text).join("\n");
|
|
55
|
+
|
|
56
|
+
/** Map count, per-line, and UTF-8 byte cuts back to the same immutable full observation. */
|
|
57
|
+
export function previewSearchObservation(observation: SearchObservation, artifact: string, maxBytes: number, maxLineChars = 500, notice = "", tools?: readonly string[]) {
|
|
58
|
+
let text = "", source = 0, view = 0;
|
|
59
|
+
const spans: { view: number; source: number; bytes: number }[] = [];
|
|
60
|
+
const mapped = (value: string) => { const size = bytes(value); if (size) spans.push({ view, source, bytes: size }); text += value; view += size; };
|
|
61
|
+
const synthetic = (value: string) => { text += value; view += bytes(value); };
|
|
62
|
+
if (notice) synthetic(notice + "\n");
|
|
63
|
+
const shown = Math.max(0, Math.min(observation.rows.length, observation.limit));
|
|
64
|
+
for (let index = 0; index < observation.rows.length; index++) {
|
|
65
|
+
const row = observation.rows[index]!;
|
|
66
|
+
if (index < shown) {
|
|
67
|
+
let end = Math.min(maxLineChars, row.text.length);
|
|
68
|
+
if (end > 0 && end < row.text.length && /[\uD800-\uDBFF]/.test(row.text[end - 1]!)) end--;
|
|
69
|
+
const kept = row.prefix + row.text.slice(0, end);
|
|
70
|
+
mapped(kept);
|
|
71
|
+
if (end < row.text.length) synthetic(`… [line truncated; ${artifactRecovery(artifact, source + bytes(kept), tools)}]`);
|
|
72
|
+
}
|
|
73
|
+
source += bytes(row.prefix + row.text);
|
|
74
|
+
if (index < observation.rows.length - 1) { if (index < shown) mapped("\n"); source++; }
|
|
75
|
+
}
|
|
76
|
+
if (!observation.rows.length) synthetic(observation.scan.status === "complete" ? observation.empty : "[No matches observed in the scanned portion]");
|
|
77
|
+
if (shown < observation.rows.length) synthetic(`[${observation.rows.length - shown} more matches omitted; ${artifactRecovery(artifact, 0, tools)}]`);
|
|
78
|
+
if (observation.scan.status !== "complete") synthetic(`\n[scan ${observation.scan.status}: ${[...new Set(observation.scan.reasons.map(reason => reason.code === "match_timeout" ? "search cut off: match_timeout" : reason.code))].join(", ")}]`);
|
|
79
|
+
const previewBytes = encoder.encode(text);
|
|
80
|
+
const selected = headTailSelection(previewBytes, previewBytes, previewBytes.length, maxBytes,
|
|
81
|
+
omitted => `\n[truncated ${omitted} preview bytes; ${artifactRecovery(artifact, 0, tools)}]\n`);
|
|
82
|
+
const visible: [number, number][] = [];
|
|
83
|
+
for (const [lo, hi] of selected.visible) for (const span of spans) {
|
|
84
|
+
const begin = Math.max(lo, span.view), end = Math.min(hi, span.view + span.bytes);
|
|
85
|
+
if (end <= begin) continue;
|
|
86
|
+
const a = span.source + begin - span.view, b = span.source + end - span.view;
|
|
87
|
+
const last = visible.at(-1);
|
|
88
|
+
if (last?.[1] === a) last[1] = b;
|
|
89
|
+
else visible.push([a, b]);
|
|
90
|
+
}
|
|
91
|
+
let next = 0;
|
|
92
|
+
for (const [lo, hi] of visible) { if (lo !== next) break; next = hi; }
|
|
93
|
+
const full = encoder.encode(searchObservationText(observation.rows));
|
|
94
|
+
const semantic = visible.map(([lo, hi]) => new TextDecoder().decode(full.subarray(lo, hi))).join("");
|
|
95
|
+
return { text: selected.text, semantic, preview: { unit: "utf8_bytes" as const, complete: next === source,
|
|
96
|
+
visible, next_offset: next === source ? null : next } };
|
|
97
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session tree as data. Pure: restore points + parent pointers in,
|
|
3
|
+
* a forest out; plus a flattening that the UI and the line-mode printer
|
|
4
|
+
* share. Moved from src/tui/tree.ts when the view was rewritten (D20) so the
|
|
5
|
+
* shape of history does not depend on any renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Same mark /back uses for a branch the session rewound away from. */
|
|
9
|
+
export const LEFT_BRANCH_MARK = "↩";
|
|
10
|
+
export const EMPTY_TREE_MESSAGE = "nothing to go back to -- no exchange has completed yet";
|
|
11
|
+
|
|
12
|
+
export type TreeKind = "user" | "other";
|
|
13
|
+
export type TreeFilter = "user" | "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
|
14
|
+
|
|
15
|
+
export interface TreeNode {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly prompt: string;
|
|
18
|
+
readonly onPath: boolean;
|
|
19
|
+
readonly current: boolean;
|
|
20
|
+
readonly kind: TreeKind;
|
|
21
|
+
readonly children: readonly TreeNode[];
|
|
22
|
+
readonly category?: "tool" | "message" | "metadata";
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
readonly labelAt?: string;
|
|
25
|
+
readonly at?: string;
|
|
26
|
+
readonly content?: string;
|
|
27
|
+
/** Exact event position; user messages navigate before themselves. */
|
|
28
|
+
readonly target?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A restore point as the log already knows it. parent pointers are a
|
|
33
|
+
* separate map so this file never imports store.
|
|
34
|
+
*/
|
|
35
|
+
export interface TreePoint {
|
|
36
|
+
readonly node: number;
|
|
37
|
+
readonly atEvent: string;
|
|
38
|
+
readonly prompt: string;
|
|
39
|
+
readonly onPath: boolean;
|
|
40
|
+
readonly kind?: TreeKind;
|
|
41
|
+
readonly category?: "tool" | "message" | "metadata";
|
|
42
|
+
readonly label?: string;
|
|
43
|
+
readonly labelAt?: string;
|
|
44
|
+
readonly at?: string;
|
|
45
|
+
readonly content?: string;
|
|
46
|
+
readonly target?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NodeParent = { readonly node: number; readonly at_event?: string } | null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Restore points plus parent pointers → the tree the view paints.
|
|
53
|
+
*
|
|
54
|
+
* Same-node exchanges stay a chain. A child node whose parent.at_event
|
|
55
|
+
* matches a point hangs off that point as a sibling of the continuation,
|
|
56
|
+
* which is how a rewind becomes a visible fork instead of a missing row.
|
|
57
|
+
*/
|
|
58
|
+
export function treeFromPoints(
|
|
59
|
+
points: readonly TreePoint[],
|
|
60
|
+
parents: ReadonlyMap<number, NodeParent>,
|
|
61
|
+
currentNode: number,
|
|
62
|
+
): TreeNode[] {
|
|
63
|
+
const byNode = new Map<number, TreePoint[]>();
|
|
64
|
+
for (const p of points) {
|
|
65
|
+
const list = byNode.get(p.node) ?? [];
|
|
66
|
+
list.push(p);
|
|
67
|
+
byNode.set(p.node, list);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lastHere = [...points].reverse().find((p) => p.onPath && p.node === currentNode);
|
|
71
|
+
const lastOnPath = lastHere ?? [...points].reverse().find((p) => p.onPath);
|
|
72
|
+
|
|
73
|
+
function toNode(p: TreePoint, children: readonly TreeNode[]): TreeNode {
|
|
74
|
+
return {
|
|
75
|
+
...p,
|
|
76
|
+
id: `${p.node}:${p.atEvent}`,
|
|
77
|
+
prompt: p.prompt,
|
|
78
|
+
onPath: p.onPath,
|
|
79
|
+
current: lastOnPath !== undefined && lastOnPath.node === p.node && lastOnPath.atEvent === p.atEvent,
|
|
80
|
+
kind: p.kind ?? "user",
|
|
81
|
+
children,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Wire event chains and branch edges separately. Recursing once per event
|
|
86
|
+
// overflows the JS stack on otherwise valid long sessions.
|
|
87
|
+
type MutableNode = TreeNode & { children: TreeNode[] };
|
|
88
|
+
const chains = new Map<number, MutableNode[]>();
|
|
89
|
+
const events = new Map<string, MutableNode>();
|
|
90
|
+
for (const [id, pts] of byNode) {
|
|
91
|
+
const nodes = pts.map(p => toNode(p, []) as MutableNode);
|
|
92
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
93
|
+
events.set(nodes[i]!.id, nodes[i]!);
|
|
94
|
+
if (nodes[i + 1]) nodes[i]!.children.push(nodes[i + 1]!);
|
|
95
|
+
}
|
|
96
|
+
chains.set(id, nodes);
|
|
97
|
+
}
|
|
98
|
+
const forest: TreeNode[] = [];
|
|
99
|
+
for (const [id, nodes] of [...chains].sort(([a], [b]) => a - b)) {
|
|
100
|
+
const first = nodes[0];
|
|
101
|
+
if (!first) continue;
|
|
102
|
+
let parent = parents.get(id);
|
|
103
|
+
const visited = new Set<number>([id]);
|
|
104
|
+
while (parent && !chains.get(parent.node)?.length && !visited.has(parent.node)) {
|
|
105
|
+
visited.add(parent.node); parent = parents.get(parent.node);
|
|
106
|
+
}
|
|
107
|
+
const anchor = parent && !visited.has(parent.node) ? events.get(`${parent.node}:${parent.at_event}`) ?? chains.get(parent.node)?.at(-1) : undefined;
|
|
108
|
+
if (anchor) anchor.children.push(first); else forest.push(first);
|
|
109
|
+
}
|
|
110
|
+
return forest;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
export interface TreeRow { readonly node: TreeNode; readonly depth: number; readonly branch: string }
|
|
115
|
+
|
|
116
|
+
/** Depth-first rows with connector prefixes; iterative so a 10k-event session cannot overflow the stack. */
|
|
117
|
+
export function flattenTree(nodes: readonly TreeNode[], filter: TreeFilter = "all"): TreeRow[] {
|
|
118
|
+
const rows: TreeRow[] = [];
|
|
119
|
+
const stack = visibleTree(nodes, filter).map((node, i, all) => ({ node, prefix: "", root: true, last: i === all.length - 1 })).reverse();
|
|
120
|
+
while (stack.length) {
|
|
121
|
+
const { node, prefix, root, last } = stack.pop()!;
|
|
122
|
+
rows.push({ node, depth: prefix.length / 3, branch: root ? "" : prefix + (last ? "└─ " : "├─ ") });
|
|
123
|
+
const next = root ? "" : prefix + (last ? " " : "│ ");
|
|
124
|
+
const children = node.children;
|
|
125
|
+
for (let i = children.length - 1; i >= 0; i--) stack.push({ node: children[i]!, prefix: next.length > 30 ? next.slice(-30) : next, root: false, last: i === children.length - 1 });
|
|
126
|
+
}
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Filtering hides a node but keeps its descendants: they re-attach to the
|
|
132
|
+
* nearest visible ancestor, so a "user only" view is still a tree.
|
|
133
|
+
*/
|
|
134
|
+
export function visibleTree(nodes: readonly TreeNode[], filter: TreeFilter): TreeNode[] {
|
|
135
|
+
if (filter === "all") return [...nodes];
|
|
136
|
+
const out: TreeNode[] = [];
|
|
137
|
+
const stack = [...nodes].reverse().map(node => ({ node, into: out }));
|
|
138
|
+
while (stack.length) {
|
|
139
|
+
const { node: n, into } = stack.pop()!;
|
|
140
|
+
const include = filter === "user" || filter === "user-only" ? n.kind === "user" :
|
|
141
|
+
filter === "labeled-only" ? Boolean(n.label) : filter === "no-tools" ? n.category !== "tool" && n.category !== "metadata" : n.category !== "metadata";
|
|
142
|
+
const children: TreeNode[] = [];
|
|
143
|
+
if (include) into.push({ ...n, children });
|
|
144
|
+
for (let i = n.children.length - 1; i >= 0; i--) stack.push({ node: n.children[i]!, into: include ? children : into });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { packageDeclaration, type PackageDeclaration } from "./packages.ts";
|
|
|
4
4
|
/** Validated configuration. D19: display policy never mutates the log. */
|
|
5
5
|
export interface Settings {
|
|
6
6
|
readonly theme?: string;
|
|
7
|
+
/** Presentation only: no animation is allowed to advance a log or block input. */
|
|
8
|
+
readonly animations?: boolean;
|
|
7
9
|
readonly thinking?: string;
|
|
8
10
|
readonly statusBar?: boolean;
|
|
11
|
+
/** Look for a newer published xeno once a day and say so (docs/tasks/feat-update-check.md). Default on. */
|
|
12
|
+
readonly updateCheck?: boolean;
|
|
9
13
|
readonly hideThinkingBlock?: boolean;
|
|
10
14
|
readonly showCacheMissNotices?: boolean;
|
|
11
15
|
readonly defaultProvider?: string;
|
|
@@ -13,9 +17,11 @@ export interface Settings {
|
|
|
13
17
|
readonly defaultThinking?: string;
|
|
14
18
|
readonly modelThinkingLevels?: Readonly<Record<string, ThinkingLevel>>;
|
|
15
19
|
readonly scopedModels?: readonly string[];
|
|
20
|
+
/** provider/model keys, most recent first; the /model picker's first section. */
|
|
21
|
+
readonly recentModels?: readonly string[];
|
|
16
22
|
readonly steeringMode?: "all" | "one-at-a-time";
|
|
17
23
|
readonly followUpMode?: "all" | "one-at-a-time";
|
|
18
|
-
readonly tuiMode?: "regular" | "fullscreen";
|
|
24
|
+
readonly tuiMode?: "auto" | "regular" | "fullscreen";
|
|
19
25
|
readonly fullscreenExitOutput?: "transcript" | "resume-hint";
|
|
20
26
|
readonly fullscreenScrollbar?: "auto" | "always" | "hidden";
|
|
21
27
|
readonly fullscreenCopyOnSelect?: boolean;
|
|
@@ -35,12 +41,14 @@ export interface Settings {
|
|
|
35
41
|
readonly packages?: readonly PackageDeclaration[];
|
|
36
42
|
readonly enableSkillCommands?: boolean;
|
|
37
43
|
readonly defaultProjectTrust?: "ask" | "always" | "never";
|
|
38
|
-
readonly terminal?: { readonly clearOnShrink?: boolean; readonly showTerminalProgress?: boolean; readonly showImages?: boolean; readonly imageWidthCells?: number
|
|
44
|
+
readonly terminal?: { readonly clearOnShrink?: boolean; readonly showTerminalProgress?: boolean; readonly showImages?: boolean; readonly imageWidthCells?: number;
|
|
45
|
+
/** How pictures are drawn; `auto` names them instead when the terminal can only draw block characters. */
|
|
46
|
+
readonly imageProtocol?: "auto" | "kitty" | "sixel" | "blocks" };
|
|
39
47
|
readonly images?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
|
|
40
48
|
readonly markdown?: { readonly mermaid?: "off" | "final" | "streaming" };
|
|
41
49
|
}
|
|
42
|
-
export const SETTINGS_KEYS = ["theme", "thinking", "statusBar", "hideThinkingBlock", "showCacheMissNotices", "defaultProvider", "defaultModel", "defaultThinking",
|
|
43
|
-
"modelThinkingLevels", "scopedModels", "steeringMode", "followUpMode", "tuiMode", "fullscreenExitOutput", "fullscreenScrollbar",
|
|
50
|
+
export const SETTINGS_KEYS = ["theme", "animations", "thinking", "statusBar", "updateCheck", "hideThinkingBlock", "showCacheMissNotices", "defaultProvider", "defaultModel", "defaultThinking",
|
|
51
|
+
"modelThinkingLevels", "scopedModels", "recentModels", "steeringMode", "followUpMode", "tuiMode", "fullscreenExitOutput", "fullscreenScrollbar",
|
|
44
52
|
"fullscreenCopyOnSelect", "fullscreenWheelScrollLines", "editorPaddingX", "outputPad", "autocompleteMaxVisible",
|
|
45
53
|
"showHardwareCursor", "quietStartup", "doubleEscapeAction", "treeFilterMode", "externalEditor", "terminal", "images", "markdown",
|
|
46
54
|
"skills", "prompts", "themes", "extensions", "packages", "enableSkillCommands", "defaultProjectTrust"] as const;
|
|
@@ -52,11 +60,11 @@ type Rule = { readonly kind: "boolean" | "string" | "strings" } | { readonly kin
|
|
|
52
60
|
const levels: Rule = { kind: "enum", values: THINKING_LEVELS };
|
|
53
61
|
const delivery: Rule = { kind: "enum", values: ["all", "one-at-a-time"] };
|
|
54
62
|
const rules: Readonly<Record<string, Rule>> = {
|
|
55
|
-
theme: { kind: "string" }, thinking: levels, statusBar: { kind: "boolean" },
|
|
63
|
+
theme: { kind: "string" }, animations: { kind: "boolean" }, thinking: levels, statusBar: { kind: "boolean" }, updateCheck: { kind: "boolean" },
|
|
56
64
|
hideThinkingBlock: { kind: "boolean" }, showCacheMissNotices: { kind: "boolean" },
|
|
57
65
|
defaultProvider: { kind: "string" }, defaultModel: { kind: "string" }, defaultThinking: levels,
|
|
58
|
-
scopedModels: { kind: "strings" }, steeringMode: delivery, followUpMode: delivery,
|
|
59
|
-
tuiMode: { kind: "enum", values: ["regular", "fullscreen"] },
|
|
66
|
+
scopedModels: { kind: "strings" }, recentModels: { kind: "strings" }, steeringMode: delivery, followUpMode: delivery,
|
|
67
|
+
tuiMode: { kind: "enum", values: ["auto", "regular", "fullscreen"] },
|
|
60
68
|
fullscreenExitOutput: { kind: "enum", values: ["transcript", "resume-hint"] },
|
|
61
69
|
fullscreenScrollbar: { kind: "enum", values: ["auto", "always", "hidden"] },
|
|
62
70
|
fullscreenCopyOnSelect: { kind: "boolean" },
|
|
@@ -70,6 +78,7 @@ const rules: Readonly<Record<string, Rule>> = {
|
|
|
70
78
|
externalEditor: { kind: "string" }, "terminal.clearOnShrink": { kind: "boolean" },
|
|
71
79
|
"terminal.showTerminalProgress": { kind: "boolean" },
|
|
72
80
|
"terminal.showImages": { kind: "boolean" }, "terminal.imageWidthCells": { kind: "integer", min: 1, max: 240 },
|
|
81
|
+
"terminal.imageProtocol": { kind: "enum", values: ["auto", "kitty", "sixel", "blocks"] },
|
|
73
82
|
"images.autoResize": { kind: "boolean" }, "images.blockImages": { kind: "boolean" },
|
|
74
83
|
"markdown.mermaid": { kind: "enum", values: ["off", "final", "streaming"] },
|
|
75
84
|
skills: { kind: "strings" }, prompts: { kind: "strings" }, themes: { kind: "strings" }, extensions: { kind: "strings" },
|
|
@@ -77,19 +86,22 @@ const rules: Readonly<Record<string, Rule>> = {
|
|
|
77
86
|
};
|
|
78
87
|
/** One catalog supplies menu values and command validation. */
|
|
79
88
|
export const DISPLAY_SETTINGS = [
|
|
89
|
+
{ id: "animations", label: "Animations", fallback: "on" },
|
|
80
90
|
{ id: "hideThinkingBlock", label: "Hide thinking blocks", fallback: "on" },
|
|
81
91
|
{ id: "showCacheMissNotices", label: "Cache miss notices", fallback: "off" },
|
|
82
92
|
{ id: "enableSkillCommands", label: "Skill commands", fallback: "on" },
|
|
83
93
|
{ id: "defaultProjectTrust", label: "Default project trust", fallback: "ask" },
|
|
94
|
+
{ id: "updateCheck", label: "Check for updates", fallback: "on" },
|
|
84
95
|
{ id: "terminal.showImages", label: "Show images", fallback: "on" },
|
|
96
|
+
{ id: "terminal.imageProtocol", label: "Image protocol", fallback: "auto" },
|
|
85
97
|
{ id: "terminal.imageWidthCells", label: "Image width", fallback: "60" },
|
|
86
98
|
{ id: "images.autoResize", label: "Resize images", fallback: "on" },
|
|
87
99
|
{ id: "images.blockImages", label: "Block image transmission", fallback: "off" },
|
|
88
100
|
{ id: "markdown.mermaid", label: "Mermaid diagrams", fallback: "streaming" },
|
|
89
|
-
{ id: "tuiMode", label: "TUI mode", fallback: "
|
|
101
|
+
{ id: "tuiMode", label: "TUI mode", fallback: "fullscreen" },
|
|
90
102
|
{ id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "transcript" },
|
|
91
|
-
{ id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "
|
|
92
|
-
{ id: "fullscreenCopyOnSelect", label: "
|
|
103
|
+
{ id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "hidden" },
|
|
104
|
+
{ id: "fullscreenCopyOnSelect", label: "Copy on select", fallback: "on" },
|
|
93
105
|
{ id: "fullscreenWheelScrollLines", label: "Fullscreen wheel scroll lines", fallback: "auto" },
|
|
94
106
|
{ id: "editorPaddingX", label: "Editor padding", fallback: "0" },
|
|
95
107
|
{ id: "outputPad", label: "Output padding", fallback: "1" },
|
|
@@ -166,6 +178,10 @@ function validate(key: string, raw: unknown, out: Record<string, unknown>, diagn
|
|
|
166
178
|
else diagnostics.push("settings." + key + " must be " + (rule.kind === "enum" ? rule.values.join("|") : rule.kind === "integer" ? rule.min + ".." + rule.max + (rule.auto ? " or auto" : "") : rule.kind));
|
|
167
179
|
}
|
|
168
180
|
export function settingValues(key: string): string[] {
|
|
181
|
+
// `auto` is the unset default and stays valid in settings.json; cycling it in
|
|
182
|
+
// the menu would be a press that visibly does nothing on a terminal where
|
|
183
|
+
// auto already means fullscreen, so the menu toggles the two real screens.
|
|
184
|
+
if (key === "tuiMode") return ["regular", "fullscreen"];
|
|
169
185
|
const rule = rules[key];
|
|
170
186
|
if (rule?.kind === "enum") return [...rule.values];
|
|
171
187
|
if (rule?.kind === "boolean") return ["on", "off"];
|
package/src/core/skills.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { parseFrontmatter } from "./frontmatter.ts";
|
|
11
|
+
import type { LogEvent } from "./events.ts";
|
|
11
12
|
|
|
12
13
|
/** Agent Skills name rule. Assumed from the spec, not measured. */
|
|
13
14
|
const MAX_NAME_LENGTH = 64;
|
|
@@ -25,6 +26,28 @@ export interface Skill {
|
|
|
25
26
|
readonly filePath: string;
|
|
26
27
|
readonly body: string;
|
|
27
28
|
readonly disableModelInvocation: boolean;
|
|
29
|
+
readonly userInvocable?: boolean;
|
|
30
|
+
readonly context?: "fork";
|
|
31
|
+
readonly allowedTools?: readonly string[];
|
|
32
|
+
readonly disallowedTools?: readonly string[];
|
|
33
|
+
/** Stored outside the model prefix so resource reads reproduce the loaded bytes. */
|
|
34
|
+
readonly sourceText?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LoadedSkill = { name: string; path: string; sha256: string; artifact: string;
|
|
38
|
+
mode: "inline" | "fork" | "read"; origin: "user" | "model"; node: number; event: string };
|
|
39
|
+
|
|
40
|
+
/** O.12: explicit capability intersection, never an approval grant. */
|
|
41
|
+
export function skillTools(parent: readonly string[], skill: Skill): string[] {
|
|
42
|
+
return parent.filter(name => (skill.allowedTools === undefined || skill.allowedTools.includes(name)) && !skill.disallowedTools?.includes(name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deriveLoadedSkills(previous: readonly LoadedSkill[], events: readonly LogEvent[], node: number): LoadedSkill[] {
|
|
46
|
+
const loaded = new Map(previous.map(item => [item.path, item]));
|
|
47
|
+
for (const e of events) if (e.t === "skill_loaded") loaded.set(e.path, {
|
|
48
|
+
name: e.name, path: e.path, sha256: e.sha256, artifact: e.artifact, mode: e.mode, origin: e.origin, node, event: e.id,
|
|
49
|
+
});
|
|
50
|
+
return [...loaded.values()];
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
export function isValidSkillName(name: string): boolean {
|
|
@@ -38,6 +61,22 @@ export function parseSkill(input: {
|
|
|
38
61
|
}): { skill: Skill | null; diagnostics: readonly string[] } {
|
|
39
62
|
const parsed = parseFrontmatter(input.text);
|
|
40
63
|
const diagnostics = [...parsed.diagnostics];
|
|
64
|
+
const supported = ["name", "description", "disable-model-invocation", "user-invocable", "context", "allowed-tools", "disallowed-tools", "license", "compatibility"];
|
|
65
|
+
for (const key of Object.keys(parsed.fields)) if (!supported.includes(key)) diagnostics.push(`${input.filePath}: unsupported skill field ${key}`);
|
|
66
|
+
for (const key of ["disable-model-invocation", "user-invocable"]) {
|
|
67
|
+
if (parsed.fields[key] !== undefined && typeof parsed.fields[key] !== "boolean") diagnostics.push(`${input.filePath}: ${key} must be boolean`);
|
|
68
|
+
}
|
|
69
|
+
if (parsed.fields["context"] !== undefined && parsed.fields["context"] !== "fork") diagnostics.push(`${input.filePath}: context must be fork`);
|
|
70
|
+
const toolNames = (key: string): string[] | undefined => {
|
|
71
|
+
const value = parsed.fields[key];
|
|
72
|
+
if (value === undefined) return undefined;
|
|
73
|
+
const aliases: Record<string, string> = { Read: "read", Write: "write", Edit: "edit", Grep: "grep", Glob: "find", Bash: "bash" };
|
|
74
|
+
const names = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\s,]+/).filter(Boolean) : [];
|
|
75
|
+
if ((names.length === 0 && value !== "") || names.some(name => !/^[A-Za-z][A-Za-z0-9_-]*$/.test(name))) diagnostics.push(`${input.filePath}: ${key} requires explicit tool names; permission patterns are unsupported`);
|
|
76
|
+
return [...new Set(names.map(name => aliases[name] ?? name))];
|
|
77
|
+
};
|
|
78
|
+
const allowedTools = toolNames("allowed-tools"), disallowedTools = toolNames("disallowed-tools");
|
|
79
|
+
if (diagnostics.length > 0) return { skill: null, diagnostics };
|
|
41
80
|
const desc = parsed.fields["description"];
|
|
42
81
|
const description = typeof desc === "string" ? desc.trim() : "";
|
|
43
82
|
if (description.length === 0) {
|
|
@@ -62,6 +101,11 @@ export function parseSkill(input: {
|
|
|
62
101
|
filePath: input.filePath,
|
|
63
102
|
body: parsed.body,
|
|
64
103
|
disableModelInvocation: disable,
|
|
104
|
+
userInvocable: parsed.fields["user-invocable"] !== false,
|
|
105
|
+
sourceText: input.text,
|
|
106
|
+
...(parsed.fields["context"] === "fork" ? { context: "fork" as const } : {}),
|
|
107
|
+
...(allowedTools ? { allowedTools } : {}),
|
|
108
|
+
...(disallowedTools ? { disallowedTools } : {}),
|
|
65
109
|
},
|
|
66
110
|
diagnostics,
|
|
67
111
|
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** Q.17: an optional syntax for DataQuery, never an alternate execution path. */
|
|
2
|
+
import { parseDataQuery, type DataQuery } from "./data-query.ts";
|
|
3
|
+
import { isRecord } from "./event-validation.ts";
|
|
4
|
+
|
|
5
|
+
// Assumed synchronous parsing budgets, not measured model or database limits.
|
|
6
|
+
// Reject over-budget input before lexing; never execute a parsed prefix.
|
|
7
|
+
export const MAX_SQL_CHARS = 16_384;
|
|
8
|
+
export const MAX_SQL_TOKENS = 4_096;
|
|
9
|
+
export const SQL_QUERY_HELP = "SQL-style subset: SELECT * or fields/COUNT/SUM/AVG/MIN/MAX(field) AS alias FROM source; optional WHERE comparisons, IS [NOT] NULL or CONTAINS(field, literal), joined by AND; GROUP BY; ORDER BY field [ASC|DESC]. No comments, joins, subqueries, OR, HAVING, DISTINCT, LIMIT/OFFSET or multiple statements. Use artifact pages for large results.";
|
|
10
|
+
|
|
11
|
+
type Token = { kind: "word" | "identifier" | "string" | "number" | "symbol" | "end"; text: string; at: number };
|
|
12
|
+
type Aggregate = NonNullable<DataQuery["aggregate"]>[number];
|
|
13
|
+
type Predicate = NonNullable<DataQuery["where"]>[number];
|
|
14
|
+
const OPERATIONS = ["count", "sum", "avg", "min", "max"] as const;
|
|
15
|
+
const RESERVED = new Set(("SELECT FROM SOURCE WHERE GROUP BY ORDER ASC DESC AS AND IS NOT NULL TRUE FALSE CONTAINS " +
|
|
16
|
+
"COUNT SUM AVG MIN MAX DISTINCT LIMIT OFFSET HAVING JOIN INNER LEFT RIGHT OUTER ON UNION WITH OR IN LIKE BETWEEN " +
|
|
17
|
+
"INSERT UPDATE DELETE DROP CREATE ALTER INTO VALUES RETURNING").split(" "));
|
|
18
|
+
const JSON_OPERATORS = ["where", "select", "group_by", "aggregate", "order_by"] as const;
|
|
19
|
+
|
|
20
|
+
function sqlError(at: number, message: string): never {
|
|
21
|
+
// Locations refer to UTF-16 offsets in the supplied string, without echoing data literals.
|
|
22
|
+
throw new Error("SQL at offset " + at + ": " + message + ". " + SQL_QUERY_HELP);
|
|
23
|
+
}
|
|
24
|
+
function tokenize(sql: string): Token[] {
|
|
25
|
+
if (sql.length > MAX_SQL_CHARS) sqlError(MAX_SQL_CHARS, "input exceeds " + MAX_SQL_CHARS + " characters");
|
|
26
|
+
const tokens: Token[] = [];
|
|
27
|
+
let at = 0;
|
|
28
|
+
const emit = (kind: Token["kind"], text: string, start: number) => {
|
|
29
|
+
if (tokens.length >= MAX_SQL_TOKENS) sqlError(start, "input exceeds " + MAX_SQL_TOKENS + " tokens");
|
|
30
|
+
tokens.push({ kind, text, at: start });
|
|
31
|
+
};
|
|
32
|
+
while (at < sql.length) {
|
|
33
|
+
const c = sql[at]!;
|
|
34
|
+
if (/\s/.test(c)) { at++; continue; }
|
|
35
|
+
const start = at;
|
|
36
|
+
if (c === "'" || c === '"') {
|
|
37
|
+
const quote = c;
|
|
38
|
+
let value = "", closed = false;
|
|
39
|
+
at++;
|
|
40
|
+
while (at < sql.length) {
|
|
41
|
+
const next = sql[at++]!;
|
|
42
|
+
if (next !== quote) value += next;
|
|
43
|
+
else if (sql[at] === quote) { value += quote; at++; }
|
|
44
|
+
else { closed = true; break; }
|
|
45
|
+
}
|
|
46
|
+
if (!closed) sqlError(start, "unterminated quoted value");
|
|
47
|
+
emit(quote === "'" ? "string" : "identifier", value, start);
|
|
48
|
+
} else if (/[A-Za-z_]/.test(c)) {
|
|
49
|
+
at++;
|
|
50
|
+
while (at < sql.length && /[A-Za-z0-9_$]/.test(sql[at]!)) at++;
|
|
51
|
+
emit("word", sql.slice(start, at), start);
|
|
52
|
+
} else if (/[0-9+-]/.test(c) || (c === "." && /[0-9]/.test(sql[at + 1] ?? ""))) {
|
|
53
|
+
const numeric = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?/.exec(sql.slice(at));
|
|
54
|
+
if (!numeric || !Number.isFinite(Number(numeric[0]))) sqlError(start, "expected a finite numeric literal; comments are unsupported");
|
|
55
|
+
at += numeric[0].length;
|
|
56
|
+
emit("number", numeric[0], start);
|
|
57
|
+
} else {
|
|
58
|
+
const pair = sql.slice(at, at + 2);
|
|
59
|
+
if (["<=", ">=", "!=", "<>"].includes(pair)) { at += 2; emit("symbol", pair, start); }
|
|
60
|
+
else if (",().*;=<>".includes(c)) { at++; emit("symbol", c, start); }
|
|
61
|
+
else sqlError(start, "unsupported character");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
tokens.push({ kind: "end", text: "", at: sql.length });
|
|
65
|
+
return tokens;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class SqlParser {
|
|
69
|
+
private readonly tokens: readonly Token[];
|
|
70
|
+
private index = 0;
|
|
71
|
+
constructor(sql: string) { this.tokens = tokenize(sql); }
|
|
72
|
+
private get current(): Token { return this.tokens[this.index]!; }
|
|
73
|
+
private isWord(word: string): boolean { return this.current.kind === "word" && this.current.text.toUpperCase() === word; }
|
|
74
|
+
private word(word: string): boolean {
|
|
75
|
+
if (!this.isWord(word)) return false;
|
|
76
|
+
this.index++; return true;
|
|
77
|
+
}
|
|
78
|
+
private symbol(text: string): boolean {
|
|
79
|
+
if (this.current.kind !== "symbol" || this.current.text !== text) return false;
|
|
80
|
+
this.index++; return true;
|
|
81
|
+
}
|
|
82
|
+
private requireWord(word: string): void {
|
|
83
|
+
if (!this.word(word)) sqlError(this.current.at, "expected " + word);
|
|
84
|
+
}
|
|
85
|
+
private requireSymbol(text: string): void {
|
|
86
|
+
if (!this.symbol(text)) sqlError(this.current.at, "expected " + text);
|
|
87
|
+
}
|
|
88
|
+
private identifier(): string {
|
|
89
|
+
const token = this.current;
|
|
90
|
+
if (token.kind !== "identifier" && (token.kind !== "word" || RESERVED.has(token.text.toUpperCase()))) {
|
|
91
|
+
sqlError(token.at, 'expected a field or alias; quote reserved names with double quotes');
|
|
92
|
+
}
|
|
93
|
+
if (!token.text) sqlError(token.at, "field and alias names must be nonempty");
|
|
94
|
+
this.index++;
|
|
95
|
+
return token.text;
|
|
96
|
+
}
|
|
97
|
+
private field(): string {
|
|
98
|
+
const parts = [this.identifier()];
|
|
99
|
+
while (this.symbol(".")) parts.push(this.identifier());
|
|
100
|
+
return parts.join(".");
|
|
101
|
+
}
|
|
102
|
+
private fields(): string[] {
|
|
103
|
+
const fields = [this.field()];
|
|
104
|
+
while (this.symbol(",")) fields.push(this.field());
|
|
105
|
+
return fields;
|
|
106
|
+
}
|
|
107
|
+
private literal(): string | number | boolean {
|
|
108
|
+
const token = this.current;
|
|
109
|
+
if (token.kind === "number") { this.index++; return Number(token.text); }
|
|
110
|
+
if (token.kind === "string") { this.index++; return token.text; }
|
|
111
|
+
if (this.word("TRUE")) return true;
|
|
112
|
+
if (this.word("FALSE")) return false;
|
|
113
|
+
sqlError(token.at, "expected a string, finite number or boolean literal; use IS NULL for null checks");
|
|
114
|
+
}
|
|
115
|
+
private predicate(): Predicate {
|
|
116
|
+
if (this.word("CONTAINS")) {
|
|
117
|
+
this.requireSymbol("(");
|
|
118
|
+
const field = this.field();
|
|
119
|
+
this.requireSymbol(",");
|
|
120
|
+
const value = this.literal();
|
|
121
|
+
this.requireSymbol(")");
|
|
122
|
+
return { field, op: "contains", value };
|
|
123
|
+
}
|
|
124
|
+
const field = this.field();
|
|
125
|
+
if (this.word("IS")) {
|
|
126
|
+
const not = this.word("NOT");
|
|
127
|
+
this.requireWord("NULL");
|
|
128
|
+
return { field, op: not ? "ne" : "eq", value: null };
|
|
129
|
+
}
|
|
130
|
+
const token = this.current;
|
|
131
|
+
const ops: Record<string, Predicate["op"]> = { "=": "eq", "!=": "ne", "<>": "ne", "<": "lt", "<=": "lte", ">": "gt", ">=": "gte" };
|
|
132
|
+
const op = token.kind === "symbol" ? ops[token.text] : undefined;
|
|
133
|
+
if (op === undefined) sqlError(token.at, "expected a comparison operator or IS [NOT] NULL");
|
|
134
|
+
this.index++;
|
|
135
|
+
return { field, op, value: this.literal() };
|
|
136
|
+
}
|
|
137
|
+
parse(source: Pick<DataQuery, "format" | "pointer">): DataQuery {
|
|
138
|
+
this.requireWord("SELECT");
|
|
139
|
+
const wildcard = this.symbol("*");
|
|
140
|
+
const selected: string[] = [], fields: string[] = [], aggregates: Aggregate[] = [];
|
|
141
|
+
if (!wildcard) {
|
|
142
|
+
do {
|
|
143
|
+
const token = this.current;
|
|
144
|
+
const op = OPERATIONS.find(value => token.kind === "word" && value.toUpperCase() === token.text.toUpperCase());
|
|
145
|
+
if (op !== undefined) {
|
|
146
|
+
this.index++;
|
|
147
|
+
this.requireSymbol("(");
|
|
148
|
+
const star = this.symbol("*");
|
|
149
|
+
if (star && op !== "count") sqlError(token.at, "only COUNT accepts *");
|
|
150
|
+
const field = star ? undefined : this.field();
|
|
151
|
+
this.requireSymbol(")"); this.requireWord("AS");
|
|
152
|
+
const alias = this.identifier();
|
|
153
|
+
aggregates.push({ op, ...(field === undefined ? {} : { field }), as: alias });
|
|
154
|
+
selected.push(alias);
|
|
155
|
+
} else {
|
|
156
|
+
const field = this.field();
|
|
157
|
+
selected.push(field); fields.push(field);
|
|
158
|
+
}
|
|
159
|
+
} while (this.symbol(","));
|
|
160
|
+
}
|
|
161
|
+
this.requireWord("FROM"); this.requireWord("SOURCE");
|
|
162
|
+
const query: DataQuery = { ...source, ...(!wildcard ? { select: selected } : {}) };
|
|
163
|
+
if (this.word("WHERE")) {
|
|
164
|
+
const where = [this.predicate()];
|
|
165
|
+
while (this.word("AND")) where.push(this.predicate());
|
|
166
|
+
query.where = where;
|
|
167
|
+
}
|
|
168
|
+
if (this.word("GROUP")) { this.requireWord("BY"); query.group_by = this.fields(); }
|
|
169
|
+
if (this.word("ORDER")) {
|
|
170
|
+
this.requireWord("BY");
|
|
171
|
+
const order: NonNullable<DataQuery["order_by"]>[number][] = [];
|
|
172
|
+
do {
|
|
173
|
+
const field = this.field(), descending = this.word("DESC");
|
|
174
|
+
if (!descending) this.word("ASC");
|
|
175
|
+
order.push({ field, ...(descending ? { descending: true } : {}) });
|
|
176
|
+
} while (this.symbol(","));
|
|
177
|
+
query.order_by = order;
|
|
178
|
+
}
|
|
179
|
+
this.symbol(";");
|
|
180
|
+
if (this.current.kind !== "end") sqlError(this.current.at, "unexpected clause or additional statement");
|
|
181
|
+
if (aggregates.length) {
|
|
182
|
+
query.aggregate = aggregates;
|
|
183
|
+
const grouped = query.group_by ?? [];
|
|
184
|
+
if (fields.length !== grouped.length || fields.some(field => !grouped.includes(field))) {
|
|
185
|
+
sqlError(this.current.at, "GROUP BY must match the selected non-aggregate fields");
|
|
186
|
+
}
|
|
187
|
+
if (query.order_by?.some(order => !selected.includes(order.field))) {
|
|
188
|
+
sqlError(this.current.at, "aggregate ORDER BY must name a selected group field or aggregate alias");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// The existing algebra owns arity, duplicate names, and grouping invariants.
|
|
192
|
+
try { return parseDataQuery(query); }
|
|
193
|
+
catch (error) { return sqlError(this.current.at, String(error)); }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Compiles one finite statement. It neither opens sources nor evaluates code. */
|
|
198
|
+
export function compileSqlQuery(sql: string, source: Pick<DataQuery, "format" | "pointer">): DataQuery {
|
|
199
|
+
return new SqlParser(sql).parse(parseDataQuery(source));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Both syntaxes converge before source I/O, permissions, execution and archival. */
|
|
203
|
+
export function parseQueryInput(input: unknown): DataQuery {
|
|
204
|
+
if (!isRecord(input) || !Object.hasOwn(input, "sql")) return parseDataQuery(input);
|
|
205
|
+
if (typeof input["sql"] !== "string") throw new Error("sql must be a string");
|
|
206
|
+
if (JSON_OPERATORS.some(key => Object.hasOwn(input, key))) {
|
|
207
|
+
throw new Error("sql cannot be combined with where, select, group_by, aggregate or order_by");
|
|
208
|
+
}
|
|
209
|
+
const source = parseDataQuery({ format: input["format"], ...(input["pointer"] === undefined ? {} : { pointer: input["pointer"] }) });
|
|
210
|
+
return compileSqlQuery(input["sql"], source);
|
|
211
|
+
}
|