@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,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F2 delayed recall: information observed exactly once, asked about later.
|
|
3
|
+
*
|
|
4
|
+
* The runner deletes each page when its stage ends, so the workspace holds no
|
|
5
|
+
* durable copy; only what the harness kept (a log, a summary, the model's own
|
|
6
|
+
* notes) can answer a later question. This isolates the claim the harness makes
|
|
7
|
+
* -- lossless history beats lossy summaries when a detail's relevance is only
|
|
8
|
+
* revealed later -- from the F1 ledger task, where state lives on disk and any
|
|
9
|
+
* file-capable harness can continue. Modelled on MemoryAgentBench's incremental
|
|
10
|
+
* chunk injection (accurate retrieval + conflict resolution); none of its data
|
|
11
|
+
* or scoring is reused. Pure: no I/O, clock or ambient randomness.
|
|
12
|
+
*/
|
|
13
|
+
import { seededRandom } from "./experiment.ts";
|
|
14
|
+
|
|
15
|
+
export type RecallKind = "recent" | "recall" | "latest";
|
|
16
|
+
export interface RecallEvent { ticket: string; code: string; action: "assign" | "reassign" }
|
|
17
|
+
export interface RecallQuestion {
|
|
18
|
+
stage: number; kind: RecallKind; ticket: string; expected: string;
|
|
19
|
+
/** Page holding the expected code; distance is stage - source. */
|
|
20
|
+
source: number;
|
|
21
|
+
/** Codes the ticket held earlier; answering one of them is a stale answer. */
|
|
22
|
+
stale: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface RecallPage { stage: number; path: string; answerPath: string; body: string; events: RecallEvent[]; question: RecallQuestion }
|
|
25
|
+
export interface RecallFixture { seed: number; disclose: boolean; contract: string; pages: RecallPage[] }
|
|
26
|
+
export interface RecallOptions {
|
|
27
|
+
stages: number; eventsPerPage: number; paddingChars: number;
|
|
28
|
+
/**
|
|
29
|
+
* Whether the contract says later stages may ask about earlier pages.
|
|
30
|
+
* Default false: disclosed, a diligent model copies every event into a notes
|
|
31
|
+
* file and the task collapses into F1 (state on disk). Undisclosed is the
|
|
32
|
+
* case the claim is about -- relevance revealed only after the fact.
|
|
33
|
+
*/
|
|
34
|
+
disclose?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Log-like filler at roughly 4 chars/token: hex filler tokenizes ~2x denser,
|
|
38
|
+
// which would double the cost of the same nominal length for no realism gain.
|
|
39
|
+
const LEVELS = ["INFO", "INFO", "INFO", "DEBUG", "WARN"];
|
|
40
|
+
const SERVICES = ["api", "worker", "scheduler", "cache", "gateway", "billing", "search", "auth"];
|
|
41
|
+
const MESSAGES = ["heartbeat ok", "request served", "cache refreshed", "retrying upstream call", "job finished",
|
|
42
|
+
"connection pooled", "config reloaded", "slow query logged", "queue drained", "health check passed"];
|
|
43
|
+
|
|
44
|
+
const pad = (n: number, width: number) => String(n).padStart(width, "0");
|
|
45
|
+
|
|
46
|
+
export function recallFixture(seed: number, options: RecallOptions): RecallFixture {
|
|
47
|
+
const { stages, eventsPerPage, paddingChars } = options, disclose = options.disclose ?? false;
|
|
48
|
+
if (!Number.isSafeInteger(seed) || !Number.isSafeInteger(stages) || stages < 1 || stages > 256 ||
|
|
49
|
+
!Number.isSafeInteger(eventsPerPage) || eventsPerPage < 2 || eventsPerPage > 200 ||
|
|
50
|
+
!Number.isSafeInteger(paddingChars) || paddingChars < 0 || paddingChars > 4_000_000) throw new RangeError("invalid recall fixture dimensions");
|
|
51
|
+
const random = seededRandom(seed), int = (n: number) => Math.floor(random() * n);
|
|
52
|
+
const pick = <T>(xs: readonly T[]): T => xs[int(xs.length)]!;
|
|
53
|
+
const used = new Set<string>();
|
|
54
|
+
const fresh = (make: () => string) => { for (;;) { const v = make(); if (!used.has(v)) { used.add(v); return v; } } };
|
|
55
|
+
const hex = (n: number) => Array.from({ length: n }, () => "0123456789abcdef"[int(16)]).join("");
|
|
56
|
+
// Current state and each ticket's history, replayed exactly as a reader would.
|
|
57
|
+
const history = new Map<string, { stage: number; code: string }[]>();
|
|
58
|
+
const asked = new Set<string>();
|
|
59
|
+
const pages: RecallPage[] = [];
|
|
60
|
+
for (let stage = 0; stage < stages; stage++) {
|
|
61
|
+
const earlier = [...history.keys()];
|
|
62
|
+
// A quarter of events after page 0 reassign an earlier ticket (conflicts).
|
|
63
|
+
const reassign = stage === 0 ? 0 : Math.min(earlier.length, Math.floor(eventsPerPage / 4));
|
|
64
|
+
const events: RecallEvent[] = [];
|
|
65
|
+
const targets = new Set<string>();
|
|
66
|
+
while (targets.size < reassign) targets.add(pick(earlier));
|
|
67
|
+
for (const ticket of targets) events.push({ ticket, code: fresh(() => `K-${hex(12)}`), action: "reassign" });
|
|
68
|
+
while (events.length < eventsPerPage) events.push({ ticket: fresh(() => `T-${pad(10000 + int(90000), 5)}`), code: fresh(() => `K-${hex(12)}`), action: "assign" });
|
|
69
|
+
// Shuffle so reassignments are not always first on the page.
|
|
70
|
+
for (let i = events.length - 1; i > 0; i--) { const j = int(i + 1); [events[i], events[j]] = [events[j]!, events[i]!]; }
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
const past = history.get(event.ticket) ?? [];
|
|
73
|
+
past.push({ stage, code: event.code }); history.set(event.ticket, past);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const question = chooseQuestion(stage, history, asked, int);
|
|
77
|
+
asked.add(question.ticket);
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
for (let usedChars = 0, second = 0; usedChars < paddingChars; second++) {
|
|
80
|
+
const line = `2026-09-28T${pad(Math.floor(second / 3600) % 24, 2)}:${pad(Math.floor(second / 60) % 60, 2)}:${pad(second % 60, 2)}Z ` +
|
|
81
|
+
`${pick(LEVELS)} ${pick(SERVICES)}-${int(8)} ${pick(MESSAGES)} latency=${int(900)}ms queue=${int(64)}`;
|
|
82
|
+
lines.push(line); usedChars += line.length + 1;
|
|
83
|
+
}
|
|
84
|
+
// Spread events through the whole page, including its tail, so a head-only
|
|
85
|
+
// preview cannot pass as a full observation (same rule as F1).
|
|
86
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
87
|
+
const at = Math.floor((i + 0.5) * lines.length / events.length);
|
|
88
|
+
lines.splice(at, 0, `EVENT ${JSON.stringify(events[i])}`);
|
|
89
|
+
}
|
|
90
|
+
pages.push({ stage, path: `incoming/page-${pad(stage, 2)}.txt`, answerPath: `answers/stage-${pad(stage, 2)}.txt`,
|
|
91
|
+
body: lines.join("\n"), events, question });
|
|
92
|
+
}
|
|
93
|
+
const contract = "This task runs in stages. Each stage delivers one page at incoming/page-NN.txt; the page is deleted when the stage ends. " +
|
|
94
|
+
"Pages contain EVENT lines (JSON) that assign or reassign a code to a ticket, mixed with unrelated log lines. " +
|
|
95
|
+
"A ticket's current code is the code from its most recent EVENT across all pages so far. " +
|
|
96
|
+
(disclose ? "Each stage asks about one ticket, which may have appeared on this page or on any earlier page. "
|
|
97
|
+
: "Each stage asks about one ticket. ") +
|
|
98
|
+
"Read the page in full with read, then write only the ticket's current code to the stage's answer file. Use only the provided tools. Reply briefly when the stage is done.";
|
|
99
|
+
return { seed, disclose, contract, pages };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Rotate kinds so every family appears; fall back when a kind has no candidate yet. */
|
|
103
|
+
function chooseQuestion(stage: number, history: Map<string, { stage: number; code: string }[]>, asked: Set<string>, int: (n: number) => number): RecallQuestion {
|
|
104
|
+
const rows = [...history.entries()].filter(([ticket]) => !asked.has(ticket)).map(([ticket, past]) => ({ ticket, past, last: past.at(-1)! }));
|
|
105
|
+
const candidates: Record<RecallKind, typeof rows> = {
|
|
106
|
+
// Asked on the page that last set it, so no retention is needed.
|
|
107
|
+
recent: rows.filter((row) => row.last.stage === stage),
|
|
108
|
+
// Set once, on an earlier page: pure retrieval of a detail seen once.
|
|
109
|
+
recall: rows.filter((row) => row.past.length === 1 && row.last.stage < stage),
|
|
110
|
+
// Changed on an earlier page: the stale code must be rejected.
|
|
111
|
+
latest: rows.filter((row) => row.past.length > 1 && row.last.stage < stage),
|
|
112
|
+
};
|
|
113
|
+
const rotation: RecallKind[] = ["recent", "recall", "latest"];
|
|
114
|
+
const preferred = rotation[stage % 3]!;
|
|
115
|
+
for (const kind of [preferred, "recall", "latest", "recent"] as RecallKind[]) {
|
|
116
|
+
const pool = candidates[kind];
|
|
117
|
+
if (pool.length === 0) continue;
|
|
118
|
+
// Pick the source page first, uniformly, so distances are spread rather
|
|
119
|
+
// than dominated by whichever page had the most unasked tickets.
|
|
120
|
+
const sources = [...new Set(pool.map((row) => row.last.stage))].sort((a, b) => a - b);
|
|
121
|
+
const source = sources[int(sources.length)]!;
|
|
122
|
+
const onSource = pool.filter((row) => row.last.stage === source);
|
|
123
|
+
const row = onSource[int(onSource.length)]!;
|
|
124
|
+
return { stage, kind, ticket: row.ticket, expected: row.last.code, source, stale: row.past.slice(0, -1).map((p) => p.code) };
|
|
125
|
+
}
|
|
126
|
+
throw new Error("recall fixture has no question candidate");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function recallPrompt(fixture: RecallFixture, stage: number): string {
|
|
130
|
+
const page = fixture.pages[stage]; if (!page) throw new RangeError("unknown recall stage");
|
|
131
|
+
return (stage === 0 ? fixture.contract + "\n\n" : "Continue under the original rules. ") +
|
|
132
|
+
`Stage ${stage}: read ${page.path} in full. Question: what is the current code of ticket ${page.question.ticket}? ` +
|
|
133
|
+
`Write only the code to ${page.answerPath}, then finish this stage.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type RecallVerdict = { ok: true; reason: "exact" } | { ok: false; reason: "missing" | "stale" | "other_ticket" | "wrong" };
|
|
137
|
+
|
|
138
|
+
/** Only whitespace and one wrapping quote/backtick pair are forgiven. */
|
|
139
|
+
export function verifyRecallAnswer(body: string | null, question: RecallQuestion, codes: ReadonlySet<string>): RecallVerdict {
|
|
140
|
+
if (body === null) return { ok: false, reason: "missing" };
|
|
141
|
+
let answer = body.trim();
|
|
142
|
+
const wrapped = /^(["'`])(.*)\1$/s.exec(answer);
|
|
143
|
+
if (wrapped) answer = wrapped[2]!.trim();
|
|
144
|
+
if (answer === "") return { ok: false, reason: "missing" };
|
|
145
|
+
if (answer === question.expected) return { ok: true, reason: "exact" };
|
|
146
|
+
if (question.stale.includes(answer)) return { ok: false, reason: "stale" };
|
|
147
|
+
return { ok: false, reason: codes.has(answer) ? "other_ticket" : "wrong" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Every code any page issued, to tell a confused lookup from a fabrication. */
|
|
151
|
+
export function recallCodes(fixture: RecallFixture): Set<string> {
|
|
152
|
+
return new Set(fixture.pages.flatMap((page) => page.events.map((event) => event.code)));
|
|
153
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** N.12: a returned response is durable evidence, not necessarily a completed action. */
|
|
2
|
+
import type { GuidanceEvent, LogEvent } from "./events.ts";
|
|
3
|
+
|
|
4
|
+
// One appended attempt is an operational assumption, not a measured optimum.
|
|
5
|
+
// It shares the existing request ceiling and never replays a tool automatically.
|
|
6
|
+
export const DEFAULT_OUTPUT_RECOVERY_LIMIT = 1;
|
|
7
|
+
|
|
8
|
+
export function validateOutputRecoveryLimit(limit: number): void {
|
|
9
|
+
if (!Number.isSafeInteger(limit) || limit < 0) throw new RangeError("output recovery limit must be a nonnegative safe integer");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Both wire dialects have explicit terminal reasons; an unknown reason is not success. */
|
|
13
|
+
export function isCompleteResponse(reason: string | null | undefined): boolean {
|
|
14
|
+
return reason !== undefined && reason !== null && ["stop", "end_turn", "stop_sequence", "tool_calls", "tool_use"].includes(reason);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isOutputLimit(reason: string | null | undefined): boolean {
|
|
18
|
+
return reason === "length" || reason === "max_tokens";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function incompleteToolText(reason: string | null | undefined): string {
|
|
22
|
+
return `not executed: the model response ${isOutputLimit(reason) ? "reached its output limit" : "did not finish successfully"}; ` +
|
|
23
|
+
"no tools from that response were run. Regenerate a complete call only if the action is still needed.";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Explicit metadata proves non-execution; legacy pending calls remain unknown (D11). */
|
|
27
|
+
export function knownUnexecutedTool(events: readonly LogEvent[], callId: string): string | null {
|
|
28
|
+
const owner = events.findLast(event => event.t === "assistant" && event.blocks.some(block => block.type === "tool_use" && block.call_id === callId));
|
|
29
|
+
return owner?.t === "assistant" && owner.stop_reason !== undefined && !isCompleteResponse(owner.stop_reason)
|
|
30
|
+
? incompleteToolText(owner.stop_reason) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Supply effective ancestry, not only the current segment: cutting must not reset this limit. */
|
|
34
|
+
export function outputRecoveryNotice(events: readonly LogEvent[], limit: number, remainingRequests: number):
|
|
35
|
+
Pick<GuidanceEvent, "reason" | "sources" | "text"> | null {
|
|
36
|
+
validateOutputRecoveryLimit(limit);
|
|
37
|
+
const current = events.slice(events.findLastIndex(event => event.t === "user") + 1);
|
|
38
|
+
const assistant = current.findLast(event => event.t === "assistant");
|
|
39
|
+
const notices = current.filter(event => event.t === "guidance" && event.reason === "output_recovery");
|
|
40
|
+
if (assistant?.t !== "assistant" || !isOutputLimit(assistant.stop_reason) || notices.length >= limit || remainingRequests < 1 ||
|
|
41
|
+
notices.some(event => event.t === "guidance" && event.sources.includes(assistant.id))) return null;
|
|
42
|
+
return { reason: "output_recovery", sources: [assistant.id], text:
|
|
43
|
+
"Harness: the previous response hit its output token limit and is incomplete. All tool calls in that response were left unexecuted. " +
|
|
44
|
+
"Continue from the existing evidence, avoid repeating the earlier analysis, and give a concise result or the next necessary complete tool call. " +
|
|
45
|
+
"Do not claim unexecuted actions succeeded. This recovery consumes the existing request allowance." };
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** O.5: shared work may overlap only before the next exclusive barrier. */
|
|
2
|
+
export function schedulingWindow(modes: readonly ("parallel" | "exclusive")[], start: number, limit: number): number {
|
|
3
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("maxParallelTools must be a positive integer");
|
|
4
|
+
if (modes[start] !== "parallel") return Math.min(start + 1, modes.length);
|
|
5
|
+
let end = start;
|
|
6
|
+
while (end < modes.length && modes[end] === "parallel" && end - start < limit) end++;
|
|
7
|
+
return end;
|
|
8
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings schema and merge. Pure: store reads the files, this module
|
|
3
|
+
* decides which keys exist and which layer wins.
|
|
4
|
+
*
|
|
5
|
+
* Write-back is a patch of keys the user actually changed. Dumping the
|
|
6
|
+
* whole default object into ~/.xeno/settings.json freezes today's defaults
|
|
7
|
+
* for that user forever (feat-theming design).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const SETTINGS_KEYS = [
|
|
11
|
+
"theme",
|
|
12
|
+
"thinking",
|
|
13
|
+
"statusBar",
|
|
14
|
+
"defaultProvider",
|
|
15
|
+
"defaultModel",
|
|
16
|
+
"defaultThinking",
|
|
17
|
+
"scopedModels",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type SettingsKey = (typeof SETTINGS_KEYS)[number];
|
|
21
|
+
|
|
22
|
+
export interface Settings {
|
|
23
|
+
readonly theme?: string;
|
|
24
|
+
readonly thinking?: string;
|
|
25
|
+
readonly statusBar?: boolean;
|
|
26
|
+
readonly defaultProvider?: string;
|
|
27
|
+
readonly defaultModel?: string;
|
|
28
|
+
/** Ctrl+S in the thinking selector. Distinct from `thinking` so /settings does not overwrite a saved default. */
|
|
29
|
+
readonly defaultThinking?: string;
|
|
30
|
+
/** `--models` / Ctrl+P set. Absent means there is nothing to cycle. */
|
|
31
|
+
readonly scopedModels?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SettingsLayers {
|
|
35
|
+
readonly builtin: Settings;
|
|
36
|
+
readonly user?: Settings;
|
|
37
|
+
readonly project?: Settings;
|
|
38
|
+
readonly cli?: Settings;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SettingsParse {
|
|
42
|
+
readonly settings: Settings;
|
|
43
|
+
readonly diagnostics: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const THINKING_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
47
|
+
|
|
48
|
+
export function defaultSettings(): Settings {
|
|
49
|
+
return { theme: "dark", thinking: "high", statusBar: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mergeSettings(layers: SettingsLayers): Settings {
|
|
53
|
+
return {
|
|
54
|
+
...layers.builtin,
|
|
55
|
+
...layers.user,
|
|
56
|
+
...layers.project,
|
|
57
|
+
...layers.cli,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Only keys present on `explicit`. Passing the merged object here would
|
|
63
|
+
* persist defaults and pin old users to them.
|
|
64
|
+
*/
|
|
65
|
+
export function settingsToWrite(explicit: Partial<Settings>): Record<string, unknown> {
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
if (explicit.theme !== undefined) out["theme"] = explicit.theme;
|
|
68
|
+
if (explicit.thinking !== undefined) out["thinking"] = explicit.thinking;
|
|
69
|
+
if (explicit.statusBar !== undefined) out["statusBar"] = explicit.statusBar;
|
|
70
|
+
if (explicit.defaultProvider !== undefined) out["defaultProvider"] = explicit.defaultProvider;
|
|
71
|
+
if (explicit.defaultModel !== undefined) out["defaultModel"] = explicit.defaultModel;
|
|
72
|
+
if (explicit.defaultThinking !== undefined) out["defaultThinking"] = explicit.defaultThinking;
|
|
73
|
+
if (explicit.scopedModels !== undefined) out["scopedModels"] = explicit.scopedModels;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseSettings(json: unknown): SettingsParse {
|
|
78
|
+
if (json === null || json === undefined) {
|
|
79
|
+
return { settings: {}, diagnostics: [] };
|
|
80
|
+
}
|
|
81
|
+
if (typeof json !== "object" || Array.isArray(json)) {
|
|
82
|
+
return { settings: {}, diagnostics: ["settings is not an object"] };
|
|
83
|
+
}
|
|
84
|
+
const rec = json as { readonly [k: string]: unknown };
|
|
85
|
+
const diagnostics: string[] = [];
|
|
86
|
+
const settings: { [K in SettingsKey]?: Settings[K] } = {};
|
|
87
|
+
|
|
88
|
+
for (const key of Object.keys(rec)) {
|
|
89
|
+
if (!(SETTINGS_KEYS as readonly string[]).includes(key)) {
|
|
90
|
+
diagnostics.push(`unknown setting: ${key}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const theme = rec["theme"];
|
|
95
|
+
if (theme !== undefined) {
|
|
96
|
+
if (typeof theme === "string" && theme.length > 0) settings.theme = theme;
|
|
97
|
+
else diagnostics.push("settings.theme must be a non-empty string");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const thinking = rec["thinking"];
|
|
101
|
+
if (thinking !== undefined) {
|
|
102
|
+
if (typeof thinking === "string" && THINKING_LEVELS.has(thinking)) settings.thinking = thinking;
|
|
103
|
+
else diagnostics.push("settings.thinking must be low|medium|high|xhigh|max");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const statusBar = rec["statusBar"];
|
|
107
|
+
if (statusBar !== undefined) {
|
|
108
|
+
if (typeof statusBar === "boolean") settings.statusBar = statusBar;
|
|
109
|
+
else diagnostics.push("settings.statusBar must be a boolean");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const defaultProvider = rec["defaultProvider"];
|
|
113
|
+
if (defaultProvider !== undefined) {
|
|
114
|
+
if (typeof defaultProvider === "string") settings.defaultProvider = defaultProvider;
|
|
115
|
+
else diagnostics.push("settings.defaultProvider must be a string");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const defaultModel = rec["defaultModel"];
|
|
119
|
+
if (defaultModel !== undefined) {
|
|
120
|
+
if (typeof defaultModel === "string") settings.defaultModel = defaultModel;
|
|
121
|
+
else diagnostics.push("settings.defaultModel must be a string");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const defaultThinking = rec["defaultThinking"];
|
|
125
|
+
if (defaultThinking !== undefined) {
|
|
126
|
+
if (typeof defaultThinking === "string" && THINKING_LEVELS.has(defaultThinking)) {
|
|
127
|
+
settings.defaultThinking = defaultThinking;
|
|
128
|
+
} else diagnostics.push("settings.defaultThinking must be low|medium|high|xhigh|max");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scopedModels = rec["scopedModels"];
|
|
132
|
+
if (scopedModels !== undefined) {
|
|
133
|
+
const parsed = parseScopedModels(scopedModels);
|
|
134
|
+
if (parsed === undefined) diagnostics.push("settings.scopedModels must be an array of strings");
|
|
135
|
+
else settings.scopedModels = parsed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { settings, diagnostics };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseScopedModels(raw: unknown): readonly string[] | undefined {
|
|
142
|
+
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
|
|
143
|
+
if (typeof raw === "string") {
|
|
144
|
+
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Keys that were actually present on the raw object, after validation. */
|
|
150
|
+
export function explicitSettings(raw: unknown, parsed: Settings): Partial<Settings> {
|
|
151
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
152
|
+
const rec = raw as { readonly [k: string]: unknown };
|
|
153
|
+
const out: {
|
|
154
|
+
theme?: string;
|
|
155
|
+
thinking?: string;
|
|
156
|
+
statusBar?: boolean;
|
|
157
|
+
defaultProvider?: string;
|
|
158
|
+
defaultModel?: string;
|
|
159
|
+
defaultThinking?: string;
|
|
160
|
+
scopedModels?: readonly string[];
|
|
161
|
+
} = {};
|
|
162
|
+
if ("theme" in rec && parsed.theme !== undefined) out.theme = parsed.theme;
|
|
163
|
+
if ("thinking" in rec && parsed.thinking !== undefined) out.thinking = parsed.thinking;
|
|
164
|
+
if ("statusBar" in rec && parsed.statusBar !== undefined) out.statusBar = parsed.statusBar;
|
|
165
|
+
if ("defaultProvider" in rec && parsed.defaultProvider !== undefined) {
|
|
166
|
+
out.defaultProvider = parsed.defaultProvider;
|
|
167
|
+
}
|
|
168
|
+
if ("defaultModel" in rec && parsed.defaultModel !== undefined) out.defaultModel = parsed.defaultModel;
|
|
169
|
+
if ("defaultThinking" in rec && parsed.defaultThinking !== undefined) {
|
|
170
|
+
out.defaultThinking = parsed.defaultThinking;
|
|
171
|
+
}
|
|
172
|
+
if ("scopedModels" in rec && parsed.scopedModels !== undefined) out.scopedModels = parsed.scopedModels;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill validation and the frozen-prefix fragment.
|
|
3
|
+
*
|
|
4
|
+
* A skill is a text resource, not a tool. The system prompt gets the name
|
|
5
|
+
* and description so the model can decide whether to read the file; the
|
|
6
|
+
* body stays out of the prefix because the prefix is sent on every request
|
|
7
|
+
* (feat-skills-and-prompts design; Agent Skills progressive disclosure).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
11
|
+
|
|
12
|
+
/** Agent Skills name rule. Assumed from the spec, not measured. */
|
|
13
|
+
const MAX_NAME_LENGTH = 64;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 1–64 chars, lowercase / digits / hyphens, no leading, trailing, or
|
|
17
|
+
* doubled hyphen. A name that fails this is skipped, not coerced — an
|
|
18
|
+
* auto-slug would make two different directories collide silently.
|
|
19
|
+
*/
|
|
20
|
+
const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
|
|
22
|
+
export interface Skill {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly description: string;
|
|
25
|
+
readonly filePath: string;
|
|
26
|
+
readonly body: string;
|
|
27
|
+
readonly disableModelInvocation: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isValidSkillName(name: string): boolean {
|
|
31
|
+
return name.length >= 1 && name.length <= MAX_NAME_LENGTH && NAME_RE.test(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseSkill(input: {
|
|
35
|
+
readonly filePath: string;
|
|
36
|
+
readonly parentDirName: string;
|
|
37
|
+
readonly text: string;
|
|
38
|
+
}): { skill: Skill | null; diagnostics: readonly string[] } {
|
|
39
|
+
const parsed = parseFrontmatter(input.text);
|
|
40
|
+
const diagnostics = [...parsed.diagnostics];
|
|
41
|
+
const desc = parsed.fields["description"];
|
|
42
|
+
const description = typeof desc === "string" ? desc.trim() : "";
|
|
43
|
+
if (description.length === 0) {
|
|
44
|
+
diagnostics.push(`${input.filePath}: missing description`);
|
|
45
|
+
return { skill: null, diagnostics };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const named = parsed.fields["name"];
|
|
49
|
+
const name = typeof named === "string" && named.trim().length > 0
|
|
50
|
+
? named.trim()
|
|
51
|
+
: input.parentDirName;
|
|
52
|
+
if (!isValidSkillName(name)) {
|
|
53
|
+
diagnostics.push(`${input.filePath}: name "${name}" is not a valid skill name`);
|
|
54
|
+
return { skill: null, diagnostics };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const disable = parsed.fields["disable-model-invocation"] === true;
|
|
58
|
+
return {
|
|
59
|
+
skill: {
|
|
60
|
+
name,
|
|
61
|
+
description,
|
|
62
|
+
filePath: input.filePath,
|
|
63
|
+
body: parsed.body,
|
|
64
|
+
disableModelInvocation: disable,
|
|
65
|
+
},
|
|
66
|
+
diagnostics,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* XML fragment per the Agent Skills integrate-skills shape, so a skill
|
|
72
|
+
* written for pi is recognised here. Location is the path the model should
|
|
73
|
+
* `read`; the body is deliberately absent.
|
|
74
|
+
*/
|
|
75
|
+
export function formatSkillsForPrompt(skills: readonly Skill[]): string {
|
|
76
|
+
const visible = skills.filter((s) => !s.disableModelInvocation);
|
|
77
|
+
if (visible.length === 0) return "";
|
|
78
|
+
const lines = [
|
|
79
|
+
"The following skills provide specialized instructions for specific tasks.",
|
|
80
|
+
"Use the read tool to load a skill's file when the task matches its description.",
|
|
81
|
+
"When a skill file references a relative path, resolve it against the skill directory.",
|
|
82
|
+
"",
|
|
83
|
+
"<available_skills>",
|
|
84
|
+
];
|
|
85
|
+
for (const skill of visible) {
|
|
86
|
+
lines.push(" <skill>");
|
|
87
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
88
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
89
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
|
90
|
+
lines.push(" </skill>");
|
|
91
|
+
}
|
|
92
|
+
lines.push("</available_skills>");
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Append the skills section without touching the context-file assembly. */
|
|
97
|
+
export function appendSkillsToSystem(system: string, skills: readonly Skill[]): string {
|
|
98
|
+
const section = formatSkillsForPrompt(skills);
|
|
99
|
+
return section.length === 0 ? system : `${system}\n\n${section}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `/skill:name` is the user forcing a read. Wrapping the body with the
|
|
104
|
+
* path lets relative references inside the skill resolve the same way a
|
|
105
|
+
* model-issued `read` would.
|
|
106
|
+
*/
|
|
107
|
+
export function skillInvocationMessage(skill: Skill, args: string): string {
|
|
108
|
+
const wrapped =
|
|
109
|
+
`<skill name="${escapeXml(skill.name)}" location="${escapeXml(skill.filePath)}">\n` +
|
|
110
|
+
`${skill.body}\n` +
|
|
111
|
+
`</skill>`;
|
|
112
|
+
return args.length > 0 ? `${wrapped}\n\nUser: ${args}` : wrapped;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function escapeXml(s: string): string {
|
|
116
|
+
return s
|
|
117
|
+
.replace(/&/g, "&")
|
|
118
|
+
.replace(/</g, "<")
|
|
119
|
+
.replace(/>/g, ">")
|
|
120
|
+
.replace(/"/g, """);
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** N.9: restartable progress accounting independent of model-context boundaries. */
|
|
2
|
+
import type { LogEvent } from "./events.ts";
|
|
3
|
+
import { pendingToolCalls } from "./execution.ts";
|
|
4
|
+
import { exchangeProgressChecks } from "./evidence.ts";
|
|
5
|
+
import { deriveProgress, type ProgressEvidence } from "./progress.ts";
|
|
6
|
+
|
|
7
|
+
// A conservative hypothesis: enough room to change method after a mid-budget
|
|
8
|
+
// notice, while bounding repeated paid requests. Not a task-quality classifier.
|
|
9
|
+
export const DEFAULT_STALL_LIMIT = 12;
|
|
10
|
+
export interface StallState {
|
|
11
|
+
evidence: readonly ProgressEvidence[];
|
|
12
|
+
pending: readonly LogEvent[];
|
|
13
|
+
pendingNode: number;
|
|
14
|
+
consecutive: number;
|
|
15
|
+
warned: boolean;
|
|
16
|
+
}
|
|
17
|
+
export function emptyStallState(): StallState {
|
|
18
|
+
return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, warned: false };
|
|
19
|
+
}
|
|
20
|
+
export function finishStallExchange(state: StallState): StallState {
|
|
21
|
+
const assistant = state.pending[0];
|
|
22
|
+
if (assistant?.t !== "assistant" || pendingToolCalls(state.pending).length > 0) return state;
|
|
23
|
+
if (!assistant.blocks.some((block) => block.type === "tool_use")) return { ...state, pending: [] };
|
|
24
|
+
const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, exchangeProgressChecks(state.pending));
|
|
25
|
+
return { ...state, pending: [], evidence: progress.evidence,
|
|
26
|
+
consecutive: progress.progressed ? 0 : state.consecutive + 1, warned: progress.progressed ? false : state.warned };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Fold original events, never the retained projection where evidence can repeat. */
|
|
30
|
+
export function advanceStall(state: StallState, entries: readonly { node: number; event: LogEvent }[]): StallState {
|
|
31
|
+
let result = state;
|
|
32
|
+
for (const { node, event } of entries) {
|
|
33
|
+
if (event.t === "assistant" || event.t === "user" || event.t === "cut" || (event.t === "guidance" && event.reason === "stall")) {
|
|
34
|
+
result = finishStallExchange(result);
|
|
35
|
+
if (event.t === "user") result = { ...result, pending: [], consecutive: 0, warned: false };
|
|
36
|
+
if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
|
|
37
|
+
if (event.t === "guidance") result = { ...result, warned: true };
|
|
38
|
+
} else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
export function stallDecision(state: StallState, limit: number) {
|
|
43
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
|
|
44
|
+
const current = finishStallExchange(state);
|
|
45
|
+
return { consecutive: current.consecutive,
|
|
46
|
+
stop: limit > 0 && current.consecutive >= limit,
|
|
47
|
+
warn: limit > 0 && current.consecutive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
|
|
48
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text views an edit is matched against.
|
|
3
|
+
*
|
|
4
|
+
* A model never sees the bytes of a file, only read's rendering of them:
|
|
5
|
+
* no byte order mark, no carriage returns. An edit copied from that
|
|
6
|
+
* rendering has to be matched against the same view and written back in the
|
|
7
|
+
* file's own encoding -- otherwise every edit to a CRLF file fails, however
|
|
8
|
+
* exactly the model copied what it was shown.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TextView {
|
|
12
|
+
/** Content with the BOM removed and every line ending as `\n`. */
|
|
13
|
+
text: string;
|
|
14
|
+
bom: boolean;
|
|
15
|
+
/** Decided by the first line ending, as pi does; mixed files take the first. */
|
|
16
|
+
ending: "\n" | "\r\n";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toView(raw: string): TextView {
|
|
20
|
+
const bom = raw.startsWith("");
|
|
21
|
+
const body = bom ? raw.slice(1) : raw;
|
|
22
|
+
const lf = body.indexOf("\n");
|
|
23
|
+
const ending = lf > 0 && body[lf - 1] === "\r" ? "\r\n" : "\n";
|
|
24
|
+
return { text: toLF(body), bom, ending };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fromView(view: TextView, text: string): string {
|
|
28
|
+
return (view.bom ? "" : "") + (view.ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toLF(text: string): string {
|
|
32
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The equivalence a copied snippet is allowed to differ by.
|
|
37
|
+
*
|
|
38
|
+
* Each rule is a known way a model's copy drifts from the file: trailing
|
|
39
|
+
* whitespace dropped, typographic quotes and dashes straightened, exotic
|
|
40
|
+
* spaces turned into plain ones, compatibility forms folded (NFKC). Ported
|
|
41
|
+
* from pi-mono `edit-diff.ts` `normalizeForFuzzyMatch` (MIT, 6f75515).
|
|
42
|
+
*
|
|
43
|
+
* The line count is preserved -- no rule adds or removes a `\n` -- which is
|
|
44
|
+
* what lets a match found here be mapped back onto whole lines of the
|
|
45
|
+
* original, leaving every untouched line byte for byte as it was.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeLoose(text: string): string {
|
|
48
|
+
return text
|
|
49
|
+
.normalize("NFKC")
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map((line) => line.trimEnd())
|
|
52
|
+
.join("\n")
|
|
53
|
+
.replace(/[‘’‚‛]/g, "'")
|
|
54
|
+
.replace(/[“”„‟]/g, "\"")
|
|
55
|
+
.replace(/[‐‑‒–—―−]/g, "-")
|
|
56
|
+
.replace(/[ - ]/g, " ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Levenshtein distance with the row buffer reused; inputs are bounded by callers. */
|
|
60
|
+
export function editDistance(a: string, b: string): number {
|
|
61
|
+
if (a === b) return 0;
|
|
62
|
+
if (a.length === 0) return b.length;
|
|
63
|
+
if (b.length === 0) return a.length;
|
|
64
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
65
|
+
let current = new Array<number>(b.length + 1);
|
|
66
|
+
for (let i = 1; i <= a.length; i++) {
|
|
67
|
+
current[0] = i;
|
|
68
|
+
for (let j = 1; j <= b.length; j++) {
|
|
69
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
70
|
+
current[j] = Math.min(previous[j]! + 1, current[j - 1]! + 1, previous[j - 1]! + cost);
|
|
71
|
+
}
|
|
72
|
+
[previous, current] = [current, previous];
|
|
73
|
+
}
|
|
74
|
+
return previous[b.length]!;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 1 for identical, 0 for nothing in common. */
|
|
78
|
+
export function similarity(a: string, b: string): number {
|
|
79
|
+
const longest = Math.max(a.length, b.length);
|
|
80
|
+
return longest === 0 ? 1 : 1 - editDistance(a, b) / longest;
|
|
81
|
+
}
|