@thispointon/kondi-chat 0.1.2

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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Agent Tools — tools available to the frontier model during conversation.
3
+ *
4
+ * The orchestrator calls executeTool() when the model emits a tool_use.
5
+ * Each tool is a bounded operation: read a file, search code, run a command,
6
+ * create a task card, or update the session plan.
7
+ */
8
+
9
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
10
+ import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
11
+ import { join, resolve, dirname, relative } from 'node:path';
12
+
13
+ /** Safely check if a path is within a base directory */
14
+ function isPathSafe(base: string, fullPath: string): boolean {
15
+ const rel = relative(base, fullPath);
16
+ return !rel.startsWith('..') && !resolve(fullPath).includes('\0');
17
+ }
18
+ import { execSync, execFileSync } from 'node:child_process';
19
+ import type { ToolDefinition, Session, TaskKind } from '../types.ts';
20
+ import type { Ledger } from '../audit/ledger.ts';
21
+ import { runPipeline, type PipelineConfig } from './pipeline.ts';
22
+ import { computeUnifiedDiff } from './diff.ts';
23
+ import type { MemoryManager } from '../context/memory.ts';
24
+ import type { PermissionManager } from './permissions.ts';
25
+ import type { LoopGuard } from './loop-guard.ts';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tool definitions (provider-agnostic JSON Schema)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const AGENT_TOOLS: ToolDefinition[] = [
32
+ {
33
+ name: 'create_task',
34
+ description: 'Create a task card for a coding change and execute it. Use this when the user wants code written, fixed, refactored, or tested. The task goes through: dispatch → execute → verify → reflect.',
35
+ parameters: {
36
+ type: 'object',
37
+ properties: {
38
+ description: {
39
+ type: 'string',
40
+ description: 'Clear description of what code change to make',
41
+ },
42
+ kind: {
43
+ type: 'string',
44
+ description: 'Type of task (e.g., implementation, fix, refactor, test, analysis — or any custom kind)',
45
+ },
46
+ },
47
+ required: ['description'],
48
+ },
49
+ },
50
+ {
51
+ name: 'consult',
52
+ description:
53
+ 'Ask a specialized domain expert (aerospace engineer, security auditor, database ' +
54
+ 'architect, etc.) for an opinion on a specific question. Use this when the problem ' +
55
+ 'has a clear domain angle that would benefit from an expert perspective — DO NOT use ' +
56
+ 'it for routine coding questions, trivia, or tasks you can handle yourself. ' +
57
+ 'Consultants are pure text-in/text-out: they see only the question and optional ' +
58
+ 'context you pass, not the full conversation or your tool history. ' +
59
+ 'Call with an empty role to list available consultants. Consultant definitions live ' +
60
+ 'in .kondi-chat/consultants.json and can be edited by the user.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ role: {
65
+ type: 'string',
66
+ description:
67
+ 'Machine id of the consultant (e.g. "aerospace-engineer", "security-auditor"). ' +
68
+ 'Omit or pass empty string to list every available consultant and their descriptions.',
69
+ },
70
+ question: {
71
+ type: 'string',
72
+ description:
73
+ 'The specific question you want the expert to answer. Be concrete — "is this retraction sequence safe under loss-of-hydraulic-pressure?" beats "is this safe?".',
74
+ },
75
+ context: {
76
+ type: 'string',
77
+ description:
78
+ 'Optional extra context: relevant code snippet, design summary, constraints, ' +
79
+ 'prior decisions. Keep it focused — the consultant cannot read files itself.',
80
+ },
81
+ },
82
+ required: [],
83
+ },
84
+ },
85
+ {
86
+ name: 'read_file',
87
+ description: 'Read the contents of a file in the working directory.',
88
+ parameters: {
89
+ type: 'object',
90
+ properties: {
91
+ path: {
92
+ type: 'string',
93
+ description: 'Relative path from the working directory',
94
+ },
95
+ offset: {
96
+ type: 'number',
97
+ description: 'Line number to start reading from (0-based, default: 0)',
98
+ },
99
+ max_lines: {
100
+ type: 'number',
101
+ description: 'Maximum number of lines to return (default: 200)',
102
+ },
103
+ },
104
+ required: ['path'],
105
+ },
106
+ },
107
+ {
108
+ name: 'list_files',
109
+ description: 'List files and directories in the working directory.',
110
+ parameters: {
111
+ type: 'object',
112
+ properties: {
113
+ path: {
114
+ type: 'string',
115
+ description: 'Relative path to list (default: root)',
116
+ },
117
+ recursive: {
118
+ type: 'boolean',
119
+ description: 'List recursively (default: false)',
120
+ },
121
+ },
122
+ },
123
+ },
124
+ {
125
+ name: 'search_code',
126
+ description: 'Search for a pattern in code files using grep.',
127
+ parameters: {
128
+ type: 'object',
129
+ properties: {
130
+ pattern: {
131
+ type: 'string',
132
+ description: 'Regex pattern to search for',
133
+ },
134
+ path: {
135
+ type: 'string',
136
+ description: 'Relative path to search in (default: .)',
137
+ },
138
+ glob: {
139
+ type: 'string',
140
+ description: 'File glob filter (e.g., "*.ts", "*.py")',
141
+ },
142
+ },
143
+ required: ['pattern'],
144
+ },
145
+ },
146
+ {
147
+ name: 'repo_map',
148
+ description: 'Get the project structure: tech stack, entry points, subsystems, build/test commands, and conventions. Useful for understanding an unfamiliar codebase before making changes.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {},
152
+ },
153
+ },
154
+ {
155
+ name: 'find_symbol',
156
+ description: 'Search the codebase for a function, class, interface, type, or export by name. Returns file path and line number. Faster and more precise than search_code for navigating code structure.',
157
+ parameters: {
158
+ type: 'object',
159
+ properties: {
160
+ name: { type: 'string', description: 'Symbol name to search for (prefix match)' },
161
+ },
162
+ required: ['name'],
163
+ },
164
+ },
165
+ {
166
+ name: 'related_files',
167
+ description: 'Find files that import or are imported by a given file. Use to understand dependencies and impact of changes.',
168
+ parameters: {
169
+ type: 'object',
170
+ properties: {
171
+ path: { type: 'string', description: 'Relative file path' },
172
+ },
173
+ required: ['path'],
174
+ },
175
+ },
176
+ {
177
+ name: 'run_command',
178
+ description: 'Run a shell command in the working directory. Use for tests, builds, linting, or other local operations.',
179
+ parameters: {
180
+ type: 'object',
181
+ properties: {
182
+ command: {
183
+ type: 'string',
184
+ description: 'The command to run',
185
+ },
186
+ timeout: {
187
+ type: 'number',
188
+ description: 'Timeout in milliseconds (default: 30000)',
189
+ },
190
+ },
191
+ required: ['command'],
192
+ },
193
+ },
194
+ {
195
+ name: 'update_plan',
196
+ description: 'Update the session plan and working state. Use this to track goals, decisions, and constraints as the conversation evolves.',
197
+ parameters: {
198
+ type: 'object',
199
+ properties: {
200
+ goal: { type: 'string', description: 'Current high-level goal' },
201
+ decisions: {
202
+ type: 'array',
203
+ items: { type: 'string' },
204
+ description: 'Key decisions made',
205
+ },
206
+ constraints: {
207
+ type: 'array',
208
+ items: { type: 'string' },
209
+ description: 'Constraints to respect',
210
+ },
211
+ plan: {
212
+ type: 'array',
213
+ items: { type: 'string' },
214
+ description: 'Ordered steps in the current plan',
215
+ },
216
+ },
217
+ },
218
+ },
219
+ {
220
+ name: 'write_file',
221
+ description: 'Write content to a file. Creates the file if it does not exist, overwrites if it does. Use for creating new files or full replacements.',
222
+ parameters: {
223
+ type: 'object',
224
+ properties: {
225
+ path: {
226
+ type: 'string',
227
+ description: 'Relative path from the working directory',
228
+ },
229
+ content: {
230
+ type: 'string',
231
+ description: 'The full file content to write',
232
+ },
233
+ },
234
+ required: ['path', 'content'],
235
+ },
236
+ },
237
+ {
238
+ name: 'spawn_agent',
239
+ description: 'Spawn a bounded sub-agent to handle a focused sub-task. `research` can read and search; `worker` can read/write/edit/run commands; `planner` has no tools and just reasons about the instruction. Sub-agents do not nest.',
240
+ parameters: {
241
+ type: 'object',
242
+ properties: {
243
+ type: { type: 'string', enum: ['research', 'worker', 'planner'], description: 'Sub-agent role' },
244
+ instruction: { type: 'string', description: 'Clear, bounded task for the sub-agent' },
245
+ },
246
+ required: ['type', 'instruction'],
247
+ },
248
+ },
249
+ {
250
+ name: 'update_memory',
251
+ description: 'Update a KONDI.md memory file to record project conventions, decisions, or preferences. Scope "project" writes to <workingDir>/KONDI.md; "user" writes to ~/.kondi-chat/KONDI.md.',
252
+ parameters: {
253
+ type: 'object',
254
+ properties: {
255
+ scope: { type: 'string', enum: ['project', 'user'], description: 'Which memory file to update' },
256
+ operation: { type: 'string', enum: ['append', 'replace'], description: 'Append to the existing file or overwrite it' },
257
+ content: { type: 'string', description: 'Markdown content to append or write' },
258
+ },
259
+ required: ['scope', 'operation', 'content'],
260
+ },
261
+ },
262
+ {
263
+ name: 'edit_file',
264
+ description: 'Edit a file by replacing a specific string with new content. The old_string must match exactly (including whitespace).',
265
+ parameters: {
266
+ type: 'object',
267
+ properties: {
268
+ path: {
269
+ type: 'string',
270
+ description: 'Relative path from the working directory',
271
+ },
272
+ old_string: {
273
+ type: 'string',
274
+ description: 'The exact text to find and replace',
275
+ },
276
+ new_string: {
277
+ type: 'string',
278
+ description: 'The replacement text',
279
+ },
280
+ },
281
+ required: ['path', 'old_string', 'new_string'],
282
+ },
283
+ },
284
+ ];
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Tool execution context
288
+ // ---------------------------------------------------------------------------
289
+
290
+ export interface ToolContext {
291
+ workingDir: string;
292
+ session: Session;
293
+ ledger: Ledger;
294
+ pipelineConfig: PipelineConfig;
295
+ /** Spec 04 — optional memory store for KONDI.md files. */
296
+ memoryManager?: MemoryManager;
297
+ /** Spec 04 — callback to update ContextManager's active-file anchor for subdir memory. */
298
+ setActiveFile?: (path: string) => void;
299
+ /** Spec 01 — permission gate, consulted before every tool dispatch. */
300
+ permissionManager?: PermissionManager;
301
+ /** Spec 01 — used by permission requests to push events to the TUI. */
302
+ emit?: (event: any) => void;
303
+ /** Spec 05 — files mutated during the current turn (write_file / edit_file). */
304
+ mutatedFiles?: Set<string>;
305
+ /** Spec 07 — used by spawn_agent to run bounded child agent loops. */
306
+ spawnSubAgent?: (type: 'research' | 'worker' | 'planner', instruction: string) => Promise<string>;
307
+ /** Spec 08 — current-turn loop guard for tools that want to inspect status. */
308
+ loopGuard?: LoopGuard;
309
+ /** Domain-expert consultants loaded from .kondi-chat/consultants.json. */
310
+ consultants?: import('./consultants.ts').Consultant[];
311
+ /** Symbol index for find_symbol and related_files tools. */
312
+ symbolIndex?: import('../context/symbol-index.ts').SymbolIndexer;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Tool dispatcher
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export interface ToolExecutionResult {
320
+ content: string;
321
+ isError?: boolean;
322
+ /** Spec 03 — unified diff populated by write_file / edit_file. */
323
+ diff?: string;
324
+ }
325
+
326
+ export async function executeTool(
327
+ name: string,
328
+ args: Record<string, unknown>,
329
+ ctx: ToolContext,
330
+ ): Promise<ToolExecutionResult> {
331
+ try {
332
+ switch (name) {
333
+ case 'create_task':
334
+ return await toolCreateTask(args, ctx);
335
+ case 'consult': {
336
+ const { executeConsult } = await import('./consultants.ts');
337
+ return await executeConsult(args, ctx.consultants ?? [], ctx.ledger, ctx.workingDir);
338
+ }
339
+ case 'repo_map': {
340
+ const map = ctx.session.repoMap;
341
+ if (!map) return { content: 'No repo map available. The project may not have been bootstrapped.' };
342
+ const lines: string[] = [];
343
+ if (map.stack.length > 0) lines.push(`Stack: ${map.stack.join(', ')}`);
344
+ if (map.entrypoints.length > 0) lines.push(`Entry points: ${map.entrypoints.join(', ')}`);
345
+ if (map.subsystems.length > 0) {
346
+ lines.push('Subsystems:');
347
+ for (const s of map.subsystems) {
348
+ lines.push(` ${s.name}: ${s.purpose} (${s.paths.join(', ')})`);
349
+ }
350
+ }
351
+ const cmds = map.commands;
352
+ if (cmds.build || cmds.test || cmds.lint || cmds.typecheck) {
353
+ lines.push('Commands:');
354
+ if (cmds.build) lines.push(` build: ${cmds.build}`);
355
+ if (cmds.test) lines.push(` test: ${cmds.test}`);
356
+ if (cmds.lint) lines.push(` lint: ${cmds.lint}`);
357
+ if (cmds.typecheck) lines.push(` typecheck: ${cmds.typecheck}`);
358
+ }
359
+ if (map.conventions.length > 0) lines.push(`Conventions: ${map.conventions.join('; ')}`);
360
+ // Add symbol index summary if available
361
+ if (ctx.symbolIndex) lines.push(ctx.symbolIndex.format());
362
+ return { content: lines.join('\n') || 'Repo map is empty.' };
363
+ }
364
+ case 'find_symbol': {
365
+ if (!ctx.symbolIndex) return { content: 'Symbol index not available' };
366
+ const results = ctx.symbolIndex.findSymbol(String(args.name || ''));
367
+ if (results.length === 0) return { content: `No symbols matching "${args.name}"` };
368
+ return {
369
+ content: results.map(s => `${s.kind} ${s.name} → ${s.file}:${s.line}`).join('\n'),
370
+ };
371
+ }
372
+ case 'related_files': {
373
+ if (!ctx.symbolIndex) return { content: 'Symbol index not available' };
374
+ const related = ctx.symbolIndex.relatedFiles(String(args.path || ''));
375
+ if (related.length === 0) return { content: `No related files for "${args.path}"` };
376
+ return { content: related.join('\n') };
377
+ }
378
+ case 'read_file':
379
+ return await toolReadFile(args, ctx);
380
+ case 'list_files':
381
+ return toolListFiles(args, ctx);
382
+ case 'search_code':
383
+ return toolSearchCode(args, ctx);
384
+ case 'run_command':
385
+ return toolRunCommand(args, ctx);
386
+ case 'update_plan':
387
+ return toolUpdatePlan(args, ctx);
388
+ case 'write_file':
389
+ return await toolWriteFile(args, ctx);
390
+ case 'edit_file':
391
+ return await toolEditFile(args, ctx);
392
+ case 'update_memory':
393
+ return toolUpdateMemory(args, ctx);
394
+ case 'spawn_agent':
395
+ return await toolSpawnAgent(args, ctx);
396
+ default:
397
+ return { content: `Unknown tool: ${name}`, isError: true };
398
+ }
399
+ } catch (error) {
400
+ return { content: `Tool error: ${(error as Error).message}`, isError: true };
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Tool implementations
406
+ // ---------------------------------------------------------------------------
407
+
408
+ async function toolCreateTask(
409
+ args: Record<string, unknown>,
410
+ ctx: ToolContext,
411
+ ): Promise<ToolExecutionResult> {
412
+ const description = args.description as string;
413
+
414
+ let result;
415
+ try {
416
+ result = await runPipeline(description, ctx.session, ctx.ledger, ctx.pipelineConfig);
417
+ } catch (e) {
418
+ const { PipelineError } = await import('./errors.ts');
419
+ if (e instanceof PipelineError) {
420
+ // Structured pipeline failure — tell the model which stage broke
421
+ // and whether it's worth retrying.
422
+ return {
423
+ content:
424
+ `create_task failed at stage "${e.stage}" (${e.severity}): ${e.message}\n\n` +
425
+ (e.severity === 'recoverable'
426
+ ? 'This is recoverable — consider adjusting the task description or reading related files first.'
427
+ : 'This is fatal — the pipeline cannot complete this task as described. Consider a different approach.'),
428
+ isError: true,
429
+ };
430
+ }
431
+ return {
432
+ content: `create_task failed: ${e instanceof Error ? e.message : String(e)}`,
433
+ isError: true,
434
+ };
435
+ }
436
+
437
+ const summary = [
438
+ `Task ${result.task.id} (${result.task.kind}): ${result.task.status}`,
439
+ result.promoted ? '(promoted to frontier after failures)' : '',
440
+ '',
441
+ result.reflection,
442
+ '',
443
+ result.verification
444
+ ? `Verification: ${result.verification.passed ? 'PASSED' : 'FAILED'}`
445
+ : 'Verification: skipped',
446
+ ].filter(Boolean).join('\n');
447
+
448
+ return { content: summary };
449
+ }
450
+
451
+ async function toolReadFile(
452
+ args: Record<string, unknown>,
453
+ ctx: ToolContext,
454
+ ): Promise<{ content: string; isError?: boolean }> {
455
+ const relPath = args.path as string;
456
+ const offset = (args.offset as number) || 0;
457
+ const maxLines = (args.max_lines as number) || 200;
458
+ const base = resolve(ctx.workingDir);
459
+ const fullPath = resolve(join(ctx.workingDir, relPath));
460
+
461
+ if (!isPathSafe(base, fullPath)) {
462
+ return { content: `Path traversal blocked: ${relPath}`, isError: true };
463
+ }
464
+
465
+ ctx.setActiveFile?.(relPath);
466
+ let content: string;
467
+ try {
468
+ content = await readFile(fullPath, 'utf-8');
469
+ } catch (e: any) {
470
+ if (e?.code === 'ENOENT') return { content: `File not found: ${relPath}`, isError: true };
471
+ return { content: `Read failed: ${e?.message || String(e)}`, isError: true };
472
+ }
473
+ const allLines = content.split('\n');
474
+ const totalLines = allLines.length;
475
+ const start = Math.min(offset, totalLines);
476
+ const slice = allLines.slice(start, start + maxLines);
477
+ const result = slice.join('\n');
478
+
479
+ const header = offset > 0 ? `[lines ${start + 1}–${start + slice.length} of ${totalLines}]\n` : '';
480
+ const footer = (start + slice.length < totalLines)
481
+ ? `\n\n... (${totalLines - start - slice.length} more lines)`
482
+ : '';
483
+ return { content: `${header}${result}${footer}` };
484
+ }
485
+
486
+ function toolListFiles(
487
+ args: Record<string, unknown>,
488
+ ctx: ToolContext,
489
+ ): { content: string; isError?: boolean } {
490
+ const relPath = (args.path as string) || '.';
491
+ const recursive = (args.recursive as boolean) || false;
492
+ const base = resolve(ctx.workingDir);
493
+ const fullPath = resolve(join(ctx.workingDir, relPath));
494
+
495
+ if (!isPathSafe(base, fullPath)) {
496
+ return { content: `Path traversal blocked: ${relPath}`, isError: true };
497
+ }
498
+ if (!existsSync(fullPath)) {
499
+ return { content: `Directory not found: ${relPath}`, isError: true };
500
+ }
501
+
502
+ // process.stderr.write(`[tool] list_files: ${relPath}${recursive ? ' (recursive)' : ''}\n`);
503
+
504
+ if (recursive) {
505
+ try {
506
+ const output = execSync(
507
+ `find . -maxdepth 4 -type f ` +
508
+ `-not -path '*/node_modules/*' -not -path '*/.git/*' ` +
509
+ `-not -path '*/target/*' -not -path '*/__pycache__/*' ` +
510
+ `-not -path '*/.next/*' -not -path '*/dist/*' ` +
511
+ `| sort | head -100`,
512
+ { cwd: fullPath, encoding: 'utf-8', timeout: 10_000 },
513
+ ).trim();
514
+ return { content: output || '(empty directory)' };
515
+ } catch {
516
+ return { content: '(failed to list files)', isError: true };
517
+ }
518
+ }
519
+
520
+ const entries = readdirSync(fullPath);
521
+ const formatted = entries.map(entry => {
522
+ const entryPath = join(fullPath, entry);
523
+ try {
524
+ const stat = statSync(entryPath);
525
+ return stat.isDirectory() ? `${entry}/` : entry;
526
+ } catch {
527
+ return entry;
528
+ }
529
+ });
530
+ return { content: formatted.join('\n') || '(empty directory)' };
531
+ }
532
+
533
+ function toolSearchCode(
534
+ args: Record<string, unknown>,
535
+ ctx: ToolContext,
536
+ ): { content: string; isError?: boolean } {
537
+ const pattern = args.pattern as string;
538
+ const relPath = (args.path as string) || '.';
539
+ const glob = args.glob as string | undefined;
540
+ const base = resolve(ctx.workingDir);
541
+ const searchPath = resolve(join(ctx.workingDir, relPath));
542
+
543
+ if (!isPathSafe(base, searchPath)) {
544
+ return { content: `Path traversal blocked: ${relPath}`, isError: true };
545
+ }
546
+
547
+ // process.stderr.write(`[tool] search_code: "${pattern}" in ${relPath}\n`);
548
+
549
+ // Sanitize glob (defense-in-depth even though execFileSync skips the shell).
550
+ const safeGlob = glob ? glob.replace(/[^a-zA-Z0-9.*?_\-\/]/g, '') : '';
551
+ const grepArgs: string[] = [
552
+ '-rnE', // recursive, line numbers, extended regex
553
+ '--exclude-dir=node_modules',
554
+ '--exclude-dir=.git',
555
+ ];
556
+ if (safeGlob) grepArgs.push(`--include=${safeGlob}`);
557
+ grepArgs.push('-e', pattern, searchPath);
558
+
559
+ try {
560
+ const raw = execFileSync('grep', grepArgs, {
561
+ encoding: 'utf-8',
562
+ timeout: 15_000,
563
+ cwd: ctx.workingDir,
564
+ maxBuffer: 4 * 1024 * 1024,
565
+ });
566
+ const lines = raw.split('\n');
567
+ const head = lines.slice(0, 50).join('\n').trim();
568
+ return { content: head || 'No matches found.' };
569
+ } catch (error: any) {
570
+ // grep returns exit code 1 for no matches.
571
+ if (error.status === 1) {
572
+ return { content: 'No matches found.' };
573
+ }
574
+ // Exit 2 = invalid regex / IO error. Surface a useful message rather
575
+ // than the raw shell complaint so the model can correct its pattern.
576
+ if (error.status === 2) {
577
+ return {
578
+ content: `Invalid regex: ${pattern} — grep -E rejected it. Try escaping special chars or use search_files for a literal lookup.`,
579
+ isError: true,
580
+ };
581
+ }
582
+ return { content: `Search error: ${error.message}`, isError: true };
583
+ }
584
+ }
585
+
586
+ function toolRunCommand(
587
+ args: Record<string, unknown>,
588
+ ctx: ToolContext,
589
+ ): { content: string; isError?: boolean } {
590
+ const command = args.command as string;
591
+ const timeout = (args.timeout as number) || 30_000;
592
+
593
+ // process.stderr.write(`[tool] run_command: ${command}\n`);
594
+
595
+ try {
596
+ const output = execSync(command, {
597
+ cwd: ctx.workingDir,
598
+ encoding: 'utf-8',
599
+ timeout,
600
+ stdio: ['pipe', 'pipe', 'pipe'],
601
+ });
602
+ const trimmed = output.trim().slice(-4000);
603
+ return { content: trimmed || '(no output)' };
604
+ } catch (error: any) {
605
+ const stdout = error.stdout?.toString() || '';
606
+ const stderr = error.stderr?.toString() || '';
607
+ const combined = `${stdout}\n${stderr}`.trim().slice(-4000);
608
+ return { content: `Exit code ${error.status ?? 'unknown'}:\n${combined || error.message}`, isError: true };
609
+ }
610
+ }
611
+
612
+ function toolUpdatePlan(
613
+ args: Record<string, unknown>,
614
+ ctx: ToolContext,
615
+ ): { content: string } {
616
+ const state = ctx.session.state;
617
+
618
+ if (args.goal !== undefined) state.goal = args.goal as string;
619
+ if (args.decisions !== undefined) state.decisions = args.decisions as string[];
620
+ if (args.constraints !== undefined) state.constraints = args.constraints as string[];
621
+ if (args.plan !== undefined) state.currentPlan = args.plan as string[];
622
+
623
+ // process.stderr.write(`[tool] update_plan: goal="${state.goal}"\n`);
624
+
625
+ const summary = [
626
+ `Goal: ${state.goal || '(not set)'}`,
627
+ `Plan: ${state.currentPlan.join(' → ') || '(none)'}`,
628
+ `Decisions: ${state.decisions.join('; ') || '(none)'}`,
629
+ `Constraints: ${state.constraints.join('; ') || '(none)'}`,
630
+ ].join('\n');
631
+
632
+ return { content: `Plan updated.\n${summary}` };
633
+ }
634
+
635
+ async function toolWriteFile(
636
+ args: Record<string, unknown>,
637
+ ctx: ToolContext,
638
+ ): Promise<ToolExecutionResult> {
639
+ const relPath = args.path as string;
640
+ const content = args.content as string;
641
+ const base = resolve(ctx.workingDir);
642
+ const fullPath = resolve(join(ctx.workingDir, relPath));
643
+
644
+ if (!isPathSafe(base, fullPath)) {
645
+ return { content: `Path traversal blocked: ${relPath}`, isError: true };
646
+ }
647
+
648
+ const existed = existsSync(fullPath);
649
+ let originalContent = '';
650
+ if (existed) {
651
+ try { originalContent = await readFile(fullPath, 'utf-8'); } catch { originalContent = ''; }
652
+ const backupDir = join(ctx.workingDir, '.kondi-chat', 'backups', 'latest');
653
+ const backupPath = join(backupDir, relPath);
654
+ await mkdir(dirname(backupPath), { recursive: true });
655
+ await copyFile(fullPath, backupPath);
656
+ }
657
+
658
+ try {
659
+ await mkdir(dirname(fullPath), { recursive: true });
660
+ await writeFile(fullPath, content);
661
+ } catch (e: any) {
662
+ return { content: `Write failed: ${e?.message || String(e)}`, isError: true };
663
+ }
664
+ ctx.setActiveFile?.(relPath);
665
+ ctx.mutatedFiles?.add(relPath);
666
+
667
+ const d = computeUnifiedDiff(relPath, originalContent, content);
668
+ return {
669
+ content: `${existed ? 'Updated' : 'Created'} ${relPath} (+${d.linesAdded}/-${d.linesRemoved})`,
670
+ diff: d.diff || undefined,
671
+ };
672
+ }
673
+
674
+ async function toolEditFile(
675
+ args: Record<string, unknown>,
676
+ ctx: ToolContext,
677
+ ): Promise<ToolExecutionResult> {
678
+ const relPath = args.path as string;
679
+ const oldString = args.old_string as string;
680
+ const newString = args.new_string as string;
681
+ const base = resolve(ctx.workingDir);
682
+ const fullPath = resolve(join(ctx.workingDir, relPath));
683
+
684
+ if (!isPathSafe(base, fullPath)) {
685
+ return { content: `Path traversal blocked: ${relPath}`, isError: true };
686
+ }
687
+
688
+ let original: string;
689
+ try {
690
+ original = await readFile(fullPath, 'utf-8');
691
+ } catch (e: any) {
692
+ if (e?.code === 'ENOENT') return { content: `File not found: ${relPath}`, isError: true };
693
+ return { content: `Read failed: ${e?.message || String(e)}`, isError: true };
694
+ }
695
+
696
+ // Check the old_string exists
697
+ const idx = original.indexOf(oldString);
698
+ if (idx === -1) {
699
+ return { content: `old_string not found in ${relPath}. Make sure it matches exactly (including whitespace).`, isError: true };
700
+ }
701
+
702
+ // Check it's unique
703
+ const secondIdx = original.indexOf(oldString, idx + 1);
704
+ if (secondIdx !== -1) {
705
+ return { content: `old_string matches multiple locations in ${relPath}. Provide more context to make it unique.`, isError: true };
706
+ }
707
+
708
+ // Backup
709
+ const backupDir = join(ctx.workingDir, '.kondi-chat', 'backups', 'latest');
710
+ const backupPath = join(backupDir, relPath);
711
+ try {
712
+ await mkdir(dirname(backupPath), { recursive: true });
713
+ await copyFile(fullPath, backupPath);
714
+ } catch (e: any) {
715
+ return { content: `Backup failed: ${e?.message || String(e)}`, isError: true };
716
+ }
717
+
718
+ // Apply edit
719
+ const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length);
720
+ try {
721
+ await writeFile(fullPath, updated);
722
+ } catch (e: any) {
723
+ return { content: `Write failed: ${e?.message || String(e)}`, isError: true };
724
+ }
725
+ ctx.setActiveFile?.(relPath);
726
+ ctx.mutatedFiles?.add(relPath);
727
+
728
+ const d = computeUnifiedDiff(relPath, original, updated);
729
+ return {
730
+ content: `Edited ${relPath} (+${d.linesAdded}/-${d.linesRemoved})`,
731
+ diff: d.diff || undefined,
732
+ };
733
+ }
734
+
735
+ async function toolSpawnAgent(
736
+ args: Record<string, unknown>,
737
+ ctx: ToolContext,
738
+ ): Promise<ToolExecutionResult> {
739
+ const type = args.type as 'research' | 'worker' | 'planner';
740
+ const instruction = args.instruction as string;
741
+ if (!ctx.spawnSubAgent) {
742
+ return { content: 'spawn_agent is not available in this context (no sub-agent runner)', isError: true };
743
+ }
744
+ if (!['research', 'worker', 'planner'].includes(type)) {
745
+ return { content: `Invalid sub-agent type: ${type}`, isError: true };
746
+ }
747
+ if (!instruction) {
748
+ return { content: 'spawn_agent requires a non-empty instruction', isError: true };
749
+ }
750
+ try {
751
+ const result = await ctx.spawnSubAgent(type, instruction);
752
+ return { content: result };
753
+ } catch (e) {
754
+ return { content: `spawn_agent failed: ${(e as Error).message}`, isError: true };
755
+ }
756
+ }
757
+
758
+ function toolUpdateMemory(
759
+ args: Record<string, unknown>,
760
+ ctx: ToolContext,
761
+ ): ToolExecutionResult {
762
+ const scope = args.scope as 'project' | 'user';
763
+ const operation = args.operation as 'append' | 'replace';
764
+ const content = args.content as string;
765
+ if (!ctx.memoryManager) {
766
+ return { content: 'Memory manager not available', isError: true };
767
+ }
768
+ if (scope !== 'project' && scope !== 'user') {
769
+ return { content: `Invalid scope: ${scope} (expected 'project' or 'user')`, isError: true };
770
+ }
771
+ if (operation !== 'append' && operation !== 'replace') {
772
+ return { content: `Invalid operation: ${operation} (expected 'append' or 'replace')`, isError: true };
773
+ }
774
+ const { path } = ctx.memoryManager.updateMemory(scope, operation, content);
775
+ // Track memory file mutations for checkpoint coverage (Spec 05 clarification).
776
+ if (ctx.mutatedFiles) {
777
+ try {
778
+ const rel = relative(ctx.workingDir, path);
779
+ if (!rel.startsWith('..')) ctx.mutatedFiles.add(rel);
780
+ } catch { /* ignore */ }
781
+ }
782
+ return { content: `Memory ${operation} → ${path}` };
783
+ }