@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,347 @@
1
+ /**
2
+ * Consultants — domain-expert personas the agent can call on demand.
3
+ *
4
+ * A consultant is a triple of (model, system prompt, optional context
5
+ * strategy). The agent decides when to ask for help and passes a
6
+ * specific question; the consultant's response comes back through the
7
+ * normal tool-call channel. Because consultants are configured in a JSON
8
+ * file, users can add new experts without touching TypeScript.
9
+ *
10
+ * Consultants are deliberately stateless and pure text-in/text-out — they
11
+ * do NOT have access to the main agent's tool set, memory, or session.
12
+ * If you need a consultant that can read files or run commands, spawn a
13
+ * sub-agent via `spawn_agent` instead; that path is heavier but fully
14
+ * agentic. Consultants are for opinion, not for execution.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
18
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
19
+ import type { ProviderId } from '../types.ts';
20
+ import type { Ledger } from '../audit/ledger.ts';
21
+ import { callLLM } from '../providers/llm-caller.ts';
22
+ import type { ToolExecutionResult } from './tools.ts';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface Consultant {
29
+ /** Short machine identifier used in the `consult` tool call. */
30
+ role: string;
31
+ /** Human-readable name shown in listings. */
32
+ name: string;
33
+ /**
34
+ * One-line description of when the agent should consult this expert.
35
+ * Surfaced to the agent so it can decide whether a given problem warrants
36
+ * this particular perspective.
37
+ */
38
+ description: string;
39
+ /** Provider + model that runs this persona. */
40
+ provider: ProviderId;
41
+ model: string;
42
+ /**
43
+ * The full system prompt that defines the persona. This is where the
44
+ * expertise actually lives — the choice of model is secondary to a
45
+ * well-written system prompt describing priorities, vocabulary, and
46
+ * what to flag.
47
+ */
48
+ system: string;
49
+ /** Soft cap on output tokens for this consultant. Default 2048. */
50
+ maxOutputTokens?: number;
51
+ /**
52
+ * Static text that should be included in this consultant's context on
53
+ * every call. Good for: project-specific constraints the consultant
54
+ * should always know ("target DO-178C DAL-B", "monorepo of 40k LOC
55
+ * TypeScript", etc.), vocabulary, stable decisions. Appended to the
56
+ * system prompt so it benefits from provider-side prompt caching.
57
+ */
58
+ contextText?: string;
59
+ /**
60
+ * Files to read from the working directory on every consultation and
61
+ * inject as context. Paths are relative to the working dir. Each file
62
+ * is capped at `contextFileMaxBytes` (default 50 KB) to prevent a
63
+ * stray large file from blowing the prompt budget; the total load is
64
+ * capped at `contextTotalMaxBytes` (default 200 KB).
65
+ *
66
+ * Use for slow-changing reference material like specs, design docs,
67
+ * or the README. Do NOT use for active source files the agent is
68
+ * editing — that path belongs in the per-call `context` argument so
69
+ * the consultant sees the current state, not a stale snapshot.
70
+ */
71
+ contextFiles?: string[];
72
+ /** Per-file byte cap (default 50_000). */
73
+ contextFileMaxBytes?: number;
74
+ /** Total context byte cap across all contextFiles (default 200_000). */
75
+ contextTotalMaxBytes?: number;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Default roster — created on first run so the file exists to edit
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const DEFAULT_CONSULTANTS: Consultant[] = [
83
+ {
84
+ role: 'aerospace-engineer',
85
+ name: 'Senior Aerospace Engineer',
86
+ description:
87
+ 'Review designs and implementations for flight-safety, fault tolerance, margins, ' +
88
+ 'redundancy, and certification implications. Use for avionics, flight control, ' +
89
+ 'propulsion, actuation, or any safety-critical embedded code.',
90
+ provider: 'openai',
91
+ model: 'gpt-5.4',
92
+ system:
93
+ 'You are a senior aerospace engineer with 30 years of experience across avionics, ' +
94
+ 'flight control software, propulsion control, and safety-critical embedded systems. ' +
95
+ 'When reviewing a design or implementation, think explicitly about: failure modes ' +
96
+ '(FMEA), single points of failure, margins (timing, current, thermal, structural), ' +
97
+ 'redundancy and voting, fault containment, fail-operational vs fail-safe behavior, ' +
98
+ 'flight envelope, bus loading and real-time scheduling, certification implications ' +
99
+ '(DO-178C DAL, DO-254), and what the pilot sees when it breaks. Be blunt about risks. ' +
100
+ 'If the question is outside your domain, say so explicitly rather than guess. ' +
101
+ 'Output: numbered concerns in priority order, each with severity (LOW/MED/HIGH/BLOCKING) ' +
102
+ 'and a concrete mitigation.',
103
+ maxOutputTokens: 2048,
104
+ // Example of persistent context — commented out because these paths
105
+ // likely don't exist in your project. Edit consultants.json to point
106
+ // at real spec files once you have them:
107
+ //
108
+ // "contextText": "Target platform: ARM Cortex-R52 triple-core lockstep. DO-178C DAL-B.",
109
+ // "contextFiles": ["specs/fmea.md", "specs/safety-case.md"]
110
+ },
111
+ {
112
+ role: 'security-auditor',
113
+ name: 'Application Security Auditor',
114
+ description:
115
+ 'Review code for security vulnerabilities: OWASP top-10, authn/authz, input validation, ' +
116
+ 'secrets handling, injection, SSRF, crypto misuse, race conditions, supply-chain risks. ' +
117
+ 'Use when touching auth flows, user input, cryptography, file I/O, or network requests.',
118
+ provider: 'anthropic',
119
+ model: 'claude-sonnet-4-5-20250929',
120
+ system:
121
+ 'You are an application security auditor with a red-team background. Review the ' +
122
+ 'supplied code or design against: OWASP top-10, authentication and authorization ' +
123
+ 'flows, input validation and parser differentials, injection (SQL, command, path, ' +
124
+ 'template), SSRF and DNS rebinding, cryptography misuse and weak randomness, secrets ' +
125
+ 'handling, TOCTOU and race conditions, deserialization risks, dependency/supply-chain ' +
126
+ 'integrity, and denial-of-service surface. Be specific about exploit scenarios. ' +
127
+ 'Output: numbered findings in severity order (INFO/LOW/MED/HIGH/CRITICAL), each with ' +
128
+ 'a one-line attack description and a concrete remediation.',
129
+ maxOutputTokens: 2048,
130
+ },
131
+ {
132
+ role: 'database-architect',
133
+ name: 'Database Architect',
134
+ description:
135
+ 'Review schemas, queries, migrations, and data access patterns. Use for questions ' +
136
+ 'about indexes, transaction isolation, migration safety on large tables, query plans, ' +
137
+ 'normalization trade-offs, partitioning, or OLTP-vs-OLAP boundaries.',
138
+ provider: 'anthropic',
139
+ model: 'claude-sonnet-4-5-20250929',
140
+ system:
141
+ 'You are a database architect fluent in Postgres, MySQL, SQLite, and general RDBMS ' +
142
+ 'theory. When reviewing a schema, query, or migration, think about: index coverage, ' +
143
+ 'query plan stability, lock scope and duration, isolation levels and phantom reads, ' +
144
+ 'migration safety on large tables (NOT NULL backfills, column drops, type changes), ' +
145
+ 'transaction semantics, normalization vs denormalization trade-offs, JSON/JSONB usage, ' +
146
+ 'partitioning, read-replica consistency, and OLTP/OLAP mixing. Point out anti-patterns ' +
147
+ 'directly. Output: numbered concerns by severity, each with a concrete fix.',
148
+ maxOutputTokens: 2048,
149
+ },
150
+ ];
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Loader
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Load consultants from `<storageDir>/consultants.json`. If the file
158
+ * doesn't exist, seed it with the default roster so users have a
159
+ * starting point to edit.
160
+ */
161
+ export function loadConsultants(storageDir: string): Consultant[] {
162
+ const path = join(storageDir, 'consultants.json');
163
+ if (!existsSync(path)) {
164
+ try {
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ writeFileSync(path, JSON.stringify(DEFAULT_CONSULTANTS, null, 2));
167
+ } catch {
168
+ /* non-fatal — fall back to in-memory defaults */
169
+ }
170
+ return [...DEFAULT_CONSULTANTS];
171
+ }
172
+ try {
173
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
174
+ if (!Array.isArray(raw)) return [...DEFAULT_CONSULTANTS];
175
+ return raw.filter(
176
+ (c): c is Consultant =>
177
+ typeof c?.role === 'string' &&
178
+ typeof c?.name === 'string' &&
179
+ typeof c?.provider === 'string' &&
180
+ typeof c?.model === 'string' &&
181
+ typeof c?.system === 'string',
182
+ );
183
+ } catch {
184
+ return [...DEFAULT_CONSULTANTS];
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Execution
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Run the `consult` tool. Three behaviors based on `args`:
194
+ *
195
+ * 1. No `role` provided → return a roster listing so the agent can
196
+ * decide who to ask next. Pure discovery call, cheap.
197
+ * 2. Unknown `role` → return an error listing the valid roles.
198
+ * 3. Valid `role` + `question` → invoke the consultant's LLM with the
199
+ * system prompt from the JSON, return the response as tool content.
200
+ *
201
+ * Consultation is logged to the ledger as `phase: 'consult'` with the
202
+ * consultant's role in the promptSummary so `/routing` and `/cost` can
203
+ * attribute the spend.
204
+ */
205
+ export async function executeConsult(
206
+ args: Record<string, unknown>,
207
+ consultants: Consultant[],
208
+ ledger: Ledger,
209
+ workingDir: string,
210
+ ): Promise<ToolExecutionResult> {
211
+ const role = typeof args.role === 'string' ? args.role.trim() : '';
212
+ const question = typeof args.question === 'string' ? args.question : '';
213
+ const callerContext = typeof args.context === 'string' ? args.context : '';
214
+
215
+ if (!role) {
216
+ return { content: formatRoster(consultants) };
217
+ }
218
+
219
+ const consultant = consultants.find(c => c.role === role);
220
+ if (!consultant) {
221
+ return {
222
+ content:
223
+ `Unknown consultant role: ${role}\n\n` +
224
+ `Available roles:\n${consultants.map(c => ` - ${c.role} — ${c.description}`).join('\n')}`,
225
+ isError: true,
226
+ };
227
+ }
228
+
229
+ if (!question) {
230
+ return {
231
+ content: `consult requires a "question" when a role is specified. You asked for ${consultant.name} but didn't pass a question.`,
232
+ isError: true,
233
+ };
234
+ }
235
+
236
+ // Assemble the persistent context block (contextText + contextFiles).
237
+ // Failures loading a file are reported inline so the consultant — and
238
+ // the orchestrating agent — can see that a reference is missing, but
239
+ // they do not abort the call.
240
+ const persistent = assemblePersistentContext(consultant, workingDir);
241
+ const systemPrompt = persistent
242
+ ? `${consultant.system}\n\n--- Project reference (persistent) ---\n${persistent}`
243
+ : consultant.system;
244
+
245
+ const userMessage = callerContext
246
+ ? `${question}\n\n--- Context from caller ---\n${callerContext}`
247
+ : question;
248
+
249
+ try {
250
+ const response = await callLLM({
251
+ provider: consultant.provider,
252
+ model: consultant.model,
253
+ systemPrompt,
254
+ userMessage,
255
+ maxOutputTokens: consultant.maxOutputTokens ?? 2048,
256
+ temperature: 0.2,
257
+ });
258
+ ledger.record(
259
+ 'consult',
260
+ response,
261
+ `consult ${consultant.role}: ${question.slice(0, 160)}`,
262
+ );
263
+ return {
264
+ content:
265
+ `[${consultant.name} · ${response.model}]\n\n${response.content || '(no response)'}`,
266
+ };
267
+ } catch (error) {
268
+ return {
269
+ content: `Consultation with ${consultant.role} failed: ${(error as Error).message}`,
270
+ isError: true,
271
+ };
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Build the persistent-context block from `contextText` + `contextFiles`.
277
+ * Files are resolved against `workingDir`, byte-capped per file and in
278
+ * total, and path-escape-protected so a consultant config can't read
279
+ * arbitrary paths outside the project by setting `"../../etc/passwd"`.
280
+ */
281
+ function assemblePersistentContext(consultant: Consultant, workingDir: string): string {
282
+ const perFileCap = consultant.contextFileMaxBytes ?? 50_000;
283
+ const totalCap = consultant.contextTotalMaxBytes ?? 200_000;
284
+
285
+ const parts: string[] = [];
286
+ if (consultant.contextText && consultant.contextText.trim().length > 0) {
287
+ parts.push(consultant.contextText.trim());
288
+ }
289
+
290
+ if (consultant.contextFiles && consultant.contextFiles.length > 0) {
291
+ const base = resolve(workingDir);
292
+ let remaining = totalCap;
293
+ for (const relOrAbs of consultant.contextFiles) {
294
+ if (remaining <= 0) {
295
+ parts.push(`[context file skipped — total cap ${totalCap} bytes reached]`);
296
+ break;
297
+ }
298
+ const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(base, relOrAbs);
299
+ if (!abs.startsWith(base)) {
300
+ parts.push(`[context file rejected — outside working dir: ${relOrAbs}]`);
301
+ continue;
302
+ }
303
+ try {
304
+ const stat = statSync(abs);
305
+ if (!stat.isFile()) {
306
+ parts.push(`[context file not a regular file: ${relOrAbs}]`);
307
+ continue;
308
+ }
309
+ const cap = Math.min(perFileCap, remaining);
310
+ const raw = readFileSync(abs, 'utf-8');
311
+ const clipped = raw.length > cap
312
+ ? raw.slice(0, cap) + `\n[...truncated from ${raw.length} to ${cap} bytes]`
313
+ : raw;
314
+ remaining -= clipped.length;
315
+ parts.push(`# ${relOrAbs}\n${clipped}`);
316
+ } catch (e) {
317
+ parts.push(`[context file load failed: ${relOrAbs} — ${(e as Error).message}]`);
318
+ }
319
+ }
320
+ }
321
+
322
+ return parts.join('\n\n');
323
+ }
324
+
325
+ function formatRoster(consultants: Consultant[]): string {
326
+ if (consultants.length === 0) {
327
+ return 'No consultants configured. Edit .kondi-chat/consultants.json to add some.';
328
+ }
329
+ const lines: string[] = ['Available consultants:', ''];
330
+ for (const c of consultants) {
331
+ lines.push(` ${c.role}`);
332
+ lines.push(` ${c.name} (${c.provider}/${c.model})`);
333
+ lines.push(` ${c.description}`);
334
+ if (c.contextText) {
335
+ const preview = c.contextText.trim().replace(/\s+/g, ' ');
336
+ lines.push(` baseline: ${preview.length > 120 ? preview.slice(0, 117) + '…' : preview}`);
337
+ }
338
+ if (c.contextFiles && c.contextFiles.length > 0) {
339
+ lines.push(` attached files: ${c.contextFiles.join(', ')}`);
340
+ }
341
+ lines.push('');
342
+ }
343
+ lines.push(
344
+ 'Call consult({role: "<role>", question: "<your question>", context?: "<optional file or design snippet>"}) to ask one.',
345
+ );
346
+ return lines.join('\n');
347
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Unified diff computation for file edits.
3
+ *
4
+ * Self-contained line-level LCS. Used by write_file / edit_file tools
5
+ * (Spec 03) and git_diff tool (Spec 02) — the latter imports from here.
6
+ */
7
+
8
+ const CONTEXT_LINES = 3;
9
+ const MAX_LINES = 200;
10
+ const MAX_BYTES = 200 * 1024;
11
+ const MAX_SOURCE_LINES = 5000;
12
+
13
+ export interface DiffResult {
14
+ /** Unified diff string with ---/+++ headers, or '' if empty/binary/too-large. */
15
+ diff: string;
16
+ linesAdded: number;
17
+ linesRemoved: number;
18
+ /** True if output was capped at MAX_LINES. */
19
+ truncated: boolean;
20
+ /** True if input was skipped (too large or binary). */
21
+ skipped?: 'file-too-large' | 'binary' | 'empty';
22
+ }
23
+
24
+ function isBinary(s: string): boolean {
25
+ // Fast heuristic: NUL byte in first 8KiB
26
+ const limit = Math.min(s.length, 8192);
27
+ for (let i = 0; i < limit; i++) {
28
+ if (s.charCodeAt(i) === 0) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /** Compute LCS-based line diff and emit unified-diff hunks. */
34
+ export function computeUnifiedDiff(
35
+ filePath: string,
36
+ oldContent: string,
37
+ newContent: string,
38
+ ): DiffResult {
39
+ if (oldContent === newContent) {
40
+ return { diff: '', linesAdded: 0, linesRemoved: 0, truncated: false, skipped: 'empty' };
41
+ }
42
+ if (
43
+ oldContent.length > MAX_BYTES ||
44
+ newContent.length > MAX_BYTES ||
45
+ isBinary(oldContent) ||
46
+ isBinary(newContent)
47
+ ) {
48
+ const skipped = isBinary(oldContent) || isBinary(newContent) ? 'binary' : 'file-too-large';
49
+ return { diff: '', linesAdded: 0, linesRemoved: 0, truncated: true, skipped };
50
+ }
51
+
52
+ const a = oldContent === '' ? [] : oldContent.split('\n');
53
+ const b = newContent === '' ? [] : newContent.split('\n');
54
+ if (a.length > MAX_SOURCE_LINES || b.length > MAX_SOURCE_LINES) {
55
+ return { diff: '', linesAdded: 0, linesRemoved: 0, truncated: true, skipped: 'file-too-large' };
56
+ }
57
+
58
+ const ops = diffLines(a, b);
59
+ const hunks = buildHunks(a, b, ops, CONTEXT_LINES);
60
+
61
+ let linesAdded = 0;
62
+ let linesRemoved = 0;
63
+ const out: string[] = [];
64
+ out.push(`--- ${oldContent === '' ? '/dev/null' : `a/${filePath}`}`);
65
+ out.push(`+++ ${newContent === '' ? '/dev/null' : `b/${filePath}`}`);
66
+
67
+ let truncated = false;
68
+ for (const h of hunks) {
69
+ out.push(`@@ -${h.oldStart},${h.oldLen} +${h.newStart},${h.newLen} @@`);
70
+ for (const line of h.lines) {
71
+ if (line[0] === '+') linesAdded++;
72
+ else if (line[0] === '-') linesRemoved++;
73
+ if (out.length >= MAX_LINES + 2) { truncated = true; break; }
74
+ out.push(line);
75
+ }
76
+ if (truncated) break;
77
+ }
78
+ if (truncated) out.push(`... (diff truncated at ${MAX_LINES} lines)`);
79
+
80
+ return { diff: out.join('\n'), linesAdded, linesRemoved, truncated };
81
+ }
82
+
83
+ // ── LCS line diff ─────────────────────────────────────────────────────
84
+
85
+ type Op = { kind: 'eq' | 'del' | 'add'; aIdx: number; bIdx: number };
86
+
87
+ function diffLines(a: string[], b: string[]): Op[] {
88
+ const n = a.length, m = b.length;
89
+ // DP table of LCS lengths
90
+ const dp: Uint32Array[] = [];
91
+ for (let i = 0; i <= n; i++) dp.push(new Uint32Array(m + 1));
92
+ for (let i = n - 1; i >= 0; i--) {
93
+ for (let j = m - 1; j >= 0; j--) {
94
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
95
+ }
96
+ }
97
+ const ops: Op[] = [];
98
+ let i = 0, j = 0;
99
+ while (i < n && j < m) {
100
+ if (a[i] === b[j]) { ops.push({ kind: 'eq', aIdx: i, bIdx: j }); i++; j++; }
101
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ kind: 'del', aIdx: i, bIdx: j }); i++; }
102
+ else { ops.push({ kind: 'add', aIdx: i, bIdx: j }); j++; }
103
+ }
104
+ while (i < n) { ops.push({ kind: 'del', aIdx: i, bIdx: j }); i++; }
105
+ while (j < m) { ops.push({ kind: 'add', aIdx: i, bIdx: j }); j++; }
106
+ return ops;
107
+ }
108
+
109
+ interface Hunk {
110
+ oldStart: number; oldLen: number;
111
+ newStart: number; newLen: number;
112
+ lines: string[];
113
+ }
114
+
115
+ function buildHunks(a: string[], b: string[], ops: Op[], context: number): Hunk[] {
116
+ // Find change regions and expand with context.
117
+ const hunks: Hunk[] = [];
118
+ let i = 0;
119
+ while (i < ops.length) {
120
+ if (ops[i].kind === 'eq') { i++; continue; }
121
+ // Start of a change — walk back for leading context.
122
+ let start = i;
123
+ let ctxBefore = 0;
124
+ while (start > 0 && ops[start - 1].kind === 'eq' && ctxBefore < context) {
125
+ start--; ctxBefore++;
126
+ }
127
+ // Walk forward through changes, allowing up to 2*context eq lines to merge adjacent hunks.
128
+ let end = i;
129
+ while (end < ops.length) {
130
+ if (ops[end].kind !== 'eq') { end++; continue; }
131
+ // Count eq run
132
+ let runEnd = end;
133
+ while (runEnd < ops.length && ops[runEnd].kind === 'eq') runEnd++;
134
+ const runLen = runEnd - end;
135
+ const isTail = runEnd === ops.length;
136
+ if (isTail || runLen > 2 * context) {
137
+ // Keep up to `context` trailing eq lines.
138
+ end = Math.min(end + context, runEnd);
139
+ break;
140
+ }
141
+ end = runEnd;
142
+ }
143
+
144
+ const lines: string[] = [];
145
+ const firstOp = ops[start];
146
+ const oldStartIdx = firstOp.aIdx;
147
+ const newStartIdx = firstOp.bIdx;
148
+ let oldLen = 0, newLen = 0;
149
+ for (let k = start; k < end; k++) {
150
+ const op = ops[k];
151
+ if (op.kind === 'eq') {
152
+ lines.push(' ' + a[op.aIdx]); oldLen++; newLen++;
153
+ } else if (op.kind === 'del') {
154
+ lines.push('-' + a[op.aIdx]); oldLen++;
155
+ } else {
156
+ lines.push('+' + b[op.bIdx]); newLen++;
157
+ }
158
+ }
159
+ // Unified diff: 1-based line numbers; if len==0 the "start" is the line BEFORE which
160
+ // content is added/removed, i.e. the 0-based index itself.
161
+ hunks.push({
162
+ oldStart: oldLen === 0 ? oldStartIdx : oldStartIdx + 1,
163
+ oldLen,
164
+ newStart: newLen === 0 ? newStartIdx : newStartIdx + 1,
165
+ newLen,
166
+ lines,
167
+ });
168
+ i = end;
169
+ }
170
+ return hunks;
171
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Structured error hierarchy for the agent engine.
3
+ *
4
+ * All domain-specific failure modes should inherit from `KondiError`
5
+ * rather than throw bare `Error` instances, so the backend and TUI can
6
+ * make informed decisions about how to surface a failure to the user
7
+ * (retry vs. give up vs. abort the turn).
8
+ *
9
+ * Design principles:
10
+ * - Every error carries a `severity` so callers can distinguish
11
+ * recoverable ("retry with different args") from fatal ("the
12
+ * pipeline cannot continue").
13
+ * - Every error carries a `stage` string so log messages and ledger
14
+ * entries can attribute the failure to the specific step that broke.
15
+ * - Errors are plain `Error` subclasses so they work with existing
16
+ * stack-trace tooling and Node's `instanceof` checks.
17
+ *
18
+ * This file is deliberately tiny — the goal is to stop swallowing errors
19
+ * at the pipeline layer, not to refactor every throw site in the
20
+ * codebase in one pass.
21
+ */
22
+
23
+ /** Coarse severity ladder for structured failures. */
24
+ export type ErrorSeverity =
25
+ | 'info' // informational — the caller can ignore this
26
+ | 'warning' // worth logging but not worth aborting for
27
+ | 'recoverable' // retry with different args may succeed
28
+ | 'fatal'; // the enclosing operation cannot continue
29
+
30
+ /**
31
+ * Base class for every structured engine error. Plain Errors are still
32
+ * valid throws — they just get treated as 'fatal' when a PipelineError
33
+ * isn't thrown.
34
+ */
35
+ export class KondiError extends Error {
36
+ readonly severity: ErrorSeverity;
37
+ readonly stage: string;
38
+ readonly cause?: unknown;
39
+
40
+ constructor(message: string, opts: { severity: ErrorSeverity; stage: string; cause?: unknown }) {
41
+ super(message);
42
+ this.name = this.constructor.name;
43
+ this.severity = opts.severity;
44
+ this.stage = opts.stage;
45
+ this.cause = opts.cause;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Thrown from within `runPipeline` when a stage cannot complete. Carries
51
+ * the stage name (`dispatch`/`execute`/`apply`/`verify`/`reflect`) so
52
+ * downstream error handlers can surface a meaningful location.
53
+ */
54
+ export class PipelineError extends KondiError {
55
+ constructor(
56
+ message: string,
57
+ opts: { severity: ErrorSeverity; stage: 'dispatch' | 'execute' | 'apply' | 'verify' | 'reflect'; cause?: unknown },
58
+ ) {
59
+ super(message, opts);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Thrown from tool executors on structured tool failures (not every
65
+ * tool error — routine "file not found" still returns `{ isError: true }`).
66
+ * Reserved for failures that should surface as errors rather than as
67
+ * tool-result content the model can read and react to.
68
+ */
69
+ export class ToolError extends KondiError {
70
+ readonly toolName: string;
71
+ constructor(message: string, opts: { severity: ErrorSeverity; toolName: string; cause?: unknown }) {
72
+ super(message, { severity: opts.severity, stage: `tool:${opts.toolName}`, cause: opts.cause });
73
+ this.toolName = opts.toolName;
74
+ }
75
+ }
76
+
77
+ /** Thrown by LLM provider calls when the request fails definitively. */
78
+ export class LlmCallError extends KondiError {
79
+ readonly provider: string;
80
+ readonly model: string;
81
+ readonly status?: number;
82
+ constructor(
83
+ message: string,
84
+ opts: { severity: ErrorSeverity; provider: string; model: string; status?: number; cause?: unknown },
85
+ ) {
86
+ super(message, { severity: opts.severity, stage: `llm:${opts.provider}/${opts.model}`, cause: opts.cause });
87
+ this.provider = opts.provider;
88
+ this.model = opts.model;
89
+ this.status = opts.status;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Helper: turn an unknown thrown value into a KondiError. Use when
95
+ * wrapping code that may throw bare Errors — the result is always a
96
+ * KondiError subclass so downstream `instanceof` checks are reliable.
97
+ */
98
+ export function asKondiError(e: unknown, fallbackStage: string): KondiError {
99
+ if (e instanceof KondiError) return e;
100
+ const message = e instanceof Error ? e.message : String(e);
101
+ return new KondiError(message, { severity: 'fatal', stage: fallbackStage, cause: e });
102
+ }