klyro 0.1.20 → 0.1.22
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/dist/cli/context.d.ts +2 -0
- package/dist/cli/context.js +25 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/scan.d.ts +8 -0
- package/dist/cli/scan.js +22 -0
- package/dist/cli/slash/parser.d.ts +7 -0
- package/dist/cli/slash/parser.js +4 -1
- package/dist/context/accounting.d.ts +16 -0
- package/dist/context/accounting.js +18 -0
- package/dist/context/assembly.d.ts +17 -0
- package/dist/context/assembly.js +21 -0
- package/dist/context/compaction.d.ts +18 -0
- package/dist/context/compaction.js +39 -0
- package/dist/context/import-graph.d.ts +8 -0
- package/dist/context/import-graph.js +76 -0
- package/dist/context/lifecycle.d.ts +7 -0
- package/dist/context/lifecycle.js +30 -0
- package/dist/context/memory.d.ts +5 -0
- package/dist/context/memory.js +33 -0
- package/dist/context/project-map.d.ts +9 -4
- package/dist/context/project-map.js +150 -2
- package/dist/index.js +2 -0
- package/dist/tools/expand-result.d.ts +11 -0
- package/dist/tools/expand-result.js +17 -0
- package/dist/tools/fs/read-file.js +9 -0
- package/dist/tools/lsp/diagnostics.d.ts +20 -0
- package/dist/tools/lsp/diagnostics.js +28 -0
- package/dist/tools/memory-write.d.ts +6 -0
- package/dist/tools/memory-write.js +15 -0
- package/dist/tools/registry.js +14 -0
- package/dist/tools/repo-map.d.ts +8 -0
- package/dist/tools/repo-map.js +95 -0
- package/dist/tools/search/imports.d.ts +12 -0
- package/dist/tools/search/imports.js +20 -0
- package/dist/tools/symbols/find-symbol.d.ts +17 -0
- package/dist/tools/symbols/find-symbol.js +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.5 — /context inspector + compaction notice UI + context meter
|
|
3
|
+
*/
|
|
4
|
+
import { accounting, contextMeter } from '../context/accounting.js';
|
|
5
|
+
import { totalTokens, estimateTokens } from '../context/tokenizer.js';
|
|
6
|
+
export function renderContextBreakdown(system, messages) {
|
|
7
|
+
const acc = accounting(system, messages);
|
|
8
|
+
const byCat = new Map();
|
|
9
|
+
byCat.set('system prompt', system ? estimateTokens(system) : 0);
|
|
10
|
+
// estimate tools/map/KLYRO as part of system already
|
|
11
|
+
let msgTokens = 0;
|
|
12
|
+
for (const m of messages)
|
|
13
|
+
msgTokens += totalTokens(undefined, [m]);
|
|
14
|
+
byCat.set('messages', msgTokens);
|
|
15
|
+
const largest = [...messages].map((m) => ({ m, t: totalTokens(undefined, [m]) })).sort((a, b) => b.t - a.t).slice(0, 3);
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push(`context ${acc.pct}% ${contextMeter(acc.pct)} ${acc.used.toLocaleString()} / ${acc.cap.toLocaleString()} compact at ${Math.round(acc.compactAt * 100)}%`);
|
|
18
|
+
lines.push(` reserve ${acc.reserveOutput.toLocaleString()} output`);
|
|
19
|
+
lines.push('');
|
|
20
|
+
for (const [k, v] of byCat)
|
|
21
|
+
lines.push(`${k.padEnd(24)} ${v.toLocaleString().padStart(7)} ${Math.round((v / acc.cap) * 100)}%`);
|
|
22
|
+
lines.push(` largest ${largest.map((l) => `${l.m.content[0]?.text?.slice(0, 30) ?? l.m.role} ${l.t}`).join('\n ')}`);
|
|
23
|
+
lines.push(` reserved for output ${acc.reserveOutput.toLocaleString().padStart(7)} ${Math.round((acc.reserveOutput / acc.cap) * 100)}%`);
|
|
24
|
+
return lines.join('\n');
|
|
25
|
+
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -503,6 +503,31 @@ export async function startRepl(opts = {}) {
|
|
|
503
503
|
}
|
|
504
504
|
return;
|
|
505
505
|
}
|
|
506
|
+
case 'project': {
|
|
507
|
+
const { runScan } = await import('./scan.js');
|
|
508
|
+
let out = '';
|
|
509
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
510
|
+
process.stdout.write = ((c) => { out += String(c); return true; });
|
|
511
|
+
await runScan({ cwd, json: false });
|
|
512
|
+
process.stdout.write = orig;
|
|
513
|
+
queuedAppend({ id: `proj-${Date.now()}`, kind: 'text', text: out.slice(0, 4000), role: 'assistant' });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
case 'context': {
|
|
517
|
+
const { renderContextBreakdown } = await import('./context.js');
|
|
518
|
+
// use last transcript via closure? approximate with empty
|
|
519
|
+
const { accounting } = await import('../context/accounting.js');
|
|
520
|
+
const sys = ''; // system prompt approx
|
|
521
|
+
const breakdown = renderContextBreakdown(sys, []);
|
|
522
|
+
queuedAppend({ id: `ctx-${Date.now()}`, kind: 'text', text: breakdown, role: 'assistant' });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
case 'compact': {
|
|
526
|
+
queuedAppend({ id: `compact-${Date.now()}`, kind: 'text', text: 'Compacting context…', role: 'assistant' });
|
|
527
|
+
// 8.3 compaction would summarize oldest 60% — stub emits compacted event
|
|
528
|
+
queuedStatus({ status: 'running' });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
506
531
|
case 'compact':
|
|
507
532
|
queuedAppend({
|
|
508
533
|
id: `stub-${Date.now()}`,
|
package/dist/cli/scan.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.1 — klyro scan / klyro project
|
|
3
|
+
* Scans project and prints ProjectMap. Used by /project and for L6 verifier feeding.
|
|
4
|
+
*/
|
|
5
|
+
import { buildProjectMapCached, formatProjectMap } from '../context/project-map.js';
|
|
6
|
+
export async function runScan(opts) {
|
|
7
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const m = await buildProjectMapCached(cwd);
|
|
10
|
+
const elapsed = Date.now() - start;
|
|
11
|
+
if (opts.json) {
|
|
12
|
+
process.stdout.write(JSON.stringify({ ...m, elapsedMs: elapsed }, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
process.stdout.write(formatProjectMap(m) + '\n');
|
|
16
|
+
process.stdout.write(`\n(scan ${elapsed}ms)\n`);
|
|
17
|
+
}
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
export async function runProject(opts) {
|
|
21
|
+
return runScan(opts);
|
|
22
|
+
}
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Anything not starting with "/" is a regular prompt and yields
|
|
15
15
|
* { kind: 'prompt', text }.
|
|
16
16
|
*/
|
|
17
|
-
const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'verify', 'exit', 'clear'];
|
|
17
|
+
const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'verify', 'project', 'context', 'compact', 'exit', 'clear'];
|
|
18
18
|
export function parse(input) {
|
|
19
19
|
const trimmed = input.trim();
|
|
20
20
|
if (!trimmed.startsWith('/')) {
|
|
@@ -36,6 +36,9 @@ export function parse(input) {
|
|
|
36
36
|
case 'memory': return { kind: 'memory' };
|
|
37
37
|
case 'jobs': return { kind: 'jobs' };
|
|
38
38
|
case 'verify': return { kind: 'verify' };
|
|
39
|
+
case 'project': return { kind: 'project' };
|
|
40
|
+
case 'context': return { kind: 'context' };
|
|
41
|
+
case 'compact': return { kind: 'compact', focus: rest || undefined };
|
|
39
42
|
case 'quit':
|
|
40
43
|
case 'exit':
|
|
41
44
|
case 'q': return { kind: 'quit' };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Message } from '../agent/message.js';
|
|
2
|
+
export interface ContextAccounting {
|
|
3
|
+
used: number;
|
|
4
|
+
cap: number;
|
|
5
|
+
pct: number;
|
|
6
|
+
reserveOutput: number;
|
|
7
|
+
compactAt: number;
|
|
8
|
+
toolResultMax: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function accounting(system: string | undefined, messages: Message[], opts?: {
|
|
11
|
+
cap?: number;
|
|
12
|
+
reserveOutput?: number;
|
|
13
|
+
toolResultMax?: number;
|
|
14
|
+
compactAt?: number;
|
|
15
|
+
}): ContextAccounting;
|
|
16
|
+
export declare function contextMeter(pct: number): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.1 — Accounting & stable assembly
|
|
3
|
+
* Live token estimate, ctx%, compactAt, reserveOutput, toolResultMax
|
|
4
|
+
*/
|
|
5
|
+
import { totalTokens } from './tokenizer.js';
|
|
6
|
+
export function accounting(system, messages, opts = {}) {
|
|
7
|
+
const cap = opts.cap ?? 120_000;
|
|
8
|
+
const reserveOutput = opts.reserveOutput ?? 16_000;
|
|
9
|
+
const toolResultMax = opts.toolResultMax ?? 2000;
|
|
10
|
+
const compactAt = opts.compactAt ?? 0.8;
|
|
11
|
+
const used = totalTokens(system, messages);
|
|
12
|
+
const pct = Math.round((used / cap) * 100);
|
|
13
|
+
return { used, cap, pct, reserveOutput, compactAt, toolResultMax };
|
|
14
|
+
}
|
|
15
|
+
export function contextMeter(pct) {
|
|
16
|
+
const filled = Math.round((pct / 100) * 20);
|
|
17
|
+
return `${'▰'.repeat(filled)}${'▱'.repeat(20 - filled)}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.1 — Stable assembly order: identity → tools → env+map+KLYRO.md → summary → messages → reminders
|
|
3
|
+
* Cache-friendly: deterministic serialization for prompt caching (turn 5+ cache-read ≥80%).
|
|
4
|
+
*/
|
|
5
|
+
import type { Message } from '../agent/message.js';
|
|
6
|
+
export interface AssemblyParts {
|
|
7
|
+
identity: string;
|
|
8
|
+
tools: string;
|
|
9
|
+
envMap: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
messages: Message[];
|
|
12
|
+
reminders?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function assemble(parts: AssemblyParts): {
|
|
15
|
+
system: string;
|
|
16
|
+
messages: Message[];
|
|
17
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function assemble(parts) {
|
|
2
|
+
const segs = [];
|
|
3
|
+
if (parts.identity)
|
|
4
|
+
segs.push(parts.identity);
|
|
5
|
+
if (parts.tools)
|
|
6
|
+
segs.push(parts.tools);
|
|
7
|
+
if (parts.envMap)
|
|
8
|
+
segs.push(parts.envMap);
|
|
9
|
+
if (parts.summary)
|
|
10
|
+
segs.push(parts.summary);
|
|
11
|
+
// reminders are injected into last user turn per spec
|
|
12
|
+
const messages = [...parts.messages];
|
|
13
|
+
if (parts.reminders && messages.length > 0) {
|
|
14
|
+
const last = messages[messages.length - 1];
|
|
15
|
+
if (last && last.role === 'user') {
|
|
16
|
+
const text = last.content.filter((b) => b.kind === 'text').map((b) => b.text).join('\n');
|
|
17
|
+
messages[messages.length - 1] = { ...last, content: [{ kind: 'text', text: text + '\n\n' + parts.reminders }] };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { system: segs.join('\n\n'), messages };
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.3 — Auto-compaction: (a) elide → (b) summarize 60% with model.small → (c) keep last N verbatim
|
|
3
|
+
* Trigger at compactAt (80%) or /compact [focus]. Validates summary mentions every checkpointed file else fallback.
|
|
4
|
+
*/
|
|
5
|
+
import type { Message } from '../agent/message.js';
|
|
6
|
+
export interface CompactionResult {
|
|
7
|
+
messages: Message[];
|
|
8
|
+
summary: string;
|
|
9
|
+
dropped: number;
|
|
10
|
+
method: 'elide' | 'summarize' | 'fallback';
|
|
11
|
+
}
|
|
12
|
+
export declare function compact(messages: Message[], opts: {
|
|
13
|
+
system?: string;
|
|
14
|
+
cap?: number;
|
|
15
|
+
focus?: string;
|
|
16
|
+
checkpointedFiles?: string[];
|
|
17
|
+
summarizeFn?: (prompt: string) => Promise<string>;
|
|
18
|
+
}): Promise<CompactionResult>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { compressTranscript } from './tokenizer.js';
|
|
2
|
+
export async function compact(messages, opts) {
|
|
3
|
+
const cap = opts.cap ?? 120_000;
|
|
4
|
+
// (a) elide old tool results
|
|
5
|
+
const elided = compressTranscript(opts.system, messages, { total: cap, reservedOutput: 16_000 });
|
|
6
|
+
if (opts.checkpointedFiles && elided.dropped > 0) {
|
|
7
|
+
// (b) try summarize oldest 60% using strict template (mock small model)
|
|
8
|
+
const n = Math.floor(messages.length * 0.6);
|
|
9
|
+
const oldest = messages.slice(0, n);
|
|
10
|
+
const newest = messages.slice(n);
|
|
11
|
+
const template = `Summarize the following ${oldest.length} messages, mentioning every file: ${opts.checkpointedFiles.join(', ')}.\nFocus: ${opts.focus ?? 'general'}\n\n` + oldest.map((m) => JSON.stringify(m.content).slice(0, 500)).join('\n');
|
|
12
|
+
let summary = `Earlier in session: ${oldest.length} turns covering ${opts.checkpointedFiles.join(', ')}`;
|
|
13
|
+
if (opts.summarizeFn) {
|
|
14
|
+
try {
|
|
15
|
+
summary = await opts.summarizeFn(template);
|
|
16
|
+
}
|
|
17
|
+
catch { /* fallback */ }
|
|
18
|
+
}
|
|
19
|
+
// validate: every checkpointed file mentioned
|
|
20
|
+
const missing = opts.checkpointedFiles.filter((f) => !summary.includes(f.split('/').pop() ?? f));
|
|
21
|
+
if (missing.length === 0) {
|
|
22
|
+
// (c) keep last N verbatim + summary as first message
|
|
23
|
+
const summaryMsg = { role: 'user', content: [{ kind: 'text', text: summary }] };
|
|
24
|
+
return { messages: [summaryMsg, ...newest], summary, dropped: elided.dropped, method: 'summarize' };
|
|
25
|
+
}
|
|
26
|
+
// retry once then fallback to elide
|
|
27
|
+
if (opts.summarizeFn) {
|
|
28
|
+
try {
|
|
29
|
+
const retry = await opts.summarizeFn(template + '\nEnsure to mention: ' + missing.join(', '));
|
|
30
|
+
if (missing.every((f) => retry.includes(f.split('/').pop() ?? f))) {
|
|
31
|
+
const summaryMsg2 = { role: 'user', content: [{ kind: 'text', text: retry }] };
|
|
32
|
+
return { messages: [summaryMsg2, ...newest], summary: retry, dropped: elided.dropped, method: 'summarize' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* fallback */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { messages: elided.messages, summary: elided.dropped > 0 ? `Elided ${elided.dropped} observations` : '', dropped: elided.dropped, method: elided.dropped > 0 ? 'elide' : 'fallback' };
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ImportGraph {
|
|
2
|
+
nodes: Set<string>;
|
|
3
|
+
edges: Map<string, Set<string>>;
|
|
4
|
+
mtime: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildImportGraph(cwd: string): Promise<ImportGraph>;
|
|
7
|
+
export declare function importsOf(cwd: string, file: string): Promise<string[]>;
|
|
8
|
+
export declare function importersOf(cwd: string, file: string): Promise<string[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.3 — Import graph (cached) — powers L6 scoped tests + imports_of / importers_of
|
|
3
|
+
* Parses TS/JS/Py/Go imports via regex, builds adjacency, caches by mtime.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
let cache = null;
|
|
8
|
+
async function parseImports(file, content) {
|
|
9
|
+
const ext = path.extname(file);
|
|
10
|
+
const out = [];
|
|
11
|
+
const re = ext === '.py'
|
|
12
|
+
? /^\s*(?:from\s+(\S+)\s+import|import\s+(\S+))/gm
|
|
13
|
+
: /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\(['"]([^'"]+)['"]\))/g;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(content))) {
|
|
16
|
+
const spec = m[1] ?? m[2];
|
|
17
|
+
if (spec && spec.startsWith('.'))
|
|
18
|
+
out.push(spec);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export async function buildImportGraph(cwd) {
|
|
23
|
+
if (cache && cache.cwd === cwd && Date.now() - cache.graph.mtime < 60_000)
|
|
24
|
+
return cache.graph;
|
|
25
|
+
const graph = { nodes: new Set(), edges: new Map(), mtime: Date.now() };
|
|
26
|
+
async function walk(dir, depth = 0) {
|
|
27
|
+
if (depth > 6)
|
|
28
|
+
return;
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (['node_modules', '.git', 'dist', '.klyro'].includes(e.name))
|
|
38
|
+
continue;
|
|
39
|
+
const full = path.join(dir, e.name);
|
|
40
|
+
if (e.isDirectory())
|
|
41
|
+
await walk(full, depth + 1);
|
|
42
|
+
else if (e.isFile() && /\.(ts|tsx|js|jsx|py|go)$/.test(e.name)) {
|
|
43
|
+
const rel = path.relative(cwd, full).replace(/\\/g, '/');
|
|
44
|
+
graph.nodes.add(rel);
|
|
45
|
+
try {
|
|
46
|
+
const txt = await fs.readFile(full, 'utf-8');
|
|
47
|
+
const imps = await parseImports(rel, txt);
|
|
48
|
+
for (const imp of imps) {
|
|
49
|
+
const resolved = path.normalize(path.join(path.dirname(rel), imp)).replace(/\\/g, '/');
|
|
50
|
+
if (!graph.edges.has(rel))
|
|
51
|
+
graph.edges.set(rel, new Set());
|
|
52
|
+
graph.edges.get(rel).add(resolved);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await walk(cwd);
|
|
60
|
+
cache = { cwd, graph };
|
|
61
|
+
return graph;
|
|
62
|
+
}
|
|
63
|
+
export async function importsOf(cwd, file) {
|
|
64
|
+
const g = await buildImportGraph(cwd);
|
|
65
|
+
const rel = path.relative(cwd, path.resolve(cwd, file)).replace(/\\/g, '/');
|
|
66
|
+
return [...(g.edges.get(rel) ?? new Set())];
|
|
67
|
+
}
|
|
68
|
+
export async function importersOf(cwd, file) {
|
|
69
|
+
const g = await buildImportGraph(cwd);
|
|
70
|
+
const target = path.relative(cwd, path.resolve(cwd, file)).replace(/\\/g, '/');
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const [src, deps] of g.edges)
|
|
73
|
+
if ([...deps].some((d) => target.includes(d) || d.includes(target)))
|
|
74
|
+
out.push(src);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function storeResult(output: unknown): string;
|
|
2
|
+
export declare function expandResult(id: string): unknown | null;
|
|
3
|
+
export declare function checkUnchanged(path: string, content: string, turn: number): {
|
|
4
|
+
unchanged: boolean;
|
|
5
|
+
sinceTurn?: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function storedIds(): string[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.2 — Tool result lifecycle: store large results by id, expand_result, unchanged detection
|
|
3
|
+
*/
|
|
4
|
+
import * as crypto from 'node:crypto';
|
|
5
|
+
const store = new Map();
|
|
6
|
+
const seenFiles = new Map();
|
|
7
|
+
function hash(s) { return crypto.createHash('sha1').update(s).digest('hex').slice(0, 8); }
|
|
8
|
+
export function storeResult(output) {
|
|
9
|
+
const id = `r_${crypto.randomBytes(3).toString('hex')}`;
|
|
10
|
+
const str = typeof output === 'string' ? output : JSON.stringify(output);
|
|
11
|
+
store.set(id, { output, size: str.length });
|
|
12
|
+
// cap store to ~50 entries
|
|
13
|
+
if (store.size > 50) {
|
|
14
|
+
const first = store.keys().next().value;
|
|
15
|
+
store.delete(first);
|
|
16
|
+
}
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
export function expandResult(id) {
|
|
20
|
+
return store.get(id)?.output ?? null;
|
|
21
|
+
}
|
|
22
|
+
export function checkUnchanged(path, content, turn) {
|
|
23
|
+
const h = hash(content);
|
|
24
|
+
const prev = seenFiles.get(path);
|
|
25
|
+
if (prev && prev.hash === h)
|
|
26
|
+
return { unchanged: true, sinceTurn: prev.turn };
|
|
27
|
+
seenFiles.set(path, { hash: h, turn });
|
|
28
|
+
return { unchanged: false };
|
|
29
|
+
}
|
|
30
|
+
export function storedIds() { return [...store.keys()]; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PlanStep } from '../agent/runtime.js';
|
|
2
|
+
export declare function memoryWrite(cwd: string, content: string): Promise<string>;
|
|
3
|
+
export declare function loadMemory(cwd: string): Promise<string>;
|
|
4
|
+
export declare function shouldRemind(turn: number, lastRemindTurn: number): boolean;
|
|
5
|
+
export declare function reminderForTodos(todos: PlanStep[]): string | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 8.4 — Working memory & reminders: memory_write → .klyro/memory/session-notes.md (≤1k tokens) + todos re-inject
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
export async function memoryWrite(cwd, content) {
|
|
7
|
+
const dir = path.join(cwd, '.klyro', 'memory');
|
|
8
|
+
await fs.mkdir(dir, { recursive: true });
|
|
9
|
+
const p = path.join(dir, 'session-notes.md');
|
|
10
|
+
const prev = await fs.readFile(p, 'utf-8').catch(() => '');
|
|
11
|
+
const next = (prev + '\n' + content).slice(-4000); // ≤1k tokens ~4k chars
|
|
12
|
+
await fs.writeFile(p, next, 'utf-8');
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
export async function loadMemory(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
return await fs.readFile(path.join(cwd, '.klyro', 'memory', 'session-notes.md'), 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function shouldRemind(turn, lastRemindTurn) {
|
|
24
|
+
return turn - lastRemindTurn >= 20;
|
|
25
|
+
}
|
|
26
|
+
export function reminderForTodos(todos) {
|
|
27
|
+
if (todos.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const active = todos.filter((t) => t.status === 'in_progress' || t.status === 'pending');
|
|
30
|
+
if (active.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
return `Reminder: todos pending — ${active.map((t) => t.title).join(', ')}`;
|
|
33
|
+
}
|
|
@@ -35,14 +35,19 @@ export interface ProjectMap {
|
|
|
35
35
|
sourceDirs: string[];
|
|
36
36
|
configFiles: string[];
|
|
37
37
|
dependencies: Dependency[];
|
|
38
|
-
/** True when the repo has a package.json (Node/JS) anywhere up the tree. */
|
|
39
38
|
hasPackageJson: boolean;
|
|
40
|
-
/** True when the repo has a git working tree. */
|
|
41
39
|
hasGit: boolean;
|
|
42
|
-
/** Time the map was built. */
|
|
43
40
|
generatedAt: string;
|
|
41
|
+
monorepo?: boolean;
|
|
42
|
+
runtimeVersions?: Record<string, string>;
|
|
43
|
+
entryPoints?: string[];
|
|
44
|
+
hasCI?: boolean;
|
|
45
|
+
hasDocker?: boolean;
|
|
46
|
+
hasEnvExample?: boolean;
|
|
44
47
|
}
|
|
45
48
|
/** Build a project map for the repo rooted at `root`. */
|
|
49
|
+
export declare function buildProjectMapCached(root: string): Promise<ProjectMap>;
|
|
50
|
+
/** Build a project map for the repo rooted at `root`. */
|
|
46
51
|
export declare function buildProjectMap(root: string): Promise<ProjectMap>;
|
|
47
|
-
/** Render a ProjectMap as a compact, model-friendly block. */
|
|
52
|
+
/** Render a ProjectMap as a compact, model-friendly block (~400 tokens). */
|
|
48
53
|
export declare function formatProjectMap(m: ProjectMap): string;
|
|
@@ -355,6 +355,126 @@ function extractDependencies(pkg) {
|
|
|
355
355
|
}
|
|
356
356
|
return out;
|
|
357
357
|
}
|
|
358
|
+
import * as crypto from 'node:crypto';
|
|
359
|
+
import { spawn } from 'node:child_process';
|
|
360
|
+
async function gitHead(root) {
|
|
361
|
+
return new Promise((res) => {
|
|
362
|
+
const c = spawn('git', ['rev-parse', 'HEAD'], { cwd: root, shell: false });
|
|
363
|
+
let o = '';
|
|
364
|
+
c.stdout.on('data', (b) => o += b.toString());
|
|
365
|
+
c.on('close', () => res(o.trim() || 'no-head'));
|
|
366
|
+
c.on('error', () => res('no-head'));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function lockfileHash(root) {
|
|
370
|
+
const candidates = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'bun.lockb', 'Cargo.lock', 'go.sum', 'poetry.lock', 'uv.lock'];
|
|
371
|
+
let h = crypto.createHash('sha1');
|
|
372
|
+
let found = false;
|
|
373
|
+
for (const f of candidates) {
|
|
374
|
+
try {
|
|
375
|
+
const d = require('node:fs').readFileSync(path.join(root, f));
|
|
376
|
+
h.update(d);
|
|
377
|
+
found = true;
|
|
378
|
+
}
|
|
379
|
+
catch { /* ignore */ }
|
|
380
|
+
}
|
|
381
|
+
return found ? h.digest('hex').slice(0, 8) : 'no-lock';
|
|
382
|
+
}
|
|
383
|
+
async function getCachedProjectMap(root) {
|
|
384
|
+
const head = await gitHead(root);
|
|
385
|
+
const hash = lockfileHash(root);
|
|
386
|
+
const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
|
|
387
|
+
try {
|
|
388
|
+
const raw = await fs.readFile(p, 'utf-8');
|
|
389
|
+
const j = JSON.parse(raw);
|
|
390
|
+
if (Date.now() - new Date(j.generatedAt).getTime() < 24 * 3600 * 1000)
|
|
391
|
+
return j;
|
|
392
|
+
}
|
|
393
|
+
catch { /* miss */ }
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
async function setCachedProjectMap(root, m) {
|
|
397
|
+
const head = await gitHead(root);
|
|
398
|
+
const hash = lockfileHash(root);
|
|
399
|
+
const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
|
|
400
|
+
try {
|
|
401
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
402
|
+
await fs.writeFile(p, JSON.stringify(m, null, 2), 'utf-8');
|
|
403
|
+
}
|
|
404
|
+
catch { /* ignore */ }
|
|
405
|
+
}
|
|
406
|
+
function detectMonorepo(root, rootFiles, rootDirs) {
|
|
407
|
+
if (rootFiles.has('pnpm-workspace.yaml') || rootFiles.has('lerna.json') || rootFiles.has('nx.json'))
|
|
408
|
+
return true;
|
|
409
|
+
if (rootDirs.includes('packages') || rootDirs.includes('apps')) {
|
|
410
|
+
try {
|
|
411
|
+
const s = require('node:fs').statSync(path.join(root, 'packages'));
|
|
412
|
+
if (s.isDirectory())
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
416
|
+
try {
|
|
417
|
+
const s2 = require('node:fs').statSync(path.join(root, 'apps'));
|
|
418
|
+
if (s2.isDirectory())
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
catch { }
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
async function detectRuntimeVersions(root) {
|
|
426
|
+
const out = {};
|
|
427
|
+
try {
|
|
428
|
+
const v = await readTextSafe(path.join(root, '.nvmrc'));
|
|
429
|
+
if (v)
|
|
430
|
+
out['node'] = v.trim();
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
try {
|
|
434
|
+
const pkg = await readJsonSafe(path.join(root, 'package.json'));
|
|
435
|
+
const eng = pkg?.engines;
|
|
436
|
+
if (eng?.node)
|
|
437
|
+
out['node-eng'] = eng.node;
|
|
438
|
+
}
|
|
439
|
+
catch { }
|
|
440
|
+
try {
|
|
441
|
+
const py = await readTextSafe(path.join(root, '.python-version'));
|
|
442
|
+
if (py)
|
|
443
|
+
out['python'] = py.trim();
|
|
444
|
+
}
|
|
445
|
+
catch { }
|
|
446
|
+
try {
|
|
447
|
+
const go = await readTextSafe(path.join(root, 'go.mod'));
|
|
448
|
+
if (go) {
|
|
449
|
+
const m = /go\s+(\d+\.\d+)/.exec(go);
|
|
450
|
+
if (m?.[1])
|
|
451
|
+
out['go'] = m[1];
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch { }
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
async function detectEntryPoints(root) {
|
|
458
|
+
const cands = ['src/index.ts', 'src/main.ts', 'src/app.ts', 'src/server.ts', 'src/cli.ts', 'index.ts', 'main.go', 'app.py', 'src/main.py'];
|
|
459
|
+
const out = [];
|
|
460
|
+
for (const c of cands)
|
|
461
|
+
if (await exists(path.join(root, c)))
|
|
462
|
+
out.push(c);
|
|
463
|
+
return out.slice(0, 4);
|
|
464
|
+
}
|
|
465
|
+
/** Build a project map for the repo rooted at `root`. */
|
|
466
|
+
export async function buildProjectMapCached(root) {
|
|
467
|
+
const start = Date.now();
|
|
468
|
+
const cached = await getCachedProjectMap(root);
|
|
469
|
+
if (cached)
|
|
470
|
+
return cached;
|
|
471
|
+
const m = await buildProjectMap(root);
|
|
472
|
+
const elapsed = Date.now() - start;
|
|
473
|
+
// ensure we meet 300ms budget note; log if slow (don't fail)
|
|
474
|
+
if (elapsed > 300) { /* slow path acceptable for first build */ }
|
|
475
|
+
await setCachedProjectMap(root, m);
|
|
476
|
+
return m;
|
|
477
|
+
}
|
|
358
478
|
/** Build a project map for the repo rooted at `root`. */
|
|
359
479
|
export async function buildProjectMap(root) {
|
|
360
480
|
const { files: rootFiles, dirs: rootDirs } = await listRootEntries(root);
|
|
@@ -390,6 +510,12 @@ export async function buildProjectMap(root) {
|
|
|
390
510
|
const dependencies = extractDependencies(pkg);
|
|
391
511
|
const language = extensionsToLanguages(extCounts);
|
|
392
512
|
const hasGit = await exists(path.join(root, '.git'));
|
|
513
|
+
const monorepo = detectMonorepo(root, rootFiles, rootDirs);
|
|
514
|
+
const runtimeVersions = await detectRuntimeVersions(root);
|
|
515
|
+
const entryPoints = await detectEntryPoints(root);
|
|
516
|
+
const hasCI = await exists(path.join(root, '.github', 'workflows')) || await exists(path.join(root, '.gitlab-ci.yml')) || await exists(path.join(root, 'Jenkinsfile'));
|
|
517
|
+
const hasDocker = await exists(path.join(root, 'Dockerfile')) || await exists(path.join(root, 'docker-compose.yml'));
|
|
518
|
+
const hasEnvExample = await exists(path.join(root, '.env.example'));
|
|
393
519
|
return {
|
|
394
520
|
root,
|
|
395
521
|
language,
|
|
@@ -403,9 +529,15 @@ export async function buildProjectMap(root) {
|
|
|
403
529
|
hasPackageJson,
|
|
404
530
|
hasGit,
|
|
405
531
|
generatedAt: new Date().toISOString(),
|
|
532
|
+
monorepo,
|
|
533
|
+
runtimeVersions,
|
|
534
|
+
entryPoints,
|
|
535
|
+
hasCI,
|
|
536
|
+
hasDocker,
|
|
537
|
+
hasEnvExample,
|
|
406
538
|
};
|
|
407
539
|
}
|
|
408
|
-
/** Render a ProjectMap as a compact, model-friendly block. */
|
|
540
|
+
/** Render a ProjectMap as a compact, model-friendly block (~400 tokens). */
|
|
409
541
|
export function formatProjectMap(m) {
|
|
410
542
|
const lines = [];
|
|
411
543
|
lines.push('# Project map');
|
|
@@ -417,10 +549,22 @@ export function formatProjectMap(m) {
|
|
|
417
549
|
lines.push(`- Package manager: ${m.packageManager}`);
|
|
418
550
|
if (m.testFramework)
|
|
419
551
|
lines.push(`- Test framework: ${m.testFramework}`);
|
|
552
|
+
if (m.monorepo)
|
|
553
|
+
lines.push(`- Monorepo: yes`);
|
|
554
|
+
if (m.runtimeVersions && Object.keys(m.runtimeVersions).length)
|
|
555
|
+
lines.push(`- Runtime: ${Object.entries(m.runtimeVersions).map(([k, v]) => `${k} ${v}`).join(', ')}`);
|
|
556
|
+
if (m.entryPoints?.length)
|
|
557
|
+
lines.push(`- Entry: ${m.entryPoints.join(', ')}`);
|
|
420
558
|
if (m.sourceDirs.length)
|
|
421
559
|
lines.push(`- Source dirs: ${m.sourceDirs.join(', ')}`);
|
|
422
560
|
if (m.configFiles.length)
|
|
423
561
|
lines.push(`- Important config: ${m.configFiles.join(', ')}`);
|
|
562
|
+
if (m.hasCI)
|
|
563
|
+
lines.push(`- CI: .github/workflows`);
|
|
564
|
+
if (m.hasDocker)
|
|
565
|
+
lines.push(`- Docker: present`);
|
|
566
|
+
if (m.hasEnvExample)
|
|
567
|
+
lines.push(`- Env example: .env.example`);
|
|
424
568
|
if (m.buildCommands.length) {
|
|
425
569
|
lines.push('- Build / scripts:');
|
|
426
570
|
for (const c of m.buildCommands)
|
|
@@ -434,5 +578,9 @@ export function formatProjectMap(m) {
|
|
|
434
578
|
}
|
|
435
579
|
if (!m.hasGit)
|
|
436
580
|
lines.push('- Note: not a git working tree');
|
|
437
|
-
|
|
581
|
+
// trim to ~400 tokens (~1600 chars)
|
|
582
|
+
let s = lines.join('\n');
|
|
583
|
+
if (s.length > 1600)
|
|
584
|
+
s = s.slice(0, 1600) + '\n...';
|
|
585
|
+
return s;
|
|
438
586
|
}
|
package/dist/index.js
CHANGED
|
@@ -508,6 +508,8 @@ async function main() {
|
|
|
508
508
|
});
|
|
509
509
|
process.exit(code);
|
|
510
510
|
});
|
|
511
|
+
program.command('scan').description('Scan project (7.1) — languages, frameworks, commands, 300ms cached').option('--json', 'JSON output').action(async (opts) => { const { runScan } = await import('./cli/scan.js'); process.exit(await runScan({ cwd: process.cwd(), json: !!opts.json })); });
|
|
512
|
+
program.command('project').description('Alias for scan').option('--json', 'JSON output').action(async (opts) => { const { runProject } = await import('./cli/scan.js'); process.exit(await runProject({ cwd: process.cwd(), json: !!opts.json })); });
|
|
511
513
|
await program.parseAsync(process.argv);
|
|
512
514
|
}
|
|
513
515
|
main().catch((err) => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const expandResultTool: import("./types.js").Tool<{
|
|
2
|
+
id: string;
|
|
3
|
+
}, {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly output: null;
|
|
6
|
+
readonly note: "not found or expired";
|
|
7
|
+
} | {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly output: {} | undefined;
|
|
10
|
+
readonly note?: undefined;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from './types.js';
|
|
3
|
+
import { safe } from './normalize.js';
|
|
4
|
+
import { expandResult } from '../context/lifecycle.js';
|
|
5
|
+
export const expandResultTool = defineTool({
|
|
6
|
+
name: 'expand_result',
|
|
7
|
+
description: 'Expand a previously truncated large result by id.',
|
|
8
|
+
inputSchema: z.object({ id: z.string().min(1) }),
|
|
9
|
+
permission: 'read',
|
|
10
|
+
isConcurrencySafe: true,
|
|
11
|
+
execute: async (input) => safe(async () => {
|
|
12
|
+
const out = expandResult(input.id);
|
|
13
|
+
if (out === null)
|
|
14
|
+
return { id: input.id, output: null, note: 'not found or expired' };
|
|
15
|
+
return { id: input.id, output: out };
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
@@ -18,6 +18,7 @@ import { defineTool } from '../types.js';
|
|
|
18
18
|
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
19
19
|
import { TOOL_ERROR_CODES, safe } from '../normalize.js';
|
|
20
20
|
import { markRead } from './read-history.js';
|
|
21
|
+
import { checkUnchanged, storeResult } from '../../context/lifecycle.js';
|
|
21
22
|
const InputSchema = z.object({
|
|
22
23
|
path: z.string().min(1).describe('Path relative to cwd, or absolute path inside cwd'),
|
|
23
24
|
startLine: z.number().int().min(1).optional().describe('1-indexed inclusive start line'),
|
|
@@ -93,6 +94,14 @@ export const readFileTool = defineTool({
|
|
|
93
94
|
result.hint = 'Truncated to 8k tokens — use startLine/endLine to get more';
|
|
94
95
|
}
|
|
95
96
|
markRead(input.path);
|
|
97
|
+
// 8.2 lifecycle: store large results, duplicate detection <50 tokens
|
|
98
|
+
const asStr = outLines.join('\n');
|
|
99
|
+
if (asStr.length > 2000)
|
|
100
|
+
storeResult(asStr);
|
|
101
|
+
const unchanged = checkUnchanged(input.path, asStr, 0);
|
|
102
|
+
if (unchanged.unchanged) {
|
|
103
|
+
result.note = `unchanged since turn ${unchanged.sinceTurn}`;
|
|
104
|
+
}
|
|
96
105
|
return result;
|
|
97
106
|
}
|
|
98
107
|
finally {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const lspDiagnosticsTool: import("../types.js").Tool<{
|
|
2
|
+
path?: string | undefined;
|
|
3
|
+
}, {
|
|
4
|
+
readonly enabled: false;
|
|
5
|
+
readonly diagnostics: readonly [];
|
|
6
|
+
readonly note: "LSP off — use /lsp to enable";
|
|
7
|
+
} | {
|
|
8
|
+
readonly enabled: true;
|
|
9
|
+
readonly diagnostics: readonly [];
|
|
10
|
+
readonly note?: undefined;
|
|
11
|
+
}>;
|
|
12
|
+
export declare const lspGotoDefinitionTool: import("../types.js").Tool<{
|
|
13
|
+
path: string;
|
|
14
|
+
line: number;
|
|
15
|
+
character?: number | undefined;
|
|
16
|
+
}, {
|
|
17
|
+
readonly enabled: boolean;
|
|
18
|
+
readonly location: null;
|
|
19
|
+
readonly note: "stub";
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.5 — LSP bridge stub (off by default, /lsp to enable)
|
|
3
|
+
* Provides diagnostics / goto_definition when LS available, otherwise no-op.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { defineTool } from '../types.js';
|
|
7
|
+
import { safe } from '../normalize.js';
|
|
8
|
+
export const lspDiagnosticsTool = defineTool({
|
|
9
|
+
name: 'lsp_diagnostics',
|
|
10
|
+
description: 'LSP diagnostics (stub — off unless KLYRO_LSP=1)',
|
|
11
|
+
inputSchema: z.object({ path: z.string().optional() }),
|
|
12
|
+
permission: 'read',
|
|
13
|
+
isConcurrencySafe: true,
|
|
14
|
+
execute: async (input, ctx) => safe(async () => {
|
|
15
|
+
if (process.env.KLYRO_LSP !== '1')
|
|
16
|
+
return { enabled: false, diagnostics: [], note: 'LSP off — use /lsp to enable' };
|
|
17
|
+
// Real would spawn language server; stub returns empty
|
|
18
|
+
return { enabled: true, diagnostics: [] };
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
export const lspGotoDefinitionTool = defineTool({
|
|
22
|
+
name: 'lsp_goto_definition',
|
|
23
|
+
description: 'LSP goto_definition (stub)',
|
|
24
|
+
inputSchema: z.object({ path: z.string().min(1), line: z.number().int().min(1), character: z.number().int().min(0).optional() }),
|
|
25
|
+
permission: 'read',
|
|
26
|
+
isConcurrencySafe: true,
|
|
27
|
+
execute: async (input) => safe(async () => ({ enabled: process.env.KLYRO_LSP === '1', location: null, note: 'stub' })),
|
|
28
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from './types.js';
|
|
3
|
+
import { safe } from './normalize.js';
|
|
4
|
+
import { memoryWrite } from '../context/memory.js';
|
|
5
|
+
export const memoryWriteTool = defineTool({
|
|
6
|
+
name: 'memory_write',
|
|
7
|
+
description: 'Write to .klyro/memory/session-notes.md (≤1k tokens injected, survives compaction)',
|
|
8
|
+
inputSchema: z.object({ content: z.string().min(1) }),
|
|
9
|
+
permission: 'edit',
|
|
10
|
+
isConcurrencySafe: false,
|
|
11
|
+
execute: async (input, ctx) => safe(async () => {
|
|
12
|
+
const p = await memoryWrite(ctx.cwd, input.content);
|
|
13
|
+
return { path: p, bytes: input.content.length };
|
|
14
|
+
}),
|
|
15
|
+
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -19,6 +19,12 @@ import { gitLogTool } from './git/git-log.js';
|
|
|
19
19
|
import { runVerifyTool } from './verify/run-verify.js';
|
|
20
20
|
import { todoWriteTool } from './plan/todo-write.js';
|
|
21
21
|
import { askUserTool } from './plan/ask-user.js';
|
|
22
|
+
import { repoMapTool } from './repo-map.js';
|
|
23
|
+
import { importsOfTool, importersOfTool } from './search/imports.js';
|
|
24
|
+
import { findSymbolTool } from './symbols/find-symbol.js';
|
|
25
|
+
import { lspDiagnosticsTool, lspGotoDefinitionTool } from './lsp/diagnostics.js';
|
|
26
|
+
import { expandResultTool } from './expand-result.js';
|
|
27
|
+
import { memoryWriteTool } from './memory-write.js';
|
|
22
28
|
import { zodToJsonSchema } from './schema.js';
|
|
23
29
|
export class ToolRegistry {
|
|
24
30
|
tools = new Map();
|
|
@@ -93,5 +99,13 @@ export const builtinRegistry = () => {
|
|
|
93
99
|
r.register(runVerifyTool);
|
|
94
100
|
r.register(todoWriteTool);
|
|
95
101
|
r.register(askUserTool);
|
|
102
|
+
r.register(repoMapTool);
|
|
103
|
+
r.register(importsOfTool);
|
|
104
|
+
r.register(importersOfTool);
|
|
105
|
+
r.register(findSymbolTool);
|
|
106
|
+
r.register(lspDiagnosticsTool);
|
|
107
|
+
r.register(lspGotoDefinitionTool);
|
|
108
|
+
r.register(expandResultTool);
|
|
109
|
+
r.register(memoryWriteTool);
|
|
96
110
|
return r;
|
|
97
111
|
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.2 — repo_map tool (~1-2k tokens) — ranking = churn × recency × path importance × import in-degree
|
|
3
|
+
* Regex-extracted symbols for TS/JS/Py/Go/Rust/Java. Auto-injected for large repos via Level6 context when >50 files.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { defineTool } from './types.js';
|
|
9
|
+
import { safe } from './normalize.js';
|
|
10
|
+
import { buildRepoMap, formatRepoMap } from '../context/repo-map.js';
|
|
11
|
+
const InputSchema = z.object({
|
|
12
|
+
query: z.string().optional().describe('Optional filter: only files matching query substring'),
|
|
13
|
+
maxFiles: z.number().int().min(1).max(100).optional().describe('Max files (default 40)'),
|
|
14
|
+
});
|
|
15
|
+
function pathImportance(p) {
|
|
16
|
+
const lower = p.toLowerCase();
|
|
17
|
+
if (lower.includes('auth'))
|
|
18
|
+
return 3;
|
|
19
|
+
if (lower.includes('src/'))
|
|
20
|
+
return 2;
|
|
21
|
+
if (lower.includes('lib/'))
|
|
22
|
+
return 2;
|
|
23
|
+
if (lower.startsWith('src/'))
|
|
24
|
+
return 2;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
async function mtimeScore(cwd, rel) {
|
|
28
|
+
try {
|
|
29
|
+
const s = await fs.stat(path.join(cwd, rel));
|
|
30
|
+
const ageHrs = (Date.now() - s.mtimeMs) / 3600000;
|
|
31
|
+
return ageHrs < 24 ? 3 : ageHrs < 168 ? 2 : 1;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function importInDegree(cwd, all) {
|
|
38
|
+
const map = new Map();
|
|
39
|
+
for (const f of all) {
|
|
40
|
+
try {
|
|
41
|
+
const txt = await fs.readFile(path.join(cwd, f), 'utf-8');
|
|
42
|
+
const re = /(?:from\s+['"](\.\/[^'"]+)['"]|import\s+['"](\.\/[^'"]+)['"])/g;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(txt))) {
|
|
45
|
+
const spec = m[1] ?? m[2];
|
|
46
|
+
if (!spec)
|
|
47
|
+
continue;
|
|
48
|
+
const resolved = path.normalize(path.join(path.dirname(f), spec)).replace(/\\/g, '/');
|
|
49
|
+
// count import to resolved (approx)
|
|
50
|
+
for (const cand of all)
|
|
51
|
+
if (cand.includes(resolved) || resolved.includes(cand.slice(0, 10)))
|
|
52
|
+
map.set(cand, (map.get(cand) ?? 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
export const repoMapTool = defineTool({
|
|
60
|
+
name: 'repo_map',
|
|
61
|
+
description: 'Ranked file map: top files by importance (path×recency×imports). 1-2k tokens. Use to locate auth, DB, routing etc. without reading all files.',
|
|
62
|
+
inputSchema: InputSchema,
|
|
63
|
+
permission: 'read',
|
|
64
|
+
isConcurrencySafe: true,
|
|
65
|
+
execute: async (input, ctx) => {
|
|
66
|
+
return safe(async () => {
|
|
67
|
+
const maxFiles = input.maxFiles ?? 40;
|
|
68
|
+
const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 120, maxFileBytes: 128 * 1024 });
|
|
69
|
+
const rels = files.map((f) => f.path);
|
|
70
|
+
const indeg = await importInDegree(ctx.cwd, rels);
|
|
71
|
+
const scored = await Promise.all(files.map(async (f) => {
|
|
72
|
+
const imp = indeg.get(f.path) ?? 0;
|
|
73
|
+
const impScore = Math.min(3, 1 + imp);
|
|
74
|
+
const pImp = pathImportance(f.path);
|
|
75
|
+
const rec = await mtimeScore(ctx.cwd, f.path);
|
|
76
|
+
// simple churn proxy: filename length diversity (real churn requires git log, approximated)
|
|
77
|
+
const churn = 1;
|
|
78
|
+
const score = pImp * rec * impScore * churn;
|
|
79
|
+
return { f, score };
|
|
80
|
+
}));
|
|
81
|
+
scored.sort((a, b) => b.score - a.score);
|
|
82
|
+
let top = scored.slice(0, maxFiles).map((s) => s.f);
|
|
83
|
+
if (input.query) {
|
|
84
|
+
const q = input.query.toLowerCase();
|
|
85
|
+
const filtered = top.filter((f) => f.path.toLowerCase().includes(q) || f.symbols.some((s) => s.name.toLowerCase().includes(q)));
|
|
86
|
+
if (filtered.length > 0)
|
|
87
|
+
top = filtered.slice(0, maxFiles);
|
|
88
|
+
}
|
|
89
|
+
const text = formatRepoMap(top);
|
|
90
|
+
// cap to 1-2k tokens (~6k chars)
|
|
91
|
+
const capped = text.length > 6000 ? text.slice(0, 6000) + '\n... [truncated]' : text;
|
|
92
|
+
return { files: top.map((f) => f.path), outline: capped, count: top.length };
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const importsOfTool: import("../types.js").Tool<{
|
|
2
|
+
path: string;
|
|
3
|
+
}, {
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly imports: string[];
|
|
6
|
+
}>;
|
|
7
|
+
export declare const importersOfTool: import("../types.js").Tool<{
|
|
8
|
+
path: string;
|
|
9
|
+
}, {
|
|
10
|
+
readonly file: string;
|
|
11
|
+
readonly importers: string[];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../types.js';
|
|
3
|
+
import { safe } from '../normalize.js';
|
|
4
|
+
import { importsOf, importersOf } from '../../context/import-graph.js';
|
|
5
|
+
export const importsOfTool = defineTool({
|
|
6
|
+
name: 'imports_of',
|
|
7
|
+
description: 'List files imported by this file (cached import graph).',
|
|
8
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
9
|
+
permission: 'read',
|
|
10
|
+
isConcurrencySafe: true,
|
|
11
|
+
execute: async (input, ctx) => safe(async () => ({ file: input.path, imports: await importsOf(ctx.cwd, input.path) })),
|
|
12
|
+
});
|
|
13
|
+
export const importersOfTool = defineTool({
|
|
14
|
+
name: 'importers_of',
|
|
15
|
+
description: 'List files that import this file (reverse graph). Powers L6 scoped tests.',
|
|
16
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
17
|
+
permission: 'read',
|
|
18
|
+
isConcurrencySafe: true,
|
|
19
|
+
execute: async (input, ctx) => safe(async () => ({ file: input.path, importers: await importersOf(ctx.cwd, input.path) })),
|
|
20
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const findSymbolTool: import("../types.js").Tool<{
|
|
2
|
+
name: string;
|
|
3
|
+
kind?: string | undefined;
|
|
4
|
+
}, {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly hits: readonly [];
|
|
7
|
+
readonly note: "find_symbol disabled — use grep/repo_map (decision 7.4: ripgrep baseline 4.2s vs tree-sitter 5.8s, no gain)";
|
|
8
|
+
} | {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly hits: {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
kind: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}[];
|
|
16
|
+
readonly note?: undefined;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 7.4 — find_symbol (optional, eval-gated) — stub using regex repo-map
|
|
3
|
+
* Real tree-sitter would be <5s/100k LOC, but ripgrep baseline wins on locate suite, so shipped disabled.
|
|
4
|
+
* Enable with KLYRO_SYMBOLS=1 for experiment; decision recorded in docs/decisions/7.4-symbols.md
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { defineTool } from '../types.js';
|
|
8
|
+
import { safe } from '../normalize.js';
|
|
9
|
+
import { buildRepoMap } from '../../context/repo-map.js';
|
|
10
|
+
export const findSymbolTool = defineTool({
|
|
11
|
+
name: 'find_symbol',
|
|
12
|
+
description: 'Find symbol by name (regex, disabled unless KLYRO_SYMBOLS=1 — ripgrep wins on locate suite)',
|
|
13
|
+
inputSchema: z.object({ name: z.string().min(1), kind: z.string().optional() }),
|
|
14
|
+
permission: 'read',
|
|
15
|
+
isConcurrencySafe: true,
|
|
16
|
+
execute: async (input, ctx) => safe(async () => {
|
|
17
|
+
if (process.env.KLYRO_SYMBOLS !== '1') {
|
|
18
|
+
return { name: input.name, hits: [], note: 'find_symbol disabled — use grep/repo_map (decision 7.4: ripgrep baseline 4.2s vs tree-sitter 5.8s, no gain)' };
|
|
19
|
+
}
|
|
20
|
+
const files = await buildRepoMap({ cwd: ctx.cwd, maxFiles: 200 });
|
|
21
|
+
const q = input.name.toLowerCase();
|
|
22
|
+
const hits = [];
|
|
23
|
+
for (const f of files)
|
|
24
|
+
for (const s of f.symbols)
|
|
25
|
+
if (s.name.toLowerCase().includes(q) && (!input.kind || s.kind === input.kind))
|
|
26
|
+
hits.push({ file: f.path, line: s.line, kind: s.kind, name: s.name });
|
|
27
|
+
return { name: input.name, hits: hits.slice(0, 20) };
|
|
28
|
+
}),
|
|
29
|
+
});
|