jeo-code 0.7.30 → 0.7.32
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/CHANGELOG.md +46 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +3 -3
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +12 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/compaction.ts +6 -19
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +103 -18
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/memory-graph.ts +6 -1
- package/src/agent/memory.ts +73 -52
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +100 -5
- package/src/agent/ts-language-service.ts +173 -0
- package/src/ai/model-manager.ts +58 -26
- package/src/ai/sse.ts +22 -7
- package/src/ai/think-tags.ts +10 -1
- package/src/commands/deep-interview.ts +49 -1
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- package/src/commands/launch/tmux.ts +32 -0
- package/src/commands/launch.ts +227 -750
- package/src/tui/app.ts +32 -12
- package/src/tui/components/evolution.ts +23 -3
- package/src/tui/components/width.ts +11 -6
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsp_rename` tool — cross-file TypeScript/JavaScript symbol rename (gjc `lsp`
|
|
3
|
+
* `action:"rename"` parity), using the same in-process `ts.LanguageService` as the
|
|
4
|
+
* read-only `lsp` tool. MUTATING: kept as its own tool (not an action on `lsp`) so
|
|
5
|
+
* the read-only-role toolset filter — which excludes tools by NAME — cannot be
|
|
6
|
+
* bypassed by an `apply:true` argument hidden inside a nominally "read-only" tool.
|
|
7
|
+
* Writes go through the existing `writeTool`, so the mutation lock and stale-read
|
|
8
|
+
* guard apply exactly as they do for `write`/`edit`/`ast_edit`.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type * as ts from "typescript";
|
|
13
|
+
import type { ToolHandler } from "./engine";
|
|
14
|
+
import type { ToolResult } from "./tools";
|
|
15
|
+
import { writeTool } from "./tools";
|
|
16
|
+
import { languageServiceFor, parseSymbolSelector, resolvePosition, lineTextAt, lineOfPosition, formatLocation } from "./ts-language-service";
|
|
17
|
+
|
|
18
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
19
|
+
export const LSP_RENAME_TOOL_PROTOCOL_LINE =
|
|
20
|
+
`lsp_rename {file, line, symbol?, new_name, apply?} — rename a TypeScript/JavaScript symbol across every ` +
|
|
21
|
+
`file in the project via the same in-process language service as 'lsp' (same file/line/symbol addressing). ` +
|
|
22
|
+
`'apply' defaults to true (writes files — mutation lock/stale-read checks apply, same as write/edit); ` +
|
|
23
|
+
`apply:false previews every location that would change without writing anything.`;
|
|
24
|
+
|
|
25
|
+
function err(message: string): ToolResult {
|
|
26
|
+
return { success: false, output: "", error: message };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
30
|
+
|
|
31
|
+
export function createLspRenameTool(): ToolHandler {
|
|
32
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
33
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
34
|
+
if (!fileArg) return err(`lsp_rename requires a non-empty "file".`);
|
|
35
|
+
|
|
36
|
+
const newName = typeof args.new_name === "string" ? args.new_name.trim()
|
|
37
|
+
: typeof args.newName === "string" ? args.newName.trim() : "";
|
|
38
|
+
if (!newName) return err(`lsp_rename requires a non-empty "new_name".`);
|
|
39
|
+
if (!IDENTIFIER_RE.test(newName)) return err(`"new_name" must be a valid identifier, got '${newName}'.`);
|
|
40
|
+
|
|
41
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
42
|
+
let text: string;
|
|
43
|
+
try {
|
|
44
|
+
text = await fs.readFile(absPath, "utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return err(`Cannot read file '${fileArg}'.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lineRaw = args.line;
|
|
50
|
+
const line = typeof lineRaw === "number" ? lineRaw : parseInt(String(lineRaw ?? ""), 10);
|
|
51
|
+
if (!Number.isFinite(line) || line < 1) return err(`lsp_rename requires a positive integer "line".`);
|
|
52
|
+
|
|
53
|
+
const symbol = parseSymbolSelector(typeof args.symbol === "string" ? args.symbol : undefined);
|
|
54
|
+
const pos = resolvePosition(text, line, symbol);
|
|
55
|
+
if ("error" in pos) return err(pos.error);
|
|
56
|
+
|
|
57
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
58
|
+
const renameInfo = service.getRenameInfo(absPath, pos.position, {});
|
|
59
|
+
if (!renameInfo.canRename) {
|
|
60
|
+
const reason = "localizedErrorMessage" in renameInfo ? renameInfo.localizedErrorMessage : "not a renameable symbol here";
|
|
61
|
+
return err(`Cannot rename here: ${reason}.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const locations = service.findRenameLocations(absPath, pos.position, false, false, {}) ?? [];
|
|
65
|
+
if (locations.length === 0) return { success: true, output: "No rename locations found." };
|
|
66
|
+
|
|
67
|
+
const apply = args.apply !== false; // default true
|
|
68
|
+
|
|
69
|
+
const byFile = new Map<string, ts.TextSpan[]>();
|
|
70
|
+
for (const loc of locations) {
|
|
71
|
+
const spans = byFile.get(loc.fileName) ?? [];
|
|
72
|
+
spans.push(loc.textSpan);
|
|
73
|
+
byFile.set(loc.fileName, spans);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const preview: string[] = [];
|
|
77
|
+
let totalEdits = 0;
|
|
78
|
+
for (const [fName, spans] of byFile) {
|
|
79
|
+
const rel = path.relative(cwd, fName) || fName;
|
|
80
|
+
let content: string;
|
|
81
|
+
try {
|
|
82
|
+
content = await fs.readFile(fName, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return err(`Cannot read file '${rel}' while applying rename.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const span of spans) {
|
|
88
|
+
totalEdits++;
|
|
89
|
+
const line1 = lineOfPosition(content, span.start);
|
|
90
|
+
preview.push(formatLocation(rel, line1, lineTextAt(content, line1)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (apply) {
|
|
94
|
+
let newText = content;
|
|
95
|
+
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
96
|
+
newText = newText.slice(0, span.start) + newName + newText.slice(span.start + span.length);
|
|
97
|
+
}
|
|
98
|
+
const res = await writeTool(rel, newText, cwd);
|
|
99
|
+
if (!res.success) return err(`${rel}: ${res.error ?? "write failed"}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const header = apply
|
|
104
|
+
? `Renamed ${totalEdits} occurrence(s) across ${byFile.size} file(s):`
|
|
105
|
+
: `Preview: ${totalEdits} occurrence(s) across ${byFile.size} file(s) would be renamed to '${newName}' (apply:false, nothing written):`;
|
|
106
|
+
return { success: true, output: `${header}\n${preview.join("\n")}` };
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsp` tool — read-only TypeScript/JavaScript language-service queries (gjc `lsp`
|
|
3
|
+
* parity, scoped to TS/JS/TSX/JSX via the in-process `ts.LanguageService` from
|
|
4
|
+
* `ts-language-service.ts` — NOT a real LSP client/server, NOT multi-language).
|
|
5
|
+
* Safe for every subagent role. Cross-file rename (mutating) is the separate
|
|
6
|
+
* `lsp_rename` tool so the read-only-role toolset filter (which gates by tool
|
|
7
|
+
* NAME, not by argument) cannot be bypassed by a `rename` action hidden in here.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import * as ts from "typescript";
|
|
12
|
+
import type { ToolHandler } from "./engine";
|
|
13
|
+
import type { ToolResult } from "./tools";
|
|
14
|
+
import { languageServiceFor, parseSymbolSelector, resolvePosition, lineTextAt, lineOfPosition, formatLocation } from "./ts-language-service";
|
|
15
|
+
import { resolveTargetFiles } from "./ast-file-scan";
|
|
16
|
+
|
|
17
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
18
|
+
export const LSP_TOOL_PROTOCOL_LINE =
|
|
19
|
+
`lsp {action:"definition"|"references"|"hover"|"symbols"|"diagnostics", file, line?, symbol?} — ` +
|
|
20
|
+
`TypeScript/JavaScript language-service queries (in-process ts.LanguageService, not a real LSP server). ` +
|
|
21
|
+
`'symbol' is a substring on 'line' used to resolve the column automatically (append "#N" to pick the Nth ` +
|
|
22
|
+
`occurrence). 'definition'/'references'/'hover' need file+line(+symbol); 'symbols' needs only file; ` +
|
|
23
|
+
`'diagnostics' takes file (a path, glob, or "*" for the whole project) and needs no line/symbol. Cross-file ` +
|
|
24
|
+
`rename is the separate 'lsp_rename' tool.`;
|
|
25
|
+
|
|
26
|
+
async function readAbs(absPath: string): Promise<string | null> {
|
|
27
|
+
try {
|
|
28
|
+
return await fs.readFile(absPath, "utf-8");
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function err(message: string): ToolResult {
|
|
35
|
+
return { success: false, output: "", error: message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ResolvedTarget {
|
|
39
|
+
absPath: string;
|
|
40
|
+
rel: string;
|
|
41
|
+
text: string;
|
|
42
|
+
position: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveTarget(args: Record<string, any>, cwd: string): Promise<ResolvedTarget | { error: string }> {
|
|
46
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
47
|
+
if (!fileArg) return { error: `lsp requires a non-empty "file".` };
|
|
48
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
49
|
+
const text = await readAbs(absPath);
|
|
50
|
+
if (text === null) return { error: `Cannot read file '${fileArg}'.` };
|
|
51
|
+
|
|
52
|
+
const lineRaw = args.line;
|
|
53
|
+
const line = typeof lineRaw === "number" ? lineRaw : parseInt(String(lineRaw ?? ""), 10);
|
|
54
|
+
if (!Number.isFinite(line) || line < 1) return { error: `lsp requires a positive integer "line".` };
|
|
55
|
+
|
|
56
|
+
const symbol = parseSymbolSelector(typeof args.symbol === "string" ? args.symbol : undefined);
|
|
57
|
+
const pos = resolvePosition(text, line, symbol);
|
|
58
|
+
if ("error" in pos) return { error: pos.error };
|
|
59
|
+
|
|
60
|
+
return { absPath, rel: path.relative(cwd, absPath) || absPath, text, position: pos.position };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_REFERENCES_FULL = 50;
|
|
64
|
+
const MAX_DIAGNOSTIC_FILES = 50;
|
|
65
|
+
|
|
66
|
+
export function createLspTool(): ToolHandler {
|
|
67
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
68
|
+
const action = String(args.action ?? "").trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (action === "definition") {
|
|
71
|
+
const t = await resolveTarget(args, cwd);
|
|
72
|
+
if ("error" in t) return err(t.error);
|
|
73
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
74
|
+
const result = service.getDefinitionAndBoundSpan(t.absPath, t.position);
|
|
75
|
+
const defs = result?.definitions ?? [];
|
|
76
|
+
if (defs.length === 0) return { success: true, output: "No definition found." };
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
for (const d of defs) {
|
|
79
|
+
const dText = (await readAbs(d.fileName)) ?? "";
|
|
80
|
+
const dLine = lineOfPosition(dText, d.textSpan.start);
|
|
81
|
+
const rel = path.relative(cwd, d.fileName) || d.fileName;
|
|
82
|
+
lines.push(formatLocation(rel, dLine, lineTextAt(dText, dLine)));
|
|
83
|
+
}
|
|
84
|
+
return { success: true, output: lines.join("\n") };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (action === "references") {
|
|
88
|
+
const t = await resolveTarget(args, cwd);
|
|
89
|
+
if ("error" in t) return err(t.error);
|
|
90
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
91
|
+
const refs = service.getReferencesAtPosition(t.absPath, t.position) ?? [];
|
|
92
|
+
if (refs.length === 0) return { success: true, output: "No references found." };
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
for (let i = 0; i < refs.length; i++) {
|
|
95
|
+
const r = refs[i]!;
|
|
96
|
+
const rel = path.relative(cwd, r.fileName) || r.fileName;
|
|
97
|
+
const rText = (await readAbs(r.fileName)) ?? "";
|
|
98
|
+
const rLine = lineOfPosition(rText, r.textSpan.start);
|
|
99
|
+
lines.push(i < MAX_REFERENCES_FULL ? formatLocation(rel, rLine, lineTextAt(rText, rLine)) : `${rel}:${rLine}`);
|
|
100
|
+
}
|
|
101
|
+
return { success: true, output: `${refs.length} reference(s):\n${lines.join("\n")}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (action === "hover") {
|
|
105
|
+
const t = await resolveTarget(args, cwd);
|
|
106
|
+
if ("error" in t) return err(t.error);
|
|
107
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
108
|
+
const info = service.getQuickInfoAtPosition(t.absPath, t.position);
|
|
109
|
+
if (!info) return { success: true, output: "No hover info available at that position." };
|
|
110
|
+
const signature = (info.displayParts ?? []).map(p => p.text).join("");
|
|
111
|
+
const doc = (info.documentation ?? []).map(p => p.text).join("").trim();
|
|
112
|
+
return { success: true, output: doc ? `${signature}\n\n${doc}` : signature };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (action === "symbols") {
|
|
116
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
117
|
+
if (!fileArg) return err(`lsp {action:"symbols"} requires a non-empty "file".`);
|
|
118
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
119
|
+
const text = await readAbs(absPath);
|
|
120
|
+
if (text === null) return err(`Cannot read file '${fileArg}'.`);
|
|
121
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
122
|
+
const items = service.getNavigationBarItems(absPath);
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
const walk = (nodes: readonly ts.NavigationBarItem[], depth: number) => {
|
|
125
|
+
for (const n of nodes) {
|
|
126
|
+
const span = n.spans[0];
|
|
127
|
+
const line1 = span ? lineOfPosition(text, span.start) : undefined;
|
|
128
|
+
lines.push(`${" ".repeat(depth)}${n.text} (${n.kind})${line1 ? ` — line ${line1}` : ""}`);
|
|
129
|
+
if (n.childItems && n.childItems.length) walk(n.childItems, depth + 1);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(items, 0);
|
|
133
|
+
return { success: true, output: lines.length ? lines.join("\n") : "No symbols found." };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (action === "diagnostics") {
|
|
137
|
+
const fileArg = args.file;
|
|
138
|
+
const rawPaths = fileArg === "*"
|
|
139
|
+
? ["**/*"]
|
|
140
|
+
: Array.isArray(fileArg) ? fileArg.map((p: unknown) => String(p))
|
|
141
|
+
: typeof fileArg === "string" && fileArg.trim() ? [fileArg.trim()]
|
|
142
|
+
: [];
|
|
143
|
+
if (rawPaths.length === 0) {
|
|
144
|
+
return err(`lsp {action:"diagnostics"} requires a "file" (path, glob, or "*" for the whole project).`);
|
|
145
|
+
}
|
|
146
|
+
let files: string[];
|
|
147
|
+
try {
|
|
148
|
+
files = await resolveTargetFiles(rawPaths, cwd);
|
|
149
|
+
} catch (e: any) {
|
|
150
|
+
return err(e.message);
|
|
151
|
+
}
|
|
152
|
+
if (files.length === 0) return { success: true, output: "No matching TypeScript/JavaScript files." };
|
|
153
|
+
|
|
154
|
+
const scanned = files.slice(0, MAX_DIAGNOSTIC_FILES);
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
let count = 0;
|
|
157
|
+
for (const absPath of scanned) {
|
|
158
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
159
|
+
const text = (await readAbs(absPath)) ?? "";
|
|
160
|
+
const diags = [...service.getSyntacticDiagnostics(absPath), ...service.getSemanticDiagnostics(absPath)];
|
|
161
|
+
if (diags.length === 0) continue;
|
|
162
|
+
const rel = path.relative(cwd, absPath) || absPath;
|
|
163
|
+
for (const d of diags) {
|
|
164
|
+
count++;
|
|
165
|
+
const msg = ts.flattenDiagnosticMessageText(d.messageText, " ");
|
|
166
|
+
const line1 = d.start !== undefined ? lineOfPosition(text, d.start) : 0;
|
|
167
|
+
lines.push(`${rel}:${line1}: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (count === 0) return { success: true, output: `0 diagnostics across ${scanned.length} file(s).` };
|
|
171
|
+
const note = files.length > MAX_DIAGNOSTIC_FILES ? ` (scanned first ${MAX_DIAGNOSTIC_FILES} of ${files.length} files)` : "";
|
|
172
|
+
return { success: true, output: `${count} diagnostic(s)${note}:\n${lines.join("\n")}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return err(`Unknown lsp action '${action}'. Use definition | references | hover | symbols | diagnostics.`);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -39,6 +39,8 @@ export interface ConceptGraph {
|
|
|
39
39
|
edges: Map<string, Set<string>>;
|
|
40
40
|
/** from-ID → set of link targets that have NO node (tolerated broken links). */
|
|
41
41
|
broken: Map<string, Set<string>>;
|
|
42
|
+
/** ponytail: cached undirected adjacency for O(1) retrieval during graph expansion */
|
|
43
|
+
adj?: Map<string, Set<string>>;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** A markdown inline link `](target)` — captures the target, not the label. */
|
|
@@ -115,7 +117,10 @@ function undirectedAdjacency(graph: ConceptGraph): Map<string, Set<string>> {
|
|
|
115
117
|
* nodes are dropped. With `hops` 0 this is just the valid seeds.
|
|
116
118
|
*/
|
|
117
119
|
export function expandByGraph(seedIds: Iterable<string>, graph: ConceptGraph, hops = 1): Set<string> {
|
|
118
|
-
|
|
120
|
+
if (!graph.adj) {
|
|
121
|
+
graph.adj = undirectedAdjacency(graph);
|
|
122
|
+
}
|
|
123
|
+
const adj = graph.adj;
|
|
119
124
|
const result = new Set<string>();
|
|
120
125
|
for (const id of seedIds) if (graph.nodes.has(id)) result.add(id);
|
|
121
126
|
let frontier = [...result];
|
package/src/agent/memory.ts
CHANGED
|
@@ -251,57 +251,58 @@ export async function lintMemoryBundle(cwd: string): Promise<GraphLintReport> {
|
|
|
251
251
|
return lintConceptGraph(concepts, buildConceptGraph(concepts));
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
const CONCEPT_LOAD_BATCH = 16;
|
|
255
|
+
|
|
254
256
|
async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
|
|
255
257
|
const files = await findMarkdownFiles(bundleDir);
|
|
256
|
-
const
|
|
257
|
-
for (const file of files) {
|
|
258
|
+
const loadOne = async (file: string): Promise<Concept | null> => {
|
|
258
259
|
const relPath = path.relative(bundleDir, file).replace(/\\/g, "/");
|
|
259
|
-
if (isReservedFile(relPath))
|
|
260
|
+
if (isReservedFile(relPath)) return null;
|
|
260
261
|
|
|
261
|
-
// Skip the read+parse when the file is byte-for-byte unchanged since we last
|
|
262
|
-
// saw it (same mtime+size). A missing stat falls through to a fresh read.
|
|
263
262
|
let sig: string | null = null;
|
|
264
263
|
try {
|
|
265
264
|
const st = await fs.stat(file);
|
|
266
265
|
sig = `${st.mtimeMs}:${st.size}`;
|
|
267
266
|
} catch {
|
|
268
|
-
|
|
267
|
+
return null;
|
|
269
268
|
}
|
|
270
269
|
const cached = parsedConceptCache.get(file);
|
|
271
270
|
if (cached && cached.sig === sig) {
|
|
272
|
-
// LRU refresh so a hot file is evicted last.
|
|
273
271
|
parsedConceptCache.delete(file);
|
|
274
272
|
parsedConceptCache.set(file, cached);
|
|
275
|
-
|
|
276
|
-
continue;
|
|
273
|
+
return cached.concept;
|
|
277
274
|
}
|
|
278
275
|
|
|
279
|
-
let parsed;
|
|
280
276
|
try {
|
|
281
|
-
parsed = parseConcept(await fs.readFile(file, "utf-8"));
|
|
277
|
+
const parsed = parseConcept(await fs.readFile(file, "utf-8"));
|
|
278
|
+
if (!parsed.hasFrontmatter) {
|
|
279
|
+
cacheParsedConcept(file, sig, null);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const fm = parsed.frontmatter;
|
|
283
|
+
const concept: Concept = {
|
|
284
|
+
type: (fm.type as string) || "RepoFact",
|
|
285
|
+
title: (fm.title as string) || path.basename(file, ".md"),
|
|
286
|
+
description: (fm.description as string) || "",
|
|
287
|
+
body: parsed.body.trim(),
|
|
288
|
+
tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
|
|
289
|
+
confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
|
|
290
|
+
pinned: fm.pinned === true,
|
|
291
|
+
relPath,
|
|
292
|
+
};
|
|
293
|
+
cacheParsedConcept(file, sig, concept);
|
|
294
|
+
return concept;
|
|
282
295
|
} catch {
|
|
283
296
|
cacheParsedConcept(file, sig, null);
|
|
284
|
-
|
|
297
|
+
return null;
|
|
285
298
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
const concept: Concept = {
|
|
292
|
-
type: (fm.type as string) || "RepoFact",
|
|
293
|
-
title: (fm.title as string) || path.basename(file, ".md"),
|
|
294
|
-
description: (fm.description as string) || "",
|
|
295
|
-
body: parsed.body.trim(),
|
|
296
|
-
tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
|
|
297
|
-
confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
|
|
298
|
-
pinned: fm.pinned === true,
|
|
299
|
-
relPath,
|
|
300
|
-
};
|
|
301
|
-
cacheParsedConcept(file, sig, concept);
|
|
302
|
-
concepts.push(concept);
|
|
299
|
+
};
|
|
300
|
+
const results: Concept[] = [];
|
|
301
|
+
for (let i = 0; i < files.length; i += CONCEPT_LOAD_BATCH) {
|
|
302
|
+
const batch = await Promise.all(files.slice(i, i + CONCEPT_LOAD_BATCH).map(loadOne));
|
|
303
|
+
for (const c of batch) if (c) results.push(c);
|
|
303
304
|
}
|
|
304
|
-
return
|
|
305
|
+
return results;
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
/** Store a per-file parse result under its stat signature, evicting the oldest
|
|
@@ -329,12 +330,35 @@ export interface CorpusStats {
|
|
|
329
330
|
df: Map<string, number>;
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
interface LowercasedConceptFields {
|
|
334
|
+
title: string;
|
|
335
|
+
description: string;
|
|
336
|
+
body: string;
|
|
337
|
+
type: string;
|
|
338
|
+
tagsText: string;
|
|
339
|
+
searchableText: string;
|
|
340
|
+
}
|
|
341
|
+
const lowercasedFieldsCache = new WeakMap<Concept, LowercasedConceptFields>();
|
|
342
|
+
|
|
343
|
+
function getLowercasedFields(concept: Concept): LowercasedConceptFields {
|
|
344
|
+
let cached = lowercasedFieldsCache.get(concept);
|
|
345
|
+
if (!cached) {
|
|
346
|
+
const title = concept.title.toLowerCase();
|
|
347
|
+
const description = concept.description.toLowerCase();
|
|
348
|
+
const body = concept.body.toLowerCase();
|
|
349
|
+
const type = concept.type.toLowerCase();
|
|
350
|
+
const tagsText = concept.tags.map(t => t.toLowerCase()).join(" ");
|
|
351
|
+
const searchableText = [title, tagsText, type, description, body].join(" ");
|
|
352
|
+
cached = { title, description, body, type, tagsText, searchableText };
|
|
353
|
+
lowercasedFieldsCache.set(concept, cached);
|
|
354
|
+
}
|
|
355
|
+
return cached;
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
/** Lowercased searchable text of a concept (title + tags + type + description +
|
|
333
359
|
* body) — the field corpus over which document frequency is measured. */
|
|
334
360
|
function searchableText(concept: Concept): string {
|
|
335
|
-
return
|
|
336
|
-
.join(" ")
|
|
337
|
-
.toLowerCase();
|
|
361
|
+
return getLowercasedFields(concept).searchableText;
|
|
338
362
|
}
|
|
339
363
|
|
|
340
364
|
/** Build IDF corpus stats from the concept set: per distinct token, how many
|
|
@@ -372,19 +396,15 @@ function tokenIdf(token: string, stats: CorpusStats): number {
|
|
|
372
396
|
* field-weighted presence count (IDF = 1). 0 = no hit. */
|
|
373
397
|
export function scoreConcept(concept: Concept, tokens: string[], stats?: CorpusStats): number {
|
|
374
398
|
if (tokens.length === 0) return 0;
|
|
375
|
-
const
|
|
376
|
-
const desc = concept.description.toLowerCase();
|
|
377
|
-
const body = concept.body.toLowerCase();
|
|
378
|
-
const type = concept.type.toLowerCase();
|
|
379
|
-
const tags = concept.tags.map(t => t.toLowerCase());
|
|
399
|
+
const fields = getLowercasedFields(concept);
|
|
380
400
|
let score = 0;
|
|
381
401
|
for (const t of tokens) {
|
|
382
402
|
let weight = 0;
|
|
383
|
-
if (title.includes(t)) weight += 5;
|
|
384
|
-
if (
|
|
385
|
-
if (type.includes(t)) weight += 2;
|
|
386
|
-
if (
|
|
387
|
-
if (body.includes(t)) weight += 1;
|
|
403
|
+
if (fields.title.includes(t)) weight += 5;
|
|
404
|
+
if (fields.tagsText.includes(t)) weight += 3;
|
|
405
|
+
if (fields.type.includes(t)) weight += 2;
|
|
406
|
+
if (fields.description.includes(t)) weight += 2;
|
|
407
|
+
if (fields.body.includes(t)) weight += 1;
|
|
388
408
|
score += stats ? weight * tokenIdf(t, stats) : weight;
|
|
389
409
|
}
|
|
390
410
|
return score;
|
|
@@ -462,10 +482,10 @@ function graphProximityOrder(concepts: Concept[], seedIds: string[], graph: Conc
|
|
|
462
482
|
* final tiebreak. The failure boost only fires when the query actually hits the
|
|
463
483
|
* concept (score > 0), so an unrelated FailedAttempt never crowds out relevant
|
|
464
484
|
* context. */
|
|
465
|
-
function priorityOrder(concepts: Concept[], query?: string): Concept[] {
|
|
485
|
+
function priorityOrder(concepts: Concept[], query?: string, stats?: CorpusStats, graph?: ConceptGraph): Concept[] {
|
|
466
486
|
const tokens = tokenize(query);
|
|
467
|
-
const
|
|
468
|
-
const scored = concepts.map(c => ({ c, id: conceptId(c.relPath), score: scoreConcept(c, tokens,
|
|
487
|
+
const localStats = stats ?? buildCorpusStats(concepts);
|
|
488
|
+
const scored = concepts.map(c => ({ c, id: conceptId(c.relPath), score: scoreConcept(c, tokens, localStats) }));
|
|
469
489
|
|
|
470
490
|
// Channel 1 — lexical ("sparse"/BM25): query-hit concepts, IDF score descending.
|
|
471
491
|
// Stable sort keeps input order on ties.
|
|
@@ -474,7 +494,8 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
|
|
|
474
494
|
// Channel 2 — graph proximity ("dense"): neighbours of the query-hit seeds.
|
|
475
495
|
let graphOrder: string[] = [];
|
|
476
496
|
if (tokens.length > 0 && lexicalOrder.length > 0) {
|
|
477
|
-
|
|
497
|
+
const localGraph = graph ?? buildConceptGraph(concepts);
|
|
498
|
+
graphOrder = graphProximityOrder(concepts, lexicalOrder, localGraph);
|
|
478
499
|
}
|
|
479
500
|
|
|
480
501
|
const fused = reciprocalRankFusion([lexicalOrder, graphOrder]);
|
|
@@ -483,9 +504,6 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
|
|
|
483
504
|
.map(({ c, id, score }, i) => ({
|
|
484
505
|
concept: c,
|
|
485
506
|
i,
|
|
486
|
-
// Query-relevant past failure → resurface first so the loop is reminded of
|
|
487
|
-
// what NOT to repeat for the current task. Gated on score > 0: a stale,
|
|
488
|
-
// unrelated dead end stays out of the way.
|
|
489
507
|
failure: c.type === "FailedAttempt" && score > 0,
|
|
490
508
|
core: c.confidence === "high",
|
|
491
509
|
rrf: fused.get(id) ?? 0,
|
|
@@ -554,8 +572,11 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
|
|
|
554
572
|
const sectionLenByType = new Map<string, number>(); // type -> rendered length of that type's section so far
|
|
555
573
|
let total = 0; // rendered length of `selected` under renderConcepts
|
|
556
574
|
|
|
575
|
+
const stats = buildCorpusStats(concepts);
|
|
576
|
+
const graph = buildConceptGraph(concepts);
|
|
577
|
+
|
|
557
578
|
const take = (pool: Concept[]) => {
|
|
558
|
-
for (const c of priorityOrder(pool, query)) {
|
|
579
|
+
for (const c of priorityOrder(pool, query, stats, graph)) {
|
|
559
580
|
const itemLen = renderConceptItem(c).length;
|
|
560
581
|
const priorSectionLen = sectionLenByType.get(c.type);
|
|
561
582
|
let newSectionLen: number;
|
|
@@ -579,7 +600,7 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
|
|
|
579
600
|
};
|
|
580
601
|
take(concepts.filter(c => c.pinned)); // reserved budget: invariants survive the cap
|
|
581
602
|
take(concepts.filter(c => !c.pinned)); // recency/query fill in the remaining budget
|
|
582
|
-
if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query)[0]!);
|
|
603
|
+
if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query, stats, graph)[0]!);
|
|
583
604
|
return selected;
|
|
584
605
|
}
|
|
585
606
|
|
package/src/agent/session.ts
CHANGED
|
@@ -316,6 +316,33 @@ export async function latestSessionId(cwd = process.cwd()): Promise<string | und
|
|
|
316
316
|
return list[0]?.id;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/** Result of resolving a full session id or a short id prefix. */
|
|
320
|
+
export type SessionRefResolution =
|
|
321
|
+
| { kind: "ok"; id: string }
|
|
322
|
+
| { kind: "ambiguous"; matches: string[] }
|
|
323
|
+
| { kind: "not-found" };
|
|
324
|
+
|
|
325
|
+
/** Resolve a full session id OR a short id PREFIX (gjc-parity: `--resume <prefix>`,
|
|
326
|
+
* `/session resume <prefix>`) to exactly one session id. Exact match wins immediately
|
|
327
|
+
* (fast path, no directory scan) before falling back to a prefix scan of listSessions(). */
|
|
328
|
+
export async function resolveSessionRef(idOrPrefix: string, cwd = process.cwd()): Promise<SessionRefResolution> {
|
|
329
|
+
const trimmed = idOrPrefix.trim();
|
|
330
|
+
if (!trimmed) return { kind: "not-found" };
|
|
331
|
+
// Fast path: exact file exists.
|
|
332
|
+
try {
|
|
333
|
+
await fs.access(sessionPath(trimmed, cwd));
|
|
334
|
+
return { kind: "ok", id: trimmed };
|
|
335
|
+
} catch {
|
|
336
|
+
// fall through to prefix scan
|
|
337
|
+
}
|
|
338
|
+
const all = await listSessions(cwd);
|
|
339
|
+
const lower = trimmed.toLowerCase();
|
|
340
|
+
const matches = all.filter(s => s.id.toLowerCase().startsWith(lower)).map(s => s.id);
|
|
341
|
+
if (matches.length === 1) return { kind: "ok", id: matches[0]! };
|
|
342
|
+
if (matches.length > 1) return { kind: "ambiguous", matches };
|
|
343
|
+
return { kind: "not-found" };
|
|
344
|
+
}
|
|
345
|
+
|
|
319
346
|
/**
|
|
320
347
|
* Locate the session's JSONL header, apply `mutate`, and rewrite the file in place.
|
|
321
348
|
* `mutate` returns false to signal "no change needed" (skips the write). Shared by
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* `subagent` control tool. Concurrency is real (JS event loop): a detached run's
|
|
7
7
|
* awaits interleave with the parent's between steps.
|
|
8
8
|
*
|
|
9
|
+
* Detached runs also support live steering (peer messaging) via `steer()` /
|
|
10
|
+
* `steerDrainFor()` — a running parent (or another subagent, via the `irc` tool)
|
|
11
|
+
* can push a message into a running detached subagent's inbox; the subagent's own
|
|
12
|
+
* agent loop drains it between steps, same mechanism as ordinary task steering.
|
|
13
|
+
*
|
|
9
14
|
* Lifecycle is bounded to the turn that created the registry — `cancelAll()` on
|
|
10
15
|
* turn teardown guarantees no background promise leaks into the next turn.
|
|
11
16
|
*/
|
|
@@ -34,13 +39,17 @@ interface Entry {
|
|
|
34
39
|
abort: AbortController;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
/** A detached run: receives its own AbortSignal and
|
|
38
|
-
* final ToolResult. The
|
|
39
|
-
|
|
42
|
+
/** A detached run: receives its own AbortSignal and its own registry id, and
|
|
43
|
+
* resolves to the subagent's final ToolResult. The `id` param lets a runner
|
|
44
|
+
* wire up its own steer drain (e.g. `registry.steerDrainFor(id)`); runners that
|
|
45
|
+
* don't need it can ignore the 2nd param. The runner is responsible for
|
|
46
|
+
* streaming live events itself. */
|
|
47
|
+
export type DetachedRunner = (signal: AbortSignal, id: string) => Promise<ToolResult>;
|
|
40
48
|
|
|
41
49
|
export class SubagentRegistry {
|
|
42
50
|
private readonly entries = new Map<string, Entry>();
|
|
43
51
|
private readonly seq = new Map<string, number>();
|
|
52
|
+
private readonly steerInboxes = new Map<string, string[]>();
|
|
44
53
|
|
|
45
54
|
/** Register and START a detached run; returns the (running) record immediately. */
|
|
46
55
|
launch(role: string, task: string, runner: DetachedRunner): SubagentRecord {
|
|
@@ -55,9 +64,10 @@ export class SubagentRegistry {
|
|
|
55
64
|
status: "running",
|
|
56
65
|
startedAt: Date.now(),
|
|
57
66
|
};
|
|
67
|
+
this.steerInboxes.set(id, []);
|
|
58
68
|
const promise = (async () => {
|
|
59
69
|
try {
|
|
60
|
-
const res = await runner(abort.signal);
|
|
70
|
+
const res = await runner(abort.signal, id);
|
|
61
71
|
// A cancel that already fired wins — don't overwrite the terminal state.
|
|
62
72
|
if (record.status === "cancelled") return;
|
|
63
73
|
record.status = res.success ? "completed" : "failed";
|
|
@@ -69,6 +79,7 @@ export class SubagentRegistry {
|
|
|
69
79
|
record.result = err instanceof Error ? err.message : String(err);
|
|
70
80
|
} finally {
|
|
71
81
|
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
82
|
+
this.steerInboxes.delete(id);
|
|
72
83
|
}
|
|
73
84
|
})();
|
|
74
85
|
this.entries.set(id, { record, promise, abort });
|
|
@@ -107,6 +118,31 @@ export class SubagentRegistry {
|
|
|
107
118
|
return targets.map(e => e.record);
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
/** Push a live message into a running id's steer inbox (parent → detached subagent,
|
|
122
|
+
* or subagent → subagent via the `irc` tool). Returns false if the id is unknown,
|
|
123
|
+
* not running, or the (trimmed) message is empty — true once queued. */
|
|
124
|
+
steer(id: string, message: string): boolean {
|
|
125
|
+
const rec = this.get(id);
|
|
126
|
+
if (!rec || rec.status !== "running") return false;
|
|
127
|
+
const trimmed = message.trim();
|
|
128
|
+
if (!trimmed) return false;
|
|
129
|
+
const inbox = this.steerInboxes.get(id);
|
|
130
|
+
if (!inbox) return false;
|
|
131
|
+
inbox.push(trimmed);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A drain closure bound to one id — splices its inbox to empty on each call.
|
|
136
|
+
* Wire this into a detached runner's own agent-loop `steer` option so pending
|
|
137
|
+
* messages are picked up between the subagent's own steps. */
|
|
138
|
+
steerDrainFor(id: string): () => string[] {
|
|
139
|
+
return () => {
|
|
140
|
+
const inbox = this.steerInboxes.get(id);
|
|
141
|
+
if (!inbox || inbox.length === 0) return [];
|
|
142
|
+
return inbox.splice(0, inbox.length);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
/** Cancel the given ids (or all running, when empty): aborts the run and marks the
|
|
111
147
|
* record cancelled. Already-terminal records are returned unchanged. */
|
|
112
148
|
cancel(ids: string[]): SubagentRecord[] {
|