brainclaw 1.24.0 → 1.26.0

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 (46) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-code-map.js +9 -2
  3. package/dist/commands/code-map.js +120 -6
  4. package/dist/commands/mcp-catalog.js +46 -0
  5. package/dist/commands/mcp.js +58 -6
  6. package/dist/commands/session-start.js +84 -13
  7. package/dist/core/bootstrap.js +28 -4
  8. package/dist/core/code-map/aggregate.js +36 -31
  9. package/dist/core/code-map/backend.js +162 -5
  10. package/dist/core/code-map/core.js +1 -0
  11. package/dist/core/code-map/export.js +212 -0
  12. package/dist/core/code-map/finalizer.js +57 -2
  13. package/dist/core/code-map/freshness.js +81 -15
  14. package/dist/core/code-map/impact.js +409 -0
  15. package/dist/core/code-map/indexes.js +64 -3
  16. package/dist/core/code-map/lang/python/index.js +4 -2
  17. package/dist/core/code-map/lang/query-runtime.js +2 -0
  18. package/dist/core/code-map/lang/typescript/config.js +271 -0
  19. package/dist/core/code-map/lang/typescript/index.js +24 -6
  20. package/dist/core/code-map/lang/usages.js +333 -0
  21. package/dist/core/code-map/memory-reader.js +15 -0
  22. package/dist/core/code-map/query.js +285 -71
  23. package/dist/core/code-map/refresh.js +0 -0
  24. package/dist/core/code-map/resolve.js +28 -2
  25. package/dist/core/code-map/store.js +1 -0
  26. package/dist/core/code-map/types.js +70 -9
  27. package/dist/core/code-map/vocabulary.js +6 -0
  28. package/dist/core/code-map/work-section.js +12 -14
  29. package/dist/core/context-diff.js +17 -3
  30. package/dist/core/entity-operations.js +14 -2
  31. package/dist/core/federation-pull.js +151 -3
  32. package/dist/core/federation-push.js +16 -3
  33. package/dist/core/hint-aging.js +4 -1
  34. package/dist/core/identity.js +69 -17
  35. package/dist/core/io.js +27 -0
  36. package/dist/core/project-discovery.js +7 -1
  37. package/dist/core/protocol-tool-policy.js +3 -0
  38. package/dist/core/runtime.js +23 -0
  39. package/dist/core/worktree.js +89 -2
  40. package/dist/facts.js +15 -12
  41. package/dist/facts.json +14 -11
  42. package/docs/cli.md +8 -0
  43. package/docs/code-map.md +60 -28
  44. package/docs/integrations/mcp.md +5 -2
  45. package/docs/mcp-schema-changelog.md +11 -1
  46. package/package.json +1 -1
@@ -26,6 +26,12 @@ function coercePaths(raw) {
26
26
  return raw.filter((p) => typeof p === 'string');
27
27
  return [];
28
28
  }
