topchester-ai 0.10.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) {
@@ -97,23 +97,26 @@ async function enqueueFileMutation(path, mutate) {
97
97
  }
98
98
  //#endregion
99
99
  //#region src/agent/tools/types.ts
100
+ function isToolErrorResult(result) {
101
+ return "error" in result && typeof result.error === "string";
102
+ }
100
103
  function defineTool(definition) {
101
104
  return definition;
102
105
  }
103
106
  //#endregion
104
107
  //#region src/agent/tools/edit-file.ts
105
108
  const editFileEditSchema = z.object({
106
- old_text: z.string(),
107
- 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.")
108
111
  });
109
112
  const editFileTool = defineTool({
110
113
  name: "edit_file",
111
- description: "Edit an existing UTF-8 file inside the workspace with exact text replacements.",
112
- 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\"}]}}",
113
116
  argsSchema: z.object({
114
- path: z.string(),
115
- expected_hash: z.string().optional(),
116
- 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.")
117
120
  }),
118
121
  execute: (context, args) => editWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
119
122
  });
@@ -123,7 +126,7 @@ async function editWorkspaceFile(workspaceRoot, args, options = {}) {
123
126
  const fileStat = await statExistingFile(scopedPath.path, args.path);
124
127
  const beforeBytes = await readFile(scopedPath.path);
125
128
  const beforeHash = hashBytes$1(beforeBytes);
126
- if (args.expected_hash && args.expected_hash !== beforeHash) throw new Error(`edit_file expected_hash did not match ${scopedPath.relativePath}.`);
129
+ if (args.expected_current_hash && args.expected_current_hash !== beforeHash) throw new Error(`edit_file expected_current_hash did not match ${scopedPath.relativePath}.`);
127
130
  const result = applyExactEdits(decodeUtf8$1(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
128
131
  const afterBytes = Buffer.from(result.newContent, "utf8");
129
132
  const afterHash = hashBytes$1(afterBytes);
@@ -376,7 +379,7 @@ const ignoredDirectories = new Set([
376
379
  ]);
377
380
  const findFileTool = defineTool({
378
381
  name: "find_file",
379
- 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.",
380
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\"}}",
381
384
  argsSchema: findFileArgsSchema,
382
385
  execute: (context, args) => findWorkspaceFilesByName(context.workspaceRoot, args, {
@@ -829,7 +832,7 @@ function parseGitLog(output) {
829
832
  };
830
833
  });
831
834
  }
832
- function truncateText(content, maxBytes) {
835
+ function truncateText$1(content, maxBytes) {
833
836
  if (Buffer.byteLength(content) <= maxBytes) return {
834
837
  content,
835
838
  truncated: false
@@ -1056,7 +1059,7 @@ async function inspectGitDiff(context, args) {
1056
1059
  changedFiles.add(file);
1057
1060
  }
1058
1061
  }
1059
- const bounded = truncateText(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
1062
+ const bounded = truncateText$1(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
1060
1063
  return {
1061
1064
  tool: "git_diff",
1062
1065
  path: path ?? void 0,
@@ -1644,6 +1647,8 @@ const READ_ONLY_COMMANDS = new Set([
1644
1647
  "find",
1645
1648
  "fd",
1646
1649
  "cat",
1650
+ "sed",
1651
+ "sort",
1647
1652
  "head",
1648
1653
  "tail",
1649
1654
  "wc",
@@ -1851,6 +1856,7 @@ function validateSimpleCommand(command, context) {
1851
1856
  case "git": return validateGitCommand(command, context);
1852
1857
  case "find": return validateFindCommand(command, context);
1853
1858
  case "fd": return validateGenericCommandArgs(command, context, FD_OPTIONS_WITH_VALUES);
1859
+ case "sed": return validateSedCommand(command, context);
1854
1860
  default: return validateGenericCommandArgs(command, context, COMMON_OPTIONS_WITH_VALUES);
1855
1861
  }
1856
1862
  }
@@ -1873,6 +1879,88 @@ function validateGitCommand(command, context) {
1873
1879
  function validateFindCommand(command, context) {
1874
1880
  return validateGenericCommandArgs(command, context, FIND_OPTIONS_WITH_VALUES);
1875
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
+ }
1876
1964
  function validateGenericCommandArgs(command, context, optionsWithValues, knownPathlessWords = /* @__PURE__ */ new Set()) {
1877
1965
  for (let index = 0; index < command.args.length; index += 1) {
1878
1966
  const arg = command.args[index] ?? "";
@@ -2310,6 +2398,256 @@ function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
2310
2398
  relativePath: relativePath || "."
2311
2399
  };
2312
2400
  }
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
+ });
2313
2651
  const readFileTool = defineTool({
2314
2652
  name: "read_file",
2315
2653
  description: "Read a UTF-8 file inside the workspace.",
@@ -2333,14 +2671,14 @@ async function readWorkspaceFile(workspaceRoot, path) {
2333
2671
  }
2334
2672
  const writeFileTool = defineTool({
2335
2673
  name: "write_file",
2336
- description: "Create a new UTF-8 file inside the workspace, or explicitly replace one with an expected hash.",
2337
- 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, and replace an existing whole file only with overwrite:true and expected_hash from read_file. To use it, 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}}",
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}}",
2338
2676
  argsSchema: z.object({
2339
- path: z.string(),
2340
- content: z.string(),
2341
- create_parent_dirs: z.boolean().optional(),
2342
- overwrite: z.boolean().optional(),
2343
- expected_hash: z.string().optional()
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.")
2344
2682
  }),
2345
2683
  execute: (context, args) => writeWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
2346
2684
  });
@@ -2396,12 +2734,12 @@ async function writeWorkspaceFile(workspaceRoot, args, options = {}) {
2396
2734
  });
2397
2735
  }
2398
2736
  async function overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options) {
2399
- if (!args.expected_hash) throw new Error(`write_file overwrite requires expected_hash for ${scopedPath.relativePath}.`);
2737
+ if (!args.expected_current_hash) throw new Error(`write_file overwrite requires expected_current_hash for ${scopedPath.relativePath}.`);
2400
2738
  if (!existingTarget) throw new Error(`write_file overwrite requires an existing file: ${scopedPath.relativePath}`);
2401
2739
  if (!existingTarget.isFile()) throw new Error(`write_file overwrite requires a regular file: ${scopedPath.relativePath}`);
2402
2740
  const beforeBytes = await readFile(scopedPath.path);
2403
2741
  const beforeHash = hashBytes(beforeBytes);
2404
- if (args.expected_hash !== beforeHash) throw new Error(`write_file expected_hash did not match ${scopedPath.relativePath}.`);
2742
+ if (args.expected_current_hash !== beforeHash) throw new Error(`write_file expected_current_hash did not match ${scopedPath.relativePath}.`);
2405
2743
  const beforeLineCount = countLogicalLines$1(decodeUtf8(scopedPath.relativePath, beforeBytes));
2406
2744
  const afterBytes = encodeUtf8Text(args.content);
2407
2745
  const afterHash = hashBytes(afterBytes);
@@ -2543,6 +2881,7 @@ function isNodeError$2(error) {
2543
2881
  //#endregion
2544
2882
  //#region src/agent/tools/registry.ts
2545
2883
  const toolRegistry = {
2884
+ [planTodoTool.name]: planTodoTool,
2546
2885
  [readFileTool.name]: readFileTool,
2547
2886
  [listFilesTool.name]: listFilesTool,
2548
2887
  [grepTool.name]: grepTool,
@@ -2769,16 +3108,23 @@ var ModelGateway = class ModelGateway {
2769
3108
  }
2770
3109
  async generateText(request) {
2771
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
+ });
2772
3122
  return {
2773
- text: (await generateText({
2774
- model: resolved.model,
2775
- system: request.system,
2776
- prompt: request.prompt,
2777
- abortSignal: request.abortSignal
2778
- })).text,
3123
+ text: result.text,
2779
3124
  providerId: resolved.providerId,
2780
3125
  modelId: resolved.modelId,
2781
- purpose: resolved.purpose
3126
+ purpose: resolved.purpose,
3127
+ ...usage ? { usage } : {}
2782
3128
  };
2783
3129
  }
2784
3130
  async generateAgentStep(request) {
@@ -2811,7 +3157,7 @@ var ModelGateway = class ModelGateway {
2811
3157
  }
2812
3158
  return result;
2813
3159
  } catch (error) {
2814
- const reason = formatErrorMessage$1(error);
3160
+ const reason = formatErrorMessage$2(error);
2815
3161
  attempts.push({
2816
3162
  protocol: "native-openai-compatible",
2817
3163
  status: "failed",
@@ -2847,6 +3193,11 @@ var ModelGateway = class ModelGateway {
2847
3193
  providerOptions,
2848
3194
  abortSignal: request.abortSignal
2849
3195
  });
3196
+ const usage = normalizeUsage(result.usage, {
3197
+ providerId: resolved.providerId,
3198
+ providerConfig: resolved.providerConfig,
3199
+ responseBody: result.response.body
3200
+ });
2850
3201
  const toolCalls = result.toolCalls.map((call, index) => {
2851
3202
  const parsed = parseNativeToolCall(call.toolName, call.input);
2852
3203
  if (!parsed) throw new Error(`Native tool call for ${call.toolName} did not match the registered schema.`);
@@ -2866,6 +3217,7 @@ var ModelGateway = class ModelGateway {
2866
3217
  providerId: resolved.providerId,
2867
3218
  modelId: resolved.modelId,
2868
3219
  purpose: resolved.purpose,
3220
+ ...usage ? { usage } : {},
2869
3221
  toolCalls,
2870
3222
  toolProtocol: "native-openai-compatible",
2871
3223
  protocolAttempts: attempts,
@@ -2881,6 +3233,11 @@ var ModelGateway = class ModelGateway {
2881
3233
  prompt: request.prompt,
2882
3234
  abortSignal: request.abortSignal
2883
3235
  });
3236
+ const usage = normalizeUsage(result.usage, {
3237
+ providerId: resolved.providerId,
3238
+ providerConfig: resolved.providerConfig,
3239
+ responseBody: result.response.body
3240
+ });
2884
3241
  const parsed = parseToolCallWithSource(result.text, allowedSources);
2885
3242
  const defaultProtocol = allowedSources.length === 1 && allowedSources[0] === "text-xml" ? "text-xml" : "text-json";
2886
3243
  const toolProtocol = parsed?.source === "text-xml" ? "text-xml" : parsed ? "text-json" : defaultProtocol;
@@ -2894,6 +3251,7 @@ var ModelGateway = class ModelGateway {
2894
3251
  providerId: resolved.providerId,
2895
3252
  modelId: resolved.modelId,
2896
3253
  purpose: resolved.purpose,
3254
+ ...usage ? { usage } : {},
2897
3255
  toolCalls: parsed ? [{
2898
3256
  id: `${parsed.source}-0`,
2899
3257
  tool: parsed.call.tool,
@@ -2922,6 +3280,9 @@ function buildNativeProviderOptions(providerId, config) {
2922
3280
  function shouldApplyOpenRouterRoutingOptions(providerId, config) {
2923
3281
  if (config.openRouterToolRouting === "force") return true;
2924
3282
  if (config.openRouterToolRouting === "off") return false;
3283
+ return isOpenRouterProvider(providerId, config);
3284
+ }
3285
+ function isOpenRouterProvider(providerId, config) {
2925
3286
  return providerId.toLowerCase().includes("openrouter") || config.baseURL.toLowerCase().includes("openrouter.ai");
2926
3287
  }
2927
3288
  function hasOpenRouterRoutingOptions(providerOptions, providerId) {
@@ -2929,14 +3290,32 @@ function hasOpenRouterRoutingOptions(providerOptions, providerId) {
2929
3290
  return Boolean(options && typeof options === "object" && "provider" in options);
2930
3291
  }
2931
3292
  function isNativeToolFallbackError(error) {
2932
- const message = formatErrorMessage$1(error).toLowerCase();
3293
+ const message = formatErrorMessage$2(error).toLowerCase();
2933
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");
2934
3295
  }
2935
3296
  function extractWarningMessages(warnings) {
2936
3297
  if (!Array.isArray(warnings)) return [];
2937
- 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;
2938
3310
  }
2939
- 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) {
2940
3319
  return error instanceof Error ? error.message : String(error);
2941
3320
  }
2942
3321
  //#endregion
@@ -2946,8 +3325,6 @@ const modelPurposeSchema = z.enum([
2946
3325
  "agent.fast",
2947
3326
  "kb.scan",
2948
3327
  "kb.summarize",
2949
- "kb.extract",
2950
- "kb.embed",
2951
3328
  "fallback"
2952
3329
  ]);
2953
3330
  const modelPurposes = modelPurposeSchema.options;
@@ -3042,7 +3419,7 @@ function readConfigFile(path) {
3042
3419
  try {
3043
3420
  return parse(readFileSync(path, "utf8"));
3044
3421
  } catch (error) {
3045
- throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage(error)}`);
3422
+ throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage$1(error)}`);
3046
3423
  }
3047
3424
  }
3048
3425
  function parseConfigFile(path, value) {
@@ -3078,6 +3455,7 @@ function normalizeConfigInput(value) {
3078
3455
  ensureKnownProvider(providers, kbSummarizeModelRef.provider);
3079
3456
  delete models["kb.summarize"];
3080
3457
  }
3458
+ applyKnownProviderDefaults(providers);
3081
3459
  return {
3082
3460
  ...value,
3083
3461
  models: {
@@ -3134,6 +3512,20 @@ function ensureKnownProvider(providers, provider) {
3134
3512
  }
3135
3513
  };
3136
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
+ }
3137
3529
  function deepMerge(base, override, path = []) {
3138
3530
  if (Array.isArray(base) && Array.isArray(override)) return path.join(".") === "ignore.paths" ? [...base, ...override] : override;
3139
3531
  if (!isPlainObject(base) || !isPlainObject(override)) return override;
@@ -3147,7 +3539,7 @@ function isPlainObject(value) {
3147
3539
  function formatZodIssue(issue) {
3148
3540
  return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
3149
3541
  }
3150
- function formatErrorMessage(error) {
3542
+ function formatErrorMessage$1(error) {
3151
3543
  return error instanceof Error ? error.message : String(error);
3152
3544
  }
3153
3545
  //#endregion
@@ -3241,107 +3633,6 @@ function normalizeModelGatewayConfig(config) {
3241
3633
  };
3242
3634
  }
3243
3635
  //#endregion
3244
- //#region src/cli/ui.ts
3245
- const colors = {
3246
- bgSoftGray: "\x1B[48;5;236m",
3247
- blue: "\x1B[34m",
3248
- cyan: "\x1B[36m",
3249
- darkGray: "\x1B[90m",
3250
- dim: "\x1B[2m",
3251
- green: "\x1B[32m",
3252
- orange: "\x1B[38;5;208m",
3253
- purple: "\x1B[35m",
3254
- red: "\x1B[31m",
3255
- reset: "\x1B[0m",
3256
- resetForeground: "\x1B[39m",
3257
- yellow: "\x1B[33m"
3258
- };
3259
- const ui = {
3260
- heading(text) {
3261
- return color(`Topchester ${text}`, "cyan");
3262
- },
3263
- label(text) {
3264
- return color(text, "dim");
3265
- },
3266
- muted(text) {
3267
- return color(text, "darkGray");
3268
- },
3269
- model(text) {
3270
- return color(text, "blue");
3271
- },
3272
- modelInline(text) {
3273
- if (!shouldUseColor()) return text;
3274
- return `${colors.blue}${text}${colors.resetForeground}`;
3275
- },
3276
- ok(text) {
3277
- return color(text, "green");
3278
- },
3279
- warn(text) {
3280
- return color(text, "yellow");
3281
- },
3282
- error(text) {
3283
- return color(text, "red");
3284
- },
3285
- softBackground(text) {
3286
- return color(text, "bgSoftGray");
3287
- },
3288
- async spinner(text, action) {
3289
- return withStatusLine(text, action, void 0, 80, false);
3290
- },
3291
- async progress(text, action) {
3292
- let latest = text;
3293
- return withStatusLine(text, () => action((message) => {
3294
- latest = message;
3295
- }), () => latest, 80, true);
3296
- }
3297
- };
3298
- async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
3299
- if (!shouldUseColor()) {
3300
- if (!emitPlainProgress) return action();
3301
- const timer = setInterval(() => {
3302
- stderr.write(`${getText()}\n`);
3303
- }, Math.max(progressEveryMs, 5e3));
3304
- try {
3305
- return await action();
3306
- } finally {
3307
- clearInterval(timer);
3308
- }
3309
- }
3310
- const frames = [
3311
- "⠋",
3312
- "⠙",
3313
- "⠹",
3314
- "⠸",
3315
- "⠼",
3316
- "⠴",
3317
- "⠦",
3318
- "⠧",
3319
- "⠇",
3320
- "⠏"
3321
- ];
3322
- let index = 0;
3323
- stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
3324
- const timer = setInterval(() => {
3325
- index = (index + 1) % frames.length;
3326
- stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
3327
- }, progressEveryMs);
3328
- try {
3329
- return await action();
3330
- } finally {
3331
- clearInterval(timer);
3332
- stderr.write(`\r\u001b[2K`);
3333
- }
3334
- }
3335
- function color(text, colorName) {
3336
- if (!shouldUseColor()) return text;
3337
- return `${colors[colorName]}${text}${colors.reset}`;
3338
- }
3339
- function shouldUseColor() {
3340
- if (process.env.NO_COLOR) return false;
3341
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
3342
- return stdout.isTTY === true;
3343
- }
3344
- //#endregion
3345
3636
  //#region src/knowledge/status.ts
3346
3637
  function getKnowledgeStatus(workspaceRoot) {
3347
3638
  const kbPathSource = process.env.TOPCHESTER_KB_DIR ? "env" : "default";
@@ -3674,6 +3965,14 @@ const l1ConfidenceLevels = [
3674
3965
  "medium",
3675
3966
  "high"
3676
3967
  ];
3968
+ const l1FileRoles = [
3969
+ "source",
3970
+ "test",
3971
+ "config",
3972
+ "doc",
3973
+ "script",
3974
+ "unknown"
3975
+ ];
3677
3976
  const nonEmptyStringSchema = z.string().min(1);
3678
3977
  const sha256HashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
3679
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" });
@@ -3685,7 +3984,7 @@ const l1FileSymbolSchema = z.object({
3685
3984
  kind: nonEmptyStringSchema,
3686
3985
  name: nonEmptyStringSchema,
3687
3986
  exported: z.boolean(),
3688
- summary: nonEmptyStringSchema
3987
+ summary: nonEmptyStringSchema.optional()
3689
3988
  }).strict();
3690
3989
  const l1FileEvidenceSchema = z.object({
3691
3990
  kind: nonEmptyStringSchema,
@@ -3702,6 +4001,7 @@ const l1FileEntrySchema = z.object({
3702
4001
  size_bytes: z.number().int().nonnegative(),
3703
4002
  last_scanned_at: isoUtcTimestampSchema,
3704
4003
  scan_status: z.enum(l1FileScanStatuses),
4004
+ file_role: z.enum(l1FileRoles).default("unknown"),
3705
4005
  summary: nonEmptyStringSchema,
3706
4006
  responsibilities: z.array(nonEmptyStringSchema),
3707
4007
  symbols: z.array(l1FileSymbolSchema),
@@ -3710,6 +4010,9 @@ const l1FileEntrySchema = z.object({
3710
4010
  module_ids: z.array(l1ModuleIdSchema),
3711
4011
  feature_ids: z.array(l1FeatureIdSchema),
3712
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([]),
3713
4016
  evidence: z.array(l1FileEvidenceSchema),
3714
4017
  confidence: z.enum(l1ConfidenceLevels)
3715
4018
  }).strict().refine((entry) => entry.id === `file:${entry.path}`, {
@@ -3777,6 +4080,123 @@ function formatCountProgress(label, completed, total, detail) {
3777
4080
  return `${label} [${formatProgressBar(safeCompleted, safeTotal)}] ${safeCompleted}/${safeTotal} (${percent}%)${suffix}`;
3778
4081
  }
3779
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
3780
4200
  //#region src/knowledge/compiler/manifest.ts
3781
4201
  const knowledgeCompilerIdentity = {
3782
4202
  name: "topchester-knowledge-compiler",
@@ -3819,6 +4239,8 @@ async function processL1Queue(options) {
3819
4239
  await persistQueue(options.queuePath, queuedFiles, now().toISOString());
3820
4240
  options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index + 1, queuedFiles.length, item.path) });
3821
4241
  }
4242
+ options.onProgress?.({ message: "Linking L1 file relationships..." });
4243
+ await postProcessL1Entries(options.kbPath);
3822
4244
  const summary = await summarizeL1Queue(options.kbPath, queuedFiles);
3823
4245
  await writeManifest(options, summary, now().toISOString());
3824
4246
  return {
@@ -3878,7 +4300,11 @@ async function processL1QueueItem(options) {
3878
4300
  }
3879
4301
  function buildL1FileEntrySystemPrompt() {
3880
4302
  return [
3881
- "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\".",
3882
4308
  "Return exactly one JSON object and no markdown.",
3883
4309
  "Do not include secrets, credentials, or raw provider payloads."
3884
4310
  ].join("\n");
@@ -3887,6 +4313,35 @@ function buildL1FileEntryPrompt(input) {
3887
4313
  return [
3888
4314
  "Create an L1 file entry for this workspace-relative path.",
3889
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
+ "",
3890
4345
  "Use this JSON shape:",
3891
4346
  JSON.stringify({
3892
4347
  $schema: l1FileEntrySchemaPath,
@@ -3899,6 +4354,7 @@ function buildL1FileEntryPrompt(input) {
3899
4354
  size_bytes: 0,
3900
4355
  last_scanned_at: "2026-05-11T00:00:00Z",
3901
4356
  scan_status: "current",
4357
+ file_role: "source",
3902
4358
  summary: "One clear sentence.",
3903
4359
  responsibilities: ["What this file owns or does."],
3904
4360
  symbols: [],
@@ -3907,6 +4363,9 @@ function buildL1FileEntryPrompt(input) {
3907
4363
  module_ids: [],
3908
4364
  feature_ids: [],
3909
4365
  test_ids: [],
4366
+ declared_test_targets: [],
4367
+ likely_test_targets: [],
4368
+ tested_by: [],
3910
4369
  evidence: [{
3911
4370
  kind: "path",
3912
4371
  value: "<path>"
@@ -3944,41 +4403,49 @@ function normalizeL1FileEntry(value, deterministic) {
3944
4403
  }
3945
4404
  function normalizeModelOwnedL1Fields(value, path) {
3946
4405
  const record = value;
4406
+ const exports = normalizeStringArray(record.exports);
3947
4407
  return {
3948
4408
  ...record,
4409
+ file_role: inferL1FileRole(path),
3949
4410
  responsibilities: normalizeStringArray(record.responsibilities),
3950
- symbols: normalizeSymbols(record.symbols, path),
4411
+ symbols: normalizeSymbols(record.symbols, path, exports),
3951
4412
  imports: normalizePrefixedIds(record.imports, "file:"),
3952
- exports: normalizeStringArray(record.exports),
4413
+ exports,
3953
4414
  module_ids: normalizePrefixedIds(record.module_ids, "module:"),
3954
4415
  feature_ids: normalizePrefixedIds(record.feature_ids, "feature:"),
3955
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:"),
3956
4420
  evidence: normalizeEvidence(record.evidence)
3957
4421
  };
3958
4422
  }
3959
4423
  function normalizeStringArray(value) {
3960
4424
  if (!Array.isArray(value)) return [];
3961
- return value.filter((item) => typeof item === "string" && item.trim().length > 0);
4425
+ return dedupeStrings(value.filter((item) => typeof item === "string").map((item) => item.trim()));
3962
4426
  }
3963
4427
  function normalizePrefixedIds(value, prefix) {
3964
4428
  return normalizeStringArray(value).filter((item) => item.startsWith(prefix));
3965
4429
  }
3966
4430
  function normalizeEvidence(value) {
3967
4431
  if (!Array.isArray(value)) return [];
3968
- return value.flatMap((item) => {
4432
+ return dedupeRecords(value.flatMap((item) => {
3969
4433
  if (!item || typeof item !== "object" || Array.isArray(item)) return [];
3970
4434
  const record = item;
3971
- if (typeof record.kind !== "string" || record.kind.trim().length === 0) return [];
3972
- 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 [];
3973
4439
  return [{
3974
- kind: record.kind,
3975
- value: record.value
4440
+ kind,
4441
+ value: recordValue
3976
4442
  }];
3977
- });
4443
+ }), (item) => `${item.kind}\0${item.value}`);
3978
4444
  }
3979
- function normalizeSymbols(value, path) {
4445
+ function normalizeSymbols(value, path, exports) {
3980
4446
  if (!Array.isArray(value)) return [];
3981
- return value.flatMap((item) => {
4447
+ const exportedNames = new Set(exports);
4448
+ return dedupeRecords(value.flatMap((item) => {
3982
4449
  if (typeof item === "string") {
3983
4450
  const name = item.trim();
3984
4451
  if (!name || !path) return [];
@@ -3986,8 +4453,7 @@ function normalizeSymbols(value, path) {
3986
4453
  id: `symbol:${path}#${name}`,
3987
4454
  kind: "symbol",
3988
4455
  name,
3989
- exported: false,
3990
- summary: `Symbol named ${name}.`
4456
+ exported: exportedNames.has(name)
3991
4457
  }];
3992
4458
  }
3993
4459
  if (!item || typeof item !== "object" || Array.isArray(item)) return [];
@@ -3995,14 +4461,36 @@ function normalizeSymbols(value, path) {
3995
4461
  const rawId = typeof record.id === "string" && record.id.startsWith("symbol:") ? record.id : void 0;
3996
4462
  const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : rawId?.slice(rawId.lastIndexOf("#") + 1);
3997
4463
  if (!name || !path) return [];
3998
- return [{
4464
+ const summary = typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary.trim() : "";
4465
+ return [removeUndefinedValues({
3999
4466
  id: rawId ?? `symbol:${path}#${name}`,
4000
- 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",
4001
4468
  name,
4002
- exported: typeof record.exported === "boolean" ? record.exported : false,
4003
- summary: typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary : `Symbol named ${name}.`
4004
- }];
4005
- });
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;
4006
4494
  }
4007
4495
  function extractTopLevelJsonObjects(text) {
4008
4496
  const objects = [];
@@ -4404,11 +4892,11 @@ async function getL1SyncStatus(kbPath, kbReady, file) {
4404
4892
  if (entry.path !== file.path || entry.size_bytes !== file.sizeBytes || entry.content_hash !== file.hash) return "changed";
4405
4893
  return entry.scan_status;
4406
4894
  } catch (error) {
4407
- if (isFileNotFoundError$1(error)) return "missing_entry";
4895
+ if (isFileNotFoundError$2(error)) return "missing_entry";
4408
4896
  return "invalid";
4409
4897
  }
4410
4898
  }
4411
- function isFileNotFoundError$1(error) {
4899
+ function isFileNotFoundError$2(error) {
4412
4900
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
4413
4901
  }
4414
4902
  function assertKbSummarizeModelConfigured(model) {
@@ -4522,6 +5010,430 @@ function isNodeError(error) {
4522
5010
  return error instanceof Error && "code" in error;
4523
5011
  }
4524
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
4525
5437
  //#region src/tui/markdown.ts
4526
5438
  const codeFenceSentinel = "topchester-code-fence";
4527
5439
  function renderMarkdown(text, width) {
@@ -4753,6 +5665,19 @@ const toolCallPayloadSchema = z.object({
4753
5665
  label: z.string(),
4754
5666
  call: z.record(z.string(), jsonValueSchema)
4755
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
+ });
4756
5681
  const statusPayloadSchema = z.object({
4757
5682
  kind: z.literal("status"),
4758
5683
  status: z.string()
@@ -4774,6 +5699,7 @@ const choicePayloadSchema = z.object({
4774
5699
  const sessionEventPayloadSchema = z.discriminatedUnion("kind", [
4775
5700
  messagePayloadSchema,
4776
5701
  toolCallPayloadSchema,
5702
+ taskPlanPayloadSchema,
4777
5703
  statusPayloadSchema,
4778
5704
  knowledgeStatusPayloadSchema,
4779
5705
  choicePayloadSchema
@@ -4854,6 +5780,7 @@ async function resolveLatestSessionId(workspaceRoot) {
4854
5780
  function rehydrateSession(events) {
4855
5781
  const messages = [];
4856
5782
  let status;
5783
+ let taskPlan;
4857
5784
  let visibleOnlyActionValues = /* @__PURE__ */ new Set();
4858
5785
  for (const event of events) switch (event.kind) {
4859
5786
  case "message":
@@ -4869,6 +5796,12 @@ function rehydrateSession(events) {
4869
5796
  case "tool_call":
4870
5797
  messages.push(toolCallMessage(event.call, event.label));
4871
5798
  break;
5799
+ case "task_plan":
5800
+ taskPlan = {
5801
+ items: event.items,
5802
+ updatedAt: event.updatedAt
5803
+ };
5804
+ break;
4872
5805
  case "knowledge_status": break;
4873
5806
  case "choice":
4874
5807
  messages.push({
@@ -4886,7 +5819,8 @@ function rehydrateSession(events) {
4886
5819
  }
4887
5820
  return {
4888
5821
  messages,
4889
- status
5822
+ status,
5823
+ ...taskPlan === void 0 ? {} : { taskPlan }
4890
5824
  };
4891
5825
  }
4892
5826
  function buildHandle(sessionDir, metadata) {
@@ -5264,6 +6198,71 @@ async function executeKbCommand(args, context) {
5264
6198
  return { messages: ["Usage: /kb init, /kb compile, /kb sync, /kb reset, or /kb status"] };
5265
6199
  }
5266
6200
  //#endregion
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
+ };
6257
+ }
6258
+ };
6259
+ function choiceAction(label, value) {
6260
+ return value === void 0 ? { label } : {
6261
+ label,
6262
+ value
6263
+ };
6264
+ }
6265
+ //#endregion
5267
6266
  //#region src/tui/keys.ts
5268
6267
  function isUpKey(data) {
5269
6268
  return matchesKey(data, "up") || data === "\x1B[A";
@@ -5274,6 +6273,9 @@ function isDownKey(data) {
5274
6273
  function isEnterKey(data) {
5275
6274
  return matchesKey(data, "enter") || data === "\n" || data === "\r";
5276
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
+ }
5277
6279
  function isTabKey(data) {
5278
6280
  return matchesKey(data, "tab") || data === " ";
5279
6281
  }
@@ -5380,12 +6382,14 @@ function getStartupThreadMessages(context) {
5380
6382
  lines.push("Ask Topchester what you want to change.");
5381
6383
  return [systemMessage(lines.join("\n"))];
5382
6384
  }
5383
- function renderStaticLayout(messages, folderName = "", modelLabel = "") {
6385
+ function renderStaticLayout(messages, folderName = "", modelLabel = "", taskPlan) {
5384
6386
  const threadLines = messages.flatMap((message) => renderChatMessage(message));
5385
6387
  const status = formatStatusLine(folderName, modelLabel);
6388
+ const planLines = taskPlan && taskPlan.items.length > 0 ? [...formatTaskPlanForTui(taskPlan, 72), ""] : [];
5386
6389
  return [
5387
6390
  ...threadLines,
5388
6391
  "",
6392
+ ...planLines,
5389
6393
  "┌──────────────────────────────────────────────────────────────────────┐",
5390
6394
  "│ > │",
5391
6395
  "└──────────────────────────────────────────────────────────────────────┘",
@@ -5467,23 +6471,35 @@ function stripAnsi(text) {
5467
6471
  }
5468
6472
  //#endregion
5469
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~";
5470
6479
  var ChatLayout = class {
5471
6480
  terminal;
5472
6481
  messages;
5473
6482
  folderName;
5474
6483
  modelLabel;
5475
- input = new Input();
6484
+ inputFocused = false;
6485
+ promptValue = "";
6486
+ promptCursor = 0;
5476
6487
  status = "ready";
5477
6488
  knowledgeStatus;
5478
6489
  ephemeralLine;
6490
+ taskPlanNoticeLine;
5479
6491
  noticeLine;
5480
6492
  promptHint;
6493
+ taskPlan;
5481
6494
  cancelPending;
5482
6495
  submitMessage;
5483
6496
  submitCommand;
5484
6497
  activeModalActionIndex = 0;
5485
6498
  activeSlashSuggestionIndex = 0;
5486
6499
  threadScrollOffset = 0;
6500
+ pasteBuffer;
6501
+ pasteCounter = 0;
6502
+ pastedContent = /* @__PURE__ */ new Map();
5487
6503
  promptHistory = new PromptHistory();
5488
6504
  exitAgent;
5489
6505
  transcriptMode;
@@ -5494,14 +6510,6 @@ var ChatLayout = class {
5494
6510
  this.modelLabel = modelLabel;
5495
6511
  this.exitAgent = typeof options === "function" ? options : options.exitAgent ?? (() => {});
5496
6512
  this.transcriptMode = typeof options === "function" ? "viewport" : options.transcriptMode ?? "viewport";
5497
- this.input.onSubmit = (value) => {
5498
- if (value.trim().length > 0) {
5499
- const message = value.trim();
5500
- this.addMessage(userMessage(message));
5501
- this.input.setValue("");
5502
- this.submitUserInput(message);
5503
- }
5504
- };
5505
6513
  }
5506
6514
  addMessage(message) {
5507
6515
  this.messages.push(message);
@@ -5514,6 +6522,24 @@ var ChatLayout = class {
5514
6522
  setKnowledgeStatus(status) {
5515
6523
  this.knowledgeStatus = formatKnowledgeFooterStatus(status);
5516
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
+ }
5517
6543
  isReady() {
5518
6544
  return this.status === "ready";
5519
6545
  }
@@ -5536,7 +6562,10 @@ var ChatLayout = class {
5536
6562
  this.submitCommand = submit;
5537
6563
  }
5538
6564
  setInputValue(value) {
5539
- this.input.setValue(value);
6565
+ this.promptValue = value;
6566
+ this.promptCursor = value.length;
6567
+ this.pastedContent.clear();
6568
+ this.pasteCounter = 0;
5540
6569
  }
5541
6570
  getConversationTurns() {
5542
6571
  return this.messages.flatMap((message) => {
@@ -5556,10 +6585,10 @@ var ChatLayout = class {
5556
6585
  });
5557
6586
  }
5558
6587
  get focused() {
5559
- return this.input.focused;
6588
+ return this.inputFocused;
5560
6589
  }
5561
6590
  set focused(value) {
5562
- this.input.focused = value;
6591
+ this.inputFocused = value;
5563
6592
  }
5564
6593
  handleInput(data) {
5565
6594
  if (this.cancelPending && matchesKey(data, "escape")) {
@@ -5568,15 +6597,15 @@ var ChatLayout = class {
5568
6597
  }
5569
6598
  if (this.handleModalInput(data)) return;
5570
6599
  if (this.handleSlashSuggestionInput(data)) return;
6600
+ if (this.handlePromptPasteInput(data)) return;
6601
+ if (this.handlePromptNewLineInput(data)) return;
6602
+ if (this.handlePromptSubmitInput(data)) return;
5571
6603
  if (this.handleThreadScrollInput(data)) return;
6604
+ if (this.handlePromptVerticalCursorInput(data)) return;
5572
6605
  if (this.handlePromptHistoryInput(data)) return;
5573
- const previousInput = this.input.getValue();
5574
- this.input.handleInput(data);
5575
- if (this.input.getValue() !== previousInput) this.promptHistory.resetBrowsing();
5576
- }
5577
- invalidate() {
5578
- this.input.invalidate();
6606
+ this.handlePromptEditInput(data);
5579
6607
  }
6608
+ invalidate() {}
5580
6609
  render(width) {
5581
6610
  const safeWidth = Math.max(20, width);
5582
6611
  const footerLines = this.getActiveModal() ? this.renderModalHelp(safeWidth) : this.renderPrompt(safeWidth);
@@ -5604,6 +6633,7 @@ var ChatLayout = class {
5604
6633
  return [...this.renderThreadMessageLines(messageLines, innerWidth, width, message.kind === "user"), ...spacer];
5605
6634
  });
5606
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));
5607
6637
  if (this.noticeLine) lines.push(...this.renderThreadMessageLines([` ${this.noticeLine}`], innerWidth, width, false));
5608
6638
  return lines;
5609
6639
  }
@@ -5619,17 +6649,69 @@ var ChatLayout = class {
5619
6649
  const bottom = `└${"─".repeat(Math.max(0, width - 2))}┘`;
5620
6650
  const prefix = "> ";
5621
6651
  const innerWidth = Math.max(1, width - 4 - 2);
5622
- 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);
5623
6653
  const statusInnerWidth = Math.max(1, width - 2);
5624
6654
  const status = truncateToWidth(` ${formatStatusLine(this.folderName, this.modelLabel, this.status, this.knowledgeStatus, statusInnerWidth)} `, width, "…", true);
5625
6655
  return [
5626
6656
  ...this.renderSlashSuggestions(width),
6657
+ ...this.renderTaskPlan(width),
5627
6658
  top,
5628
- `│ ${prefix}${inputLine} │`,
6659
+ ...inputLines.map((line, index) => `│ ${index === 0 ? prefix : " "}${padPromptInputLine(line, innerWidth)} │`),
5629
6660
  bottom,
5630
6661
  status
5631
6662
  ];
5632
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
+ }
5633
6715
  renderSlashSuggestions(width) {
5634
6716
  const suggestions = this.getSlashSuggestions();
5635
6717
  if (suggestions.length === 0 || this.promptHint) return [];
@@ -5676,6 +6758,14 @@ var ChatLayout = class {
5676
6758
  this.exitAgent();
5677
6759
  return true;
5678
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
+ }
5679
6769
  this.submitModalAction(action.value ?? action.label);
5680
6770
  return true;
5681
6771
  }
@@ -5718,17 +6808,196 @@ var ChatLayout = class {
5718
6808
  handlePromptHistoryInput(data) {
5719
6809
  if (this.promptHint) return false;
5720
6810
  if (isUpKey(data)) {
5721
- const prompt = this.promptHistory.previous(this.input.getValue());
5722
- 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
+ }
5723
6816
  return true;
5724
6817
  }
5725
6818
  if (isDownKey(data)) {
5726
6819
  const prompt = this.promptHistory.next();
5727
- if (prompt !== void 0) this.input.setValue(prompt);
6820
+ if (prompt !== void 0) {
6821
+ this.promptValue = prompt;
6822
+ this.promptCursor = prompt.length;
6823
+ }
5728
6824
  return true;
5729
6825
  }
5730
6826
  return false;
5731
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();
6967
+ return true;
6968
+ }
6969
+ return false;
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
+ }
5732
7001
  handleSlashSuggestionInput(data) {
5733
7002
  const suggestions = this.getSlashSuggestions();
5734
7003
  if (suggestions.length === 0) {
@@ -5747,19 +7016,19 @@ var ChatLayout = class {
5747
7016
  this.completeSlashSuggestion(suggestions);
5748
7017
  return true;
5749
7018
  }
5750
- if (isEnterKey(data) && this.input.getValue().trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
7019
+ if (isEnterKey(data) && this.promptValue.trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
5751
7020
  this.completeSlashSuggestion(suggestions);
5752
7021
  return true;
5753
7022
  }
5754
7023
  return false;
5755
7024
  }
5756
7025
  completeSlashSuggestion(suggestions) {
5757
- this.input.setValue(suggestions[this.activeSlashSuggestionIndex]?.value ?? this.input.getValue());
5758
- this.input.handleInput("\x1B[F");
7026
+ this.promptValue = suggestions[this.activeSlashSuggestionIndex]?.value ?? this.promptValue;
7027
+ this.promptCursor = this.promptValue.length;
5759
7028
  this.promptHistory.resetBrowsing();
5760
7029
  }
5761
7030
  getSlashSuggestions() {
5762
- return getSlashCommandSuggestions(this.input.getValue());
7031
+ return getSlashCommandSuggestions(this.promptValue);
5763
7032
  }
5764
7033
  getActiveModal() {
5765
7034
  return this.messages[this.getActiveModalIndex()];
@@ -5772,6 +7041,7 @@ var ChatLayout = class {
5772
7041
  this.submitUserInput(message);
5773
7042
  }
5774
7043
  submitUserInput(message) {
7044
+ this.setTaskPlanNotice(void 0);
5775
7045
  this.promptHistory.add(message);
5776
7046
  if (message.startsWith("/")) this.submitCommand?.(message);
5777
7047
  else this.submitMessage?.(message);
@@ -5780,8 +7050,15 @@ var ChatLayout = class {
5780
7050
  function colorUserMessageBorder(line) {
5781
7051
  return line.replace("▌", ui.modelInline("▌"));
5782
7052
  }
5783
- function renderInputWithoutPrompt(input, width) {
5784
- 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
+ });
5785
7062
  }
5786
7063
  //#endregion
5787
7064
  //#region src/agent/conversation.ts
@@ -5793,58 +7070,6 @@ function buildConversationPrompt(turns, latestMessage) {
5793
7070
  return lines.join("\n\n");
5794
7071
  }
5795
7072
  //#endregion
5796
- //#region src/agent/events.ts
5797
- const agentEvent = {
5798
- status(status) {
5799
- return {
5800
- type: "status",
5801
- status
5802
- };
5803
- },
5804
- systemMessage(text) {
5805
- return {
5806
- type: "message",
5807
- role: "system",
5808
- text
5809
- };
5810
- },
5811
- assistantMessage(text, meta) {
5812
- return meta === void 0 ? {
5813
- type: "message",
5814
- role: "assistant",
5815
- text
5816
- } : {
5817
- type: "message",
5818
- role: "assistant",
5819
- text,
5820
- meta
5821
- };
5822
- },
5823
- toolCall(call, label) {
5824
- return {
5825
- type: "tool_call",
5826
- call,
5827
- label
5828
- };
5829
- },
5830
- knowledgeStatus(status, guidance) {
5831
- return guidance === void 0 ? {
5832
- type: "knowledge_status",
5833
- status
5834
- } : {
5835
- type: "knowledge_status",
5836
- status,
5837
- guidance
5838
- };
5839
- },
5840
- choice(options) {
5841
- return {
5842
- type: "choice",
5843
- ...options
5844
- };
5845
- }
5846
- };
5847
- //#endregion
5848
7073
  //#region src/agent/health.ts
5849
7074
  async function checkAgentReady(modelGateway, abortSignal) {
5850
7075
  const abortController = new AbortController();
@@ -5877,20 +7102,25 @@ function isAbortError(error) {
5877
7102
  //#endregion
5878
7103
  //#region src/agent/tools/executor.ts
5879
7104
  async function executeToolCall(workspaceRoot, call, options = {}) {
5880
- const definition = getToolDefinition(call.tool);
5881
7105
  const startedAt = Date.now();
5882
7106
  const context = {
5883
7107
  workspaceRoot,
5884
7108
  pathEnv: options.pathEnv,
5885
- logger: options.logger
7109
+ logger: options.logger,
7110
+ taskPlan: options.taskPlan
5886
7111
  };
5887
- options.logger?.debug({
5888
- event: "tool_call",
5889
- tool: call.tool,
5890
- args: summarizeToolArgs(call)
5891
- }, "tool call");
5892
7112
  try {
5893
- 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);
5894
7124
  const durationMs = Date.now() - startedAt;
5895
7125
  options.logger?.debug({
5896
7126
  event: "tool_result",
@@ -5910,23 +7140,40 @@ async function executeToolCall(workspaceRoot, call, options = {}) {
5910
7140
  }, "tool result content");
5911
7141
  return result;
5912
7142
  } catch (error) {
5913
- options.logger?.error({
5914
- event: "tool_error",
7143
+ const message = formatErrorMessage(error);
7144
+ const logPayload = {
7145
+ event: "tool_result",
5915
7146
  tool: call.tool,
5916
7147
  durationMs: Date.now() - startedAt,
7148
+ error: message,
5917
7149
  err: error
5918
- }, "tool failed");
5919
- 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
+ };
5920
7159
  }
5921
7160
  }
5922
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
+ }
5923
7170
  if (call.tool === "write_file") return {
5924
7171
  path: call.args.path,
5925
7172
  contentLength: call.args.content.length,
5926
7173
  lineCount: countLogicalLines(call.args.content),
5927
7174
  createParentDirs: Boolean(call.args.create_parent_dirs),
5928
7175
  overwrite: Boolean(call.args.overwrite),
5929
- expectedHashProvided: Boolean(call.args.expected_hash)
7176
+ expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
5930
7177
  };
5931
7178
  if (call.tool !== "edit_file") return call.args;
5932
7179
  return {
@@ -5934,10 +7181,15 @@ function summarizeToolArgs(call) {
5934
7181
  editCount: call.args.edits.length,
5935
7182
  oldTextLengths: call.args.edits.map((edit) => edit.old_text.length),
5936
7183
  newTextLengths: call.args.edits.map((edit) => edit.new_text.length),
5937
- expectedHashProvided: Boolean(call.args.expected_hash)
7184
+ expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
5938
7185
  };
5939
7186
  }
5940
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
+ };
5941
7193
  if (result.tool === "inspect_command") return {
5942
7194
  cwd: result.cwd,
5943
7195
  exitCode: result.exitCode,
@@ -6006,6 +7258,9 @@ function countLogicalLines(content) {
6006
7258
  const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
6007
7259
  return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
6008
7260
  }
7261
+ function formatErrorMessage(error) {
7262
+ return error instanceof Error ? error.message : String(error);
7263
+ }
6009
7264
  //#endregion
6010
7265
  //#region src/agent/prompts.ts
6011
7266
  function getChatSystemPrompt() {
@@ -6029,6 +7284,11 @@ function getChatSystemPrompt() {
6029
7284
  "",
6030
7285
  "Tool use:",
6031
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.",
6032
7292
  "- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
6033
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.",
6034
7294
  "- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks.",
@@ -6037,8 +7297,10 @@ function getChatSystemPrompt() {
6037
7297
  "- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.",
6038
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.",
6039
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.",
6040
7301
  "- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
6041
- "- 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_hash from read_file.",
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.",
6042
7304
  "- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path.",
6043
7305
  "- Do not use inspect_command for file creation or file mutation.",
6044
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.",
@@ -6050,34 +7312,83 @@ function getChatSystemPrompt() {
6050
7312
  }
6051
7313
  //#endregion
6052
7314
  //#region src/agent/runtime.ts
6053
- const MAX_TOOL_CALLS_PER_TURN = 8;
7315
+ const MAX_TOOL_CALLS_PER_TURN = 75;
6054
7316
  var TopchesterAgentRuntime = class {
6055
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
+ */
6056
7325
  constructor(context) {
6057
7326
  this.context = context;
6058
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
+ */
6059
7335
  async checkAgent(abortSignal) {
6060
7336
  const result = await checkAgentReady(this.context.modelGateway, abortSignal);
6061
7337
  if (result === "ready") return [agentEvent.status("ready")];
6062
7338
  if (result === "timed-out") return [agentEvent.systemMessage("Agent is taking a while, so I skipped the startup check."), agentEvent.status("ready")];
6063
7339
  return [agentEvent.systemMessage("Agent did not say it was ready."), agentEvent.status("ready")];
6064
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
+ */
6065
7347
  async checkKnowledgeBase() {
6066
7348
  return getKnowledgeStatusEvents(await this.getKnowledgeStatusWithNonCleanFileCount());
6067
7349
  }
6068
- async submitMessage(conversation, message, abortSignal) {
6069
- 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);
6070
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
+ };
6071
7370
  let nextPrompt = prompt;
6072
7371
  let totalDurationMs = 0;
6073
7372
  let lastModelId = "model";
6074
7373
  let afterTool;
6075
7374
  let toolProtocolOverride = readToolProtocolEnvOverride();
7375
+ let requestedPlanClosure = false;
6076
7376
  for (let toolCalls = 0; toolCalls <= MAX_TOOL_CALLS_PER_TURN; toolCalls += 1) {
6077
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");
6078
7389
  const result = await generateAgentStep(this.context, {
6079
7390
  purpose: "agent.primary",
6080
- system: getChatSystemPrompt(),
7391
+ system,
6081
7392
  prompt: nextPrompt,
6082
7393
  abortSignal,
6083
7394
  toolProtocol: toolProtocolOverride
@@ -6093,6 +7404,11 @@ var TopchesterAgentRuntime = class {
6093
7404
  durationMs,
6094
7405
  totalDurationMs,
6095
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,
6096
7412
  hasToolCall: Boolean(toolCall),
6097
7413
  toolProtocol: result.toolProtocol,
6098
7414
  protocolAttempts: result.protocolAttempts,
@@ -6113,25 +7429,45 @@ var TopchesterAgentRuntime = class {
6113
7429
  if (result.providerRejectedTools && result.toolProtocol === "text-json") toolProtocolOverride = "text-json";
6114
7430
  else if (result.providerRejectedTools && result.toolProtocol === "text-xml") toolProtocolOverride = "text-xml";
6115
7431
  if (!toolCall) {
6116
- 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"));
6117
7441
  return events;
6118
7442
  }
6119
7443
  if (toolCalls === MAX_TOOL_CALLS_PER_TURN) {
6120
- 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"));
6121
7450
  return events;
6122
7451
  }
6123
7452
  const executableToolCall = toolCall;
6124
- const toolResult = await executeToolCall(this.context.workspaceRoot, executableToolCall, { logger: this.context.logger });
6125
- 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));
6126
7459
  afterTool = executableToolCall.tool;
6127
- nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol)}`;
7460
+ nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol, toolResult)}`;
6128
7461
  }
6129
- return [
6130
- ...events,
6131
- agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs)),
6132
- agentEvent.status("ready")
6133
- ];
7462
+ await emit(agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs)), agentEvent.status("ready"));
7463
+ return events;
6134
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
+ */
6135
7471
  async submitSlashCommand(command, onProgress) {
6136
7472
  const result = await executeSlashCommand(command, {
6137
7473
  workspaceRoot: this.context.workspaceRoot,
@@ -6145,6 +7481,12 @@ var TopchesterAgentRuntime = class {
6145
7481
  events.push(agentEvent.status("ready"));
6146
7482
  return events;
6147
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
+ */
6148
7490
  async getKnowledgeStatusWithNonCleanFileCount() {
6149
7491
  const status = getKnowledgeStatus(this.context.workspaceRoot);
6150
7492
  if (!status.kbExists || !status.kbIsDirectory || status.kbContentState !== "ready") return status;
@@ -6154,7 +7496,50 @@ var TopchesterAgentRuntime = class {
6154
7496
  nonCleanFileCount: result.files.length
6155
7497
  };
6156
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
+ }
6157
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
+ */
6158
7543
  async function generateAgentStep(context, request) {
6159
7544
  if ("generateAgentStep" in context.modelGateway && typeof context.modelGateway.generateAgentStep === "function") return context.modelGateway.generateAgentStep({
6160
7545
  ...request,
@@ -6183,15 +7568,33 @@ async function generateAgentStep(context, request) {
6183
7568
  openRouterRoutingApplied: false
6184
7569
  };
6185
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
+ */
6186
7577
  function readToolProtocolEnvOverride() {
6187
7578
  const value = process.env.TOPCHESTER_TOOL_PROTOCOL;
6188
7579
  if (value === "auto" || value === "native" || value === "text-json" || value === "text-xml") return value;
6189
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
+ */
6190
7587
  function formatTuiSyncStatus(status) {
6191
7588
  if (status === "current") return ui.ok(status);
6192
7589
  if (status === "invalid" || status === "missing_file") return ui.error(status);
6193
7590
  return ui.warn(status);
6194
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
+ */
6195
7598
  function shouldRefreshKnowledgeStatus(command) {
6196
7599
  const parsed = parseSlashCommand(command);
6197
7600
  return parsed?.name === "kb" && [
@@ -6202,19 +7605,44 @@ function shouldRefreshKnowledgeStatus(command) {
6202
7605
  "status"
6203
7606
  ].includes(parsed.args[0] ?? "");
6204
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
+ */
6205
7614
  function getKnowledgeStatusEvents(status) {
6206
7615
  return [agentEvent.knowledgeStatus(status, formatStartupKnowledgeGuidance(status))];
6207
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
+ */
6208
7622
  function formatStartupKnowledgeGuidance(status) {
6209
7623
  if (!status.kbExists) return "Next: run /kb init, then /kb compile to create project knowledge.";
6210
7624
  if (!status.kbIsDirectory) return "Fix the KB path or config, then run /kb status.";
6211
7625
  if (status.kbContentState !== "ready") return "Next: run /kb compile to build project knowledge.";
6212
7626
  if ((status.nonCleanFileCount ?? 0) > 0) return "Next: run /kb sync to update project knowledge, or /kb status to inspect the files.";
6213
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
+ */
6214
7635
  function formatToolResultForPrompt(result) {
6215
7636
  const path = result.path ? ` ${JSON.stringify(result.path)}` : "";
6216
7637
  const command = result.command ? ` via ${result.command}` : "";
6217
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");
6218
7646
  if (result.tool === "read_file") return [
6219
7647
  `Tool result from ${result.tool}${path}${command}:${warning}`,
6220
7648
  `hash: ${result.hash}`,
@@ -6222,6 +7650,7 @@ function formatToolResultForPrompt(result) {
6222
7650
  result.content,
6223
7651
  "```"
6224
7652
  ].join("\n");
7653
+ if (result.tool === "plan_todo") return [`Tool result from ${result.tool}:`, result.content].join("\n");
6225
7654
  if (result.tool === "edit_file") return [
6226
7655
  `Tool result from ${result.tool}${path}:`,
6227
7656
  `before_hash: ${result.beforeHash}`,
@@ -6319,11 +7748,49 @@ function formatToolResultForPrompt(result) {
6319
7748
  "```"
6320
7749
  ].join("\n");
6321
7750
  }
6322
- function formatContinuationInstruction(protocol) {
6323
- 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");
6324
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
+ */
6325
7790
  function formatToolCallMessage(call, result) {
7791
+ if (result && isToolErrorResult(result)) return `${call.tool} failed: ${result.error}`;
6326
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`;
6327
7794
  case "read_file": return `read_file: ${call.args.path}`;
6328
7795
  case "list_files": return `list_files: ${call.args.path}${call.args.recursive ? " (recursive)" : ""}`;
6329
7796
  case "grep": return `grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
@@ -6338,21 +7805,48 @@ function formatToolCallMessage(call, result) {
6338
7805
  case "inspect_command": return `inspect_command: ${call.args.command}`;
6339
7806
  }
6340
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
+ */
6341
7813
  function formatGitDiffCallSummary(call, result) {
6342
- if (result?.tool === "git_diff") return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
7814
+ if (result?.tool === "git_diff" && !isToolErrorResult(result)) return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
6343
7815
  return call.args.scope;
6344
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
+ */
6345
7823
  function formatEditFileChangeSummary(result) {
6346
- if (result?.tool !== "edit_file") return "";
7824
+ if (result?.tool !== "edit_file" || isToolErrorResult(result)) return "";
6347
7825
  return ` (changed ${result.editEvent.diffSummary})`;
6348
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
+ */
6349
7832
  function formatWriteFileChangeSummary(result) {
6350
- if (result?.tool !== "write_file") return "";
7833
+ if (result?.tool !== "write_file" || isToolErrorResult(result)) return "";
6351
7834
  return ` (${result.writeEvent.writeSummary})`;
6352
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
+ */
6353
7841
  function formatAgentMessageMeta(model, durationMs) {
6354
7842
  return `${model} · ${formatDuration$1(durationMs)}`;
6355
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
+ */
6356
7850
  function formatDuration$1(durationMs) {
6357
7851
  const totalSeconds = Math.max(0, durationMs / 1e3);
6358
7852
  if (totalSeconds < 10) return `${formatNumber(totalSeconds, 1)} sec`;
@@ -6362,6 +7856,12 @@ function formatDuration$1(durationMs) {
6362
7856
  if (seconds === 0) return `${minutes} min`;
6363
7857
  return `${minutes} min ${seconds} sec`;
6364
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
+ */
6365
7865
  function formatNumber(value, fractionDigits) {
6366
7866
  return value.toLocaleString("en", {
6367
7867
  minimumFractionDigits: fractionDigits,
@@ -6381,6 +7881,7 @@ function renderRuntimeEvent(event) {
6381
7881
  body: event.body,
6382
7882
  actions: event.actions
6383
7883
  })];
7884
+ case "task_plan": return [];
6384
7885
  case "status": return [];
6385
7886
  }
6386
7887
  }
@@ -6394,6 +7895,7 @@ var TopchesterTuiShell = class {
6394
7895
  options;
6395
7896
  runtime;
6396
7897
  session;
7898
+ taskPlanNoticeTimer;
6397
7899
  constructor(context, runtime, options = {}) {
6398
7900
  this.context = context;
6399
7901
  this.options = options;
@@ -6410,7 +7912,7 @@ var TopchesterTuiShell = class {
6410
7912
  const folderName = getFolderName(this.context.workspaceRoot);
6411
7913
  const modelLabel = getModelLabel(this.context);
6412
7914
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
6413
- console.log(renderStaticLayout(messages, folderName, modelLabel));
7915
+ console.log(renderStaticLayout(messages, folderName, modelLabel, this.options.initialTaskPlan));
6414
7916
  return;
6415
7917
  }
6416
7918
  const terminal = new ProcessTerminal();
@@ -6429,6 +7931,7 @@ var TopchesterTuiShell = class {
6429
7931
  process.exit(0);
6430
7932
  }
6431
7933
  });
7934
+ app.setTaskPlan(this.options.initialTaskPlan);
6432
7935
  app.setSubmitMessage((message) => {
6433
7936
  this.submitChatMessage(app, tui, message);
6434
7937
  });
@@ -6471,7 +7974,7 @@ var TopchesterTuiShell = class {
6471
7974
  busy.start();
6472
7975
  tui.requestRender();
6473
7976
  try {
6474
- await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal));
7977
+ await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal), tui);
6475
7978
  } catch (error) {
6476
7979
  if (cancelled) {
6477
7980
  app.addMessage(systemMessage("Agent check stopped."));
@@ -6485,7 +7988,7 @@ var TopchesterTuiShell = class {
6485
7988
  app.setCancelPending(void 0);
6486
7989
  busy.stop();
6487
7990
  }
6488
- if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase());
7991
+ if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase(), tui);
6489
7992
  tui.requestRender();
6490
7993
  }
6491
7994
  async submitChatMessage(app, tui, message) {
@@ -6507,12 +8010,16 @@ var TopchesterTuiShell = class {
6507
8010
  busy.start();
6508
8011
  tui.requestRender();
6509
8012
  try {
8013
+ await this.clearTaskPlanForNewTurn(app);
6510
8014
  await this.persistPayloadWithWarning(app, {
6511
8015
  kind: "message",
6512
8016
  role: "user",
6513
8017
  text: message
6514
8018
  });
6515
- 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
+ });
6516
8023
  } catch (error) {
6517
8024
  if (cancelled) {
6518
8025
  app.addMessage(systemMessage("Response stopped."));
@@ -6542,10 +8049,11 @@ var TopchesterTuiShell = class {
6542
8049
  busy.start();
6543
8050
  tui.requestRender();
6544
8051
  try {
8052
+ await this.clearTaskPlanForNewTurn(app);
6545
8053
  await this.persistPayloadWithWarning(app, slashCommandToSessionPayload(command));
6546
8054
  await this.applyRuntimeEvents(app, await this.runtime.submitSlashCommand(command, (event) => {
6547
8055
  busy.setActivity(event.message);
6548
- }));
8056
+ }), tui);
6549
8057
  } catch (error) {
6550
8058
  const errorMessage = error instanceof Error ? error.message : String(error);
6551
8059
  app.addMessage(systemMessage(`Command failed: ${errorMessage}`));
@@ -6559,14 +8067,41 @@ var TopchesterTuiShell = class {
6559
8067
  tui.requestRender();
6560
8068
  }
6561
8069
  }
6562
- async applyRuntimeEvents(app, events) {
8070
+ async applyRuntimeEvents(app, events, renderRequester) {
6563
8071
  for (const event of events) {
6564
8072
  if (event.type === "status") app.setStatus(event.status);
6565
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
+ }
6566
8079
  for (const message of renderRuntimeEvent(event)) app.addMessage(message);
6567
8080
  await this.persistPayloadWithWarning(app, runtimeEventToSessionPayload(event));
6568
8081
  }
6569
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
+ }
6570
8105
  async persistPayloadWithWarning(app, payload) {
6571
8106
  if (!this.session || !payload) return;
6572
8107
  try {
@@ -6626,6 +8161,11 @@ function runtimeEventToSessionPayload(event) {
6626
8161
  label: event.label,
6627
8162
  call: event.call
6628
8163
  };
8164
+ case "task_plan": return {
8165
+ kind: "task_plan",
8166
+ items: event.plan.items,
8167
+ updatedAt: event.plan.updatedAt
8168
+ };
6629
8169
  case "knowledge_status": return;
6630
8170
  case "choice": return {
6631
8171
  kind: "choice",
@@ -6874,7 +8414,14 @@ function printPlainEvent(event) {
6874
8414
  console.log(event.label);
6875
8415
  return;
6876
8416
  }
6877
- 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
+ }
6878
8425
  }
6879
8426
  function pushJson(events, runId, sessionId, type, fields) {
6880
8427
  events.push({
@@ -6906,9 +8453,12 @@ program.action(async () => {
6906
8453
  try {
6907
8454
  if (options.resume) {
6908
8455
  const loaded = await loadSession(context.workspaceRoot, options.resume);
8456
+ const session = await loadSessionForAppend(context.workspaceRoot, loaded.sessionId);
8457
+ const rehydrated = rehydrateSession(loaded.events);
6909
8458
  await new TopchesterTuiShell(context, void 0, {
6910
- session: await loadSessionForAppend(context.workspaceRoot, loaded.sessionId),
6911
- initialMessages: rehydrateSession(loaded.events).messages
8459
+ session,
8460
+ initialMessages: rehydrated.messages,
8461
+ initialTaskPlan: rehydrated.taskPlan
6912
8462
  }).render();
6913
8463
  return;
6914
8464
  }
@@ -6940,6 +8490,9 @@ program.command("run").description("run one prompt or slash command without open
6940
8490
  process.exitCode = 1;
6941
8491
  }
6942
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
+ });
6943
8496
  const kbCommand = program.command("kb").description("knowledge base commands");
6944
8497
  kbCommand.command("init").description("initialize a project knowledge base").action(async () => {
6945
8498
  const context = createContextFromOptions();
@@ -6973,6 +8526,12 @@ kbCommand.command("sync").description("sync non-clean project files into the kno
6973
8526
  console.log(formatKnowledgeSyncResult(result).join("\n"));
6974
8527
  if (isPartialKnowledgeCompileResult(result)) process.exitCode = 2;
6975
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
+ });
6976
8535
  kbCommand.command("reset").description("delete the local project knowledge base and cache").action(async () => {
6977
8536
  const context = createContextFromOptions();
6978
8537
  const result = await ui.progress("Resetting project knowledge base...", (report) => resetKnowledgeBase(context.workspaceRoot, { onProgress: (event) => report(event.message) }));
@@ -7019,6 +8578,31 @@ function createContextFromOptions() {
7019
8578
  devFlags: options.dev
7020
8579
  });
7021
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
+ }
7022
8606
  function formatStartupError(error) {
7023
8607
  const message = error instanceof Error ? error.message : String(error);
7024
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";
@@ -7032,6 +8616,11 @@ function parsePositiveInteger(value) {
7032
8616
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Expected a positive integer.");
7033
8617
  return parsed;
7034
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
+ }
7035
8624
  function formatDryRunSyncStatus(status) {
7036
8625
  if (status === "current") return ui.ok(status);
7037
8626
  if (status === "invalid" || status === "missing_file") return ui.error(status);