@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory System — persistent KONDI.md + AGENTS.md files injected into
|
|
3
|
+
* the system prompt.
|
|
4
|
+
*
|
|
5
|
+
* Hierarchy (lowest to highest priority; later overrides earlier):
|
|
6
|
+
* 1. user memory ~/.kondi-chat/KONDI.md (+ AGENTS.md)
|
|
7
|
+
* 2. project memory <workingDir>/KONDI.md (+ AGENTS.md)
|
|
8
|
+
* 3. subdir memory nearest-ancestor KONDI.md or AGENTS.md from the active file
|
|
9
|
+
*
|
|
10
|
+
* AGENTS.md is an open convention supported by Claude Code, Cursor,
|
|
11
|
+
* Copilot, Gemini CLI, Windsurf, Aider, Zed, and others. kondi-chat
|
|
12
|
+
* reads it at the same levels as KONDI.md. If both files exist at the
|
|
13
|
+
* same level, both are loaded (AGENTS.md first, then KONDI.md).
|
|
14
|
+
*
|
|
15
|
+
* No YAML frontmatter parsing, no file watcher — load() re-stats on each call
|
|
16
|
+
* and re-reads only files whose mtime changed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { dirname, join, resolve, relative } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** Files to search at each level, in load order. */
|
|
24
|
+
const MEMORY_FILENAMES = ['AGENTS.md', 'KONDI.md'];
|
|
25
|
+
const MAX_FILE_BYTES = 50_000;
|
|
26
|
+
const MAX_SUBDIR_DEPTH = 5;
|
|
27
|
+
|
|
28
|
+
export interface MemoryEntry {
|
|
29
|
+
source: 'user' | 'project' | 'subdirectory';
|
|
30
|
+
path: string;
|
|
31
|
+
content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Cached {
|
|
35
|
+
mtimeMs: number;
|
|
36
|
+
content: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readCapped(path: string): string {
|
|
40
|
+
const buf = readFileSync(path, 'utf-8');
|
|
41
|
+
return buf.length > MAX_FILE_BYTES
|
|
42
|
+
? buf.slice(0, MAX_FILE_BYTES) + '\n\n(truncated)'
|
|
43
|
+
: buf;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class MemoryManager {
|
|
47
|
+
private workingDir: string;
|
|
48
|
+
private cache = new Map<string, Cached>();
|
|
49
|
+
|
|
50
|
+
constructor(workingDir: string) {
|
|
51
|
+
this.workingDir = resolve(workingDir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read user-level + project-level memory, plus the nearest-ancestor
|
|
56
|
+
* KONDI.md or AGENTS.md for activeFile. Both filenames are checked at
|
|
57
|
+
* every level; if both exist, both are loaded (AGENTS.md first).
|
|
58
|
+
*/
|
|
59
|
+
load(activeFile?: string): MemoryEntry[] {
|
|
60
|
+
const entries: MemoryEntry[] = [];
|
|
61
|
+
|
|
62
|
+
// User level: ~/.kondi-chat/AGENTS.md, ~/.kondi-chat/KONDI.md
|
|
63
|
+
const userDir = join(homedir(), '.kondi-chat');
|
|
64
|
+
for (const fn of MEMORY_FILENAMES) {
|
|
65
|
+
const e = this.readIfPresent(join(userDir, fn), 'user');
|
|
66
|
+
if (e) entries.push(e);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Project level: <workingDir>/AGENTS.md, <workingDir>/KONDI.md
|
|
70
|
+
const projectPaths = new Set<string>();
|
|
71
|
+
for (const fn of MEMORY_FILENAMES) {
|
|
72
|
+
const p = join(this.workingDir, fn);
|
|
73
|
+
projectPaths.add(p);
|
|
74
|
+
const e = this.readIfPresent(p, 'project');
|
|
75
|
+
if (e) entries.push(e);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Subdirectory: walk up from activeFile toward workingDir, stop at project root.
|
|
79
|
+
if (activeFile) {
|
|
80
|
+
const full = resolve(this.workingDir, activeFile);
|
|
81
|
+
if (!relative(this.workingDir, full).startsWith('..')) {
|
|
82
|
+
let dir = dirname(full);
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let found = false;
|
|
85
|
+
while (!found && depth < MAX_SUBDIR_DEPTH && dir.length >= this.workingDir.length && dir !== this.workingDir) {
|
|
86
|
+
for (const fn of MEMORY_FILENAMES) {
|
|
87
|
+
const candidate = join(dir, fn);
|
|
88
|
+
if (projectPaths.has(candidate)) continue;
|
|
89
|
+
const e = this.readIfPresent(candidate, 'subdirectory');
|
|
90
|
+
if (e) { entries.push(e); found = true; }
|
|
91
|
+
}
|
|
92
|
+
if (found) break;
|
|
93
|
+
const parent = dirname(dir);
|
|
94
|
+
if (parent === dir) break;
|
|
95
|
+
dir = parent;
|
|
96
|
+
depth++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private readIfPresent(path: string, source: MemoryEntry['source']): MemoryEntry | null {
|
|
105
|
+
if (!existsSync(path)) { this.cache.delete(path); return null; }
|
|
106
|
+
try {
|
|
107
|
+
const stat = statSync(path);
|
|
108
|
+
const cached = this.cache.get(path);
|
|
109
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
110
|
+
return { source, path, content: cached.content };
|
|
111
|
+
}
|
|
112
|
+
const content = readCapped(path);
|
|
113
|
+
this.cache.set(path, { mtimeMs: stat.mtimeMs, content });
|
|
114
|
+
return { source, path, content };
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Render memory entries as a delimited system-prompt section. Lower-priority first. */
|
|
121
|
+
formatForPrompt(entries: MemoryEntry[]): string {
|
|
122
|
+
if (entries.length === 0) return '';
|
|
123
|
+
const order: MemoryEntry['source'][] = ['user', 'project', 'subdirectory'];
|
|
124
|
+
const sorted = [...entries].sort(
|
|
125
|
+
(a, b) => order.indexOf(a.source) - order.indexOf(b.source),
|
|
126
|
+
);
|
|
127
|
+
const parts: string[] = ['## Memory (advisory, not safety-critical; higher sections override lower)'];
|
|
128
|
+
for (const e of sorted) {
|
|
129
|
+
parts.push(`### Memory: ${e.source} (${e.path})\n${e.content.trim()}`);
|
|
130
|
+
}
|
|
131
|
+
return parts.join('\n\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Write a KONDI.md file. `append` concatenates to the existing file; `replace` overwrites.
|
|
136
|
+
* Returns the absolute path written.
|
|
137
|
+
*/
|
|
138
|
+
updateMemory(
|
|
139
|
+
scope: 'user' | 'project',
|
|
140
|
+
operation: 'append' | 'replace',
|
|
141
|
+
content: string,
|
|
142
|
+
): { path: string } {
|
|
143
|
+
// Writes always go to KONDI.md (the kondi-chat-specific file).
|
|
144
|
+
// AGENTS.md is a cross-tool convention and is typically hand-authored
|
|
145
|
+
// or maintained by a separate process — the agent shouldn't overwrite it.
|
|
146
|
+
const target = scope === 'user'
|
|
147
|
+
? join(homedir(), '.kondi-chat', 'KONDI.md')
|
|
148
|
+
: join(this.workingDir, 'KONDI.md');
|
|
149
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
150
|
+
if (operation === 'append' && existsSync(target)) {
|
|
151
|
+
const existing = readFileSync(target, 'utf-8');
|
|
152
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
153
|
+
writeFileSync(target, existing + sep + content + '\n');
|
|
154
|
+
} else {
|
|
155
|
+
writeFileSync(target, content.endsWith('\n') ? content : content + '\n');
|
|
156
|
+
}
|
|
157
|
+
this.cache.delete(target);
|
|
158
|
+
return { path: target };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight Investigation — automatically reads relevant files before
|
|
3
|
+
* the agent loop starts so the model begins each turn already knowing
|
|
4
|
+
* the relevant code.
|
|
5
|
+
*
|
|
6
|
+
* Instead of the agent spending its first 3-4 tool calls on read_file,
|
|
7
|
+
* the preflight infers which files matter from the task text and injects
|
|
8
|
+
* a compact snapshot into the system prompt. Zero LLM calls — just
|
|
9
|
+
* regex matching against the file tree + reading the matches.
|
|
10
|
+
*
|
|
11
|
+
* The router is unaffected — this runs before router.select() and
|
|
12
|
+
* provides context that any model can use regardless of which one
|
|
13
|
+
* is selected for the phase.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { join, relative, extname } from 'node:path';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
const MAX_FILE_SIZE = 8_000; // chars per file
|
|
21
|
+
const MAX_TOTAL_SIZE = 30_000; // total preflight context chars
|
|
22
|
+
const MAX_FILES = 8;
|
|
23
|
+
|
|
24
|
+
const CODE_EXTENSIONS = new Set([
|
|
25
|
+
'.ts', '.tsx', '.js', '.jsx', '.py', '.rs', '.go', '.java',
|
|
26
|
+
'.cpp', '.cc', '.c', '.h', '.hpp', '.cs', '.rb', '.php',
|
|
27
|
+
'.swift', '.kt', '.sh', '.sql', '.md', '.json', '.yml', '.yaml', '.toml',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
interface PreflightResult {
|
|
31
|
+
filesRead: string[];
|
|
32
|
+
context: string;
|
|
33
|
+
gitDiff?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scan the task text for file references, find related files, read
|
|
38
|
+
* them, and assemble a preflight context block.
|
|
39
|
+
*/
|
|
40
|
+
export function runPreflight(
|
|
41
|
+
workingDir: string,
|
|
42
|
+
taskText: string,
|
|
43
|
+
): PreflightResult {
|
|
44
|
+
const filesRead: string[] = [];
|
|
45
|
+
const sections: string[] = [];
|
|
46
|
+
let totalChars = 0;
|
|
47
|
+
|
|
48
|
+
// 1. Extract explicit file references from the task text.
|
|
49
|
+
const explicitFiles = extractFileReferences(taskText);
|
|
50
|
+
|
|
51
|
+
// 2. Infer related files from keywords in the task.
|
|
52
|
+
const inferredFiles = inferRelatedFiles(workingDir, taskText);
|
|
53
|
+
|
|
54
|
+
// 3. Merge, deduplicate, cap at MAX_FILES.
|
|
55
|
+
const candidates = [...new Set([...explicitFiles, ...inferredFiles])].slice(0, MAX_FILES);
|
|
56
|
+
|
|
57
|
+
// 4. Read each file.
|
|
58
|
+
for (const relPath of candidates) {
|
|
59
|
+
if (totalChars >= MAX_TOTAL_SIZE) break;
|
|
60
|
+
const absPath = join(workingDir, relPath);
|
|
61
|
+
if (!existsSync(absPath)) continue;
|
|
62
|
+
try {
|
|
63
|
+
const stat = statSync(absPath);
|
|
64
|
+
if (!stat.isFile() || stat.size > 100_000) continue;
|
|
65
|
+
let content = readFileSync(absPath, 'utf-8');
|
|
66
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
67
|
+
content = content.slice(0, MAX_FILE_SIZE) + `\n... (${content.length - MAX_FILE_SIZE} chars truncated)`;
|
|
68
|
+
}
|
|
69
|
+
sections.push(`### ${relPath}\n\`\`\`\n${content}\n\`\`\``);
|
|
70
|
+
filesRead.push(relPath);
|
|
71
|
+
totalChars += content.length;
|
|
72
|
+
} catch {
|
|
73
|
+
// Skip unreadable files
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 5. Recent git diff (last commit or uncommitted changes).
|
|
78
|
+
let gitDiff: string | undefined;
|
|
79
|
+
try {
|
|
80
|
+
const diff = execSync('git diff --stat HEAD 2>/dev/null || git diff --stat 2>/dev/null', {
|
|
81
|
+
cwd: workingDir,
|
|
82
|
+
encoding: 'utf-8',
|
|
83
|
+
timeout: 5000,
|
|
84
|
+
}).trim();
|
|
85
|
+
if (diff && diff.length < 2000) {
|
|
86
|
+
gitDiff = diff;
|
|
87
|
+
sections.push(`### Recent changes (git diff --stat)\n${diff}`);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Not a git repo or git not available
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const context = sections.length > 0
|
|
94
|
+
? `## Preflight: relevant files (auto-loaded)\n\n${sections.join('\n\n')}`
|
|
95
|
+
: '';
|
|
96
|
+
|
|
97
|
+
return { filesRead, context, gitDiff };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract explicit file paths from the task text.
|
|
102
|
+
* Matches patterns like "src/foo.ts", "package.json", "./bar/baz.py".
|
|
103
|
+
*/
|
|
104
|
+
function extractFileReferences(text: string): string[] {
|
|
105
|
+
const refs: string[] = [];
|
|
106
|
+
// Match file paths with known extensions
|
|
107
|
+
const pathRe = /(?:^|\s|["'`(])([.\w/-]+(?:\.\w{1,10}))(?=[\s"'`),.:;]|$)/gm;
|
|
108
|
+
let match;
|
|
109
|
+
while ((match = pathRe.exec(text)) !== null) {
|
|
110
|
+
const candidate = match[1].replace(/^\.\//, '');
|
|
111
|
+
const ext = extname(candidate).toLowerCase();
|
|
112
|
+
if (CODE_EXTENSIONS.has(ext) || candidate === 'package.json' || candidate === 'Cargo.toml') {
|
|
113
|
+
refs.push(candidate);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return refs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Infer related files from keywords in the task. Scans the file tree
|
|
121
|
+
* (shallow, no node_modules) for filenames that match significant
|
|
122
|
+
* words from the task.
|
|
123
|
+
*/
|
|
124
|
+
function inferRelatedFiles(workingDir: string, text: string): string[] {
|
|
125
|
+
// Extract significant words (3+ chars, not common stop words)
|
|
126
|
+
const stopWords = new Set(['the', 'this', 'that', 'with', 'from', 'have', 'been', 'will',
|
|
127
|
+
'can', 'should', 'would', 'could', 'make', 'like', 'just', 'also', 'into', 'about',
|
|
128
|
+
'what', 'when', 'where', 'which', 'there', 'their', 'them', 'then', 'than', 'some',
|
|
129
|
+
'more', 'most', 'very', 'each', 'every', 'all', 'any', 'both', 'few', 'other',
|
|
130
|
+
'such', 'only', 'same', 'how', 'does', 'did', 'has', 'had', 'not', 'but', 'for',
|
|
131
|
+
'are', 'was', 'were', 'and', 'the', 'add', 'fix', 'update', 'change', 'remove',
|
|
132
|
+
'write', 'read', 'create', 'delete', 'implement', 'refactor', 'test', 'run', 'build',
|
|
133
|
+
'use', 'using', 'file', 'code', 'function', 'method', 'class', 'module',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const words = text.toLowerCase()
|
|
137
|
+
.replace(/[^a-z0-9_-]/g, ' ')
|
|
138
|
+
.split(/\s+/)
|
|
139
|
+
.filter(w => w.length >= 3 && !stopWords.has(w));
|
|
140
|
+
|
|
141
|
+
if (words.length === 0) return [];
|
|
142
|
+
|
|
143
|
+
// Scan the file tree (max 2 levels deep, skip heavy dirs)
|
|
144
|
+
const skipDirs = new Set(['node_modules', '.git', '.kondi-chat', '.claude', 'dist', 'target', '__pycache__', '.next', '.venv', 'venv']);
|
|
145
|
+
const matches: string[] = [];
|
|
146
|
+
|
|
147
|
+
function scan(dir: string, depth: number) {
|
|
148
|
+
if (depth > 2) return;
|
|
149
|
+
try {
|
|
150
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (skipDirs.has(entry.name)) continue;
|
|
153
|
+
const rel = relative(workingDir, join(dir, entry.name));
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
scan(join(dir, entry.name), depth + 1);
|
|
156
|
+
} else if (entry.isFile()) {
|
|
157
|
+
const ext = extname(entry.name).toLowerCase();
|
|
158
|
+
if (!CODE_EXTENSIONS.has(ext)) continue;
|
|
159
|
+
const nameLower = entry.name.toLowerCase().replace(extname(entry.name), '');
|
|
160
|
+
// Check if any task word appears in the filename
|
|
161
|
+
for (const word of words) {
|
|
162
|
+
if (nameLower.includes(word) || word.includes(nameLower)) {
|
|
163
|
+
matches.push(rel);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Permission error or similar
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
scan(workingDir, 0);
|
|
175
|
+
return matches;
|
|
176
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Brain — unified context assembler.
|
|
3
|
+
*
|
|
4
|
+
* Pulls together every context source into one coherent block that
|
|
5
|
+
* gets injected into the system prompt before every model call:
|
|
6
|
+
*
|
|
7
|
+
* 1. AGENTS.md / KONDI.md (project conventions)
|
|
8
|
+
* 2. Recent receipts (what happened in prior turns)
|
|
9
|
+
* 3. Relevant skills (procedures for this type of task)
|
|
10
|
+
* 4. Preflight files (auto-loaded relevant code)
|
|
11
|
+
* 5. Repo summary (from bootstrap)
|
|
12
|
+
*
|
|
13
|
+
* The brain doesn't decide which model runs — that's the router's
|
|
14
|
+
* job. The brain decides what context that model should see.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ReceiptStore } from './receipts.ts';
|
|
18
|
+
import { MemoryManager } from './memory.ts';
|
|
19
|
+
import { runPreflight } from './preflight.ts';
|
|
20
|
+
import { loadSkills, selectSkills, formatSkillsForContext, seedDefaultSkills } from './skills.ts';
|
|
21
|
+
import { TaskStore } from '../engine/task-store.ts';
|
|
22
|
+
import type { Session } from '../types.ts';
|
|
23
|
+
|
|
24
|
+
export interface BrainContext {
|
|
25
|
+
/** Full assembled context for injection into system prompt. */
|
|
26
|
+
fullContext: string;
|
|
27
|
+
/** Files auto-loaded by preflight. */
|
|
28
|
+
preflightFiles: string[];
|
|
29
|
+
/** Skills matched for this task. */
|
|
30
|
+
skillsUsed: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assemble all context sources for a given task.
|
|
35
|
+
* Called once per turn, before the agent loop starts.
|
|
36
|
+
*/
|
|
37
|
+
export function assembleBrainContext(
|
|
38
|
+
workingDir: string,
|
|
39
|
+
session: Session,
|
|
40
|
+
task: string,
|
|
41
|
+
opts?: { skipPreflight?: boolean },
|
|
42
|
+
): BrainContext {
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
const preflightFiles: string[] = [];
|
|
45
|
+
const skillsUsed: string[] = [];
|
|
46
|
+
|
|
47
|
+
// 1. Memory (AGENTS.md, KONDI.md)
|
|
48
|
+
const memory = new MemoryManager(workingDir);
|
|
49
|
+
const entries = memory.load();
|
|
50
|
+
if (entries.length > 0) {
|
|
51
|
+
sections.push(
|
|
52
|
+
'## Project conventions\n\n' +
|
|
53
|
+
entries.map(e => `### ${e.source}: ${e.path}\n${e.content}`).join('\n\n')
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Active task (persisted across sessions)
|
|
58
|
+
const storageDir = `${workingDir}/.kondi-chat`;
|
|
59
|
+
const taskStore = new TaskStore(storageDir);
|
|
60
|
+
const activeTask = taskStore.formatForContext();
|
|
61
|
+
if (activeTask) {
|
|
62
|
+
sections.push(activeTask);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Recent receipts
|
|
66
|
+
const receipts = new ReceiptStore(storageDir, session.id);
|
|
67
|
+
const recentReceipts = receipts.formatForContext(3);
|
|
68
|
+
if (recentReceipts) {
|
|
69
|
+
sections.push(recentReceipts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Skills
|
|
73
|
+
seedDefaultSkills(workingDir);
|
|
74
|
+
const skills = loadSkills(workingDir);
|
|
75
|
+
const matched = selectSkills(task, skills, 2);
|
|
76
|
+
if (matched.length > 0) {
|
|
77
|
+
sections.push(formatSkillsForContext(matched));
|
|
78
|
+
skillsUsed.push(...matched.map(s => s.name));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Preflight (relevant files) — skipped for short/follow-up messages
|
|
82
|
+
if (!opts?.skipPreflight) {
|
|
83
|
+
const preflight = runPreflight(workingDir, task);
|
|
84
|
+
if (preflight.context) {
|
|
85
|
+
sections.push(preflight.context);
|
|
86
|
+
preflightFiles.push(...preflight.filesRead);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5. Repo summary (grounding context from bootstrap, if available)
|
|
91
|
+
if (session.groundingContext) {
|
|
92
|
+
// Already in the system prompt via cacheablePrefix — don't duplicate.
|
|
93
|
+
// Just note it so the model knows it's there.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
fullContext: sections.join('\n\n'),
|
|
98
|
+
preflightFiles,
|
|
99
|
+
skillsUsed,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Receipts — structured per-turn summaries that persist
|
|
3
|
+
* across turns and sessions.
|
|
4
|
+
*
|
|
5
|
+
* After every turn, a receipt records what changed, why, which files
|
|
6
|
+
* were touched, and what comes next. The last N receipts are injected
|
|
7
|
+
* into the system prompt so the model has cross-turn continuity
|
|
8
|
+
* without re-reading the entire conversation history.
|
|
9
|
+
*
|
|
10
|
+
* Receipts are cheap: no LLM call needed. They're assembled from
|
|
11
|
+
* data already available in the turn (tool calls, model responses,
|
|
12
|
+
* ledger entries). Storage is a simple JSONL file under the session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export interface TurnReceipt {
|
|
19
|
+
turnNumber: number;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
userGoal: string;
|
|
22
|
+
modelUsed: string;
|
|
23
|
+
filesRead: string[];
|
|
24
|
+
filesWritten: string[];
|
|
25
|
+
toolsCalled: string[];
|
|
26
|
+
outcome: string;
|
|
27
|
+
remainingWork?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ReceiptStore {
|
|
31
|
+
private path: string;
|
|
32
|
+
|
|
33
|
+
constructor(storageDir: string, sessionId: string) {
|
|
34
|
+
const dir = join(storageDir, 'sessions');
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
this.path = join(dir, `${sessionId}-receipts.jsonl`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Append a receipt after a turn completes. */
|
|
40
|
+
record(receipt: TurnReceipt): void {
|
|
41
|
+
appendFileSync(this.path, JSON.stringify(receipt) + '\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get the last N receipts for injection into context. */
|
|
45
|
+
getRecent(count = 3): TurnReceipt[] {
|
|
46
|
+
if (!existsSync(this.path)) return [];
|
|
47
|
+
try {
|
|
48
|
+
const lines = readFileSync(this.path, 'utf-8').trim().split('\n').filter(Boolean);
|
|
49
|
+
return lines.slice(-count).map(l => JSON.parse(l));
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Format receipts for injection into the system prompt. */
|
|
56
|
+
formatForContext(count = 3): string {
|
|
57
|
+
const receipts = this.getRecent(count);
|
|
58
|
+
if (receipts.length === 0) return '';
|
|
59
|
+
|
|
60
|
+
const lines = ['## Recent turns'];
|
|
61
|
+
for (const r of receipts) {
|
|
62
|
+
lines.push(`Turn ${r.turnNumber}: ${r.userGoal.slice(0, 100)}`);
|
|
63
|
+
if (r.filesWritten.length > 0) lines.push(` wrote: ${r.filesWritten.join(', ')}`);
|
|
64
|
+
if (r.filesRead.length > 0) lines.push(` read: ${r.filesRead.join(', ')}`);
|
|
65
|
+
lines.push(` outcome: ${r.outcome.slice(0, 200)}`);
|
|
66
|
+
if (r.remainingWork) lines.push(` remaining: ${r.remainingWork}`);
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a receipt from turn data. Called at the end of handleSubmit
|
|
75
|
+
* with whatever data is available — no LLM call needed.
|
|
76
|
+
*/
|
|
77
|
+
export function buildReceipt(
|
|
78
|
+
turnNumber: number,
|
|
79
|
+
userGoal: string,
|
|
80
|
+
modelUsed: string,
|
|
81
|
+
toolCalls: Array<{ name: string; args: string; is_error: boolean }>,
|
|
82
|
+
finalContent: string,
|
|
83
|
+
): TurnReceipt {
|
|
84
|
+
const filesRead: string[] = [];
|
|
85
|
+
const filesWritten: string[] = [];
|
|
86
|
+
const toolsCalled: string[] = [];
|
|
87
|
+
|
|
88
|
+
for (const tc of toolCalls) {
|
|
89
|
+
toolsCalled.push(tc.name);
|
|
90
|
+
if (tc.name === 'read_file') filesRead.push(tc.args);
|
|
91
|
+
if (tc.name === 'write_file' || tc.name === 'edit_file') filesWritten.push(tc.args);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract a short outcome from the final content.
|
|
95
|
+
const outcomeLines = finalContent.split('\n').filter(l => l.trim().length > 0);
|
|
96
|
+
const outcome = outcomeLines.slice(0, 3).join(' ').slice(0, 300);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
turnNumber,
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
userGoal: userGoal.slice(0, 200),
|
|
102
|
+
modelUsed,
|
|
103
|
+
filesRead: [...new Set(filesRead)],
|
|
104
|
+
filesWritten: [...new Set(filesWritten)],
|
|
105
|
+
toolsCalled: [...new Set(toolsCalled)],
|
|
106
|
+
outcome,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills — reusable task procedures loaded from .kondi-chat/skills/.
|
|
3
|
+
*
|
|
4
|
+
* Each skill is a SKILL.md file with structured instructions for how
|
|
5
|
+
* to approach a category of task (debugging, adding features, refactoring,
|
|
6
|
+
* code review, etc.). The skill router picks 1-3 relevant skills per
|
|
7
|
+
* task and injects them into the system prompt so the model follows
|
|
8
|
+
* a proven procedure instead of winging it.
|
|
9
|
+
*
|
|
10
|
+
* The router picks models. Skills teach models HOW to work.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
export interface Skill {
|
|
18
|
+
name: string;
|
|
19
|
+
content: string;
|
|
20
|
+
/** First line of the file, used as a short description for matching. */
|
|
21
|
+
description: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_SKILL_SIZE = 5_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load skills from user-level + project-level .kondi-chat/skills/.
|
|
28
|
+
* Project-level overrides user-level on name collision.
|
|
29
|
+
*/
|
|
30
|
+
export function loadSkills(workingDir: string): Skill[] {
|
|
31
|
+
const skills = new Map<string, Skill>();
|
|
32
|
+
|
|
33
|
+
// User-level first
|
|
34
|
+
loadFromDir(join(homedir(), '.kondi-chat', 'skills'), skills);
|
|
35
|
+
// Project-level overrides
|
|
36
|
+
loadFromDir(join(workingDir, '.kondi-chat', 'skills'), skills);
|
|
37
|
+
|
|
38
|
+
return [...skills.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadFromDir(dir: string, skills: Map<string, Skill>): void {
|
|
42
|
+
if (!existsSync(dir)) return;
|
|
43
|
+
try {
|
|
44
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
45
|
+
try {
|
|
46
|
+
let content = readFileSync(join(dir, file), 'utf-8');
|
|
47
|
+
if (content.length > MAX_SKILL_SIZE) {
|
|
48
|
+
content = content.slice(0, MAX_SKILL_SIZE) + '\n(truncated)';
|
|
49
|
+
}
|
|
50
|
+
const name = basename(file, '.md').toLowerCase();
|
|
51
|
+
const firstLine = content.split('\n').find(l => l.trim().length > 0 && !l.startsWith('#')) || '';
|
|
52
|
+
skills.set(name, { name, content, description: firstLine.slice(0, 200) });
|
|
53
|
+
} catch { /* skip unreadable */ }
|
|
54
|
+
}
|
|
55
|
+
} catch { /* dir not readable */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pick the most relevant skills for a given task. Simple keyword
|
|
60
|
+
* matching — no LLM call needed.
|
|
61
|
+
*/
|
|
62
|
+
export function selectSkills(task: string, skills: Skill[], max = 2): Skill[] {
|
|
63
|
+
if (skills.length === 0) return [];
|
|
64
|
+
const taskLower = task.toLowerCase();
|
|
65
|
+
|
|
66
|
+
const scored = skills.map(skill => {
|
|
67
|
+
let score = 0;
|
|
68
|
+
// Match skill name against task words
|
|
69
|
+
if (taskLower.includes(skill.name)) score += 10;
|
|
70
|
+
// Match keywords from the skill description
|
|
71
|
+
const descWords = skill.description.toLowerCase().split(/\s+/);
|
|
72
|
+
for (const word of descWords) {
|
|
73
|
+
if (word.length >= 4 && taskLower.includes(word)) score += 1;
|
|
74
|
+
}
|
|
75
|
+
return { skill, score };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return scored
|
|
79
|
+
.filter(s => s.score > 0)
|
|
80
|
+
.sort((a, b) => b.score - a.score)
|
|
81
|
+
.slice(0, max)
|
|
82
|
+
.map(s => s.skill);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format selected skills for injection into the system prompt.
|
|
87
|
+
*/
|
|
88
|
+
export function formatSkillsForContext(skills: Skill[]): string {
|
|
89
|
+
if (skills.length === 0) return '';
|
|
90
|
+
const sections = skills.map(s => `### Skill: ${s.name}\n${s.content}`);
|
|
91
|
+
return `## Relevant procedures\n\n${sections.join('\n\n')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Seed default skills if the skills directory doesn't exist.
|
|
96
|
+
*/
|
|
97
|
+
export function seedDefaultSkills(workingDir: string): void {
|
|
98
|
+
const dir = join(workingDir, '.kondi-chat', 'skills');
|
|
99
|
+
if (existsSync(dir)) return;
|
|
100
|
+
mkdirSync(dir, { recursive: true });
|
|
101
|
+
|
|
102
|
+
const defaults: Record<string, string> = {
|
|
103
|
+
'debug': `# Debug
|
|
104
|
+
|
|
105
|
+
When debugging an issue:
|
|
106
|
+
1. Reproduce the problem — read the error message carefully
|
|
107
|
+
2. Find the source: search_code for the error text, read the relevant file
|
|
108
|
+
3. Understand the context: read related_files to see dependencies
|
|
109
|
+
4. Form a hypothesis about the root cause
|
|
110
|
+
5. Make the minimal fix — don't refactor while debugging
|
|
111
|
+
6. Verify: run the tests or typecheck to confirm the fix
|
|
112
|
+
7. Check for similar patterns elsewhere in the codebase`,
|
|
113
|
+
|
|
114
|
+
'add-feature': `# Add Feature
|
|
115
|
+
|
|
116
|
+
When implementing a new feature:
|
|
117
|
+
1. Read existing code in the area — understand conventions, patterns, types
|
|
118
|
+
2. Check for tests — understand how existing features are tested
|
|
119
|
+
3. Plan the change: which files need modification, which are new
|
|
120
|
+
4. Implement incrementally — write, then verify with typecheck/tests
|
|
121
|
+
5. Follow existing patterns — don't introduce new conventions
|
|
122
|
+
6. Add tests if the project has them
|
|
123
|
+
7. Review your own diff before reporting done`,
|
|
124
|
+
|
|
125
|
+
'refactor': `# Refactor
|
|
126
|
+
|
|
127
|
+
When refactoring code:
|
|
128
|
+
1. Understand WHY it needs refactoring — state the goal clearly
|
|
129
|
+
2. Read all the code you'll touch + its tests
|
|
130
|
+
3. Make sure existing tests pass BEFORE you start
|
|
131
|
+
4. Refactor in small steps — one file or one concept at a time
|
|
132
|
+
5. Run tests after each step
|
|
133
|
+
6. Don't change behavior — only structure
|
|
134
|
+
7. If you find bugs during refactoring, note them but don't fix them in the same change`,
|
|
135
|
+
|
|
136
|
+
'review': `# Code Review
|
|
137
|
+
|
|
138
|
+
When reviewing code:
|
|
139
|
+
1. Read the full diff, not just the changed lines
|
|
140
|
+
2. Check: does it do what it claims? Are edge cases handled?
|
|
141
|
+
3. Look for: security issues, error handling gaps, race conditions
|
|
142
|
+
4. Check naming: are new names consistent with existing conventions?
|
|
143
|
+
5. Check tests: are new behaviors tested? Are old tests still valid?
|
|
144
|
+
6. Be specific: "line 42 misses the null case" not "needs more error handling"
|
|
145
|
+
7. Distinguish blocking issues from suggestions`,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const [name, content] of Object.entries(defaults)) {
|
|
149
|
+
const path = join(dir, `${name}.md`);
|
|
150
|
+
if (!existsSync(path)) {
|
|
151
|
+
try { require('node:fs').writeFileSync(path, content); } catch { /* ignore */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|