topchester-ai 0.9.0 → 0.11.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/dist/cli.mjs +3204 -514
- package/dist/cli.mjs.map +1 -1
- package/package.json +5 -2
package/dist/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { parse } from "yaml";
|
|
|
15
15
|
import pino from "pino";
|
|
16
16
|
import picomatch from "picomatch";
|
|
17
17
|
import { uuidv7 } from "uuidv7";
|
|
18
|
-
import {
|
|
18
|
+
import { CURSOR_MARKER, Markdown, ProcessTerminal, TUI, decodeKittyPrintable, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
19
19
|
import { highlight, supportsLanguage } from "cli-highlight";
|
|
20
20
|
//#region src/agent/tools/ai-sdk-tools.ts
|
|
21
21
|
function toAiSdkToolSet(definitions) {
|
|
@@ -28,13 +28,20 @@ function toAiSdkToolSet(definitions) {
|
|
|
28
28
|
//#region src/knowledge/session-overlay.ts
|
|
29
29
|
const overlays = /* @__PURE__ */ new Map();
|
|
30
30
|
function recordAgentFileEdit(workspaceRoot, event) {
|
|
31
|
+
return recordAgentFileMutation(workspaceRoot, event);
|
|
32
|
+
}
|
|
33
|
+
function recordAgentFileCreate(workspaceRoot, event) {
|
|
34
|
+
return recordAgentFileMutation(workspaceRoot, event);
|
|
35
|
+
}
|
|
36
|
+
function recordAgentFileMutation(workspaceRoot, event) {
|
|
31
37
|
const overlay = getOrCreateOverlay(resolve(workspaceRoot));
|
|
32
38
|
const previous = overlay.dirtyFiles.get(event.path);
|
|
33
39
|
overlay.drift = "dirty_known";
|
|
34
40
|
overlay.kbState = "needs_sync";
|
|
35
41
|
overlay.needsSync = true;
|
|
36
42
|
overlay.updatedAt = event.timestamp;
|
|
37
|
-
overlay.
|
|
43
|
+
overlay.mutationEvents.push(event);
|
|
44
|
+
if (event.kind === "file_edit") overlay.editEvents.push(event);
|
|
38
45
|
overlay.dirtyFiles.set(event.path, {
|
|
39
46
|
path: event.path,
|
|
40
47
|
source: "agent",
|
|
@@ -42,7 +49,7 @@ function recordAgentFileEdit(workspaceRoot, event) {
|
|
|
42
49
|
kbState: "needs_sync",
|
|
43
50
|
l1State: "stale",
|
|
44
51
|
derivedState: "suspect",
|
|
45
|
-
beforeHash: previous?.beforeHash ?? event.beforeHash,
|
|
52
|
+
beforeHash: previous?.beforeHash ?? (event.kind === "file_create" ? void 0 : event.beforeHash),
|
|
46
53
|
afterHash: event.afterHash,
|
|
47
54
|
firstChangedLine: event.firstChangedLine,
|
|
48
55
|
lastEditedAt: event.timestamp,
|
|
@@ -59,7 +66,8 @@ function getOrCreateOverlay(workspaceRoot) {
|
|
|
59
66
|
kbState: "current",
|
|
60
67
|
needsSync: false,
|
|
61
68
|
dirtyFiles: /* @__PURE__ */ new Map(),
|
|
62
|
-
editEvents: []
|
|
69
|
+
editEvents: [],
|
|
70
|
+
mutationEvents: []
|
|
63
71
|
};
|
|
64
72
|
overlays.set(workspaceRoot, overlay);
|
|
65
73
|
return overlay;
|
|
@@ -72,6 +80,7 @@ function snapshotOverlay(overlay) {
|
|
|
72
80
|
needsSync: overlay.needsSync,
|
|
73
81
|
dirtyFiles: [...overlay.dirtyFiles.values()].map((file) => ({ ...file })),
|
|
74
82
|
editEvents: overlay.editEvents.map((event) => ({ ...event })),
|
|
83
|
+
mutationEvents: overlay.mutationEvents.map((event) => ({ ...event })),
|
|
75
84
|
updatedAt: overlay.updatedAt
|
|
76
85
|
};
|
|
77
86
|
}
|
|
@@ -88,37 +97,40 @@ async function enqueueFileMutation(path, mutate) {
|
|
|
88
97
|
}
|
|
89
98
|
//#endregion
|
|
90
99
|
//#region src/agent/tools/types.ts
|
|
100
|
+
function isToolErrorResult(result) {
|
|
101
|
+
return "error" in result && typeof result.error === "string";
|
|
102
|
+
}
|
|
91
103
|
function defineTool(definition) {
|
|
92
104
|
return definition;
|
|
93
105
|
}
|
|
94
106
|
//#endregion
|
|
95
107
|
//#region src/agent/tools/edit-file.ts
|
|
96
108
|
const editFileEditSchema = z.object({
|
|
97
|
-
old_text: z.string(),
|
|
98
|
-
new_text: z.string()
|
|
109
|
+
old_text: z.string().describe("Exact current file text to replace; include whitespace exactly."),
|
|
110
|
+
new_text: z.string().describe("Replacement text for old_text.")
|
|
99
111
|
});
|
|
100
112
|
const editFileTool = defineTool({
|
|
101
113
|
name: "edit_file",
|
|
102
|
-
description: "Edit an existing UTF-8 file inside the workspace with exact text replacements.",
|
|
103
|
-
prompt: "edit_file: edit an existing UTF-8 file inside the workspace with exact old_text/new_text replacements; read the file first, keep old_text small but unique, and make multiple disjoint edits for one file in one call. To use it, reply with only JSON: {\"tool\":\"edit_file\",\"args\":{\"path\":\"src/example.ts\",\"
|
|
114
|
+
description: "Edit an existing UTF-8 file inside the workspace with exact text replacements. Use expected_current_hash only as the current/pre-edit hash from read_file, never as a predicted post-edit hash.",
|
|
115
|
+
prompt: "edit_file: edit an existing UTF-8 file inside the workspace with exact old_text/new_text replacements; read the file first, keep old_text small but unique, and make multiple disjoint edits for one file in one call. expected_current_hash is optional and must be the current/pre-edit hash returned by the latest read_file for that file; never invent it or use a predicted after-edit hash. To use it, reply with only JSON: {\"tool\":\"edit_file\",\"args\":{\"path\":\"src/example.ts\",\"expected_current_hash\":\"sha256:current-file-hash-from-read_file\",\"edits\":[{\"old_text\":\"const enabled = false;\\n\",\"new_text\":\"const enabled = true;\\n\"}]}}",
|
|
104
116
|
argsSchema: z.object({
|
|
105
|
-
path: z.string(),
|
|
106
|
-
|
|
107
|
-
edits: z.array(editFileEditSchema).min(1)
|
|
117
|
+
path: z.string().describe("Workspace-relative path to the existing UTF-8 file to edit."),
|
|
118
|
+
expected_current_hash: z.string().optional().describe("Optional current file hash returned by the latest read_file result for this file. This is checked before editing to catch stale reads; it is not the hash after the edit."),
|
|
119
|
+
edits: z.array(editFileEditSchema).min(1).describe("Exact text replacements to apply to the original file.")
|
|
108
120
|
}),
|
|
109
121
|
execute: (context, args) => editWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
|
|
110
122
|
});
|
|
111
123
|
async function editWorkspaceFile(workspaceRoot, args, options = {}) {
|
|
112
|
-
const scopedPath = resolveWorkspaceScopedPath$
|
|
124
|
+
const scopedPath = resolveWorkspaceScopedPath$4(workspaceRoot, args.path);
|
|
113
125
|
return enqueueFileMutation(scopedPath.path, async () => {
|
|
114
126
|
const fileStat = await statExistingFile(scopedPath.path, args.path);
|
|
115
127
|
const beforeBytes = await readFile(scopedPath.path);
|
|
116
|
-
const beforeHash = hashBytes(beforeBytes);
|
|
117
|
-
if (args.
|
|
118
|
-
const result = applyExactEdits(decodeUtf8(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
|
|
128
|
+
const beforeHash = hashBytes$1(beforeBytes);
|
|
129
|
+
if (args.expected_current_hash && args.expected_current_hash !== beforeHash) throw new Error(`edit_file expected_current_hash did not match ${scopedPath.relativePath}.`);
|
|
130
|
+
const result = applyExactEdits(decodeUtf8$1(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
|
|
119
131
|
const afterBytes = Buffer.from(result.newContent, "utf8");
|
|
120
|
-
const afterHash = hashBytes(afterBytes);
|
|
121
|
-
await writeFileAtomically(scopedPath.path, afterBytes, fileStat.mode);
|
|
132
|
+
const afterHash = hashBytes$1(afterBytes);
|
|
133
|
+
await writeFileAtomically$1(scopedPath.path, afterBytes, fileStat.mode);
|
|
122
134
|
const editEvent = {
|
|
123
135
|
kind: "file_edit",
|
|
124
136
|
source: "agent",
|
|
@@ -172,7 +184,7 @@ function applyExactEdits(content, edits, path = "file") {
|
|
|
172
184
|
firstChangedLine: getFirstChangedLine(document.body, newBody)
|
|
173
185
|
};
|
|
174
186
|
}
|
|
175
|
-
function resolveWorkspaceScopedPath$
|
|
187
|
+
function resolveWorkspaceScopedPath$4(workspaceRoot, path) {
|
|
176
188
|
if (path.includes("\0")) throw new Error("edit_file path is invalid.");
|
|
177
189
|
const resolvedWorkspace = resolve(workspaceRoot);
|
|
178
190
|
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
@@ -189,13 +201,13 @@ async function statExistingFile(resolvedPath, originalPath) {
|
|
|
189
201
|
try {
|
|
190
202
|
fileStat = await stat(resolvedPath);
|
|
191
203
|
} catch (error) {
|
|
192
|
-
if (isNodeError$
|
|
204
|
+
if (isNodeError$3(error) && error.code === "ENOENT") throw new Error(`edit_file can only edit existing files: ${originalPath}`);
|
|
193
205
|
throw error;
|
|
194
206
|
}
|
|
195
207
|
if (!fileStat.isFile()) throw new Error(`edit_file can only edit regular files: ${originalPath}`);
|
|
196
208
|
return fileStat;
|
|
197
209
|
}
|
|
198
|
-
function decodeUtf8(path, bytes) {
|
|
210
|
+
function decodeUtf8$1(path, bytes) {
|
|
199
211
|
try {
|
|
200
212
|
return new TextDecoder("utf-8", {
|
|
201
213
|
fatal: true,
|
|
@@ -205,7 +217,7 @@ function decodeUtf8(path, bytes) {
|
|
|
205
217
|
throw new Error(`edit_file can only edit UTF-8 text files: ${path}`);
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
|
-
async function writeFileAtomically(path, content, mode) {
|
|
220
|
+
async function writeFileAtomically$1(path, content, mode) {
|
|
209
221
|
const temporaryPath = resolve(dirname(path), `.${basename(path)}.topchester-${process.pid}-${randomUUID()}.tmp`);
|
|
210
222
|
try {
|
|
211
223
|
await writeFile(temporaryPath, content, {
|
|
@@ -218,7 +230,7 @@ async function writeFileAtomically(path, content, mode) {
|
|
|
218
230
|
throw error;
|
|
219
231
|
}
|
|
220
232
|
}
|
|
221
|
-
function hashBytes(bytes) {
|
|
233
|
+
function hashBytes$1(bytes) {
|
|
222
234
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
223
235
|
}
|
|
224
236
|
function summarizeDiff(diff) {
|
|
@@ -231,7 +243,7 @@ function summarizeDiff(diff) {
|
|
|
231
243
|
}
|
|
232
244
|
return `+${added}/-${removed}`;
|
|
233
245
|
}
|
|
234
|
-
function isNodeError$
|
|
246
|
+
function isNodeError$3(error) {
|
|
235
247
|
return error instanceof Error && "code" in error;
|
|
236
248
|
}
|
|
237
249
|
function normalizeEdits(edits) {
|
|
@@ -367,7 +379,7 @@ const ignoredDirectories = new Set([
|
|
|
367
379
|
]);
|
|
368
380
|
const findFileTool = defineTool({
|
|
369
381
|
name: "find_file",
|
|
370
|
-
description: "Find files by fuzzy name inside the workspace.",
|
|
382
|
+
description: "Find files by fuzzy name inside the workspace. Results are file paths, not file contents; use read_file next when the user needs contents.",
|
|
371
383
|
prompt: "find_file: find existing files by fuzzy path or filename inside the workspace; matches may appear in the middle of a filename, and results are file paths, not file contents. To use it, reply with only JSON: {\"tool\":\"find_file\",\"args\":{\"query\":\"runtime\"}}",
|
|
372
384
|
argsSchema: findFileArgsSchema,
|
|
373
385
|
execute: (context, args) => findWorkspaceFilesByName(context.workspaceRoot, args, {
|
|
@@ -376,7 +388,7 @@ const findFileTool = defineTool({
|
|
|
376
388
|
})
|
|
377
389
|
});
|
|
378
390
|
async function findWorkspaceFilesByName(workspaceRoot, args, options = {}) {
|
|
379
|
-
const scopedPath = resolveWorkspaceScopedPath$
|
|
391
|
+
const scopedPath = resolveWorkspaceScopedPath$3(workspaceRoot, args.path);
|
|
380
392
|
const matches = (await collectWorkspaceFiles(scopedPath.workspaceRoot, scopedPath.path, scopedPath.relativePath, options)).map((path) => {
|
|
381
393
|
const score = scoreFileMatch(args.query, path);
|
|
382
394
|
return score > 0 ? {
|
|
@@ -574,7 +586,7 @@ function scoreSubsequenceMatch(query, value) {
|
|
|
574
586
|
function normalize(value) {
|
|
575
587
|
return value.trim().toLowerCase().replaceAll("\\", "/");
|
|
576
588
|
}
|
|
577
|
-
function resolveWorkspaceScopedPath$
|
|
589
|
+
function resolveWorkspaceScopedPath$3(workspaceRoot, path) {
|
|
578
590
|
const resolvedWorkspace = resolve(workspaceRoot);
|
|
579
591
|
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
580
592
|
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
@@ -618,6 +630,714 @@ function getExitCode$1(error) {
|
|
|
618
630
|
function isRecord$3(value) {
|
|
619
631
|
return typeof value === "object" && value !== null;
|
|
620
632
|
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/agent/tools/git-runner.ts
|
|
635
|
+
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
636
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 4e4;
|
|
637
|
+
const GIT_FIELD_SEPARATOR = "";
|
|
638
|
+
async function runGit(options) {
|
|
639
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
640
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
641
|
+
const args = [
|
|
642
|
+
"--no-optional-locks",
|
|
643
|
+
"-c",
|
|
644
|
+
"core.quotepath=false",
|
|
645
|
+
"-c",
|
|
646
|
+
"core.fsmonitor=false",
|
|
647
|
+
...options.args
|
|
648
|
+
];
|
|
649
|
+
return new Promise((resolveCommand) => {
|
|
650
|
+
const child = spawn("git", args, {
|
|
651
|
+
cwd: options.cwd,
|
|
652
|
+
env: {
|
|
653
|
+
...process.env,
|
|
654
|
+
PATH: options.pathEnv ?? process.env.PATH ?? "",
|
|
655
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
656
|
+
GIT_PAGER: "cat",
|
|
657
|
+
PAGER: "cat",
|
|
658
|
+
LESS: "-F -X"
|
|
659
|
+
},
|
|
660
|
+
stdio: [
|
|
661
|
+
"ignore",
|
|
662
|
+
"pipe",
|
|
663
|
+
"pipe"
|
|
664
|
+
]
|
|
665
|
+
});
|
|
666
|
+
const stdoutChunks = [];
|
|
667
|
+
const stderrChunks = [];
|
|
668
|
+
let stdoutBytes = 0;
|
|
669
|
+
let stderrBytes = 0;
|
|
670
|
+
let truncated = false;
|
|
671
|
+
let timedOut = false;
|
|
672
|
+
let settled = false;
|
|
673
|
+
const timer = setTimeout(() => {
|
|
674
|
+
timedOut = true;
|
|
675
|
+
child.kill("SIGTERM");
|
|
676
|
+
setTimeout(() => {
|
|
677
|
+
if (!settled) child.kill("SIGKILL");
|
|
678
|
+
}, 500).unref();
|
|
679
|
+
}, timeoutMs);
|
|
680
|
+
child.stdout.on("data", (chunk) => {
|
|
681
|
+
const appended = appendBoundedChunk(stdoutChunks, stdoutBytes, chunk, maxOutputBytes);
|
|
682
|
+
stdoutBytes = appended.bytes;
|
|
683
|
+
truncated = truncated || appended.truncated;
|
|
684
|
+
});
|
|
685
|
+
child.stderr.on("data", (chunk) => {
|
|
686
|
+
const appended = appendBoundedChunk(stderrChunks, stderrBytes, chunk, Math.min(8e3, maxOutputBytes));
|
|
687
|
+
stderrBytes = appended.bytes;
|
|
688
|
+
truncated = truncated || appended.truncated;
|
|
689
|
+
});
|
|
690
|
+
child.once("error", (error) => {
|
|
691
|
+
clearTimeout(timer);
|
|
692
|
+
settled = true;
|
|
693
|
+
resolveCommand({
|
|
694
|
+
stdout: "",
|
|
695
|
+
stderr: error.code === "ENOENT" ? "git is not available on PATH.\n" : `${error.message}\n`,
|
|
696
|
+
exitCode: 127,
|
|
697
|
+
timedOut: false,
|
|
698
|
+
truncated: false,
|
|
699
|
+
binary: false,
|
|
700
|
+
missingGit: error.code === "ENOENT"
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
child.once("close", (code) => {
|
|
704
|
+
clearTimeout(timer);
|
|
705
|
+
settled = true;
|
|
706
|
+
const stdout = Buffer.concat(stdoutChunks);
|
|
707
|
+
const stderr = Buffer.concat(stderrChunks);
|
|
708
|
+
const binary = !options.allowNulOutput && (containsNul(stdout) || containsNul(stderr));
|
|
709
|
+
resolveCommand({
|
|
710
|
+
stdout: binary ? "" : stdout.toString("utf8"),
|
|
711
|
+
stderr: binary ? "git output contained binary data.\n" : stderr.toString("utf8"),
|
|
712
|
+
exitCode: code ?? (timedOut ? 124 : 1),
|
|
713
|
+
timedOut,
|
|
714
|
+
truncated,
|
|
715
|
+
binary,
|
|
716
|
+
missingGit: false
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function ensureInsideWorkspace(workspaceRoot, path) {
|
|
722
|
+
if (path.length === 0 || path.includes("\0")) throw new Error("Git path is invalid.");
|
|
723
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
724
|
+
const absolutePath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
725
|
+
const relativePath = relative(resolvedWorkspace, absolutePath);
|
|
726
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`Git path must stay inside the workspace: ${path}`);
|
|
727
|
+
return {
|
|
728
|
+
workspaceRoot: resolvedWorkspace,
|
|
729
|
+
absolutePath,
|
|
730
|
+
relativePath: relativePath || "."
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
async function getRepoInfo(workspaceRoot, pathEnv) {
|
|
734
|
+
const workspace = await realpath(resolve(workspaceRoot));
|
|
735
|
+
const root = await runGit({
|
|
736
|
+
cwd: workspace,
|
|
737
|
+
pathEnv,
|
|
738
|
+
args: ["rev-parse", "--show-toplevel"],
|
|
739
|
+
maxOutputBytes: 8e3
|
|
740
|
+
});
|
|
741
|
+
if (root.missingGit) return {
|
|
742
|
+
available: false,
|
|
743
|
+
missingGit: true,
|
|
744
|
+
repoRoot: null,
|
|
745
|
+
repoRootAbsolute: null,
|
|
746
|
+
branch: null,
|
|
747
|
+
head: null,
|
|
748
|
+
hasHead: false,
|
|
749
|
+
message: "git is not available on PATH."
|
|
750
|
+
};
|
|
751
|
+
if (root.exitCode !== 0) return {
|
|
752
|
+
available: false,
|
|
753
|
+
missingGit: false,
|
|
754
|
+
repoRoot: null,
|
|
755
|
+
repoRootAbsolute: null,
|
|
756
|
+
branch: null,
|
|
757
|
+
head: null,
|
|
758
|
+
hasHead: false,
|
|
759
|
+
message: "This workspace is not inside a Git repository."
|
|
760
|
+
};
|
|
761
|
+
const repoRootAbsolute = root.stdout.trim();
|
|
762
|
+
const repoRoot = relative(workspace, repoRootAbsolute) || ".";
|
|
763
|
+
const hasHead = (await runGit({
|
|
764
|
+
cwd: workspace,
|
|
765
|
+
pathEnv,
|
|
766
|
+
args: [
|
|
767
|
+
"rev-parse",
|
|
768
|
+
"--verify",
|
|
769
|
+
"HEAD"
|
|
770
|
+
],
|
|
771
|
+
maxOutputBytes: 8e3
|
|
772
|
+
})).exitCode === 0;
|
|
773
|
+
const branchResult = await runGit({
|
|
774
|
+
cwd: workspace,
|
|
775
|
+
pathEnv,
|
|
776
|
+
args: [
|
|
777
|
+
"symbolic-ref",
|
|
778
|
+
"--quiet",
|
|
779
|
+
"--short",
|
|
780
|
+
"HEAD"
|
|
781
|
+
],
|
|
782
|
+
maxOutputBytes: 8e3
|
|
783
|
+
});
|
|
784
|
+
const headResult = hasHead ? await runGit({
|
|
785
|
+
cwd: workspace,
|
|
786
|
+
pathEnv,
|
|
787
|
+
args: [
|
|
788
|
+
"rev-parse",
|
|
789
|
+
"--short",
|
|
790
|
+
"HEAD"
|
|
791
|
+
],
|
|
792
|
+
maxOutputBytes: 8e3
|
|
793
|
+
}) : void 0;
|
|
794
|
+
return {
|
|
795
|
+
available: true,
|
|
796
|
+
missingGit: false,
|
|
797
|
+
repoRoot,
|
|
798
|
+
repoRootAbsolute,
|
|
799
|
+
branch: branchResult.exitCode === 0 ? branchResult.stdout.trim() : null,
|
|
800
|
+
head: headResult?.exitCode === 0 ? headResult.stdout.trim() : null,
|
|
801
|
+
hasHead
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function parsePorcelainStatus(output) {
|
|
805
|
+
return output.split("\0").filter(Boolean).map((entry) => {
|
|
806
|
+
const indexStatus = entry[0] ?? " ";
|
|
807
|
+
const worktreeStatus = entry[1] ?? " ";
|
|
808
|
+
const path = entry.slice(3);
|
|
809
|
+
const untracked = indexStatus === "?" && worktreeStatus === "?";
|
|
810
|
+
const staged = !untracked && indexStatus !== " ";
|
|
811
|
+
const unstaged = !untracked && worktreeStatus !== " ";
|
|
812
|
+
return {
|
|
813
|
+
path,
|
|
814
|
+
indexStatus,
|
|
815
|
+
worktreeStatus,
|
|
816
|
+
status: classifyStatus(indexStatus, worktreeStatus),
|
|
817
|
+
staged,
|
|
818
|
+
unstaged,
|
|
819
|
+
untracked
|
|
820
|
+
};
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
function parseGitLog(output) {
|
|
824
|
+
return output.split("\n").filter(Boolean).map((line) => {
|
|
825
|
+
const [sha = "", shortSha = "", timestamp = "0", authorName = "", subject = ""] = line.split(GIT_FIELD_SEPARATOR);
|
|
826
|
+
return {
|
|
827
|
+
sha,
|
|
828
|
+
shortSha,
|
|
829
|
+
timestamp: Number(timestamp),
|
|
830
|
+
authorName,
|
|
831
|
+
subject
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
function truncateText$1(content, maxBytes) {
|
|
836
|
+
if (Buffer.byteLength(content) <= maxBytes) return {
|
|
837
|
+
content,
|
|
838
|
+
truncated: false
|
|
839
|
+
};
|
|
840
|
+
return {
|
|
841
|
+
content: Buffer.from(content, "utf8").subarray(0, maxBytes).toString("utf8"),
|
|
842
|
+
truncated: true
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function gitLogPrettyFormat() {
|
|
846
|
+
return `%H%x1f%h%x1f%ct%x1f%an%x1f%s`;
|
|
847
|
+
}
|
|
848
|
+
function classifyStatus(indexStatus, worktreeStatus) {
|
|
849
|
+
if (indexStatus === "?" && worktreeStatus === "?") return "untracked";
|
|
850
|
+
if (indexStatus === "U" || worktreeStatus === "U" || indexStatus === "A" && worktreeStatus === "A") return "conflicted";
|
|
851
|
+
if (indexStatus === "R") return "renamed";
|
|
852
|
+
if (indexStatus === "C") return "copied";
|
|
853
|
+
if (indexStatus === "D" || worktreeStatus === "D") return "deleted";
|
|
854
|
+
if (indexStatus === "A") return "added";
|
|
855
|
+
if (indexStatus === "M" || worktreeStatus === "M") return "modified";
|
|
856
|
+
return "unknown";
|
|
857
|
+
}
|
|
858
|
+
function appendBoundedChunk(chunks, currentBytes, chunk, maxBytes) {
|
|
859
|
+
if (currentBytes >= maxBytes) return {
|
|
860
|
+
bytes: currentBytes,
|
|
861
|
+
truncated: true
|
|
862
|
+
};
|
|
863
|
+
const remaining = maxBytes - currentBytes;
|
|
864
|
+
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
865
|
+
chunks.push(slice);
|
|
866
|
+
return {
|
|
867
|
+
bytes: currentBytes + slice.length,
|
|
868
|
+
truncated: chunk.length > remaining
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function containsNul(buffer) {
|
|
872
|
+
return buffer.includes(0);
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/agent/tools/git.ts
|
|
876
|
+
const gitStatusValueSchema = z.enum([
|
|
877
|
+
"added",
|
|
878
|
+
"modified",
|
|
879
|
+
"deleted",
|
|
880
|
+
"renamed",
|
|
881
|
+
"copied",
|
|
882
|
+
"untracked",
|
|
883
|
+
"conflicted",
|
|
884
|
+
"unknown"
|
|
885
|
+
]);
|
|
886
|
+
const gitStatusArgsSchema = z.object({
|
|
887
|
+
path: z.string().optional().default("."),
|
|
888
|
+
include_untracked: z.boolean().optional().default(true)
|
|
889
|
+
});
|
|
890
|
+
const gitDiffArgsSchema = z.object({
|
|
891
|
+
scope: z.enum([
|
|
892
|
+
"all",
|
|
893
|
+
"unstaged",
|
|
894
|
+
"staged"
|
|
895
|
+
]).optional().default("all"),
|
|
896
|
+
path: z.string().optional(),
|
|
897
|
+
include_untracked: z.boolean().optional().default(false),
|
|
898
|
+
context_lines: z.number().int().min(0).max(20).optional().default(3),
|
|
899
|
+
max_bytes: z.number().int().min(1e3).max(2e5).optional().default(4e4)
|
|
900
|
+
});
|
|
901
|
+
const gitLogArgsSchema = z.object({
|
|
902
|
+
limit: z.number().int().min(1).max(50).optional().default(10),
|
|
903
|
+
path: z.string().optional()
|
|
904
|
+
});
|
|
905
|
+
const gitAddArgsSchema = z.object({
|
|
906
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
907
|
+
expected_status: z.array(z.object({
|
|
908
|
+
path: z.string().min(1),
|
|
909
|
+
status: gitStatusValueSchema
|
|
910
|
+
})).min(1)
|
|
911
|
+
});
|
|
912
|
+
const gitCommitArgsSchema = z.object({
|
|
913
|
+
message: z.string().trim().min(1).max(500),
|
|
914
|
+
expected_staged_paths: z.array(z.string().min(1)).min(1)
|
|
915
|
+
});
|
|
916
|
+
const gitStatusTool = defineTool({
|
|
917
|
+
name: "git_status",
|
|
918
|
+
description: "Inspect structured Git branch and changed-file status inside the workspace.",
|
|
919
|
+
prompt: "git_status: inspect branch, head, clean state, staged, unstaged, and untracked files without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_status\",\"args\":{\"path\":\".\",\"include_untracked\":true}}",
|
|
920
|
+
argsSchema: gitStatusArgsSchema,
|
|
921
|
+
execute: (context, args) => inspectGitStatus(context, args)
|
|
922
|
+
});
|
|
923
|
+
const gitDiffTool = defineTool({
|
|
924
|
+
name: "git_diff",
|
|
925
|
+
description: "Inspect bounded Git diffs for staged, unstaged, and optionally untracked files.",
|
|
926
|
+
prompt: "git_diff: inspect a bounded Git diff; use scope \"all\", \"unstaged\", or \"staged\", and include_untracked:true only when untracked file patches are needed. To use it, reply with only JSON: {\"tool\":\"git_diff\",\"args\":{\"scope\":\"all\",\"include_untracked\":true}}",
|
|
927
|
+
argsSchema: gitDiffArgsSchema,
|
|
928
|
+
execute: (context, args) => inspectGitDiff(context, args)
|
|
929
|
+
});
|
|
930
|
+
const gitLogTool = defineTool({
|
|
931
|
+
name: "git_log",
|
|
932
|
+
description: "Inspect recent Git commits as bounded structured summaries.",
|
|
933
|
+
prompt: "git_log: inspect recent commits without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_log\",\"args\":{\"limit\":10,\"path\":\"src/agent/runtime.ts\"}}",
|
|
934
|
+
argsSchema: gitLogArgsSchema,
|
|
935
|
+
execute: (context, args) => inspectGitLog(context, args)
|
|
936
|
+
});
|
|
937
|
+
const gitAddTool = defineTool({
|
|
938
|
+
name: "git_add",
|
|
939
|
+
description: "Stage explicit changed paths after current Git status has been inspected.",
|
|
940
|
+
prompt: "git_add: stage only explicit paths the user asked to stage; first inspect git_status, reject broad paths, and pass expected_status for each path. To use it, reply with only JSON: {\"tool\":\"git_add\",\"args\":{\"paths\":[\"src/example.ts\"],\"expected_status\":[{\"path\":\"src/example.ts\",\"status\":\"modified\"}]}}",
|
|
941
|
+
argsSchema: gitAddArgsSchema,
|
|
942
|
+
execute: (context, args) => stageGitPaths(context, args)
|
|
943
|
+
});
|
|
944
|
+
const gitCommitTool = defineTool({
|
|
945
|
+
name: "git_commit",
|
|
946
|
+
description: "Create a Git commit from exactly the expected staged paths.",
|
|
947
|
+
prompt: "git_commit: commit only after the user explicitly asks and staged paths exactly match expected_staged_paths. To use it, reply with only JSON: {\"tool\":\"git_commit\",\"args\":{\"message\":\"Add feature\",\"expected_staged_paths\":[\"src/example.ts\"]}}",
|
|
948
|
+
argsSchema: gitCommitArgsSchema,
|
|
949
|
+
execute: (context, args) => commitGitPaths(context, args)
|
|
950
|
+
});
|
|
951
|
+
async function inspectGitStatus(context, args) {
|
|
952
|
+
const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
|
|
953
|
+
const unavailable = formatUnavailable("git_status", repo);
|
|
954
|
+
if (unavailable) return {
|
|
955
|
+
...unavailable,
|
|
956
|
+
branch: null,
|
|
957
|
+
head: null,
|
|
958
|
+
hasHead: false,
|
|
959
|
+
clean: true,
|
|
960
|
+
files: [],
|
|
961
|
+
truncated: false
|
|
962
|
+
};
|
|
963
|
+
const scopedPath = ensureInsideWorkspace(context.workspaceRoot, args.path);
|
|
964
|
+
const result = await runGit({
|
|
965
|
+
cwd: requireRepoRoot(repo),
|
|
966
|
+
pathEnv: context.pathEnv,
|
|
967
|
+
allowNulOutput: true,
|
|
968
|
+
args: [
|
|
969
|
+
"status",
|
|
970
|
+
"--porcelain=v1",
|
|
971
|
+
"-z",
|
|
972
|
+
"--no-renames",
|
|
973
|
+
args.include_untracked ? "--untracked-files=all" : "--untracked-files=no",
|
|
974
|
+
"--",
|
|
975
|
+
scopedPath.relativePath
|
|
976
|
+
]
|
|
977
|
+
});
|
|
978
|
+
const files = parsePorcelainStatus(result.stdout);
|
|
979
|
+
return {
|
|
980
|
+
tool: "git_status",
|
|
981
|
+
path: scopedPath.relativePath,
|
|
982
|
+
content: formatGitStatusContent(repo, files),
|
|
983
|
+
repoRoot: repo.repoRoot,
|
|
984
|
+
branch: repo.branch,
|
|
985
|
+
head: repo.head,
|
|
986
|
+
hasHead: repo.hasHead,
|
|
987
|
+
clean: files.length === 0,
|
|
988
|
+
files,
|
|
989
|
+
truncated: result.truncated,
|
|
990
|
+
warning: result.truncated ? "git_status output was truncated." : void 0
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
async function inspectGitDiff(context, args) {
|
|
994
|
+
const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
|
|
995
|
+
const unavailable = formatUnavailable("git_diff", repo);
|
|
996
|
+
if (unavailable) return {
|
|
997
|
+
...unavailable,
|
|
998
|
+
scope: args.scope,
|
|
999
|
+
path: args.path,
|
|
1000
|
+
fileCount: 0,
|
|
1001
|
+
truncated: false
|
|
1002
|
+
};
|
|
1003
|
+
const repoRoot = requireRepoRoot(repo);
|
|
1004
|
+
const path = args.path ? ensureInsideWorkspace(context.workspaceRoot, args.path).relativePath : void 0;
|
|
1005
|
+
const sections = [];
|
|
1006
|
+
let truncated = false;
|
|
1007
|
+
const changedFiles = /* @__PURE__ */ new Set();
|
|
1008
|
+
if (args.scope === "all" || args.scope === "staged") {
|
|
1009
|
+
const diff = await runDiff(repoRoot, [
|
|
1010
|
+
"diff",
|
|
1011
|
+
"--cached",
|
|
1012
|
+
"--no-ext-diff",
|
|
1013
|
+
"--no-textconv",
|
|
1014
|
+
"--no-renames"
|
|
1015
|
+
], {
|
|
1016
|
+
context,
|
|
1017
|
+
path,
|
|
1018
|
+
contextLines: args.context_lines,
|
|
1019
|
+
maxBytes: args.max_bytes
|
|
1020
|
+
});
|
|
1021
|
+
appendSection(sections, "staged", diff.content);
|
|
1022
|
+
truncated = truncated || diff.truncated;
|
|
1023
|
+
for (const file of await diffNameOnly(repoRoot, true, context, path)) changedFiles.add(file);
|
|
1024
|
+
}
|
|
1025
|
+
if (args.scope === "all" || args.scope === "unstaged") {
|
|
1026
|
+
const diff = await runDiff(repoRoot, [
|
|
1027
|
+
"diff",
|
|
1028
|
+
"--no-ext-diff",
|
|
1029
|
+
"--no-textconv",
|
|
1030
|
+
"--no-renames"
|
|
1031
|
+
], {
|
|
1032
|
+
context,
|
|
1033
|
+
path,
|
|
1034
|
+
contextLines: args.context_lines,
|
|
1035
|
+
maxBytes: args.max_bytes
|
|
1036
|
+
});
|
|
1037
|
+
appendSection(sections, "unstaged", diff.content);
|
|
1038
|
+
truncated = truncated || diff.truncated;
|
|
1039
|
+
for (const file of await diffNameOnly(repoRoot, false, context, path)) changedFiles.add(file);
|
|
1040
|
+
}
|
|
1041
|
+
if (args.include_untracked && args.scope !== "staged") {
|
|
1042
|
+
const untracked = await getUntrackedFiles(repoRoot, context, path);
|
|
1043
|
+
for (const file of untracked) {
|
|
1044
|
+
const diff = await runDiff(repoRoot, [
|
|
1045
|
+
"diff",
|
|
1046
|
+
"--no-index",
|
|
1047
|
+
"--no-ext-diff",
|
|
1048
|
+
"--no-textconv",
|
|
1049
|
+
"--no-renames"
|
|
1050
|
+
], {
|
|
1051
|
+
context,
|
|
1052
|
+
path: void 0,
|
|
1053
|
+
extraPathspecs: ["/dev/null", file],
|
|
1054
|
+
contextLines: args.context_lines,
|
|
1055
|
+
maxBytes: args.max_bytes
|
|
1056
|
+
});
|
|
1057
|
+
appendSection(sections, `untracked ${file}`, diff.content);
|
|
1058
|
+
truncated = truncated || diff.truncated;
|
|
1059
|
+
changedFiles.add(file);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const bounded = truncateText$1(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
|
|
1063
|
+
return {
|
|
1064
|
+
tool: "git_diff",
|
|
1065
|
+
path: path ?? void 0,
|
|
1066
|
+
content: bounded.content,
|
|
1067
|
+
repoRoot: repo.repoRoot,
|
|
1068
|
+
scope: args.scope,
|
|
1069
|
+
fileCount: changedFiles.size,
|
|
1070
|
+
truncated: truncated || bounded.truncated,
|
|
1071
|
+
warning: truncated || bounded.truncated ? "git_diff output was truncated." : void 0
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
async function inspectGitLog(context, args) {
|
|
1075
|
+
const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
|
|
1076
|
+
const unavailable = formatUnavailable("git_log", repo);
|
|
1077
|
+
if (unavailable) return {
|
|
1078
|
+
...unavailable,
|
|
1079
|
+
commits: [],
|
|
1080
|
+
truncated: false
|
|
1081
|
+
};
|
|
1082
|
+
if (!repo.hasHead) return {
|
|
1083
|
+
tool: "git_log",
|
|
1084
|
+
path: args.path,
|
|
1085
|
+
content: "This Git repository has no commits yet.",
|
|
1086
|
+
repoRoot: repo.repoRoot,
|
|
1087
|
+
commits: [],
|
|
1088
|
+
truncated: false,
|
|
1089
|
+
warning: "Git repository has no commits."
|
|
1090
|
+
};
|
|
1091
|
+
const repoRoot = requireRepoRoot(repo);
|
|
1092
|
+
const path = args.path ? ensureInsideWorkspace(context.workspaceRoot, args.path).relativePath : void 0;
|
|
1093
|
+
const result = await runGit({
|
|
1094
|
+
cwd: repoRoot,
|
|
1095
|
+
pathEnv: context.pathEnv,
|
|
1096
|
+
args: [
|
|
1097
|
+
"log",
|
|
1098
|
+
"-n",
|
|
1099
|
+
String(args.limit),
|
|
1100
|
+
`--pretty=format:${gitLogPrettyFormat()}`,
|
|
1101
|
+
"--",
|
|
1102
|
+
...path ? [path] : []
|
|
1103
|
+
]
|
|
1104
|
+
});
|
|
1105
|
+
const commits = parseGitLog(result.stdout);
|
|
1106
|
+
return {
|
|
1107
|
+
tool: "git_log",
|
|
1108
|
+
path,
|
|
1109
|
+
content: commits.length === 0 ? "No commits matched." : commits.map(formatCommitSummary).join("\n"),
|
|
1110
|
+
repoRoot: repo.repoRoot,
|
|
1111
|
+
commits,
|
|
1112
|
+
truncated: result.truncated,
|
|
1113
|
+
warning: result.truncated ? "git_log output was truncated." : void 0
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
async function stageGitPaths(context, args) {
|
|
1117
|
+
const repo = await requireAvailableRepo(context);
|
|
1118
|
+
const repoRoot = requireRepoRoot(repo);
|
|
1119
|
+
const paths = normalizeExplicitPaths(context.workspaceRoot, args.paths);
|
|
1120
|
+
const expected = new Map(args.expected_status.map((entry) => [entry.path, entry.status]));
|
|
1121
|
+
const status = await currentStatus(repoRoot, context, true);
|
|
1122
|
+
const statusByPath = new Map(status.map((file) => [file.path, file]));
|
|
1123
|
+
for (const path of paths) {
|
|
1124
|
+
const file = statusByPath.get(path);
|
|
1125
|
+
const expectedStatus = expected.get(path);
|
|
1126
|
+
if (!expectedStatus) throw new Error(`git_add expected_status is required for ${path}.`);
|
|
1127
|
+
if (!file) throw new Error(`git_add can only stage paths present in git_status: ${path}`);
|
|
1128
|
+
if (file.status !== expectedStatus) throw new Error(`git_add expected ${path} to be ${expectedStatus}, but it is ${file.status}.`);
|
|
1129
|
+
}
|
|
1130
|
+
const result = await runGit({
|
|
1131
|
+
cwd: repoRoot,
|
|
1132
|
+
pathEnv: context.pathEnv,
|
|
1133
|
+
args: [
|
|
1134
|
+
"add",
|
|
1135
|
+
"--",
|
|
1136
|
+
...paths
|
|
1137
|
+
]
|
|
1138
|
+
});
|
|
1139
|
+
if (result.exitCode !== 0) throw new Error(`git_add failed: ${result.stderr || result.stdout}`.trim());
|
|
1140
|
+
const files = await currentStatus(repoRoot, context, true);
|
|
1141
|
+
const stagedPaths = files.filter((file) => file.staged).map((file) => file.path);
|
|
1142
|
+
return {
|
|
1143
|
+
tool: "git_add",
|
|
1144
|
+
content: [
|
|
1145
|
+
`staged_paths: ${paths.join(", ")}`,
|
|
1146
|
+
"",
|
|
1147
|
+
formatGitStatusFileList(files)
|
|
1148
|
+
].join("\n").trimEnd(),
|
|
1149
|
+
repoRoot: repo.repoRoot,
|
|
1150
|
+
stagedPaths: paths,
|
|
1151
|
+
files,
|
|
1152
|
+
warning: paths.every((path) => stagedPaths.includes(path)) ? void 0 : "Some requested paths were not staged."
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
async function commitGitPaths(context, args) {
|
|
1156
|
+
const repo = await requireAvailableRepo(context);
|
|
1157
|
+
const repoRoot = requireRepoRoot(repo);
|
|
1158
|
+
const expectedPaths = normalizeExplicitPaths(context.workspaceRoot, args.expected_staged_paths);
|
|
1159
|
+
const stagedFiles = (await currentStatus(repoRoot, context, true)).filter((file) => file.staged);
|
|
1160
|
+
const stagedPaths = stagedFiles.map((file) => file.path).sort();
|
|
1161
|
+
if (stagedPaths.length === 0) throw new Error("git_commit requires staged changes.");
|
|
1162
|
+
if (!sameStringSet(stagedPaths, [...expectedPaths].sort())) throw new Error(`git_commit staged paths did not match expected_staged_paths. staged=${stagedPaths.join(", ")} expected=${expectedPaths.join(", ")}`);
|
|
1163
|
+
const stagedWithUnstagedChanges = stagedFiles.filter((file) => file.unstaged).map((file) => file.path);
|
|
1164
|
+
if (stagedWithUnstagedChanges.length > 0) throw new Error(`git_commit refuses paths with both staged and unstaged changes: ${stagedWithUnstagedChanges.join(", ")}`);
|
|
1165
|
+
const stat = await runGit({
|
|
1166
|
+
cwd: repoRoot,
|
|
1167
|
+
pathEnv: context.pathEnv,
|
|
1168
|
+
args: [
|
|
1169
|
+
"diff",
|
|
1170
|
+
"--cached",
|
|
1171
|
+
"--stat",
|
|
1172
|
+
"--"
|
|
1173
|
+
]
|
|
1174
|
+
});
|
|
1175
|
+
const nameStatus = await runGit({
|
|
1176
|
+
cwd: repoRoot,
|
|
1177
|
+
pathEnv: context.pathEnv,
|
|
1178
|
+
args: [
|
|
1179
|
+
"diff",
|
|
1180
|
+
"--cached",
|
|
1181
|
+
"--name-status",
|
|
1182
|
+
"--"
|
|
1183
|
+
]
|
|
1184
|
+
});
|
|
1185
|
+
const commit = await runGit({
|
|
1186
|
+
cwd: repoRoot,
|
|
1187
|
+
pathEnv: context.pathEnv,
|
|
1188
|
+
args: [
|
|
1189
|
+
"commit",
|
|
1190
|
+
"--no-gpg-sign",
|
|
1191
|
+
"-m",
|
|
1192
|
+
args.message
|
|
1193
|
+
],
|
|
1194
|
+
timeoutMs: 3e4
|
|
1195
|
+
});
|
|
1196
|
+
if (commit.exitCode !== 0) throw new Error(`git_commit failed: ${commit.stderr || commit.stdout}`.trim());
|
|
1197
|
+
const commitSummary = parseGitLog((await runGit({
|
|
1198
|
+
cwd: repoRoot,
|
|
1199
|
+
pathEnv: context.pathEnv,
|
|
1200
|
+
args: [
|
|
1201
|
+
"log",
|
|
1202
|
+
"-n",
|
|
1203
|
+
"1",
|
|
1204
|
+
`--pretty=format:${gitLogPrettyFormat()}`
|
|
1205
|
+
]
|
|
1206
|
+
})).stdout)[0];
|
|
1207
|
+
if (!commitSummary) throw new Error("git_commit succeeded but the new commit could not be read.");
|
|
1208
|
+
const remainingFiles = await currentStatus(repoRoot, context, true);
|
|
1209
|
+
return {
|
|
1210
|
+
tool: "git_commit",
|
|
1211
|
+
content: [
|
|
1212
|
+
`commit: ${commitSummary.shortSha} ${commitSummary.subject}`,
|
|
1213
|
+
`staged_paths: ${stagedPaths.join(", ")}`,
|
|
1214
|
+
"",
|
|
1215
|
+
"stat:",
|
|
1216
|
+
stat.stdout.trim() || "(none)",
|
|
1217
|
+
"",
|
|
1218
|
+
"remaining:",
|
|
1219
|
+
formatGitStatusFileList(remainingFiles) || "clean"
|
|
1220
|
+
].join("\n"),
|
|
1221
|
+
repoRoot: repo.repoRoot,
|
|
1222
|
+
commit: commitSummary,
|
|
1223
|
+
stagedPaths,
|
|
1224
|
+
remainingFiles,
|
|
1225
|
+
stat: stat.stdout,
|
|
1226
|
+
nameStatus: nameStatus.stdout
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
async function requireAvailableRepo(context) {
|
|
1230
|
+
const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
|
|
1231
|
+
if (!repo.available) throw new Error(repo.message ?? "Git repository is unavailable.");
|
|
1232
|
+
return repo;
|
|
1233
|
+
}
|
|
1234
|
+
async function currentStatus(repoRoot, context, includeUntracked) {
|
|
1235
|
+
return parsePorcelainStatus((await runGit({
|
|
1236
|
+
cwd: repoRoot,
|
|
1237
|
+
pathEnv: context.pathEnv,
|
|
1238
|
+
allowNulOutput: true,
|
|
1239
|
+
args: [
|
|
1240
|
+
"status",
|
|
1241
|
+
"--porcelain=v1",
|
|
1242
|
+
"-z",
|
|
1243
|
+
"--no-renames",
|
|
1244
|
+
includeUntracked ? "--untracked-files=all" : "--untracked-files=no",
|
|
1245
|
+
"--"
|
|
1246
|
+
]
|
|
1247
|
+
})).stdout);
|
|
1248
|
+
}
|
|
1249
|
+
async function runDiff(repoRoot, baseArgs, options) {
|
|
1250
|
+
const result = await runGit({
|
|
1251
|
+
cwd: repoRoot,
|
|
1252
|
+
pathEnv: options.context.pathEnv,
|
|
1253
|
+
args: [
|
|
1254
|
+
...baseArgs,
|
|
1255
|
+
`--unified=${options.contextLines}`,
|
|
1256
|
+
"--",
|
|
1257
|
+
...options.extraPathspecs ?? (options.path ? [options.path] : [])
|
|
1258
|
+
],
|
|
1259
|
+
maxOutputBytes: options.maxBytes,
|
|
1260
|
+
allowExitCodeOne: true
|
|
1261
|
+
});
|
|
1262
|
+
if (result.binary) return {
|
|
1263
|
+
content: "Binary diff omitted.",
|
|
1264
|
+
truncated: result.truncated
|
|
1265
|
+
};
|
|
1266
|
+
return {
|
|
1267
|
+
content: result.stdout,
|
|
1268
|
+
truncated: result.truncated
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
async function diffNameOnly(repoRoot, staged, context, path) {
|
|
1272
|
+
return (await runGit({
|
|
1273
|
+
cwd: repoRoot,
|
|
1274
|
+
pathEnv: context.pathEnv,
|
|
1275
|
+
args: [
|
|
1276
|
+
"diff",
|
|
1277
|
+
staged ? "--cached" : "--no-ext-diff",
|
|
1278
|
+
"--name-only",
|
|
1279
|
+
"--",
|
|
1280
|
+
...path ? [path] : []
|
|
1281
|
+
]
|
|
1282
|
+
})).stdout.split("\n").filter(Boolean);
|
|
1283
|
+
}
|
|
1284
|
+
async function getUntrackedFiles(repoRoot, context, path) {
|
|
1285
|
+
return (await runGit({
|
|
1286
|
+
cwd: repoRoot,
|
|
1287
|
+
pathEnv: context.pathEnv,
|
|
1288
|
+
args: [
|
|
1289
|
+
"ls-files",
|
|
1290
|
+
"--others",
|
|
1291
|
+
"--exclude-standard",
|
|
1292
|
+
"--",
|
|
1293
|
+
...path ? [path] : []
|
|
1294
|
+
]
|
|
1295
|
+
})).stdout.split("\n").filter(Boolean);
|
|
1296
|
+
}
|
|
1297
|
+
function appendSection(sections, label, content) {
|
|
1298
|
+
if (content.trim().length === 0) return;
|
|
1299
|
+
sections.push(`## ${label}\n${content.trimEnd()}\n`);
|
|
1300
|
+
}
|
|
1301
|
+
function formatUnavailable(tool, repo) {
|
|
1302
|
+
if (repo.available) return;
|
|
1303
|
+
return {
|
|
1304
|
+
tool,
|
|
1305
|
+
repoRoot: null,
|
|
1306
|
+
content: repo.message ?? "Git repository is unavailable.",
|
|
1307
|
+
warning: repo.message
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
function formatGitStatusContent(repo, files) {
|
|
1311
|
+
return [
|
|
1312
|
+
`branch: ${repo.branch ?? "(detached)"}`,
|
|
1313
|
+
`head: ${repo.head ?? "(none)"}`,
|
|
1314
|
+
`clean: ${files.length === 0}`,
|
|
1315
|
+
"",
|
|
1316
|
+
formatGitStatusFileList(files) || "No changed files."
|
|
1317
|
+
].join("\n");
|
|
1318
|
+
}
|
|
1319
|
+
function formatGitStatusFileList(files) {
|
|
1320
|
+
return files.map((file) => `${file.indexStatus}${file.worktreeStatus} ${file.path}`).sort((left, right) => left.localeCompare(right)).join("\n");
|
|
1321
|
+
}
|
|
1322
|
+
function formatCommitSummary(commit) {
|
|
1323
|
+
return `${commit.shortSha} ${commit.subject} (${commit.authorName}, ${(/* @__PURE__ */ new Date(commit.timestamp * 1e3)).toISOString()})`;
|
|
1324
|
+
}
|
|
1325
|
+
function requireRepoRoot(repo) {
|
|
1326
|
+
if (!repo.repoRootAbsolute) throw new Error("Git repository root is unavailable.");
|
|
1327
|
+
return repo.repoRootAbsolute;
|
|
1328
|
+
}
|
|
1329
|
+
function normalizeExplicitPaths(workspaceRoot, paths) {
|
|
1330
|
+
const normalized = paths.map((path) => {
|
|
1331
|
+
if (path === "." || path.includes("*") || path.includes("?") || path.includes("[") || path.includes("]")) throw new Error(`Git mutation tools require explicit file paths, not broad pathspecs: ${path}`);
|
|
1332
|
+
const scoped = ensureInsideWorkspace(workspaceRoot, path);
|
|
1333
|
+
if (scoped.relativePath === ".") throw new Error("Git mutation tools require explicit file paths.");
|
|
1334
|
+
return scoped.relativePath;
|
|
1335
|
+
});
|
|
1336
|
+
return [...new Set(normalized)];
|
|
1337
|
+
}
|
|
1338
|
+
function sameStringSet(left, right) {
|
|
1339
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1340
|
+
}
|
|
621
1341
|
const grepTool = defineTool({
|
|
622
1342
|
name: "grep",
|
|
623
1343
|
description: "Search text inside the workspace.",
|
|
@@ -632,7 +1352,7 @@ const grepTool = defineTool({
|
|
|
632
1352
|
})
|
|
633
1353
|
});
|
|
634
1354
|
async function grepWorkspace(workspaceRoot, args, options = {}) {
|
|
635
|
-
const scopedPath = resolveWorkspaceScopedPath$
|
|
1355
|
+
const scopedPath = resolveWorkspaceScopedPath$2(workspaceRoot, args.path ?? ".");
|
|
636
1356
|
const executable = await findSearchExecutable(options.pathEnv);
|
|
637
1357
|
if (!executable) {
|
|
638
1358
|
const warning = "grep could not run because neither rg nor grep is available on PATH.";
|
|
@@ -696,7 +1416,7 @@ async function grepWorkspace(workspaceRoot, args, options = {}) {
|
|
|
696
1416
|
content: truncateToolOutput(result.stdout.trimEnd() || "No matches.")
|
|
697
1417
|
};
|
|
698
1418
|
}
|
|
699
|
-
function resolveWorkspaceScopedPath$
|
|
1419
|
+
function resolveWorkspaceScopedPath$2(workspaceRoot, path) {
|
|
700
1420
|
const resolvedWorkspace = resolve(workspaceRoot);
|
|
701
1421
|
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
702
1422
|
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
@@ -927,6 +1647,8 @@ const READ_ONLY_COMMANDS = new Set([
|
|
|
927
1647
|
"find",
|
|
928
1648
|
"fd",
|
|
929
1649
|
"cat",
|
|
1650
|
+
"sed",
|
|
1651
|
+
"sort",
|
|
930
1652
|
"head",
|
|
931
1653
|
"tail",
|
|
932
1654
|
"wc",
|
|
@@ -1134,6 +1856,7 @@ function validateSimpleCommand(command, context) {
|
|
|
1134
1856
|
case "git": return validateGitCommand(command, context);
|
|
1135
1857
|
case "find": return validateFindCommand(command, context);
|
|
1136
1858
|
case "fd": return validateGenericCommandArgs(command, context, FD_OPTIONS_WITH_VALUES);
|
|
1859
|
+
case "sed": return validateSedCommand(command, context);
|
|
1137
1860
|
default: return validateGenericCommandArgs(command, context, COMMON_OPTIONS_WITH_VALUES);
|
|
1138
1861
|
}
|
|
1139
1862
|
}
|
|
@@ -1156,6 +1879,88 @@ function validateGitCommand(command, context) {
|
|
|
1156
1879
|
function validateFindCommand(command, context) {
|
|
1157
1880
|
return validateGenericCommandArgs(command, context, FIND_OPTIONS_WITH_VALUES);
|
|
1158
1881
|
}
|
|
1882
|
+
function validateSedCommand(command, context) {
|
|
1883
|
+
let sawScript = false;
|
|
1884
|
+
for (let index = 0; index < command.args.length; index += 1) {
|
|
1885
|
+
const arg = command.args[index] ?? "";
|
|
1886
|
+
if (arg === "-i" || arg.startsWith("-i") || arg === "--in-place" || arg.startsWith("--in-place=")) return {
|
|
1887
|
+
allowed: false,
|
|
1888
|
+
reason: "inspect_command rejected 'sed' because in-place edits are unsafe."
|
|
1889
|
+
};
|
|
1890
|
+
if (arg === "-e" || arg === "--expression") {
|
|
1891
|
+
const script = command.args[index + 1];
|
|
1892
|
+
if (!script) return {
|
|
1893
|
+
allowed: false,
|
|
1894
|
+
reason: "inspect_command rejected 'sed' because -e requires a script."
|
|
1895
|
+
};
|
|
1896
|
+
const scriptResult = validateSedScript(script);
|
|
1897
|
+
if (!scriptResult.allowed) return scriptResult;
|
|
1898
|
+
sawScript = true;
|
|
1899
|
+
index += 1;
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
if (arg === "-n" || arg === "--quiet" || arg === "--silent" || /^-[Erun]+$/.test(arg)) continue;
|
|
1903
|
+
if (arg.startsWith("-")) return {
|
|
1904
|
+
allowed: false,
|
|
1905
|
+
reason: `inspect_command rejected 'sed' because '${arg}' is not allowed.`
|
|
1906
|
+
};
|
|
1907
|
+
if (!sawScript && looksLikeSedScript(arg)) {
|
|
1908
|
+
const scriptResult = validateSedScript(arg);
|
|
1909
|
+
if (!scriptResult.allowed) return scriptResult;
|
|
1910
|
+
sawScript = true;
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
const scoped = resolveWorkspacePath$1(context.workspaceRoot, arg, context.cwd);
|
|
1914
|
+
if (!scoped.allowed) return {
|
|
1915
|
+
allowed: false,
|
|
1916
|
+
reason: scoped.reason
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
return sawScript ? { allowed: true } : {
|
|
1920
|
+
allowed: false,
|
|
1921
|
+
reason: "inspect_command rejected 'sed' because it requires an inline script."
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
function looksLikeSedScript(arg) {
|
|
1925
|
+
return arg.startsWith("s") || /^(\d+|\$)?(,(\d+|\$))?[pdq]$/.test(arg);
|
|
1926
|
+
}
|
|
1927
|
+
function validateSedScript(script) {
|
|
1928
|
+
if (script.includes("\n")) return {
|
|
1929
|
+
allowed: false,
|
|
1930
|
+
reason: "inspect_command rejected 'sed' because multiline scripts are unsafe."
|
|
1931
|
+
};
|
|
1932
|
+
if (/^(\d+|\$)?(,(\d+|\$))?[pdq]$/.test(script)) return { allowed: true };
|
|
1933
|
+
if (!script.startsWith("s") || script.length < 4) return {
|
|
1934
|
+
allowed: false,
|
|
1935
|
+
reason: "inspect_command rejected 'sed' because only simple read-only scripts are allowed."
|
|
1936
|
+
};
|
|
1937
|
+
const delimiter = script[1] ?? "";
|
|
1938
|
+
if (!delimiter || /[A-Za-z0-9\\\n]/.test(delimiter)) return {
|
|
1939
|
+
allowed: false,
|
|
1940
|
+
reason: "inspect_command rejected 'sed' because the substitution delimiter is invalid."
|
|
1941
|
+
};
|
|
1942
|
+
const delimiters = findUnescapedDelimiterIndexes(script, delimiter);
|
|
1943
|
+
if (delimiters.length < 3) return {
|
|
1944
|
+
allowed: false,
|
|
1945
|
+
reason: "inspect_command rejected 'sed' because the substitution script is incomplete."
|
|
1946
|
+
};
|
|
1947
|
+
const flags = script.slice(delimiters[2] + 1);
|
|
1948
|
+
if (/[^gIpM0-9]/.test(flags) || /[ew]/.test(flags)) return {
|
|
1949
|
+
allowed: false,
|
|
1950
|
+
reason: "inspect_command rejected 'sed' because substitution flags are unsafe."
|
|
1951
|
+
};
|
|
1952
|
+
return { allowed: true };
|
|
1953
|
+
}
|
|
1954
|
+
function findUnescapedDelimiterIndexes(script, delimiter) {
|
|
1955
|
+
const indexes = [];
|
|
1956
|
+
for (let index = 0; index < script.length; index += 1) {
|
|
1957
|
+
if (script[index] !== delimiter) continue;
|
|
1958
|
+
let slashCount = 0;
|
|
1959
|
+
for (let back = index - 1; back >= 0 && script[back] === "\\"; back -= 1) slashCount += 1;
|
|
1960
|
+
if (slashCount % 2 === 0) indexes.push(index);
|
|
1961
|
+
}
|
|
1962
|
+
return indexes;
|
|
1963
|
+
}
|
|
1159
1964
|
function validateGenericCommandArgs(command, context, optionsWithValues, knownPathlessWords = /* @__PURE__ */ new Set()) {
|
|
1160
1965
|
for (let index = 0; index < command.args.length; index += 1) {
|
|
1161
1966
|
const arg = command.args[index] ?? "";
|
|
@@ -1518,7 +2323,7 @@ const listFilesTool = defineTool({
|
|
|
1518
2323
|
execute: (context, args) => listWorkspaceFiles(context.workspaceRoot, args)
|
|
1519
2324
|
});
|
|
1520
2325
|
async function listWorkspaceFiles(workspaceRoot, args) {
|
|
1521
|
-
const scopedPath = resolveWorkspaceScopedPath(workspaceRoot, args.path);
|
|
2326
|
+
const scopedPath = resolveWorkspaceScopedPath$1(workspaceRoot, args.path);
|
|
1522
2327
|
if (!(await stat(scopedPath.path)).isDirectory()) throw new Error(`list_files can only list directories inside the workspace: ${args.path}`);
|
|
1523
2328
|
const entries = args.recursive ? await collectRecursiveEntries(scopedPath.workspaceRoot, scopedPath.path, args.limit) : await collectTopLevelEntries(scopedPath.workspaceRoot, scopedPath.path, args.limit);
|
|
1524
2329
|
const truncated = entries.truncated;
|
|
@@ -1582,7 +2387,7 @@ async function sortedDirectoryEntries(path) {
|
|
|
1582
2387
|
function formatEntryPath(path, isDirectory) {
|
|
1583
2388
|
return isDirectory ? `${path}/` : path;
|
|
1584
2389
|
}
|
|
1585
|
-
function resolveWorkspaceScopedPath(workspaceRoot, path) {
|
|
2390
|
+
function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
|
|
1586
2391
|
const resolvedWorkspace = resolve(workspaceRoot);
|
|
1587
2392
|
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
1588
2393
|
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
@@ -1593,35 +2398,501 @@ function resolveWorkspaceScopedPath(workspaceRoot, path) {
|
|
|
1593
2398
|
relativePath: relativePath || "."
|
|
1594
2399
|
};
|
|
1595
2400
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
2401
|
+
//#endregion
|
|
2402
|
+
//#region src/cli/ui.ts
|
|
2403
|
+
const colors = {
|
|
2404
|
+
bgSoftGray: "\x1B[48;5;236m",
|
|
2405
|
+
blue: "\x1B[34m",
|
|
2406
|
+
cyan: "\x1B[36m",
|
|
2407
|
+
darkGray: "\x1B[90m",
|
|
2408
|
+
dim: "\x1B[2m",
|
|
2409
|
+
green: "\x1B[32m",
|
|
2410
|
+
orange: "\x1B[38;5;208m",
|
|
2411
|
+
purple: "\x1B[35m",
|
|
2412
|
+
red: "\x1B[31m",
|
|
2413
|
+
reset: "\x1B[0m",
|
|
2414
|
+
resetForeground: "\x1B[39m",
|
|
2415
|
+
yellow: "\x1B[33m"
|
|
2416
|
+
};
|
|
2417
|
+
const ui = {
|
|
2418
|
+
heading(text) {
|
|
2419
|
+
return color(`Topchester ${text}`, "cyan");
|
|
2420
|
+
},
|
|
2421
|
+
label(text) {
|
|
2422
|
+
return color(text, "dim");
|
|
2423
|
+
},
|
|
2424
|
+
muted(text) {
|
|
2425
|
+
return color(text, "darkGray");
|
|
2426
|
+
},
|
|
2427
|
+
model(text) {
|
|
2428
|
+
return color(text, "blue");
|
|
2429
|
+
},
|
|
2430
|
+
modelInline(text) {
|
|
2431
|
+
if (!shouldUseColor()) return text;
|
|
2432
|
+
return `${colors.blue}${text}${colors.resetForeground}`;
|
|
2433
|
+
},
|
|
2434
|
+
ok(text) {
|
|
2435
|
+
return color(text, "green");
|
|
2436
|
+
},
|
|
2437
|
+
warn(text) {
|
|
2438
|
+
return color(text, "yellow");
|
|
2439
|
+
},
|
|
2440
|
+
error(text) {
|
|
2441
|
+
return color(text, "red");
|
|
2442
|
+
},
|
|
2443
|
+
softBackground(text) {
|
|
2444
|
+
return color(text, "bgSoftGray");
|
|
2445
|
+
},
|
|
2446
|
+
async spinner(text, action) {
|
|
2447
|
+
return withStatusLine(text, action, void 0, 80, false);
|
|
2448
|
+
},
|
|
2449
|
+
async progress(text, action) {
|
|
2450
|
+
let latest = text;
|
|
2451
|
+
return withStatusLine(text, () => action((message) => {
|
|
2452
|
+
latest = message;
|
|
2453
|
+
}), () => latest, 80, true);
|
|
2454
|
+
}
|
|
2455
|
+
};
|
|
2456
|
+
async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
|
|
2457
|
+
if (!shouldUseColor()) {
|
|
2458
|
+
if (!emitPlainProgress) return action();
|
|
2459
|
+
const timer = setInterval(() => {
|
|
2460
|
+
stderr.write(`${getText()}\n`);
|
|
2461
|
+
}, Math.max(progressEveryMs, 5e3));
|
|
2462
|
+
try {
|
|
2463
|
+
return await action();
|
|
2464
|
+
} finally {
|
|
2465
|
+
clearInterval(timer);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
const frames = [
|
|
2469
|
+
"⠋",
|
|
2470
|
+
"⠙",
|
|
2471
|
+
"⠹",
|
|
2472
|
+
"⠸",
|
|
2473
|
+
"⠼",
|
|
2474
|
+
"⠴",
|
|
2475
|
+
"⠦",
|
|
2476
|
+
"⠧",
|
|
2477
|
+
"⠇",
|
|
2478
|
+
"⠏"
|
|
2479
|
+
];
|
|
2480
|
+
let index = 0;
|
|
2481
|
+
stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
|
|
2482
|
+
const timer = setInterval(() => {
|
|
2483
|
+
index = (index + 1) % frames.length;
|
|
2484
|
+
stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
|
|
2485
|
+
}, progressEveryMs);
|
|
2486
|
+
try {
|
|
2487
|
+
return await action();
|
|
2488
|
+
} finally {
|
|
2489
|
+
clearInterval(timer);
|
|
2490
|
+
stderr.write(`\r\u001b[2K`);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
function color(text, colorName) {
|
|
2494
|
+
if (!shouldUseColor()) return text;
|
|
2495
|
+
return `${colors[colorName]}${text}${colors.reset}`;
|
|
2496
|
+
}
|
|
2497
|
+
function shouldUseColor() {
|
|
2498
|
+
if (process.env.NO_COLOR) return false;
|
|
2499
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
2500
|
+
return stdout.isTTY === true;
|
|
2501
|
+
}
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/agent/task-plan.ts
|
|
2504
|
+
const planTodoStatusSchema = z.enum([
|
|
2505
|
+
"pending",
|
|
2506
|
+
"in_progress",
|
|
2507
|
+
"completed"
|
|
2508
|
+
]);
|
|
2509
|
+
const taskPlanItemSchema = z.object({
|
|
2510
|
+
text: z.string().trim().min(1, "Plan item text cannot be empty."),
|
|
2511
|
+
status: planTodoStatusSchema
|
|
2512
|
+
});
|
|
2513
|
+
const planTodoArgsSchema = z.object({ items: z.array(taskPlanItemSchema).max(20, "Plan updates are limited to 20 items.") }).superRefine((args, context) => {
|
|
2514
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2515
|
+
let inProgressCount = 0;
|
|
2516
|
+
let incompleteCount = 0;
|
|
2517
|
+
args.items.forEach((item, index) => {
|
|
2518
|
+
const key = item.text.toLocaleLowerCase("en");
|
|
2519
|
+
if (seen.has(key)) context.addIssue({
|
|
2520
|
+
code: "custom",
|
|
2521
|
+
message: "Plan item text must be unique.",
|
|
2522
|
+
path: [
|
|
2523
|
+
"items",
|
|
2524
|
+
index,
|
|
2525
|
+
"text"
|
|
2526
|
+
]
|
|
2527
|
+
});
|
|
2528
|
+
seen.add(key);
|
|
2529
|
+
if (item.status === "in_progress") inProgressCount += 1;
|
|
2530
|
+
if (item.status !== "completed") incompleteCount += 1;
|
|
2531
|
+
});
|
|
2532
|
+
if (inProgressCount > 1) context.addIssue({
|
|
2533
|
+
code: "custom",
|
|
2534
|
+
message: "At most one plan item can be in_progress.",
|
|
2535
|
+
path: ["items"]
|
|
2536
|
+
});
|
|
2537
|
+
if (args.items.length > 0 && incompleteCount > 0 && inProgressCount === 0) context.addIssue({
|
|
2538
|
+
code: "custom",
|
|
2539
|
+
message: "A non-completed plan must have exactly one in_progress item.",
|
|
2540
|
+
path: ["items"]
|
|
2541
|
+
});
|
|
2542
|
+
});
|
|
2543
|
+
function createEmptyTaskPlanState(now = /* @__PURE__ */ new Date()) {
|
|
2544
|
+
return {
|
|
2545
|
+
items: [],
|
|
2546
|
+
updatedAt: now.toISOString()
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
function applyTaskPlanUpdate(_previous, args, now = /* @__PURE__ */ new Date()) {
|
|
2550
|
+
return {
|
|
2551
|
+
items: planTodoArgsSchema.parse(args).items.map((item) => ({
|
|
2552
|
+
text: item.text,
|
|
2553
|
+
status: item.status
|
|
2554
|
+
})),
|
|
2555
|
+
updatedAt: now.toISOString()
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
function createTaskPlanController(initialState = createEmptyTaskPlanState(), now = () => /* @__PURE__ */ new Date()) {
|
|
2559
|
+
let state = initialState;
|
|
2560
|
+
return {
|
|
2561
|
+
update(args) {
|
|
2562
|
+
state = applyTaskPlanUpdate(state, args, now());
|
|
2563
|
+
return state;
|
|
2564
|
+
},
|
|
2565
|
+
get() {
|
|
2566
|
+
return state;
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
function summarizeTaskPlan(state) {
|
|
2571
|
+
const pendingCount = state.items.filter((item) => item.status === "pending").length;
|
|
2572
|
+
const inProgressCount = state.items.filter((item) => item.status === "in_progress").length;
|
|
2573
|
+
const completedCount = state.items.filter((item) => item.status === "completed").length;
|
|
2574
|
+
const currentItem = state.items.find((item) => item.status === "in_progress")?.text;
|
|
2575
|
+
return {
|
|
2576
|
+
pendingCount,
|
|
2577
|
+
inProgressCount,
|
|
2578
|
+
completedCount,
|
|
2579
|
+
...currentItem === void 0 ? {} : { currentItem }
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
function hasOpenTaskPlan(state) {
|
|
2583
|
+
return Boolean(state && state.items.some((item) => item.status !== "completed"));
|
|
2584
|
+
}
|
|
2585
|
+
function formatTaskPlanForPrompt(state) {
|
|
2586
|
+
const summary = summarizeTaskPlan(state);
|
|
2587
|
+
return [
|
|
2588
|
+
"Plan updated",
|
|
2589
|
+
`pending: ${summary.pendingCount}`,
|
|
2590
|
+
`in_progress: ${summary.inProgressCount}`,
|
|
2591
|
+
`completed: ${summary.completedCount}`,
|
|
2592
|
+
summary.currentItem ? `current: ${summary.currentItem}` : ""
|
|
2593
|
+
].filter(Boolean).join("\n");
|
|
2594
|
+
}
|
|
2595
|
+
function detectTaskPlanChange(previous, next) {
|
|
2596
|
+
const hadPlan = Boolean(previous && previous.items.length > 0);
|
|
2597
|
+
const hasPlan = Boolean(next && next.items.length > 0);
|
|
2598
|
+
if (!hadPlan && hasPlan) return "created";
|
|
2599
|
+
if (hadPlan && !hasPlan) return "cleared";
|
|
2600
|
+
if (hadPlan && hasPlan) return "updated";
|
|
2601
|
+
return "unchanged";
|
|
2602
|
+
}
|
|
2603
|
+
function formatTaskPlanNotice(change, state) {
|
|
2604
|
+
if (change === "unchanged") return;
|
|
2605
|
+
if (change === "cleared" || state.items.length === 0) return "todo plan cleared";
|
|
2606
|
+
const summary = summarizeTaskPlan(state);
|
|
2607
|
+
if (summary.inProgressCount === 0 && summary.pendingCount === 0) return "todo plan completed";
|
|
2608
|
+
const prefix = change === "created" ? "todo plan created" : "todo plan updated";
|
|
2609
|
+
return summary.currentItem ? `${prefix}: ${summary.currentItem}` : prefix;
|
|
2610
|
+
}
|
|
2611
|
+
function formatTaskPlanForTui(state, width, visibleLimit = 6) {
|
|
2612
|
+
if (state.items.length === 0) return [];
|
|
2613
|
+
const itemWidth = Math.max(1, Math.max(12, width) - 6);
|
|
2614
|
+
const visibleItems = state.items.slice(0, visibleLimit);
|
|
2615
|
+
const lines = visibleItems.map((item) => formatTaskPlanTuiLine(item, truncateText(item.text, itemWidth)));
|
|
2616
|
+
const remaining = state.items.length - visibleItems.length;
|
|
2617
|
+
if (remaining > 0) lines.push(ui.muted(` +${remaining} more`));
|
|
2618
|
+
return lines;
|
|
2619
|
+
}
|
|
2620
|
+
function formatTaskPlanTuiLine(item, text) {
|
|
2621
|
+
switch (item.status) {
|
|
2622
|
+
case "completed": return ` ${ui.ok("[x]")} ${ui.muted(text)}`;
|
|
2623
|
+
case "in_progress": return ` ${ui.ok("[>]")} ${ui.ok(text)}`;
|
|
2624
|
+
case "pending": return ` ${ui.muted("[ ]")} ${text}`;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
function truncateText(text, width) {
|
|
2628
|
+
if (text.length <= width) return text;
|
|
2629
|
+
if (width <= 3) return ".".repeat(Math.max(0, width));
|
|
2630
|
+
return `${text.slice(0, width - 3)}...`;
|
|
2631
|
+
}
|
|
2632
|
+
//#endregion
|
|
2633
|
+
//#region src/agent/tools/plan-todo.ts
|
|
2634
|
+
const planTodoTool = defineTool({
|
|
2635
|
+
name: "plan_todo",
|
|
2636
|
+
description: "Replace the visible session task plan for multi-step work.",
|
|
2637
|
+
prompt: "plan_todo: replace the visible session task plan for non-trivial multi-step work; keep 2-6 short items, exactly one in_progress item while work remains, and use [] only to clear. To use it, reply with only JSON: {\"tool\":\"plan_todo\",\"args\":{\"items\":[{\"text\":\"Inspect relevant files\",\"status\":\"in_progress\"},{\"text\":\"Implement focused change\",\"status\":\"pending\"}]}}",
|
|
2638
|
+
argsSchema: planTodoArgsSchema,
|
|
2639
|
+
async execute(context, args) {
|
|
2640
|
+
if (!context.taskPlan) throw new Error("plan_todo requires runtime task-plan state.");
|
|
2641
|
+
const plan = context.taskPlan.update(args);
|
|
2642
|
+
const summary = summarizeTaskPlan(plan);
|
|
2643
|
+
return {
|
|
2644
|
+
tool: "plan_todo",
|
|
2645
|
+
content: formatTaskPlanForPrompt(plan),
|
|
2646
|
+
plan,
|
|
2647
|
+
...summary
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
const readFileTool = defineTool({
|
|
2652
|
+
name: "read_file",
|
|
2653
|
+
description: "Read a UTF-8 file inside the workspace.",
|
|
2654
|
+
prompt: "read_file: read a UTF-8 file inside the workspace. To use it, reply with only JSON: {\"tool\":\"read_file\",\"args\":{\"path\":\"package.json\"}}",
|
|
2655
|
+
argsSchema: z.object({ path: z.string() }),
|
|
2656
|
+
execute: (context, args) => readWorkspaceFile(context.workspaceRoot, args.path)
|
|
2657
|
+
});
|
|
2658
|
+
async function readWorkspaceFile(workspaceRoot, path) {
|
|
2659
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
2660
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
2661
|
+
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
2662
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`read_file can only read files inside the workspace: ${path}`);
|
|
2663
|
+
const bytes = await readFile(resolvedPath);
|
|
2664
|
+
const content = bytes.toString("utf8");
|
|
2665
|
+
return {
|
|
2666
|
+
tool: "read_file",
|
|
2667
|
+
path: relativePath || ".",
|
|
2668
|
+
content,
|
|
1614
2669
|
hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`
|
|
1615
2670
|
};
|
|
1616
2671
|
}
|
|
2672
|
+
const writeFileTool = defineTool({
|
|
2673
|
+
name: "write_file",
|
|
2674
|
+
description: "Create a new UTF-8 file inside the workspace, or explicitly replace one. For overwrite:true, expected_current_hash must be the current/pre-write hash from read_file, never a predicted post-write hash.",
|
|
2675
|
+
prompt: "write_file: create a new UTF-8 file inside the workspace by default; use edit_file for targeted changes to existing files; pass create_parent_dirs:true only when creating the folder path is intended. Replace an existing whole file only with overwrite:true and expected_current_hash set to the current/pre-write hash returned by the latest read_file for that file; never invent it or use a predicted after-write hash. To create a file, reply with only JSON: {\"tool\":\"write_file\",\"args\":{\"path\":\"test/example.test.ts\",\"content\":\"import { it, expect } from \\\"vitest\\\";\\n\\nit(\\\"works\\\", () => {\\n expect(true).toBe(true);\\n});\\n\",\"create_parent_dirs\":true}}",
|
|
2676
|
+
argsSchema: z.object({
|
|
2677
|
+
path: z.string().describe("Workspace-relative path to write."),
|
|
2678
|
+
content: z.string().describe("Complete UTF-8 file content to write."),
|
|
2679
|
+
create_parent_dirs: z.boolean().optional().describe("Create missing parent directories only when that is explicitly intended."),
|
|
2680
|
+
overwrite: z.boolean().optional().describe("Set true only for intentional whole-file replacement."),
|
|
2681
|
+
expected_current_hash: z.string().optional().describe("Required with overwrite:true. Use the current file hash returned by the latest read_file result for this file. This is checked before writing and is not the hash after the write.")
|
|
2682
|
+
}),
|
|
2683
|
+
execute: (context, args) => writeWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
|
|
2684
|
+
});
|
|
2685
|
+
async function writeWorkspaceFile(workspaceRoot, args, options = {}) {
|
|
2686
|
+
const scopedPath = resolveWorkspaceScopedPath(workspaceRoot, args.path);
|
|
2687
|
+
return enqueueFileMutation(scopedPath.path, async () => {
|
|
2688
|
+
const existingTarget = await statTarget(scopedPath.path);
|
|
2689
|
+
if (args.overwrite) return overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options);
|
|
2690
|
+
if (existingTarget) throw new Error(`write_file can only create new files: ${scopedPath.relativePath}`);
|
|
2691
|
+
const createdParentDirs = await ensureParentDirectory(scopedPath.workspaceRoot, scopedPath.path, scopedPath.relativePath, Boolean(args.create_parent_dirs));
|
|
2692
|
+
const bytes = encodeUtf8Text(args.content);
|
|
2693
|
+
const hash = hashBytes(bytes);
|
|
2694
|
+
const lineCount = countLogicalLines$1(args.content);
|
|
2695
|
+
await writeFileAtomically(scopedPath.path, bytes);
|
|
2696
|
+
const writeEvent = {
|
|
2697
|
+
kind: "file_create",
|
|
2698
|
+
source: "agent",
|
|
2699
|
+
path: scopedPath.relativePath,
|
|
2700
|
+
afterHash: hash,
|
|
2701
|
+
firstChangedLine: 1,
|
|
2702
|
+
writeSummary: `created +${lineCount}`,
|
|
2703
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2704
|
+
};
|
|
2705
|
+
const overlayState = recordAgentFileCreate(workspaceRoot, writeEvent);
|
|
2706
|
+
options.logger?.debug({
|
|
2707
|
+
event: "file_create",
|
|
2708
|
+
...writeEvent,
|
|
2709
|
+
bytesWritten: bytes.length,
|
|
2710
|
+
lineCount,
|
|
2711
|
+
createdParentDirs,
|
|
2712
|
+
kbState: overlayState.kbState,
|
|
2713
|
+
dirtyFileCount: overlayState.dirtyFiles.length
|
|
2714
|
+
}, "file create");
|
|
2715
|
+
const content = [
|
|
2716
|
+
`Created ${scopedPath.relativePath}`,
|
|
2717
|
+
`hash: ${hash}`,
|
|
2718
|
+
`bytes_written: ${bytes.length}`,
|
|
2719
|
+
`line_count: ${lineCount}`,
|
|
2720
|
+
`kb_state: ${overlayState.kbState}`,
|
|
2721
|
+
createdParentDirs.length > 0 ? `created_parent_dirs: ${createdParentDirs.join(", ")}` : void 0
|
|
2722
|
+
].filter(Boolean).join("\n");
|
|
2723
|
+
return {
|
|
2724
|
+
tool: "write_file",
|
|
2725
|
+
path: scopedPath.relativePath,
|
|
2726
|
+
content,
|
|
2727
|
+
hash,
|
|
2728
|
+
bytesWritten: bytes.length,
|
|
2729
|
+
lineCount,
|
|
2730
|
+
createdParentDirs,
|
|
2731
|
+
kbState: "needs_sync",
|
|
2732
|
+
writeEvent
|
|
2733
|
+
};
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
async function overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options) {
|
|
2737
|
+
if (!args.expected_current_hash) throw new Error(`write_file overwrite requires expected_current_hash for ${scopedPath.relativePath}.`);
|
|
2738
|
+
if (!existingTarget) throw new Error(`write_file overwrite requires an existing file: ${scopedPath.relativePath}`);
|
|
2739
|
+
if (!existingTarget.isFile()) throw new Error(`write_file overwrite requires a regular file: ${scopedPath.relativePath}`);
|
|
2740
|
+
const beforeBytes = await readFile(scopedPath.path);
|
|
2741
|
+
const beforeHash = hashBytes(beforeBytes);
|
|
2742
|
+
if (args.expected_current_hash !== beforeHash) throw new Error(`write_file expected_current_hash did not match ${scopedPath.relativePath}.`);
|
|
2743
|
+
const beforeLineCount = countLogicalLines$1(decodeUtf8(scopedPath.relativePath, beforeBytes));
|
|
2744
|
+
const afterBytes = encodeUtf8Text(args.content);
|
|
2745
|
+
const afterHash = hashBytes(afterBytes);
|
|
2746
|
+
const afterLineCount = countLogicalLines$1(args.content);
|
|
2747
|
+
const bytesChanged = afterBytes.length - beforeBytes.length;
|
|
2748
|
+
const lineDelta = afterLineCount - beforeLineCount;
|
|
2749
|
+
await writeFileAtomically(scopedPath.path, afterBytes, Number(existingTarget.mode));
|
|
2750
|
+
const writeEvent = {
|
|
2751
|
+
kind: "file_overwrite",
|
|
2752
|
+
source: "agent",
|
|
2753
|
+
path: scopedPath.relativePath,
|
|
2754
|
+
beforeHash,
|
|
2755
|
+
afterHash,
|
|
2756
|
+
firstChangedLine: 1,
|
|
2757
|
+
writeSummary: `overwritten +${afterLineCount}/-${beforeLineCount}`,
|
|
2758
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2759
|
+
};
|
|
2760
|
+
const overlayState = recordAgentFileMutation(workspaceRoot, writeEvent);
|
|
2761
|
+
options.logger?.debug({
|
|
2762
|
+
event: "file_overwrite",
|
|
2763
|
+
...writeEvent,
|
|
2764
|
+
bytesWritten: afterBytes.length,
|
|
2765
|
+
bytesChanged,
|
|
2766
|
+
lineCount: afterLineCount,
|
|
2767
|
+
lineDelta,
|
|
2768
|
+
kbState: overlayState.kbState,
|
|
2769
|
+
dirtyFileCount: overlayState.dirtyFiles.length
|
|
2770
|
+
}, "file overwrite");
|
|
2771
|
+
const content = [
|
|
2772
|
+
`Overwrote ${scopedPath.relativePath}`,
|
|
2773
|
+
`before_hash: ${beforeHash}`,
|
|
2774
|
+
`after_hash: ${afterHash}`,
|
|
2775
|
+
`bytes_written: ${afterBytes.length}`,
|
|
2776
|
+
`bytes_changed: ${bytesChanged}`,
|
|
2777
|
+
`line_count: ${afterLineCount}`,
|
|
2778
|
+
`line_delta: ${lineDelta}`,
|
|
2779
|
+
`kb_state: ${overlayState.kbState}`
|
|
2780
|
+
].join("\n");
|
|
2781
|
+
return {
|
|
2782
|
+
tool: "write_file",
|
|
2783
|
+
path: scopedPath.relativePath,
|
|
2784
|
+
content,
|
|
2785
|
+
hash: afterHash,
|
|
2786
|
+
beforeHash,
|
|
2787
|
+
bytesWritten: afterBytes.length,
|
|
2788
|
+
bytesChanged,
|
|
2789
|
+
lineCount: afterLineCount,
|
|
2790
|
+
lineDelta,
|
|
2791
|
+
createdParentDirs: [],
|
|
2792
|
+
kbState: "needs_sync",
|
|
2793
|
+
writeEvent
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
function resolveWorkspaceScopedPath(workspaceRoot, path) {
|
|
2797
|
+
if (path.includes("\0") || path.length === 0) throw new Error("write_file path is invalid.");
|
|
2798
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
2799
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
2800
|
+
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
2801
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`write_file can only write files inside the workspace: ${path}`);
|
|
2802
|
+
if (relativePath === "") throw new Error("write_file path must point to a file inside the workspace.");
|
|
2803
|
+
return {
|
|
2804
|
+
workspaceRoot: resolvedWorkspace,
|
|
2805
|
+
path: resolvedPath,
|
|
2806
|
+
relativePath
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
async function statTarget(path) {
|
|
2810
|
+
try {
|
|
2811
|
+
return await stat(path);
|
|
2812
|
+
} catch (error) {
|
|
2813
|
+
if (isNodeError$2(error) && error.code === "ENOENT") return;
|
|
2814
|
+
throw error;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
async function ensureParentDirectory(workspaceRoot, path, relativePath, createParentDirs) {
|
|
2818
|
+
const parent = dirname(path);
|
|
2819
|
+
try {
|
|
2820
|
+
if (!(await stat(parent)).isDirectory()) throw new Error(`write_file parent path is not a directory: ${dirname(relativePath)}`);
|
|
2821
|
+
return [];
|
|
2822
|
+
} catch (error) {
|
|
2823
|
+
if (!isNodeError$2(error) || error.code !== "ENOENT") throw error;
|
|
2824
|
+
}
|
|
2825
|
+
if (!createParentDirs) throw new Error(`write_file parent directory does not exist: ${dirname(relativePath)}`);
|
|
2826
|
+
const createdParentDirs = await collectMissingParentDirs(workspaceRoot, parent);
|
|
2827
|
+
await mkdir(parent, { recursive: true });
|
|
2828
|
+
return createdParentDirs;
|
|
2829
|
+
}
|
|
2830
|
+
async function collectMissingParentDirs(workspaceRoot, parent) {
|
|
2831
|
+
const missing = [];
|
|
2832
|
+
let current = parent;
|
|
2833
|
+
while (current !== workspaceRoot) try {
|
|
2834
|
+
if (!(await stat(current)).isDirectory()) throw new Error(`write_file parent path is not a directory: ${relative(workspaceRoot, current)}`);
|
|
2835
|
+
break;
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
if (!isNodeError$2(error) || error.code !== "ENOENT") throw error;
|
|
2838
|
+
missing.push(relative(workspaceRoot, current));
|
|
2839
|
+
current = dirname(current);
|
|
2840
|
+
}
|
|
2841
|
+
return missing.reverse();
|
|
2842
|
+
}
|
|
2843
|
+
function encodeUtf8Text(content) {
|
|
2844
|
+
if (content.includes("\0")) throw new Error("write_file content must not contain NUL bytes.");
|
|
2845
|
+
return Buffer.from(content, "utf8");
|
|
2846
|
+
}
|
|
2847
|
+
function decodeUtf8(path, bytes) {
|
|
2848
|
+
try {
|
|
2849
|
+
return new TextDecoder("utf-8", {
|
|
2850
|
+
fatal: true,
|
|
2851
|
+
ignoreBOM: true
|
|
2852
|
+
}).decode(bytes);
|
|
2853
|
+
} catch {
|
|
2854
|
+
throw new Error(`write_file overwrite requires the existing file to be UTF-8 text: ${path}`);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
async function writeFileAtomically(path, content, mode = 438) {
|
|
2858
|
+
const temporaryPath = resolve(dirname(path), `.${basename(path)}.topchester-${process.pid}-${randomUUID()}.tmp`);
|
|
2859
|
+
try {
|
|
2860
|
+
await writeFile(temporaryPath, content, {
|
|
2861
|
+
flag: "wx",
|
|
2862
|
+
mode: mode & 511
|
|
2863
|
+
});
|
|
2864
|
+
await rename(temporaryPath, path);
|
|
2865
|
+
} catch (error) {
|
|
2866
|
+
await rm(temporaryPath, { force: true });
|
|
2867
|
+
throw error;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
function hashBytes(bytes) {
|
|
2871
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
2872
|
+
}
|
|
2873
|
+
function countLogicalLines$1(content) {
|
|
2874
|
+
if (content.length === 0) return 0;
|
|
2875
|
+
const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
|
|
2876
|
+
return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
|
|
2877
|
+
}
|
|
2878
|
+
function isNodeError$2(error) {
|
|
2879
|
+
return error instanceof Error && "code" in error;
|
|
2880
|
+
}
|
|
1617
2881
|
//#endregion
|
|
1618
2882
|
//#region src/agent/tools/registry.ts
|
|
1619
2883
|
const toolRegistry = {
|
|
2884
|
+
[planTodoTool.name]: planTodoTool,
|
|
1620
2885
|
[readFileTool.name]: readFileTool,
|
|
1621
2886
|
[listFilesTool.name]: listFilesTool,
|
|
1622
2887
|
[grepTool.name]: grepTool,
|
|
1623
2888
|
[findFileTool.name]: findFileTool,
|
|
1624
2889
|
[editFileTool.name]: editFileTool,
|
|
2890
|
+
[writeFileTool.name]: writeFileTool,
|
|
2891
|
+
[gitStatusTool.name]: gitStatusTool,
|
|
2892
|
+
[gitDiffTool.name]: gitDiffTool,
|
|
2893
|
+
[gitLogTool.name]: gitLogTool,
|
|
2894
|
+
[gitAddTool.name]: gitAddTool,
|
|
2895
|
+
[gitCommitTool.name]: gitCommitTool,
|
|
1625
2896
|
[inspectCommandTool.name]: inspectCommandTool
|
|
1626
2897
|
};
|
|
1627
2898
|
function isToolName(name) {
|
|
@@ -1837,16 +3108,23 @@ var ModelGateway = class ModelGateway {
|
|
|
1837
3108
|
}
|
|
1838
3109
|
async generateText(request) {
|
|
1839
3110
|
const resolved = this.resolveModel(request.purpose);
|
|
3111
|
+
const result = await generateText({
|
|
3112
|
+
model: resolved.model,
|
|
3113
|
+
system: request.system,
|
|
3114
|
+
prompt: request.prompt,
|
|
3115
|
+
abortSignal: request.abortSignal
|
|
3116
|
+
});
|
|
3117
|
+
const usage = normalizeUsage(result.usage, {
|
|
3118
|
+
providerId: resolved.providerId,
|
|
3119
|
+
providerConfig: resolved.providerConfig,
|
|
3120
|
+
responseBody: result.response.body
|
|
3121
|
+
});
|
|
1840
3122
|
return {
|
|
1841
|
-
text:
|
|
1842
|
-
model: resolved.model,
|
|
1843
|
-
system: request.system,
|
|
1844
|
-
prompt: request.prompt,
|
|
1845
|
-
abortSignal: request.abortSignal
|
|
1846
|
-
})).text,
|
|
3123
|
+
text: result.text,
|
|
1847
3124
|
providerId: resolved.providerId,
|
|
1848
3125
|
modelId: resolved.modelId,
|
|
1849
|
-
purpose: resolved.purpose
|
|
3126
|
+
purpose: resolved.purpose,
|
|
3127
|
+
...usage ? { usage } : {}
|
|
1850
3128
|
};
|
|
1851
3129
|
}
|
|
1852
3130
|
async generateAgentStep(request) {
|
|
@@ -1879,7 +3157,7 @@ var ModelGateway = class ModelGateway {
|
|
|
1879
3157
|
}
|
|
1880
3158
|
return result;
|
|
1881
3159
|
} catch (error) {
|
|
1882
|
-
const reason = formatErrorMessage$
|
|
3160
|
+
const reason = formatErrorMessage$2(error);
|
|
1883
3161
|
attempts.push({
|
|
1884
3162
|
protocol: "native-openai-compatible",
|
|
1885
3163
|
status: "failed",
|
|
@@ -1915,6 +3193,11 @@ var ModelGateway = class ModelGateway {
|
|
|
1915
3193
|
providerOptions,
|
|
1916
3194
|
abortSignal: request.abortSignal
|
|
1917
3195
|
});
|
|
3196
|
+
const usage = normalizeUsage(result.usage, {
|
|
3197
|
+
providerId: resolved.providerId,
|
|
3198
|
+
providerConfig: resolved.providerConfig,
|
|
3199
|
+
responseBody: result.response.body
|
|
3200
|
+
});
|
|
1918
3201
|
const toolCalls = result.toolCalls.map((call, index) => {
|
|
1919
3202
|
const parsed = parseNativeToolCall(call.toolName, call.input);
|
|
1920
3203
|
if (!parsed) throw new Error(`Native tool call for ${call.toolName} did not match the registered schema.`);
|
|
@@ -1934,6 +3217,7 @@ var ModelGateway = class ModelGateway {
|
|
|
1934
3217
|
providerId: resolved.providerId,
|
|
1935
3218
|
modelId: resolved.modelId,
|
|
1936
3219
|
purpose: resolved.purpose,
|
|
3220
|
+
...usage ? { usage } : {},
|
|
1937
3221
|
toolCalls,
|
|
1938
3222
|
toolProtocol: "native-openai-compatible",
|
|
1939
3223
|
protocolAttempts: attempts,
|
|
@@ -1949,6 +3233,11 @@ var ModelGateway = class ModelGateway {
|
|
|
1949
3233
|
prompt: request.prompt,
|
|
1950
3234
|
abortSignal: request.abortSignal
|
|
1951
3235
|
});
|
|
3236
|
+
const usage = normalizeUsage(result.usage, {
|
|
3237
|
+
providerId: resolved.providerId,
|
|
3238
|
+
providerConfig: resolved.providerConfig,
|
|
3239
|
+
responseBody: result.response.body
|
|
3240
|
+
});
|
|
1952
3241
|
const parsed = parseToolCallWithSource(result.text, allowedSources);
|
|
1953
3242
|
const defaultProtocol = allowedSources.length === 1 && allowedSources[0] === "text-xml" ? "text-xml" : "text-json";
|
|
1954
3243
|
const toolProtocol = parsed?.source === "text-xml" ? "text-xml" : parsed ? "text-json" : defaultProtocol;
|
|
@@ -1962,6 +3251,7 @@ var ModelGateway = class ModelGateway {
|
|
|
1962
3251
|
providerId: resolved.providerId,
|
|
1963
3252
|
modelId: resolved.modelId,
|
|
1964
3253
|
purpose: resolved.purpose,
|
|
3254
|
+
...usage ? { usage } : {},
|
|
1965
3255
|
toolCalls: parsed ? [{
|
|
1966
3256
|
id: `${parsed.source}-0`,
|
|
1967
3257
|
tool: parsed.call.tool,
|
|
@@ -1990,6 +3280,9 @@ function buildNativeProviderOptions(providerId, config) {
|
|
|
1990
3280
|
function shouldApplyOpenRouterRoutingOptions(providerId, config) {
|
|
1991
3281
|
if (config.openRouterToolRouting === "force") return true;
|
|
1992
3282
|
if (config.openRouterToolRouting === "off") return false;
|
|
3283
|
+
return isOpenRouterProvider(providerId, config);
|
|
3284
|
+
}
|
|
3285
|
+
function isOpenRouterProvider(providerId, config) {
|
|
1993
3286
|
return providerId.toLowerCase().includes("openrouter") || config.baseURL.toLowerCase().includes("openrouter.ai");
|
|
1994
3287
|
}
|
|
1995
3288
|
function hasOpenRouterRoutingOptions(providerOptions, providerId) {
|
|
@@ -1997,14 +3290,32 @@ function hasOpenRouterRoutingOptions(providerOptions, providerId) {
|
|
|
1997
3290
|
return Boolean(options && typeof options === "object" && "provider" in options);
|
|
1998
3291
|
}
|
|
1999
3292
|
function isNativeToolFallbackError(error) {
|
|
2000
|
-
const message = formatErrorMessage$
|
|
3293
|
+
const message = formatErrorMessage$2(error).toLowerCase();
|
|
2001
3294
|
return message.includes("tool") || message.includes("function") || message.includes("parallel_tool_calls") || message.includes("tool_choice") || message.includes("requested parameters") || message.includes("provider routing") || message.includes("provider-selection") || message.includes("invalid request");
|
|
2002
3295
|
}
|
|
2003
3296
|
function extractWarningMessages(warnings) {
|
|
2004
3297
|
if (!Array.isArray(warnings)) return [];
|
|
2005
|
-
return warnings.map((warning) => formatErrorMessage$
|
|
3298
|
+
return warnings.map((warning) => formatErrorMessage$2(warning));
|
|
3299
|
+
}
|
|
3300
|
+
function normalizeUsage(usage, context) {
|
|
3301
|
+
const costUsd = context && isOpenRouterProvider(context.providerId, context.providerConfig) ? extractOpenRouterCost(context.responseBody) : void 0;
|
|
3302
|
+
if (!usage) return costUsd === void 0 ? void 0 : { costUsd };
|
|
3303
|
+
const normalized = {
|
|
3304
|
+
...typeof usage.inputTokens === "number" ? { inputTokens: usage.inputTokens } : {},
|
|
3305
|
+
...typeof usage.outputTokens === "number" ? { outputTokens: usage.outputTokens } : {},
|
|
3306
|
+
...typeof usage.totalTokens === "number" ? { totalTokens: usage.totalTokens } : {},
|
|
3307
|
+
...costUsd === void 0 ? {} : { costUsd }
|
|
3308
|
+
};
|
|
3309
|
+
return Object.keys(normalized).length > 0 ? normalized : void 0;
|
|
2006
3310
|
}
|
|
2007
|
-
function
|
|
3311
|
+
function extractOpenRouterCost(responseBody) {
|
|
3312
|
+
if (!responseBody || typeof responseBody !== "object") return;
|
|
3313
|
+
const usage = responseBody.usage;
|
|
3314
|
+
if (!usage || typeof usage !== "object") return;
|
|
3315
|
+
const cost = usage.cost;
|
|
3316
|
+
return typeof cost === "number" && Number.isFinite(cost) ? cost : void 0;
|
|
3317
|
+
}
|
|
3318
|
+
function formatErrorMessage$2(error) {
|
|
2008
3319
|
return error instanceof Error ? error.message : String(error);
|
|
2009
3320
|
}
|
|
2010
3321
|
//#endregion
|
|
@@ -2014,8 +3325,6 @@ const modelPurposeSchema = z.enum([
|
|
|
2014
3325
|
"agent.fast",
|
|
2015
3326
|
"kb.scan",
|
|
2016
3327
|
"kb.summarize",
|
|
2017
|
-
"kb.extract",
|
|
2018
|
-
"kb.embed",
|
|
2019
3328
|
"fallback"
|
|
2020
3329
|
]);
|
|
2021
3330
|
const modelPurposes = modelPurposeSchema.options;
|
|
@@ -2110,7 +3419,7 @@ function readConfigFile(path) {
|
|
|
2110
3419
|
try {
|
|
2111
3420
|
return parse(readFileSync(path, "utf8"));
|
|
2112
3421
|
} catch (error) {
|
|
2113
|
-
throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage(error)}`);
|
|
3422
|
+
throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage$1(error)}`);
|
|
2114
3423
|
}
|
|
2115
3424
|
}
|
|
2116
3425
|
function parseConfigFile(path, value) {
|
|
@@ -2146,6 +3455,7 @@ function normalizeConfigInput(value) {
|
|
|
2146
3455
|
ensureKnownProvider(providers, kbSummarizeModelRef.provider);
|
|
2147
3456
|
delete models["kb.summarize"];
|
|
2148
3457
|
}
|
|
3458
|
+
applyKnownProviderDefaults(providers);
|
|
2149
3459
|
return {
|
|
2150
3460
|
...value,
|
|
2151
3461
|
models: {
|
|
@@ -2202,6 +3512,20 @@ function ensureKnownProvider(providers, provider) {
|
|
|
2202
3512
|
}
|
|
2203
3513
|
};
|
|
2204
3514
|
}
|
|
3515
|
+
function applyKnownProviderDefaults(providers) {
|
|
3516
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
3517
|
+
if (!isPlainObject(provider) || provider.type !== "openai-compatible" || typeof provider.baseURL !== "string") continue;
|
|
3518
|
+
if (isOpenAIProvider(providerId, provider.baseURL)) {
|
|
3519
|
+
provider.supportsStructuredOutputs ??= true;
|
|
3520
|
+
provider.toolProtocol ??= "native";
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
function isOpenAIProvider(providerId, baseURL) {
|
|
3525
|
+
const normalizedProvider = providerId.toLowerCase();
|
|
3526
|
+
const normalizedBaseURL = baseURL.toLowerCase();
|
|
3527
|
+
return normalizedProvider === "openai" || normalizedProvider === "gpt" || normalizedProvider.includes("openai") || normalizedBaseURL.includes("api.openai.com");
|
|
3528
|
+
}
|
|
2205
3529
|
function deepMerge(base, override, path = []) {
|
|
2206
3530
|
if (Array.isArray(base) && Array.isArray(override)) return path.join(".") === "ignore.paths" ? [...base, ...override] : override;
|
|
2207
3531
|
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
|
@@ -2215,7 +3539,7 @@ function isPlainObject(value) {
|
|
|
2215
3539
|
function formatZodIssue(issue) {
|
|
2216
3540
|
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
2217
3541
|
}
|
|
2218
|
-
function formatErrorMessage(error) {
|
|
3542
|
+
function formatErrorMessage$1(error) {
|
|
2219
3543
|
return error instanceof Error ? error.message : String(error);
|
|
2220
3544
|
}
|
|
2221
3545
|
//#endregion
|
|
@@ -2309,107 +3633,6 @@ function normalizeModelGatewayConfig(config) {
|
|
|
2309
3633
|
};
|
|
2310
3634
|
}
|
|
2311
3635
|
//#endregion
|
|
2312
|
-
//#region src/cli/ui.ts
|
|
2313
|
-
const colors = {
|
|
2314
|
-
bgSoftGray: "\x1B[48;5;236m",
|
|
2315
|
-
blue: "\x1B[34m",
|
|
2316
|
-
cyan: "\x1B[36m",
|
|
2317
|
-
darkGray: "\x1B[90m",
|
|
2318
|
-
dim: "\x1B[2m",
|
|
2319
|
-
green: "\x1B[32m",
|
|
2320
|
-
orange: "\x1B[38;5;208m",
|
|
2321
|
-
purple: "\x1B[35m",
|
|
2322
|
-
red: "\x1B[31m",
|
|
2323
|
-
reset: "\x1B[0m",
|
|
2324
|
-
resetForeground: "\x1B[39m",
|
|
2325
|
-
yellow: "\x1B[33m"
|
|
2326
|
-
};
|
|
2327
|
-
const ui = {
|
|
2328
|
-
heading(text) {
|
|
2329
|
-
return color(`Topchester ${text}`, "cyan");
|
|
2330
|
-
},
|
|
2331
|
-
label(text) {
|
|
2332
|
-
return color(text, "dim");
|
|
2333
|
-
},
|
|
2334
|
-
muted(text) {
|
|
2335
|
-
return color(text, "darkGray");
|
|
2336
|
-
},
|
|
2337
|
-
model(text) {
|
|
2338
|
-
return color(text, "blue");
|
|
2339
|
-
},
|
|
2340
|
-
modelInline(text) {
|
|
2341
|
-
if (!shouldUseColor()) return text;
|
|
2342
|
-
return `${colors.blue}${text}${colors.resetForeground}`;
|
|
2343
|
-
},
|
|
2344
|
-
ok(text) {
|
|
2345
|
-
return color(text, "green");
|
|
2346
|
-
},
|
|
2347
|
-
warn(text) {
|
|
2348
|
-
return color(text, "yellow");
|
|
2349
|
-
},
|
|
2350
|
-
error(text) {
|
|
2351
|
-
return color(text, "red");
|
|
2352
|
-
},
|
|
2353
|
-
softBackground(text) {
|
|
2354
|
-
return color(text, "bgSoftGray");
|
|
2355
|
-
},
|
|
2356
|
-
async spinner(text, action) {
|
|
2357
|
-
return withStatusLine(text, action, void 0, 80, false);
|
|
2358
|
-
},
|
|
2359
|
-
async progress(text, action) {
|
|
2360
|
-
let latest = text;
|
|
2361
|
-
return withStatusLine(text, () => action((message) => {
|
|
2362
|
-
latest = message;
|
|
2363
|
-
}), () => latest, 80, true);
|
|
2364
|
-
}
|
|
2365
|
-
};
|
|
2366
|
-
async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
|
|
2367
|
-
if (!shouldUseColor()) {
|
|
2368
|
-
if (!emitPlainProgress) return action();
|
|
2369
|
-
const timer = setInterval(() => {
|
|
2370
|
-
stderr.write(`${getText()}\n`);
|
|
2371
|
-
}, Math.max(progressEveryMs, 5e3));
|
|
2372
|
-
try {
|
|
2373
|
-
return await action();
|
|
2374
|
-
} finally {
|
|
2375
|
-
clearInterval(timer);
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
const frames = [
|
|
2379
|
-
"⠋",
|
|
2380
|
-
"⠙",
|
|
2381
|
-
"⠹",
|
|
2382
|
-
"⠸",
|
|
2383
|
-
"⠼",
|
|
2384
|
-
"⠴",
|
|
2385
|
-
"⠦",
|
|
2386
|
-
"⠧",
|
|
2387
|
-
"⠇",
|
|
2388
|
-
"⠏"
|
|
2389
|
-
];
|
|
2390
|
-
let index = 0;
|
|
2391
|
-
stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
|
|
2392
|
-
const timer = setInterval(() => {
|
|
2393
|
-
index = (index + 1) % frames.length;
|
|
2394
|
-
stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
|
|
2395
|
-
}, progressEveryMs);
|
|
2396
|
-
try {
|
|
2397
|
-
return await action();
|
|
2398
|
-
} finally {
|
|
2399
|
-
clearInterval(timer);
|
|
2400
|
-
stderr.write(`\r\u001b[2K`);
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
function color(text, colorName) {
|
|
2404
|
-
if (!shouldUseColor()) return text;
|
|
2405
|
-
return `${colors[colorName]}${text}${colors.reset}`;
|
|
2406
|
-
}
|
|
2407
|
-
function shouldUseColor() {
|
|
2408
|
-
if (process.env.NO_COLOR) return false;
|
|
2409
|
-
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
2410
|
-
return stdout.isTTY === true;
|
|
2411
|
-
}
|
|
2412
|
-
//#endregion
|
|
2413
3636
|
//#region src/knowledge/status.ts
|
|
2414
3637
|
function getKnowledgeStatus(workspaceRoot) {
|
|
2415
3638
|
const kbPathSource = process.env.TOPCHESTER_KB_DIR ? "env" : "default";
|
|
@@ -2742,6 +3965,14 @@ const l1ConfidenceLevels = [
|
|
|
2742
3965
|
"medium",
|
|
2743
3966
|
"high"
|
|
2744
3967
|
];
|
|
3968
|
+
const l1FileRoles = [
|
|
3969
|
+
"source",
|
|
3970
|
+
"test",
|
|
3971
|
+
"config",
|
|
3972
|
+
"doc",
|
|
3973
|
+
"script",
|
|
3974
|
+
"unknown"
|
|
3975
|
+
];
|
|
2745
3976
|
const nonEmptyStringSchema = z.string().min(1);
|
|
2746
3977
|
const sha256HashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
2747
3978
|
const isoUtcTimestampSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/).refine((value) => !Number.isNaN(Date.parse(value)), { message: "Expected a valid UTC ISO timestamp" });
|
|
@@ -2753,7 +3984,7 @@ const l1FileSymbolSchema = z.object({
|
|
|
2753
3984
|
kind: nonEmptyStringSchema,
|
|
2754
3985
|
name: nonEmptyStringSchema,
|
|
2755
3986
|
exported: z.boolean(),
|
|
2756
|
-
summary: nonEmptyStringSchema
|
|
3987
|
+
summary: nonEmptyStringSchema.optional()
|
|
2757
3988
|
}).strict();
|
|
2758
3989
|
const l1FileEvidenceSchema = z.object({
|
|
2759
3990
|
kind: nonEmptyStringSchema,
|
|
@@ -2770,6 +4001,7 @@ const l1FileEntrySchema = z.object({
|
|
|
2770
4001
|
size_bytes: z.number().int().nonnegative(),
|
|
2771
4002
|
last_scanned_at: isoUtcTimestampSchema,
|
|
2772
4003
|
scan_status: z.enum(l1FileScanStatuses),
|
|
4004
|
+
file_role: z.enum(l1FileRoles).default("unknown"),
|
|
2773
4005
|
summary: nonEmptyStringSchema,
|
|
2774
4006
|
responsibilities: z.array(nonEmptyStringSchema),
|
|
2775
4007
|
symbols: z.array(l1FileSymbolSchema),
|
|
@@ -2778,6 +4010,9 @@ const l1FileEntrySchema = z.object({
|
|
|
2778
4010
|
module_ids: z.array(l1ModuleIdSchema),
|
|
2779
4011
|
feature_ids: z.array(l1FeatureIdSchema),
|
|
2780
4012
|
test_ids: z.array(l1FileIdSchema),
|
|
4013
|
+
declared_test_targets: z.array(l1FileIdSchema).default([]),
|
|
4014
|
+
likely_test_targets: z.array(l1FileIdSchema).default([]),
|
|
4015
|
+
tested_by: z.array(l1FileIdSchema).default([]),
|
|
2781
4016
|
evidence: z.array(l1FileEvidenceSchema),
|
|
2782
4017
|
confidence: z.enum(l1ConfidenceLevels)
|
|
2783
4018
|
}).strict().refine((entry) => entry.id === `file:${entry.path}`, {
|
|
@@ -2845,6 +4080,123 @@ function formatCountProgress(label, completed, total, detail) {
|
|
|
2845
4080
|
return `${label} [${formatProgressBar(safeCompleted, safeTotal)}] ${safeCompleted}/${safeTotal} (${percent}%)${suffix}`;
|
|
2846
4081
|
}
|
|
2847
4082
|
//#endregion
|
|
4083
|
+
//#region src/knowledge/compiler/l1-postprocess.ts
|
|
4084
|
+
async function postProcessL1Entries(kbPath) {
|
|
4085
|
+
const entries = await loadL1Entries(kbPath);
|
|
4086
|
+
const entriesById = new Map(entries.map(({ entry }) => [entry.id, entry]));
|
|
4087
|
+
const entriesByPath = new Map(entries.map(({ entry }) => [entry.path, entry]));
|
|
4088
|
+
const testTargetsById = /* @__PURE__ */ new Map();
|
|
4089
|
+
for (const { entry } of entries) {
|
|
4090
|
+
if (inferL1FileRole(entry.path) !== "test") {
|
|
4091
|
+
testTargetsById.set(entry.id, {
|
|
4092
|
+
declared: [],
|
|
4093
|
+
likely: []
|
|
4094
|
+
});
|
|
4095
|
+
continue;
|
|
4096
|
+
}
|
|
4097
|
+
const declared = dedupeStrings$1([...entry.declared_test_targets.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById)), ...entry.imports.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById))]);
|
|
4098
|
+
const likely = dedupeStrings$1([...entry.likely_test_targets.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById)), ...inferLikelyTestTargets(entry.path, entriesByPath)]);
|
|
4099
|
+
testTargetsById.set(entry.id, {
|
|
4100
|
+
declared,
|
|
4101
|
+
likely
|
|
4102
|
+
});
|
|
4103
|
+
}
|
|
4104
|
+
const testedBy = /* @__PURE__ */ new Map();
|
|
4105
|
+
for (const [testId, links] of testTargetsById) for (const targetId of dedupeStrings$1([...links.declared, ...links.likely])) {
|
|
4106
|
+
const list = testedBy.get(targetId) ?? [];
|
|
4107
|
+
list.push(testId);
|
|
4108
|
+
testedBy.set(targetId, list);
|
|
4109
|
+
}
|
|
4110
|
+
let entriesUpdated = 0;
|
|
4111
|
+
let testLinksAdded = 0;
|
|
4112
|
+
for (const { entry, entryPath } of entries) {
|
|
4113
|
+
const fileRole = inferL1FileRole(entry.path);
|
|
4114
|
+
const links = testTargetsById.get(entry.id) ?? {
|
|
4115
|
+
declared: [],
|
|
4116
|
+
likely: []
|
|
4117
|
+
};
|
|
4118
|
+
const nextEntry = parseL1FileEntry({
|
|
4119
|
+
...entry,
|
|
4120
|
+
file_role: fileRole,
|
|
4121
|
+
declared_test_targets: links.declared,
|
|
4122
|
+
likely_test_targets: links.likely,
|
|
4123
|
+
tested_by: dedupeStrings$1(testedBy.get(entry.id) ?? []).sort()
|
|
4124
|
+
});
|
|
4125
|
+
testLinksAdded += nextEntry.declared_test_targets.length + nextEntry.likely_test_targets.length + nextEntry.tested_by.length;
|
|
4126
|
+
if (JSON.stringify(nextEntry) !== JSON.stringify(entry)) {
|
|
4127
|
+
await writeFile(entryPath, `${JSON.stringify(nextEntry, null, 2)}\n`);
|
|
4128
|
+
entriesUpdated += 1;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
return {
|
|
4132
|
+
entriesRead: entries.length,
|
|
4133
|
+
entriesUpdated,
|
|
4134
|
+
testLinksAdded
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
function inferL1FileRole(path) {
|
|
4138
|
+
const lowerPath = path.toLowerCase();
|
|
4139
|
+
const name = basename(lowerPath);
|
|
4140
|
+
if (isTestPath(lowerPath)) return "test";
|
|
4141
|
+
if (lowerPath.startsWith("scripts/") || lowerPath.startsWith("script/") || lowerPath.endsWith(".sh")) return "script";
|
|
4142
|
+
if (lowerPath.endsWith(".md") || lowerPath.endsWith(".mdx") || lowerPath.startsWith("docs/")) return "doc";
|
|
4143
|
+
if (name === "package.json" || name.endsWith("lock.json") || name.endsWith("-lock.yaml") || name.endsWith(".config.ts") || name.endsWith(".config.js") || name.endsWith(".config.mjs") || name.endsWith(".config.cjs") || name === "tsconfig.json" || name.startsWith(".")) return "config";
|
|
4144
|
+
if (/\.(ts|tsx|js|jsx|mts|cts)$/.test(lowerPath)) return "source";
|
|
4145
|
+
return "unknown";
|
|
4146
|
+
}
|
|
4147
|
+
async function loadL1Entries(kbPath) {
|
|
4148
|
+
const entryPaths = await listJsonFiles$1(join(kbPath, "l1-files")).catch((error) => {
|
|
4149
|
+
if (isFileNotFoundError$3(error)) return [];
|
|
4150
|
+
throw error;
|
|
4151
|
+
});
|
|
4152
|
+
const entries = [];
|
|
4153
|
+
for (const entryPath of entryPaths) entries.push({
|
|
4154
|
+
entryPath,
|
|
4155
|
+
entry: parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")))
|
|
4156
|
+
});
|
|
4157
|
+
return entries.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
|
|
4158
|
+
}
|
|
4159
|
+
async function listJsonFiles$1(directory) {
|
|
4160
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
4161
|
+
const files = [];
|
|
4162
|
+
for (const entry of entries) {
|
|
4163
|
+
const entryPath = join(directory, entry.name);
|
|
4164
|
+
if (entry.isDirectory()) files.push(...await listJsonFiles$1(entryPath));
|
|
4165
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) files.push(entryPath);
|
|
4166
|
+
}
|
|
4167
|
+
return files.sort();
|
|
4168
|
+
}
|
|
4169
|
+
function inferLikelyTestTargets(testPath, entriesByPath) {
|
|
4170
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
4171
|
+
const sourceLikePath = removeTestSuffix(testPath);
|
|
4172
|
+
candidates.add(sourceLikePath);
|
|
4173
|
+
for (const prefix of [
|
|
4174
|
+
"test/",
|
|
4175
|
+
"tests/",
|
|
4176
|
+
"__tests__/"
|
|
4177
|
+
]) if (testPath.startsWith(prefix)) candidates.add(`src/${removeTestSuffix(testPath.slice(prefix.length))}`);
|
|
4178
|
+
if (testPath.includes("/__tests__/")) candidates.add(removeTestSuffix(testPath.replace("/__tests__/", "/")));
|
|
4179
|
+
return [...candidates].filter((candidate) => candidate !== testPath).flatMap((candidate) => {
|
|
4180
|
+
const entry = entriesByPath.get(candidate);
|
|
4181
|
+
return entry ? [entry.id] : [];
|
|
4182
|
+
});
|
|
4183
|
+
}
|
|
4184
|
+
function removeTestSuffix(path) {
|
|
4185
|
+
return path.replace(/\.(test|spec)(\.[^./]+)$/i, "$2");
|
|
4186
|
+
}
|
|
4187
|
+
function isTestPath(path) {
|
|
4188
|
+
return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts)$/.test(path) || path.startsWith("test/") || path.startsWith("tests/") || path.startsWith("__tests__/") || path.includes("/__tests__/");
|
|
4189
|
+
}
|
|
4190
|
+
function isExistingNonSelfFileId(id, selfId, entriesById) {
|
|
4191
|
+
return id !== selfId && entriesById.has(id);
|
|
4192
|
+
}
|
|
4193
|
+
function dedupeStrings$1(values) {
|
|
4194
|
+
return [...new Set(values)];
|
|
4195
|
+
}
|
|
4196
|
+
function isFileNotFoundError$3(error) {
|
|
4197
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
4198
|
+
}
|
|
4199
|
+
//#endregion
|
|
2848
4200
|
//#region src/knowledge/compiler/manifest.ts
|
|
2849
4201
|
const knowledgeCompilerIdentity = {
|
|
2850
4202
|
name: "topchester-knowledge-compiler",
|
|
@@ -2887,6 +4239,8 @@ async function processL1Queue(options) {
|
|
|
2887
4239
|
await persistQueue(options.queuePath, queuedFiles, now().toISOString());
|
|
2888
4240
|
options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index + 1, queuedFiles.length, item.path) });
|
|
2889
4241
|
}
|
|
4242
|
+
options.onProgress?.({ message: "Linking L1 file relationships..." });
|
|
4243
|
+
await postProcessL1Entries(options.kbPath);
|
|
2890
4244
|
const summary = await summarizeL1Queue(options.kbPath, queuedFiles);
|
|
2891
4245
|
await writeManifest(options, summary, now().toISOString());
|
|
2892
4246
|
return {
|
|
@@ -2946,7 +4300,11 @@ async function processL1QueueItem(options) {
|
|
|
2946
4300
|
}
|
|
2947
4301
|
function buildL1FileEntrySystemPrompt() {
|
|
2948
4302
|
return [
|
|
2949
|
-
"You
|
|
4303
|
+
"You create concise, structured repository knowledge for one file.",
|
|
4304
|
+
"Prefer concrete facts visible in the file over generic descriptions.",
|
|
4305
|
+
"Do not invent modules, features, tests, routes, or dependencies.",
|
|
4306
|
+
"If uncertain, leave arrays empty and use lower confidence.",
|
|
4307
|
+
"Avoid filler such as \"This file contains code\" or \"Symbol named X\".",
|
|
2950
4308
|
"Return exactly one JSON object and no markdown.",
|
|
2951
4309
|
"Do not include secrets, credentials, or raw provider payloads."
|
|
2952
4310
|
].join("\n");
|
|
@@ -2955,6 +4313,35 @@ function buildL1FileEntryPrompt(input) {
|
|
|
2955
4313
|
return [
|
|
2956
4314
|
"Create an L1 file entry for this workspace-relative path.",
|
|
2957
4315
|
"The compiler will overwrite id, path, content_hash, size_bytes, last_scanned_at, and scan_status.",
|
|
4316
|
+
"",
|
|
4317
|
+
"Extraction rules:",
|
|
4318
|
+
"- summary: one specific sentence about the file's role in this project.",
|
|
4319
|
+
"- responsibilities: 2-6 concrete responsibilities, no duplicates, no generic boilerplate.",
|
|
4320
|
+
"- symbols: important declared or exported interfaces, types, classes, functions, constants, schemas, commands, routes, React components, tests, or config objects.",
|
|
4321
|
+
" For each symbol, set:",
|
|
4322
|
+
" - kind: interface | type | class | function | const | component | schema | command | route | test | config | symbol",
|
|
4323
|
+
" - name: exact identifier or stable label",
|
|
4324
|
+
" - exported: true only when exported from this file",
|
|
4325
|
+
" - summary: include only if it adds useful meaning beyond the name",
|
|
4326
|
+
"- imports: only workspace-local file dependencies as file:<path>; omit packages and built-ins.",
|
|
4327
|
+
"- exports: exact exported names from this file as strings.",
|
|
4328
|
+
"- test_ids: only file:<path> when this file is clearly a test or clearly references a test target.",
|
|
4329
|
+
"- file_role: source | test | config | doc | script | unknown.",
|
|
4330
|
+
"- declared_test_targets: for test files, file:<path> entries that this test directly imports or names.",
|
|
4331
|
+
"- likely_test_targets: for test files, file:<path> entries likely covered by path/name convention.",
|
|
4332
|
+
"- tested_by: leave empty; the compiler fills reverse test links after all files are processed.",
|
|
4333
|
+
"- module_ids and feature_ids: leave empty unless there is strong evidence.",
|
|
4334
|
+
"- evidence: include at least { \"kind\": \"path\", \"value\": \"<path>\" } and any high-signal local evidence.",
|
|
4335
|
+
"- confidence: high for simple files with clear structure, medium for normal files, low for vague/generated/config-heavy files.",
|
|
4336
|
+
"",
|
|
4337
|
+
"Quality rules:",
|
|
4338
|
+
"- Return valid JSON only.",
|
|
4339
|
+
"- Keep arrays concise.",
|
|
4340
|
+
"- Deduplicate all arrays.",
|
|
4341
|
+
"- Prefer exact names from source.",
|
|
4342
|
+
"- Do not copy large code snippets.",
|
|
4343
|
+
"- Do not include secrets or raw credentials.",
|
|
4344
|
+
"",
|
|
2958
4345
|
"Use this JSON shape:",
|
|
2959
4346
|
JSON.stringify({
|
|
2960
4347
|
$schema: l1FileEntrySchemaPath,
|
|
@@ -2967,6 +4354,7 @@ function buildL1FileEntryPrompt(input) {
|
|
|
2967
4354
|
size_bytes: 0,
|
|
2968
4355
|
last_scanned_at: "2026-05-11T00:00:00Z",
|
|
2969
4356
|
scan_status: "current",
|
|
4357
|
+
file_role: "source",
|
|
2970
4358
|
summary: "One clear sentence.",
|
|
2971
4359
|
responsibilities: ["What this file owns or does."],
|
|
2972
4360
|
symbols: [],
|
|
@@ -2975,6 +4363,9 @@ function buildL1FileEntryPrompt(input) {
|
|
|
2975
4363
|
module_ids: [],
|
|
2976
4364
|
feature_ids: [],
|
|
2977
4365
|
test_ids: [],
|
|
4366
|
+
declared_test_targets: [],
|
|
4367
|
+
likely_test_targets: [],
|
|
4368
|
+
tested_by: [],
|
|
2978
4369
|
evidence: [{
|
|
2979
4370
|
kind: "path",
|
|
2980
4371
|
value: "<path>"
|
|
@@ -3012,41 +4403,49 @@ function normalizeL1FileEntry(value, deterministic) {
|
|
|
3012
4403
|
}
|
|
3013
4404
|
function normalizeModelOwnedL1Fields(value, path) {
|
|
3014
4405
|
const record = value;
|
|
4406
|
+
const exports = normalizeStringArray(record.exports);
|
|
3015
4407
|
return {
|
|
3016
4408
|
...record,
|
|
4409
|
+
file_role: inferL1FileRole(path),
|
|
3017
4410
|
responsibilities: normalizeStringArray(record.responsibilities),
|
|
3018
|
-
symbols: normalizeSymbols(record.symbols, path),
|
|
4411
|
+
symbols: normalizeSymbols(record.symbols, path, exports),
|
|
3019
4412
|
imports: normalizePrefixedIds(record.imports, "file:"),
|
|
3020
|
-
exports
|
|
4413
|
+
exports,
|
|
3021
4414
|
module_ids: normalizePrefixedIds(record.module_ids, "module:"),
|
|
3022
4415
|
feature_ids: normalizePrefixedIds(record.feature_ids, "feature:"),
|
|
3023
4416
|
test_ids: normalizePrefixedIds(record.test_ids, "file:"),
|
|
4417
|
+
declared_test_targets: normalizePrefixedIds(record.declared_test_targets, "file:"),
|
|
4418
|
+
likely_test_targets: normalizePrefixedIds(record.likely_test_targets, "file:"),
|
|
4419
|
+
tested_by: normalizePrefixedIds(record.tested_by, "file:"),
|
|
3024
4420
|
evidence: normalizeEvidence(record.evidence)
|
|
3025
4421
|
};
|
|
3026
4422
|
}
|
|
3027
4423
|
function normalizeStringArray(value) {
|
|
3028
4424
|
if (!Array.isArray(value)) return [];
|
|
3029
|
-
return value.filter((item) => typeof item === "string"
|
|
4425
|
+
return dedupeStrings(value.filter((item) => typeof item === "string").map((item) => item.trim()));
|
|
3030
4426
|
}
|
|
3031
4427
|
function normalizePrefixedIds(value, prefix) {
|
|
3032
4428
|
return normalizeStringArray(value).filter((item) => item.startsWith(prefix));
|
|
3033
4429
|
}
|
|
3034
4430
|
function normalizeEvidence(value) {
|
|
3035
4431
|
if (!Array.isArray(value)) return [];
|
|
3036
|
-
return value.flatMap((item) => {
|
|
4432
|
+
return dedupeRecords(value.flatMap((item) => {
|
|
3037
4433
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
|
3038
4434
|
const record = item;
|
|
3039
|
-
|
|
3040
|
-
|
|
4435
|
+
const kind = typeof record.kind === "string" ? record.kind.trim() : "";
|
|
4436
|
+
const recordValue = typeof record.value === "string" ? record.value.trim() : "";
|
|
4437
|
+
if (kind.length === 0) return [];
|
|
4438
|
+
if (recordValue.length === 0) return [];
|
|
3041
4439
|
return [{
|
|
3042
|
-
kind
|
|
3043
|
-
value:
|
|
4440
|
+
kind,
|
|
4441
|
+
value: recordValue
|
|
3044
4442
|
}];
|
|
3045
|
-
});
|
|
4443
|
+
}), (item) => `${item.kind}\0${item.value}`);
|
|
3046
4444
|
}
|
|
3047
|
-
function normalizeSymbols(value, path) {
|
|
4445
|
+
function normalizeSymbols(value, path, exports) {
|
|
3048
4446
|
if (!Array.isArray(value)) return [];
|
|
3049
|
-
|
|
4447
|
+
const exportedNames = new Set(exports);
|
|
4448
|
+
return dedupeRecords(value.flatMap((item) => {
|
|
3050
4449
|
if (typeof item === "string") {
|
|
3051
4450
|
const name = item.trim();
|
|
3052
4451
|
if (!name || !path) return [];
|
|
@@ -3054,8 +4453,7 @@ function normalizeSymbols(value, path) {
|
|
|
3054
4453
|
id: `symbol:${path}#${name}`,
|
|
3055
4454
|
kind: "symbol",
|
|
3056
4455
|
name,
|
|
3057
|
-
exported:
|
|
3058
|
-
summary: `Symbol named ${name}.`
|
|
4456
|
+
exported: exportedNames.has(name)
|
|
3059
4457
|
}];
|
|
3060
4458
|
}
|
|
3061
4459
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
|
@@ -3063,14 +4461,36 @@ function normalizeSymbols(value, path) {
|
|
|
3063
4461
|
const rawId = typeof record.id === "string" && record.id.startsWith("symbol:") ? record.id : void 0;
|
|
3064
4462
|
const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : rawId?.slice(rawId.lastIndexOf("#") + 1);
|
|
3065
4463
|
if (!name || !path) return [];
|
|
3066
|
-
|
|
4464
|
+
const summary = typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary.trim() : "";
|
|
4465
|
+
return [removeUndefinedValues({
|
|
3067
4466
|
id: rawId ?? `symbol:${path}#${name}`,
|
|
3068
|
-
kind: typeof record.kind === "string" && record.kind.trim().length > 0 ? record.kind : "symbol",
|
|
4467
|
+
kind: typeof record.kind === "string" && record.kind.trim().length > 0 ? record.kind.trim() : "symbol",
|
|
3069
4468
|
name,
|
|
3070
|
-
exported: typeof record.exported === "boolean" ? record.exported : false,
|
|
3071
|
-
summary:
|
|
3072
|
-
}];
|
|
3073
|
-
});
|
|
4469
|
+
exported: exportedNames.has(name) || (typeof record.exported === "boolean" ? record.exported : false),
|
|
4470
|
+
summary: summary && !isGenericSymbolSummary$1(summary, name) ? summary : void 0
|
|
4471
|
+
})];
|
|
4472
|
+
}), (item) => String(item.id));
|
|
4473
|
+
}
|
|
4474
|
+
function dedupeStrings(values) {
|
|
4475
|
+
return [...new Set(values.filter((value) => value.length > 0))];
|
|
4476
|
+
}
|
|
4477
|
+
function dedupeRecords(values, keyFor) {
|
|
4478
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4479
|
+
const deduped = [];
|
|
4480
|
+
for (const value of values) {
|
|
4481
|
+
const key = keyFor(value);
|
|
4482
|
+
if (seen.has(key)) continue;
|
|
4483
|
+
seen.add(key);
|
|
4484
|
+
deduped.push(value);
|
|
4485
|
+
}
|
|
4486
|
+
return deduped;
|
|
4487
|
+
}
|
|
4488
|
+
function removeUndefinedValues(record) {
|
|
4489
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
|
|
4490
|
+
}
|
|
4491
|
+
function isGenericSymbolSummary$1(summary, name) {
|
|
4492
|
+
const normalizedSummary = summary.trim().replace(/\s+/g, " ");
|
|
4493
|
+
return normalizedSummary === `Symbol named ${name}.` || normalizedSummary === `Symbol named ${name}` || normalizedSummary === name;
|
|
3074
4494
|
}
|
|
3075
4495
|
function extractTopLevelJsonObjects(text) {
|
|
3076
4496
|
const objects = [];
|
|
@@ -3472,11 +4892,11 @@ async function getL1SyncStatus(kbPath, kbReady, file) {
|
|
|
3472
4892
|
if (entry.path !== file.path || entry.size_bytes !== file.sizeBytes || entry.content_hash !== file.hash) return "changed";
|
|
3473
4893
|
return entry.scan_status;
|
|
3474
4894
|
} catch (error) {
|
|
3475
|
-
if (isFileNotFoundError$
|
|
4895
|
+
if (isFileNotFoundError$2(error)) return "missing_entry";
|
|
3476
4896
|
return "invalid";
|
|
3477
4897
|
}
|
|
3478
4898
|
}
|
|
3479
|
-
function isFileNotFoundError$
|
|
4899
|
+
function isFileNotFoundError$2(error) {
|
|
3480
4900
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
3481
4901
|
}
|
|
3482
4902
|
function assertKbSummarizeModelConfigured(model) {
|
|
@@ -3589,6 +5009,625 @@ function assertSafeResetPath(workspaceRoot, path) {
|
|
|
3589
5009
|
function isNodeError(error) {
|
|
3590
5010
|
return error instanceof Error && "code" in error;
|
|
3591
5011
|
}
|
|
5012
|
+
//#endregion
|
|
5013
|
+
//#region src/knowledge/search.ts
|
|
5014
|
+
var L1InMemoryIndex = class {
|
|
5015
|
+
entriesById = /* @__PURE__ */ new Map();
|
|
5016
|
+
postingsByToken = /* @__PURE__ */ new Map();
|
|
5017
|
+
prefixTokensByPrefix = /* @__PURE__ */ new Map();
|
|
5018
|
+
constructor(entries) {
|
|
5019
|
+
for (const entry of entries) {
|
|
5020
|
+
this.entriesById.set(entry.id, entry);
|
|
5021
|
+
this.indexEntry(entry);
|
|
5022
|
+
}
|
|
5023
|
+
this.indexPrefixTokens();
|
|
5024
|
+
}
|
|
5025
|
+
get size() {
|
|
5026
|
+
return this.entriesById.size;
|
|
5027
|
+
}
|
|
5028
|
+
search(query, options = {}) {
|
|
5029
|
+
const tokens = tokenizeQuery(query);
|
|
5030
|
+
const scoresByEntryId = /* @__PURE__ */ new Map();
|
|
5031
|
+
const reasonsByEntryId = /* @__PURE__ */ new Map();
|
|
5032
|
+
for (const token of tokens) {
|
|
5033
|
+
this.addMatches(token, 1, scoresByEntryId, reasonsByEntryId);
|
|
5034
|
+
if (token.length >= 4) this.addPrefixMatches(token, scoresByEntryId, reasonsByEntryId);
|
|
5035
|
+
}
|
|
5036
|
+
const limit = options.limit ?? 10;
|
|
5037
|
+
return [...scoresByEntryId.entries()].map(([entryId, score]) => {
|
|
5038
|
+
const entry = this.entriesById.get(entryId);
|
|
5039
|
+
if (!entry) return;
|
|
5040
|
+
return {
|
|
5041
|
+
id: entry.id,
|
|
5042
|
+
path: entry.path,
|
|
5043
|
+
score: Math.round(score * 100) / 100,
|
|
5044
|
+
summary: entry.summary,
|
|
5045
|
+
contentHash: entry.content_hash,
|
|
5046
|
+
scanStatus: entry.scan_status,
|
|
5047
|
+
reasons: [...(reasonsByEntryId.get(entryId) ?? /* @__PURE__ */ new Map()).entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([reason]) => reason).slice(0, 6)
|
|
5048
|
+
};
|
|
5049
|
+
}).filter((match) => Boolean(match)).sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, limit);
|
|
5050
|
+
}
|
|
5051
|
+
getEntry(id) {
|
|
5052
|
+
return this.entriesById.get(id);
|
|
5053
|
+
}
|
|
5054
|
+
indexEntry(entry) {
|
|
5055
|
+
this.addField(entry, "path", [entry.path, basename(entry.path)], 6);
|
|
5056
|
+
this.addField(entry, "symbol", entry.symbols.flatMap((symbol) => [
|
|
5057
|
+
symbol.name,
|
|
5058
|
+
symbol.kind,
|
|
5059
|
+
symbol.summary
|
|
5060
|
+
].filter(isString)), 10);
|
|
5061
|
+
this.addField(entry, "export", entry.exports, 9);
|
|
5062
|
+
this.addField(entry, "responsibility", entry.responsibilities, 6);
|
|
5063
|
+
this.addField(entry, "summary", [entry.summary], 5);
|
|
5064
|
+
this.addField(entry, "import", entry.imports, 4);
|
|
5065
|
+
this.addField(entry, "test", entry.test_ids, 4);
|
|
5066
|
+
this.addField(entry, "relationship", [...entry.module_ids, ...entry.feature_ids], 3);
|
|
5067
|
+
this.addField(entry, "evidence", entry.evidence.map((evidence) => evidence.value), 3);
|
|
5068
|
+
}
|
|
5069
|
+
addField(entry, field, values, weight) {
|
|
5070
|
+
const tokens = new Set(values.flatMap(tokenizeText));
|
|
5071
|
+
for (const token of tokens) {
|
|
5072
|
+
const postings = this.postingsByToken.get(token) ?? [];
|
|
5073
|
+
postings.push({
|
|
5074
|
+
entryId: entry.id,
|
|
5075
|
+
weight,
|
|
5076
|
+
reason: `${formatField(field)} matched ${token}`
|
|
5077
|
+
});
|
|
5078
|
+
this.postingsByToken.set(token, postings);
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5081
|
+
addMatches(token, multiplier, scoresByEntryId, reasonsByEntryId) {
|
|
5082
|
+
for (const posting of this.postingsByToken.get(token) ?? []) {
|
|
5083
|
+
scoresByEntryId.set(posting.entryId, (scoresByEntryId.get(posting.entryId) ?? 0) + posting.weight * multiplier);
|
|
5084
|
+
const reasons = reasonsByEntryId.get(posting.entryId) ?? /* @__PURE__ */ new Map();
|
|
5085
|
+
reasons.set(posting.reason, Math.max(reasons.get(posting.reason) ?? 0, posting.weight * multiplier));
|
|
5086
|
+
reasonsByEntryId.set(posting.entryId, reasons);
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
addPrefixMatches(token, scoresByEntryId, reasonsByEntryId) {
|
|
5090
|
+
const matchedTokens = /* @__PURE__ */ new Set();
|
|
5091
|
+
for (const indexedToken of this.prefixTokensByPrefix.get(token) ?? []) matchedTokens.add(indexedToken);
|
|
5092
|
+
for (let prefixLength = 1; prefixLength < token.length; prefixLength += 1) {
|
|
5093
|
+
const prefix = token.slice(0, prefixLength);
|
|
5094
|
+
if (this.postingsByToken.has(prefix)) matchedTokens.add(prefix);
|
|
5095
|
+
}
|
|
5096
|
+
for (const indexedToken of matchedTokens) {
|
|
5097
|
+
if (indexedToken === token) continue;
|
|
5098
|
+
this.addMatches(indexedToken, .6, scoresByEntryId, reasonsByEntryId);
|
|
5099
|
+
}
|
|
5100
|
+
}
|
|
5101
|
+
indexPrefixTokens() {
|
|
5102
|
+
for (const indexedToken of this.postingsByToken.keys()) for (let prefixLength = 4; prefixLength < indexedToken.length; prefixLength += 1) {
|
|
5103
|
+
const prefix = indexedToken.slice(0, prefixLength);
|
|
5104
|
+
const tokens = this.prefixTokensByPrefix.get(prefix) ?? [];
|
|
5105
|
+
tokens.push(indexedToken);
|
|
5106
|
+
this.prefixTokensByPrefix.set(prefix, tokens);
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
5109
|
+
};
|
|
5110
|
+
function buildL1InMemoryIndex(entries) {
|
|
5111
|
+
return new L1InMemoryIndex(entries);
|
|
5112
|
+
}
|
|
5113
|
+
async function searchL1Knowledge(workspaceRoot, query, options = {}) {
|
|
5114
|
+
const status = getKnowledgeStatus(workspaceRoot);
|
|
5115
|
+
if (!status.kbExists || !status.kbIsDirectory) throw new Error("Run `topchester kb init` and `topchester kb compile` before searching the knowledge base.");
|
|
5116
|
+
const loadResult = await loadL1FileEntries(status.kbPath);
|
|
5117
|
+
const index = buildL1InMemoryIndex(loadResult.entries);
|
|
5118
|
+
return {
|
|
5119
|
+
workspaceRoot,
|
|
5120
|
+
kbPath: status.kbPath,
|
|
5121
|
+
query,
|
|
5122
|
+
entryCount: index.size,
|
|
5123
|
+
invalidEntryCount: loadResult.invalidEntryCount,
|
|
5124
|
+
matches: index.search(query, options)
|
|
5125
|
+
};
|
|
5126
|
+
}
|
|
5127
|
+
function createL1ContextPackFromIndex(source, query, options = {}) {
|
|
5128
|
+
const limit = options.limit ?? 8;
|
|
5129
|
+
const minScore = options.minScore ?? 12;
|
|
5130
|
+
const relevantFiles = source.index.search(query, { limit: Math.max(limit * 3, limit) }).filter((match) => match.score >= minScore).slice(0, limit).map((match) => {
|
|
5131
|
+
const entry = source.index.getEntry(match.id);
|
|
5132
|
+
if (!entry) return;
|
|
5133
|
+
return {
|
|
5134
|
+
id: match.id,
|
|
5135
|
+
path: match.path,
|
|
5136
|
+
score: match.score,
|
|
5137
|
+
reasons: match.reasons,
|
|
5138
|
+
contentHash: match.contentHash,
|
|
5139
|
+
scanStatus: match.scanStatus,
|
|
5140
|
+
l1: compactL1Entry(entry),
|
|
5141
|
+
fullL1: options.includeFullL1 ? entry : void 0
|
|
5142
|
+
};
|
|
5143
|
+
}).filter((file) => Boolean(file));
|
|
5144
|
+
const warnings = relevantFiles.length === 0 ? ["No L1 entries met the context pack score threshold."] : [];
|
|
5145
|
+
return {
|
|
5146
|
+
workspaceRoot: source.workspaceRoot,
|
|
5147
|
+
kbPath: source.kbPath,
|
|
5148
|
+
query,
|
|
5149
|
+
entryCount: source.index.size,
|
|
5150
|
+
invalidEntryCount: source.invalidEntryCount,
|
|
5151
|
+
selection: {
|
|
5152
|
+
limit,
|
|
5153
|
+
minScore
|
|
5154
|
+
},
|
|
5155
|
+
drift: {
|
|
5156
|
+
status: "unchecked",
|
|
5157
|
+
warnings: ["L1 context pack includes stored scan statuses; exact file-hash drift check has not run yet."]
|
|
5158
|
+
},
|
|
5159
|
+
summary: summarizeContextPack(query, relevantFiles),
|
|
5160
|
+
warnings,
|
|
5161
|
+
relevantFiles
|
|
5162
|
+
};
|
|
5163
|
+
}
|
|
5164
|
+
async function createL1ContextPack(workspaceRoot, query, options = {}) {
|
|
5165
|
+
const limit = options.limit ?? 8;
|
|
5166
|
+
const minScore = options.minScore ?? 12;
|
|
5167
|
+
const status = getKnowledgeStatus(workspaceRoot);
|
|
5168
|
+
if (!status.kbExists || !status.kbIsDirectory) throw new Error("Run `topchester kb init` and `topchester kb compile` before creating a context pack.");
|
|
5169
|
+
const loadResult = await loadL1FileEntries(status.kbPath);
|
|
5170
|
+
const index = buildL1InMemoryIndex(loadResult.entries);
|
|
5171
|
+
return createL1ContextPackFromIndex({
|
|
5172
|
+
workspaceRoot,
|
|
5173
|
+
kbPath: status.kbPath,
|
|
5174
|
+
index,
|
|
5175
|
+
invalidEntryCount: loadResult.invalidEntryCount
|
|
5176
|
+
}, query, {
|
|
5177
|
+
limit,
|
|
5178
|
+
minScore,
|
|
5179
|
+
includeFullL1: options.includeFullL1
|
|
5180
|
+
});
|
|
5181
|
+
}
|
|
5182
|
+
function formatL1KnowledgeSearchResult(result) {
|
|
5183
|
+
return [
|
|
5184
|
+
"KB search",
|
|
5185
|
+
`workspace: ${result.workspaceRoot}`,
|
|
5186
|
+
`knowledge folder: ${result.kbPath} [ok]`,
|
|
5187
|
+
`query: ${result.query}`,
|
|
5188
|
+
`entries indexed: ${result.entryCount}`,
|
|
5189
|
+
`invalid L1 entries skipped: ${result.invalidEntryCount}`,
|
|
5190
|
+
`matches: ${result.matches.length}`,
|
|
5191
|
+
...result.matches.length === 0 ? ["state: no L1 matches found"] : [""],
|
|
5192
|
+
...result.matches.flatMap((match) => [
|
|
5193
|
+
`${match.score}\t${match.path}\t${match.scanStatus}\t${match.contentHash}`,
|
|
5194
|
+
` reasons: ${match.reasons.join("; ") || "score match"}`,
|
|
5195
|
+
` summary: ${match.summary}`
|
|
5196
|
+
]),
|
|
5197
|
+
"----",
|
|
5198
|
+
`total matches: ${result.matches.length}`
|
|
5199
|
+
];
|
|
5200
|
+
}
|
|
5201
|
+
function formatL1ContextPackResult(result) {
|
|
5202
|
+
return [
|
|
5203
|
+
"KB context",
|
|
5204
|
+
`workspace: ${result.workspaceRoot}`,
|
|
5205
|
+
`knowledge folder: ${result.kbPath} [ok]`,
|
|
5206
|
+
`query: ${result.query}`,
|
|
5207
|
+
`entries indexed: ${result.entryCount}`,
|
|
5208
|
+
`invalid L1 entries skipped: ${result.invalidEntryCount}`,
|
|
5209
|
+
`selection: top ${result.selection.limit}, min score ${result.selection.minScore}`,
|
|
5210
|
+
`drift: ${result.drift.status}`,
|
|
5211
|
+
`relevant files: ${result.relevantFiles.length}`,
|
|
5212
|
+
`summary: ${result.summary}`,
|
|
5213
|
+
...result.warnings.map((warning) => `warning: ${warning}`),
|
|
5214
|
+
"",
|
|
5215
|
+
...result.relevantFiles.flatMap((file) => [
|
|
5216
|
+
`${file.score}\t${file.path}\t${file.scanStatus}\t${file.contentHash}`,
|
|
5217
|
+
` reasons: ${file.reasons.join("; ") || "score match"}`,
|
|
5218
|
+
` responsibilities: ${(file.l1.responsibilities ?? []).join("; ") || "(none)"}`,
|
|
5219
|
+
` symbols: ${(file.l1.symbols ?? []).map((symbol) => symbol.name).join(", ") || "(none)"}`,
|
|
5220
|
+
` imports: ${(file.l1.imports ?? []).join(", ") || "(none)"}`,
|
|
5221
|
+
` exports: ${(file.l1.exports ?? []).join(", ") || "(none)"}`,
|
|
5222
|
+
` tests: ${(file.l1.test_ids ?? []).join(", ") || "(none)"}`
|
|
5223
|
+
]),
|
|
5224
|
+
"----",
|
|
5225
|
+
`total relevant files: ${result.relevantFiles.length}`
|
|
5226
|
+
];
|
|
5227
|
+
}
|
|
5228
|
+
function formatL1ContextPackForPrompt(result) {
|
|
5229
|
+
return [
|
|
5230
|
+
"Topchester KB context pack:",
|
|
5231
|
+
"Use this as orientation only. For task-critical facts, read current source files before editing or making exact claims.",
|
|
5232
|
+
"## -- kb summary start",
|
|
5233
|
+
"```json",
|
|
5234
|
+
JSON.stringify(stripEmptyContainers({
|
|
5235
|
+
query: result.query,
|
|
5236
|
+
summary: result.summary,
|
|
5237
|
+
drift: result.drift,
|
|
5238
|
+
warnings: result.warnings,
|
|
5239
|
+
relevantFiles: result.relevantFiles.map((file) => ({
|
|
5240
|
+
id: file.id,
|
|
5241
|
+
path: file.path,
|
|
5242
|
+
score: file.score,
|
|
5243
|
+
reasons: file.reasons,
|
|
5244
|
+
contentHash: file.contentHash,
|
|
5245
|
+
scanStatus: file.scanStatus,
|
|
5246
|
+
l1: {
|
|
5247
|
+
summary: file.l1.summary,
|
|
5248
|
+
file_role: file.l1.file_role,
|
|
5249
|
+
responsibilities: file.l1.responsibilities,
|
|
5250
|
+
symbols: file.l1.symbols,
|
|
5251
|
+
imports: file.l1.imports,
|
|
5252
|
+
exports: file.l1.exports,
|
|
5253
|
+
module_ids: file.l1.module_ids,
|
|
5254
|
+
feature_ids: file.l1.feature_ids,
|
|
5255
|
+
test_ids: file.l1.test_ids,
|
|
5256
|
+
declared_test_targets: file.l1.declared_test_targets,
|
|
5257
|
+
likely_test_targets: file.l1.likely_test_targets,
|
|
5258
|
+
tested_by: file.l1.tested_by,
|
|
5259
|
+
confidence: file.l1.confidence
|
|
5260
|
+
}
|
|
5261
|
+
}))
|
|
5262
|
+
})),
|
|
5263
|
+
"```",
|
|
5264
|
+
"## -- kb summary end"
|
|
5265
|
+
].join("\n");
|
|
5266
|
+
}
|
|
5267
|
+
async function loadL1FileEntries(kbPath) {
|
|
5268
|
+
const entryPaths = await listJsonFiles(join(kbPath, "l1-files")).catch((error) => {
|
|
5269
|
+
if (isFileNotFoundError$1(error)) return [];
|
|
5270
|
+
throw error;
|
|
5271
|
+
});
|
|
5272
|
+
const parsedEntries = await Promise.all(entryPaths.map(loadL1FileEntry));
|
|
5273
|
+
const entries = parsedEntries.filter((entry) => Boolean(entry));
|
|
5274
|
+
return {
|
|
5275
|
+
entries,
|
|
5276
|
+
invalidEntryCount: parsedEntries.length - entries.length
|
|
5277
|
+
};
|
|
5278
|
+
}
|
|
5279
|
+
async function loadL1FileEntry(entryPath) {
|
|
5280
|
+
try {
|
|
5281
|
+
return parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")));
|
|
5282
|
+
} catch {
|
|
5283
|
+
return;
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
function summarizeContextPack(query, files) {
|
|
5287
|
+
if (files.length === 0) return `No strong L1 matches were found for "${query}".`;
|
|
5288
|
+
const paths = files.slice(0, 5).map((file) => file.path);
|
|
5289
|
+
return `Likely relevant L1 files for "${query}": ${paths.join(", ")}${files.length > paths.length ? ", ..." : ""}.`;
|
|
5290
|
+
}
|
|
5291
|
+
function compactL1Entry(entry) {
|
|
5292
|
+
const responsibilities = take(entry.responsibilities, 5);
|
|
5293
|
+
const symbols = take(entry.symbols, 12).map(compactSymbol);
|
|
5294
|
+
const imports = take(entry.imports, 20);
|
|
5295
|
+
const exports = take(entry.exports, 20);
|
|
5296
|
+
const moduleIds = take(entry.module_ids, 10);
|
|
5297
|
+
const featureIds = take(entry.feature_ids, 10);
|
|
5298
|
+
const testIds = take(entry.test_ids, 10);
|
|
5299
|
+
const declaredTestTargets = take(entry.declared_test_targets, 10);
|
|
5300
|
+
const likelyTestTargets = take(entry.likely_test_targets, 10);
|
|
5301
|
+
const testedBy = take(entry.tested_by, 10);
|
|
5302
|
+
return stripUndefinedProperties({
|
|
5303
|
+
file_role: entry.file_role,
|
|
5304
|
+
summary: entry.summary,
|
|
5305
|
+
responsibilities: nonEmptyArray(responsibilities),
|
|
5306
|
+
symbols: nonEmptyArray(symbols),
|
|
5307
|
+
imports: nonEmptyArray(imports),
|
|
5308
|
+
exports: nonEmptyArray(exports),
|
|
5309
|
+
module_ids: nonEmptyArray(moduleIds),
|
|
5310
|
+
feature_ids: nonEmptyArray(featureIds),
|
|
5311
|
+
test_ids: nonEmptyArray(testIds),
|
|
5312
|
+
declared_test_targets: nonEmptyArray(declaredTestTargets),
|
|
5313
|
+
likely_test_targets: nonEmptyArray(likelyTestTargets),
|
|
5314
|
+
tested_by: nonEmptyArray(testedBy),
|
|
5315
|
+
confidence: entry.confidence
|
|
5316
|
+
});
|
|
5317
|
+
}
|
|
5318
|
+
function take(items, count) {
|
|
5319
|
+
return items.slice(0, count);
|
|
5320
|
+
}
|
|
5321
|
+
function compactSymbol(symbol) {
|
|
5322
|
+
const compacted = {
|
|
5323
|
+
name: symbol.name,
|
|
5324
|
+
exported: symbol.exported,
|
|
5325
|
+
kind: symbol.kind === "symbol" ? void 0 : symbol.kind,
|
|
5326
|
+
summary: symbol.summary && !isGenericSymbolSummary(symbol.summary, symbol.name) ? symbol.summary : void 0
|
|
5327
|
+
};
|
|
5328
|
+
return compacted.kind || compacted.summary ? compacted : {
|
|
5329
|
+
name: compacted.name,
|
|
5330
|
+
exported: compacted.exported
|
|
5331
|
+
};
|
|
5332
|
+
}
|
|
5333
|
+
function stripEmptyContainers(value) {
|
|
5334
|
+
if (Array.isArray(value)) {
|
|
5335
|
+
const stripped = value.map(stripEmptyContainers).filter((item) => item !== void 0);
|
|
5336
|
+
return stripped.length > 0 ? stripped : void 0;
|
|
5337
|
+
}
|
|
5338
|
+
if (value && typeof value === "object") {
|
|
5339
|
+
const entries = Object.entries(value).map(([key, item]) => [key, stripEmptyContainers(item)]).filter(([, item]) => item !== void 0);
|
|
5340
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
5341
|
+
}
|
|
5342
|
+
return value;
|
|
5343
|
+
}
|
|
5344
|
+
function stripUndefinedProperties(value) {
|
|
5345
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
|
|
5346
|
+
}
|
|
5347
|
+
function nonEmptyArray(items) {
|
|
5348
|
+
return items.length > 0 ? items : void 0;
|
|
5349
|
+
}
|
|
5350
|
+
function isGenericSymbolSummary(summary, name) {
|
|
5351
|
+
const normalizedSummary = summary.trim().replace(/\s+/g, " ");
|
|
5352
|
+
return normalizedSummary === `Symbol named ${name}.` || normalizedSummary === `Symbol named ${name}` || normalizedSummary === name;
|
|
5353
|
+
}
|
|
5354
|
+
async function listJsonFiles(directory) {
|
|
5355
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
5356
|
+
const files = [];
|
|
5357
|
+
for (const entry of entries) {
|
|
5358
|
+
const entryPath = join(directory, entry.name);
|
|
5359
|
+
if (entry.isDirectory()) files.push(...await listJsonFiles(entryPath));
|
|
5360
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) files.push(entryPath);
|
|
5361
|
+
}
|
|
5362
|
+
return files.sort();
|
|
5363
|
+
}
|
|
5364
|
+
function tokenizeQuery(text) {
|
|
5365
|
+
return [...new Set(tokenizeText(text).filter((token) => !queryStopWords.has(token)))];
|
|
5366
|
+
}
|
|
5367
|
+
function tokenizeText(text) {
|
|
5368
|
+
const rawTokens = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().match(/[a-z0-9_]+/g) ?? [];
|
|
5369
|
+
const tokens = [];
|
|
5370
|
+
for (const rawToken of rawTokens) {
|
|
5371
|
+
const token = rawToken.replace(/^_+|_+$/g, "");
|
|
5372
|
+
if (!token || indexStopWords.has(token)) continue;
|
|
5373
|
+
tokens.push(token);
|
|
5374
|
+
const singular = singularizeToken(token);
|
|
5375
|
+
if (singular !== token) tokens.push(singular);
|
|
5376
|
+
}
|
|
5377
|
+
return tokens;
|
|
5378
|
+
}
|
|
5379
|
+
function singularizeToken(token) {
|
|
5380
|
+
if (token.length > 3 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
5381
|
+
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss") && !token.endsWith("us")) return token.slice(0, -1);
|
|
5382
|
+
return token;
|
|
5383
|
+
}
|
|
5384
|
+
function formatField(field) {
|
|
5385
|
+
switch (field) {
|
|
5386
|
+
case "path": return "path";
|
|
5387
|
+
case "symbol": return "symbol";
|
|
5388
|
+
case "export": return "export";
|
|
5389
|
+
case "responsibility": return "responsibility";
|
|
5390
|
+
case "summary": return "summary";
|
|
5391
|
+
case "import": return "import";
|
|
5392
|
+
case "test": return "test";
|
|
5393
|
+
case "relationship": return "relationship";
|
|
5394
|
+
case "evidence": return "evidence";
|
|
5395
|
+
}
|
|
5396
|
+
}
|
|
5397
|
+
function isFileNotFoundError$1(error) {
|
|
5398
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
5399
|
+
}
|
|
5400
|
+
function isString(value) {
|
|
5401
|
+
return typeof value === "string";
|
|
5402
|
+
}
|
|
5403
|
+
const indexStopWords = new Set([
|
|
5404
|
+
"a",
|
|
5405
|
+
"an",
|
|
5406
|
+
"and",
|
|
5407
|
+
"are",
|
|
5408
|
+
"as",
|
|
5409
|
+
"at",
|
|
5410
|
+
"be",
|
|
5411
|
+
"by",
|
|
5412
|
+
"for",
|
|
5413
|
+
"from",
|
|
5414
|
+
"in",
|
|
5415
|
+
"is",
|
|
5416
|
+
"it",
|
|
5417
|
+
"of",
|
|
5418
|
+
"on",
|
|
5419
|
+
"or",
|
|
5420
|
+
"the",
|
|
5421
|
+
"to",
|
|
5422
|
+
"with"
|
|
5423
|
+
]);
|
|
5424
|
+
const queryStopWords = new Set([
|
|
5425
|
+
...indexStopWords,
|
|
5426
|
+
"error",
|
|
5427
|
+
"here",
|
|
5428
|
+
"log",
|
|
5429
|
+
"see",
|
|
5430
|
+
"se",
|
|
5431
|
+
"tries",
|
|
5432
|
+
"trying",
|
|
5433
|
+
"user",
|
|
5434
|
+
"when"
|
|
5435
|
+
]);
|
|
5436
|
+
//#endregion
|
|
5437
|
+
//#region src/tui/markdown.ts
|
|
5438
|
+
const codeFenceSentinel = "topchester-code-fence";
|
|
5439
|
+
function renderMarkdown(text, width) {
|
|
5440
|
+
const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
|
|
5441
|
+
const rendered = [];
|
|
5442
|
+
let inCodeBlock = false;
|
|
5443
|
+
for (const line of lines) {
|
|
5444
|
+
if (line.includes(codeFenceSentinel)) {
|
|
5445
|
+
inCodeBlock = !inCodeBlock;
|
|
5446
|
+
continue;
|
|
5447
|
+
}
|
|
5448
|
+
rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
|
|
5449
|
+
}
|
|
5450
|
+
return rendered;
|
|
5451
|
+
}
|
|
5452
|
+
function unwrapMarkdownCodeFences(text) {
|
|
5453
|
+
return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
|
|
5454
|
+
}
|
|
5455
|
+
function getMarkdownTheme() {
|
|
5456
|
+
return {
|
|
5457
|
+
heading: ui.label,
|
|
5458
|
+
link: ui.label,
|
|
5459
|
+
linkUrl: ui.muted,
|
|
5460
|
+
code: ui.ok,
|
|
5461
|
+
codeBlock: (text) => text,
|
|
5462
|
+
codeBlockBorder: (text) => `${codeFenceSentinel}${ui.muted(text)}`,
|
|
5463
|
+
quote: ui.warn,
|
|
5464
|
+
quoteBorder: ui.warn,
|
|
5465
|
+
hr: ui.muted,
|
|
5466
|
+
listBullet: ui.label,
|
|
5467
|
+
bold: (text) => decorate(text, "\x1B[1m", "\x1B[22m"),
|
|
5468
|
+
italic: (text) => decorate(text, "\x1B[3m", "\x1B[23m"),
|
|
5469
|
+
strikethrough: (text) => decorate(text, "\x1B[9m", "\x1B[29m"),
|
|
5470
|
+
underline: (text) => decorate(text, "\x1B[4m", "\x1B[24m"),
|
|
5471
|
+
codeBlockIndent: " ",
|
|
5472
|
+
highlightCode(code, lang) {
|
|
5473
|
+
const validLanguage = lang && supportsLanguage(lang) ? lang : void 0;
|
|
5474
|
+
if (!validLanguage) return code.split("\n");
|
|
5475
|
+
try {
|
|
5476
|
+
return highlight(code, {
|
|
5477
|
+
language: validLanguage,
|
|
5478
|
+
ignoreIllegals: true
|
|
5479
|
+
}).split("\n");
|
|
5480
|
+
} catch {
|
|
5481
|
+
return code.split("\n");
|
|
5482
|
+
}
|
|
5483
|
+
}
|
|
5484
|
+
};
|
|
5485
|
+
}
|
|
5486
|
+
function decorate(text, open, close) {
|
|
5487
|
+
if (!shouldUseAnsi()) return text;
|
|
5488
|
+
return `${open}${text}${close}`;
|
|
5489
|
+
}
|
|
5490
|
+
function applyCodeBlockBackground(line) {
|
|
5491
|
+
if (!shouldUseAnsi()) return line;
|
|
5492
|
+
const background = "\x1B[48;5;235m";
|
|
5493
|
+
const reset = "\x1B[0m";
|
|
5494
|
+
return `${background}${line.split(reset).join(`${reset}${background}`)}${reset}`;
|
|
5495
|
+
}
|
|
5496
|
+
function shouldUseAnsi() {
|
|
5497
|
+
if (process.env.NO_COLOR) return false;
|
|
5498
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
5499
|
+
return stdout.isTTY === true;
|
|
5500
|
+
}
|
|
5501
|
+
//#endregion
|
|
5502
|
+
//#region src/tui/messages.ts
|
|
5503
|
+
function systemMessage(text) {
|
|
5504
|
+
return {
|
|
5505
|
+
kind: "system",
|
|
5506
|
+
text
|
|
5507
|
+
};
|
|
5508
|
+
}
|
|
5509
|
+
function userMessage(text) {
|
|
5510
|
+
return {
|
|
5511
|
+
kind: "user",
|
|
5512
|
+
text
|
|
5513
|
+
};
|
|
5514
|
+
}
|
|
5515
|
+
function agentMessage(text, meta) {
|
|
5516
|
+
return {
|
|
5517
|
+
kind: "agent",
|
|
5518
|
+
text,
|
|
5519
|
+
meta
|
|
5520
|
+
};
|
|
5521
|
+
}
|
|
5522
|
+
function toolCallMessage(call, label, resultSummary) {
|
|
5523
|
+
return resultSummary === void 0 ? {
|
|
5524
|
+
kind: "tool_call",
|
|
5525
|
+
call,
|
|
5526
|
+
label
|
|
5527
|
+
} : {
|
|
5528
|
+
kind: "tool_call",
|
|
5529
|
+
call,
|
|
5530
|
+
label,
|
|
5531
|
+
resultSummary
|
|
5532
|
+
};
|
|
5533
|
+
}
|
|
5534
|
+
function modalMessage(message) {
|
|
5535
|
+
return {
|
|
5536
|
+
kind: "modal",
|
|
5537
|
+
...message
|
|
5538
|
+
};
|
|
5539
|
+
}
|
|
5540
|
+
function renderChatMessage(message, options = {}) {
|
|
5541
|
+
if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
|
|
5542
|
+
if (message.kind === "tool_call") return renderToolCallMessage(message);
|
|
5543
|
+
if (message.text.length === 0) return [""];
|
|
5544
|
+
const lines = message.kind === "agent" && options.width !== void 0 ? renderMarkdown(message.text, Math.max(1, options.width - getPrefix(message.kind).length)) : message.text.split("\n");
|
|
5545
|
+
if (message.kind === "user") return renderUserMessage(lines);
|
|
5546
|
+
if (message.kind === "system") return renderSystemMessage(lines);
|
|
5547
|
+
const prefix = getPrefix(message.kind);
|
|
5548
|
+
const rendered = prefix.length === 0 ? lines : lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`);
|
|
5549
|
+
if (message.meta) {
|
|
5550
|
+
const metaText = `↳ ${message.meta}`;
|
|
5551
|
+
rendered.push(` ${ui.label("─".repeat(metaText.length))}`, ` ${ui.label(metaText)}`);
|
|
5552
|
+
}
|
|
5553
|
+
return rendered;
|
|
5554
|
+
}
|
|
5555
|
+
function renderUserMessage(lines) {
|
|
5556
|
+
const border = "▌";
|
|
5557
|
+
const rendered = lines.map((line) => `${border} ${line}`);
|
|
5558
|
+
return [
|
|
5559
|
+
`${border} `,
|
|
5560
|
+
...rendered,
|
|
5561
|
+
`${border} `
|
|
5562
|
+
];
|
|
5563
|
+
}
|
|
5564
|
+
function renderSystemMessage(lines) {
|
|
5565
|
+
const bodyPrefix = " ";
|
|
5566
|
+
return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
|
|
5567
|
+
}
|
|
5568
|
+
function formatSystemBodyLine(line) {
|
|
5569
|
+
return expandTabs(line).replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary)).replace(/\(created \+\d+\)$/u, (summary) => ui.muted(summary));
|
|
5570
|
+
}
|
|
5571
|
+
function renderToolCallMessage(message) {
|
|
5572
|
+
const visibleLabel = message.resultSummary && !message.label.includes(message.resultSummary) ? `${message.label} ${message.resultSummary}` : message.label;
|
|
5573
|
+
return [` ${ui.muted(expandTabs(visibleLabel))}`];
|
|
5574
|
+
}
|
|
5575
|
+
function expandTabs(line) {
|
|
5576
|
+
let column = 0;
|
|
5577
|
+
let expanded = "";
|
|
5578
|
+
for (const char of line) {
|
|
5579
|
+
if (char === " ") {
|
|
5580
|
+
const spaces = 4 - column % 4;
|
|
5581
|
+
expanded += " ".repeat(spaces);
|
|
5582
|
+
column += spaces;
|
|
5583
|
+
continue;
|
|
5584
|
+
}
|
|
5585
|
+
expanded += char;
|
|
5586
|
+
column += 1;
|
|
5587
|
+
}
|
|
5588
|
+
return expanded;
|
|
5589
|
+
}
|
|
5590
|
+
function getPrefix(kind) {
|
|
5591
|
+
switch (kind) {
|
|
5592
|
+
case "agent": return " ";
|
|
5593
|
+
case "user": return `${ui.label("You")}: `;
|
|
5594
|
+
case "system": return `${ui.label("System")}: `;
|
|
5595
|
+
}
|
|
5596
|
+
}
|
|
5597
|
+
function renderChatModal(message, selectedActionIndex) {
|
|
5598
|
+
const icon = message.tone === "warning" ? "⚠️" : "ℹ️";
|
|
5599
|
+
const title = message.tone === "warning" ? ui.warn(message.title) : ui.label(message.title);
|
|
5600
|
+
const bodyLines = message.body ? ["", ...message.body.split("\n")] : [];
|
|
5601
|
+
const actionLines = message.actions.map((action, index) => {
|
|
5602
|
+
return `${selectedActionIndex === index ? ">" : " "} ${index + 1}) ${action.label}`;
|
|
5603
|
+
});
|
|
5604
|
+
const contentLines = [
|
|
5605
|
+
`${icon} ${title}:`,
|
|
5606
|
+
...bodyLines,
|
|
5607
|
+
"",
|
|
5608
|
+
...actionLines
|
|
5609
|
+
];
|
|
5610
|
+
const contentWidth = Math.max(...contentLines.map(stripAnsi$1).map((line) => line.length), 1);
|
|
5611
|
+
const top = `╭${"─".repeat(contentWidth + 2)}╮`;
|
|
5612
|
+
const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
|
|
5613
|
+
return [
|
|
5614
|
+
top,
|
|
5615
|
+
...contentLines.map((line) => `│ ${line}${" ".repeat(contentWidth - stripAnsi$1(line).length)} │`),
|
|
5616
|
+
bottom
|
|
5617
|
+
];
|
|
5618
|
+
}
|
|
5619
|
+
function stripAnsi$1(text) {
|
|
5620
|
+
let plain = "";
|
|
5621
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
5622
|
+
if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
|
|
5623
|
+
index += 2;
|
|
5624
|
+
while (index < text.length && text[index] !== "m") index += 1;
|
|
5625
|
+
continue;
|
|
5626
|
+
}
|
|
5627
|
+
plain += text[index];
|
|
5628
|
+
}
|
|
5629
|
+
return plain;
|
|
5630
|
+
}
|
|
3592
5631
|
const isoTimestampSchema = z.string().datetime({ offset: true });
|
|
3593
5632
|
const jsonValueSchema = z.lazy(() => z.union([
|
|
3594
5633
|
z.string(),
|
|
@@ -3626,6 +5665,19 @@ const toolCallPayloadSchema = z.object({
|
|
|
3626
5665
|
label: z.string(),
|
|
3627
5666
|
call: z.record(z.string(), jsonValueSchema)
|
|
3628
5667
|
});
|
|
5668
|
+
const taskPlanItemPayloadSchema = z.object({
|
|
5669
|
+
text: z.string(),
|
|
5670
|
+
status: z.enum([
|
|
5671
|
+
"pending",
|
|
5672
|
+
"in_progress",
|
|
5673
|
+
"completed"
|
|
5674
|
+
])
|
|
5675
|
+
});
|
|
5676
|
+
const taskPlanPayloadSchema = z.object({
|
|
5677
|
+
kind: z.literal("task_plan"),
|
|
5678
|
+
items: z.array(taskPlanItemPayloadSchema),
|
|
5679
|
+
updatedAt: isoTimestampSchema
|
|
5680
|
+
});
|
|
3629
5681
|
const statusPayloadSchema = z.object({
|
|
3630
5682
|
kind: z.literal("status"),
|
|
3631
5683
|
status: z.string()
|
|
@@ -3647,6 +5699,7 @@ const choicePayloadSchema = z.object({
|
|
|
3647
5699
|
const sessionEventPayloadSchema = z.discriminatedUnion("kind", [
|
|
3648
5700
|
messagePayloadSchema,
|
|
3649
5701
|
toolCallPayloadSchema,
|
|
5702
|
+
taskPlanPayloadSchema,
|
|
3650
5703
|
statusPayloadSchema,
|
|
3651
5704
|
knowledgeStatusPayloadSchema,
|
|
3652
5705
|
choicePayloadSchema
|
|
@@ -3727,6 +5780,7 @@ async function resolveLatestSessionId(workspaceRoot) {
|
|
|
3727
5780
|
function rehydrateSession(events) {
|
|
3728
5781
|
const messages = [];
|
|
3729
5782
|
let status;
|
|
5783
|
+
let taskPlan;
|
|
3730
5784
|
let visibleOnlyActionValues = /* @__PURE__ */ new Set();
|
|
3731
5785
|
for (const event of events) switch (event.kind) {
|
|
3732
5786
|
case "message":
|
|
@@ -3740,10 +5794,13 @@ function rehydrateSession(events) {
|
|
|
3740
5794
|
if (event.role === "user") visibleOnlyActionValues = /* @__PURE__ */ new Set();
|
|
3741
5795
|
break;
|
|
3742
5796
|
case "tool_call":
|
|
3743
|
-
messages.push(
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
5797
|
+
messages.push(toolCallMessage(event.call, event.label));
|
|
5798
|
+
break;
|
|
5799
|
+
case "task_plan":
|
|
5800
|
+
taskPlan = {
|
|
5801
|
+
items: event.items,
|
|
5802
|
+
updatedAt: event.updatedAt
|
|
5803
|
+
};
|
|
3747
5804
|
break;
|
|
3748
5805
|
case "knowledge_status": break;
|
|
3749
5806
|
case "choice":
|
|
@@ -3762,7 +5819,8 @@ function rehydrateSession(events) {
|
|
|
3762
5819
|
}
|
|
3763
5820
|
return {
|
|
3764
5821
|
messages,
|
|
3765
|
-
status
|
|
5822
|
+
status,
|
|
5823
|
+
...taskPlan === void 0 ? {} : { taskPlan }
|
|
3766
5824
|
};
|
|
3767
5825
|
}
|
|
3768
5826
|
function buildHandle(sessionDir, metadata) {
|
|
@@ -4140,188 +6198,70 @@ async function executeKbCommand(args, context) {
|
|
|
4140
6198
|
return { messages: ["Usage: /kb init, /kb compile, /kb sync, /kb reset, or /kb status"] };
|
|
4141
6199
|
}
|
|
4142
6200
|
//#endregion
|
|
4143
|
-
//#region src/
|
|
4144
|
-
const
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
6201
|
+
//#region src/agent/events.ts
|
|
6202
|
+
const ABORT_CHOICE_VALUE = "__topchester_abort__";
|
|
6203
|
+
const agentEvent = {
|
|
6204
|
+
status(status) {
|
|
6205
|
+
return {
|
|
6206
|
+
type: "status",
|
|
6207
|
+
status
|
|
6208
|
+
};
|
|
6209
|
+
},
|
|
6210
|
+
systemMessage(text) {
|
|
6211
|
+
return {
|
|
6212
|
+
type: "message",
|
|
6213
|
+
role: "system",
|
|
6214
|
+
text
|
|
6215
|
+
};
|
|
6216
|
+
},
|
|
6217
|
+
assistantMessage(text, meta) {
|
|
6218
|
+
return meta === void 0 ? {
|
|
6219
|
+
type: "message",
|
|
6220
|
+
role: "assistant",
|
|
6221
|
+
text
|
|
6222
|
+
} : {
|
|
6223
|
+
type: "message",
|
|
6224
|
+
role: "assistant",
|
|
6225
|
+
text,
|
|
6226
|
+
meta
|
|
6227
|
+
};
|
|
6228
|
+
},
|
|
6229
|
+
toolCall(call, label) {
|
|
6230
|
+
return {
|
|
6231
|
+
type: "tool_call",
|
|
6232
|
+
call,
|
|
6233
|
+
label
|
|
6234
|
+
};
|
|
6235
|
+
},
|
|
6236
|
+
taskPlan(plan) {
|
|
6237
|
+
return {
|
|
6238
|
+
type: "task_plan",
|
|
6239
|
+
plan
|
|
6240
|
+
};
|
|
6241
|
+
},
|
|
6242
|
+
knowledgeStatus(status, guidance) {
|
|
6243
|
+
return guidance === void 0 ? {
|
|
6244
|
+
type: "knowledge_status",
|
|
6245
|
+
status
|
|
6246
|
+
} : {
|
|
6247
|
+
type: "knowledge_status",
|
|
6248
|
+
status,
|
|
6249
|
+
guidance
|
|
6250
|
+
};
|
|
6251
|
+
},
|
|
6252
|
+
choice(options) {
|
|
6253
|
+
return {
|
|
6254
|
+
type: "choice",
|
|
6255
|
+
...options
|
|
6256
|
+
};
|
|
4155
6257
|
}
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
function getMarkdownTheme() {
|
|
4162
|
-
return {
|
|
4163
|
-
heading: ui.label,
|
|
4164
|
-
link: ui.label,
|
|
4165
|
-
linkUrl: ui.muted,
|
|
4166
|
-
code: ui.ok,
|
|
4167
|
-
codeBlock: (text) => text,
|
|
4168
|
-
codeBlockBorder: (text) => `${codeFenceSentinel}${ui.muted(text)}`,
|
|
4169
|
-
quote: ui.warn,
|
|
4170
|
-
quoteBorder: ui.warn,
|
|
4171
|
-
hr: ui.muted,
|
|
4172
|
-
listBullet: ui.label,
|
|
4173
|
-
bold: (text) => decorate(text, "\x1B[1m", "\x1B[22m"),
|
|
4174
|
-
italic: (text) => decorate(text, "\x1B[3m", "\x1B[23m"),
|
|
4175
|
-
strikethrough: (text) => decorate(text, "\x1B[9m", "\x1B[29m"),
|
|
4176
|
-
underline: (text) => decorate(text, "\x1B[4m", "\x1B[24m"),
|
|
4177
|
-
codeBlockIndent: " ",
|
|
4178
|
-
highlightCode(code, lang) {
|
|
4179
|
-
const validLanguage = lang && supportsLanguage(lang) ? lang : void 0;
|
|
4180
|
-
if (!validLanguage) return code.split("\n");
|
|
4181
|
-
try {
|
|
4182
|
-
return highlight(code, {
|
|
4183
|
-
language: validLanguage,
|
|
4184
|
-
ignoreIllegals: true
|
|
4185
|
-
}).split("\n");
|
|
4186
|
-
} catch {
|
|
4187
|
-
return code.split("\n");
|
|
4188
|
-
}
|
|
4189
|
-
}
|
|
4190
|
-
};
|
|
4191
|
-
}
|
|
4192
|
-
function decorate(text, open, close) {
|
|
4193
|
-
if (!shouldUseAnsi()) return text;
|
|
4194
|
-
return `${open}${text}${close}`;
|
|
4195
|
-
}
|
|
4196
|
-
function applyCodeBlockBackground(line) {
|
|
4197
|
-
if (!shouldUseAnsi()) return line;
|
|
4198
|
-
const background = "\x1B[48;5;235m";
|
|
4199
|
-
const reset = "\x1B[0m";
|
|
4200
|
-
return `${background}${line.split(reset).join(`${reset}${background}`)}${reset}`;
|
|
4201
|
-
}
|
|
4202
|
-
function shouldUseAnsi() {
|
|
4203
|
-
if (process.env.NO_COLOR) return false;
|
|
4204
|
-
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
4205
|
-
return stdout.isTTY === true;
|
|
4206
|
-
}
|
|
4207
|
-
//#endregion
|
|
4208
|
-
//#region src/tui/messages.ts
|
|
4209
|
-
function systemMessage(text) {
|
|
4210
|
-
return {
|
|
4211
|
-
kind: "system",
|
|
4212
|
-
text
|
|
4213
|
-
};
|
|
4214
|
-
}
|
|
4215
|
-
function userMessage(text) {
|
|
4216
|
-
return {
|
|
4217
|
-
kind: "user",
|
|
4218
|
-
text
|
|
4219
|
-
};
|
|
4220
|
-
}
|
|
4221
|
-
function agentMessage(text, meta) {
|
|
4222
|
-
return {
|
|
4223
|
-
kind: "agent",
|
|
4224
|
-
text,
|
|
4225
|
-
meta
|
|
4226
|
-
};
|
|
4227
|
-
}
|
|
4228
|
-
function modalMessage(message) {
|
|
4229
|
-
return {
|
|
4230
|
-
kind: "modal",
|
|
4231
|
-
...message
|
|
6258
|
+
};
|
|
6259
|
+
function choiceAction(label, value) {
|
|
6260
|
+
return value === void 0 ? { label } : {
|
|
6261
|
+
label,
|
|
6262
|
+
value
|
|
4232
6263
|
};
|
|
4233
6264
|
}
|
|
4234
|
-
function renderChatMessage(message, options = {}) {
|
|
4235
|
-
if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
|
|
4236
|
-
if (message.text.length === 0) return [""];
|
|
4237
|
-
const lines = message.kind === "agent" && options.width !== void 0 ? renderMarkdown(message.text, Math.max(1, options.width - getPrefix(message.kind).length)) : message.text.split("\n");
|
|
4238
|
-
if (message.kind === "user") return renderUserMessage(lines);
|
|
4239
|
-
if (message.kind === "system") return renderSystemMessage(lines);
|
|
4240
|
-
const prefix = getPrefix(message.kind);
|
|
4241
|
-
const rendered = prefix.length === 0 ? lines : lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`);
|
|
4242
|
-
if (message.meta) {
|
|
4243
|
-
const metaText = `↳ ${message.meta}`;
|
|
4244
|
-
rendered.push(` ${ui.label("─".repeat(metaText.length))}`, ` ${ui.label(metaText)}`);
|
|
4245
|
-
}
|
|
4246
|
-
return rendered;
|
|
4247
|
-
}
|
|
4248
|
-
function renderUserMessage(lines) {
|
|
4249
|
-
const border = "▌";
|
|
4250
|
-
const rendered = lines.map((line) => `${border} ${line}`);
|
|
4251
|
-
return [
|
|
4252
|
-
`${border} `,
|
|
4253
|
-
...rendered,
|
|
4254
|
-
`${border} `
|
|
4255
|
-
];
|
|
4256
|
-
}
|
|
4257
|
-
function renderSystemMessage(lines) {
|
|
4258
|
-
const bodyPrefix = " ";
|
|
4259
|
-
return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
|
|
4260
|
-
}
|
|
4261
|
-
function formatSystemBodyLine(line) {
|
|
4262
|
-
const expandedLine = expandTabs(line);
|
|
4263
|
-
if (isToolCallLine(line)) return ui.muted(expandedLine);
|
|
4264
|
-
return expandedLine.replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary));
|
|
4265
|
-
}
|
|
4266
|
-
function isToolCallLine(line) {
|
|
4267
|
-
return /^(read_file|list_files|grep|find_file|edit_file|inspect_command): /u.test(line);
|
|
4268
|
-
}
|
|
4269
|
-
function expandTabs(line) {
|
|
4270
|
-
let column = 0;
|
|
4271
|
-
let expanded = "";
|
|
4272
|
-
for (const char of line) {
|
|
4273
|
-
if (char === " ") {
|
|
4274
|
-
const spaces = 4 - column % 4;
|
|
4275
|
-
expanded += " ".repeat(spaces);
|
|
4276
|
-
column += spaces;
|
|
4277
|
-
continue;
|
|
4278
|
-
}
|
|
4279
|
-
expanded += char;
|
|
4280
|
-
column += 1;
|
|
4281
|
-
}
|
|
4282
|
-
return expanded;
|
|
4283
|
-
}
|
|
4284
|
-
function getPrefix(kind) {
|
|
4285
|
-
switch (kind) {
|
|
4286
|
-
case "agent": return " ";
|
|
4287
|
-
case "user": return `${ui.label("You")}: `;
|
|
4288
|
-
case "system": return `${ui.label("System")}: `;
|
|
4289
|
-
}
|
|
4290
|
-
}
|
|
4291
|
-
function renderChatModal(message, selectedActionIndex) {
|
|
4292
|
-
const icon = message.tone === "warning" ? "⚠️" : "ℹ️";
|
|
4293
|
-
const title = message.tone === "warning" ? ui.warn(message.title) : ui.label(message.title);
|
|
4294
|
-
const bodyLines = message.body ? ["", ...message.body.split("\n")] : [];
|
|
4295
|
-
const actionLines = message.actions.map((action, index) => {
|
|
4296
|
-
return `${selectedActionIndex === index ? ">" : " "} ${index + 1}) ${action.label}`;
|
|
4297
|
-
});
|
|
4298
|
-
const contentLines = [
|
|
4299
|
-
`${icon} ${title}:`,
|
|
4300
|
-
...bodyLines,
|
|
4301
|
-
"",
|
|
4302
|
-
...actionLines
|
|
4303
|
-
];
|
|
4304
|
-
const contentWidth = Math.max(...contentLines.map(stripAnsi$1).map((line) => line.length), 1);
|
|
4305
|
-
const top = `╭${"─".repeat(contentWidth + 2)}╮`;
|
|
4306
|
-
const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
|
|
4307
|
-
return [
|
|
4308
|
-
top,
|
|
4309
|
-
...contentLines.map((line) => `│ ${line}${" ".repeat(contentWidth - stripAnsi$1(line).length)} │`),
|
|
4310
|
-
bottom
|
|
4311
|
-
];
|
|
4312
|
-
}
|
|
4313
|
-
function stripAnsi$1(text) {
|
|
4314
|
-
let plain = "";
|
|
4315
|
-
for (let index = 0; index < text.length; index += 1) {
|
|
4316
|
-
if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
|
|
4317
|
-
index += 2;
|
|
4318
|
-
while (index < text.length && text[index] !== "m") index += 1;
|
|
4319
|
-
continue;
|
|
4320
|
-
}
|
|
4321
|
-
plain += text[index];
|
|
4322
|
-
}
|
|
4323
|
-
return plain;
|
|
4324
|
-
}
|
|
4325
6265
|
//#endregion
|
|
4326
6266
|
//#region src/tui/keys.ts
|
|
4327
6267
|
function isUpKey(data) {
|
|
@@ -4333,6 +6273,9 @@ function isDownKey(data) {
|
|
|
4333
6273
|
function isEnterKey(data) {
|
|
4334
6274
|
return matchesKey(data, "enter") || data === "\n" || data === "\r";
|
|
4335
6275
|
}
|
|
6276
|
+
function isNewLineKey(data) {
|
|
6277
|
+
return matchesKey(data, "shift+enter") || matchesKey(data, "alt+enter") || matchesKey(data, "ctrl+enter") || data === "\x1B\r" || data === "\x1B[13;2~";
|
|
6278
|
+
}
|
|
4336
6279
|
function isTabKey(data) {
|
|
4337
6280
|
return matchesKey(data, "tab") || data === " ";
|
|
4338
6281
|
}
|
|
@@ -4439,12 +6382,14 @@ function getStartupThreadMessages(context) {
|
|
|
4439
6382
|
lines.push("Ask Topchester what you want to change.");
|
|
4440
6383
|
return [systemMessage(lines.join("\n"))];
|
|
4441
6384
|
}
|
|
4442
|
-
function renderStaticLayout(messages, folderName = "", modelLabel = "") {
|
|
6385
|
+
function renderStaticLayout(messages, folderName = "", modelLabel = "", taskPlan) {
|
|
4443
6386
|
const threadLines = messages.flatMap((message) => renderChatMessage(message));
|
|
4444
6387
|
const status = formatStatusLine(folderName, modelLabel);
|
|
6388
|
+
const planLines = taskPlan && taskPlan.items.length > 0 ? [...formatTaskPlanForTui(taskPlan, 72), ""] : [];
|
|
4445
6389
|
return [
|
|
4446
6390
|
...threadLines,
|
|
4447
6391
|
"",
|
|
6392
|
+
...planLines,
|
|
4448
6393
|
"┌──────────────────────────────────────────────────────────────────────┐",
|
|
4449
6394
|
"│ > │",
|
|
4450
6395
|
"└──────────────────────────────────────────────────────────────────────┘",
|
|
@@ -4526,23 +6471,35 @@ function stripAnsi(text) {
|
|
|
4526
6471
|
}
|
|
4527
6472
|
//#endregion
|
|
4528
6473
|
//#region src/tui/layout.ts
|
|
6474
|
+
const PROMPT_VISIBLE_CONTENT_LINES = 5;
|
|
6475
|
+
const PASTE_PREVIEW_MIN_LINES = 6;
|
|
6476
|
+
const PASTE_PREVIEW_MIN_CHARS = 500;
|
|
6477
|
+
const BRACKETED_PASTE_START = "\x1B[200~";
|
|
6478
|
+
const BRACKETED_PASTE_END = "\x1B[201~";
|
|
4529
6479
|
var ChatLayout = class {
|
|
4530
6480
|
terminal;
|
|
4531
6481
|
messages;
|
|
4532
6482
|
folderName;
|
|
4533
6483
|
modelLabel;
|
|
4534
|
-
|
|
6484
|
+
inputFocused = false;
|
|
6485
|
+
promptValue = "";
|
|
6486
|
+
promptCursor = 0;
|
|
4535
6487
|
status = "ready";
|
|
4536
6488
|
knowledgeStatus;
|
|
4537
6489
|
ephemeralLine;
|
|
6490
|
+
taskPlanNoticeLine;
|
|
4538
6491
|
noticeLine;
|
|
4539
6492
|
promptHint;
|
|
6493
|
+
taskPlan;
|
|
4540
6494
|
cancelPending;
|
|
4541
6495
|
submitMessage;
|
|
4542
6496
|
submitCommand;
|
|
4543
6497
|
activeModalActionIndex = 0;
|
|
4544
6498
|
activeSlashSuggestionIndex = 0;
|
|
4545
6499
|
threadScrollOffset = 0;
|
|
6500
|
+
pasteBuffer;
|
|
6501
|
+
pasteCounter = 0;
|
|
6502
|
+
pastedContent = /* @__PURE__ */ new Map();
|
|
4546
6503
|
promptHistory = new PromptHistory();
|
|
4547
6504
|
exitAgent;
|
|
4548
6505
|
transcriptMode;
|
|
@@ -4553,14 +6510,6 @@ var ChatLayout = class {
|
|
|
4553
6510
|
this.modelLabel = modelLabel;
|
|
4554
6511
|
this.exitAgent = typeof options === "function" ? options : options.exitAgent ?? (() => {});
|
|
4555
6512
|
this.transcriptMode = typeof options === "function" ? "viewport" : options.transcriptMode ?? "viewport";
|
|
4556
|
-
this.input.onSubmit = (value) => {
|
|
4557
|
-
if (value.trim().length > 0) {
|
|
4558
|
-
const message = value.trim();
|
|
4559
|
-
this.addMessage(userMessage(message));
|
|
4560
|
-
this.input.setValue("");
|
|
4561
|
-
this.submitUserInput(message);
|
|
4562
|
-
}
|
|
4563
|
-
};
|
|
4564
6513
|
}
|
|
4565
6514
|
addMessage(message) {
|
|
4566
6515
|
this.messages.push(message);
|
|
@@ -4573,6 +6522,24 @@ var ChatLayout = class {
|
|
|
4573
6522
|
setKnowledgeStatus(status) {
|
|
4574
6523
|
this.knowledgeStatus = formatKnowledgeFooterStatus(status);
|
|
4575
6524
|
}
|
|
6525
|
+
setTaskPlan(plan) {
|
|
6526
|
+
const change = detectTaskPlanChange(this.taskPlan, plan);
|
|
6527
|
+
this.taskPlan = plan && plan.items.length > 0 ? plan : void 0;
|
|
6528
|
+
return change;
|
|
6529
|
+
}
|
|
6530
|
+
setTaskPlanNotice(line) {
|
|
6531
|
+
this.taskPlanNoticeLine = line;
|
|
6532
|
+
}
|
|
6533
|
+
clearTaskPlan(now = /* @__PURE__ */ new Date()) {
|
|
6534
|
+
if (!this.taskPlan) return;
|
|
6535
|
+
const cleared = {
|
|
6536
|
+
items: [],
|
|
6537
|
+
updatedAt: now.toISOString()
|
|
6538
|
+
};
|
|
6539
|
+
this.taskPlan = void 0;
|
|
6540
|
+
this.taskPlanNoticeLine = void 0;
|
|
6541
|
+
return cleared;
|
|
6542
|
+
}
|
|
4576
6543
|
isReady() {
|
|
4577
6544
|
return this.status === "ready";
|
|
4578
6545
|
}
|
|
@@ -4595,26 +6562,33 @@ var ChatLayout = class {
|
|
|
4595
6562
|
this.submitCommand = submit;
|
|
4596
6563
|
}
|
|
4597
6564
|
setInputValue(value) {
|
|
4598
|
-
this.
|
|
6565
|
+
this.promptValue = value;
|
|
6566
|
+
this.promptCursor = value.length;
|
|
6567
|
+
this.pastedContent.clear();
|
|
6568
|
+
this.pasteCounter = 0;
|
|
4599
6569
|
}
|
|
4600
6570
|
getConversationTurns() {
|
|
4601
6571
|
return this.messages.flatMap((message) => {
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
6572
|
+
switch (message.kind) {
|
|
6573
|
+
case "user": return message.modelContext === false ? [] : [{
|
|
6574
|
+
role: "user",
|
|
6575
|
+
text: message.text
|
|
6576
|
+
}];
|
|
6577
|
+
case "agent": return message.text === "ready" || message.modelContext === false ? [] : [{
|
|
6578
|
+
role: "assistant",
|
|
6579
|
+
text: message.text
|
|
6580
|
+
}];
|
|
6581
|
+
case "system":
|
|
6582
|
+
case "tool_call":
|
|
6583
|
+
case "modal": return [];
|
|
6584
|
+
}
|
|
4611
6585
|
});
|
|
4612
6586
|
}
|
|
4613
6587
|
get focused() {
|
|
4614
|
-
return this.
|
|
6588
|
+
return this.inputFocused;
|
|
4615
6589
|
}
|
|
4616
6590
|
set focused(value) {
|
|
4617
|
-
this.
|
|
6591
|
+
this.inputFocused = value;
|
|
4618
6592
|
}
|
|
4619
6593
|
handleInput(data) {
|
|
4620
6594
|
if (this.cancelPending && matchesKey(data, "escape")) {
|
|
@@ -4623,15 +6597,15 @@ var ChatLayout = class {
|
|
|
4623
6597
|
}
|
|
4624
6598
|
if (this.handleModalInput(data)) return;
|
|
4625
6599
|
if (this.handleSlashSuggestionInput(data)) return;
|
|
6600
|
+
if (this.handlePromptPasteInput(data)) return;
|
|
6601
|
+
if (this.handlePromptNewLineInput(data)) return;
|
|
6602
|
+
if (this.handlePromptSubmitInput(data)) return;
|
|
4626
6603
|
if (this.handleThreadScrollInput(data)) return;
|
|
6604
|
+
if (this.handlePromptVerticalCursorInput(data)) return;
|
|
4627
6605
|
if (this.handlePromptHistoryInput(data)) return;
|
|
4628
|
-
|
|
4629
|
-
this.input.handleInput(data);
|
|
4630
|
-
if (this.input.getValue() !== previousInput) this.promptHistory.resetBrowsing();
|
|
4631
|
-
}
|
|
4632
|
-
invalidate() {
|
|
4633
|
-
this.input.invalidate();
|
|
6606
|
+
this.handlePromptEditInput(data);
|
|
4634
6607
|
}
|
|
6608
|
+
invalidate() {}
|
|
4635
6609
|
render(width) {
|
|
4636
6610
|
const safeWidth = Math.max(20, width);
|
|
4637
6611
|
const footerLines = this.getActiveModal() ? this.renderModalHelp(safeWidth) : this.renderPrompt(safeWidth);
|
|
@@ -4659,6 +6633,7 @@ var ChatLayout = class {
|
|
|
4659
6633
|
return [...this.renderThreadMessageLines(messageLines, innerWidth, width, message.kind === "user"), ...spacer];
|
|
4660
6634
|
});
|
|
4661
6635
|
if (this.ephemeralLine) lines.push(...this.renderThreadMessageLines([` ${this.ephemeralLine}`], innerWidth, width, false));
|
|
6636
|
+
if (this.taskPlanNoticeLine) lines.push(...this.renderThreadMessageLines([` ${this.taskPlanNoticeLine}`], innerWidth, width, false));
|
|
4662
6637
|
if (this.noticeLine) lines.push(...this.renderThreadMessageLines([` ${this.noticeLine}`], innerWidth, width, false));
|
|
4663
6638
|
return lines;
|
|
4664
6639
|
}
|
|
@@ -4674,17 +6649,69 @@ var ChatLayout = class {
|
|
|
4674
6649
|
const bottom = `└${"─".repeat(Math.max(0, width - 2))}┘`;
|
|
4675
6650
|
const prefix = "> ";
|
|
4676
6651
|
const innerWidth = Math.max(1, width - 4 - 2);
|
|
4677
|
-
const
|
|
6652
|
+
const inputLines = this.promptHint ? [truncateToWidth(ui.label(this.promptHint), innerWidth, "…", true)] : this.renderPromptInputLines(innerWidth);
|
|
4678
6653
|
const statusInnerWidth = Math.max(1, width - 2);
|
|
4679
6654
|
const status = truncateToWidth(` ${formatStatusLine(this.folderName, this.modelLabel, this.status, this.knowledgeStatus, statusInnerWidth)} `, width, "…", true);
|
|
4680
6655
|
return [
|
|
4681
6656
|
...this.renderSlashSuggestions(width),
|
|
6657
|
+
...this.renderTaskPlan(width),
|
|
4682
6658
|
top,
|
|
4683
|
-
`│ ${prefix}${
|
|
6659
|
+
...inputLines.map((line, index) => `│ ${index === 0 ? prefix : " "}${padPromptInputLine(line, innerWidth)} │`),
|
|
4684
6660
|
bottom,
|
|
4685
6661
|
status
|
|
4686
6662
|
];
|
|
4687
6663
|
}
|
|
6664
|
+
renderPromptInputLines(innerWidth) {
|
|
6665
|
+
const value = this.promptValue;
|
|
6666
|
+
if (!value.includes("\n")) return [this.renderPromptLineWithCursor(value, this.promptCursor, innerWidth)];
|
|
6667
|
+
const rows = this.getPromptRows(innerWidth);
|
|
6668
|
+
const cursorRowIndex = rows.findIndex((row) => this.promptCursor >= row.start && this.promptCursor <= row.end);
|
|
6669
|
+
const latestStart = Math.max(0, rows.length - PROMPT_VISIBLE_CONTENT_LINES);
|
|
6670
|
+
const visibleStart = cursorRowIndex === -1 ? latestStart : Math.min(Math.max(0, cursorRowIndex - 2), latestStart);
|
|
6671
|
+
return rows.slice(visibleStart, visibleStart + PROMPT_VISIBLE_CONTENT_LINES).map((row) => {
|
|
6672
|
+
if (this.promptCursor >= row.start && this.promptCursor <= row.end) return this.renderPromptLineWithCursor(row.text, this.promptCursor - row.start, innerWidth);
|
|
6673
|
+
return truncateToWidth(row.text.length === 0 ? " " : row.text, innerWidth, "…", true);
|
|
6674
|
+
});
|
|
6675
|
+
}
|
|
6676
|
+
getPromptRows(width) {
|
|
6677
|
+
const rows = [];
|
|
6678
|
+
let offset = 0;
|
|
6679
|
+
for (const line of this.promptValue.split("\n")) {
|
|
6680
|
+
if (line.length === 0) rows.push({
|
|
6681
|
+
text: "",
|
|
6682
|
+
start: offset,
|
|
6683
|
+
end: offset
|
|
6684
|
+
});
|
|
6685
|
+
else for (let index = 0; index < line.length; index += width) {
|
|
6686
|
+
const text = line.slice(index, index + width);
|
|
6687
|
+
rows.push({
|
|
6688
|
+
text,
|
|
6689
|
+
start: offset + index,
|
|
6690
|
+
end: offset + index + text.length
|
|
6691
|
+
});
|
|
6692
|
+
}
|
|
6693
|
+
offset += line.length + 1;
|
|
6694
|
+
}
|
|
6695
|
+
return rows.length > 0 ? rows : [{
|
|
6696
|
+
text: "",
|
|
6697
|
+
start: 0,
|
|
6698
|
+
end: 0
|
|
6699
|
+
}];
|
|
6700
|
+
}
|
|
6701
|
+
renderPromptLineWithCursor(text, cursor, width) {
|
|
6702
|
+
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
|
6703
|
+
const windowStart = safeCursor >= width ? safeCursor - width + 1 : 0;
|
|
6704
|
+
const visibleText = text.slice(windowStart, windowStart + width);
|
|
6705
|
+
const visibleCursor = safeCursor - windowStart;
|
|
6706
|
+
const beforeCursor = visibleText.slice(0, visibleCursor);
|
|
6707
|
+
const cursorChar = visibleText[visibleCursor] ?? " ";
|
|
6708
|
+
const afterCursor = visibleText.slice(visibleCursor + cursorChar.length);
|
|
6709
|
+
return truncateToWidth(`${beforeCursor}${this.inputFocused ? CURSOR_MARKER : ""}\u001b[7m${cursorChar}\u001b[27m${afterCursor}`, width, "…", true);
|
|
6710
|
+
}
|
|
6711
|
+
renderTaskPlan(width) {
|
|
6712
|
+
if (!this.taskPlan) return [];
|
|
6713
|
+
return formatTaskPlanForTui(this.taskPlan, Math.max(1, width));
|
|
6714
|
+
}
|
|
4688
6715
|
renderSlashSuggestions(width) {
|
|
4689
6716
|
const suggestions = this.getSlashSuggestions();
|
|
4690
6717
|
if (suggestions.length === 0 || this.promptHint) return [];
|
|
@@ -4731,6 +6758,14 @@ var ChatLayout = class {
|
|
|
4731
6758
|
this.exitAgent();
|
|
4732
6759
|
return true;
|
|
4733
6760
|
}
|
|
6761
|
+
if (action.value === "__topchester_abort__") {
|
|
6762
|
+
this.addMessage({
|
|
6763
|
+
kind: "user",
|
|
6764
|
+
text: action.label,
|
|
6765
|
+
modelContext: false
|
|
6766
|
+
});
|
|
6767
|
+
return true;
|
|
6768
|
+
}
|
|
4734
6769
|
this.submitModalAction(action.value ?? action.label);
|
|
4735
6770
|
return true;
|
|
4736
6771
|
}
|
|
@@ -4773,17 +6808,196 @@ var ChatLayout = class {
|
|
|
4773
6808
|
handlePromptHistoryInput(data) {
|
|
4774
6809
|
if (this.promptHint) return false;
|
|
4775
6810
|
if (isUpKey(data)) {
|
|
4776
|
-
const prompt = this.promptHistory.previous(this.
|
|
4777
|
-
if (prompt !== void 0)
|
|
6811
|
+
const prompt = this.promptHistory.previous(this.promptValue);
|
|
6812
|
+
if (prompt !== void 0) {
|
|
6813
|
+
this.promptValue = prompt;
|
|
6814
|
+
this.promptCursor = prompt.length;
|
|
6815
|
+
}
|
|
4778
6816
|
return true;
|
|
4779
6817
|
}
|
|
4780
6818
|
if (isDownKey(data)) {
|
|
4781
6819
|
const prompt = this.promptHistory.next();
|
|
4782
|
-
if (prompt !== void 0)
|
|
6820
|
+
if (prompt !== void 0) {
|
|
6821
|
+
this.promptValue = prompt;
|
|
6822
|
+
this.promptCursor = prompt.length;
|
|
6823
|
+
}
|
|
6824
|
+
return true;
|
|
6825
|
+
}
|
|
6826
|
+
return false;
|
|
6827
|
+
}
|
|
6828
|
+
handlePromptVerticalCursorInput(data) {
|
|
6829
|
+
if (this.promptHint || !this.promptValue.includes("\n")) return false;
|
|
6830
|
+
if (isUpKey(data)) {
|
|
6831
|
+
if (this.canMovePromptCursorVertically(-1)) {
|
|
6832
|
+
this.movePromptCursorVertically(-1);
|
|
6833
|
+
return true;
|
|
6834
|
+
}
|
|
6835
|
+
if (this.promptCursor > 0) {
|
|
6836
|
+
this.promptCursor = this.getCurrentPromptLineStart();
|
|
6837
|
+
return true;
|
|
6838
|
+
}
|
|
6839
|
+
return false;
|
|
6840
|
+
}
|
|
6841
|
+
if (isDownKey(data)) {
|
|
6842
|
+
if (this.canMovePromptCursorVertically(1)) {
|
|
6843
|
+
this.movePromptCursorVertically(1);
|
|
6844
|
+
return true;
|
|
6845
|
+
}
|
|
6846
|
+
if (this.promptCursor < this.promptValue.length) {
|
|
6847
|
+
this.promptCursor = this.getCurrentPromptLineEnd();
|
|
6848
|
+
return true;
|
|
6849
|
+
}
|
|
6850
|
+
return false;
|
|
6851
|
+
}
|
|
6852
|
+
return false;
|
|
6853
|
+
}
|
|
6854
|
+
canMovePromptCursorVertically(delta) {
|
|
6855
|
+
const lines = this.promptValue.split("\n");
|
|
6856
|
+
const current = this.getPromptLineCursor(lines);
|
|
6857
|
+
if (delta === -1) return current.line > 0;
|
|
6858
|
+
return current.line < lines.length - 1;
|
|
6859
|
+
}
|
|
6860
|
+
handlePromptNewLineInput(data) {
|
|
6861
|
+
if (this.promptHint || !isNewLineKey(data)) return false;
|
|
6862
|
+
this.insertPromptText("\n");
|
|
6863
|
+
this.promptHistory.resetBrowsing();
|
|
6864
|
+
return true;
|
|
6865
|
+
}
|
|
6866
|
+
handlePromptSubmitInput(data) {
|
|
6867
|
+
if (this.promptHint || !isEnterKey(data)) return false;
|
|
6868
|
+
this.submitPromptValue();
|
|
6869
|
+
return true;
|
|
6870
|
+
}
|
|
6871
|
+
handlePromptPasteInput(data) {
|
|
6872
|
+
if (this.promptHint) return false;
|
|
6873
|
+
if (this.pasteBuffer !== void 0) {
|
|
6874
|
+
this.pasteBuffer += data;
|
|
6875
|
+
this.flushPromptPasteBuffer();
|
|
6876
|
+
return true;
|
|
6877
|
+
}
|
|
6878
|
+
const startIndex = data.indexOf(BRACKETED_PASTE_START);
|
|
6879
|
+
if (startIndex === -1) return false;
|
|
6880
|
+
const beforePaste = data.slice(0, startIndex);
|
|
6881
|
+
if (beforePaste.length > 0) this.insertPromptText(beforePaste);
|
|
6882
|
+
this.pasteBuffer = data.slice(startIndex + 6);
|
|
6883
|
+
this.flushPromptPasteBuffer();
|
|
6884
|
+
this.promptHistory.resetBrowsing();
|
|
6885
|
+
return true;
|
|
6886
|
+
}
|
|
6887
|
+
flushPromptPasteBuffer() {
|
|
6888
|
+
if (this.pasteBuffer === void 0) return;
|
|
6889
|
+
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
6890
|
+
if (endIndex === -1) return;
|
|
6891
|
+
const pasted = this.pasteBuffer.slice(0, endIndex);
|
|
6892
|
+
const remaining = this.pasteBuffer.slice(endIndex + 6);
|
|
6893
|
+
this.pasteBuffer = void 0;
|
|
6894
|
+
this.insertPastedText(pasted);
|
|
6895
|
+
if (remaining.length > 0) this.handleInput(remaining);
|
|
6896
|
+
}
|
|
6897
|
+
insertPastedText(text) {
|
|
6898
|
+
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
6899
|
+
const trimmedText = normalizedText.trim();
|
|
6900
|
+
if (trimmedText.length === 0) return;
|
|
6901
|
+
const lineCount = trimmedText.split("\n").length;
|
|
6902
|
+
if (lineCount >= PASTE_PREVIEW_MIN_LINES || trimmedText.length >= PASTE_PREVIEW_MIN_CHARS) {
|
|
6903
|
+
this.pasteCounter += 1;
|
|
6904
|
+
const marker = `[Pasted #${this.pasteCounter} ${lineCount} lines ${trimmedText.length} chars]`;
|
|
6905
|
+
this.pastedContent.set(marker, trimmedText);
|
|
6906
|
+
this.insertPromptText(marker);
|
|
6907
|
+
return;
|
|
6908
|
+
}
|
|
6909
|
+
this.insertPromptText(normalizedText);
|
|
6910
|
+
}
|
|
6911
|
+
insertPromptText(text) {
|
|
6912
|
+
this.promptValue = `${this.promptValue.slice(0, this.promptCursor)}${text}${this.promptValue.slice(this.promptCursor)}`;
|
|
6913
|
+
this.promptCursor += text.length;
|
|
6914
|
+
}
|
|
6915
|
+
expandPastedContent(value) {
|
|
6916
|
+
let expanded = value;
|
|
6917
|
+
for (const [marker, content] of this.pastedContent) expanded = expanded.split(marker).join(content);
|
|
6918
|
+
return expanded;
|
|
6919
|
+
}
|
|
6920
|
+
submitPromptValue() {
|
|
6921
|
+
if (this.promptValue.trim().length === 0) return;
|
|
6922
|
+
const message = this.expandPastedContent(this.promptValue).trim();
|
|
6923
|
+
this.addMessage(userMessage(message));
|
|
6924
|
+
this.promptValue = "";
|
|
6925
|
+
this.promptCursor = 0;
|
|
6926
|
+
this.pastedContent.clear();
|
|
6927
|
+
this.pasteCounter = 0;
|
|
6928
|
+
this.submitUserInput(message);
|
|
6929
|
+
}
|
|
6930
|
+
handlePromptEditInput(data) {
|
|
6931
|
+
if (this.promptHint) return false;
|
|
6932
|
+
if (matchesKey(data, "left") || data === "\x1B[D") {
|
|
6933
|
+
this.promptCursor = Math.max(0, this.promptCursor - 1);
|
|
6934
|
+
return true;
|
|
6935
|
+
}
|
|
6936
|
+
if (matchesKey(data, "right") || data === "\x1B[C") {
|
|
6937
|
+
this.promptCursor = Math.min(this.promptValue.length, this.promptCursor + 1);
|
|
6938
|
+
return true;
|
|
6939
|
+
}
|
|
6940
|
+
if (isHomeKey(data)) {
|
|
6941
|
+
this.promptCursor = this.getCurrentPromptLineStart();
|
|
6942
|
+
return true;
|
|
6943
|
+
}
|
|
6944
|
+
if (isEndKey(data)) {
|
|
6945
|
+
this.promptCursor = this.getCurrentPromptLineEnd();
|
|
6946
|
+
return true;
|
|
6947
|
+
}
|
|
6948
|
+
if (matchesKey(data, "backspace") || data === "" || data === "\b") {
|
|
6949
|
+
if (this.promptCursor > 0) {
|
|
6950
|
+
this.promptValue = `${this.promptValue.slice(0, this.promptCursor - 1)}${this.promptValue.slice(this.promptCursor)}`;
|
|
6951
|
+
this.promptCursor -= 1;
|
|
6952
|
+
this.promptHistory.resetBrowsing();
|
|
6953
|
+
}
|
|
6954
|
+
return true;
|
|
6955
|
+
}
|
|
6956
|
+
if (matchesKey(data, "delete") || data === "\x1B[3~") {
|
|
6957
|
+
if (this.promptCursor < this.promptValue.length) {
|
|
6958
|
+
this.promptValue = `${this.promptValue.slice(0, this.promptCursor)}${this.promptValue.slice(this.promptCursor + 1)}`;
|
|
6959
|
+
this.promptHistory.resetBrowsing();
|
|
6960
|
+
}
|
|
6961
|
+
return true;
|
|
6962
|
+
}
|
|
6963
|
+
const printable = decodeKittyPrintable(data) ?? (isPrintableInput(data) ? data : void 0);
|
|
6964
|
+
if (printable !== void 0) {
|
|
6965
|
+
this.insertPromptText(printable);
|
|
6966
|
+
this.promptHistory.resetBrowsing();
|
|
4783
6967
|
return true;
|
|
4784
6968
|
}
|
|
4785
6969
|
return false;
|
|
4786
6970
|
}
|
|
6971
|
+
movePromptCursorVertically(delta) {
|
|
6972
|
+
const lines = this.promptValue.split("\n");
|
|
6973
|
+
const current = this.getPromptLineCursor(lines);
|
|
6974
|
+
const targetLine = Math.max(0, Math.min(lines.length - 1, current.line + delta));
|
|
6975
|
+
const targetColumn = Math.min(current.column, lines[targetLine]?.length ?? 0);
|
|
6976
|
+
this.promptCursor = lines.slice(0, targetLine).reduce((total, line) => total + line.length + 1, 0) + targetColumn;
|
|
6977
|
+
}
|
|
6978
|
+
getPromptLineCursor(lines = this.promptValue.split("\n")) {
|
|
6979
|
+
let offset = 0;
|
|
6980
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
6981
|
+
const line = lines[lineIndex] ?? "";
|
|
6982
|
+
const end = offset + line.length;
|
|
6983
|
+
if (this.promptCursor <= end || lineIndex === lines.length - 1) return {
|
|
6984
|
+
line: lineIndex,
|
|
6985
|
+
column: Math.max(0, this.promptCursor - offset)
|
|
6986
|
+
};
|
|
6987
|
+
offset = end + 1;
|
|
6988
|
+
}
|
|
6989
|
+
return {
|
|
6990
|
+
line: 0,
|
|
6991
|
+
column: 0
|
|
6992
|
+
};
|
|
6993
|
+
}
|
|
6994
|
+
getCurrentPromptLineStart() {
|
|
6995
|
+
return this.promptValue.lastIndexOf("\n", Math.max(0, this.promptCursor - 1)) + 1;
|
|
6996
|
+
}
|
|
6997
|
+
getCurrentPromptLineEnd() {
|
|
6998
|
+
const end = this.promptValue.indexOf("\n", this.promptCursor);
|
|
6999
|
+
return end === -1 ? this.promptValue.length : end;
|
|
7000
|
+
}
|
|
4787
7001
|
handleSlashSuggestionInput(data) {
|
|
4788
7002
|
const suggestions = this.getSlashSuggestions();
|
|
4789
7003
|
if (suggestions.length === 0) {
|
|
@@ -4802,19 +7016,19 @@ var ChatLayout = class {
|
|
|
4802
7016
|
this.completeSlashSuggestion(suggestions);
|
|
4803
7017
|
return true;
|
|
4804
7018
|
}
|
|
4805
|
-
if (isEnterKey(data) && this.
|
|
7019
|
+
if (isEnterKey(data) && this.promptValue.trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
|
|
4806
7020
|
this.completeSlashSuggestion(suggestions);
|
|
4807
7021
|
return true;
|
|
4808
7022
|
}
|
|
4809
7023
|
return false;
|
|
4810
7024
|
}
|
|
4811
7025
|
completeSlashSuggestion(suggestions) {
|
|
4812
|
-
this.
|
|
4813
|
-
this.
|
|
7026
|
+
this.promptValue = suggestions[this.activeSlashSuggestionIndex]?.value ?? this.promptValue;
|
|
7027
|
+
this.promptCursor = this.promptValue.length;
|
|
4814
7028
|
this.promptHistory.resetBrowsing();
|
|
4815
7029
|
}
|
|
4816
7030
|
getSlashSuggestions() {
|
|
4817
|
-
return getSlashCommandSuggestions(this.
|
|
7031
|
+
return getSlashCommandSuggestions(this.promptValue);
|
|
4818
7032
|
}
|
|
4819
7033
|
getActiveModal() {
|
|
4820
7034
|
return this.messages[this.getActiveModalIndex()];
|
|
@@ -4827,6 +7041,7 @@ var ChatLayout = class {
|
|
|
4827
7041
|
this.submitUserInput(message);
|
|
4828
7042
|
}
|
|
4829
7043
|
submitUserInput(message) {
|
|
7044
|
+
this.setTaskPlanNotice(void 0);
|
|
4830
7045
|
this.promptHistory.add(message);
|
|
4831
7046
|
if (message.startsWith("/")) this.submitCommand?.(message);
|
|
4832
7047
|
else this.submitMessage?.(message);
|
|
@@ -4835,8 +7050,15 @@ var ChatLayout = class {
|
|
|
4835
7050
|
function colorUserMessageBorder(line) {
|
|
4836
7051
|
return line.replace("▌", ui.modelInline("▌"));
|
|
4837
7052
|
}
|
|
4838
|
-
function
|
|
4839
|
-
return (
|
|
7053
|
+
function padPromptInputLine(line, width) {
|
|
7054
|
+
return `${line}${" ".repeat(Math.max(0, width - stripAnsi(line).length))}`;
|
|
7055
|
+
}
|
|
7056
|
+
function isPrintableInput(data) {
|
|
7057
|
+
if (data.length === 0) return false;
|
|
7058
|
+
return [...data].every((char) => {
|
|
7059
|
+
const code = char.charCodeAt(0);
|
|
7060
|
+
return code >= 32 && code !== 127 && (code < 128 || code > 159);
|
|
7061
|
+
});
|
|
4840
7062
|
}
|
|
4841
7063
|
//#endregion
|
|
4842
7064
|
//#region src/agent/conversation.ts
|
|
@@ -4848,58 +7070,6 @@ function buildConversationPrompt(turns, latestMessage) {
|
|
|
4848
7070
|
return lines.join("\n\n");
|
|
4849
7071
|
}
|
|
4850
7072
|
//#endregion
|
|
4851
|
-
//#region src/agent/events.ts
|
|
4852
|
-
const agentEvent = {
|
|
4853
|
-
status(status) {
|
|
4854
|
-
return {
|
|
4855
|
-
type: "status",
|
|
4856
|
-
status
|
|
4857
|
-
};
|
|
4858
|
-
},
|
|
4859
|
-
systemMessage(text) {
|
|
4860
|
-
return {
|
|
4861
|
-
type: "message",
|
|
4862
|
-
role: "system",
|
|
4863
|
-
text
|
|
4864
|
-
};
|
|
4865
|
-
},
|
|
4866
|
-
assistantMessage(text, meta) {
|
|
4867
|
-
return meta === void 0 ? {
|
|
4868
|
-
type: "message",
|
|
4869
|
-
role: "assistant",
|
|
4870
|
-
text
|
|
4871
|
-
} : {
|
|
4872
|
-
type: "message",
|
|
4873
|
-
role: "assistant",
|
|
4874
|
-
text,
|
|
4875
|
-
meta
|
|
4876
|
-
};
|
|
4877
|
-
},
|
|
4878
|
-
toolCall(call, label) {
|
|
4879
|
-
return {
|
|
4880
|
-
type: "tool_call",
|
|
4881
|
-
call,
|
|
4882
|
-
label
|
|
4883
|
-
};
|
|
4884
|
-
},
|
|
4885
|
-
knowledgeStatus(status, guidance) {
|
|
4886
|
-
return guidance === void 0 ? {
|
|
4887
|
-
type: "knowledge_status",
|
|
4888
|
-
status
|
|
4889
|
-
} : {
|
|
4890
|
-
type: "knowledge_status",
|
|
4891
|
-
status,
|
|
4892
|
-
guidance
|
|
4893
|
-
};
|
|
4894
|
-
},
|
|
4895
|
-
choice(options) {
|
|
4896
|
-
return {
|
|
4897
|
-
type: "choice",
|
|
4898
|
-
...options
|
|
4899
|
-
};
|
|
4900
|
-
}
|
|
4901
|
-
};
|
|
4902
|
-
//#endregion
|
|
4903
7073
|
//#region src/agent/health.ts
|
|
4904
7074
|
async function checkAgentReady(modelGateway, abortSignal) {
|
|
4905
7075
|
const abortController = new AbortController();
|
|
@@ -4932,20 +7102,25 @@ function isAbortError(error) {
|
|
|
4932
7102
|
//#endregion
|
|
4933
7103
|
//#region src/agent/tools/executor.ts
|
|
4934
7104
|
async function executeToolCall(workspaceRoot, call, options = {}) {
|
|
4935
|
-
const definition = getToolDefinition(call.tool);
|
|
4936
7105
|
const startedAt = Date.now();
|
|
4937
7106
|
const context = {
|
|
4938
7107
|
workspaceRoot,
|
|
4939
7108
|
pathEnv: options.pathEnv,
|
|
4940
|
-
logger: options.logger
|
|
7109
|
+
logger: options.logger,
|
|
7110
|
+
taskPlan: options.taskPlan
|
|
4941
7111
|
};
|
|
4942
|
-
options.logger?.debug({
|
|
4943
|
-
event: "tool_call",
|
|
4944
|
-
tool: call.tool,
|
|
4945
|
-
args: summarizeToolArgs(call)
|
|
4946
|
-
}, "tool call");
|
|
4947
7112
|
try {
|
|
4948
|
-
const
|
|
7113
|
+
const definition = getToolDefinition(call.tool);
|
|
7114
|
+
const parsedCall = {
|
|
7115
|
+
...call,
|
|
7116
|
+
args: definition.argsSchema.parse(call.args)
|
|
7117
|
+
};
|
|
7118
|
+
options.logger?.debug({
|
|
7119
|
+
event: "tool_call",
|
|
7120
|
+
tool: parsedCall.tool,
|
|
7121
|
+
args: summarizeToolArgs(parsedCall)
|
|
7122
|
+
}, "tool call");
|
|
7123
|
+
const result = await definition.execute(context, parsedCall.args);
|
|
4949
7124
|
const durationMs = Date.now() - startedAt;
|
|
4950
7125
|
options.logger?.debug({
|
|
4951
7126
|
event: "tool_result",
|
|
@@ -4965,26 +7140,56 @@ async function executeToolCall(workspaceRoot, call, options = {}) {
|
|
|
4965
7140
|
}, "tool result content");
|
|
4966
7141
|
return result;
|
|
4967
7142
|
} catch (error) {
|
|
4968
|
-
|
|
4969
|
-
|
|
7143
|
+
const message = formatErrorMessage(error);
|
|
7144
|
+
const logPayload = {
|
|
7145
|
+
event: "tool_result",
|
|
4970
7146
|
tool: call.tool,
|
|
4971
7147
|
durationMs: Date.now() - startedAt,
|
|
7148
|
+
error: message,
|
|
4972
7149
|
err: error
|
|
4973
|
-
}
|
|
4974
|
-
|
|
7150
|
+
};
|
|
7151
|
+
if (typeof options.logger?.warn === "function") options.logger.warn(logPayload, "tool returned error");
|
|
7152
|
+
else options.logger?.debug(logPayload, "tool returned error");
|
|
7153
|
+
return {
|
|
7154
|
+
tool: call.tool,
|
|
7155
|
+
content: `Tool ${call.tool} failed: ${message}`,
|
|
7156
|
+
error: message,
|
|
7157
|
+
warning: message
|
|
7158
|
+
};
|
|
4975
7159
|
}
|
|
4976
7160
|
}
|
|
4977
7161
|
function summarizeToolArgs(call) {
|
|
7162
|
+
if (call.tool === "plan_todo") {
|
|
7163
|
+
const activeItem = call.args.items.find((item) => item.status === "in_progress")?.text;
|
|
7164
|
+
return {
|
|
7165
|
+
itemCount: call.args.items.length,
|
|
7166
|
+
activeItem,
|
|
7167
|
+
completedCount: call.args.items.filter((item) => item.status === "completed").length
|
|
7168
|
+
};
|
|
7169
|
+
}
|
|
7170
|
+
if (call.tool === "write_file") return {
|
|
7171
|
+
path: call.args.path,
|
|
7172
|
+
contentLength: call.args.content.length,
|
|
7173
|
+
lineCount: countLogicalLines(call.args.content),
|
|
7174
|
+
createParentDirs: Boolean(call.args.create_parent_dirs),
|
|
7175
|
+
overwrite: Boolean(call.args.overwrite),
|
|
7176
|
+
expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
|
|
7177
|
+
};
|
|
4978
7178
|
if (call.tool !== "edit_file") return call.args;
|
|
4979
7179
|
return {
|
|
4980
7180
|
path: call.args.path,
|
|
4981
7181
|
editCount: call.args.edits.length,
|
|
4982
7182
|
oldTextLengths: call.args.edits.map((edit) => edit.old_text.length),
|
|
4983
7183
|
newTextLengths: call.args.edits.map((edit) => edit.new_text.length),
|
|
4984
|
-
|
|
7184
|
+
expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
|
|
4985
7185
|
};
|
|
4986
7186
|
}
|
|
4987
7187
|
function summarizeToolResult(result) {
|
|
7188
|
+
if (result.tool === "plan_todo") return {
|
|
7189
|
+
itemCount: result.plan.items.length,
|
|
7190
|
+
activeItem: result.currentItem,
|
|
7191
|
+
completedCount: result.completedCount
|
|
7192
|
+
};
|
|
4988
7193
|
if (result.tool === "inspect_command") return {
|
|
4989
7194
|
cwd: result.cwd,
|
|
4990
7195
|
exitCode: result.exitCode,
|
|
@@ -4994,6 +7199,50 @@ function summarizeToolResult(result) {
|
|
|
4994
7199
|
stdoutLength: result.stdout.length,
|
|
4995
7200
|
stderrLength: result.stderr.length
|
|
4996
7201
|
};
|
|
7202
|
+
if (result.tool === "git_status") return {
|
|
7203
|
+
repoRoot: result.repoRoot,
|
|
7204
|
+
branch: result.branch,
|
|
7205
|
+
head: result.head,
|
|
7206
|
+
hasHead: result.hasHead,
|
|
7207
|
+
clean: result.clean,
|
|
7208
|
+
fileCount: result.files.length,
|
|
7209
|
+
truncated: result.truncated
|
|
7210
|
+
};
|
|
7211
|
+
if (result.tool === "git_diff") return {
|
|
7212
|
+
repoRoot: result.repoRoot,
|
|
7213
|
+
scope: result.scope,
|
|
7214
|
+
path: result.path,
|
|
7215
|
+
fileCount: result.fileCount,
|
|
7216
|
+
truncated: result.truncated
|
|
7217
|
+
};
|
|
7218
|
+
if (result.tool === "git_log") return {
|
|
7219
|
+
repoRoot: result.repoRoot,
|
|
7220
|
+
commitCount: result.commits.length,
|
|
7221
|
+
truncated: result.truncated
|
|
7222
|
+
};
|
|
7223
|
+
if (result.tool === "git_add") return {
|
|
7224
|
+
repoRoot: result.repoRoot,
|
|
7225
|
+
stagedPathCount: result.stagedPaths.length,
|
|
7226
|
+
postStatusFileCount: result.files.length
|
|
7227
|
+
};
|
|
7228
|
+
if (result.tool === "git_commit") return {
|
|
7229
|
+
repoRoot: result.repoRoot,
|
|
7230
|
+
commit: result.commit.shortSha,
|
|
7231
|
+
stagedPathCount: result.stagedPaths.length,
|
|
7232
|
+
remainingFileCount: result.remainingFiles.length,
|
|
7233
|
+
statLength: result.stat.length,
|
|
7234
|
+
nameStatusLength: result.nameStatus.length
|
|
7235
|
+
};
|
|
7236
|
+
if (result.tool === "write_file") return {
|
|
7237
|
+
hash: result.hash,
|
|
7238
|
+
bytesWritten: result.bytesWritten,
|
|
7239
|
+
lineCount: result.lineCount,
|
|
7240
|
+
bytesChanged: result.bytesChanged,
|
|
7241
|
+
lineDelta: result.lineDelta,
|
|
7242
|
+
createdParentDirs: result.createdParentDirs,
|
|
7243
|
+
kbState: result.kbState,
|
|
7244
|
+
writeEvent: result.writeEvent
|
|
7245
|
+
};
|
|
4997
7246
|
if (result.tool !== "edit_file") return {};
|
|
4998
7247
|
return {
|
|
4999
7248
|
beforeHash: result.beforeHash,
|
|
@@ -5004,6 +7253,14 @@ function summarizeToolResult(result) {
|
|
|
5004
7253
|
editEvent: result.editEvent
|
|
5005
7254
|
};
|
|
5006
7255
|
}
|
|
7256
|
+
function countLogicalLines(content) {
|
|
7257
|
+
if (content.length === 0) return 0;
|
|
7258
|
+
const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
|
|
7259
|
+
return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
|
|
7260
|
+
}
|
|
7261
|
+
function formatErrorMessage(error) {
|
|
7262
|
+
return error instanceof Error ? error.message : String(error);
|
|
7263
|
+
}
|
|
5007
7264
|
//#endregion
|
|
5008
7265
|
//#region src/agent/prompts.ts
|
|
5009
7266
|
function getChatSystemPrompt() {
|
|
@@ -5027,13 +7284,25 @@ function getChatSystemPrompt() {
|
|
|
5027
7284
|
"",
|
|
5028
7285
|
"Tool use:",
|
|
5029
7286
|
"- When using a tool, output exactly one tool JSON object and no prose, markdown, or additional JSON. After the tool result, either output the next single tool JSON object or a final plain-text answer.",
|
|
7287
|
+
"- You already have permission to use the available tools to handle the user's request. Do not ask the user to provide tool results or permission to use an available tool.",
|
|
7288
|
+
"- Do not claim to have read, created, edited, staged, committed, or run anything unless a tool result in this turn confirms it.",
|
|
7289
|
+
"- Use plan_todo for non-trivial multi-step work before the first substantive repository tool call.",
|
|
7290
|
+
"- Keep plan_todo items short, user-safe, and usually 2 to 6 items. Maintain exactly one in_progress item while work remains, update it after major progress changes, and clear it only when abandoning the plan or when no visible plan is useful.",
|
|
7291
|
+
"- Do not use plan_todo for simple one-step answers, tiny reads, or trivial edits.",
|
|
5030
7292
|
"- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
|
|
5031
7293
|
"- Use find_file for path or filename lookup. Use grep for text inside files. If grep output mentions another path, treat that mentioned path as content until find_file or read_file confirms it exists.",
|
|
5032
7294
|
"- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks.",
|
|
7295
|
+
"- Use git_status, git_diff, and git_log for Git state, diffs, and history. Prefer these over inspect_command for Git workflow inspection.",
|
|
7296
|
+
"- Use git_add and git_commit only when the user explicitly asks to stage or commit. Never stage unrelated files, never stage '.', and never commit unless staged paths exactly match the user's request.",
|
|
5033
7297
|
"- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.",
|
|
5034
7298
|
"- inspect_command is not a shell. Unsafe commands, shell expansion, scripts, installs, builds, tests, network access, and file mutation are not available through it.",
|
|
5035
7299
|
"- Use read_file before editing a file so your edit is based on current file content and hash metadata.",
|
|
7300
|
+
"- When passing expected_current_hash to edit_file or write_file, use the current pre-edit/pre-write hash from the latest read_file result for that exact file. Never invent it and never use a predicted after-edit or after-write hash.",
|
|
5036
7301
|
"- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
|
|
7302
|
+
"- Use write_file to create new files by default. It fails when the file already exists unless you are replacing the whole file with overwrite:true and expected_current_hash from read_file.",
|
|
7303
|
+
"- When the user asks you to create a new file, call write_file. Do not answer that the file was created until write_file succeeds.",
|
|
7304
|
+
"- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path.",
|
|
7305
|
+
"- Do not use inspect_command for file creation or file mutation.",
|
|
5037
7306
|
"- Keep edit_file old_text small but unique. Do not include line labels or grep prefixes in old_text; use exact file text only.",
|
|
5038
7307
|
"- Use edit/write tools when they are available and the user asks you to implement, fix, add, update, or refactor code.",
|
|
5039
7308
|
"- Use command/test tools when they are available and you need to inspect the environment, run tests, format, lint, typecheck, or verify behavior.",
|
|
@@ -5043,34 +7312,83 @@ function getChatSystemPrompt() {
|
|
|
5043
7312
|
}
|
|
5044
7313
|
//#endregion
|
|
5045
7314
|
//#region src/agent/runtime.ts
|
|
5046
|
-
const MAX_TOOL_CALLS_PER_TURN =
|
|
7315
|
+
const MAX_TOOL_CALLS_PER_TURN = 75;
|
|
5047
7316
|
var TopchesterAgentRuntime = class {
|
|
5048
7317
|
context;
|
|
7318
|
+
taskPlan = createTaskPlanController();
|
|
7319
|
+
/**
|
|
7320
|
+
* Holds the shared application context for one runtime instance.
|
|
7321
|
+
* The runtime does not own those dependencies; it coordinates the
|
|
7322
|
+
* workspace, model gateway, logger, config, and task-plan state that
|
|
7323
|
+
* are passed in by the CLI or TUI layer.
|
|
7324
|
+
*/
|
|
5049
7325
|
constructor(context) {
|
|
5050
7326
|
this.context = context;
|
|
5051
7327
|
}
|
|
7328
|
+
/**
|
|
7329
|
+
* Performs the lightweight startup model check used by the interactive
|
|
7330
|
+
* agent before accepting work. The check is intentionally non-blocking
|
|
7331
|
+
* from the user's point of view: timeout and failure both produce a
|
|
7332
|
+
* visible status message, but the runtime still moves to ready so the
|
|
7333
|
+
* user can continue.
|
|
7334
|
+
*/
|
|
5052
7335
|
async checkAgent(abortSignal) {
|
|
5053
7336
|
const result = await checkAgentReady(this.context.modelGateway, abortSignal);
|
|
5054
7337
|
if (result === "ready") return [agentEvent.status("ready")];
|
|
5055
7338
|
if (result === "timed-out") return [agentEvent.systemMessage("Agent is taking a while, so I skipped the startup check."), agentEvent.status("ready")];
|
|
5056
7339
|
return [agentEvent.systemMessage("Agent did not say it was ready."), agentEvent.status("ready")];
|
|
5057
7340
|
}
|
|
7341
|
+
/**
|
|
7342
|
+
* Builds the initial knowledge-base status events shown by the TUI.
|
|
7343
|
+
* This wraps the raw filesystem status with the same non-clean file count
|
|
7344
|
+
* used by `/kb status`, so startup messaging reflects whether project
|
|
7345
|
+
* knowledge is ready, missing, stale, or waiting for a sync.
|
|
7346
|
+
*/
|
|
5058
7347
|
async checkKnowledgeBase() {
|
|
5059
7348
|
return getKnowledgeStatusEvents(await this.getKnowledgeStatusWithNonCleanFileCount());
|
|
5060
7349
|
}
|
|
5061
|
-
|
|
5062
|
-
|
|
7350
|
+
/**
|
|
7351
|
+
* Runs one user chat turn through the agent loop. It builds the model
|
|
7352
|
+
* prompt with relevant KB context, calls the model, executes any requested
|
|
7353
|
+
* tools, feeds tool results back into the next prompt, and repeats until
|
|
7354
|
+
* the model returns a normal assistant message or the loop hits its safety
|
|
7355
|
+
* limit.
|
|
7356
|
+
*
|
|
7357
|
+
* Events are accumulated for the caller and optionally streamed through
|
|
7358
|
+
* `onEvent` as soon as tool calls, task-plan updates, choices, or final
|
|
7359
|
+
* messages are available. The method also enforces visible task-plan
|
|
7360
|
+
* closure before a final answer when the model leaves an open plan.
|
|
7361
|
+
*/
|
|
7362
|
+
async submitMessage(conversation, message, abortSignal, onEvent) {
|
|
7363
|
+
const prompt = await this.buildPromptWithKnowledgeContext(buildConversationPrompt(conversation, message), message);
|
|
5063
7364
|
const events = [];
|
|
7365
|
+
const emit = async (...nextEvents) => {
|
|
7366
|
+
events.push(...nextEvents);
|
|
7367
|
+
if (!onEvent) return;
|
|
7368
|
+
for (const event of nextEvents) await onEvent(event);
|
|
7369
|
+
};
|
|
5064
7370
|
let nextPrompt = prompt;
|
|
5065
7371
|
let totalDurationMs = 0;
|
|
5066
7372
|
let lastModelId = "model";
|
|
5067
7373
|
let afterTool;
|
|
5068
7374
|
let toolProtocolOverride = readToolProtocolEnvOverride();
|
|
7375
|
+
let requestedPlanClosure = false;
|
|
5069
7376
|
for (let toolCalls = 0; toolCalls <= MAX_TOOL_CALLS_PER_TURN; toolCalls += 1) {
|
|
5070
7377
|
const startedAt = Date.now();
|
|
7378
|
+
const system = getChatSystemPrompt();
|
|
7379
|
+
this.context.logger.debug({
|
|
7380
|
+
event: "model_prompt",
|
|
7381
|
+
purpose: "agent.primary",
|
|
7382
|
+
afterTool,
|
|
7383
|
+
toolProtocol: toolProtocolOverride,
|
|
7384
|
+
promptLength: nextPrompt.length,
|
|
7385
|
+
systemLength: system.length,
|
|
7386
|
+
prompt: nextPrompt,
|
|
7387
|
+
system
|
|
7388
|
+
}, afterTool ? "model prompt after tool" : "model prompt");
|
|
5071
7389
|
const result = await generateAgentStep(this.context, {
|
|
5072
7390
|
purpose: "agent.primary",
|
|
5073
|
-
system
|
|
7391
|
+
system,
|
|
5074
7392
|
prompt: nextPrompt,
|
|
5075
7393
|
abortSignal,
|
|
5076
7394
|
toolProtocol: toolProtocolOverride
|
|
@@ -5086,6 +7404,11 @@ var TopchesterAgentRuntime = class {
|
|
|
5086
7404
|
durationMs,
|
|
5087
7405
|
totalDurationMs,
|
|
5088
7406
|
textLength: result.text.length,
|
|
7407
|
+
usage: result.usage,
|
|
7408
|
+
inputTokens: result.usage?.inputTokens,
|
|
7409
|
+
outputTokens: result.usage?.outputTokens,
|
|
7410
|
+
totalTokens: result.usage?.totalTokens,
|
|
7411
|
+
costUsd: result.usage?.costUsd,
|
|
5089
7412
|
hasToolCall: Boolean(toolCall),
|
|
5090
7413
|
toolProtocol: result.toolProtocol,
|
|
5091
7414
|
protocolAttempts: result.protocolAttempts,
|
|
@@ -5106,25 +7429,45 @@ var TopchesterAgentRuntime = class {
|
|
|
5106
7429
|
if (result.providerRejectedTools && result.toolProtocol === "text-json") toolProtocolOverride = "text-json";
|
|
5107
7430
|
else if (result.providerRejectedTools && result.toolProtocol === "text-xml") toolProtocolOverride = "text-xml";
|
|
5108
7431
|
if (!toolCall) {
|
|
5109
|
-
|
|
7432
|
+
if (hasOpenTaskPlan(this.taskPlan.get())) {
|
|
7433
|
+
if (!requestedPlanClosure) {
|
|
7434
|
+
requestedPlanClosure = true;
|
|
7435
|
+
nextPrompt = `${nextPrompt}\n\n${formatOpenPlanClosureInstruction(result.text, result.toolProtocol)}`;
|
|
7436
|
+
continue;
|
|
7437
|
+
}
|
|
7438
|
+
await emit(agentEvent.taskPlan(this.taskPlan.update({ items: [] })));
|
|
7439
|
+
}
|
|
7440
|
+
await emit(agentEvent.assistantMessage(result.text.trim() || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs)), agentEvent.status("ready"));
|
|
5110
7441
|
return events;
|
|
5111
7442
|
}
|
|
5112
7443
|
if (toolCalls === MAX_TOOL_CALLS_PER_TURN) {
|
|
5113
|
-
|
|
7444
|
+
await emit(agentEvent.choice({
|
|
7445
|
+
tone: "warning",
|
|
7446
|
+
title: "Tool call limit reached",
|
|
7447
|
+
body: `Stopped after ${MAX_TOOL_CALLS_PER_TURN} tool calls in one turn. Continue starts another turn; abort leaves the call stopped.`,
|
|
7448
|
+
actions: [choiceAction("Continue", "Continue the previous task from where you stopped."), choiceAction("Abort", ABORT_CHOICE_VALUE)]
|
|
7449
|
+
}), agentEvent.status("ready"));
|
|
5114
7450
|
return events;
|
|
5115
7451
|
}
|
|
5116
7452
|
const executableToolCall = toolCall;
|
|
5117
|
-
const toolResult = await executeToolCall(this.context.workspaceRoot, executableToolCall, {
|
|
5118
|
-
|
|
7453
|
+
const toolResult = await executeToolCall(this.context.workspaceRoot, executableToolCall, {
|
|
7454
|
+
logger: this.context.logger,
|
|
7455
|
+
taskPlan: this.taskPlan
|
|
7456
|
+
});
|
|
7457
|
+
await emit(agentEvent.toolCall(executableToolCall, formatToolCallMessage(executableToolCall, toolResult)));
|
|
7458
|
+
if (!isToolErrorResult(toolResult) && toolResult.tool === "plan_todo") await emit(agentEvent.taskPlan(toolResult.plan));
|
|
5119
7459
|
afterTool = executableToolCall.tool;
|
|
5120
|
-
nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol)}`;
|
|
7460
|
+
nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol, toolResult)}`;
|
|
5121
7461
|
}
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs)),
|
|
5125
|
-
agentEvent.status("ready")
|
|
5126
|
-
];
|
|
7462
|
+
await emit(agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs)), agentEvent.status("ready"));
|
|
7463
|
+
return events;
|
|
5127
7464
|
}
|
|
7465
|
+
/**
|
|
7466
|
+
* Executes a slash command through the shared command dispatcher and maps
|
|
7467
|
+
* the command output into runtime events. Commands that can change KB
|
|
7468
|
+
* readiness also refresh the displayed knowledge status so the TUI footer
|
|
7469
|
+
* and chat status stay aligned with the command result.
|
|
7470
|
+
*/
|
|
5128
7471
|
async submitSlashCommand(command, onProgress) {
|
|
5129
7472
|
const result = await executeSlashCommand(command, {
|
|
5130
7473
|
workspaceRoot: this.context.workspaceRoot,
|
|
@@ -5138,6 +7481,12 @@ var TopchesterAgentRuntime = class {
|
|
|
5138
7481
|
events.push(agentEvent.status("ready"));
|
|
5139
7482
|
return events;
|
|
5140
7483
|
}
|
|
7484
|
+
/**
|
|
7485
|
+
* Reads the project KB status and augments it with a count of files that
|
|
7486
|
+
* would be touched by a dry-run compile. The dry run is only performed for
|
|
7487
|
+
* a ready KB directory, because missing or incomplete KB states already
|
|
7488
|
+
* have enough information for the startup and status messages.
|
|
7489
|
+
*/
|
|
5141
7490
|
async getKnowledgeStatusWithNonCleanFileCount() {
|
|
5142
7491
|
const status = getKnowledgeStatus(this.context.workspaceRoot);
|
|
5143
7492
|
if (!status.kbExists || !status.kbIsDirectory || status.kbContentState !== "ready") return status;
|
|
@@ -5147,7 +7496,50 @@ var TopchesterAgentRuntime = class {
|
|
|
5147
7496
|
nonCleanFileCount: result.files.length
|
|
5148
7497
|
};
|
|
5149
7498
|
}
|
|
7499
|
+
/**
|
|
7500
|
+
* Adds relevant L1 knowledge context to the conversation prompt when the
|
|
7501
|
+
* compiled KB is present and ready. Search failures are logged and then
|
|
7502
|
+
* ignored on purpose: stale or broken KB search should not prevent the
|
|
7503
|
+
* user's chat turn from reaching the model.
|
|
7504
|
+
*/
|
|
7505
|
+
async buildPromptWithKnowledgeContext(prompt, message) {
|
|
7506
|
+
const status = getKnowledgeStatus(this.context.workspaceRoot);
|
|
7507
|
+
if (!status.kbExists || !status.kbIsDirectory || status.kbContentState !== "ready") return prompt;
|
|
7508
|
+
try {
|
|
7509
|
+
const contextPack = await createL1ContextPack(this.context.workspaceRoot, message, {
|
|
7510
|
+
limit: 8,
|
|
7511
|
+
minScore: 12
|
|
7512
|
+
});
|
|
7513
|
+
this.context.logger.debug({
|
|
7514
|
+
event: "kb_context_pack",
|
|
7515
|
+
query: message,
|
|
7516
|
+
entryCount: contextPack.entryCount,
|
|
7517
|
+
relevantFileCount: contextPack.relevantFiles.length,
|
|
7518
|
+
paths: contextPack.relevantFiles.map((file) => file.path),
|
|
7519
|
+
warnings: contextPack.warnings
|
|
7520
|
+
}, "kb context pack");
|
|
7521
|
+
this.context.logger.trace({
|
|
7522
|
+
event: "kb_context_pack_payload",
|
|
7523
|
+
contextPack
|
|
7524
|
+
}, "kb context pack payload");
|
|
7525
|
+
if (contextPack.relevantFiles.length === 0) return prompt;
|
|
7526
|
+
return `${formatL1ContextPackForPrompt(contextPack)}\n\nConversation:\n${prompt}`;
|
|
7527
|
+
} catch (error) {
|
|
7528
|
+
this.context.logger.debug({
|
|
7529
|
+
event: "kb_context_pack_failed",
|
|
7530
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7531
|
+
}, "kb context pack failed");
|
|
7532
|
+
return prompt;
|
|
7533
|
+
}
|
|
7534
|
+
}
|
|
5150
7535
|
};
|
|
7536
|
+
/**
|
|
7537
|
+
* Calls the configured model gateway for a single agent step and normalizes
|
|
7538
|
+
* the result into the newer `ModelAgentResult` shape. Gateways that implement
|
|
7539
|
+
* native agent stepping receive the tool registry directly; older text-only
|
|
7540
|
+
* gateways fall back to parsing a JSON or XML tool call out of the model text
|
|
7541
|
+
* so the rest of the runtime can use the same tool loop.
|
|
7542
|
+
*/
|
|
5151
7543
|
async function generateAgentStep(context, request) {
|
|
5152
7544
|
if ("generateAgentStep" in context.modelGateway && typeof context.modelGateway.generateAgentStep === "function") return context.modelGateway.generateAgentStep({
|
|
5153
7545
|
...request,
|
|
@@ -5176,15 +7568,33 @@ async function generateAgentStep(context, request) {
|
|
|
5176
7568
|
openRouterRoutingApplied: false
|
|
5177
7569
|
};
|
|
5178
7570
|
}
|
|
7571
|
+
/**
|
|
7572
|
+
* Reads the optional environment override for the tool-calling protocol.
|
|
7573
|
+
* Invalid values are ignored instead of failing startup, which keeps local
|
|
7574
|
+
* experimentation contained to supported protocol names while preserving the
|
|
7575
|
+
* normal automatic negotiation path by default.
|
|
7576
|
+
*/
|
|
5179
7577
|
function readToolProtocolEnvOverride() {
|
|
5180
7578
|
const value = process.env.TOPCHESTER_TOOL_PROTOCOL;
|
|
5181
7579
|
if (value === "auto" || value === "native" || value === "text-json" || value === "text-xml") return value;
|
|
5182
7580
|
}
|
|
7581
|
+
/**
|
|
7582
|
+
* Applies TUI styling to per-file KB sync states. The raw scanner statuses
|
|
7583
|
+
* are preserved as text, but success, warning, and error categories get
|
|
7584
|
+
* different colors so slash-command output is readable without changing the
|
|
7585
|
+
* underlying command semantics.
|
|
7586
|
+
*/
|
|
5183
7587
|
function formatTuiSyncStatus(status) {
|
|
5184
7588
|
if (status === "current") return ui.ok(status);
|
|
5185
7589
|
if (status === "invalid" || status === "missing_file") return ui.error(status);
|
|
5186
7590
|
return ui.warn(status);
|
|
5187
7591
|
}
|
|
7592
|
+
/**
|
|
7593
|
+
* Decides whether a slash command should trigger a fresh KB status event.
|
|
7594
|
+
* Only KB subcommands that can initialize, rebuild, sync, reset, or inspect
|
|
7595
|
+
* the compiled knowledge state need the refresh; other commands can return
|
|
7596
|
+
* their output without doing extra filesystem work.
|
|
7597
|
+
*/
|
|
5188
7598
|
function shouldRefreshKnowledgeStatus(command) {
|
|
5189
7599
|
const parsed = parseSlashCommand(command);
|
|
5190
7600
|
return parsed?.name === "kb" && [
|
|
@@ -5195,19 +7605,44 @@ function shouldRefreshKnowledgeStatus(command) {
|
|
|
5195
7605
|
"status"
|
|
5196
7606
|
].includes(parsed.args[0] ?? "");
|
|
5197
7607
|
}
|
|
7608
|
+
/**
|
|
7609
|
+
* Converts a computed KB status into the startup event shape consumed by the
|
|
7610
|
+
* TUI. The event carries both the structured status and a short next-step
|
|
7611
|
+
* message, letting renderers show precise state while keeping user-facing
|
|
7612
|
+
* guidance in one place.
|
|
7613
|
+
*/
|
|
5198
7614
|
function getKnowledgeStatusEvents(status) {
|
|
5199
7615
|
return [agentEvent.knowledgeStatus(status, formatStartupKnowledgeGuidance(status))];
|
|
5200
7616
|
}
|
|
7617
|
+
/**
|
|
7618
|
+
* Produces the short guidance line shown with startup KB status. The message
|
|
7619
|
+
* is deliberately action-oriented: it points to the next command that would
|
|
7620
|
+
* fix the current state and returns nothing when the KB is ready and clean.
|
|
7621
|
+
*/
|
|
5201
7622
|
function formatStartupKnowledgeGuidance(status) {
|
|
5202
7623
|
if (!status.kbExists) return "Next: run /kb init, then /kb compile to create project knowledge.";
|
|
5203
7624
|
if (!status.kbIsDirectory) return "Fix the KB path or config, then run /kb status.";
|
|
5204
7625
|
if (status.kbContentState !== "ready") return "Next: run /kb compile to build project knowledge.";
|
|
5205
7626
|
if ((status.nonCleanFileCount ?? 0) > 0) return "Next: run /kb sync to update project knowledge, or /kb status to inspect the files.";
|
|
5206
7627
|
}
|
|
7628
|
+
/**
|
|
7629
|
+
* Serializes a tool execution result into the text that is fed back to the
|
|
7630
|
+
* model after a tool call. Each tool gets the metadata the model needs for
|
|
7631
|
+
* the next step, such as file hashes, diffs, command exit status, truncation
|
|
7632
|
+
* state, or KB dirty-state signals, while errors are presented in a uniform
|
|
7633
|
+
* error block.
|
|
7634
|
+
*/
|
|
5207
7635
|
function formatToolResultForPrompt(result) {
|
|
5208
7636
|
const path = result.path ? ` ${JSON.stringify(result.path)}` : "";
|
|
5209
7637
|
const command = result.command ? ` via ${result.command}` : "";
|
|
5210
7638
|
const warning = result.warning ? `\nWarning: ${result.warning}` : "";
|
|
7639
|
+
if (isToolErrorResult(result)) return [
|
|
7640
|
+
`Tool result from ${result.tool}${path}${command}:`,
|
|
7641
|
+
`Error: ${result.error}`,
|
|
7642
|
+
"```",
|
|
7643
|
+
result.content,
|
|
7644
|
+
"```"
|
|
7645
|
+
].join("\n");
|
|
5211
7646
|
if (result.tool === "read_file") return [
|
|
5212
7647
|
`Tool result from ${result.tool}${path}${command}:${warning}`,
|
|
5213
7648
|
`hash: ${result.hash}`,
|
|
@@ -5215,6 +7650,7 @@ function formatToolResultForPrompt(result) {
|
|
|
5215
7650
|
result.content,
|
|
5216
7651
|
"```"
|
|
5217
7652
|
].join("\n");
|
|
7653
|
+
if (result.tool === "plan_todo") return [`Tool result from ${result.tool}:`, result.content].join("\n");
|
|
5218
7654
|
if (result.tool === "edit_file") return [
|
|
5219
7655
|
`Tool result from ${result.tool}${path}:`,
|
|
5220
7656
|
`before_hash: ${result.beforeHash}`,
|
|
@@ -5226,6 +7662,18 @@ function formatToolResultForPrompt(result) {
|
|
|
5226
7662
|
result.diff,
|
|
5227
7663
|
"```"
|
|
5228
7664
|
].join("\n");
|
|
7665
|
+
if (result.tool === "write_file") return [
|
|
7666
|
+
`Tool result from ${result.tool}${path}:`,
|
|
7667
|
+
result.beforeHash ? `before_hash: ${result.beforeHash}` : "",
|
|
7668
|
+
`after_hash: ${result.hash}`,
|
|
7669
|
+
`bytes_written: ${result.bytesWritten}`,
|
|
7670
|
+
result.bytesChanged !== void 0 ? `bytes_changed: ${result.bytesChanged}` : "",
|
|
7671
|
+
`line_count: ${result.lineCount}`,
|
|
7672
|
+
result.lineDelta !== void 0 ? `line_delta: ${result.lineDelta}` : "",
|
|
7673
|
+
`kb_state: ${result.kbState}`,
|
|
7674
|
+
result.createdParentDirs.length > 0 ? `created_parent_dirs: ${result.createdParentDirs.join(", ")}` : "",
|
|
7675
|
+
`summary: ${result.writeEvent.writeSummary}`
|
|
7676
|
+
].filter(Boolean).join("\n");
|
|
5229
7677
|
if (result.tool === "inspect_command") return [
|
|
5230
7678
|
`Tool result from ${result.tool} via ${result.command}:`,
|
|
5231
7679
|
`cwd: ${result.cwd}`,
|
|
@@ -5238,6 +7686,61 @@ function formatToolResultForPrompt(result) {
|
|
|
5238
7686
|
result.content,
|
|
5239
7687
|
"```"
|
|
5240
7688
|
].filter(Boolean).join("\n");
|
|
7689
|
+
if (result.tool === "git_status") return [
|
|
7690
|
+
`Tool result from ${result.tool}${path}:`,
|
|
7691
|
+
`repo_root: ${result.repoRoot ?? "(none)"}`,
|
|
7692
|
+
`branch: ${result.branch ?? "(detached)"}`,
|
|
7693
|
+
`head: ${result.head ?? "(none)"}`,
|
|
7694
|
+
`has_head: ${result.hasHead}`,
|
|
7695
|
+
`clean: ${result.clean}`,
|
|
7696
|
+
`changed_file_count: ${result.files.length}`,
|
|
7697
|
+
`truncated: ${result.truncated}`,
|
|
7698
|
+
warning ? warning.trimStart() : "",
|
|
7699
|
+
"```",
|
|
7700
|
+
result.content,
|
|
7701
|
+
"```"
|
|
7702
|
+
].filter(Boolean).join("\n");
|
|
7703
|
+
if (result.tool === "git_diff") return [
|
|
7704
|
+
`Tool result from ${result.tool}${path}:`,
|
|
7705
|
+
`repo_root: ${result.repoRoot ?? "(none)"}`,
|
|
7706
|
+
`scope: ${result.scope}`,
|
|
7707
|
+
`path: ${result.path ?? "(all)"}`,
|
|
7708
|
+
`file_count: ${result.fileCount}`,
|
|
7709
|
+
`truncated: ${result.truncated}`,
|
|
7710
|
+
warning ? warning.trimStart() : "",
|
|
7711
|
+
"```diff",
|
|
7712
|
+
result.content,
|
|
7713
|
+
"```"
|
|
7714
|
+
].filter(Boolean).join("\n");
|
|
7715
|
+
if (result.tool === "git_log") return [
|
|
7716
|
+
`Tool result from ${result.tool}${path}:`,
|
|
7717
|
+
`repo_root: ${result.repoRoot ?? "(none)"}`,
|
|
7718
|
+
`commit_count: ${result.commits.length}`,
|
|
7719
|
+
`truncated: ${result.truncated}`,
|
|
7720
|
+
warning ? warning.trimStart() : "",
|
|
7721
|
+
"```",
|
|
7722
|
+
result.content,
|
|
7723
|
+
"```"
|
|
7724
|
+
].filter(Boolean).join("\n");
|
|
7725
|
+
if (result.tool === "git_add") return [
|
|
7726
|
+
`Tool result from ${result.tool}:`,
|
|
7727
|
+
`repo_root: ${result.repoRoot ?? "(none)"}`,
|
|
7728
|
+
`staged_paths: ${result.stagedPaths.join(", ")}`,
|
|
7729
|
+
warning ? warning.trimStart() : "",
|
|
7730
|
+
"```",
|
|
7731
|
+
result.content,
|
|
7732
|
+
"```"
|
|
7733
|
+
].filter(Boolean).join("\n");
|
|
7734
|
+
if (result.tool === "git_commit") return [
|
|
7735
|
+
`Tool result from ${result.tool}:`,
|
|
7736
|
+
`repo_root: ${result.repoRoot ?? "(none)"}`,
|
|
7737
|
+
`commit: ${result.commit.shortSha} ${result.commit.subject}`,
|
|
7738
|
+
`staged_paths: ${result.stagedPaths.join(", ")}`,
|
|
7739
|
+
`remaining_changed_file_count: ${result.remainingFiles.length}`,
|
|
7740
|
+
"```",
|
|
7741
|
+
result.content,
|
|
7742
|
+
"```"
|
|
7743
|
+
].join("\n");
|
|
5241
7744
|
return [
|
|
5242
7745
|
`Tool result from ${result.tool}${path}${command}:${warning}`,
|
|
5243
7746
|
"```",
|
|
@@ -5245,26 +7748,105 @@ function formatToolResultForPrompt(result) {
|
|
|
5245
7748
|
"```"
|
|
5246
7749
|
].join("\n");
|
|
5247
7750
|
}
|
|
5248
|
-
|
|
5249
|
-
|
|
7751
|
+
/**
|
|
7752
|
+
* Builds the follow-up instruction appended after each tool result. It keeps
|
|
7753
|
+
* the model on the active task, reminds it to maintain the visible plan, and
|
|
7754
|
+
* restates the current tool-call protocol so the next model step remains
|
|
7755
|
+
* parseable by the runtime.
|
|
7756
|
+
*/
|
|
7757
|
+
function formatContinuationInstruction(protocol, result) {
|
|
7758
|
+
const toolInstruction = protocol === "text-xml" ? "If another tool is needed, reply with only one XML tool call." : protocol === "text-json" ? "If another tool is needed, reply with only that tool JSON." : "If another tool is needed, use the available tool calling path.";
|
|
7759
|
+
return [
|
|
7760
|
+
"Continue the user's request using the tool result above and the visible plan when one is active.",
|
|
7761
|
+
result.tool === "find_file" ? "find_file results are paths only; if the user asked to read or answer from file contents, call read_file on the relevant path before answering. Do not ask the user to provide the read_file result or permission." : "",
|
|
7762
|
+
"Update plan_todo after major progress changes.",
|
|
7763
|
+
"Before a final answer, close the visible plan by calling plan_todo with all finished items marked completed, or with [] if abandoning the plan.",
|
|
7764
|
+
toolInstruction,
|
|
7765
|
+
"Otherwise answer the user. Do not guess."
|
|
7766
|
+
].filter(Boolean).join(" ");
|
|
7767
|
+
}
|
|
7768
|
+
/**
|
|
7769
|
+
* Creates the corrective prompt used when the model tries to answer while a
|
|
7770
|
+
* visible task plan is still open. The draft final answer is preserved so the
|
|
7771
|
+
* model can reuse it after closing the plan, but the immediate instruction is
|
|
7772
|
+
* to call `plan_todo` first.
|
|
7773
|
+
*/
|
|
7774
|
+
function formatOpenPlanClosureInstruction(draftAnswer, protocol) {
|
|
7775
|
+
const toolInstruction = protocol === "text-xml" ? "Reply now with only one XML plan_todo tool call." : protocol === "text-json" ? "Reply now with only the plan_todo JSON object." : "Use the available tool calling path now to call plan_todo.";
|
|
7776
|
+
const trimmedDraft = draftAnswer.trim();
|
|
7777
|
+
return [
|
|
7778
|
+
"The visible plan still has unfinished items, so do not provide the final answer yet.",
|
|
7779
|
+
"First close the plan with plan_todo: mark completed work as completed, keep one item in_progress only if work truly remains, or use [] if abandoning the plan.",
|
|
7780
|
+
toolInstruction,
|
|
7781
|
+
trimmedDraft ? `After the plan_todo result, use this draft final answer if it is still accurate:\n${trimmedDraft}` : ""
|
|
7782
|
+
].filter(Boolean).join("\n");
|
|
5250
7783
|
}
|
|
7784
|
+
/**
|
|
7785
|
+
* Formats a compact, user-visible summary for a tool call event. When a
|
|
7786
|
+
* result is available the summary includes useful completion details, such as
|
|
7787
|
+
* changed-line counts, staged paths, commit subjects, or command failures,
|
|
7788
|
+
* instead of echoing the full tool payload.
|
|
7789
|
+
*/
|
|
5251
7790
|
function formatToolCallMessage(call, result) {
|
|
7791
|
+
if (result && isToolErrorResult(result)) return `${call.tool} failed: ${result.error}`;
|
|
5252
7792
|
switch (call.tool) {
|
|
7793
|
+
case "plan_todo": return result?.tool === "plan_todo" ? `plan_todo: ${result.plan.items.length} items, ${result.inProgressCount} active` : `plan_todo: ${call.args.items.length} items`;
|
|
5253
7794
|
case "read_file": return `read_file: ${call.args.path}`;
|
|
5254
7795
|
case "list_files": return `list_files: ${call.args.path}${call.args.recursive ? " (recursive)" : ""}`;
|
|
5255
7796
|
case "grep": return `grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
|
|
5256
7797
|
case "find_file": return `find_file: ${call.args.query} in ${call.args.path}`;
|
|
5257
7798
|
case "edit_file": return `edit_file: ${call.args.path}${formatEditFileChangeSummary(result)}`;
|
|
7799
|
+
case "write_file": return `write_file: ${call.args.path}${formatWriteFileChangeSummary(result)}`;
|
|
7800
|
+
case "git_status": return `git_status: ${result?.tool === "git_status" ? `${result.files.length} changed` : call.args.path}`;
|
|
7801
|
+
case "git_diff": return `git_diff: ${formatGitDiffCallSummary(call, result)}`;
|
|
7802
|
+
case "git_log": return `git_log: ${result?.tool === "git_log" ? `${result.commits.length} commits` : `${call.args.limit} commits`}`;
|
|
7803
|
+
case "git_add": return `git_add: ${result?.tool === "git_add" ? `${result.stagedPaths.length} files staged` : `${call.args.paths.length} files`}`;
|
|
7804
|
+
case "git_commit": return `git_commit: ${result?.tool === "git_commit" ? `${result.commit.shortSha} ${result.commit.subject}` : call.args.message}`;
|
|
5258
7805
|
case "inspect_command": return `inspect_command: ${call.args.command}`;
|
|
5259
7806
|
}
|
|
5260
7807
|
}
|
|
7808
|
+
/**
|
|
7809
|
+
* Summarizes a `git_diff` call for the TUI event list. Successful results
|
|
7810
|
+
* report the resolved scope, file count, and truncation marker; pending or
|
|
7811
|
+
* failed calls fall back to the requested scope from the tool arguments.
|
|
7812
|
+
*/
|
|
7813
|
+
function formatGitDiffCallSummary(call, result) {
|
|
7814
|
+
if (result?.tool === "git_diff" && !isToolErrorResult(result)) return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
|
|
7815
|
+
return call.args.scope;
|
|
7816
|
+
}
|
|
7817
|
+
/**
|
|
7818
|
+
* Returns the parenthesized change summary for a successful `edit_file`
|
|
7819
|
+
* result. Non-edit results and failed edits intentionally return an empty
|
|
7820
|
+
* suffix so the main tool-call formatter can keep one path for success,
|
|
7821
|
+
* failure, and pre-result display.
|
|
7822
|
+
*/
|
|
5261
7823
|
function formatEditFileChangeSummary(result) {
|
|
5262
|
-
if (result?.tool !== "edit_file") return "";
|
|
7824
|
+
if (result?.tool !== "edit_file" || isToolErrorResult(result)) return "";
|
|
5263
7825
|
return ` (changed ${result.editEvent.diffSummary})`;
|
|
5264
7826
|
}
|
|
7827
|
+
/**
|
|
7828
|
+
* Returns the parenthesized write summary for a successful `write_file`
|
|
7829
|
+
* result. The helper mirrors the edit summary helper, keeping write-specific
|
|
7830
|
+
* result details out of the larger switch that formats all tool-call messages.
|
|
7831
|
+
*/
|
|
7832
|
+
function formatWriteFileChangeSummary(result) {
|
|
7833
|
+
if (result?.tool !== "write_file" || isToolErrorResult(result)) return "";
|
|
7834
|
+
return ` (${result.writeEvent.writeSummary})`;
|
|
7835
|
+
}
|
|
7836
|
+
/**
|
|
7837
|
+
* Formats the assistant-message metadata shown next to the final response.
|
|
7838
|
+
* The model identifier and cumulative turn duration are kept together here
|
|
7839
|
+
* so callers do not need to know how agent-loop timing should be presented.
|
|
7840
|
+
*/
|
|
5265
7841
|
function formatAgentMessageMeta(model, durationMs) {
|
|
5266
7842
|
return `${model} · ${formatDuration$1(durationMs)}`;
|
|
5267
7843
|
}
|
|
7844
|
+
/**
|
|
7845
|
+
* Converts elapsed milliseconds into the short human-readable duration used
|
|
7846
|
+
* in assistant metadata. Very short turns keep one decimal place, normal
|
|
7847
|
+
* sub-minute turns round to seconds, and longer turns switch to minutes plus
|
|
7848
|
+
* remaining seconds.
|
|
7849
|
+
*/
|
|
5268
7850
|
function formatDuration$1(durationMs) {
|
|
5269
7851
|
const totalSeconds = Math.max(0, durationMs / 1e3);
|
|
5270
7852
|
if (totalSeconds < 10) return `${formatNumber(totalSeconds, 1)} sec`;
|
|
@@ -5274,6 +7856,12 @@ function formatDuration$1(durationMs) {
|
|
|
5274
7856
|
if (seconds === 0) return `${minutes} min`;
|
|
5275
7857
|
return `${minutes} min ${seconds} sec`;
|
|
5276
7858
|
}
|
|
7859
|
+
/**
|
|
7860
|
+
* Formats a number with a fixed number of fraction digits using the English
|
|
7861
|
+
* locale expected by the TUI metadata strings. Keeping this tiny wrapper
|
|
7862
|
+
* avoids repeating the minimum and maximum fraction-digit options at every
|
|
7863
|
+
* call site.
|
|
7864
|
+
*/
|
|
5277
7865
|
function formatNumber(value, fractionDigits) {
|
|
5278
7866
|
return value.toLocaleString("en", {
|
|
5279
7867
|
minimumFractionDigits: fractionDigits,
|
|
@@ -5285,7 +7873,7 @@ function formatNumber(value, fractionDigits) {
|
|
|
5285
7873
|
function renderRuntimeEvent(event) {
|
|
5286
7874
|
switch (event.type) {
|
|
5287
7875
|
case "message": return [event.role === "assistant" ? agentMessage(event.text, event.meta) : systemMessage(event.text)];
|
|
5288
|
-
case "tool_call": return [
|
|
7876
|
+
case "tool_call": return [toolCallMessage(event.call, event.label)];
|
|
5289
7877
|
case "knowledge_status": return [systemMessage([`KB status: ${formatKnowledgePathStatus(event.status)}${formatKbPathSource(event.status)}`, event.guidance].filter(Boolean).join("\n"))];
|
|
5290
7878
|
case "choice": return [modalMessage({
|
|
5291
7879
|
tone: event.tone,
|
|
@@ -5293,6 +7881,7 @@ function renderRuntimeEvent(event) {
|
|
|
5293
7881
|
body: event.body,
|
|
5294
7882
|
actions: event.actions
|
|
5295
7883
|
})];
|
|
7884
|
+
case "task_plan": return [];
|
|
5296
7885
|
case "status": return [];
|
|
5297
7886
|
}
|
|
5298
7887
|
}
|
|
@@ -5306,6 +7895,7 @@ var TopchesterTuiShell = class {
|
|
|
5306
7895
|
options;
|
|
5307
7896
|
runtime;
|
|
5308
7897
|
session;
|
|
7898
|
+
taskPlanNoticeTimer;
|
|
5309
7899
|
constructor(context, runtime, options = {}) {
|
|
5310
7900
|
this.context = context;
|
|
5311
7901
|
this.options = options;
|
|
@@ -5322,7 +7912,7 @@ var TopchesterTuiShell = class {
|
|
|
5322
7912
|
const folderName = getFolderName(this.context.workspaceRoot);
|
|
5323
7913
|
const modelLabel = getModelLabel(this.context);
|
|
5324
7914
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
5325
|
-
console.log(renderStaticLayout(messages, folderName, modelLabel));
|
|
7915
|
+
console.log(renderStaticLayout(messages, folderName, modelLabel, this.options.initialTaskPlan));
|
|
5326
7916
|
return;
|
|
5327
7917
|
}
|
|
5328
7918
|
const terminal = new ProcessTerminal();
|
|
@@ -5341,6 +7931,7 @@ var TopchesterTuiShell = class {
|
|
|
5341
7931
|
process.exit(0);
|
|
5342
7932
|
}
|
|
5343
7933
|
});
|
|
7934
|
+
app.setTaskPlan(this.options.initialTaskPlan);
|
|
5344
7935
|
app.setSubmitMessage((message) => {
|
|
5345
7936
|
this.submitChatMessage(app, tui, message);
|
|
5346
7937
|
});
|
|
@@ -5383,7 +7974,7 @@ var TopchesterTuiShell = class {
|
|
|
5383
7974
|
busy.start();
|
|
5384
7975
|
tui.requestRender();
|
|
5385
7976
|
try {
|
|
5386
|
-
await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal));
|
|
7977
|
+
await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal), tui);
|
|
5387
7978
|
} catch (error) {
|
|
5388
7979
|
if (cancelled) {
|
|
5389
7980
|
app.addMessage(systemMessage("Agent check stopped."));
|
|
@@ -5397,7 +7988,7 @@ var TopchesterTuiShell = class {
|
|
|
5397
7988
|
app.setCancelPending(void 0);
|
|
5398
7989
|
busy.stop();
|
|
5399
7990
|
}
|
|
5400
|
-
if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase());
|
|
7991
|
+
if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase(), tui);
|
|
5401
7992
|
tui.requestRender();
|
|
5402
7993
|
}
|
|
5403
7994
|
async submitChatMessage(app, tui, message) {
|
|
@@ -5419,12 +8010,16 @@ var TopchesterTuiShell = class {
|
|
|
5419
8010
|
busy.start();
|
|
5420
8011
|
tui.requestRender();
|
|
5421
8012
|
try {
|
|
8013
|
+
await this.clearTaskPlanForNewTurn(app);
|
|
5422
8014
|
await this.persistPayloadWithWarning(app, {
|
|
5423
8015
|
kind: "message",
|
|
5424
8016
|
role: "user",
|
|
5425
8017
|
text: message
|
|
5426
8018
|
});
|
|
5427
|
-
await this.
|
|
8019
|
+
await this.runtime.submitMessage(app.getConversationTurns(), message, abortController.signal, async (event) => {
|
|
8020
|
+
await this.applyRuntimeEvents(app, [event], tui);
|
|
8021
|
+
tui.requestRender();
|
|
8022
|
+
});
|
|
5428
8023
|
} catch (error) {
|
|
5429
8024
|
if (cancelled) {
|
|
5430
8025
|
app.addMessage(systemMessage("Response stopped."));
|
|
@@ -5454,10 +8049,11 @@ var TopchesterTuiShell = class {
|
|
|
5454
8049
|
busy.start();
|
|
5455
8050
|
tui.requestRender();
|
|
5456
8051
|
try {
|
|
8052
|
+
await this.clearTaskPlanForNewTurn(app);
|
|
5457
8053
|
await this.persistPayloadWithWarning(app, slashCommandToSessionPayload(command));
|
|
5458
8054
|
await this.applyRuntimeEvents(app, await this.runtime.submitSlashCommand(command, (event) => {
|
|
5459
8055
|
busy.setActivity(event.message);
|
|
5460
|
-
}));
|
|
8056
|
+
}), tui);
|
|
5461
8057
|
} catch (error) {
|
|
5462
8058
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
5463
8059
|
app.addMessage(systemMessage(`Command failed: ${errorMessage}`));
|
|
@@ -5471,14 +8067,41 @@ var TopchesterTuiShell = class {
|
|
|
5471
8067
|
tui.requestRender();
|
|
5472
8068
|
}
|
|
5473
8069
|
}
|
|
5474
|
-
async applyRuntimeEvents(app, events) {
|
|
8070
|
+
async applyRuntimeEvents(app, events, renderRequester) {
|
|
5475
8071
|
for (const event of events) {
|
|
5476
8072
|
if (event.type === "status") app.setStatus(event.status);
|
|
5477
8073
|
if (event.type === "knowledge_status") app.setKnowledgeStatus(event.status);
|
|
8074
|
+
if (event.type === "task_plan") {
|
|
8075
|
+
const change = app.setTaskPlan(event.plan);
|
|
8076
|
+
app.setTaskPlanNotice(formatTaskPlanNotice(change, event.plan));
|
|
8077
|
+
this.scheduleTaskPlanNoticeClear(app, renderRequester);
|
|
8078
|
+
}
|
|
5478
8079
|
for (const message of renderRuntimeEvent(event)) app.addMessage(message);
|
|
5479
8080
|
await this.persistPayloadWithWarning(app, runtimeEventToSessionPayload(event));
|
|
5480
8081
|
}
|
|
5481
8082
|
}
|
|
8083
|
+
scheduleTaskPlanNoticeClear(app, renderRequester) {
|
|
8084
|
+
if (this.taskPlanNoticeTimer) {
|
|
8085
|
+
clearTimeout(this.taskPlanNoticeTimer);
|
|
8086
|
+
this.taskPlanNoticeTimer = void 0;
|
|
8087
|
+
}
|
|
8088
|
+
if (!renderRequester) return;
|
|
8089
|
+
this.taskPlanNoticeTimer = setTimeout(() => {
|
|
8090
|
+
this.taskPlanNoticeTimer = void 0;
|
|
8091
|
+
app.setTaskPlanNotice(void 0);
|
|
8092
|
+
renderRequester.requestRender();
|
|
8093
|
+
}, 2500);
|
|
8094
|
+
this.taskPlanNoticeTimer.unref?.();
|
|
8095
|
+
}
|
|
8096
|
+
async clearTaskPlanForNewTurn(app) {
|
|
8097
|
+
const clearedPlan = app.clearTaskPlan();
|
|
8098
|
+
if (!clearedPlan) return;
|
|
8099
|
+
await this.persistPayloadWithWarning(app, {
|
|
8100
|
+
kind: "task_plan",
|
|
8101
|
+
items: clearedPlan.items,
|
|
8102
|
+
updatedAt: clearedPlan.updatedAt
|
|
8103
|
+
});
|
|
8104
|
+
}
|
|
5482
8105
|
async persistPayloadWithWarning(app, payload) {
|
|
5483
8106
|
if (!this.session || !payload) return;
|
|
5484
8107
|
try {
|
|
@@ -5501,9 +8124,14 @@ async function persistMessagesWithWarning(session, messages, warningTarget = mes
|
|
|
5501
8124
|
}
|
|
5502
8125
|
}
|
|
5503
8126
|
function chatMessageToSessionPayload(message) {
|
|
5504
|
-
if (message.kind === "system" || message.kind === "user"
|
|
8127
|
+
if (message.kind === "system" || message.kind === "user") return {
|
|
8128
|
+
kind: "message",
|
|
8129
|
+
role: message.kind,
|
|
8130
|
+
text: message.text
|
|
8131
|
+
};
|
|
8132
|
+
if (message.kind === "agent") return {
|
|
5505
8133
|
kind: "message",
|
|
5506
|
-
role:
|
|
8134
|
+
role: "assistant",
|
|
5507
8135
|
text: message.text,
|
|
5508
8136
|
...message.meta === void 0 ? {} : { meta: message.meta }
|
|
5509
8137
|
};
|
|
@@ -5514,6 +8142,11 @@ function chatMessageToSessionPayload(message) {
|
|
|
5514
8142
|
...message.body === void 0 ? {} : { body: message.body },
|
|
5515
8143
|
actions: message.actions
|
|
5516
8144
|
};
|
|
8145
|
+
if (message.kind === "tool_call") return {
|
|
8146
|
+
kind: "tool_call",
|
|
8147
|
+
label: message.label,
|
|
8148
|
+
call: message.call
|
|
8149
|
+
};
|
|
5517
8150
|
}
|
|
5518
8151
|
function runtimeEventToSessionPayload(event) {
|
|
5519
8152
|
switch (event.type) {
|
|
@@ -5528,6 +8161,11 @@ function runtimeEventToSessionPayload(event) {
|
|
|
5528
8161
|
label: event.label,
|
|
5529
8162
|
call: event.call
|
|
5530
8163
|
};
|
|
8164
|
+
case "task_plan": return {
|
|
8165
|
+
kind: "task_plan",
|
|
8166
|
+
items: event.plan.items,
|
|
8167
|
+
updatedAt: event.plan.updatedAt
|
|
8168
|
+
};
|
|
5531
8169
|
case "knowledge_status": return;
|
|
5532
8170
|
case "choice": return {
|
|
5533
8171
|
kind: "choice",
|
|
@@ -5738,16 +8376,19 @@ async function resolveRunSession(workspaceRoot, resume) {
|
|
|
5738
8376
|
}
|
|
5739
8377
|
async function loadConversation(workspaceRoot, resume) {
|
|
5740
8378
|
return rehydrateSession((await loadSession(workspaceRoot, resume)).events).messages.flatMap((message) => {
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
8379
|
+
switch (message.kind) {
|
|
8380
|
+
case "user": return message.modelContext === false ? [] : [{
|
|
8381
|
+
role: "user",
|
|
8382
|
+
text: message.text
|
|
8383
|
+
}];
|
|
8384
|
+
case "agent": return message.modelContext === false ? [] : [{
|
|
8385
|
+
role: "assistant",
|
|
8386
|
+
text: message.text
|
|
8387
|
+
}];
|
|
8388
|
+
case "system":
|
|
8389
|
+
case "tool_call":
|
|
8390
|
+
case "modal": return [];
|
|
8391
|
+
}
|
|
5751
8392
|
});
|
|
5752
8393
|
}
|
|
5753
8394
|
async function persistStartupMessages(session, context) {
|
|
@@ -5773,7 +8414,14 @@ function printPlainEvent(event) {
|
|
|
5773
8414
|
console.log(event.label);
|
|
5774
8415
|
return;
|
|
5775
8416
|
}
|
|
5776
|
-
if (event.type === "knowledge_status" && event.guidance)
|
|
8417
|
+
if (event.type === "knowledge_status" && event.guidance) {
|
|
8418
|
+
console.log(event.guidance);
|
|
8419
|
+
return;
|
|
8420
|
+
}
|
|
8421
|
+
if (event.type === "task_plan") {
|
|
8422
|
+
const notice = formatTaskPlanNotice("updated", event.plan);
|
|
8423
|
+
if (notice) console.log(notice);
|
|
8424
|
+
}
|
|
5777
8425
|
}
|
|
5778
8426
|
function pushJson(events, runId, sessionId, type, fields) {
|
|
5779
8427
|
events.push({
|
|
@@ -5805,9 +8453,12 @@ program.action(async () => {
|
|
|
5805
8453
|
try {
|
|
5806
8454
|
if (options.resume) {
|
|
5807
8455
|
const loaded = await loadSession(context.workspaceRoot, options.resume);
|
|
8456
|
+
const session = await loadSessionForAppend(context.workspaceRoot, loaded.sessionId);
|
|
8457
|
+
const rehydrated = rehydrateSession(loaded.events);
|
|
5808
8458
|
await new TopchesterTuiShell(context, void 0, {
|
|
5809
|
-
session
|
|
5810
|
-
initialMessages:
|
|
8459
|
+
session,
|
|
8460
|
+
initialMessages: rehydrated.messages,
|
|
8461
|
+
initialTaskPlan: rehydrated.taskPlan
|
|
5811
8462
|
}).render();
|
|
5812
8463
|
return;
|
|
5813
8464
|
}
|
|
@@ -5839,6 +8490,9 @@ program.command("run").description("run one prompt or slash command without open
|
|
|
5839
8490
|
process.exitCode = 1;
|
|
5840
8491
|
}
|
|
5841
8492
|
});
|
|
8493
|
+
program.command("search").description("search compiled L1 knowledge entries").argument("<query...>", "search query").option("--limit <count>", "maximum number of matches", parsePositiveInteger).option("--json", "write full JSON search result to stdout").action(async (queryParts, options) => {
|
|
8494
|
+
await executeKbSearchCommand(queryParts, options);
|
|
8495
|
+
});
|
|
5842
8496
|
const kbCommand = program.command("kb").description("knowledge base commands");
|
|
5843
8497
|
kbCommand.command("init").description("initialize a project knowledge base").action(async () => {
|
|
5844
8498
|
const context = createContextFromOptions();
|
|
@@ -5872,6 +8526,12 @@ kbCommand.command("sync").description("sync non-clean project files into the kno
|
|
|
5872
8526
|
console.log(formatKnowledgeSyncResult(result).join("\n"));
|
|
5873
8527
|
if (isPartialKnowledgeCompileResult(result)) process.exitCode = 2;
|
|
5874
8528
|
});
|
|
8529
|
+
kbCommand.command("search").alias("query").description("search compiled L1 knowledge entries").argument("<query...>", "search query").option("--limit <count>", "maximum number of matches", parsePositiveInteger).option("--json", "write full JSON search result to stdout").action(async (queryParts, options) => {
|
|
8530
|
+
await executeKbSearchCommand(queryParts, options);
|
|
8531
|
+
});
|
|
8532
|
+
kbCommand.command("context").description("create an L1 context pack for a query").argument("<query...>", "context query").option("--limit <count>", "maximum number of relevant files", parsePositiveInteger).option("--min-score <score>", "minimum match score", parseNonNegativeNumber).option("--json", "write JSON context pack to stdout").option("--full-l1", "include full raw L1 entries in JSON output").action(async (queryParts, options) => {
|
|
8533
|
+
await executeKbContextCommand(queryParts, options);
|
|
8534
|
+
});
|
|
5875
8535
|
kbCommand.command("reset").description("delete the local project knowledge base and cache").action(async () => {
|
|
5876
8536
|
const context = createContextFromOptions();
|
|
5877
8537
|
const result = await ui.progress("Resetting project knowledge base...", (report) => resetKnowledgeBase(context.workspaceRoot, { onProgress: (event) => report(event.message) }));
|
|
@@ -5918,6 +8578,31 @@ function createContextFromOptions() {
|
|
|
5918
8578
|
devFlags: options.dev
|
|
5919
8579
|
});
|
|
5920
8580
|
}
|
|
8581
|
+
async function executeKbSearchCommand(queryParts, options) {
|
|
8582
|
+
const context = createContextFromOptions();
|
|
8583
|
+
const query = queryParts.join(" ");
|
|
8584
|
+
const result = options.json ? await searchL1Knowledge(context.workspaceRoot, query, { limit: options.limit }) : await ui.spinner("Searching L1 knowledge entries...", () => searchL1Knowledge(context.workspaceRoot, query, { limit: options.limit }));
|
|
8585
|
+
if (options.json) {
|
|
8586
|
+
console.log(JSON.stringify(stripEmptyContainers(result), null, 2));
|
|
8587
|
+
return;
|
|
8588
|
+
}
|
|
8589
|
+
console.log(formatL1KnowledgeSearchResult(result).join("\n"));
|
|
8590
|
+
}
|
|
8591
|
+
async function executeKbContextCommand(queryParts, options) {
|
|
8592
|
+
const context = createContextFromOptions();
|
|
8593
|
+
const query = queryParts.join(" ");
|
|
8594
|
+
const contextPackOptions = {
|
|
8595
|
+
limit: options.limit,
|
|
8596
|
+
minScore: options.minScore,
|
|
8597
|
+
includeFullL1: options.fullL1
|
|
8598
|
+
};
|
|
8599
|
+
const result = options.json ? await createL1ContextPack(context.workspaceRoot, query, contextPackOptions) : await ui.spinner("Creating L1 context pack...", () => createL1ContextPack(context.workspaceRoot, query, contextPackOptions));
|
|
8600
|
+
if (options.json) {
|
|
8601
|
+
console.log(JSON.stringify(stripEmptyContainers(result), null, 2));
|
|
8602
|
+
return;
|
|
8603
|
+
}
|
|
8604
|
+
console.log(formatL1ContextPackResult(result).join("\n"));
|
|
8605
|
+
}
|
|
5921
8606
|
function formatStartupError(error) {
|
|
5922
8607
|
const message = error instanceof Error ? error.message : String(error);
|
|
5923
8608
|
if (message.includes("Could not read session metadata") || message.includes("Could not read session event") || message.includes("ENOENT")) return message.includes("metadata.json") || message.includes("events.jsonl") ? message : "Session not found";
|
|
@@ -5931,6 +8616,11 @@ function parsePositiveInteger(value) {
|
|
|
5931
8616
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Expected a positive integer.");
|
|
5932
8617
|
return parsed;
|
|
5933
8618
|
}
|
|
8619
|
+
function parseNonNegativeNumber(value) {
|
|
8620
|
+
const parsed = Number(value);
|
|
8621
|
+
if (!Number.isFinite(parsed) || parsed < 0) throw new Error("Expected a non-negative number.");
|
|
8622
|
+
return parsed;
|
|
8623
|
+
}
|
|
5934
8624
|
function formatDryRunSyncStatus(status) {
|
|
5935
8625
|
if (status === "current") return ui.ok(status);
|
|
5936
8626
|
if (status === "invalid" || status === "missing_file") return ui.error(status);
|