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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/code-map.js +120 -6
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +58 -6
- package/dist/commands/session-start.js +84 -13
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +162 -5
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +81 -15
- package/dist/core/code-map/impact.js +409 -0
- package/dist/core/code-map/indexes.js +64 -3
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +24 -6
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +285 -71
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +28 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +70 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/federation-pull.js +151 -3
- package/dist/core/federation-push.js +16 -3
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +69 -17
- package/dist/core/io.js +27 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/runtime.js +23 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +15 -12
- package/dist/facts.json +14 -11
- package/docs/cli.md +8 -0
- package/docs/code-map.md +60 -28
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- 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 {
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 ||
|
|
345
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)) && !
|
|
613
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
741
|
-
|
|
742
|
-
|
|
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) {
|