@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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent spawning — bounded child agent loops.
|
|
3
|
+
*
|
|
4
|
+
* This is a minimal implementation: each spawn runs an inline loop that
|
|
5
|
+
* calls callLLM + toolManager.execute on a filtered tool set. Parallelism
|
|
6
|
+
* is handled by the caller (the `spawn_agent` tool awaits one at a time in
|
|
7
|
+
* this version; Promise.all at the call site yields natural parallelism).
|
|
8
|
+
*
|
|
9
|
+
* Sub-agents do NOT nest (no recursive spawn_agent). Loop guard caps
|
|
10
|
+
* iterations and cost.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LLMMessage, ToolDefinition, Session } from '../types.ts';
|
|
14
|
+
import type { Router } from '../router/index.ts';
|
|
15
|
+
import type { ToolContext, ToolExecutionResult } from './tools.ts';
|
|
16
|
+
import type { ToolManager } from '../mcp/tool-manager.ts';
|
|
17
|
+
import { callLLM } from '../providers/llm-caller.ts';
|
|
18
|
+
import { estimateCost } from '../audit/ledger.ts';
|
|
19
|
+
|
|
20
|
+
export type SubAgentType = 'research' | 'worker' | 'planner';
|
|
21
|
+
|
|
22
|
+
const MAX_SUB_ITERATIONS = 8;
|
|
23
|
+
const MAX_SUB_COST_USD = 0.50;
|
|
24
|
+
const MAX_RESULT_CHARS = 4000;
|
|
25
|
+
|
|
26
|
+
const RESEARCH_TOOLS = new Set(['read_file', 'list_files', 'search_code', 'web_search', 'web_fetch', 'git_status', 'git_diff', 'git_log']);
|
|
27
|
+
const WORKER_TOOLS = new Set<string>([
|
|
28
|
+
'read_file', 'list_files', 'search_code', 'write_file', 'edit_file', 'run_command',
|
|
29
|
+
'git_status', 'git_diff', 'git_log', 'git_commit',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function filterToolsForType(type: SubAgentType, tools: ToolDefinition[]): ToolDefinition[] {
|
|
33
|
+
if (type === 'planner') return [];
|
|
34
|
+
const set = type === 'research' ? RESEARCH_TOOLS : WORKER_TOOLS;
|
|
35
|
+
return tools.filter(t => set.has(t.name));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function systemPromptForType(type: SubAgentType, parentGoal?: string): string {
|
|
39
|
+
const goalLine = parentGoal ? `Parent session goal: ${parentGoal}\n\n` : '';
|
|
40
|
+
if (type === 'planner') {
|
|
41
|
+
return `${goalLine}You are a planning sub-agent. Return a concise plan; do not call tools. Output a numbered list of concrete steps.`;
|
|
42
|
+
}
|
|
43
|
+
if (type === 'research') {
|
|
44
|
+
return `${goalLine}You are a research sub-agent. Gather information and return a concise summary. You may read files and search code but must not modify anything.`;
|
|
45
|
+
}
|
|
46
|
+
return `${goalLine}You are a worker sub-agent. Complete the given task. You may read, write, and edit files, and run commands. Return a short summary of what you did.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SubAgentResult {
|
|
50
|
+
type: SubAgentType;
|
|
51
|
+
instruction: string;
|
|
52
|
+
finalContent: string;
|
|
53
|
+
iterations: number;
|
|
54
|
+
inputTokens: number;
|
|
55
|
+
outputTokens: number;
|
|
56
|
+
costUsd: number;
|
|
57
|
+
model: string;
|
|
58
|
+
error?: string;
|
|
59
|
+
truncated?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runSubAgent(
|
|
63
|
+
type: SubAgentType,
|
|
64
|
+
instruction: string,
|
|
65
|
+
parent: {
|
|
66
|
+
router: Router;
|
|
67
|
+
toolManager: ToolManager;
|
|
68
|
+
toolCtx: ToolContext;
|
|
69
|
+
session: Session;
|
|
70
|
+
},
|
|
71
|
+
): Promise<SubAgentResult> {
|
|
72
|
+
const { router, toolManager, toolCtx, session } = parent;
|
|
73
|
+
const systemPrompt = systemPromptForType(type, session.state.goal);
|
|
74
|
+
const tools = filterToolsForType(type, toolManager.getTools('discuss'));
|
|
75
|
+
|
|
76
|
+
const messages: LLMMessage[] = [{ role: 'user', content: instruction }];
|
|
77
|
+
let inputTokens = 0, outputTokens = 0, costUsd = 0;
|
|
78
|
+
let finalContent = '';
|
|
79
|
+
let model = '';
|
|
80
|
+
|
|
81
|
+
for (let iter = 0; iter < MAX_SUB_ITERATIONS; iter++) {
|
|
82
|
+
const decision = await router.select(type === 'planner' ? 'dispatch' : type === 'research' ? 'discuss' : 'execute', instruction);
|
|
83
|
+
model = decision.model.alias || decision.model.name;
|
|
84
|
+
const response = await callLLM({
|
|
85
|
+
provider: decision.model.provider,
|
|
86
|
+
model: decision.model.id,
|
|
87
|
+
systemPrompt,
|
|
88
|
+
messages,
|
|
89
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
90
|
+
maxOutputTokens: 4096,
|
|
91
|
+
});
|
|
92
|
+
inputTokens += response.inputTokens;
|
|
93
|
+
outputTokens += response.outputTokens;
|
|
94
|
+
costUsd += estimateCost(response.model, response.inputTokens, response.outputTokens);
|
|
95
|
+
|
|
96
|
+
if (costUsd > MAX_SUB_COST_USD) {
|
|
97
|
+
finalContent = response.content || 'sub-agent cost cap reached';
|
|
98
|
+
return {
|
|
99
|
+
type, instruction, finalContent, iterations: iter + 1,
|
|
100
|
+
inputTokens, outputTokens, costUsd, model, error: 'cost-cap',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
105
|
+
finalContent = response.content;
|
|
106
|
+
return { type, instruction, finalContent, iterations: iter + 1, inputTokens, outputTokens, costUsd, model };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
messages.push({ role: 'assistant', content: response.content || undefined, toolCalls: response.toolCalls });
|
|
110
|
+
const toolResults = [];
|
|
111
|
+
for (const tc of response.toolCalls) {
|
|
112
|
+
const result: ToolExecutionResult = await toolManager.execute(tc.name, tc.arguments, toolCtx);
|
|
113
|
+
const capped = result.content.length > 3000 ? result.content.slice(0, 3000) + '...' : result.content;
|
|
114
|
+
toolResults.push({ toolCallId: tc.id, content: capped, isError: result.isError });
|
|
115
|
+
}
|
|
116
|
+
messages.push({ role: 'tool', toolResults });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
finalContent = finalContent || `sub-agent hit ${MAX_SUB_ITERATIONS} iterations without finishing`;
|
|
120
|
+
return {
|
|
121
|
+
type, instruction, finalContent: finalContent.slice(0, MAX_RESULT_CHARS),
|
|
122
|
+
iterations: MAX_SUB_ITERATIONS, inputTokens, outputTokens, costUsd, model,
|
|
123
|
+
error: 'max-iterations',
|
|
124
|
+
truncated: finalContent.length > MAX_RESULT_CHARS,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Formats a SubAgentResult as a short block suitable for the parent's tool_result content. */
|
|
129
|
+
export function formatSubAgentResult(r: SubAgentResult): string {
|
|
130
|
+
const header = `[sub-agent ${r.type} via ${r.model}; ${r.iterations}it; $${r.costUsd.toFixed(4)}${r.error ? `; ${r.error}` : ''}]`;
|
|
131
|
+
const body = r.finalContent.length > MAX_RESULT_CHARS ? r.finalContent.slice(0, MAX_RESULT_CHARS) + '\n[truncated]' : r.finalContent;
|
|
132
|
+
return `${header}\n${body}`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Card — bounded work packets dispatched to worker models.
|
|
3
|
+
*
|
|
4
|
+
* The task card is the contract between the conversation model and
|
|
5
|
+
* the execution model. It contains everything the worker needs and
|
|
6
|
+
* nothing it doesn't.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import type { TaskCard, TaskKind, RepoMap, SessionState, LLMResponse } from '../types.ts';
|
|
12
|
+
import { callLLM } from '../providers/llm-caller.ts';
|
|
13
|
+
import type { Ledger } from '../audit/ledger.ts';
|
|
14
|
+
import type { ProviderId } from '../types.ts';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Task Card creation — frontier model generates from conversation
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export async function createTaskCard(
|
|
21
|
+
userIntent: string,
|
|
22
|
+
sessionState: SessionState,
|
|
23
|
+
repoMap: RepoMap | undefined,
|
|
24
|
+
provider: ProviderId,
|
|
25
|
+
model: string | undefined,
|
|
26
|
+
ledger: Ledger,
|
|
27
|
+
): Promise<{ card: TaskCard; response: LLMResponse }> {
|
|
28
|
+
|
|
29
|
+
const repoContext = repoMap
|
|
30
|
+
? `Stack: ${repoMap.stack.join(', ')}
|
|
31
|
+
Subsystems: ${repoMap.subsystems.map(s => `${s.name} (${s.paths.join(', ')}): ${s.purpose}`).join('\n')}
|
|
32
|
+
Entrypoints: ${repoMap.entrypoints.join(', ')}
|
|
33
|
+
Commands: build=${repoMap.commands.build || 'n/a'} test=${repoMap.commands.test || 'n/a'} lint=${repoMap.commands.lint || 'n/a'}
|
|
34
|
+
Conventions: ${repoMap.conventions.join('; ')}`
|
|
35
|
+
: '(no repo map available)';
|
|
36
|
+
|
|
37
|
+
const stateContext = `Goal: ${sessionState.goal || 'not set'}
|
|
38
|
+
Plan: ${sessionState.currentPlan.join(' → ') || 'none'}
|
|
39
|
+
Decisions: ${sessionState.decisions.join('; ') || 'none'}
|
|
40
|
+
Constraints: ${sessionState.constraints.join('; ') || 'none'}
|
|
41
|
+
Recent failures: ${sessionState.recentFailures.join('; ') || 'none'}`;
|
|
42
|
+
|
|
43
|
+
const response = await callLLM({
|
|
44
|
+
provider,
|
|
45
|
+
model,
|
|
46
|
+
systemPrompt: `You create structured task cards for a coding assistant. Output ONLY valid JSON.
|
|
47
|
+
|
|
48
|
+
A task card is a bounded work packet with:
|
|
49
|
+
- id: short identifier (e.g., "task-001")
|
|
50
|
+
- kind: one of "implementation", "fix", "refactor", "test", "analysis"
|
|
51
|
+
- goal: clear 1-2 sentence description of what to do
|
|
52
|
+
- relevantFiles: array of file paths the worker should focus on
|
|
53
|
+
- constraints: array of things NOT to do or boundaries
|
|
54
|
+
- acceptanceCriteria: array of conditions that must be true when done
|
|
55
|
+
- outputMode: "diff" for patches, "file_replacements" for full files, "text" for analysis
|
|
56
|
+
|
|
57
|
+
Be specific and bounded. The worker model will ONLY see this card, not the conversation.`,
|
|
58
|
+
userMessage: `Create a task card for the following request.
|
|
59
|
+
|
|
60
|
+
SESSION STATE:
|
|
61
|
+
${stateContext}
|
|
62
|
+
|
|
63
|
+
REPO:
|
|
64
|
+
${repoContext}
|
|
65
|
+
|
|
66
|
+
USER REQUEST:
|
|
67
|
+
${userIntent}
|
|
68
|
+
|
|
69
|
+
Output the task card as JSON:`,
|
|
70
|
+
maxOutputTokens: 1500,
|
|
71
|
+
temperature: 0,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ledger.record('dispatch', response, `Task card creation for: ${userIntent.slice(0, 200)}`);
|
|
75
|
+
|
|
76
|
+
let parsed: any = {};
|
|
77
|
+
try {
|
|
78
|
+
// Extract JSON from response — model may wrap it in markdown code blocks
|
|
79
|
+
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
|
80
|
+
parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
|
81
|
+
} catch {
|
|
82
|
+
process.stderr.write(`[dispatch] Failed to parse task card JSON, using defaults\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const card: TaskCard = {
|
|
86
|
+
id: parsed.id || `task-${Date.now().toString(36)}`,
|
|
87
|
+
kind: parsed.kind || 'implementation',
|
|
88
|
+
goal: parsed.goal || userIntent,
|
|
89
|
+
relevantFiles: parsed.relevantFiles || [],
|
|
90
|
+
constraints: parsed.constraints || [],
|
|
91
|
+
acceptanceCriteria: parsed.acceptanceCriteria || [],
|
|
92
|
+
outputMode: parsed.outputMode || 'file_replacements',
|
|
93
|
+
failures: 0,
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
status: 'pending',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return { card, response };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Task execution — worker model processes the card
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export async function executeTaskCard(
|
|
106
|
+
card: TaskCard,
|
|
107
|
+
repoMap: RepoMap | undefined,
|
|
108
|
+
fileContents: string,
|
|
109
|
+
provider: ProviderId,
|
|
110
|
+
model: string | undefined,
|
|
111
|
+
ledger: Ledger,
|
|
112
|
+
): Promise<LLMResponse> {
|
|
113
|
+
|
|
114
|
+
const taskPrompt = `TASK CARD:
|
|
115
|
+
${JSON.stringify(card, null, 2)}
|
|
116
|
+
|
|
117
|
+
RELEVANT FILE CONTENTS:
|
|
118
|
+
${fileContents}`;
|
|
119
|
+
|
|
120
|
+
const systemPrompt = card.kind === 'analysis'
|
|
121
|
+
? `You are a code analysis agent. Analyze the code as specified in the task card. Be thorough and specific. Reference exact files and line numbers.`
|
|
122
|
+
: `You are a code execution agent. Implement exactly what the task card specifies.
|
|
123
|
+
|
|
124
|
+
Rules:
|
|
125
|
+
- Only modify files listed in relevantFiles unless absolutely necessary
|
|
126
|
+
- Respect all constraints
|
|
127
|
+
- Output your changes as ${card.outputMode === 'diff' ? 'unified diffs' : 'complete file contents with clear path labels'}
|
|
128
|
+
- When done, end with:
|
|
129
|
+
## RESULT
|
|
130
|
+
**Status:** complete | partial
|
|
131
|
+
**Files changed:** list of files
|
|
132
|
+
**Notes:** anything the reviewer should know`;
|
|
133
|
+
|
|
134
|
+
const response = await callLLM({
|
|
135
|
+
provider,
|
|
136
|
+
model,
|
|
137
|
+
systemPrompt,
|
|
138
|
+
userMessage: taskPrompt,
|
|
139
|
+
maxOutputTokens: 8192,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
ledger.record('execute', response, `Execute task ${card.id}: ${card.goal.slice(0, 200)}`, {
|
|
143
|
+
taskId: card.id,
|
|
144
|
+
promoted: card.failures >= 2,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Read relevant files for a task card
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export function readRelevantFiles(
|
|
155
|
+
workingDir: string,
|
|
156
|
+
files: string[],
|
|
157
|
+
maxCharsPerFile = 4096,
|
|
158
|
+
): string {
|
|
159
|
+
const base = resolve(workingDir);
|
|
160
|
+
const sections: string[] = [];
|
|
161
|
+
|
|
162
|
+
for (const relPath of files) {
|
|
163
|
+
const fullPath = join(workingDir, relPath);
|
|
164
|
+
const resolved = resolve(fullPath);
|
|
165
|
+
|
|
166
|
+
// Path traversal check
|
|
167
|
+
if (!resolved.startsWith(base)) continue;
|
|
168
|
+
if (!existsSync(fullPath)) {
|
|
169
|
+
sections.push(`#### ${relPath}\n(file not found)`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
let content = readFileSync(fullPath, 'utf-8');
|
|
175
|
+
if (content.length > maxCharsPerFile) {
|
|
176
|
+
content = content.slice(0, maxCharsPerFile) + '\n... (truncated)';
|
|
177
|
+
}
|
|
178
|
+
sections.push(`#### ${relPath}\n\`\`\`\n${content}\n\`\`\``);
|
|
179
|
+
} catch {
|
|
180
|
+
sections.push(`#### ${relPath}\n(read error)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return sections.join('\n\n');
|
|
185
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Router — adaptive problem framing before execution.
|
|
3
|
+
*
|
|
4
|
+
* Decides automatically whether a task is:
|
|
5
|
+
* A. directly executable (concrete, unambiguous)
|
|
6
|
+
* B. needs lightweight framing (broad but inferable)
|
|
7
|
+
* C. needs user clarification (risky or ambiguous)
|
|
8
|
+
* D. needs council/deeper deliberation (design tradeoffs)
|
|
9
|
+
*
|
|
10
|
+
* Runs a single cheap classifier call before the agent loop starts.
|
|
11
|
+
* The user never has to say "think first" or "define the problem" —
|
|
12
|
+
* kondi infers it from task shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ProviderId } from '../types.ts';
|
|
16
|
+
import { callLLM } from '../providers/llm-caller.ts';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export type TaskMode =
|
|
23
|
+
| 'execute_now'
|
|
24
|
+
| 'frame_then_execute'
|
|
25
|
+
| 'ask_clarifying_question'
|
|
26
|
+
| 'council_deliberation';
|
|
27
|
+
|
|
28
|
+
export interface TaskClassification {
|
|
29
|
+
mode: TaskMode;
|
|
30
|
+
confidence: number;
|
|
31
|
+
reason: string;
|
|
32
|
+
missingInformation: string[];
|
|
33
|
+
suggestedQuestions: string[];
|
|
34
|
+
executionGoal?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProblemFrame {
|
|
38
|
+
originalRequest: string;
|
|
39
|
+
interpretedGoal: string;
|
|
40
|
+
whyThisMatters: string;
|
|
41
|
+
currentBehavior?: string;
|
|
42
|
+
desiredBehavior?: string;
|
|
43
|
+
constraints: string[];
|
|
44
|
+
assumptions: string[];
|
|
45
|
+
unknowns: string[];
|
|
46
|
+
successCriteria: string[];
|
|
47
|
+
proposedPlan: string[];
|
|
48
|
+
executionScope: 'small' | 'medium' | 'large';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Classifier
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const CLASSIFY_PROMPT = `You classify user tasks for a coding agent. Based on the task, decide the mode:
|
|
56
|
+
|
|
57
|
+
execute_now — the task is concrete and unambiguous. Examples: "fix this error", "rename this function", "add logging", "run tests", "read this file", "explain this code"
|
|
58
|
+
|
|
59
|
+
frame_then_execute — the task is broad but you can infer what to do. Examples: "clean up the provider system", "improve the onboarding", "make this less confusing", "refactor the auth module"
|
|
60
|
+
|
|
61
|
+
ask_clarifying_question — the task is risky or genuinely ambiguous, and acting without clarification could waste effort or cause harm. Examples: "change the auth flow" (which part?), "delete unused code" (what counts as unused?), "make it production ready" (what's the target?)
|
|
62
|
+
|
|
63
|
+
council_deliberation — the task involves real design tradeoffs that benefit from multiple perspectives. Examples: "redesign the architecture", "choose between strategies", "plan the roadmap"
|
|
64
|
+
|
|
65
|
+
Most tasks are execute_now. Only escalate when there's genuine ambiguity or risk. Simple questions and straightforward coding tasks are always execute_now.
|
|
66
|
+
|
|
67
|
+
Respond with ONLY a JSON object:
|
|
68
|
+
{"mode": "execute_now|frame_then_execute|ask_clarifying_question|council_deliberation", "confidence": 0.0-1.0, "reason": "one sentence", "missingInformation": [], "suggestedQuestions": [], "executionGoal": "optional refined goal"}`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fast local classification — no LLM call. Handles 95% of inputs instantly.
|
|
72
|
+
* Only falls back to an LLM call for genuinely ambiguous cases where the
|
|
73
|
+
* heuristic has low confidence.
|
|
74
|
+
*/
|
|
75
|
+
export function classifyTaskLocal(
|
|
76
|
+
userRequest: string,
|
|
77
|
+
recentContext: string,
|
|
78
|
+
): TaskClassification {
|
|
79
|
+
const lower = userRequest.toLowerCase().trim();
|
|
80
|
+
const wordCount = lower.split(/\s+/).length;
|
|
81
|
+
|
|
82
|
+
// Very short messages are always execute_now (greetings, "yes", "do it", etc.)
|
|
83
|
+
if (wordCount <= 6) {
|
|
84
|
+
return { mode: 'execute_now', confidence: 0.95, reason: 'short input', missingInformation: [], suggestedQuestions: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Follow-up messages in an ongoing conversation are always execute_now
|
|
88
|
+
if (recentContext.length > 50) {
|
|
89
|
+
return { mode: 'execute_now', confidence: 0.9, reason: 'follow-up in conversation', missingInformation: [], suggestedQuestions: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Direct action verbs → execute_now
|
|
93
|
+
const actionVerbs = /^(fix|add|create|write|read|show|list|run|test|build|deploy|install|update|change|rename|remove|delete|explain|refactor|implement|move|copy|find|search|check|debug|log|print|open|close|merge|revert|commit|push|pull)\b/i;
|
|
94
|
+
if (actionVerbs.test(lower)) {
|
|
95
|
+
return { mode: 'execute_now', confidence: 0.9, reason: 'action verb', missingInformation: [], suggestedQuestions: [] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Questions → execute_now (the model will answer)
|
|
99
|
+
if (lower.startsWith('what') || lower.startsWith('how') || lower.startsWith('why') ||
|
|
100
|
+
lower.startsWith('where') || lower.startsWith('when') || lower.startsWith('can') ||
|
|
101
|
+
lower.startsWith('does') || lower.startsWith('is ') || lower.endsWith('?')) {
|
|
102
|
+
return { mode: 'execute_now', confidence: 0.9, reason: 'question', missingInformation: [], suggestedQuestions: [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// File references → execute_now
|
|
106
|
+
if (/\.(ts|js|py|rs|go|json|yml|yaml|toml|md|css|html|sql)\b/.test(lower)) {
|
|
107
|
+
return { mode: 'execute_now', confidence: 0.9, reason: 'file reference', missingInformation: [], suggestedQuestions: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Broad/vague multi-sentence requests with no concrete target → frame_then_execute
|
|
111
|
+
const broadIndicators = /\b(redesign|overhaul|rethink|architect|migrate|strategy|roadmap|plan out)\b/i;
|
|
112
|
+
if (broadIndicators.test(lower) && wordCount > 15) {
|
|
113
|
+
return { mode: 'frame_then_execute', confidence: 0.7, reason: 'broad request', missingInformation: [], suggestedQuestions: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Default: execute_now. The model itself is good at asking for
|
|
117
|
+
// clarification when it needs it — we don't need a pre-classifier.
|
|
118
|
+
return { mode: 'execute_now', confidence: 0.8, reason: 'default', missingInformation: [], suggestedQuestions: [] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* LLM-based classification — only called when the local heuristic has
|
|
123
|
+
* low confidence. Most inputs never reach this.
|
|
124
|
+
*/
|
|
125
|
+
export async function classifyTask(
|
|
126
|
+
userRequest: string,
|
|
127
|
+
recentContext: string,
|
|
128
|
+
classifierProvider: ProviderId,
|
|
129
|
+
classifierModel?: string,
|
|
130
|
+
): Promise<TaskClassification> {
|
|
131
|
+
// Fast path: local heuristic handles 95% of inputs with no LLM call.
|
|
132
|
+
const local = classifyTaskLocal(userRequest, recentContext);
|
|
133
|
+
if (local.confidence >= 0.7) return local;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await callLLM({
|
|
137
|
+
provider: classifierProvider,
|
|
138
|
+
model: classifierModel,
|
|
139
|
+
systemPrompt: CLASSIFY_PROMPT,
|
|
140
|
+
userMessage: `Task: ${userRequest.slice(0, 1500)}${recentContext ? `\n\nRecent context: ${recentContext.slice(0, 500)}` : ''}`,
|
|
141
|
+
maxOutputTokens: 200,
|
|
142
|
+
temperature: 0,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const text = response.content.trim();
|
|
146
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
147
|
+
if (!jsonMatch) {
|
|
148
|
+
return { mode: 'execute_now', confidence: 0.5, reason: 'classifier returned non-JSON', missingInformation: [], suggestedQuestions: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
152
|
+
return {
|
|
153
|
+
mode: parsed.mode || 'execute_now',
|
|
154
|
+
confidence: parsed.confidence ?? 0.8,
|
|
155
|
+
reason: parsed.reason || '',
|
|
156
|
+
missingInformation: parsed.missingInformation || [],
|
|
157
|
+
suggestedQuestions: parsed.suggestedQuestions || [],
|
|
158
|
+
executionGoal: parsed.executionGoal,
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
// If classification fails, default to execute — don't block the user.
|
|
162
|
+
return { mode: 'execute_now', confidence: 0.5, reason: 'classifier error — defaulting to execute', missingInformation: [], suggestedQuestions: [] };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Problem Framer
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const FRAME_PROMPT = `You are a problem framing assistant for a coding agent. The user gave a broad task. Your job is to:
|
|
171
|
+
|
|
172
|
+
1. Interpret what they actually want
|
|
173
|
+
2. Define clear success criteria
|
|
174
|
+
3. Propose a concrete plan
|
|
175
|
+
|
|
176
|
+
Be concise. No filler. Output ONLY a JSON object:
|
|
177
|
+
{
|
|
178
|
+
"interpretedGoal": "what the user really wants",
|
|
179
|
+
"whyThisMatters": "one sentence on why",
|
|
180
|
+
"currentBehavior": "what happens now (if known)",
|
|
181
|
+
"desiredBehavior": "what should happen after",
|
|
182
|
+
"constraints": ["things to preserve or avoid"],
|
|
183
|
+
"assumptions": ["things you're assuming"],
|
|
184
|
+
"unknowns": ["things you'd need to verify"],
|
|
185
|
+
"successCriteria": ["how to know it's done"],
|
|
186
|
+
"proposedPlan": ["step 1", "step 2", ...],
|
|
187
|
+
"executionScope": "small|medium|large"
|
|
188
|
+
}`;
|
|
189
|
+
|
|
190
|
+
export async function frameProblem(
|
|
191
|
+
userRequest: string,
|
|
192
|
+
recentContext: string,
|
|
193
|
+
provider: ProviderId,
|
|
194
|
+
model?: string,
|
|
195
|
+
): Promise<ProblemFrame> {
|
|
196
|
+
const response = await callLLM({
|
|
197
|
+
provider,
|
|
198
|
+
model,
|
|
199
|
+
systemPrompt: FRAME_PROMPT,
|
|
200
|
+
userMessage: `Task: ${userRequest}\n\n${recentContext ? `Context: ${recentContext.slice(0, 1000)}` : ''}`,
|
|
201
|
+
maxOutputTokens: 800,
|
|
202
|
+
temperature: 0,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const text = response.content.trim();
|
|
206
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
207
|
+
if (!jsonMatch) {
|
|
208
|
+
return {
|
|
209
|
+
originalRequest: userRequest,
|
|
210
|
+
interpretedGoal: userRequest,
|
|
211
|
+
whyThisMatters: '',
|
|
212
|
+
constraints: [],
|
|
213
|
+
assumptions: [],
|
|
214
|
+
unknowns: [],
|
|
215
|
+
successCriteria: [],
|
|
216
|
+
proposedPlan: [userRequest],
|
|
217
|
+
executionScope: 'medium',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
222
|
+
return {
|
|
223
|
+
originalRequest: userRequest,
|
|
224
|
+
interpretedGoal: parsed.interpretedGoal || userRequest,
|
|
225
|
+
whyThisMatters: parsed.whyThisMatters || '',
|
|
226
|
+
currentBehavior: parsed.currentBehavior,
|
|
227
|
+
desiredBehavior: parsed.desiredBehavior,
|
|
228
|
+
constraints: parsed.constraints || [],
|
|
229
|
+
assumptions: parsed.assumptions || [],
|
|
230
|
+
unknowns: parsed.unknowns || [],
|
|
231
|
+
successCriteria: parsed.successCriteria || [],
|
|
232
|
+
proposedPlan: parsed.proposedPlan || [],
|
|
233
|
+
executionScope: parsed.executionScope || 'medium',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Frame formatter (for TUI display)
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export function formatFrame(frame: ProblemFrame): string {
|
|
242
|
+
const lines: string[] = [
|
|
243
|
+
`Goal: ${frame.interpretedGoal}`,
|
|
244
|
+
];
|
|
245
|
+
if (frame.whyThisMatters) lines.push(`Why: ${frame.whyThisMatters}`);
|
|
246
|
+
if (frame.currentBehavior) lines.push(`Now: ${frame.currentBehavior}`);
|
|
247
|
+
if (frame.desiredBehavior) lines.push(`Target: ${frame.desiredBehavior}`);
|
|
248
|
+
if (frame.successCriteria.length > 0) {
|
|
249
|
+
lines.push(`Success: ${frame.successCriteria.join('; ')}`);
|
|
250
|
+
}
|
|
251
|
+
if (frame.proposedPlan.length > 0) {
|
|
252
|
+
lines.push(`Plan: ${frame.proposedPlan.map((s, i) => `${i + 1}. ${s}`).join(' → ')}`);
|
|
253
|
+
}
|
|
254
|
+
lines.push(`Scope: ${frame.executionScope}`);
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Store — persists task cards to disk so they survive session
|
|
3
|
+
* restarts. Active tasks are injected into context so the model
|
|
4
|
+
* knows what it was working on.
|
|
5
|
+
*
|
|
6
|
+
* Storage:
|
|
7
|
+
* .kondi-chat/tasks/current.json — the active task (if any)
|
|
8
|
+
* .kondi-chat/tasks/history/ — completed tasks
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import type { TaskCard } from '../types.ts';
|
|
14
|
+
|
|
15
|
+
export class TaskStore {
|
|
16
|
+
private tasksDir: string;
|
|
17
|
+
private currentPath: string;
|
|
18
|
+
private historyDir: string;
|
|
19
|
+
|
|
20
|
+
constructor(storageDir: string) {
|
|
21
|
+
this.tasksDir = join(storageDir, 'tasks');
|
|
22
|
+
this.currentPath = join(this.tasksDir, 'current.json');
|
|
23
|
+
this.historyDir = join(this.tasksDir, 'history');
|
|
24
|
+
mkdirSync(this.historyDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get the active task, if any. */
|
|
28
|
+
getCurrent(): TaskCard | null {
|
|
29
|
+
if (!existsSync(this.currentPath)) return null;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(this.currentPath, 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Save or update the active task. */
|
|
38
|
+
setCurrent(task: TaskCard): void {
|
|
39
|
+
writeFileSync(this.currentPath, JSON.stringify(task, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Move the active task to history (completed or abandoned). */
|
|
43
|
+
complete(): void {
|
|
44
|
+
const task = this.getCurrent();
|
|
45
|
+
if (!task) return;
|
|
46
|
+
const historyPath = join(this.historyDir, `${task.id}.json`);
|
|
47
|
+
try {
|
|
48
|
+
renameSync(this.currentPath, historyPath);
|
|
49
|
+
} catch {
|
|
50
|
+
// If rename fails, just delete current
|
|
51
|
+
try { writeFileSync(this.currentPath, ''); } catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Clear the active task without archiving. */
|
|
56
|
+
clear(): void {
|
|
57
|
+
if (existsSync(this.currentPath)) {
|
|
58
|
+
try { writeFileSync(this.currentPath, ''); } catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Format active task for injection into context. */
|
|
63
|
+
formatForContext(): string {
|
|
64
|
+
const task = this.getCurrent();
|
|
65
|
+
if (!task) return '';
|
|
66
|
+
const lines = [
|
|
67
|
+
'## Active task',
|
|
68
|
+
`Goal: ${task.goal}`,
|
|
69
|
+
`Kind: ${task.kind}`,
|
|
70
|
+
`Status: ${task.status}`,
|
|
71
|
+
];
|
|
72
|
+
if (task.relevantFiles.length > 0) {
|
|
73
|
+
lines.push(`Files: ${task.relevantFiles.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (task.constraints.length > 0) {
|
|
76
|
+
lines.push(`Constraints: ${task.constraints.join('; ')}`);
|
|
77
|
+
}
|
|
78
|
+
if (task.acceptanceCriteria.length > 0) {
|
|
79
|
+
lines.push(`Acceptance: ${task.acceptanceCriteria.join('; ')}`);
|
|
80
|
+
}
|
|
81
|
+
if (task.failures > 0) {
|
|
82
|
+
lines.push(`Failures: ${task.failures}`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
}
|