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.
Files changed (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.ja.md +5 -3
  3. package/README.ko.md +5 -3
  4. package/README.md +5 -3
  5. package/README.zh.md +5 -3
  6. package/package.json +3 -3
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +12 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/compaction.ts +6 -19
  15. package/src/agent/debug-session.ts +435 -0
  16. package/src/agent/debug-tool.ts +89 -0
  17. package/src/agent/engine.ts +103 -18
  18. package/src/agent/goal-tool.ts +57 -0
  19. package/src/agent/irc-tool.ts +71 -0
  20. package/src/agent/job-registry.ts +163 -0
  21. package/src/agent/job-tool.ts +106 -0
  22. package/src/agent/lsp-rename-tool.ts +108 -0
  23. package/src/agent/lsp-tool.ts +177 -0
  24. package/src/agent/memory-graph.ts +6 -1
  25. package/src/agent/memory.ts +73 -52
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +100 -5
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/ai/model-manager.ts +58 -26
  34. package/src/ai/sse.ts +22 -7
  35. package/src/ai/think-tags.ts +10 -1
  36. package/src/commands/deep-interview.ts +49 -1
  37. package/src/commands/launch/agents-slash.ts +489 -0
  38. package/src/commands/launch/flags.ts +27 -9
  39. package/src/commands/launch/model-slash.ts +290 -0
  40. package/src/commands/launch/session-slash.ts +305 -0
  41. package/src/commands/launch/slash-handlers.ts +6 -0
  42. package/src/commands/launch/system-slash.ts +81 -0
  43. package/src/commands/launch/tmux.ts +32 -0
  44. package/src/commands/launch.ts +227 -750
  45. package/src/tui/app.ts +32 -12
  46. package/src/tui/components/evolution.ts +23 -3
  47. 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
- const adj = undirectedAdjacency(graph);
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];
@@ -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 concepts: Concept[] = [];
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)) continue;
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
- continue;
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
- if (cached.concept) concepts.push(cached.concept);
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
- continue;
297
+ return null;
285
298
  }
286
- if (!parsed.hasFrontmatter) {
287
- cacheParsedConcept(file, sig, null);
288
- continue;
289
- }
290
- const fm = parsed.frontmatter;
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 concepts;
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 [concept.title, concept.tags.join(" "), concept.type, concept.description, concept.body]
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 title = concept.title.toLowerCase();
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 (tags.some(tag => tag.includes(t))) weight += 3;
385
- if (type.includes(t)) weight += 2;
386
- if (desc.includes(t)) weight += 2;
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 stats = buildCorpusStats(concepts);
468
- const scored = concepts.map(c => ({ c, id: conceptId(c.relPath), score: scoreConcept(c, tokens, stats) }));
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
- graphOrder = graphProximityOrder(concepts, lexicalOrder, buildConceptGraph(concepts));
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
 
@@ -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 resolves to the subagent's
38
- * final ToolResult. The runner is responsible for streaming live events itself. */
39
- export type DetachedRunner = (signal: AbortSignal) => Promise<ToolResult>;
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[] {