29
+ function optionalString(raw) {
30
+ return typeof raw === 'string' ? raw : undefined;
31
+ }
32
+ function optionalCount(raw) {
33
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
34
+ }
29
35
  function toRelatedMemoryItem(kind, item) {
30
36
  return {
31
37
  id: typeof item.id === 'string' ? item.id : '',
@@ -33,6 +39,15 @@ function toRelatedMemoryItem(kind, item) {
33
39
  text: typeof item.text === 'string' ? item.text : '',
34
40
  tags: coerceTags(item.tags),
35
41
  related_paths: coercePaths(item.related_paths),
42
+ created_at: optionalString(item.created_at),
43
+ last_confirmed_at: optionalString(item.last_confirmed_at),
44
+ last_infirmed_at: optionalString(item.last_infirmed_at),
45
+ confirmation_count: optionalCount(item.confirmation_count),
46
+ infirmation_count: optionalCount(item.infirmation_count),
47
+ saved_me_count: optionalCount(item.saved_me_count),
48
+ misled_me_count: optionalCount(item.misled_me_count),
49
+ verified_at: optionalString(item.verified_at),
50
+ verify_cmd: optionalString(item.verify_cmd),
36
51
  };
37
52
  }
38
53
  /**
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
+ import { getLifecycleStats } from '../memory-lifecycle.js';
14
15
  import { hashContent } from './extractor.js';
15
- import { coarseFreshness } from './freshness.js';
16
+ import { makeFreshnessBadge } from './freshness.js';
16
17
  import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
17
18
  // --- lazy read-path freshness budget (spec §6.1) ---
18
19
  /** Default per-query lazy-check budget (spec §6.1). */
@@ -50,6 +51,7 @@ function budgetExhausted(checker) {
50
51
  }
51
52
  export function newAccumulator() {
52
53
  return {
54
+ checkedPaths: new Set(),
53
55
  staleChangedPaths: new Set(),
54
56
  missingPaths: new Set(),
55
57
  uncheckedPaths: new Set(),
@@ -76,6 +78,7 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
76
78
  if (cached !== undefined)
77
79
  return cached;
78
80
  const abs = path.join(projectRoot, entry.path);
81
+ acc.checkedPaths.add(entry.path);
79
82
  let stat;
80
83
  try {
81
84
  stat = fs.statSync(abs);
@@ -151,46 +154,26 @@ export function validateStoreEntry(entry, checker, acc, cwd, preferredDirName) {
151
154
  * change/deletion yields `stale_changed_files`; else the manifest base status.
152
155
  */
153
156
  export function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
154
- const details = {};
155
- if (acc.staleChangedPaths.size > 0) {
156
- details.stale_changed_files = [...acc.staleChangedPaths].sort();
157
- }
158
- if (acc.missingPaths.size > 0) {
159
- details.deleted_files = [...acc.missingPaths].sort();
160
- }
161
- if (acc.uncheckedPaths.size > 0) {
162
- details.unchecked_files = [...acc.uncheckedPaths].sort();
163
- }
164
- let status = base;
165
- if (emptyIndex && base !== 'missing_index') {
166
- // §6.1 — zero confident matches: hint refresh rather than imply absence.
167
- details.hint = 'missing_index_or_refresh';
168
- }
169
- if (acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0) {
170
- status = 'stale_changed_files';
171
- }
172
- // §6.1.6 — `partial` means the lazy-check budget (file count / wall clock) ran
173
- // out before we could validate everything. Reserve it for that cause only:
174
- // unchecked-for-other-reasons (oversized file per §6.1.4, missing shard,
175
- // unreadable file) must NOT be mislabeled as budget exhaustion. When the budget
176
- // truly ran out, `partial` wins the top-line status — the agent should refresh
177
- // before trusting the result — and the confirmed-stale list still rides along
178
- // in `details.stale_changed_files`.
179
- if (budgetExhausted || acc.budgetSkippedPaths.size > 0) {
180
- status = 'partial';
181
- details.partial_reason = 'lazy_check_budget_exhausted';
182
- details.budget = { ...LAZY_BUDGET };
183
- }
184
- // pln#593 #2 — distinguish INDEX freshness (manifest state) from THIS call's
185
- // read-path spot-check. When the call-level status diverges from the index
186
- // status (a budget-limited `partial`, or a per-file `stale_changed_files` over a
187
- // `fresh` index), surface the index status so an agent does not read
188
- // status()=fresh vs find()/brief()=partial as a contradiction: it's "index
189
- // <index_status>, this call's spot-check <status>".
190
- if (status !== base)
191
- details.index_status = base;
157
+ const hasStale = acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0;
158
+ const partial = budgetExhausted || acc.budgetSkippedPaths.size > 0;
159
+ const spotStatus = partial ? 'partial' : hasStale ? 'stale' : acc.checkedPaths.size > 0 ? 'fresh' : 'not_run';
160
+ const hint = emptyIndex && base !== 'missing_index' ? 'missing_index_or_refresh' : undefined;
192
161
  void hadConfidentMatch;
193
- return { status, coarse: coarseFreshness(status), details };
162
+ // `base` stays the top-level index classification. A stale or budget-limited
163
+ // candidate spot-check is honest evidence, but it must not turn find()/brief()
164
+ // into a different badge than status()/work() for the same index state.
165
+ return makeFreshnessBadge(base, {
166
+ spotCheck: {
167
+ status: spotStatus,
168
+ checked_files: acc.checkedPaths.size,
169
+ stale_changed_files: [...acc.staleChangedPaths].sort(),
170
+ deleted_files: [...acc.missingPaths].sort(),
171
+ unchecked_files: [...acc.uncheckedPaths].sort(),
172
+ budget_exhausted: partial,
173
+ partial_reason: partial ? 'lazy_check_budget_exhausted' : null,
174
+ },
175
+ extra: hint ? { hint } : undefined,
176
+ });
194
177
  }
195
178
  const DEFAULT_FIND_LIMIT = 20;
196
179
  /** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
@@ -277,6 +260,58 @@ export function scoreEntry(entry, query) {
277
260
  score *= 0.4;
278
261
  return score;
279
262
  }
263
+ /**
264
+ * Number of distinct indexed files that import a symbol (or its defining file).
265
+ *
266
+ * This is deliberately a tie-breaker, not a broad popularity boost: a precise
267
+ * textual match must always beat a merely popular substring match. The symbol
268
+ * and file reverse indexes can name the same importer, so count their UNION to
269
+ * avoid giving named imports a double bonus over namespace/default imports.
270
+ */
271
+ export function importCentrality(entry, resolutionIndex) {
272
+ if (!resolutionIndex)
273
+ return 0;
274
+ const importers = new Set();
275
+ for (const dep of resolutionIndex.dependents_by_symbol[entry.node_id] ?? [])
276
+ importers.add(dep.path);
277
+ for (const dep of resolutionIndex.dependents_by_file[entry.path] ?? [])
278
+ importers.add(dep.path);
279
+ return importers.size;
280
+ }
281
+ /** A test-only helper is a weak orientation point when a file has real exports too. */
282
+ function isTestHelperSymbol(name) {
283
+ const normalized = normIdent(name);
284
+ return (name.startsWith('_') ||
285
+ /(?:^|_)(?:test|tests|mock|stub|fixture|reset)(?:_|$)/i.test(name) ||
286
+ normalized.includes('fortest') ||
287
+ normalized.includes('fortests'));
288
+ }
289
+ /**
290
+ * Pick one meaningful definition per file for the reading-list explanation.
291
+ * A path brief resolves all symbols in that file; repeatedly adding +12 for
292
+ * each one made the result's reason depend on index order, which could select
293
+ * an internal `__reset…ForTests` helper ahead of the public entry point.
294
+ */
295
+ function representativeDefinitions(defining) {
296
+ const byPath = new Map();
297
+ const relevance = (entry) => {
298
+ let score = entry.score_hint * 100; // public definitions before internals
299
+ if (entry.subtype === 'component' || entry.subtype === 'hook')
300
+ score += 2;
301
+ if (isTestHelperSymbol(entry.name))
302
+ score -= 50;
303
+ return score;
304
+ };
305
+ for (const entry of defining) {
306
+ const previous = byPath.get(entry.path);
307
+ if (!previous ||
308
+ relevance(entry) > relevance(previous) ||
309
+ (relevance(entry) === relevance(previous) && entry.name.localeCompare(previous.name) < 0)) {
310
+ byPath.set(entry.path, entry);
311
+ }
312
+ }
313
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
314
+ }
280
315
  function resolveRoot(ctx) {
281
316
  if (ctx.projectRoot)
282
317
  return ctx.projectRoot;
@@ -325,6 +360,7 @@ export function findInStore(query, ctx, checker, acc) {
325
360
  const root = resolveRoot(ctx);
326
361
  const maxBytes = maxParseBytes(ctx);
327
362
  const candidates = gatherSymbolEntries(index, query);
363
+ const resolutionIndex = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
328
364
  const ranked = [];
329
365
  for (const entry of candidates) {
330
366
  // §6.1 — lazy validate before serving as confident.
@@ -332,17 +368,23 @@ export function findInStore(query, ctx, checker, acc) {
332
368
  if (!confident)
333
369
  continue;
334
370
  ranked.push({
335
- node_id: entry.node_id,
336
- name: entry.name,
337
- path: entry.path,
338
- file_id: entry.file_id,
339
- kind: entry.kind,
340
- subtype: entry.subtype ?? null,
341
- score: scoreEntry(entry, query),
371
+ match: {
372
+ node_id: entry.node_id,
373
+ name: entry.name,
374
+ path: entry.path,
375
+ file_id: entry.file_id,
376
+ kind: entry.kind,
377
+ subtype: entry.subtype ?? null,
378
+ score: scoreEntry(entry, query),
379
+ },
380
+ centrality: importCentrality(entry, resolutionIndex),
342
381
  });
343
382
  }
344
- ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
345
- return { matches: ranked, base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
383
+ ranked.sort((a, b) => b.match.score - a.match.score ||
384
+ b.centrality - a.centrality ||
385
+ a.match.path.localeCompare(b.match.path) ||
386
+ a.match.name.localeCompare(b.match.name));
387
+ return { matches: ranked.map(({ match }) => match), base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
346
388
  }
347
389
  export function find(query, limit, ctx) {
348
390
  const checker = makeLazyChecker();
@@ -352,7 +394,7 @@ export function find(query, limit, ctx) {
352
394
  return {
353
395
  query,
354
396
  matches: [],
355
- freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
397
+ freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
356
398
  };
357
399
  }
358
400
  const capped = r.matches.slice(0, limit ?? DEFAULT_FIND_LIMIT);
@@ -361,43 +403,178 @@ export function find(query, limit, ctx) {
361
403
  }
362
404
  /** spec §11 — cap related memory at top 5 by relevance. */
363
405
  export const RELATED_MEMORY_CAP = 5;
406
+ const MEMORY_JOIN_MIN_IDENTIFIER_LENGTH = 4;
407
+ const MEMORY_JOIN_STOP_WORDS = new Set([
408
+ 'default', 'module', 'index', 'main', 'test', 'tests', 'spec', 'react', 'node',
409
+ ]);
410
+ function stableNames(names) {
411
+ const byNormalized = new Map();
412
+ for (const raw of names) {
413
+ const name = raw.trim();
414
+ const normalized = normIdent(name);
415
+ if (!normalized || byNormalized.has(normalized))
416
+ continue;
417
+ byNormalized.set(normalized, name);
418
+ }
419
+ return [...byNormalized.values()].sort((a, b) => a.localeCompare(b));
420
+ }
421
+ /** Avoid joining a prose memory to generic import noise such as `default` or `React`. */
422
+ function memoryJoinTerms(names) {
423
+ return stableNames(names).filter((name) => {
424
+ const normalized = normIdent(name);
425
+ return normalized.length >= MEMORY_JOIN_MIN_IDENTIFIER_LENGTH
426
+ && !MEMORY_JOIN_STOP_WORDS.has(normalized)
427
+ && !isTestHelperSymbol(name);
428
+ });
429
+ }
430
+ function escapeRegExp(text) {
431
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
432
+ }
433
+ /**
434
+ * Identifier-aware, case-insensitive text match. The surrounding guards keep
435
+ * `Auth` from matching `author`, while still matching `ENTITY_REGISTRY` in a
436
+ * prose trap written as `entity_registry`.
437
+ */
438
+ function textMentionsIdentifier(text, identifier) {
439
+ const escaped = escapeRegExp(identifier);
440
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}(?=$|[^A-Za-z0-9_$])`, 'i').test(text);
441
+ }
442
+ function isLifecycleMemoryKind(kind) {
443
+ return kind === 'decision' || kind === 'constraint' || kind === 'trap';
444
+ }
445
+ function validDate(value) {
446
+ if (!value)
447
+ return null;
448
+ const ms = Date.parse(value);
449
+ return Number.isFinite(ms) ? ms : null;
450
+ }
451
+ /**
452
+ * Make memory age explicit on the brief response. `verify_cmd` marks a
453
+ * perishable fact, so its empirical verification wins over the gentler
454
+ * lifecycle curve: missing verification is `unverified`, and one older than
455
+ * 30 days is `stale` (the same threshold used by memory anti-staleness).
456
+ */
457
+ function describeMemoryFreshness(item) {
458
+ const verifiedAt = item.verified_at ?? null;
459
+ const verificationMs = validDate(item.verified_at);
460
+ const nowMs = Date.now();
461
+ if (item.verify_cmd) {
462
+ if (verificationMs === null) {
463
+ return { status: 'unverified', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
464
+ }
465
+ const ageDays = Math.max(0, Math.floor((nowMs - verificationMs) / 86_400_000));
466
+ if (ageDays > 30)
467
+ return { status: 'stale', as_of: item.verified_at, age_days: ageDays, verified_at: verifiedAt };
468
+ }
469
+ if (!isLifecycleMemoryKind(item.kind) || validDate(item.created_at) === null) {
470
+ return { status: 'unknown', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
471
+ }
472
+ const stats = getLifecycleStats({
473
+ entity: item.kind,
474
+ created_at: item.created_at,
475
+ last_confirmed_at: item.last_confirmed_at,
476
+ last_infirmed_at: item.last_infirmed_at,
477
+ confirmation_count: item.confirmation_count,
478
+ infirmation_count: item.infirmation_count,
479
+ saved_me_count: item.saved_me_count,
480
+ misled_me_count: item.misled_me_count,
481
+ nowMs,
482
+ });
483
+ return {
484
+ status: stats.classification,
485
+ as_of: stats.anchor_at,
486
+ age_days: stats.age_days,
487
+ verified_at: verifiedAt,
488
+ };
489
+ }
364
490
  /**
365
491
  * Match memory items to a set of candidate file paths + the query symbol name
366
492
  * by (spec §11): related_paths, tags, or a literal file-path mention in the
367
493
  * memory text. Returns the top `RELATED_MEMORY_CAP` by relevance.
368
494
  */
369
- export function attachRelatedMemory(items, paths, symbolNames) {
495
+ export function attachRelatedMemory(items, paths, symbolNames, importNames = []) {
370
496
  const pathSet = new Set(paths.map((p) => p.replace(/\\/g, '/')));
371
497
  const baseNames = new Set(paths.map((p) => path.basename(p)));
372
498
  const symLower = new Set(symbolNames.map((s) => s.toLowerCase()));
499
+ const symbolTerms = memoryJoinTerms(symbolNames);
500
+ const importTerms = memoryJoinTerms(importNames);
373
501
  const scored = [];
374
502
  for (const item of items) {
375
503
  let score = 0;
504
+ const sources = new Set();
376
505
  // related_paths — strongest signal.
377
506
  for (const rp of item.related_paths ?? []) {
378
507
  const norm = rp.replace(/\\/g, '/');
379
- if (pathSet.has(norm))
508
+ if (pathSet.has(norm)) {
380
509
  score += 5;
381
- else if (baseNames.has(path.basename(norm)))
510
+ sources.add('related_path');
511
+ }
512
+ else if (baseNames.has(path.basename(norm))) {
382
513
  score += 3;
514
+ sources.add('related_path_basename');
515
+ }
383
516
  }
384
517
  // literal file-path mention in the memory text.
385
518
  const text = item.text ?? '';
386
519
  for (const p of pathSet) {
387
- if (text.includes(p))
520
+ if (text.includes(p)) {
388
521
  score += 2;
522
+ sources.add('path_mention');
523
+ }
389
524
  }
390
525
  for (const bn of baseNames) {
391
- if (text.includes(bn))
526
+ if (text.includes(bn)) {
392
527
  score += 1;
528
+ sources.add('path_basename_mention');
529
+ }
393
530
  }
394
531
  // tags matching a symbol name (e.g. tag "App" / "useAuth").
395
532
  for (const tag of item.tags ?? []) {
396
- if (symLower.has(tag.toLowerCase()))
533
+ if (symLower.has(tag.toLowerCase())) {
397
534
  score += 2;
535
+ sources.add('symbol_tag');
536
+ }
537
+ }
538
+ // pln#601 amendment — response-local join from MEMORY TEXT to the names
539
+ // defined/imported by the brief target. We only admit identifier-shaped,
540
+ // non-test-helper terms and retain the exact terms as inspectable evidence.
541
+ const matchedSymbols = symbolTerms.filter((term) => textMentionsIdentifier(text, term));
542
+ const matchedImports = importTerms.filter((term) => textMentionsIdentifier(text, term));
543
+ if (matchedSymbols.length > 0) {
544
+ score += 4 * matchedSymbols.length;
545
+ sources.add('symbol_text');
546
+ }
547
+ if (matchedImports.length > 0) {
548
+ score += 3 * matchedImports.length;
549
+ sources.add('import_text');
550
+ }
551
+ if (score > 0) {
552
+ let confidence = 0.6;
553
+ if (sources.has('related_path'))
554
+ confidence = 1;
555
+ else if (sources.has('path_mention'))
556
+ confidence = 0.95;
557
+ else if (sources.has('related_path_basename'))
558
+ confidence = 0.85;
559
+ else if (sources.has('symbol_text'))
560
+ confidence = 0.82;
561
+ else if (sources.has('import_text'))
562
+ confidence = 0.72;
563
+ else if (sources.has('symbol_tag'))
564
+ confidence = 0.65;
565
+ const evidence = {
566
+ sources: [...sources].sort(),
567
+ matched_symbols: matchedSymbols,
568
+ matched_imports: matchedImports,
569
+ confidence,
570
+ };
571
+ // Clone: these response annotations must never mutate the records returned
572
+ // by the memory reader or be persisted into Code Map shards.
573
+ scored.push({
574
+ item: { ...item, match_evidence: evidence, memory_freshness: describeMemoryFreshness(item) },
575
+ score,
576
+ });
398
577
  }
399
- if (score > 0)
400
- scored.push({ item, score });
401
578
  }
402
579
  scored.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
403
580
  return scored.slice(0, RELATED_MEMORY_CAP).map((s) => s.item);
@@ -436,7 +613,7 @@ function rankFiles(defining, forwardRows, reverseRows, symbolsIndex, importsInde
436
613
  };
437
614
  // 1. defining files — strongest, non-graph.
438
615
  const definingDirs = new Set();
439
- for (const entry of defining) {
616
+ for (const entry of representativeDefinitions(defining)) {
440
617
  const subtypeNote = entry.subtype ? ` (${entry.subtype})` : '';
441
618
  bump(entry.path, entry.file_id, `defines matching symbol ${entry.name}${subtypeNote}`, 12, false);
442
619
  definingDirs.add(path.posix.dirname(entry.path.replace(/\\/g, '/')));
@@ -590,6 +767,24 @@ function reverseDeps(resolutionIndex, definingPaths, definingByNodeId) {
590
767
  }
591
768
  return [...byPath.values()];
592
769
  }
770
+ function targetMemoryTerms(definingPaths, definingFileIds, nodeIndex, cwd, preferredDirName) {
771
+ const symbols = [...nodeIndex.values()]
772
+ .filter((entry) => definingPaths.has(entry.path))
773
+ .map((entry) => entry.name);
774
+ const imports = new Set();
775
+ for (const fileId of new Set(definingFileIds)) {
776
+ const shard = readShard(fileId, cwd, preferredDirName);
777
+ if (!shard)
778
+ continue;
779
+ for (const node of shard.nodes) {
780
+ if (node.kind !== 'module')
781
+ continue;
782
+ for (const imported of node.imported_names)
783
+ imports.add(imported);
784
+ }
785
+ }
786
+ return { symbolNames: stableNames(symbols), importNames: stableNames([...imports]) };
787
+ }
593
788
  /**
594
789
  * Heuristic: does the brief target denote a file PATH rather than a bare symbol
595
790
  * name? A path separator or a supported source extension marks a path target —
@@ -604,13 +799,17 @@ function looksLikePathTarget(target) {
604
799
  /** Find files whose path matches the target directly (path-target briefs). */
605
800
  function filesMatchingPath(symbolsIndex, target) {
606
801
  const norm = target.replace(/\\/g, '/');
607
- const seenPaths = new Set();
802
+ // Keep every symbol in the matching file. `rankFiles()` selects the one
803
+ // meaningful representative for the reason; deduping by path here used to
804
+ // retain whichever token bucket happened to be visited first (often an
805
+ // internal `__reset…ForTests` helper) and discarded the public entry point.
806
+ const seenNodeIds = new Set();
608
807
  const out = [];
609
808
  for (const bucket of Object.values(symbolsIndex.entries)) {
610
809
  for (const entry of bucket) {
611
810
  const p = entry.path.replace(/\\/g, '/');
612
- if ((p === norm || p.endsWith(`/${norm}`) || p.includes(norm)) && !seenPaths.has(entry.path)) {
613
- seenPaths.add(entry.path);
811
+ if ((p === norm || p.endsWith(`/${norm}`) || p.includes(norm)) && !seenNodeIds.has(entry.node_id)) {
812
+ seenNodeIds.add(entry.node_id);
614
813
  out.push(entry);
615
814
  }
616
815
  }
@@ -631,6 +830,8 @@ export function briefInStore(target, ctx, checker, acc) {
631
830
  return {
632
831
  defining: [],
633
832
  definingPaths: new Set(),
833
+ memorySymbolNames: [],
834
+ memoryImportNames: [],
634
835
  matchKind: 'none',
635
836
  confident: [],
636
837
  base: 'missing_index',
@@ -686,6 +887,7 @@ export function briefInStore(target, ctx, checker, acc) {
686
887
  confidentDefiningFileIds.set(e.path, e.file_id);
687
888
  }
688
889
  const nodeIndex = buildNodeIdIndex(symbolsIndex);
890
+ const memoryTerms = targetMemoryTerms(definingPaths, defining.map((entry) => entry.file_id), nodeIndex, ctx.cwd, ctx.preferredDirName);
689
891
  const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
690
892
  const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
691
893
  const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
@@ -700,13 +902,25 @@ export function briefInStore(target, ctx, checker, acc) {
700
902
  continue;
701
903
  confident.push(rf);
702
904
  }
703
- return { defining, definingPaths, matchKind, confident, base, hasIndex: true, emptyRanked: ranked.length === 0, acc };
905
+ return {
906
+ defining,
907
+ definingPaths,
908
+ memorySymbolNames: memoryTerms.symbolNames,
909
+ memoryImportNames: memoryTerms.importNames,
910
+ matchKind,
911
+ confident,
912
+ base,
913
+ hasIndex: true,
914
+ emptyRanked: ranked.length === 0,
915
+ acc,
916
+ };
704
917
  }
705
918
  /**
706
919
  * Attach related-memory ids per reading-list entry (spec §11). Shared by the
707
920
  * single-store brief() and the workspace aggregation so both surface memory identically.
708
921
  */
709
- export function attachMemoryIds(capped, related) {
922
+ export function attachMemoryIds(capped, related, targetPaths = new Set()) {
923
+ const normalizedTargetPaths = new Set([...targetPaths].map((p) => p.replace(/\\/g, '/')));
710
924
  return capped.map((f) => {
711
925
  const ids = related
712
926
  .filter((m) => {
@@ -714,7 +928,9 @@ export function attachMemoryIds(capped, related) {
714
928
  const base2 = path.basename(fileNorm);
715
929
  const inPaths = (m.related_paths ?? []).some((rp) => rp.replace(/\\/g, '/') === fileNorm || path.basename(rp) === base2);
716
930
  const inText = (m.text ?? '').includes(fileNorm) || (m.text ?? '').includes(base2);
717
- return inPaths || inText;
931
+ const symbolTextMatch = (m.match_evidence?.matched_symbols.length ?? 0) > 0
932
+ || (m.match_evidence?.matched_imports.length ?? 0) > 0;
933
+ return inPaths || inText || (normalizedTargetPaths.has(fileNorm) && symbolTextMatch);
718
934
  })
719
935
  .map((m) => m.id);
720
936
  return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
@@ -729,7 +945,7 @@ export function brief(target, limit, ctx, memoryReader) {
729
945
  target,
730
946
  suggested_files_to_read: [],
731
947
  related_memory: [],
732
- freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
948
+ freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
733
949
  };
734
950
  }
735
951
  const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
@@ -737,11 +953,9 @@ export function brief(target, limit, ctx, memoryReader) {
737
953
  const capped = reserveSourceSlots(r.confident, cap, r.definingPaths);
738
954
  // Related memory (spec §11): match by the candidate paths + symbol names.
739
955
  const candidatePaths = capped.map((f) => f.path);
740
- const symbolNames = [...new Set(r.defining.map((e) => e.name))];
741
- if (symbolNames.length === 0)
742
- symbolNames.push(target);
743
- const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames);
744
- const suggested = attachMemoryIds(capped, related);
956
+ const symbolNames = r.memorySymbolNames.length > 0 ? r.memorySymbolNames : [target];
957
+ const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames, r.memoryImportNames);
958
+ const suggested = attachMemoryIds(capped, related, r.definingPaths);
745
959
  const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyRanked);
746
960
  return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: badge };
747
961
  }
Binary file
@@ -55,7 +55,7 @@ function edgesArrayEqual(a, b) {
55
55
  for (let i = 0; i < a.length; i++) {
56
56
  const x = a[i];
57
57
  const y = b[i];
58
- if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence) {
58
+ if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence || x.origin !== y.origin) {
59
59
  return false;
60
60
  }
61
61
  }
@@ -77,6 +77,7 @@ export async function resolveProjectImports(input) {
77
77
  const ctx = {
78
78
  fileExists: (rel) => fileLang.has(toPosix(rel)),
79
79
  langOfFile: (rel) => fileLang.get(toPosix(rel)),
80
+ resolverConfig: input.resolverConfig,
80
81
  };
81
82
  // Target-file lookups for B: path -> shard, and a memoized importable index
82
83
  // (name -> importable symbols) per target file (built lazily, reused across importers).
@@ -100,9 +101,12 @@ export async function resolveProjectImports(input) {
100
101
  const provider = registry.providerForLang(shard.lang);
101
102
  // Non-pass-owned edges are preserved byte-identical (same order). Filtering BOTH
102
103
  // pass-owned kinds also strips any stale A/B edge from a prior run (idempotency).
103
- const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind));
104
+ const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind) && e.origin !== 'usage_import');
104
105
  // Fresh pass-owned set (A resolves_to + B imports_symbol). Empty when no resolver.
105
106
  const fresh = [];
107
+ const seen = new Set();
108
+ // module + imported-name → unique target proven by imports_symbol below.
109
+ const resolvedImportedBindings = new Map();
106
110
  let freshSymbolCount = 0;
107
111
  if (provider?.resolveImport) {
108
112
  const seen = new Set();
@@ -148,11 +152,33 @@ export async function resolveProjectImports(input) {
148
152
  continue; // dedup (duplicate names / re-imports)
149
153
  seen.add(symId);
150
154
  fresh.push({ id: symId, from: mod.id, to: target.id, kind: 'imports_symbol', confidence, source });
155
+ resolvedImportedBindings.set(`${r.source}\0${name}`, target);
151
156
  freshSymbolCount++;
152
157
  }
153
158
  }
154
159
  }
155
160
  }
161
+ // P4 imported-binding usages are emitted only after the exact `imports_symbol`
162
+ // proof above. If a module is unresolved, ambiguous, wildcard/default, or its
163
+ // target no longer exports the symbol, no call/reference edge survives.
164
+ for (const candidate of shard.reference_candidates ?? []) {
165
+ const target = resolvedImportedBindings.get(`${candidate.module}\0${candidate.imported_name}`);
166
+ if (!target)
167
+ continue;
168
+ const id = edgeId({ projectId, from: candidate.from, to: target.id, kind: candidate.kind });
169
+ if (seen.has(id))
170
+ continue;
171
+ seen.add(id);
172
+ fresh.push({
173
+ id,
174
+ from: candidate.from,
175
+ to: target.id,
176
+ kind: candidate.kind,
177
+ confidence: Math.min(clampConfidence(candidate.confidence), 1.0),
178
+ source: candidate.source,
179
+ origin: 'usage_import',
180
+ });
181
+ }
156
182
  // Single deterministic order over A+B so re-runs are byte-identical (from, to, kind).
157
183
  fresh.sort((a, b) => {
158
184
  if (a.from !== b.from)
@@ -193,6 +193,7 @@ export function readResolutionIndex(cwd, preferredDirName) {
193
193
  // 'toString' (a path or node id) can't resolve to an inherited prototype member.
194
194
  parsed.data.dependents_by_file = Object.assign(Object.create(null), parsed.data.dependents_by_file);
195
195
  parsed.data.dependents_by_symbol = Object.assign(Object.create(null), parsed.data.dependents_by_symbol);
196
+ parsed.data.usages_by_symbol = Object.assign(Object.create(null), parsed.data.usages_by_symbol);
196
197
  return parsed.data;
197
198
  }
198
199
  export function writeResolutionIndex(index, cwd, preferredDirName) {