@visual-z/xeno 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** O.1: own the process group, preserve partial observations, and drain before returning. */
|
|
2
|
+
import { captureCommandObservation } from "./observations.ts";
|
|
3
|
+
|
|
4
|
+
/** Operational grace, not a measured optimum: do not let ignored SIGTERM defeat cancellation. */
|
|
5
|
+
const TERMINATION_GRACE_MS = 200;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Variables that make a command behave as if no one is at the keyboard.
|
|
9
|
+
*
|
|
10
|
+
* There never is: a pager waits on a tty that does not exist, colour codes
|
|
11
|
+
* spend budget on escape bytes, and a git credential prompt or an apt
|
|
12
|
+
* question hangs the command until the timeout. Codex sets the same core set
|
|
13
|
+
* for its exec sessions (`unified_exec/process_manager.rs`). LANG is filled in
|
|
14
|
+
* only when absent -- a user's own locale is not ours to override.
|
|
15
|
+
*/
|
|
16
|
+
export function commandEnvironment(base: Readonly<Record<string, string | undefined>>): Record<string, string> {
|
|
17
|
+
const env: Record<string, string> = {};
|
|
18
|
+
for (const [key, value] of Object.entries(base)) if (value !== undefined) env[key] = value;
|
|
19
|
+
Object.assign(env, {
|
|
20
|
+
TERM: "dumb", PAGER: "cat", GIT_PAGER: "cat", NO_COLOR: "1",
|
|
21
|
+
GIT_TERMINAL_PROMPT: "0", DEBIAN_FRONTEND: "noninteractive",
|
|
22
|
+
});
|
|
23
|
+
if (env["LANG"] === undefined || env["LANG"] === "") env["LANG"] = "C.UTF-8";
|
|
24
|
+
return env;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function observeCommand(input: {
|
|
28
|
+
command: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
outputStore: string;
|
|
31
|
+
maxOutputBytes: number;
|
|
32
|
+
timeoutMs: number;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}) {
|
|
35
|
+
const started = performance.now();
|
|
36
|
+
const proc = Bun.spawn(["bash", "-o", "pipefail", "-lc", input.command], {
|
|
37
|
+
cwd: input.cwd, stdout: "pipe", stderr: "pipe", detached: true, env: commandEnvironment(process.env),
|
|
38
|
+
});
|
|
39
|
+
let stopped: "cancelled" | "timeout" | undefined;
|
|
40
|
+
let escalation: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
const kill = (signal: "SIGTERM" | "SIGKILL") => {
|
|
42
|
+
try {
|
|
43
|
+
// POSIX detached children lead their own group; never signal the caller's group.
|
|
44
|
+
if (process.platform === "win32") proc.kill(signal);
|
|
45
|
+
else process.kill(-proc.pid, signal);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as { code?: string }).code !== "ESRCH") throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const stop = (reason: "cancelled" | "timeout") => {
|
|
51
|
+
if (stopped !== undefined) return;
|
|
52
|
+
stopped = reason;
|
|
53
|
+
kill("SIGTERM");
|
|
54
|
+
escalation = setTimeout(() => kill("SIGKILL"), TERMINATION_GRACE_MS);
|
|
55
|
+
};
|
|
56
|
+
const abort = () => stop("cancelled");
|
|
57
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
58
|
+
const timer = setTimeout(() => stop("timeout"), input.timeoutMs);
|
|
59
|
+
if (input.signal?.aborted) abort();
|
|
60
|
+
try {
|
|
61
|
+
const captured = await captureCommandObservation(input.outputStore, proc.stdout, proc.stderr,
|
|
62
|
+
proc.exited, input.maxOutputBytes);
|
|
63
|
+
return { ...captured, wallMs: performance.now() - started, ...(stopped ? { stopped } : {}) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
kill("SIGKILL");
|
|
66
|
+
await proc.exited;
|
|
67
|
+
throw error;
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
clearTimeout(escalation);
|
|
71
|
+
input.signal?.removeEventListener("abort", abort);
|
|
72
|
+
// A descendant can close stdio yet ignore SIGTERM; completing capture is not proof it stopped.
|
|
73
|
+
if (stopped !== undefined) kill("SIGKILL");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's configuration, which lives with the user and not with the project.
|
|
3
|
+
*
|
|
4
|
+
* `~/.xeno/` rather than the workspace's `.xeno/`. The split is not cosmetic:
|
|
5
|
+
* the log and the blob store belong to one piece of work and should travel
|
|
6
|
+
* with it, while a credential belongs to a person. A key written into the
|
|
7
|
+
* workspace would be committed, would be seen by the snapshot scan that backs
|
|
8
|
+
* `/back`, and would ship with the project.
|
|
9
|
+
*
|
|
10
|
+
* This layer only reads and writes files. It knows nothing about providers,
|
|
11
|
+
* priority, or what a key means -- that is `runtime/auth.ts`, and keeping the
|
|
12
|
+
* two apart is what lets the priority rules be tested without a filesystem.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export function configDir(): string {
|
|
20
|
+
// XENO_CONFIG_DIR exists for tests and for anyone running two configurations
|
|
21
|
+
// side by side. Read here rather than threaded through every caller, because
|
|
22
|
+
// this is the one place that turns "the user" into a path.
|
|
23
|
+
return process.env["XENO_CONFIG_DIR"] ?? join(homedir(), ".xeno");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function authPath(): string {
|
|
27
|
+
return join(configDir(), "auth.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function settingsPath(): string {
|
|
31
|
+
return join(configDir(), "settings.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads a JSON config, treating "absent" and "unreadable" as empty.
|
|
36
|
+
*
|
|
37
|
+
* A missing config file is the normal first-run state, not an error. A
|
|
38
|
+
* corrupt one is reported to the caller as absent plus a reason, because
|
|
39
|
+
* silently starting with default settings after someone hand-edited a file
|
|
40
|
+
* is how a typo becomes an hour of confusion.
|
|
41
|
+
*/
|
|
42
|
+
export async function readJsonFile<T>(
|
|
43
|
+
path: string,
|
|
44
|
+
): Promise<{ value: T | null; error: string | null }> {
|
|
45
|
+
let text: string;
|
|
46
|
+
try {
|
|
47
|
+
text = await readFile(path, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return { value: null, error: null };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return { value: JSON.parse(text) as T, error: null };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { value: null, error: `${path} is not valid JSON: ${(err as Error).message}` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Writes a config file, 0600 for the one that holds secrets.
|
|
60
|
+
*
|
|
61
|
+
* `chmod` after the write rather than a `mode` on the open, because an
|
|
62
|
+
* existing file keeps its old permissions when reopened -- so a file created
|
|
63
|
+
* before this rule existed would stay world-readable and nothing would say so.
|
|
64
|
+
*/
|
|
65
|
+
export async function writeJsonFile(path: string, value: unknown, secret: boolean): Promise<void> {
|
|
66
|
+
await mkdir(configDir(), { recursive: true });
|
|
67
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
68
|
+
if (secret) await chmod(path, 0o600);
|
|
69
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project context files (AGENTS.md and friends) and the system prompt they
|
|
3
|
+
* join.
|
|
4
|
+
*
|
|
5
|
+
* Two halves, kept apart because they have different reasons to change:
|
|
6
|
+
*
|
|
7
|
+
* DISCOVERY is I/O. It walks directories, reads bytes, skips what it
|
|
8
|
+
* cannot, and truncates what does not fit. It runs once at process
|
|
9
|
+
* start.
|
|
10
|
+
*
|
|
11
|
+
* ASSEMBLY is a pure function of those bytes. Same files in, same
|
|
12
|
+
* string out. That is what makes the ordering rules testable without
|
|
13
|
+
* a filesystem, and what makes `frozen.system` a value rather than a
|
|
14
|
+
* live view of disk.
|
|
15
|
+
*
|
|
16
|
+
* Re-reading these files at a node boundary would change `system` and
|
|
17
|
+
* invalidate the prefix cache for the whole conversation (D6). They are
|
|
18
|
+
* frozen at startup; a reload is a cut, and is a later card.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile, stat } from "node:fs/promises";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
|
+
import { assertNever } from "../core/exhaustive.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-file cap. Assumed, not measured: a rule file larger than this is a
|
|
27
|
+
* document someone should split, not a prompt prefix. Hitting it truncates
|
|
28
|
+
* that file and names it in the prompt; the omitted rules are simply not
|
|
29
|
+
* followed, which is the failure mode the notice exists to make visible.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_FILE_BYTES = 64 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cap on the sum of loaded context files. Assumed, not measured: 256KB of
|
|
35
|
+
* instructions already crowds out the task. Hitting it truncates the file
|
|
36
|
+
* that overflowed (and names it); later files may be omitted, so a huge
|
|
37
|
+
* farther file can starve a nearer one.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_TOTAL_BYTES = 256 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Directories walked from cwd toward the workspace root, including cwd.
|
|
43
|
+
* Assumed, not measured: a 32-level nest is not a project layout, it is a
|
|
44
|
+
* pathologically generated tree. Hitting it drops the farther (workspace)
|
|
45
|
+
* files -- the walk starts at cwd so nearer rules are kept.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_WALK_DEPTH = 32;
|
|
48
|
+
|
|
49
|
+
const AGENTS_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"] as const;
|
|
50
|
+
|
|
51
|
+
export type ContextRole = "system" | "agents" | "append";
|
|
52
|
+
|
|
53
|
+
export interface LoadedFile {
|
|
54
|
+
path: string;
|
|
55
|
+
content: string;
|
|
56
|
+
/** Original size in UTF-8 bytes, before any truncation. */
|
|
57
|
+
bytes: number;
|
|
58
|
+
truncated: boolean;
|
|
59
|
+
role: ContextRole;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ContextNotice =
|
|
63
|
+
| { kind: "loaded"; path: string; truncated: boolean }
|
|
64
|
+
| { kind: "skipped"; path: string; reason: string };
|
|
65
|
+
|
|
66
|
+
export interface LoadedContext {
|
|
67
|
+
/** Replacement for the builtin system prompt, if any. */
|
|
68
|
+
system: LoadedFile | null;
|
|
69
|
+
/** AGENTS.md chain, far (global) to near (cwd). */
|
|
70
|
+
agents: LoadedFile[];
|
|
71
|
+
/** APPEND_SYSTEM.md then `--append-system-prompt`, in append order. */
|
|
72
|
+
appends: LoadedFile[];
|
|
73
|
+
notices: ContextNotice[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LoadOptions {
|
|
77
|
+
workspace: string;
|
|
78
|
+
cwd: string;
|
|
79
|
+
/** `~/.xeno` or `XENO_CONFIG_DIR`. Injected so tests never touch the real home. */
|
|
80
|
+
configDir: string;
|
|
81
|
+
noContextFiles: boolean;
|
|
82
|
+
appendSystemPrompt: readonly string[];
|
|
83
|
+
/** Raw `--system-prompt` value. Omitted when the flag was not passed. */
|
|
84
|
+
systemPrompt?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class Budget {
|
|
88
|
+
remaining = MAX_TOTAL_BYTES;
|
|
89
|
+
|
|
90
|
+
fit(text: string, bytes: number): { content: string; truncated: boolean } {
|
|
91
|
+
const cap = Math.min(MAX_FILE_BYTES, this.remaining);
|
|
92
|
+
if (bytes <= cap) {
|
|
93
|
+
this.remaining -= bytes;
|
|
94
|
+
return { content: text, truncated: false };
|
|
95
|
+
}
|
|
96
|
+
const content = truncateUtf8(text, cap);
|
|
97
|
+
this.remaining = Math.max(0, this.remaining - Buffer.byteLength(content, "utf8"));
|
|
98
|
+
return { content, truncated: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk from cwd up to the workspace root, nearest first, then reverse so
|
|
104
|
+
* assembly sees farther files first. cwd outside the workspace contributes
|
|
105
|
+
* only the workspace itself -- the session is rooted there, and climbing
|
|
106
|
+
* into unrelated trees would load someone else's rules.
|
|
107
|
+
*/
|
|
108
|
+
function projectDirectories(workspace: string, cwd: string): string[] {
|
|
109
|
+
const root = resolve(workspace);
|
|
110
|
+
const start = resolve(cwd);
|
|
111
|
+
if (!isInside(start, root)) return [root];
|
|
112
|
+
|
|
113
|
+
const fromCwd: string[] = [];
|
|
114
|
+
let dir = start;
|
|
115
|
+
for (let i = 0; i < MAX_WALK_DEPTH; i++) {
|
|
116
|
+
fromCwd.push(dir);
|
|
117
|
+
if (dir === root) break;
|
|
118
|
+
const parent = dirname(dir);
|
|
119
|
+
if (parent === dir) break;
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
return fromCwd.reverse();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isInside(child: string, parent: string): boolean {
|
|
126
|
+
const rel = relative(parent, child);
|
|
127
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
131
|
+
if (maxBytes <= 0) return "";
|
|
132
|
+
const buf = Buffer.from(text, "utf8");
|
|
133
|
+
if (buf.byteLength <= maxBytes) return text;
|
|
134
|
+
let end = maxBytes;
|
|
135
|
+
// Do not split a multibyte sequence: a dangling continuation byte is
|
|
136
|
+
// not valid UTF-8, and the model would see a replacement character in
|
|
137
|
+
// the middle of a rule.
|
|
138
|
+
while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
|
|
139
|
+
return buf.subarray(0, end).toString("utf8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type ReadResult =
|
|
143
|
+
| { status: "missing" }
|
|
144
|
+
| { status: "ok"; text: string; bytes: number }
|
|
145
|
+
| { status: "skip"; reason: string };
|
|
146
|
+
|
|
147
|
+
async function tryRead(path: string): Promise<ReadResult> {
|
|
148
|
+
let buf: Buffer;
|
|
149
|
+
try {
|
|
150
|
+
buf = await readFile(path);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const code = (err as { code?: string }).code;
|
|
153
|
+
if (code === "ENOENT") return { status: "missing" };
|
|
154
|
+
if (code === "EACCES") return { status: "skip", reason: "permission denied" };
|
|
155
|
+
if (code === "EISDIR") return { status: "skip", reason: "is a directory" };
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
return { status: "skip", reason: message };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
161
|
+
return { status: "ok", text, bytes: buf.byteLength };
|
|
162
|
+
} catch {
|
|
163
|
+
return { status: "skip", reason: "not valid UTF-8" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function absorbText(
|
|
168
|
+
path: string,
|
|
169
|
+
text: string,
|
|
170
|
+
role: ContextRole,
|
|
171
|
+
bytes: number,
|
|
172
|
+
budget: Budget,
|
|
173
|
+
notices: ContextNotice[],
|
|
174
|
+
): LoadedFile {
|
|
175
|
+
const fitted = budget.fit(text, bytes);
|
|
176
|
+
const file: LoadedFile = {
|
|
177
|
+
path,
|
|
178
|
+
content: fitted.content,
|
|
179
|
+
bytes,
|
|
180
|
+
truncated: fitted.truncated,
|
|
181
|
+
role,
|
|
182
|
+
};
|
|
183
|
+
notices.push({ kind: "loaded", path, truncated: file.truncated });
|
|
184
|
+
return file;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function absorbFile(
|
|
188
|
+
path: string,
|
|
189
|
+
role: ContextRole,
|
|
190
|
+
budget: Budget,
|
|
191
|
+
notices: ContextNotice[],
|
|
192
|
+
): Promise<LoadedFile | null> {
|
|
193
|
+
const got = await tryRead(path);
|
|
194
|
+
if (got.status === "missing") return null;
|
|
195
|
+
if (got.status === "skip") {
|
|
196
|
+
notices.push({ kind: "skipped", path, reason: got.reason });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return absorbText(path, got.text, role, got.bytes, budget, notices);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `--system-prompt` is text, or a file if the argument names one that
|
|
204
|
+
* exists. An existing path that cannot be read is skipped (and reported)
|
|
205
|
+
* rather than silently becoming the path string as the prompt.
|
|
206
|
+
*/
|
|
207
|
+
async function absorbSystemArg(
|
|
208
|
+
value: string,
|
|
209
|
+
cwd: string,
|
|
210
|
+
budget: Budget,
|
|
211
|
+
notices: ContextNotice[],
|
|
212
|
+
): Promise<LoadedFile | null> {
|
|
213
|
+
const candidate = resolve(cwd, value);
|
|
214
|
+
let file = false;
|
|
215
|
+
try {
|
|
216
|
+
file = (await stat(candidate)).isFile();
|
|
217
|
+
} catch {
|
|
218
|
+
file = false;
|
|
219
|
+
}
|
|
220
|
+
if (file) return absorbFile(candidate, "system", budget, notices);
|
|
221
|
+
return absorbText("--system-prompt", value, "system", Buffer.byteLength(value, "utf8"), budget, notices);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One file per directory: override, else AGENTS.md, else CLAUDE.md.
|
|
226
|
+
*
|
|
227
|
+
* An unreadable chosen file does not fall through to the next name -- the
|
|
228
|
+
* directory had a preference, it just could not be honoured. A name that
|
|
229
|
+
* is missing or is a directory is not a preference, so the next candidate
|
|
230
|
+
* is tried.
|
|
231
|
+
*/
|
|
232
|
+
async function absorbAgentsAt(
|
|
233
|
+
dir: string,
|
|
234
|
+
budget: Budget,
|
|
235
|
+
notices: ContextNotice[],
|
|
236
|
+
): Promise<LoadedFile | null> {
|
|
237
|
+
for (const name of AGENTS_CANDIDATES) {
|
|
238
|
+
const path = join(dir, name);
|
|
239
|
+
const got = await tryRead(path);
|
|
240
|
+
if (got.status === "missing") continue;
|
|
241
|
+
if (got.status === "skip") {
|
|
242
|
+
if (got.reason === "is a directory") continue;
|
|
243
|
+
notices.push({ kind: "skipped", path, reason: got.reason });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
return absorbText(path, got.text, "agents", got.bytes, budget, notices);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function loadContext(opts: LoadOptions): Promise<LoadedContext> {
|
|
252
|
+
const budget = new Budget();
|
|
253
|
+
const notices: ContextNotice[] = [];
|
|
254
|
+
const agents: LoadedFile[] = [];
|
|
255
|
+
const appends: LoadedFile[] = [];
|
|
256
|
+
|
|
257
|
+
let system: LoadedFile | null = null;
|
|
258
|
+
if (opts.systemPrompt !== undefined) {
|
|
259
|
+
// The flag is an explicit choice. A missing/unreadable file does not
|
|
260
|
+
// fall through to SYSTEM.md -- that would make a typo in the path
|
|
261
|
+
// silently restore the project prompt.
|
|
262
|
+
system = await absorbSystemArg(opts.systemPrompt, opts.cwd, budget, notices);
|
|
263
|
+
} else {
|
|
264
|
+
system =
|
|
265
|
+
(await absorbFile(join(opts.workspace, ".xeno", "SYSTEM.md"), "system", budget, notices)) ??
|
|
266
|
+
(await absorbFile(join(opts.configDir, "SYSTEM.md"), "system", budget, notices));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!opts.noContextFiles) {
|
|
270
|
+
const globalAgents = await absorbFile(join(opts.configDir, "AGENTS.md"), "agents", budget, notices);
|
|
271
|
+
if (globalAgents) agents.push(globalAgents);
|
|
272
|
+
for (const dir of projectDirectories(opts.workspace, opts.cwd)) {
|
|
273
|
+
const file = await absorbAgentsAt(dir, budget, notices);
|
|
274
|
+
if (file) agents.push(file);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const globalAppend = await absorbFile(join(opts.configDir, "APPEND_SYSTEM.md"), "append", budget, notices);
|
|
279
|
+
if (globalAppend) appends.push(globalAppend);
|
|
280
|
+
const projectAppend = await absorbFile(
|
|
281
|
+
join(opts.workspace, ".xeno", "APPEND_SYSTEM.md"),
|
|
282
|
+
"append",
|
|
283
|
+
budget,
|
|
284
|
+
notices,
|
|
285
|
+
);
|
|
286
|
+
if (projectAppend) appends.push(projectAppend);
|
|
287
|
+
for (const text of opts.appendSystemPrompt) {
|
|
288
|
+
appends.push(
|
|
289
|
+
absorbText(
|
|
290
|
+
"--append-system-prompt",
|
|
291
|
+
text,
|
|
292
|
+
"append",
|
|
293
|
+
Buffer.byteLength(text, "utf8"),
|
|
294
|
+
budget,
|
|
295
|
+
notices,
|
|
296
|
+
),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { system, agents, appends, notices };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const CONTEXT_INTRO =
|
|
304
|
+
"# Project context\n\n" +
|
|
305
|
+
"These files were loaded at startup. Later files are closer to the working " +
|
|
306
|
+
"directory and take precedence.";
|
|
307
|
+
|
|
308
|
+
function truncationLine(file: LoadedFile): string {
|
|
309
|
+
const included = Buffer.byteLength(file.content, "utf8");
|
|
310
|
+
const omitted = Math.max(0, file.bytes - included);
|
|
311
|
+
return `[truncated: ${file.path} exceeded a size limit; ${omitted} bytes omitted]`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function escapeAttr(value: string): string {
|
|
315
|
+
return value.replace(/&/g, "&").replace(/"/g, """);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderFile(file: LoadedFile): string {
|
|
319
|
+
const inner = file.truncated ? `${file.content}\n${truncationLine(file)}` : file.content;
|
|
320
|
+
return `<context_file path="${escapeAttr(file.path)}">\n${inner}\n</context_file>`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* File contents in, prompt string out. The path of every loaded file is in
|
|
325
|
+
* the result so the model can cite which rule it is following, and a
|
|
326
|
+
* truncated file is named so a silent cut cannot hide.
|
|
327
|
+
*/
|
|
328
|
+
export function assembleSystemPrompt(builtin: string, loaded: LoadedContext): string {
|
|
329
|
+
const parts: string[] = [];
|
|
330
|
+
if (loaded.system !== null) {
|
|
331
|
+
parts.push(loaded.system.content);
|
|
332
|
+
if (loaded.system.truncated) parts.push(truncationLine(loaded.system));
|
|
333
|
+
} else {
|
|
334
|
+
parts.push(builtin);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const files = [...loaded.agents, ...loaded.appends];
|
|
338
|
+
if (files.length > 0) {
|
|
339
|
+
parts.push(CONTEXT_INTRO);
|
|
340
|
+
for (const file of files) parts.push(renderFile(file));
|
|
341
|
+
}
|
|
342
|
+
return parts.join("\n\n");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function formatNotice(notice: ContextNotice): string {
|
|
346
|
+
switch (notice.kind) {
|
|
347
|
+
case "loaded":
|
|
348
|
+
return notice.truncated
|
|
349
|
+
? `context ${notice.path} (truncated)`
|
|
350
|
+
: `context ${notice.path}`;
|
|
351
|
+
case "skipped":
|
|
352
|
+
return `context skipped ${notice.path} (${notice.reason})`;
|
|
353
|
+
default:
|
|
354
|
+
return assertNever(notice, "formatNotice");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate paths for @-file expansion.
|
|
3
|
+
*
|
|
4
|
+
* A glob on every keystroke is a stall in a large repo (the card's rejected
|
|
5
|
+
* alternative). Scan once, cache for the session. The snapshot scanner is a
|
|
6
|
+
* different job -- it hashes contents and refuses to truncate -- so this walk
|
|
7
|
+
* is its own function rather than a mode flag on scanWorkspace.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdir } from "node:fs/promises";
|
|
11
|
+
import type { Dirent } from "node:fs";
|
|
12
|
+
import { join, relative, sep } from "node:path";
|
|
13
|
+
import { DEFAULT_IGNORE } from "./workspace.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cap on indexed paths. Assumed, not measured: enough to fuzzy-search a
|
|
17
|
+
* large repo; past this the picker is hiding a missing filter, not serving
|
|
18
|
+
* one. Hitting it truncates and says so -- a silent cut would make @ miss
|
|
19
|
+
* files that exist.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_INDEX_ENTRIES = 20_000;
|
|
22
|
+
|
|
23
|
+
export interface FileIndex {
|
|
24
|
+
readonly paths: readonly string[];
|
|
25
|
+
readonly truncated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IndexOptions {
|
|
29
|
+
readonly ignore?: readonly string[];
|
|
30
|
+
readonly maxEntries?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isIgnored(rel: string, ignore: readonly string[]): boolean {
|
|
34
|
+
const parts = rel.split(sep);
|
|
35
|
+
return ignore.some((i) => parts.includes(i));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildFileIndex(root: string, opts: IndexOptions = {}): Promise<FileIndex> {
|
|
39
|
+
const ignore = opts.ignore ?? DEFAULT_IGNORE;
|
|
40
|
+
const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
|
|
41
|
+
const paths: string[] = [];
|
|
42
|
+
let truncated = false;
|
|
43
|
+
|
|
44
|
+
async function walk(dir: string): Promise<void> {
|
|
45
|
+
if (truncated) return;
|
|
46
|
+
let entries: Dirent[];
|
|
47
|
+
try {
|
|
48
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
53
|
+
if (truncated) return;
|
|
54
|
+
// Do not follow symlinks: a link to / walks the disk, and
|
|
55
|
+
// node_modules/.bin is nothing but links. Same judgment as
|
|
56
|
+
// feat-workspace-snapshots, which also skips them via isFile().
|
|
57
|
+
if (entry.isSymbolicLink()) continue;
|
|
58
|
+
const abs = join(dir, entry.name);
|
|
59
|
+
const rel = relative(root, abs);
|
|
60
|
+
if (isIgnored(rel, ignore)) continue;
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
await walk(abs);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!entry.isFile()) continue;
|
|
66
|
+
if (paths.length >= max) {
|
|
67
|
+
truncated = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
paths.push(rel.split(sep).join("/"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await walk(root);
|
|
75
|
+
return { paths, truncated };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function indexTruncationNote(index: FileIndex): string | null {
|
|
79
|
+
if (!index.truncated) return null;
|
|
80
|
+
return `file index truncated at ${index.paths.length} entries; later paths are not searchable`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current git branch for the status line.
|
|
3
|
+
*
|
|
4
|
+
* I/O lives here because tui cannot do I/O. Failure is silent: a missing
|
|
5
|
+
* binary, a directory that is not a repo, a detached HEAD, or a hang must
|
|
6
|
+
* not throw and must not stall TUI attach -- the status line just omits
|
|
7
|
+
* the branch.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Assumed, not measured. `git symbolic-ref` is a local read; 200ms is long
|
|
12
|
+
* enough for a cold disk and short enough that a hung credential helper
|
|
13
|
+
* cannot freeze the first paint.
|
|
14
|
+
*/
|
|
15
|
+
export const GIT_BRANCH_TIMEOUT_MS = 200;
|
|
16
|
+
|
|
17
|
+
export function currentBranch(
|
|
18
|
+
cwd: string,
|
|
19
|
+
command = "git",
|
|
20
|
+
timeoutMs = GIT_BRANCH_TIMEOUT_MS,
|
|
21
|
+
): string | null {
|
|
22
|
+
try {
|
|
23
|
+
const p = Bun.spawnSync([command, "symbolic-ref", "--short", "HEAD"], {
|
|
24
|
+
cwd,
|
|
25
|
+
timeout: timeoutMs,
|
|
26
|
+
stdout: "pipe",
|
|
27
|
+
stderr: "pipe",
|
|
28
|
+
});
|
|
29
|
+
if (p.exitCode !== 0) return null;
|
|
30
|
+
const name = new TextDecoder().decode(p.stdout).trim();
|
|
31
|
+
return name.length > 0 ? name : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Search immutable observation bodies without loading large command output into RAM. */
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { blobFilePath, verifyBlob } from "./blobs.ts";
|
|
4
|
+
|
|
5
|
+
export async function observationContains(dir: string, sha256: string, terms: readonly string[]): Promise<boolean> {
|
|
6
|
+
if (!await verifyBlob(dir, sha256)) throw new Error(`missing or corrupt observation ${sha256}`);
|
|
7
|
+
const remaining = new Set(terms);
|
|
8
|
+
const overlap = Math.max(0, ...terms.map((term) => term.length - 1));
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
let tail = "";
|
|
11
|
+
for await (const chunk of createReadStream(blobFilePath(dir, sha256))) {
|
|
12
|
+
const text = tail + decoder.decode(chunk as Uint8Array, { stream: true }).toLowerCase();
|
|
13
|
+
for (const term of remaining) if (text.includes(term)) remaining.delete(term);
|
|
14
|
+
if (remaining.size === 0) return true;
|
|
15
|
+
tail = overlap > 0 ? text.slice(-overlap) : "";
|
|
16
|
+
}
|
|
17
|
+
const last = tail + decoder.decode().toLowerCase();
|
|
18
|
+
return [...remaining].every((term) => last.includes(term));
|
|
19
|
+
}
|