@cstart/coldstart 2.0.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/README.md +236 -0
- package/dist/cache/disk-cache.d.ts +14 -0
- package/dist/cache/disk-cache.d.ts.map +1 -0
- package/dist/cache/disk-cache.js +371 -0
- package/dist/cache/disk-cache.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +206 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +111 -0
- package/dist/constants.js.map +1 -0
- package/dist/daemon-lock.d.ts +63 -0
- package/dist/daemon-lock.d.ts.map +1 -0
- package/dist/daemon-lock.js +218 -0
- package/dist/daemon-lock.js.map +1 -0
- package/dist/daemon-log.d.ts +26 -0
- package/dist/daemon-log.d.ts.map +1 -0
- package/dist/daemon-log.js +110 -0
- package/dist/daemon-log.js.map +1 -0
- package/dist/index-manager.d.ts +103 -0
- package/dist/index-manager.d.ts.map +1 -0
- package/dist/index-manager.js +361 -0
- package/dist/index-manager.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +724 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer/content-tokens.d.ts +74 -0
- package/dist/indexer/content-tokens.d.ts.map +1 -0
- package/dist/indexer/content-tokens.js +406 -0
- package/dist/indexer/content-tokens.js.map +1 -0
- package/dist/indexer/cpp-include-roots.d.ts +7 -0
- package/dist/indexer/cpp-include-roots.d.ts.map +1 -0
- package/dist/indexer/cpp-include-roots.js +166 -0
- package/dist/indexer/cpp-include-roots.js.map +1 -0
- package/dist/indexer/csharp-synthetic.d.ts +9 -0
- package/dist/indexer/csharp-synthetic.d.ts.map +1 -0
- package/dist/indexer/csharp-synthetic.js +51 -0
- package/dist/indexer/csharp-synthetic.js.map +1 -0
- package/dist/indexer/django-synthetic.d.ts +12 -0
- package/dist/indexer/django-synthetic.d.ts.map +1 -0
- package/dist/indexer/django-synthetic.js +49 -0
- package/dist/indexer/django-synthetic.js.map +1 -0
- package/dist/indexer/extractors/angularjs.d.ts +16 -0
- package/dist/indexer/extractors/angularjs.d.ts.map +1 -0
- package/dist/indexer/extractors/angularjs.js +49 -0
- package/dist/indexer/extractors/angularjs.js.map +1 -0
- package/dist/indexer/extractors/cpp.d.ts +9 -0
- package/dist/indexer/extractors/cpp.d.ts.map +1 -0
- package/dist/indexer/extractors/cpp.js +254 -0
- package/dist/indexer/extractors/cpp.js.map +1 -0
- package/dist/indexer/extractors/csharp.d.ts +19 -0
- package/dist/indexer/extractors/csharp.d.ts.map +1 -0
- package/dist/indexer/extractors/csharp.js +327 -0
- package/dist/indexer/extractors/csharp.js.map +1 -0
- package/dist/indexer/extractors/env.d.ts +9 -0
- package/dist/indexer/extractors/env.d.ts.map +1 -0
- package/dist/indexer/extractors/env.js +43 -0
- package/dist/indexer/extractors/env.js.map +1 -0
- package/dist/indexer/extractors/go.d.ts +9 -0
- package/dist/indexer/extractors/go.d.ts.map +1 -0
- package/dist/indexer/extractors/go.js +227 -0
- package/dist/indexer/extractors/go.js.map +1 -0
- package/dist/indexer/extractors/graphql.d.ts +22 -0
- package/dist/indexer/extractors/graphql.d.ts.map +1 -0
- package/dist/indexer/extractors/graphql.js +88 -0
- package/dist/indexer/extractors/graphql.js.map +1 -0
- package/dist/indexer/extractors/groovy.d.ts +9 -0
- package/dist/indexer/extractors/groovy.d.ts.map +1 -0
- package/dist/indexer/extractors/groovy.js +356 -0
- package/dist/indexer/extractors/groovy.js.map +1 -0
- package/dist/indexer/extractors/java.d.ts +10 -0
- package/dist/indexer/extractors/java.d.ts.map +1 -0
- package/dist/indexer/extractors/java.js +586 -0
- package/dist/indexer/extractors/java.js.map +1 -0
- package/dist/indexer/extractors/kotlin.d.ts +10 -0
- package/dist/indexer/extractors/kotlin.d.ts.map +1 -0
- package/dist/indexer/extractors/kotlin.js +398 -0
- package/dist/indexer/extractors/kotlin.js.map +1 -0
- package/dist/indexer/extractors/node-helpers.d.ts +13 -0
- package/dist/indexer/extractors/node-helpers.d.ts.map +1 -0
- package/dist/indexer/extractors/node-helpers.js +17 -0
- package/dist/indexer/extractors/node-helpers.js.map +1 -0
- package/dist/indexer/extractors/parser-factory.d.ts +4 -0
- package/dist/indexer/extractors/parser-factory.d.ts.map +1 -0
- package/dist/indexer/extractors/parser-factory.js +28 -0
- package/dist/indexer/extractors/parser-factory.js.map +1 -0
- package/dist/indexer/extractors/php.d.ts +17 -0
- package/dist/indexer/extractors/php.d.ts.map +1 -0
- package/dist/indexer/extractors/php.js +363 -0
- package/dist/indexer/extractors/php.js.map +1 -0
- package/dist/indexer/extractors/python.d.ts +14 -0
- package/dist/indexer/extractors/python.d.ts.map +1 -0
- package/dist/indexer/extractors/python.js +449 -0
- package/dist/indexer/extractors/python.js.map +1 -0
- package/dist/indexer/extractors/ruby.d.ts +10 -0
- package/dist/indexer/extractors/ruby.d.ts.map +1 -0
- package/dist/indexer/extractors/ruby.js +996 -0
- package/dist/indexer/extractors/ruby.js.map +1 -0
- package/dist/indexer/extractors/rust.d.ts +9 -0
- package/dist/indexer/extractors/rust.d.ts.map +1 -0
- package/dist/indexer/extractors/rust.js +322 -0
- package/dist/indexer/extractors/rust.js.map +1 -0
- package/dist/indexer/extractors/toml.d.ts +9 -0
- package/dist/indexer/extractors/toml.d.ts.map +1 -0
- package/dist/indexer/extractors/toml.js +84 -0
- package/dist/indexer/extractors/toml.js.map +1 -0
- package/dist/indexer/extractors/xml.d.ts +9 -0
- package/dist/indexer/extractors/xml.d.ts.map +1 -0
- package/dist/indexer/extractors/xml.js +163 -0
- package/dist/indexer/extractors/xml.js.map +1 -0
- package/dist/indexer/extractors/yaml.d.ts +9 -0
- package/dist/indexer/extractors/yaml.d.ts.map +1 -0
- package/dist/indexer/extractors/yaml.js +117 -0
- package/dist/indexer/extractors/yaml.js.map +1 -0
- package/dist/indexer/git.d.ts +19 -0
- package/dist/indexer/git.d.ts.map +1 -0
- package/dist/indexer/git.js +55 -0
- package/dist/indexer/git.js.map +1 -0
- package/dist/indexer/graph.d.ts +7 -0
- package/dist/indexer/graph.d.ts.map +1 -0
- package/dist/indexer/graph.js +20 -0
- package/dist/indexer/graph.js.map +1 -0
- package/dist/indexer/indexed-file.d.ts +15 -0
- package/dist/indexer/indexed-file.d.ts.map +1 -0
- package/dist/indexer/indexed-file.js +50 -0
- package/dist/indexer/indexed-file.js.map +1 -0
- package/dist/indexer/invariants.d.ts +15 -0
- package/dist/indexer/invariants.d.ts.map +1 -0
- package/dist/indexer/invariants.js +96 -0
- package/dist/indexer/invariants.js.map +1 -0
- package/dist/indexer/laravel-synthetic.d.ts +10 -0
- package/dist/indexer/laravel-synthetic.d.ts.map +1 -0
- package/dist/indexer/laravel-synthetic.js +78 -0
- package/dist/indexer/laravel-synthetic.js.map +1 -0
- package/dist/indexer/parser.d.ts +4 -0
- package/dist/indexer/parser.d.ts.map +1 -0
- package/dist/indexer/parser.js +499 -0
- package/dist/indexer/parser.js.map +1 -0
- package/dist/indexer/patch.d.ts +3 -0
- package/dist/indexer/patch.d.ts.map +1 -0
- package/dist/indexer/patch.js +291 -0
- package/dist/indexer/patch.js.map +1 -0
- package/dist/indexer/rails-synthetic.d.ts +11 -0
- package/dist/indexer/rails-synthetic.d.ts.map +1 -0
- package/dist/indexer/rails-synthetic.js +80 -0
- package/dist/indexer/rails-synthetic.js.map +1 -0
- package/dist/indexer/reconcile.d.ts +9 -0
- package/dist/indexer/reconcile.d.ts.map +1 -0
- package/dist/indexer/reconcile.js +96 -0
- package/dist/indexer/reconcile.js.map +1 -0
- package/dist/indexer/resolvers/cpp.d.ts +15 -0
- package/dist/indexer/resolvers/cpp.d.ts.map +1 -0
- package/dist/indexer/resolvers/cpp.js +35 -0
- package/dist/indexer/resolvers/cpp.js.map +1 -0
- package/dist/indexer/resolvers/csharp.d.ts +2 -0
- package/dist/indexer/resolvers/csharp.d.ts.map +1 -0
- package/dist/indexer/resolvers/csharp.js +89 -0
- package/dist/indexer/resolvers/csharp.js.map +1 -0
- package/dist/indexer/resolvers/generic.d.ts +10 -0
- package/dist/indexer/resolvers/generic.d.ts.map +1 -0
- package/dist/indexer/resolvers/generic.js +35 -0
- package/dist/indexer/resolvers/generic.js.map +1 -0
- package/dist/indexer/resolvers/go.d.ts +2 -0
- package/dist/indexer/resolvers/go.d.ts.map +1 -0
- package/dist/indexer/resolvers/go.js +171 -0
- package/dist/indexer/resolvers/go.js.map +1 -0
- package/dist/indexer/resolvers/index.d.ts +26 -0
- package/dist/indexer/resolvers/index.d.ts.map +1 -0
- package/dist/indexer/resolvers/index.js +301 -0
- package/dist/indexer/resolvers/index.js.map +1 -0
- package/dist/indexer/resolvers/java.d.ts +17 -0
- package/dist/indexer/resolvers/java.d.ts.map +1 -0
- package/dist/indexer/resolvers/java.js +104 -0
- package/dist/indexer/resolvers/java.js.map +1 -0
- package/dist/indexer/resolvers/php.d.ts +2 -0
- package/dist/indexer/resolvers/php.d.ts.map +1 -0
- package/dist/indexer/resolvers/php.js +108 -0
- package/dist/indexer/resolvers/php.js.map +1 -0
- package/dist/indexer/resolvers/python.d.ts +17 -0
- package/dist/indexer/resolvers/python.d.ts.map +1 -0
- package/dist/indexer/resolvers/python.js +63 -0
- package/dist/indexer/resolvers/python.js.map +1 -0
- package/dist/indexer/resolvers/ruby.d.ts +13 -0
- package/dist/indexer/resolvers/ruby.d.ts.map +1 -0
- package/dist/indexer/resolvers/ruby.js +199 -0
- package/dist/indexer/resolvers/ruby.js.map +1 -0
- package/dist/indexer/resolvers/rust.d.ts +12 -0
- package/dist/indexer/resolvers/rust.d.ts.map +1 -0
- package/dist/indexer/resolvers/rust.js +61 -0
- package/dist/indexer/resolvers/rust.js.map +1 -0
- package/dist/indexer/resolvers/shared.d.ts +18 -0
- package/dist/indexer/resolvers/shared.d.ts.map +1 -0
- package/dist/indexer/resolvers/shared.js +71 -0
- package/dist/indexer/resolvers/shared.js.map +1 -0
- package/dist/indexer/rust-workspace.d.ts +25 -0
- package/dist/indexer/rust-workspace.d.ts.map +1 -0
- package/dist/indexer/rust-workspace.js +155 -0
- package/dist/indexer/rust-workspace.js.map +1 -0
- package/dist/indexer/symbol-edges.d.ts +19 -0
- package/dist/indexer/symbol-edges.d.ts.map +1 -0
- package/dist/indexer/symbol-edges.js +117 -0
- package/dist/indexer/symbol-edges.js.map +1 -0
- package/dist/indexer/tokenize.d.ts +26 -0
- package/dist/indexer/tokenize.d.ts.map +1 -0
- package/dist/indexer/tokenize.js +200 -0
- package/dist/indexer/tokenize.js.map +1 -0
- package/dist/indexer/ts-parser.d.ts +10 -0
- package/dist/indexer/ts-parser.d.ts.map +1 -0
- package/dist/indexer/ts-parser.js +605 -0
- package/dist/indexer/ts-parser.js.map +1 -0
- package/dist/indexer/walker.d.ts +9 -0
- package/dist/indexer/walker.d.ts.map +1 -0
- package/dist/indexer/walker.js +91 -0
- package/dist/indexer/walker.js.map +1 -0
- package/dist/init.d.ts +111 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +787 -0
- package/dist/init.js.map +1 -0
- package/dist/kb/cli.d.ts +2 -0
- package/dist/kb/cli.d.ts.map +1 -0
- package/dist/kb/cli.js +377 -0
- package/dist/kb/cli.js.map +1 -0
- package/dist/kb/commit.d.ts +6 -0
- package/dist/kb/commit.d.ts.map +1 -0
- package/dist/kb/commit.js +84 -0
- package/dist/kb/commit.js.map +1 -0
- package/dist/kb/fold.d.ts +42 -0
- package/dist/kb/fold.d.ts.map +1 -0
- package/dist/kb/fold.js +285 -0
- package/dist/kb/fold.js.map +1 -0
- package/dist/kb/freshness.d.ts +9 -0
- package/dist/kb/freshness.d.ts.map +1 -0
- package/dist/kb/freshness.js +52 -0
- package/dist/kb/freshness.js.map +1 -0
- package/dist/kb/git.d.ts +4 -0
- package/dist/kb/git.d.ts.map +1 -0
- package/dist/kb/git.js +28 -0
- package/dist/kb/git.js.map +1 -0
- package/dist/kb/ids.d.ts +8 -0
- package/dist/kb/ids.d.ts.map +1 -0
- package/dist/kb/ids.js +42 -0
- package/dist/kb/ids.js.map +1 -0
- package/dist/kb/lint.d.ts +23 -0
- package/dist/kb/lint.d.ts.map +1 -0
- package/dist/kb/lint.js +102 -0
- package/dist/kb/lint.js.map +1 -0
- package/dist/kb/lookup.d.ts +55 -0
- package/dist/kb/lookup.d.ts.map +1 -0
- package/dist/kb/lookup.js +92 -0
- package/dist/kb/lookup.js.map +1 -0
- package/dist/kb/notes-index.d.ts +31 -0
- package/dist/kb/notes-index.d.ts.map +1 -0
- package/dist/kb/notes-index.js +0 -0
- package/dist/kb/notes-index.js.map +1 -0
- package/dist/kb/raw-log.d.ts +34 -0
- package/dist/kb/raw-log.d.ts.map +1 -0
- package/dist/kb/raw-log.js +106 -0
- package/dist/kb/raw-log.js.map +1 -0
- package/dist/kb/render.d.ts +17 -0
- package/dist/kb/render.d.ts.map +1 -0
- package/dist/kb/render.js +101 -0
- package/dist/kb/render.js.map +1 -0
- package/dist/kb/search.d.ts +110 -0
- package/dist/kb/search.d.ts.map +1 -0
- package/dist/kb/search.js +453 -0
- package/dist/kb/search.js.map +1 -0
- package/dist/kb/store.d.ts +35 -0
- package/dist/kb/store.d.ts.map +1 -0
- package/dist/kb/store.js +160 -0
- package/dist/kb/store.js.map +1 -0
- package/dist/kb/types.d.ts +142 -0
- package/dist/kb/types.d.ts.map +1 -0
- package/dist/kb/types.js +13 -0
- package/dist/kb/types.js.map +1 -0
- package/dist/kb/view-template.d.ts +8 -0
- package/dist/kb/view-template.d.ts.map +1 -0
- package/dist/kb/view-template.js +8 -0
- package/dist/kb/view-template.js.map +1 -0
- package/dist/kb/view.d.ts +42 -0
- package/dist/kb/view.d.ts.map +1 -0
- package/dist/kb/view.js +118 -0
- package/dist/kb/view.js.map +1 -0
- package/dist/kb/write.d.ts +72 -0
- package/dist/kb/write.d.ts.map +1 -0
- package/dist/kb/write.js +345 -0
- package/dist/kb/write.js.map +1 -0
- package/dist/keeper-state.d.ts +29 -0
- package/dist/keeper-state.d.ts.map +1 -0
- package/dist/keeper-state.js +89 -0
- package/dist/keeper-state.js.map +1 -0
- package/dist/keeper.d.ts +23 -0
- package/dist/keeper.d.ts.map +1 -0
- package/dist/keeper.js +105 -0
- package/dist/keeper.js.map +1 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +102 -0
- package/dist/migrate.js.map +1 -0
- package/dist/restart.d.ts +15 -0
- package/dist/restart.d.ts.map +1 -0
- package/dist/restart.js +48 -0
- package/dist/restart.js.map +1 -0
- package/dist/server/find.d.ts +39 -0
- package/dist/server/find.d.ts.map +1 -0
- package/dist/server/find.js +979 -0
- package/dist/server/find.js.map +1 -0
- package/dist/server/mcp.d.ts +117 -0
- package/dist/server/mcp.d.ts.map +1 -0
- package/dist/server/mcp.js +301 -0
- package/dist/server/mcp.js.map +1 -0
- package/dist/server/searcher.d.ts +14 -0
- package/dist/server/searcher.d.ts.map +1 -0
- package/dist/server/searcher.js +146 -0
- package/dist/server/searcher.js.map +1 -0
- package/dist/server/tools.d.ts +17 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +875 -0
- package/dist/server/tools.js.map +1 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +157 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/watcher.d.ts +11 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +67 -0
- package/dist/watcher.js.map +1 -0
- package/hooks/canonical-find-key.mjs +68 -0
- package/hooks/codex-find-nudge.mjs +15 -0
- package/hooks/codex-find-preguard.mjs +14 -0
- package/hooks/codex-kb-elicit.mjs +329 -0
- package/hooks/codex-kb-recall.mjs +151 -0
- package/hooks/codex-nudge-handler.mjs +370 -0
- package/hooks/codex-preguard-handler.mjs +75 -0
- package/hooks/codex-run-hook.mjs +132 -0
- package/hooks/coldstart-call.mjs +58 -0
- package/hooks/cursor-find-nudge.mjs +15 -0
- package/hooks/cursor-find-preguard.mjs +14 -0
- package/hooks/cursor-input.mjs +72 -0
- package/hooks/cursor-kb-elicit.mjs +335 -0
- package/hooks/cursor-kb-recall.mjs +138 -0
- package/hooks/cursor-nudge-handler.mjs +33 -0
- package/hooks/cursor-preguard-handler.mjs +39 -0
- package/hooks/cursor-run-hook.mjs +129 -0
- package/hooks/find-nudge.mjs +15 -0
- package/hooks/find-preguard.mjs +14 -0
- package/hooks/kb-elicit.mjs +334 -0
- package/hooks/kb-recall.mjs +151 -0
- package/hooks/nudge-handler.mjs +370 -0
- package/hooks/preguard-handler.mjs +75 -0
- package/hooks/run-hook.mjs +132 -0
- package/package.json +69 -0
- package/templates/coldstart.cli.md +60 -0
- package/templates/coldstart.mcp.md +59 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coldstart find <terms...>` — the rich-page query surface.
|
|
3
|
+
*
|
|
4
|
+
* One command, one page, one read. The design (verified against the arches
|
|
5
|
+
* q03/q09 traces, see docs/) fuses two signals the two-tool surface kept apart:
|
|
6
|
+
*
|
|
7
|
+
* - grep-RECALL for candidate selection: ripgrep every term across the repo,
|
|
8
|
+
* rank files by DISTINCT-TERM COVERAGE. This catches body-level matches the
|
|
9
|
+
* declared-name index misses (nested defs, dynamic refs) and is the reason a
|
|
10
|
+
* discriminating file rises — it covers MORE of the query than its lookalikes.
|
|
11
|
+
* - AST + grep PRECISION per candidate: for the top files, print indexed
|
|
12
|
+
* symbols (with line numbers) AND a filtered scan of body lines that contain
|
|
13
|
+
* the query terms (def/class/assignment lines first). The nested defs the
|
|
14
|
+
* parser cannot see and the `editable=False`-style context both show up here,
|
|
15
|
+
* inline — so the agent answers without a follow-up Read.
|
|
16
|
+
*
|
|
17
|
+
* Recall is bounded by the terms it is given: a one-token query cannot
|
|
18
|
+
* out-rank lookalikes. The skill instructs the agent to pass every salient
|
|
19
|
+
* identifier from the task — that is the load-bearing half of this command.
|
|
20
|
+
*/
|
|
21
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { deriveRelatedFiles } from '../indexer/content-tokens.js';
|
|
24
|
+
import { resolveRg, invalidateRg } from './searcher.js';
|
|
25
|
+
import { loadAll, notebookExists } from '../kb/store.js';
|
|
26
|
+
import { NOTES_REL } from '../kb/raw-log.js';
|
|
27
|
+
import { stampAnchors } from '../kb/freshness.js';
|
|
28
|
+
let _searcher = null;
|
|
29
|
+
/** Per-term scans run concurrently up to this cap, each scan single-threaded.
|
|
30
|
+
* Measured (jmri): 3 concurrent threads=1 git greps ≈ one combined pass (2.85s
|
|
31
|
+
* vs 2.65s) WITH per-term attribution, vs 0.7-2.5s × N terms sequentially. */
|
|
32
|
+
const SCAN_CONCURRENCY = 4;
|
|
33
|
+
function probe(bin, args, cwd, requireSuccess = false) {
|
|
34
|
+
try {
|
|
35
|
+
execFileSync(bin, args, { cwd, stdio: 'ignore', timeout: 4000 });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (requireSuccess)
|
|
40
|
+
return false;
|
|
41
|
+
// ENOENT = binary absent (unusable); any other exit = present but errored (usable)
|
|
42
|
+
return e.code !== 'ENOENT';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function pickSearcher(root) {
|
|
46
|
+
if (_searcher)
|
|
47
|
+
return _searcher;
|
|
48
|
+
const rg = resolveRg();
|
|
49
|
+
if (rg)
|
|
50
|
+
_searcher = { kind: 'rg', rg };
|
|
51
|
+
// The git probe MUST require exit 0: `rev-parse --is-inside-work-tree` is the
|
|
52
|
+
// work-tree test itself. With the lenient rule, any non-git root picked
|
|
53
|
+
// gitgrep, every `git grep` then failed, and find's body-content lane
|
|
54
|
+
// silently vanished (name/symbol matches only).
|
|
55
|
+
else if (probe('git', ['rev-parse', '--is-inside-work-tree'], root, true))
|
|
56
|
+
_searcher = { kind: 'gitgrep' };
|
|
57
|
+
else if (probe('grep', ['--version'], root))
|
|
58
|
+
_searcher = { kind: 'grep' };
|
|
59
|
+
else
|
|
60
|
+
_searcher = { kind: 'node' };
|
|
61
|
+
return _searcher;
|
|
62
|
+
}
|
|
63
|
+
const MAX_CANDIDATES_LISTED = 12;
|
|
64
|
+
const DETAIL_TOP = 3; // top files get an inline convergence preview; the rest list as bare paths
|
|
65
|
+
// Convergence-preview parameters (denoise hub files: show where the rarest terms cluster).
|
|
66
|
+
const WINDOW = 3; // lines each side counted for local term convergence
|
|
67
|
+
const RARE_FRAC = 0.05; // a term is a discriminator if it hits < 5% of candidate files
|
|
68
|
+
const MAX_PREVIEW_LINES = 8; // total body lines shown per file
|
|
69
|
+
const MAX_CLUSTERS = 2; // distinct match regions shown per file
|
|
70
|
+
const IMPORT_RE = /^\s*(import\b|from\s+[\w.]+\s+import\b|export\s+[^=]*\bfrom\b|(?:const|let|var)\s+[^=]*=\s*require\s*\(|require\s*\(|use\s+[\w:\\]+|using\s+[\w.]+|#include\b|@import\b)/;
|
|
71
|
+
/** Prose/doc files (release notes, READMEs). They mention every term in plain
|
|
72
|
+
* text and otherwise out-rank the source file the agent is actually looking for,
|
|
73
|
+
* so they're partitioned into a secondary list rather than competing with code. */
|
|
74
|
+
const DOC_EXT = /\.(md|markdown|rst|txt|adoc|rdoc)$/i;
|
|
75
|
+
const isDoc = (rel) => DOC_EXT.test(rel);
|
|
76
|
+
const MAX_DOC_CANDIDATES = 4;
|
|
77
|
+
/** Stylesheets match style vocabulary (sidebar/nav/menu as class names) and crowd code queries,
|
|
78
|
+
* but ARE the right answer for a genuine CSS task — so they're partitioned into a bare list
|
|
79
|
+
* (no preview, no evidence sentence) rather than dropped. SCSS is the source, never single it out. */
|
|
80
|
+
const STYLE_EXT = /\.(css|scss|sass|less)$/i;
|
|
81
|
+
const isStyle = (rel) => STYLE_EXT.test(rel);
|
|
82
|
+
const MAX_STYLE_CANDIDATES = 4;
|
|
83
|
+
/** Minified bundles and vendored third-party trees are never the human-readable answer: a minified
|
|
84
|
+
* file matches many terms on one giant line (inflating coverage) and previews as noise. Detected by
|
|
85
|
+
* path (vendored tree / *.min.*) OR by content shape — chars-per-line ≫ real source (minified bundles
|
|
86
|
+
* run 2700-43000 cpl, real source p99 ≈ 600), which catches un-suffixed bundles like nifty.js that the
|
|
87
|
+
* path rule misses. Scoped to script/style languages so data/config files and golds are never touched.
|
|
88
|
+
* Index-only (lineCount/tokenEstimate), NO file read. */
|
|
89
|
+
const VENDOR_PATH = /(^|\/)(node_modules|vendor|bower_components)\//i;
|
|
90
|
+
const MIN_SUFFIX = /\.min\.(js|css)$/i;
|
|
91
|
+
const SCRIPT_STYLE_EXT = /\.(js|jsx|ts|tsx|mjs|cjs|css|scss|sass|less)$/i;
|
|
92
|
+
const MINIFIED_CPL = 1000;
|
|
93
|
+
const isVendorOrMinified = (rel, file) => {
|
|
94
|
+
if (VENDOR_PATH.test(rel) || MIN_SUFFIX.test(rel))
|
|
95
|
+
return true;
|
|
96
|
+
return SCRIPT_STYLE_EXT.test(rel) && file.lineCount > 0
|
|
97
|
+
&& (file.tokenEstimate * 4) / file.lineCount > MINIFIED_CPL;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Pass 1 — import co-citation among the candidate set (precision relations).
|
|
101
|
+
*
|
|
102
|
+
* For the files we're about to list, compute how they relate TO EACH OTHER via the
|
|
103
|
+
* import graph, so the page can say "consumed by graph.py" instead of leaving the
|
|
104
|
+
* agent to infer it. Three relations, strongest first:
|
|
105
|
+
* - consumes / consumedBy: a DIRECT import edge between two candidates (a fact).
|
|
106
|
+
* - siblings: two candidates share a non-hub import neighbour (co-citation). A
|
|
107
|
+
* shared neighbour imported by > HUB_DEGREE files is a utility hub, not evidence
|
|
108
|
+
* of relatedness, so it's discounted (offline: hub-discounted co-citation degree
|
|
109
|
+
* separated gold from lookalikes 2.05×; raw degree did not).
|
|
110
|
+
* Index-only (outEdges/inEdges/importedByCount), NO file reads.
|
|
111
|
+
*/
|
|
112
|
+
const HUB_DEGREE = 30;
|
|
113
|
+
function computeImportRelations(index, candidates) {
|
|
114
|
+
const candSet = new Set(candidates);
|
|
115
|
+
// Non-hub neighbour signature per candidate: low-fanin import targets + low-fanout importers.
|
|
116
|
+
// Importer ids are namespaced ('<' prefix) so a shared target and a shared importer never collide.
|
|
117
|
+
const sig = new Map();
|
|
118
|
+
for (const c of candidates) {
|
|
119
|
+
const s = new Set();
|
|
120
|
+
for (const t of index.outEdges.get(c) ?? []) {
|
|
121
|
+
const f = index.files.get(t);
|
|
122
|
+
if (f && f.importedByCount <= HUB_DEGREE)
|
|
123
|
+
s.add(t);
|
|
124
|
+
}
|
|
125
|
+
for (const p of index.inEdges.get(c) ?? []) {
|
|
126
|
+
if ((index.outEdges.get(p) ?? []).length <= HUB_DEGREE)
|
|
127
|
+
s.add('<' + p);
|
|
128
|
+
}
|
|
129
|
+
sig.set(c, s);
|
|
130
|
+
}
|
|
131
|
+
const rels = new Map();
|
|
132
|
+
for (const c of candidates) {
|
|
133
|
+
const outs = new Set(index.outEdges.get(c) ?? []);
|
|
134
|
+
const ins = new Set(index.inEdges.get(c) ?? []);
|
|
135
|
+
const consumes = [], consumedBy = [], siblings = [];
|
|
136
|
+
const sc = sig.get(c);
|
|
137
|
+
for (const d of candidates) {
|
|
138
|
+
if (d === c)
|
|
139
|
+
continue;
|
|
140
|
+
if (outs.has(d)) {
|
|
141
|
+
consumes.push(d);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ins.has(d)) {
|
|
145
|
+
consumedBy.push(d);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const sd = sig.get(d);
|
|
149
|
+
let shared = false;
|
|
150
|
+
for (const n of sc)
|
|
151
|
+
if (sd.has(n)) {
|
|
152
|
+
shared = true;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (shared)
|
|
156
|
+
siblings.push(d);
|
|
157
|
+
}
|
|
158
|
+
rels.set(c, { consumes, consumedBy, siblings });
|
|
159
|
+
void candSet;
|
|
160
|
+
}
|
|
161
|
+
return rels;
|
|
162
|
+
}
|
|
163
|
+
/** Separator-fold for the NAME/PATH/SYMBOL channels: query vocabulary and code
|
|
164
|
+
* naming disagree on separators for the same compound — `spatial-view` vs
|
|
165
|
+
* `spatial_view` vs `SpatialView` vs `spatialview` are one concept. Folding
|
|
166
|
+
* `-`/`_` out of BOTH sides makes those channels separator-invariant (a strict
|
|
167
|
+
* superset of the literal matches). Body/grep matching stays literal — text is
|
|
168
|
+
* matched as written. (Root-caused from the q16 three-arm trace: the gold
|
|
169
|
+
* migration ranked tail-[1/3] purely because the agent hyphenated its query.) */
|
|
170
|
+
export function foldSep(s) {
|
|
171
|
+
return s.replace(/[-_]/g, '');
|
|
172
|
+
}
|
|
173
|
+
/** Clean a raw query into distinct, matchable terms (identifiers ≥3 chars). */
|
|
174
|
+
export function parseTerms(raw) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
for (const w of raw.split(/[\s[\]|,()'".]+/)) {
|
|
178
|
+
if (w.length < 3 || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(w))
|
|
179
|
+
continue;
|
|
180
|
+
const key = w.toLowerCase();
|
|
181
|
+
if (seen.has(key))
|
|
182
|
+
continue;
|
|
183
|
+
seen.add(key);
|
|
184
|
+
out.push(w);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** argv for one single-threaded per-term scan (they parallelize ACROSS terms). */
|
|
189
|
+
function scanArgv(searcher, term) {
|
|
190
|
+
switch (searcher.kind) {
|
|
191
|
+
case 'rg':
|
|
192
|
+
// `-j2`: two worker threads per scan — bounded so N concurrent finds can't
|
|
193
|
+
// thrash (unbounded default threads was the original mechanism, see PR#46),
|
|
194
|
+
// but measured on jmri/M1: -j1 3-term recall 1.04s, -j2 0.60s, -j4 0.70s
|
|
195
|
+
// with sys-time blowup. 12 simultaneous -j2 scans finish in 0.8s — no thrash.
|
|
196
|
+
// rg skips .gitignore'd + binary files and searches untracked by default.
|
|
197
|
+
return { bin: searcher.rg.bin, args: ['-l', '-i', '-F', '-j', '2', '--', term, '.'], ...(searcher.rg.argv0 ? { argv0: searcher.rg.argv0 } : {}) };
|
|
198
|
+
case 'gitgrep':
|
|
199
|
+
// `-c grep.threads=1`: same bounded-threads reasoning as rg -j1 above.
|
|
200
|
+
// `--untracked`: also search new, uncommitted files — the index includes them
|
|
201
|
+
// (the keeper watches live edits), but plain `git grep` only sees tracked files.
|
|
202
|
+
return { bin: 'git', args: ['-c', 'grep.threads=1', 'grep', '--untracked', '-l', '-i', '-F', '-I', '-e', term] };
|
|
203
|
+
case 'grep':
|
|
204
|
+
return { bin: 'grep', args: ['-r', '-l', '-i', '-F', '-I', '--', term, '.'] };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Run one external scan; repo-relative paths, [] on no-match, null on SPAWN failure
|
|
208
|
+
* (binary vanished — distinct from exit 1 so the caller can re-resolve rg). */
|
|
209
|
+
function listFilesExternal(searcher, root, term) {
|
|
210
|
+
const { bin, args, argv0 } = scanArgv(searcher, term);
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
execFile(bin, args, {
|
|
213
|
+
cwd: root,
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
216
|
+
...(argv0 ? { argv0 } : {}),
|
|
217
|
+
}, (err, stdout) => {
|
|
218
|
+
// execFile err: string `code` (ENOENT/EACCES) = spawn-level failure;
|
|
219
|
+
// numeric `code` = the tool ran and exited non-zero (= no matches).
|
|
220
|
+
if (err && typeof err.code === 'string')
|
|
221
|
+
return resolve(null);
|
|
222
|
+
resolve((stdout ?? '').split('\n').map((l) => l.replace(/^\.\//, '').trim()).filter(Boolean));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/** Map with a concurrency cap; results stay index-aligned with `items`. */
|
|
227
|
+
async function mapLimit(items, limit, fn) {
|
|
228
|
+
const out = new Array(items.length);
|
|
229
|
+
let next = 0;
|
|
230
|
+
const worker = async () => {
|
|
231
|
+
while (next < items.length) {
|
|
232
|
+
const i = next++;
|
|
233
|
+
out[i] = await fn(items[i]);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Build term-coverage per indexed file. External searchers run per-term; the
|
|
241
|
+
* Node fallback reads each indexed file once and tests all terms together.
|
|
242
|
+
* Only files present in the index count (restricts to indexed code files).
|
|
243
|
+
*/
|
|
244
|
+
export async function collectCoverage(index, root, terms) {
|
|
245
|
+
const coverage = new Map();
|
|
246
|
+
const add = (rel, term) => {
|
|
247
|
+
if (!index.files.has(rel))
|
|
248
|
+
return;
|
|
249
|
+
let s = coverage.get(rel);
|
|
250
|
+
if (!s) {
|
|
251
|
+
s = new Set();
|
|
252
|
+
coverage.set(rel, s);
|
|
253
|
+
}
|
|
254
|
+
s.add(term);
|
|
255
|
+
};
|
|
256
|
+
const lowers = terms.map((t) => t.toLowerCase());
|
|
257
|
+
// (A) body-content matches — what grep sees. Terms scan concurrently (capped).
|
|
258
|
+
let searcher = pickSearcher(root);
|
|
259
|
+
if (searcher.kind !== 'node') {
|
|
260
|
+
const s = searcher;
|
|
261
|
+
let results = await mapLimit(terms, SCAN_CONCURRENCY, (t) => listFilesExternal(s, root, t));
|
|
262
|
+
if (searcher.kind === 'rg' && results.some((r) => r === null)) {
|
|
263
|
+
// The persisted rg vanished (app uninstalled, binary moved). Drop the
|
|
264
|
+
// record, re-resolve the whole chain once, and rerun the scans.
|
|
265
|
+
invalidateRg();
|
|
266
|
+
_searcher = null;
|
|
267
|
+
searcher = pickSearcher(root);
|
|
268
|
+
if (searcher.kind !== 'node') {
|
|
269
|
+
const s2 = searcher;
|
|
270
|
+
results = await mapLimit(terms, SCAN_CONCURRENCY, (t) => listFilesExternal(s2, root, t));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
results = terms.map(() => []);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (let i = 0; i < terms.length; i++) {
|
|
277
|
+
for (const rel of results[i] ?? [])
|
|
278
|
+
add(rel, terms[i]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (searcher.kind === 'node') {
|
|
282
|
+
// Pure-Node fallback: one read per indexed file, all terms tested at once.
|
|
283
|
+
for (const [rel, file] of index.files.entries()) {
|
|
284
|
+
let text;
|
|
285
|
+
try {
|
|
286
|
+
text = readFileSync(file.path, 'utf8').toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
for (let i = 0; i < terms.length; i++) {
|
|
292
|
+
if (text.includes(lowers[i]))
|
|
293
|
+
add(rel, terms[i]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// (B) name/path/symbol matches — the signal grep CANNOT see. A file whose
|
|
298
|
+
// distinguishing token is its own filename (e.g. 11726_join_tile_nodegroup.py)
|
|
299
|
+
// or a declared symbol name is invisible to a body-content grep; without this
|
|
300
|
+
// it sinks into a wall of equal-coverage lookalikes. This restores the
|
|
301
|
+
// declared-name index that the two-tool GO surface ranked on.
|
|
302
|
+
// Separator-folded: `spatial-view` must hit `spatial_view_…filter.py`.
|
|
303
|
+
const folded = lowers.map(foldSep);
|
|
304
|
+
for (const [rel, file] of index.files.entries()) {
|
|
305
|
+
const fpath = foldSep(rel.toLowerCase());
|
|
306
|
+
for (let i = 0; i < terms.length; i++) {
|
|
307
|
+
const t = folded[i];
|
|
308
|
+
if (fpath.includes(t) || file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t))) {
|
|
309
|
+
add(rel, terms[i]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return coverage;
|
|
314
|
+
}
|
|
315
|
+
/** Per-term document frequency over the candidate set (how many candidate files contain each term).
|
|
316
|
+
* This is the rarity signal: a low-DF term is a discriminator, a high-DF term ("view" → 45% of files)
|
|
317
|
+
* is noise that should neither rank a file nor light up a body line. */
|
|
318
|
+
function docFreq(coverage, terms) {
|
|
319
|
+
const df = new Map();
|
|
320
|
+
for (const t of terms)
|
|
321
|
+
df.set(t, 0);
|
|
322
|
+
for (const set of coverage.values()) {
|
|
323
|
+
for (const t of set)
|
|
324
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
325
|
+
}
|
|
326
|
+
return df;
|
|
327
|
+
}
|
|
328
|
+
/** Mark comment-only and docstring lines so they don't count as content matches — this is what kills
|
|
329
|
+
* the "view"⊂"review" docstring noise and prose hits. Approximate (line-based) but cheap and language-agnostic. */
|
|
330
|
+
function excludedLines(lines) {
|
|
331
|
+
const ex = new Array(lines.length).fill(false);
|
|
332
|
+
let inTriple = false;
|
|
333
|
+
let delim = '';
|
|
334
|
+
for (let i = 0; i < lines.length; i++) {
|
|
335
|
+
const s = lines[i].trim();
|
|
336
|
+
if (inTriple) {
|
|
337
|
+
ex[i] = true;
|
|
338
|
+
if (s.includes(delim))
|
|
339
|
+
inTriple = false;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const m = s.match(/("""|''')/);
|
|
343
|
+
if (m) {
|
|
344
|
+
ex[i] = true;
|
|
345
|
+
if ((s.split(m[1]).length - 1) % 2 === 1) {
|
|
346
|
+
inTriple = true;
|
|
347
|
+
delim = m[1];
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (/^(#|\/\/|\/\*|\*|<!--|;;|--\s)/.test(s))
|
|
352
|
+
ex[i] = true;
|
|
353
|
+
}
|
|
354
|
+
return ex;
|
|
355
|
+
}
|
|
356
|
+
/** Innermost indexed symbol whose line range contains `line` (1-based) — used to LABEL a preview
|
|
357
|
+
* cluster with its scope (e.g. "in class ResourceInstance"), not to print the whole symbol list. */
|
|
358
|
+
function enclosing(file, line) {
|
|
359
|
+
let best = null;
|
|
360
|
+
for (const s of file.symbols) {
|
|
361
|
+
if (s.startLine <= line && line <= s.endLine) {
|
|
362
|
+
if (!best || s.endLine - s.startLine < best.endLine - best.startLine)
|
|
363
|
+
best = s;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return best;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Convergence preview: instead of dumping every line that mentions a term (which floods hub files
|
|
370
|
+
* like models.py with matches from unrelated classes), score each matched line by RARITY-WEIGHTED
|
|
371
|
+
* LOCAL CONVERGENCE — sum of 1/DF over the distinct query terms within ±WINDOW lines. The answer is
|
|
372
|
+
* where the rare terms co-locate; scattered single-common-term matches score ~20-60× lower and drop out.
|
|
373
|
+
* A matched line that is an import statement is boosted and annotated with its resolved target, because
|
|
374
|
+
* it both marks a usage and points at the definition file.
|
|
375
|
+
*/
|
|
376
|
+
function convergencePreview(file, terms, df, nFiles, specToTarget) {
|
|
377
|
+
let text;
|
|
378
|
+
try {
|
|
379
|
+
text = readFileSync(file.path, 'utf8');
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
const lines = text.split('\n');
|
|
385
|
+
const excl = excludedLines(lines);
|
|
386
|
+
const lterms = terms.map((t) => t.toLowerCase());
|
|
387
|
+
const weight = (t) => nFiles / Math.max(1, df.get(t) ?? nFiles);
|
|
388
|
+
const isRare = (t) => (df.get(t) ?? nFiles) / nFiles < RARE_FRAC;
|
|
389
|
+
// terms present in each non-excluded line
|
|
390
|
+
const inLine = lines.map((l, i) => {
|
|
391
|
+
if (excl[i])
|
|
392
|
+
return [];
|
|
393
|
+
const low = l.toLowerCase();
|
|
394
|
+
return terms.filter((_t, k) => low.includes(lterms[k]));
|
|
395
|
+
});
|
|
396
|
+
const hits = [];
|
|
397
|
+
for (let i = 0; i < lines.length; i++) {
|
|
398
|
+
if (inLine[i].length === 0)
|
|
399
|
+
continue;
|
|
400
|
+
const windowTerms = new Set();
|
|
401
|
+
for (let j = Math.max(0, i - WINDOW); j <= Math.min(lines.length - 1, i + WINDOW); j++) {
|
|
402
|
+
for (const t of inLine[j])
|
|
403
|
+
windowTerms.add(t);
|
|
404
|
+
}
|
|
405
|
+
// keep only discriminating lines: ≥2 distinct terms nearby, OR a rare term on the line itself.
|
|
406
|
+
// (drops `resourceinstanceid` in an unrelated class — one common term, no neighbours.)
|
|
407
|
+
if (windowTerms.size < 2 && !inLine[i].some(isRare))
|
|
408
|
+
continue;
|
|
409
|
+
let score = 0;
|
|
410
|
+
for (const t of windowTerms)
|
|
411
|
+
score += weight(t);
|
|
412
|
+
if (IMPORT_RE.test(lines[i]))
|
|
413
|
+
score *= 1.5;
|
|
414
|
+
hits.push({ line: i, score });
|
|
415
|
+
}
|
|
416
|
+
if (hits.length === 0)
|
|
417
|
+
return [];
|
|
418
|
+
// cluster kept hits by proximity, then rank clusters by peak score
|
|
419
|
+
hits.sort((a, b) => a.line - b.line);
|
|
420
|
+
const clusters = [];
|
|
421
|
+
for (const h of hits) {
|
|
422
|
+
const last = clusters[clusters.length - 1];
|
|
423
|
+
if (last && h.line - last[last.length - 1].line <= WINDOW)
|
|
424
|
+
last.push(h);
|
|
425
|
+
else
|
|
426
|
+
clusters.push([h]);
|
|
427
|
+
}
|
|
428
|
+
const peakOf = (c) => Math.max(...c.map((h) => h.score));
|
|
429
|
+
clusters.sort((a, b) => peakOf(b) - peakOf(a));
|
|
430
|
+
const out = [];
|
|
431
|
+
let shown = 0;
|
|
432
|
+
for (const cluster of clusters.slice(0, MAX_CLUSTERS)) {
|
|
433
|
+
if (shown >= MAX_PREVIEW_LINES)
|
|
434
|
+
break;
|
|
435
|
+
const peak = cluster.reduce((a, b) => (b.score > a.score ? b : a));
|
|
436
|
+
const sym = enclosing(file, peak.line + 1);
|
|
437
|
+
out.push(sym
|
|
438
|
+
? ` in ${sym.kind} ${sym.name} [L${sym.startLine}-${sym.endLine}]:`
|
|
439
|
+
: ` near L${peak.line + 1}:`);
|
|
440
|
+
const lineSet = new Set(cluster.map((h) => h.line));
|
|
441
|
+
const start = Math.max(0, cluster[0].line - 1); // one context line above
|
|
442
|
+
const end = Math.min(lines.length - 1, cluster[cluster.length - 1].line + 1); // and below
|
|
443
|
+
for (let i = start; i <= end && shown < MAX_PREVIEW_LINES; i++) {
|
|
444
|
+
if (excl[i] && !lineSet.has(i))
|
|
445
|
+
continue; // skip comment context, keep comment only if it's a hit
|
|
446
|
+
const raw = lines[i].trim().slice(0, 120);
|
|
447
|
+
if (!raw)
|
|
448
|
+
continue;
|
|
449
|
+
let suffix = '';
|
|
450
|
+
if (IMPORT_RE.test(lines[i])) {
|
|
451
|
+
const tgt = specToTarget.find((st) => st.spec && lines[i].includes(st.spec));
|
|
452
|
+
if (tgt)
|
|
453
|
+
suffix = ` → defined in ${tgt.target}`;
|
|
454
|
+
}
|
|
455
|
+
out.push(` L${i + 1}: ${raw}${suffix}`);
|
|
456
|
+
shown++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
// Notebook lane: agent-authored knowledge about a previewed file, shown at
|
|
462
|
+
// the point where the read decision is made (49% of gold answer files were
|
|
463
|
+
// note-anchored after one 27-question run). Evidence, not instruction — a
|
|
464
|
+
// title + gist + freshness stamp; whether the file still needs opening stays
|
|
465
|
+
// the agent's call. One note per file: file-note summary beats flow-step role
|
|
466
|
+
// beats lesson title.
|
|
467
|
+
export function buildNoteMap(root, lterms) {
|
|
468
|
+
const out = new Map();
|
|
469
|
+
if (!notebookExists(root))
|
|
470
|
+
return out;
|
|
471
|
+
let notes;
|
|
472
|
+
try {
|
|
473
|
+
notes = loadAll(root).notes;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
for (const note of notes) {
|
|
479
|
+
if (note.status !== 'active')
|
|
480
|
+
continue;
|
|
481
|
+
const rank = note.type === 'file' ? 0 : note.type === 'flow' ? 1 : 2;
|
|
482
|
+
// Hub files anchor many notes — pick the one the QUERY is about (term
|
|
483
|
+
// hits on the note's declared identity), not just the "best" type. An
|
|
484
|
+
// off-topic annotation on a hub file is noise, not evidence. A file
|
|
485
|
+
// note's identity includes its facet SYMBOLS: a query naming GraphModel
|
|
486
|
+
// is about the models.py file note even though its title is just the
|
|
487
|
+
// path — without them, any flow sharing one query word steals the slot
|
|
488
|
+
// from the note that holds the answer.
|
|
489
|
+
const name = foldSep([note.title, ...note.aliases,
|
|
490
|
+
...(note.type === 'file' ? note.facets.map((f) => f.symbol) : [])].join(' ').toLowerCase());
|
|
491
|
+
const hits = lterms.filter((t) => name.includes(foldSep(t))).length;
|
|
492
|
+
for (const a of note.anchors) {
|
|
493
|
+
const cur = out.get(a.path);
|
|
494
|
+
if (cur && (cur.hits > hits || (cur.hits === hits && cur.rank <= rank)))
|
|
495
|
+
continue;
|
|
496
|
+
const role = note.type === 'flow' ? note.steps.find((s) => s.path === a.path)?.role : undefined;
|
|
497
|
+
out.set(a.path, { note, role, rank, hits });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
/** First sentence (or first ~n chars) of a prose blob — the agent-authored
|
|
503
|
+
* summary opener, which prompt v4 makes carry the file's non-obvious point. */
|
|
504
|
+
function firstSentence(s, n) {
|
|
505
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
506
|
+
const stop = t.search(/[.!?](\s|$)/);
|
|
507
|
+
const cut = stop >= 20 ? t.slice(0, stop + 1) : t;
|
|
508
|
+
return cut.length > n ? cut.slice(0, n) + '…' : cut;
|
|
509
|
+
}
|
|
510
|
+
/** Empty line → the caller drops it: a Summary: that only restates the path is
|
|
511
|
+
* noise, not evidence (a file note's title IS the path).
|
|
512
|
+
* `summary` — the gist is summary-grade prose (a single's body sentence or a
|
|
513
|
+
* query-matched facet detail): a past agent's verified description of THIS
|
|
514
|
+
* file. find then drops the convergence preview for the file — the summary
|
|
515
|
+
* IS the preview (user ruling 2026-07-06: file summaries live in find; flows
|
|
516
|
+
* live in kb search). Stale notes still surface — most of a note survives
|
|
517
|
+
* an edit — with [evidence changed] marking what to re-verify. */
|
|
518
|
+
export function noteLine(root, rel, entry, lterms = []) {
|
|
519
|
+
const { note, role } = entry;
|
|
520
|
+
const clamp = (s, n) => {
|
|
521
|
+
const t = s.replace(/\s+/g, ' ').trim();
|
|
522
|
+
return t.length > n ? t.slice(0, n) + '…' : t;
|
|
523
|
+
};
|
|
524
|
+
let gist = '';
|
|
525
|
+
let summary = false;
|
|
526
|
+
if (note.type === 'flow') {
|
|
527
|
+
// Full role text — the step role is the flow's statement about THIS
|
|
528
|
+
// file, and a clamp cuts exactly at the payload (q8 replay evidence).
|
|
529
|
+
const roleTxt = role ? role.replace(/\s+/g, ' ').trim() : '';
|
|
530
|
+
gist = `part of "${clamp(note.title, 80)}"${roleTxt ? ` — ${roleTxt}` : ''}`;
|
|
531
|
+
}
|
|
532
|
+
else if (note.type === 'file') {
|
|
533
|
+
// Hub whose facet the query names → that facet's FULL detail (it replaces
|
|
534
|
+
// the preview, so untruncated is still a net page shrink; a clamp only
|
|
535
|
+
// hides the payload — 11-transcript replay showed agents never fetch the
|
|
536
|
+
// full note off a teaser line). Single → the WHOLE body: at ~400B the
|
|
537
|
+
// body IS the note (user ruling 2026-07-07). Otherwise the hub's symbol
|
|
538
|
+
// inventory (what the note knows, one lookup away).
|
|
539
|
+
const matched = lterms.length
|
|
540
|
+
? note.facets.find((f) => { const fs = foldSep(f.symbol.toLowerCase()); return lterms.some((t) => fs.includes(foldSep(t))); })
|
|
541
|
+
: undefined;
|
|
542
|
+
const prose = (note.summary || note.body || '').replace(/\s+/g, ' ').trim();
|
|
543
|
+
if (matched) {
|
|
544
|
+
gist = `${matched.symbol} — ${matched.detail.replace(/\s+/g, ' ').trim()}`;
|
|
545
|
+
summary = true;
|
|
546
|
+
}
|
|
547
|
+
else if (prose && note.character === 'single') {
|
|
548
|
+
gist = prose;
|
|
549
|
+
summary = true;
|
|
550
|
+
}
|
|
551
|
+
else if (prose) {
|
|
552
|
+
gist = firstSentence(prose, 220);
|
|
553
|
+
summary = true;
|
|
554
|
+
}
|
|
555
|
+
else if (note.facets.length)
|
|
556
|
+
gist = clamp(`facets: ${note.facets.map((f) => f.symbol).join(', ')}`, 150);
|
|
557
|
+
else if (note.aliases.length)
|
|
558
|
+
gist = clamp(note.aliases[0], 120);
|
|
559
|
+
else
|
|
560
|
+
return { line: '', summary: false }; // nothing beyond the path — silence over noise
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
gist = `${note.kind ?? 'lesson'}: ${clamp(note.title, 130)}`;
|
|
564
|
+
}
|
|
565
|
+
const anchor = note.anchors.find((a) => a.path === rel);
|
|
566
|
+
const state = anchor ? stampAnchors(root, [anchor])[0]?.state : undefined;
|
|
567
|
+
const fresh = state === 'fresh' ? ' [fresh]' : state === 'changed' || state === 'missing' ? ' [evidence changed]' : '';
|
|
568
|
+
// "Summary:" (user ruling 2026-07-08): a past agent's high-level overview of
|
|
569
|
+
// THIS file — and the full note is a real markdown file the reader can open
|
|
570
|
+
// directly. A path is the one pointer agents reliably follow (grep→Read is
|
|
571
|
+
// trained-in; "re-search by title words" measurably is not).
|
|
572
|
+
return { line: ` Summary: ${gist}${fresh} · full note: ${NOTES_REL}/${note.id}.md`, summary };
|
|
573
|
+
}
|
|
574
|
+
export async function buildRichPage(index, root, rawQuery, asData = false, via = false) {
|
|
575
|
+
const terms = parseTerms(rawQuery);
|
|
576
|
+
if (terms.length === 0) {
|
|
577
|
+
return 'find: no usable terms. Pass the salient identifiers from the task, e.g. `coldstart find ResourceInstance principaluser editable`.';
|
|
578
|
+
}
|
|
579
|
+
// 1. grep-recall: term → indexed files, accumulate distinct-term coverage.
|
|
580
|
+
const coverage = await collectCoverage(index, root, terms);
|
|
581
|
+
if (coverage.size === 0) {
|
|
582
|
+
return `find: no indexed file contains any of [${terms.join(', ')}].\nThese identifiers may not exist in the repo, or be in excluded/binary files. Reformulate, or grep directly.`;
|
|
583
|
+
}
|
|
584
|
+
const df = docFreq(coverage, terms);
|
|
585
|
+
const nFiles = index.files.size;
|
|
586
|
+
const lterms = terms.map((t) => t.toLowerCase());
|
|
587
|
+
const flterms = lterms.map(foldSep); // name/path/symbol channels match separator-folded
|
|
588
|
+
const rareTerms = terms.filter((t) => (df.get(t) ?? nFiles) / nFiles < RARE_FRAC);
|
|
589
|
+
// Rarity (BM25-style IDF): a term hitting few files is a discriminator; a common domain
|
|
590
|
+
// token (order/payment/view) hits ~everything → near-zero weight. This is what makes the
|
|
591
|
+
// ranker robust on domain-heavy repos and what keeps it from promoting hubs.
|
|
592
|
+
const idf = (t) => {
|
|
593
|
+
const d = df.get(t) ?? 0;
|
|
594
|
+
return Math.log(1 + (nFiles - d + 0.5) / (d + 0.5));
|
|
595
|
+
};
|
|
596
|
+
const maxIdf = Math.max(1, ...terms.map(idf));
|
|
597
|
+
// Relevance score — index-only, NO file reads (term frequency was measured to add nothing
|
|
598
|
+
// once name/path are in play; see find-ranking sim). Binary term-coverage sets the tier;
|
|
599
|
+
// within/across tiers a file is lifted by rarity-weighted evidence that a term NAMES a
|
|
600
|
+
// declared symbol here (definition signal) or appears in the PATH. Length-free + IDF-weighted,
|
|
601
|
+
// so the 2700-line hub gets no edge. (Offline: top-12 recall 67%→79%, hub-misfires ~halved.)
|
|
602
|
+
const NAME_W = 3, PATH_W = 2;
|
|
603
|
+
const scoreCache = new Map();
|
|
604
|
+
const score = (rel) => {
|
|
605
|
+
let v = scoreCache.get(rel);
|
|
606
|
+
if (v !== undefined)
|
|
607
|
+
return v;
|
|
608
|
+
const file = index.files.get(rel);
|
|
609
|
+
const fpathLow = foldSep(rel.toLowerCase());
|
|
610
|
+
let boost = 0;
|
|
611
|
+
for (let k = 0; k < terms.length; k++) {
|
|
612
|
+
const t = flterms[k];
|
|
613
|
+
if (!t)
|
|
614
|
+
continue;
|
|
615
|
+
if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t)))
|
|
616
|
+
boost += NAME_W * idf(terms[k]);
|
|
617
|
+
if (fpathLow.includes(t))
|
|
618
|
+
boost += PATH_W * idf(terms[k]);
|
|
619
|
+
}
|
|
620
|
+
v = coverage.get(rel).size * 3 + boost / maxIdf;
|
|
621
|
+
scoreCache.set(rel, v);
|
|
622
|
+
return v;
|
|
623
|
+
};
|
|
624
|
+
// rank: relevance score desc, then path (stable). importedBy dropped — centrality was the hub trap.
|
|
625
|
+
const ranked = [...coverage.entries()].sort((a, b) => {
|
|
626
|
+
const d = score(b[0]) - score(a[0]);
|
|
627
|
+
return d !== 0 ? d : a[0].localeCompare(b[0]);
|
|
628
|
+
});
|
|
629
|
+
// Per-file MATCH EVIDENCE, as a short plain-English sentence — what the file actually does with the
|
|
630
|
+
// query terms, strongest signal first: declares a symbol of that name > the term is in its path >
|
|
631
|
+
// the term is imported here (a consumer, not the definer) > a rare term sits in its body. Common
|
|
632
|
+
// (non-discriminating) body terms are omitted — every file matches them, so listing them is the
|
|
633
|
+
// noise we're cutting. `disc` (count of discriminating signals) doubles as the NOISE FLOOR:
|
|
634
|
+
// disc===0 means the file matched only common words → dropped from the listing. Index-only
|
|
635
|
+
// (graph edges + declared symbols), NO file reads; cached per file.
|
|
636
|
+
const rareSet = new Set(rareTerms);
|
|
637
|
+
const tick = (s) => `\`${s}\``;
|
|
638
|
+
// Does `rel` import a definition that carries `term`? A term appearing on an import line resolves
|
|
639
|
+
// to an out-edge whose target file is named for the term or declares a symbol of that name. Cheap:
|
|
640
|
+
// walks only this file's out-edges. Lets the sentence say "imports X" (consumer) vs "mentions X".
|
|
641
|
+
const importTargetFor = (rel, term) => {
|
|
642
|
+
const tl = foldSep(term.toLowerCase());
|
|
643
|
+
for (const tgt of index.outEdges.get(rel) ?? []) {
|
|
644
|
+
if (foldSep(tgt.toLowerCase()).includes(tl))
|
|
645
|
+
return tgt;
|
|
646
|
+
const tf = index.files.get(tgt);
|
|
647
|
+
if (tf && tf.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(tl)))
|
|
648
|
+
return tgt;
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
};
|
|
652
|
+
// Grade-1 symbol locator: for a previewed file, the DECLARED symbols whose name matches a
|
|
653
|
+
// discriminating query term, with their line ranges — so the agent reads the right offset
|
|
654
|
+
// instead of windowing a large file to find them. The convergence preview shows where rare
|
|
655
|
+
// terms CLUSTER (often an incidental method); this shows where the NAMED symbols LIVE — the
|
|
656
|
+
// agent's actual read targets. (q22 read graph.py 8× hunting for serialize/restore_state that
|
|
657
|
+
// the index had located at L1965/L2098.) Index-only, NO file read. Container symbols that
|
|
658
|
+
// enclose another matched symbol are dropped (keep the specific method, not the 2800-line class).
|
|
659
|
+
const MATCHED_SYM_CAP = 6;
|
|
660
|
+
const MATCHED_SYM_MIN_LINES = 80; // only worth it on files big enough to hunt within
|
|
661
|
+
// Gate symbol matching on terms that DISCRIMINATE THIS QUERY (idf ≥ the query's median), not the
|
|
662
|
+
// global 5% rare threshold: `serialize`/`restore` are common repo-wide but are exactly the symbols
|
|
663
|
+
// a "graph serialize restore" query wants, while generic `graph`/`function` would flood a god-file.
|
|
664
|
+
const sortedIdf = terms.map(idf).sort((a, b) => a - b);
|
|
665
|
+
const medIdf = sortedIdf[Math.floor((sortedIdf.length - 1) / 2)];
|
|
666
|
+
const discTermIdx = terms.map((_t, k) => k).filter((k) => idf(terms[k]) >= medIdf);
|
|
667
|
+
// Structured ranking export (--json): the real score(), coverage, and per-file
|
|
668
|
+
// symbol-binding (NAME channel) + path-binding for every term — so a convergence
|
|
669
|
+
// characterization reads the ranker's actual numbers, not a parse of the page.
|
|
670
|
+
if (asData) {
|
|
671
|
+
const discSet = new Set(discTermIdx.map((k) => lterms[k]));
|
|
672
|
+
const rows = ranked.slice(0, 40).map(([rel]) => {
|
|
673
|
+
const file = index.files.get(rel);
|
|
674
|
+
const fpathLow = foldSep(rel.toLowerCase());
|
|
675
|
+
const defines = [], inPath = [];
|
|
676
|
+
for (let k = 0; k < terms.length; k++) {
|
|
677
|
+
const t = flterms[k];
|
|
678
|
+
if (!t)
|
|
679
|
+
continue;
|
|
680
|
+
if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(t)))
|
|
681
|
+
defines.push(terms[k]);
|
|
682
|
+
if (fpathLow.includes(t))
|
|
683
|
+
inPath.push(terms[k]);
|
|
684
|
+
}
|
|
685
|
+
// does a DISCRIMINATING term bind to a declared symbol here? (Signal 1)
|
|
686
|
+
const defsDisc = defines.filter((t) => discSet.has(t.toLowerCase()));
|
|
687
|
+
return { path: rel, score: +score(rel).toFixed(4), coverage: coverage.get(rel).size,
|
|
688
|
+
defines, inPath, definesDiscriminating: defsDisc };
|
|
689
|
+
});
|
|
690
|
+
return JSON.stringify({
|
|
691
|
+
terms, nFiles, rareTerms,
|
|
692
|
+
discTerms: discTermIdx.map((k) => terms[k]),
|
|
693
|
+
idf: Object.fromEntries(terms.map((t) => [t, +idf(t).toFixed(4)])),
|
|
694
|
+
ranked: rows,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const matchedSymbols = (rel) => {
|
|
698
|
+
const file = index.files.get(rel);
|
|
699
|
+
if (file.lineCount < MATCHED_SYM_MIN_LINES)
|
|
700
|
+
return [];
|
|
701
|
+
const hits = file.symbols.filter((s) => {
|
|
702
|
+
const nl = foldSep(s.name.toLowerCase());
|
|
703
|
+
return discTermIdx.some((k) => nl.includes(flterms[k]));
|
|
704
|
+
});
|
|
705
|
+
if (hits.length === 0)
|
|
706
|
+
return [];
|
|
707
|
+
// drop a symbol if it strictly contains another matched symbol (keep the inner, specific one)
|
|
708
|
+
const specific = hits.filter((s) => !hits.some((o) => o !== s && o.startLine >= s.startLine && o.endLine <= s.endLine
|
|
709
|
+
&& (o.startLine > s.startLine || o.endLine < s.endLine)));
|
|
710
|
+
// rank by rarity-weighted term match (Σ idf of distinct discriminating terms in the name), so the
|
|
711
|
+
// most query-specific methods win the cap; tiebreak by line order for readability.
|
|
712
|
+
const symScore = (s) => {
|
|
713
|
+
const nl = foldSep(s.name.toLowerCase());
|
|
714
|
+
return discTermIdx.reduce((acc, k) => acc + (nl.includes(flterms[k]) ? idf(terms[k]) : 0), 0);
|
|
715
|
+
};
|
|
716
|
+
return specific
|
|
717
|
+
.sort((a, b) => symScore(b) - symScore(a) || a.startLine - b.startLine)
|
|
718
|
+
.slice(0, MATCHED_SYM_CAP)
|
|
719
|
+
.sort((a, b) => a.startLine - b.startLine)
|
|
720
|
+
.map((s) => `${s.name} [L${s.startLine}-${s.endLine}]`);
|
|
721
|
+
};
|
|
722
|
+
const evCache = new Map();
|
|
723
|
+
const evidence = (rel) => {
|
|
724
|
+
const cached = evCache.get(rel);
|
|
725
|
+
if (cached)
|
|
726
|
+
return cached;
|
|
727
|
+
const file = index.files.get(rel);
|
|
728
|
+
const fpathLow = foldSep(rel.toLowerCase());
|
|
729
|
+
const matched = coverage.get(rel);
|
|
730
|
+
const declares = [], inPath = [], mentions = [];
|
|
731
|
+
const imports = [];
|
|
732
|
+
for (let k = 0; k < terms.length; k++) {
|
|
733
|
+
const t = terms[k];
|
|
734
|
+
if (!matched.has(t))
|
|
735
|
+
continue;
|
|
736
|
+
if (file.symbols.some((s) => foldSep(s.name.toLowerCase()).includes(flterms[k]))) {
|
|
737
|
+
declares.push(t);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (fpathLow.includes(flterms[k])) {
|
|
741
|
+
inPath.push(t);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (!rareSet.has(t))
|
|
745
|
+
continue; // common body-only term → noise, omit
|
|
746
|
+
const tgt = importTargetFor(rel, t);
|
|
747
|
+
if (tgt)
|
|
748
|
+
imports.push({ term: t, from: tgt });
|
|
749
|
+
else
|
|
750
|
+
mentions.push(t);
|
|
751
|
+
}
|
|
752
|
+
const parts = [];
|
|
753
|
+
if (declares.length)
|
|
754
|
+
parts.push(`Defines ${declares.map(tick).join(', ')}.`);
|
|
755
|
+
if (inPath.length)
|
|
756
|
+
parts.push(`Named for ${inPath.map(tick).join(', ')} (in its path).`);
|
|
757
|
+
for (const im of imports)
|
|
758
|
+
parts.push(`Imports ${tick(im.term)} from ${im.from}.`);
|
|
759
|
+
if (mentions.length)
|
|
760
|
+
parts.push(`Mentions ${mentions.map(tick).join(', ')} in its body.`);
|
|
761
|
+
const disc = declares.length + inPath.length + imports.length + mentions.length;
|
|
762
|
+
const res = { text: parts.join(' '), disc, declares, inPath, imports, mentions };
|
|
763
|
+
evCache.set(rel, res);
|
|
764
|
+
return res;
|
|
765
|
+
};
|
|
766
|
+
// Compact "Role:" line — the file's identity in query terms, strongest signal first, one line.
|
|
767
|
+
const roleText = (rel) => {
|
|
768
|
+
const ev = evidence(rel);
|
|
769
|
+
const segs = [];
|
|
770
|
+
if (ev.declares.length)
|
|
771
|
+
segs.push(`defines ${ev.declares.map(tick).join(', ')}`);
|
|
772
|
+
if (ev.inPath.length)
|
|
773
|
+
segs.push(`named for ${ev.inPath.map(tick).join(', ')}`);
|
|
774
|
+
if (ev.imports.length)
|
|
775
|
+
segs.push(`imports ${ev.imports.map((i) => tick(i.term)).join(', ')} (from ${base(ev.imports[0].from)})`);
|
|
776
|
+
if (ev.mentions.length)
|
|
777
|
+
segs.push(`mentions ${ev.mentions.map(tick).join(', ')}`);
|
|
778
|
+
return segs.join('; ');
|
|
779
|
+
};
|
|
780
|
+
// Partition the listing. Vendored/minified bundles are dropped outright (never an answer); code
|
|
781
|
+
// files lead with previews; stylesheets and prose/docs split into bare secondary lists so they can't
|
|
782
|
+
// crowd or out-rank the source file. NOISE FLOOR: drop code files with no discriminating evidence.
|
|
783
|
+
const droppedCount = ranked.filter(([rel]) => isVendorOrMinified(rel, index.files.get(rel))).length;
|
|
784
|
+
const kept = ranked.filter(([rel]) => !isVendorOrMinified(rel, index.files.get(rel)));
|
|
785
|
+
const styleRanked = kept.filter(([rel]) => isStyle(rel));
|
|
786
|
+
const docRanked = kept.filter(([rel]) => isDoc(rel));
|
|
787
|
+
const codeRanked = kept.filter(([rel]) => !isDoc(rel) && !isStyle(rel) && evidence(rel).disc > 0);
|
|
788
|
+
const lines = [];
|
|
789
|
+
lines.push(`find: ${terms.join(' ')} (${terms.length} terms, ${coverage.size} candidate files)`);
|
|
790
|
+
if (rareTerms.length === 0) {
|
|
791
|
+
lines.push(` ⚠ no discriminating term — all of [${terms.join(', ')}] are common in this repo. Add a specific symbol, filename, or field name and re-run.`);
|
|
792
|
+
}
|
|
793
|
+
lines.push('');
|
|
794
|
+
// Top files get an inline convergence preview; the rest list as bare paths (cheap landscape,
|
|
795
|
+
// and `| head` truncation now drops whole tail files instead of cutting every file's evidence).
|
|
796
|
+
const detailSet = (codeRanked.length > 0 ? codeRanked : docRanked).slice(0, DETAIL_TOP);
|
|
797
|
+
const candidateList = codeRanked.slice(0, MAX_CANDIDATES_LISTED).map(([rel]) => rel);
|
|
798
|
+
const candidateSet = new Set(candidateList);
|
|
799
|
+
// Pass 1 — import co-citation among the listed candidates (precision relations).
|
|
800
|
+
const relations = computeImportRelations(index, candidateList);
|
|
801
|
+
const base = (rel) => rel.split('/').pop() ?? rel;
|
|
802
|
+
// Query-grounded "via" (A/B variant only): the connecting term = a DISCRIMINATING term the DEFINER
|
|
803
|
+
// declares that the CONSUMER also matched — i.e. the rare query identifier that flows across the edge.
|
|
804
|
+
// Filtered to discriminating terms (idf ≥ query median): a common connector like `graph`/`delete`
|
|
805
|
+
// sits on every edge and is noise; the rare one (`LoadStaging`) is the meaning-making token. No reads.
|
|
806
|
+
const discLower = new Set(discTermIdx.map((k) => lterms[k]));
|
|
807
|
+
const viaTerms = (definer, other) => {
|
|
808
|
+
const decl = new Set(evidence(definer).declares.map((t) => t.toLowerCase()));
|
|
809
|
+
if (decl.size === 0)
|
|
810
|
+
return [];
|
|
811
|
+
const out = [];
|
|
812
|
+
for (const t of coverage.get(other) ?? []) {
|
|
813
|
+
const tl = t.toLowerCase();
|
|
814
|
+
if (decl.has(tl) && discLower.has(tl))
|
|
815
|
+
out.push(t);
|
|
816
|
+
}
|
|
817
|
+
return out.sort((a, b) => idf(b) - idf(a)).slice(0, 2);
|
|
818
|
+
};
|
|
819
|
+
// Render the relation line for a candidate, strongest signal first, capped. When `via`, annotate each
|
|
820
|
+
// edge with the query term that connects the two files (the "consumed in X via blah" the agent asked for).
|
|
821
|
+
const relText = (rel, via) => {
|
|
822
|
+
const r = relations.get(rel);
|
|
823
|
+
if (!r)
|
|
824
|
+
return '';
|
|
825
|
+
const label = (other, definer, consumer) => {
|
|
826
|
+
if (!via)
|
|
827
|
+
return tick(base(other));
|
|
828
|
+
const vs = viaTerms(definer, consumer);
|
|
829
|
+
return `${tick(base(other))}${vs.length ? ` (via ${vs.map(tick).join(', ')})` : ''}`;
|
|
830
|
+
};
|
|
831
|
+
const parts = [];
|
|
832
|
+
if (r.consumedBy.length)
|
|
833
|
+
parts.push(`used by ${r.consumedBy.slice(0, 3).map((d) => label(d, rel, d)).join(', ')}`);
|
|
834
|
+
if (r.consumes.length)
|
|
835
|
+
parts.push(`uses ${r.consumes.slice(0, 3).map((d) => label(d, d, rel)).join(', ')}`);
|
|
836
|
+
if (r.siblings.length)
|
|
837
|
+
parts.push(`near ${r.siblings.slice(0, 3).map((d) => tick(base(d))).join(', ')}`);
|
|
838
|
+
return parts.join(' · ');
|
|
839
|
+
};
|
|
840
|
+
// Soft anchor: the connected set the agent should NAME, not just the file it opens. If ≥2 of the
|
|
841
|
+
// top files relate to each other, say so up front — the recall losses were the agent answering from
|
|
842
|
+
// one opened file and dropping the cluster it never read.
|
|
843
|
+
const connected = detailSet.filter(([rel]) => {
|
|
844
|
+
const r = relations.get(rel);
|
|
845
|
+
return r && (r.consumedBy.length || r.consumes.length || r.siblings.length);
|
|
846
|
+
}).length;
|
|
847
|
+
lines.push('== matches (top files previewed; ranked by term coverage + definition/path match) ==');
|
|
848
|
+
if (connected >= 2) {
|
|
849
|
+
lines.push("Several of these top files reference each other (see Wired: below) — they're likely one connected answer set; the connected files belong in your answer even if you don't open each one.");
|
|
850
|
+
}
|
|
851
|
+
const noteMap = buildNoteMap(root, lterms);
|
|
852
|
+
detailSet.forEach(([rel, ts]) => {
|
|
853
|
+
const file = index.files.get(rel);
|
|
854
|
+
lines.push('');
|
|
855
|
+
lines.push(`▸ ${rel} [${ts.size}/${terms.length}]`);
|
|
856
|
+
const role = roleText(rel);
|
|
857
|
+
if (role)
|
|
858
|
+
lines.push(` Role: ${role}`);
|
|
859
|
+
const syms = matchedSymbols(rel);
|
|
860
|
+
if (syms.length)
|
|
861
|
+
lines.push(` Read: ${syms.join(', ')}`);
|
|
862
|
+
const rt = relText(rel, via);
|
|
863
|
+
if (rt)
|
|
864
|
+
lines.push(` Wired: ${rt}`);
|
|
865
|
+
const noted = noteMap.get(rel);
|
|
866
|
+
let noteSummarized = false;
|
|
867
|
+
if (noted) {
|
|
868
|
+
const nl = noteLine(root, rel, noted, lterms);
|
|
869
|
+
if (nl.line)
|
|
870
|
+
lines.push(nl.line);
|
|
871
|
+
noteSummarized = nl.summary;
|
|
872
|
+
}
|
|
873
|
+
// A summary-grade note replaces the convergence preview: the walls of
|
|
874
|
+
// preview text exist to tell the agent what's inside the file, and a
|
|
875
|
+
// past agent already wrote that answer down. Weak gists (symbol
|
|
876
|
+
// inventory, alias, flow-step role) keep the preview — they don't
|
|
877
|
+
// describe the file's content.
|
|
878
|
+
if (noteSummarized)
|
|
879
|
+
return;
|
|
880
|
+
const specToTarget = index.edges
|
|
881
|
+
.filter((e) => e.from === rel)
|
|
882
|
+
.map((e) => ({ spec: e.specifier, target: index.files.get(e.to)?.relativePath ?? e.to }));
|
|
883
|
+
const preview = convergencePreview(file, terms, df, nFiles, specToTarget);
|
|
884
|
+
if (preview.length)
|
|
885
|
+
for (const p of preview)
|
|
886
|
+
lines.push(p);
|
|
887
|
+
else
|
|
888
|
+
lines.push(' (no discriminating line — terms appear scattered; open the file)');
|
|
889
|
+
});
|
|
890
|
+
const rest = codeRanked.slice(DETAIL_TOP, MAX_CANDIDATES_LISTED);
|
|
891
|
+
if (rest.length > 0) {
|
|
892
|
+
lines.push('');
|
|
893
|
+
lines.push('-- more candidates (paths + what matched) --');
|
|
894
|
+
for (const [rel, ts] of rest) {
|
|
895
|
+
const role = roleText(rel);
|
|
896
|
+
const rt = relText(rel, via);
|
|
897
|
+
const tail = [role, rt].filter(Boolean).join(' · ');
|
|
898
|
+
lines.push(` [${ts.size}/${terms.length}] ${rel}${tail ? ` — ${tail}` : ''}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (codeRanked.length > MAX_CANDIDATES_LISTED) {
|
|
902
|
+
lines.push(` [+${codeRanked.length - MAX_CANDIDATES_LISTED} more lower-coverage code files]`);
|
|
903
|
+
}
|
|
904
|
+
if (droppedCount > 0) {
|
|
905
|
+
lines.push(` [${droppedCount} vendor/minified file${droppedCount === 1 ? '' : 's'} hidden]`);
|
|
906
|
+
}
|
|
907
|
+
// Pass 2 — shared-rare-token bridge (recall). For each previewed file, find files that
|
|
908
|
+
// co-mention the same rare, identifier-shaped tokens but produce NO import edge — the
|
|
909
|
+
// implicit-reference relations (migrations↔models, config-by-name, JS↔Python pairs) that the
|
|
910
|
+
// import graph cannot see. Gated by deriveRelatedFiles: cross-dir only, import-edge dedup,
|
|
911
|
+
// ≥2 shared tokens OR string-literal provenance, twin-merge, cap 3. Index-only, NO file reads.
|
|
912
|
+
const bridgeExclude = new Set(candidateSet);
|
|
913
|
+
for (const [rel] of detailSet)
|
|
914
|
+
bridgeExclude.add(rel);
|
|
915
|
+
// Query-relevance gate. A bridge survives only if a shared token relates to a query
|
|
916
|
+
// stem. Without it, any file sharing an unrelated rare token with a big top match
|
|
917
|
+
// (e.g. a migration that touches one of models.py's many unrelated fields) surfaces —
|
|
918
|
+
// measured offline as 73% of related entries, all recurring noise (9075_external_oauth,
|
|
919
|
+
// 0001_initial, …); the query-relevant gold (q19 javascript.htm via `term…`) survives.
|
|
920
|
+
const qStems = new Set();
|
|
921
|
+
for (const t of terms) {
|
|
922
|
+
const tl = t.toLowerCase();
|
|
923
|
+
if (tl.length >= 4)
|
|
924
|
+
qStems.add(tl);
|
|
925
|
+
for (const part of tl.split(/[_\W]+/))
|
|
926
|
+
if (part.length >= 4)
|
|
927
|
+
qStems.add(part);
|
|
928
|
+
}
|
|
929
|
+
const relatesToQuery = (toks) => qStems.size === 0 ||
|
|
930
|
+
toks.some((tok) => {
|
|
931
|
+
const tl = tok.toLowerCase();
|
|
932
|
+
for (const s of qStems)
|
|
933
|
+
if (tl.includes(s) || s.includes(tl))
|
|
934
|
+
return true;
|
|
935
|
+
return false;
|
|
936
|
+
});
|
|
937
|
+
const bridged = [];
|
|
938
|
+
const bridgedSeen = new Set();
|
|
939
|
+
for (const [rel] of detailSet) {
|
|
940
|
+
const src = index.files.get(rel);
|
|
941
|
+
if (!src?.contentTokens)
|
|
942
|
+
continue;
|
|
943
|
+
for (const rf of deriveRelatedFiles(rel, src.contentTokens, index, bridgeExclude)) {
|
|
944
|
+
if (bridgedSeen.has(rf.fileId))
|
|
945
|
+
continue;
|
|
946
|
+
if (!relatesToQuery(rf.tokens))
|
|
947
|
+
continue;
|
|
948
|
+
bridgedSeen.add(rf.fileId);
|
|
949
|
+
bridgeExclude.add(rf.fileId);
|
|
950
|
+
bridged.push({ from: rel, fileId: rf.fileId, tokens: rf.tokens, alsoFileIds: rf.alsoFileIds });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (bridged.length > 0) {
|
|
954
|
+
lines.push('');
|
|
955
|
+
lines.push('-- related files (no import edge; share rare identifiers with a top match) --');
|
|
956
|
+
for (const b of bridged.slice(0, 6)) {
|
|
957
|
+
const also = b.alsoFileIds?.length ? ` (+${b.alsoFileIds.length} similar)` : '';
|
|
958
|
+
lines.push(` ${b.fileId}${also} — shares ${b.tokens.slice(0, 3).map(tick).join(', ')} with ${base(b.from)}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (styleRanked.length > 0) {
|
|
962
|
+
lines.push('');
|
|
963
|
+
lines.push('-- stylesheets matching your terms (css/scss — not previewed) --');
|
|
964
|
+
for (const [rel, ts] of styleRanked.slice(0, MAX_STYLE_CANDIDATES)) {
|
|
965
|
+
lines.push(` [${ts.size}/${terms.length}] ${rel}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (docRanked.length > 0) {
|
|
969
|
+
lines.push('');
|
|
970
|
+
lines.push('-- docs/notes mentioning your terms (prose, not definitions) --');
|
|
971
|
+
for (const [rel, ts] of docRanked.slice(0, MAX_DOC_CANDIDATES)) {
|
|
972
|
+
lines.push(` [${ts.size}/${terms.length}] ${rel}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
lines.push('');
|
|
976
|
+
lines.push('Previews show where your rarest terms converge (comments/docstrings filtered, ±1 context line). If the answer is not here, add a more discriminating term and re-run.');
|
|
977
|
+
return lines.join('\n');
|
|
978
|
+
}
|
|
979
|
+
//# sourceMappingURL=find.js.map
|