@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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/bin/kondi-chat +56 -0
- package/bin/kondi-chat.js +72 -0
- package/package.json +55 -0
- package/scripts/demo.tape +49 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/audit/analytics.ts +261 -0
- package/src/audit/ledger.ts +253 -0
- package/src/audit/telemetry.ts +165 -0
- package/src/cli/backend.ts +675 -0
- package/src/cli/commands.ts +419 -0
- package/src/cli/help.ts +182 -0
- package/src/cli/submit-helpers.ts +159 -0
- package/src/cli/submit.ts +539 -0
- package/src/cli/wizard.ts +121 -0
- package/src/context/bootstrap.ts +138 -0
- package/src/context/budget.ts +100 -0
- package/src/context/manager.ts +666 -0
- package/src/context/memory.ts +160 -0
- package/src/context/preflight.ts +176 -0
- package/src/context/project-brain.ts +101 -0
- package/src/context/receipts.ts +108 -0
- package/src/context/skills.ts +154 -0
- package/src/context/symbol-index.ts +240 -0
- package/src/council/profiles.ts +137 -0
- package/src/council/tool.ts +138 -0
- package/src/council-engine/cli/council-artifacts.ts +230 -0
- package/src/council-engine/cli/council-config.ts +178 -0
- package/src/council-engine/cli/council-session-export.ts +116 -0
- package/src/council-engine/cli/kondi.ts +98 -0
- package/src/council-engine/cli/llm-caller.ts +229 -0
- package/src/council-engine/cli/localStorage-shim.ts +119 -0
- package/src/council-engine/cli/node-platform.ts +68 -0
- package/src/council-engine/cli/run-council.ts +481 -0
- package/src/council-engine/cli/run-pipeline.ts +772 -0
- package/src/council-engine/cli/session-export.ts +153 -0
- package/src/council-engine/configs/councils/analysis.json +101 -0
- package/src/council-engine/configs/councils/code-planning.json +86 -0
- package/src/council-engine/configs/councils/coding.json +89 -0
- package/src/council-engine/configs/councils/debate.json +97 -0
- package/src/council-engine/configs/councils/solo-claude.json +34 -0
- package/src/council-engine/configs/councils/solo-gpt.json +34 -0
- package/src/council-engine/council/coding-orchestrator.ts +1205 -0
- package/src/council-engine/council/context-bootstrap.ts +147 -0
- package/src/council-engine/council/context-inspection.ts +42 -0
- package/src/council-engine/council/context-store.ts +763 -0
- package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
- package/src/council-engine/council/factory.ts +164 -0
- package/src/council-engine/council/index.ts +201 -0
- package/src/council-engine/council/ledger-store.ts +438 -0
- package/src/council-engine/council/prompts.ts +1689 -0
- package/src/council-engine/council/storage-cleanup.ts +164 -0
- package/src/council-engine/council/store.ts +1110 -0
- package/src/council-engine/council/synthesis.ts +291 -0
- package/src/council-engine/council/types.ts +845 -0
- package/src/council-engine/council/validation.ts +613 -0
- package/src/council-engine/pipeline/build-detect.ts +73 -0
- package/src/council-engine/pipeline/executor.ts +1048 -0
- package/src/council-engine/pipeline/index.ts +9 -0
- package/src/council-engine/pipeline/install-detect.ts +84 -0
- package/src/council-engine/pipeline/memory-store.ts +182 -0
- package/src/council-engine/pipeline/output-parsers.ts +146 -0
- package/src/council-engine/pipeline/run-output.ts +149 -0
- package/src/council-engine/pipeline/session-import.ts +177 -0
- package/src/council-engine/pipeline/store.ts +753 -0
- package/src/council-engine/pipeline/test-detect.ts +82 -0
- package/src/council-engine/pipeline/types.ts +401 -0
- package/src/council-engine/services/deliberationSummary.ts +114 -0
- package/src/council-engine/tsconfig.json +16 -0
- package/src/council-engine/types/mcp.ts +122 -0
- package/src/council-engine/utils/filterTools.ts +73 -0
- package/src/engine/apply.ts +238 -0
- package/src/engine/checkpoints.ts +237 -0
- package/src/engine/consultants.ts +347 -0
- package/src/engine/diff.ts +171 -0
- package/src/engine/errors.ts +102 -0
- package/src/engine/git-tools.ts +246 -0
- package/src/engine/hooks.ts +181 -0
- package/src/engine/loop-guard.ts +155 -0
- package/src/engine/permissions.ts +293 -0
- package/src/engine/pipeline.ts +376 -0
- package/src/engine/sub-agents.ts +133 -0
- package/src/engine/task-card.ts +185 -0
- package/src/engine/task-router.ts +256 -0
- package/src/engine/task-store.ts +86 -0
- package/src/engine/tools.ts +783 -0
- package/src/engine/verify.ts +111 -0
- package/src/mcp/client.ts +225 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/tool-manager.ts +192 -0
- package/src/mcp/types.ts +61 -0
- package/src/providers/llm-caller.ts +943 -0
- package/src/providers/rate-limiter.ts +238 -0
- package/src/router/NOTES.md +28 -0
- package/src/router/collector.ts +474 -0
- package/src/router/embeddings.ts +286 -0
- package/src/router/index.ts +299 -0
- package/src/router/intent-router.ts +225 -0
- package/src/router/nn-router.ts +205 -0
- package/src/router/profiles.ts +309 -0
- package/src/router/registry.ts +565 -0
- package/src/router/rules.ts +274 -0
- package/src/router/train.py +408 -0
- package/src/session/store.ts +211 -0
- package/src/test-utils/mock-llm.ts +39 -0
- package/src/types.ts +322 -0
- 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
|
+
}
|