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 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 { Input, Markdown, ProcessTerminal, TUI, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
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.editEvents.push(event);
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\",\"expected_hash\":\"sha256:optional-current-file-hash\",\"edits\":[{\"old_text\":\"const enabled = false;\\n\",\"new_text\":\"const enabled = true;\\n\"}]}}",
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
- expected_hash: z.string().optional(),
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$3(workspaceRoot, args.path);
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.expected_hash && args.expected_hash !== beforeHash) throw new Error(`edit_file expected_hash did not match ${scopedPath.relativePath}.`);
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$3(workspaceRoot, path) {
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$2(error) && error.code === "ENOENT") throw new Error(`edit_file can only edit existing files: ${originalPath}`);
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$2(error) {
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$2(workspaceRoot, args.path);
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$2(workspaceRoot, path) {
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$1(workspaceRoot, args.path ?? ".");
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$1(workspaceRoot, path) {
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
- const readFileTool = defineTool({
1597
- name: "read_file",
1598
- description: "Read a UTF-8 file inside the workspace.",
1599
- 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\"}}",
1600
- argsSchema: z.object({ path: z.string() }),
1601
- execute: (context, args) => readWorkspaceFile(context.workspaceRoot, args.path)
1602
- });
1603
- async function readWorkspaceFile(workspaceRoot, path) {
1604
- const resolvedWorkspace = resolve(workspaceRoot);
1605
- const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
1606
- const relativePath = relative(resolvedWorkspace, resolvedPath);
1607
- if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`read_file can only read files inside the workspace: ${path}`);
1608
- const bytes = await readFile(resolvedPath);
1609
- const content = bytes.toString("utf8");
1610
- return {
1611
- tool: "read_file",
1612
- path: relativePath || ".",
1613
- content,
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: (await generateText({
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$1(error);
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$1(error).toLowerCase();
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$1(warning));
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 formatErrorMessage$1(error) {
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 summarize one repository file for Topchester's L1 knowledge base.",
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: normalizeStringArray(record.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" && item.trim().length > 0);
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
- if (typeof record.kind !== "string" || record.kind.trim().length === 0) return [];
3040
- if (typeof record.value !== "string" || record.value.trim().length === 0) return [];
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: record.kind,
3043
- value: record.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
- return value.flatMap((item) => {
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: false,
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
- return [{
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: typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary : `Symbol named ${name}.`
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$1(error)) return "missing_entry";
4895
+ if (isFileNotFoundError$2(error)) return "missing_entry";
3476
4896
  return "invalid";
3477
4897
  }
3478
4898
  }
3479
- function isFileNotFoundError$1(error) {
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
- kind: "system",
3745
- text: event.label
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/tui/markdown.ts
4144
- const codeFenceSentinel = "topchester-code-fence";
4145
- function renderMarkdown(text, width) {
4146
- const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
4147
- const rendered = [];
4148
- let inCodeBlock = false;
4149
- for (const line of lines) {
4150
- if (line.includes(codeFenceSentinel)) {
4151
- inCodeBlock = !inCodeBlock;
4152
- continue;
4153
- }
4154
- rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
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
- return rendered;
4157
- }
4158
- function unwrapMarkdownCodeFences(text) {
4159
- return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
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
- input = new Input();
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.input.setValue(value);
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
- if (message.kind === "user" && message.modelContext !== false) return [{
4603
- role: "user",
4604
- text: message.text
4605
- }];
4606
- if (message.kind === "agent" && message.text !== "ready" && message.modelContext !== false) return [{
4607
- role: "assistant",
4608
- text: message.text
4609
- }];
4610
- return [];
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.input.focused;
6588
+ return this.inputFocused;
4615
6589
  }
4616
6590
  set focused(value) {
4617
- this.input.focused = value;
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
- const previousInput = this.input.getValue();
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 inputLine = this.promptHint ? truncateToWidth(ui.label(this.promptHint), innerWidth, "…", true) : truncateToWidth(renderInputWithoutPrompt(this.input, innerWidth), innerWidth, "…", true);
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}${inputLine} │`,
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.input.getValue());
4777
- if (prompt !== void 0) this.input.setValue(prompt);
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) this.input.setValue(prompt);
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.input.getValue().trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
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.input.setValue(suggestions[this.activeSlashSuggestionIndex]?.value ?? this.input.getValue());
4813
- this.input.handleInput("\x1B[F");
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.input.getValue());
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 renderInputWithoutPrompt(input, width) {
4839
- return (input.render(width + 2)[0] ?? "").replace(/^> /, "");
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 result = await definition.execute(context, call.args);
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
- options.logger?.error({
4969
- event: "tool_error",
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
- }, "tool failed");
4974
- throw error;
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
- expectedHashProvided: Boolean(call.args.expected_hash)
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 = 8;
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
- async submitMessage(conversation, message, abortSignal) {
5062
- const prompt = buildConversationPrompt(conversation, message);
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: getChatSystemPrompt(),
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
- events.push(agentEvent.assistantMessage(result.text.trim() || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs)), agentEvent.status("ready"));
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
- events.push(agentEvent.systemMessage(`Stopped after ${MAX_TOOL_CALLS_PER_TURN} tool calls in one turn.`), agentEvent.status("ready"));
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, { logger: this.context.logger });
5118
- events.push(agentEvent.toolCall(executableToolCall, formatToolCallMessage(executableToolCall, toolResult)));
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
- return [
5123
- ...events,
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
- function formatContinuationInstruction(protocol) {
5249
- return `Continue the user's request using the tool result above. ${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."} Otherwise answer the user. Do not guess.`;
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 [systemMessage(event.label)];
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.applyRuntimeEvents(app, await this.runtime.submitMessage(app.getConversationTurns(), message, abortController.signal));
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" || message.kind === "agent") return {
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: message.kind === "agent" ? "assistant" : message.kind,
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
- if (message.kind === "modal" || message.modelContext === false) return [];
5742
- if (message.kind === "user") return [{
5743
- role: "user",
5744
- text: message.text
5745
- }];
5746
- if (message.kind === "agent") return [{
5747
- role: "assistant",
5748
- text: message.text
5749
- }];
5750
- return [];
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) console.log(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: await loadSessionForAppend(context.workspaceRoot, loaded.sessionId),
5810
- initialMessages: rehydrateSession(loaded.events).messages
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);