brainclaw 1.9.1 → 1.10.1

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 (95) hide show
  1. package/README.md +78 -25
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +18 -1
  4. package/dist/commands/code-map.js +129 -0
  5. package/dist/commands/codev.js +7 -0
  6. package/dist/commands/dispatch-watch.js +1 -1
  7. package/dist/commands/doctor.js +3 -5
  8. package/dist/commands/loops-handlers.js +4 -1
  9. package/dist/commands/mcp-read-handlers.js +8 -0
  10. package/dist/commands/mcp.js +121 -1
  11. package/dist/commands/metrics.js +0 -1
  12. package/dist/commands/release-claims.js +1 -1
  13. package/dist/commands/run-profile.js +3 -2
  14. package/dist/commands/sequence.js +1 -1
  15. package/dist/commands/switch.js +100 -89
  16. package/dist/commands/sync.js +1 -1
  17. package/dist/commands/upgrade.js +0 -7
  18. package/dist/core/agent-context.js +1 -1
  19. package/dist/core/agent-files.js +13 -2
  20. package/dist/core/agent-integrations.js +3 -3
  21. package/dist/core/agent-registry.js +2 -2
  22. package/dist/core/assignments.js +12 -0
  23. package/dist/core/brainclaw-version.js +2 -2
  24. package/dist/core/code-map/backend.js +176 -0
  25. package/dist/core/code-map/core.js +81 -0
  26. package/dist/core/code-map/drafts.js +2 -0
  27. package/dist/core/code-map/extractor.js +29 -0
  28. package/dist/core/code-map/finalizer.js +191 -0
  29. package/dist/core/code-map/freshness.js +144 -0
  30. package/dist/core/code-map/ids.js +0 -0
  31. package/dist/core/code-map/importable.js +35 -0
  32. package/dist/core/code-map/indexes.js +197 -0
  33. package/dist/core/code-map/lang/java/imports.scm +17 -0
  34. package/dist/core/code-map/lang/java/index.js +254 -0
  35. package/dist/core/code-map/lang/java/tags.scm +48 -0
  36. package/dist/core/code-map/lang/php/imports.scm +21 -0
  37. package/dist/core/code-map/lang/php/index.js +251 -0
  38. package/dist/core/code-map/lang/php/tags.scm +44 -0
  39. package/dist/core/code-map/lang/provider.js +9 -0
  40. package/dist/core/code-map/lang/providers.js +24 -0
  41. package/dist/core/code-map/lang/python/imports.scm +90 -0
  42. package/dist/core/code-map/lang/python/index.js +364 -0
  43. package/dist/core/code-map/lang/python/tags.scm +81 -0
  44. package/dist/core/code-map/lang/query-runtime.js +374 -0
  45. package/dist/core/code-map/lang/registry.js +125 -0
  46. package/dist/core/code-map/lang/typescript/imports.scm +90 -0
  47. package/dist/core/code-map/lang/typescript/index.js +306 -0
  48. package/dist/core/code-map/lang/typescript/tags.js.scm +106 -0
  49. package/dist/core/code-map/lang/typescript/tags.scm +151 -0
  50. package/dist/core/code-map/lock.js +210 -0
  51. package/dist/core/code-map/materialized.js +51 -0
  52. package/dist/core/code-map/memory-reader.js +59 -0
  53. package/dist/core/code-map/paths.js +53 -0
  54. package/dist/core/code-map/query.js +599 -0
  55. package/dist/core/code-map/refresh.js +0 -0
  56. package/dist/core/code-map/resolve.js +177 -0
  57. package/dist/core/code-map/store.js +206 -0
  58. package/dist/core/code-map/types.js +293 -0
  59. package/dist/core/code-map/vocabulary.js +57 -0
  60. package/dist/core/code-map/wasm-loader.js +294 -0
  61. package/dist/core/code-map/work-section.js +206 -0
  62. package/dist/core/codev-rounds.js +4 -0
  63. package/dist/core/context.js +1 -1
  64. package/dist/core/cross-project.js +1 -1
  65. package/dist/core/dispatcher.js +0 -2
  66. package/dist/core/entity-operations.js +0 -3
  67. package/dist/core/execution-adapters.js +11 -10
  68. package/dist/core/execution-profile.js +58 -0
  69. package/dist/core/facade-schema.js +9 -0
  70. package/dist/core/ids.js +1 -1
  71. package/dist/core/instruction-templates.js +2 -0
  72. package/dist/core/instructions.js +0 -1
  73. package/dist/core/loops/lock.js +0 -3
  74. package/dist/core/mcp-command-resolution.js +3 -1
  75. package/dist/core/protocol-skills.js +5 -3
  76. package/dist/core/security-detectors.js +2 -2
  77. package/dist/core/security-extract.js +2 -2
  78. package/dist/core/store-resolution.js +41 -4
  79. package/dist/facts.js +9 -5
  80. package/dist/facts.json +8 -4
  81. package/dist/vendor/web-tree-sitter/tree-sitter.js +3980 -0
  82. package/dist/vendor/web-tree-sitter/tree-sitter.wasm +0 -0
  83. package/dist/wasm/tree-sitter-java.wasm +0 -0
  84. package/dist/wasm/tree-sitter-javascript.wasm +0 -0
  85. package/dist/wasm/tree-sitter-php.wasm +0 -0
  86. package/dist/wasm/tree-sitter-python.wasm +0 -0
  87. package/dist/wasm/tree-sitter-tsx.wasm +0 -0
  88. package/dist/wasm/tree-sitter-typescript.wasm +0 -0
  89. package/dist/wasm/tree-sitter.wasm +0 -0
  90. package/docs/cli.md +46 -8
  91. package/docs/code-map.md +209 -0
  92. package/docs/integrations/mcp.md +13 -6
  93. package/docs/mcp-schema-changelog.md +7 -3
  94. package/docs/quickstart.md +1 -1
  95. package/package.json +11 -6
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Code Map query logic (spec §6.1, §9, §11, §12.1) — the agent-facing
3
+ * `find()` / `brief()` implementations live here; backend.ts is the thin
4
+ * CodeQueryBackend adapter that wires this to the durable store.
5
+ *
6
+ * Everything reads from `indexes/**` + `files/**` (store.ts readers). No WASM,
7
+ * no graph DB. The hot correctness feature is the bounded lazy read-path
8
+ * freshness check (§6.1): before a shard selected from an index is trusted, we
9
+ * stat the live file and, when cheap, hash it — detecting modifications and
10
+ * deletions (NOT additions) within a per-query budget.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { hashContent } from './extractor.js';
15
+ import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
16
+ // --- lazy read-path freshness budget (spec §6.1) ---
17
+ /** Default per-query lazy-check budget (spec §6.1). */
18
+ export const LAZY_BUDGET = {
19
+ maxFilesChecked: 32,
20
+ maxWallMs: 2500,
21
+ };
22
+ /**
23
+ * Build a bounded lazy freshness checker for a single query (spec §6.1). The
24
+ * stat/hash logic lives in `validateEntry`, which compares against the stored
25
+ * shard's mtime/size/file_hash; this object only carries the shared budget +
26
+ * per-path memoization so a brief() that touches one file from several ranking
27
+ * signals spends a single budget slot.
28
+ */
29
+ function makeLazyChecker(budget = LAZY_BUDGET) {
30
+ return {
31
+ budget,
32
+ startedAt: Date.now(),
33
+ memo: new Map(),
34
+ filesChecked: 0,
35
+ exhausted: false,
36
+ };
37
+ }
38
+ /** Has the lazy-check budget (file count or wall clock) been spent? */
39
+ function budgetExhausted(checker) {
40
+ if (checker.exhausted)
41
+ return true;
42
+ if (checker.filesChecked >= checker.budget.maxFilesChecked) {
43
+ checker.exhausted = true;
44
+ }
45
+ else if (Date.now() - checker.startedAt >= checker.budget.maxWallMs) {
46
+ checker.exhausted = true;
47
+ }
48
+ return checker.exhausted;
49
+ }
50
+ function newAccumulator() {
51
+ return {
52
+ staleChangedPaths: new Set(),
53
+ missingPaths: new Set(),
54
+ uncheckedPaths: new Set(),
55
+ budgetSkippedPaths: new Set(),
56
+ };
57
+ }
58
+ /**
59
+ * Validate a single index entry's backing shard against the live file. Uses the
60
+ * shard's stored mtime/size + file_hash for an accurate content comparison.
61
+ * Records the outcome on the accumulator. Returns whether the entry may be
62
+ * served as a *confident* (fresh) result.
63
+ */
64
+ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd, preferredDirName) {
65
+ const cached = checker.memo.get(entry.path);
66
+ if (cached !== undefined)
67
+ return cached;
68
+ const abs = path.join(projectRoot, entry.path);
69
+ let stat;
70
+ try {
71
+ stat = fs.statSync(abs);
72
+ }
73
+ catch {
74
+ acc.missingPaths.add(entry.path); // §6.1.2 — deletion.
75
+ checker.memo.set(entry.path, false);
76
+ return false;
77
+ }
78
+ const shard = readShard(entry.file_id, cwd, preferredDirName);
79
+ if (!shard) {
80
+ // No backing shard to compare against — treat as unchecked, not confident.
81
+ acc.uncheckedPaths.add(entry.path);
82
+ checker.memo.set(entry.path, false);
83
+ return false;
84
+ }
85
+ // §6.1.3 — cheap gate: mtime + size match => fresh for this read.
86
+ if (stat.mtimeMs === shard.mtime_ms && stat.size === shard.size_bytes) {
87
+ checker.memo.set(entry.path, true);
88
+ return true;
89
+ }
90
+ // §6.1.4/§6.1.6 — gate tripped: hash only when within budget AND not oversized.
91
+ // These are distinct reasons: an oversized file can never be hashed on the read
92
+ // path (§6.1.4), whereas a budget-exhausted skip is what §6.1.6 maps to
93
+ // `partial`. Keep them separable so the badge reason is accurate.
94
+ if (stat.size > maxParseFileBytes) {
95
+ acc.uncheckedPaths.add(entry.path); // structurally unverifiable, not budget.
96
+ checker.memo.set(entry.path, false);
97
+ return false;
98
+ }
99
+ if (budgetExhausted(checker)) {
100
+ acc.uncheckedPaths.add(entry.path);
101
+ acc.budgetSkippedPaths.add(entry.path);
102
+ checker.memo.set(entry.path, false);
103
+ return false;
104
+ }
105
+ checker.filesChecked++;
106
+ let live;
107
+ try {
108
+ live = fs.readFileSync(abs, 'utf-8');
109
+ }
110
+ catch {
111
+ acc.uncheckedPaths.add(entry.path);
112
+ checker.memo.set(entry.path, false);
113
+ return false;
114
+ }
115
+ if (hashContent(live) === shard.file_hash) {
116
+ checker.memo.set(entry.path, true); // §6.1 — identical despite mtime touch.
117
+ return true;
118
+ }
119
+ acc.staleChangedPaths.add(entry.path); // §6.1.5 — confirmed content change.
120
+ checker.memo.set(entry.path, false);
121
+ return false;
122
+ }
123
+ /**
124
+ * Derive the response freshness badge from the base manifest status + the
125
+ * outcomes recorded during this query's lazy check (spec §6.1, §9).
126
+ *
127
+ * Precedence: an exhausted budget yields `partial`; otherwise any detected
128
+ * change/deletion yields `stale_changed_files`; else the manifest base status.
129
+ */
130
+ function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
131
+ const details = {};
132
+ if (acc.staleChangedPaths.size > 0) {
133
+ details.stale_changed_files = [...acc.staleChangedPaths].sort();
134
+ }
135
+ if (acc.missingPaths.size > 0) {
136
+ details.deleted_files = [...acc.missingPaths].sort();
137
+ }
138
+ if (acc.uncheckedPaths.size > 0) {
139
+ details.unchecked_files = [...acc.uncheckedPaths].sort();
140
+ }
141
+ let status = base;
142
+ if (emptyIndex && base !== 'missing_index') {
143
+ // §6.1 — zero confident matches: hint refresh rather than imply absence.
144
+ details.hint = 'missing_index_or_refresh';
145
+ }
146
+ if (acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0) {
147
+ status = 'stale_changed_files';
148
+ }
149
+ // §6.1.6 — `partial` means the lazy-check budget (file count / wall clock) ran
150
+ // out before we could validate everything. Reserve it for that cause only:
151
+ // unchecked-for-other-reasons (oversized file per §6.1.4, missing shard,
152
+ // unreadable file) must NOT be mislabeled as budget exhaustion. When the budget
153
+ // truly ran out, `partial` wins the top-line status — the agent should refresh
154
+ // before trusting the result — and the confirmed-stale list still rides along
155
+ // in `details.stale_changed_files`.
156
+ if (budgetExhausted || acc.budgetSkippedPaths.size > 0) {
157
+ status = 'partial';
158
+ details.partial_reason = 'lazy_check_budget_exhausted';
159
+ details.budget = { ...LAZY_BUDGET };
160
+ }
161
+ // pln#593 #2 — distinguish INDEX freshness (manifest state) from THIS call's
162
+ // read-path spot-check. When the call-level status diverges from the index
163
+ // status (a budget-limited `partial`, or a per-file `stale_changed_files` over a
164
+ // `fresh` index), surface the index status so an agent does not read
165
+ // status()=fresh vs find()/brief()=partial as a contradiction: it's "index
166
+ // <index_status>, this call's spot-check <status>".
167
+ if (status !== base)
168
+ details.index_status = base;
169
+ void hadConfidentMatch;
170
+ return { status, details };
171
+ }
172
+ const DEFAULT_FIND_LIMIT = 20;
173
+ /** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
174
+ function queryTokens(query) {
175
+ const lower = query.toLowerCase();
176
+ const tokens = new Set([lower]);
177
+ for (const part of query.split(/[^A-Za-z0-9]+/)) {
178
+ if (!part)
179
+ continue;
180
+ for (const sub of part.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/\s+/)) {
181
+ if (sub)
182
+ tokens.add(sub.toLowerCase());
183
+ }
184
+ }
185
+ return [...tokens];
186
+ }
187
+ /**
188
+ * Score a symbol index entry against the query. Exact (full-query) token match
189
+ * scores highest; a prefix/substring match scores lower. Exported symbols and
190
+ * components/hooks get a small boost (these are what agents most want to find).
191
+ */
192
+ function scoreEntry(entry, query) {
193
+ const q = query.toLowerCase();
194
+ const name = entry.name.toLowerCase();
195
+ let score = 0;
196
+ if (name === q)
197
+ score += 10;
198
+ else if (name.startsWith(q))
199
+ score += 6;
200
+ else if (name.includes(q))
201
+ score += 3;
202
+ else
203
+ score += 1; // matched only via a sub-token bucket
204
+ score *= entry.score_hint; // exported (1.0) vs internal (0.8)
205
+ if (entry.subtype === 'component' || entry.subtype === 'hook')
206
+ score += 1;
207
+ return score;
208
+ }
209
+ function resolveRoot(ctx) {
210
+ if (ctx.projectRoot)
211
+ return ctx.projectRoot;
212
+ const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
213
+ return manifest?.project_root ?? ctx.cwd ?? process.cwd();
214
+ }
215
+ function maxParseBytes(ctx) {
216
+ const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
217
+ return manifest?.extractor_config.max_parse_file_bytes ?? 1024 * 1024;
218
+ }
219
+ function baseStatus(ctx) {
220
+ const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
221
+ return manifest ? manifest.freshness.status : 'missing_index';
222
+ }
223
+ /** Gather candidate symbol entries from the symbols index for a query. */
224
+ function gatherSymbolEntries(index, query) {
225
+ const seen = new Set();
226
+ const out = [];
227
+ for (const token of queryTokens(query)) {
228
+ const bucket = index.entries[token];
229
+ if (!bucket)
230
+ continue;
231
+ for (const entry of bucket) {
232
+ if (seen.has(entry.node_id))
233
+ continue;
234
+ seen.add(entry.node_id);
235
+ out.push(entry);
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+ export function find(query, limit, ctx) {
241
+ const base = baseStatus(ctx);
242
+ const index = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
243
+ if (!index) {
244
+ return {
245
+ query,
246
+ matches: [],
247
+ freshness_badge: { status: 'missing_index', details: { hint: 'run refresh' } },
248
+ };
249
+ }
250
+ const root = resolveRoot(ctx);
251
+ const maxBytes = maxParseBytes(ctx);
252
+ const checker = makeLazyChecker();
253
+ const acc = newAccumulator();
254
+ const candidates = gatherSymbolEntries(index, query);
255
+ const ranked = [];
256
+ for (const entry of candidates) {
257
+ // §6.1 — lazy validate before serving as confident.
258
+ const confident = validateEntry(entry, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
259
+ if (!confident)
260
+ continue;
261
+ ranked.push({
262
+ node_id: entry.node_id,
263
+ name: entry.name,
264
+ path: entry.path,
265
+ file_id: entry.file_id,
266
+ kind: entry.kind,
267
+ subtype: entry.subtype ?? null,
268
+ score: scoreEntry(entry, query),
269
+ });
270
+ }
271
+ ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
272
+ const capped = ranked.slice(0, limit ?? DEFAULT_FIND_LIMIT);
273
+ const badge = deriveBadge(base, acc, checker.exhausted, capped.length > 0, candidates.length === 0);
274
+ return { query, matches: capped, freshness_badge: badge };
275
+ }
276
+ /** spec §11 — cap related memory at top 5 by relevance. */
277
+ export const RELATED_MEMORY_CAP = 5;
278
+ /**
279
+ * Match memory items to a set of candidate file paths + the query symbol name
280
+ * by (spec §11): related_paths, tags, or a literal file-path mention in the
281
+ * memory text. Returns the top `RELATED_MEMORY_CAP` by relevance.
282
+ */
283
+ export function attachRelatedMemory(items, paths, symbolNames) {
284
+ const pathSet = new Set(paths.map((p) => p.replace(/\\/g, '/')));
285
+ const baseNames = new Set(paths.map((p) => path.basename(p)));
286
+ const symLower = new Set(symbolNames.map((s) => s.toLowerCase()));
287
+ const scored = [];
288
+ for (const item of items) {
289
+ let score = 0;
290
+ // related_paths — strongest signal.
291
+ for (const rp of item.related_paths ?? []) {
292
+ const norm = rp.replace(/\\/g, '/');
293
+ if (pathSet.has(norm))
294
+ score += 5;
295
+ else if (baseNames.has(path.basename(norm)))
296
+ score += 3;
297
+ }
298
+ // literal file-path mention in the memory text.
299
+ const text = item.text ?? '';
300
+ for (const p of pathSet) {
301
+ if (text.includes(p))
302
+ score += 2;
303
+ }
304
+ for (const bn of baseNames) {
305
+ if (text.includes(bn))
306
+ score += 1;
307
+ }
308
+ // tags matching a symbol name (e.g. tag "App" / "useAuth").
309
+ for (const tag of item.tags ?? []) {
310
+ if (symLower.has(tag.toLowerCase()))
311
+ score += 2;
312
+ }
313
+ if (score > 0)
314
+ scored.push({ item, score });
315
+ }
316
+ scored.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
317
+ return scored.slice(0, RELATED_MEMORY_CAP).map((s) => s.item);
318
+ }
319
+ /** spec §9 — the brief reading list is capped at 12 files. */
320
+ export const BRIEF_FILE_CAP = 12;
321
+ /**
322
+ * Build the ranked suggested_files_to_read for a brief (spec §9; P1d graph signals).
323
+ *
324
+ * Relevance signals, highest first:
325
+ * - defining file of the matching symbol (+12)
326
+ * - reverse dependent — a file that imports the target (+5, blast radius; P1d)
327
+ * - forward dependency — a file the target imports, resolved (+4; P1d)
328
+ * - import-specifier heuristic (+3, weak fallback)
329
+ * - same directory as a defining file (+1)
330
+ *
331
+ * `bump` accumulates score but keeps the reason of the STRONGEST single signal
332
+ * (Codex review) and tracks whether a path is graph-only. Each signal class bumps a
333
+ * given path at most once (callers dedupe their rows), bounding score runaway.
334
+ */
335
+ function rankFiles(defining, forwardRows, reverseRows, symbolsIndex, importsIndex, query) {
336
+ const byPath = new Map();
337
+ const bump = (p, fileId, reason, delta, graph) => {
338
+ const cur = byPath.get(p);
339
+ if (cur) {
340
+ cur.score += delta;
341
+ if (delta > cur.bestDelta) {
342
+ cur.bestDelta = delta;
343
+ cur.reason = reason;
344
+ }
345
+ cur.graphDerived = cur.graphDerived && graph; // graph-only iff every signal is graph
346
+ }
347
+ else {
348
+ byPath.set(p, { path: p, file_id: fileId, reason, score: delta, bestDelta: delta, graphDerived: graph });
349
+ }
350
+ };
351
+ // 1. defining files — strongest, non-graph.
352
+ const definingDirs = new Set();
353
+ for (const entry of defining) {
354
+ const subtypeNote = entry.subtype ? ` (${entry.subtype})` : '';
355
+ bump(entry.path, entry.file_id, `defines matching symbol ${entry.name}${subtypeNote}`, 12, false);
356
+ definingDirs.add(path.posix.dirname(entry.path.replace(/\\/g, '/')));
357
+ }
358
+ // 2. reverse dependents (P1d) — who imports the target = blast radius.
359
+ for (const r of reverseRows)
360
+ bump(r.path, r.file_id, r.reason, 5, true);
361
+ // 3. forward dependencies (P1d) — files the target imports (resolved).
362
+ for (const f of forwardRows)
363
+ bump(f.path, f.file_id, f.reason, 4, true);
364
+ // 4. import-specifier heuristic — weak fallback (kept; real graph rows outrank it).
365
+ // Dedup by path (a file is bumped ONCE even if it matches several specifiers /
366
+ // appears in several token buckets) so the weak signal can't accumulate.
367
+ if (importsIndex) {
368
+ const qLower = query.toLowerCase();
369
+ const heuristicPaths = new Map();
370
+ for (const [moduleSpec, entries] of Object.entries(importsIndex.entries)) {
371
+ const specLower = moduleSpec.toLowerCase();
372
+ const relevant = specLower.includes(qLower) ||
373
+ [...definingDirs].some((d) => moduleSpec.includes(path.posix.basename(d)));
374
+ if (!relevant)
375
+ continue;
376
+ for (const e of entries) {
377
+ if (!heuristicPaths.has(e.path))
378
+ heuristicPaths.set(e.path, { fileId: e.file_id, reason: `imports ${moduleSpec}` });
379
+ }
380
+ }
381
+ for (const [p, { fileId, reason }] of heuristicPaths)
382
+ bump(p, fileId, reason, 3, false);
383
+ }
384
+ // 5. files that share a directory with a defining file — bumped ONCE per file
385
+ // (the symbols index repeats a file across every symbol AND every token bucket;
386
+ // without dedup a symbol-dense file would accumulate +1 dozens of times and bury
387
+ // the real graph signals).
388
+ if (definingDirs.size > 0) {
389
+ const sameDirPaths = new Map(); // path -> file_id
390
+ for (const bucket of Object.values(symbolsIndex.entries)) {
391
+ for (const entry of bucket) {
392
+ const dir = path.posix.dirname(entry.path.replace(/\\/g, '/'));
393
+ if (definingDirs.has(dir) && !sameDirPaths.has(entry.path))
394
+ sameDirPaths.set(entry.path, entry.file_id);
395
+ }
396
+ }
397
+ for (const [p, fid] of sameDirPaths)
398
+ bump(p, fid, `shares directory with the matching symbol`, 1, false);
399
+ }
400
+ return [...byPath.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
401
+ }
402
+ /** Build a node-id → symbol index entry map (deduped; entries repeat across token buckets). */
403
+ function buildNodeIdIndex(symbolsIndex) {
404
+ const out = new Map();
405
+ for (const bucket of Object.values(symbolsIndex.entries)) {
406
+ for (const entry of bucket)
407
+ if (!out.has(entry.node_id))
408
+ out.set(entry.node_id, entry);
409
+ }
410
+ return out;
411
+ }
412
+ /**
413
+ * Forward dependencies of the target: files the defining symbols import. Read from
414
+ * each (already-validated) defining shard's `imports_symbol` edges, mapped to the
415
+ * target symbol's own index entry (path + file_id + name). Deduped by path. Reading
416
+ * only confident defining shards is the graph-SOURCE freshness gate (Codex review):
417
+ * a stale importer shard's edge list is not trusted.
418
+ */
419
+ function forwardDeps(confidentDefiningFileIds, // path -> file_id of confident defining files
420
+ nodeIndex, cwd, preferredDirName) {
421
+ const byPath = new Map();
422
+ for (const fileId of new Set(confidentDefiningFileIds.values())) {
423
+ const shard = readShard(fileId, cwd, preferredDirName);
424
+ if (!shard)
425
+ continue;
426
+ for (const edge of shard.edges) {
427
+ if (edge.kind !== 'imports_symbol')
428
+ continue;
429
+ const target = nodeIndex.get(edge.to);
430
+ if (!target)
431
+ continue;
432
+ if (byPath.has(target.path))
433
+ continue;
434
+ byPath.set(target.path, {
435
+ path: target.path,
436
+ file_id: target.file_id,
437
+ reason: `imported by the matching symbol (resolved): ${target.name}`,
438
+ });
439
+ }
440
+ }
441
+ return [...byPath.values()];
442
+ }
443
+ /**
444
+ * Reverse dependents of the target (blast radius), from the P1d resolution index:
445
+ * files that import any defining file (`dependents_by_file`) or any defining symbol
446
+ * (`dependents_by_symbol`). Deduped by importer path; the strongest-named reason wins.
447
+ */
448
+ function reverseDeps(resolutionIndex, definingPaths, definingByNodeId) {
449
+ if (!resolutionIndex)
450
+ return [];
451
+ const byPath = new Map();
452
+ const add = (importerPath, fileId, reason) => {
453
+ if (!byPath.has(importerPath))
454
+ byPath.set(importerPath, { path: importerPath, file_id: fileId, reason });
455
+ };
456
+ // by symbol — more precise (names the symbol).
457
+ for (const [nodeId, entry] of definingByNodeId) {
458
+ for (const dep of resolutionIndex.dependents_by_symbol[nodeId] ?? []) {
459
+ add(dep.path, dep.file_id, `imports the matching symbol ${entry.name}`);
460
+ }
461
+ }
462
+ // by file — covers default/namespace imports + path-target briefs.
463
+ for (const p of definingPaths) {
464
+ const base = path.posix.basename(p.replace(/\\/g, '/'));
465
+ for (const dep of resolutionIndex.dependents_by_file[p] ?? []) {
466
+ add(dep.path, dep.file_id, `imports ${base}`);
467
+ }
468
+ }
469
+ return [...byPath.values()];
470
+ }
471
+ /**
472
+ * Heuristic: does the brief target denote a file PATH rather than a bare symbol
473
+ * name? A path separator or a supported source extension marks a path target —
474
+ * for which we resolve the exact file directly instead of fuzzy-tokenizing the
475
+ * name. Fuzzy-tokenizing a path floods the brief with unrelated same-token symbols
476
+ * (e.g. brief('src/commands/switch.ts') pulling in every `switch`-named symbol and
477
+ * code-map test). pln#593 1b.
478
+ */
479
+ function looksLikePathTarget(target) {
480
+ return /[\\/]/.test(target) || /\.(?:ts|tsx|js|jsx|mjs|cjs|py|php|java)$/i.test(target);
481
+ }
482
+ /** Find files whose path matches the target directly (path-target briefs). */
483
+ function filesMatchingPath(symbolsIndex, target) {
484
+ const norm = target.replace(/\\/g, '/');
485
+ const seenPaths = new Set();
486
+ const out = [];
487
+ for (const bucket of Object.values(symbolsIndex.entries)) {
488
+ for (const entry of bucket) {
489
+ const p = entry.path.replace(/\\/g, '/');
490
+ if ((p === norm || p.endsWith(`/${norm}`) || p.includes(norm)) && !seenPaths.has(entry.path)) {
491
+ seenPaths.add(entry.path);
492
+ out.push(entry);
493
+ }
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+ export function brief(target, limit, ctx, memoryReader) {
499
+ const base = baseStatus(ctx);
500
+ const symbolsIndex = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
501
+ if (!symbolsIndex) {
502
+ return {
503
+ target,
504
+ suggested_files_to_read: [],
505
+ related_memory: [],
506
+ freshness_badge: { status: 'missing_index', details: { hint: 'run refresh' } },
507
+ };
508
+ }
509
+ const importsIndex = readImportsIndex(ctx.cwd, ctx.preferredDirName);
510
+ const resolutionIndex = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
511
+ // Resolve target -> defining symbol entries. A brief orients on a SPECIFIC target,
512
+ // so prefer EXACT name matches when present — otherwise the token index floods the
513
+ // result with unrelated same-token symbols (e.g. `resolveProjectImports` would pull
514
+ // in every `resolve*`), burying the real defining file + its graph signals. Fall
515
+ // back to the fuzzy token set, then to a path match. (find() stays fuzzy by design.)
516
+ let defining;
517
+ if (looksLikePathTarget(target)) {
518
+ // PATH target (pln#593 1b): resolve the exact file; the graph signals (its
519
+ // imports / dependents / direct tests) then rank below it via rankFiles. Skip
520
+ // the fuzzy token gather entirely — it floods a path brief with same-token
521
+ // noise. Degrade to the fuzzy set only if the path resolves to nothing indexed.
522
+ defining = filesMatchingPath(symbolsIndex, target);
523
+ if (defining.length === 0)
524
+ defining = gatherSymbolEntries(symbolsIndex, target);
525
+ }
526
+ else {
527
+ defining = gatherSymbolEntries(symbolsIndex, target);
528
+ const exact = defining.filter((e) => e.name.toLowerCase() === target.toLowerCase());
529
+ if (exact.length > 0)
530
+ defining = exact;
531
+ else if (defining.length === 0)
532
+ defining = filesMatchingPath(symbolsIndex, target);
533
+ }
534
+ const root = resolveRoot(ctx);
535
+ const maxBytes = maxParseBytes(ctx);
536
+ const checker = makeLazyChecker();
537
+ const acc = newAccumulator();
538
+ // P1d graph signals. FORWARD: read from defining shards — but only CONFIDENT ones
539
+ // (validate first; a stale importer shard's edge list is not trusted). REVERSE: from
540
+ // the resolution index (each importer row is lazy-validated below like any other).
541
+ const definingPaths = new Set(defining.map((e) => e.path));
542
+ const definingByNodeId = new Map(defining.map((e) => [e.node_id, e]));
543
+ const confidentDefiningFileIds = new Map();
544
+ for (const e of defining) {
545
+ if (confidentDefiningFileIds.has(e.path))
546
+ continue;
547
+ const ok = validateEntry({ path: e.path, file_id: e.file_id }, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
548
+ if (ok)
549
+ confidentDefiningFileIds.set(e.path, e.file_id);
550
+ }
551
+ const nodeIndex = buildNodeIdIndex(symbolsIndex);
552
+ const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
553
+ const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
554
+ const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
555
+ // §6.1 — lazy validate each suggested file; exclude deletions from the confident
556
+ // list (still recorded in the badge). P1d: a GRAPH-ONLY row that fails validation
557
+ // (stale / unchecked / deleted) is SUPPRESSED — no silent stale graph hints (Codex).
558
+ const confident = [];
559
+ for (const rf of ranked) {
560
+ const ok = validateEntry({ path: rf.path, file_id: rf.file_id }, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
561
+ if (acc.missingPaths.has(rf.path))
562
+ continue; // deletion: exclude entirely.
563
+ if (rf.graphDerived && !ok)
564
+ continue; // graph-only + not confident → suppress.
565
+ // Non-graph stale/unchecked rows still appear (badge flags them) so the agent
566
+ // knows the file exists but may be out of date.
567
+ confident.push(rf);
568
+ }
569
+ const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
570
+ const capped = confident.slice(0, cap);
571
+ // Related memory (spec §11): match by the candidate paths + symbol names.
572
+ const candidatePaths = capped.map((f) => f.path);
573
+ const symbolNames = [...new Set(defining.map((e) => e.name))];
574
+ if (symbolNames.length === 0)
575
+ symbolNames.push(target);
576
+ const memoryItems = memoryReader(ctx);
577
+ const related = attachRelatedMemory(memoryItems, candidatePaths, symbolNames);
578
+ // Attach matching memory ids per file (those whose related_paths/text name it).
579
+ const suggested = capped.map((f) => {
580
+ const ids = related
581
+ .filter((m) => {
582
+ const fileNorm = f.path.replace(/\\/g, '/');
583
+ const base2 = path.basename(fileNorm);
584
+ const inPaths = (m.related_paths ?? []).some((rp) => rp.replace(/\\/g, '/') === fileNorm || path.basename(rp) === base2);
585
+ const inText = (m.text ?? '').includes(fileNorm) || (m.text ?? '').includes(base2);
586
+ return inPaths || inText;
587
+ })
588
+ .map((m) => m.id);
589
+ return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
590
+ });
591
+ const badge = deriveBadge(base, acc, checker.exhausted, capped.length > 0, ranked.length === 0);
592
+ return {
593
+ target,
594
+ suggested_files_to_read: suggested,
595
+ related_memory: related,
596
+ freshness_badge: badge,
597
+ };
598
+ }
599
+ //# sourceMappingURL=query.js.map
Binary file