@nanonets/graft 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +29 -7
  2. package/dist/ask/ask.d.ts +52 -1
  3. package/dist/ask/ask.d.ts.map +1 -1
  4. package/dist/ask/ask.js +274 -83
  5. package/dist/ask/ask.js.map +1 -1
  6. package/dist/ask/fuse.d.ts +59 -17
  7. package/dist/ask/fuse.d.ts.map +1 -1
  8. package/dist/ask/fuse.js +51 -25
  9. package/dist/ask/fuse.js.map +1 -1
  10. package/dist/ask/graphrank.d.ts +6 -0
  11. package/dist/ask/graphrank.d.ts.map +1 -1
  12. package/dist/ask/graphrank.js +5 -1
  13. package/dist/ask/graphrank.js.map +1 -1
  14. package/dist/claude/format.js +1 -1
  15. package/dist/claude/format.js.map +1 -1
  16. package/dist/claude/hooks.d.ts +16 -0
  17. package/dist/claude/hooks.d.ts.map +1 -1
  18. package/dist/claude/hooks.js +57 -2
  19. package/dist/claude/hooks.js.map +1 -1
  20. package/dist/claude/init.d.ts +3 -0
  21. package/dist/claude/init.d.ts.map +1 -1
  22. package/dist/claude/init.js +6 -1
  23. package/dist/claude/init.js.map +1 -1
  24. package/dist/claude/skill-template.d.ts.map +1 -1
  25. package/dist/claude/skill-template.js +20 -9
  26. package/dist/claude/skill-template.js.map +1 -1
  27. package/dist/cli.d.ts +3 -14
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +99 -70
  30. package/dist/cli.js.map +1 -1
  31. package/dist/context/savings.d.ts +34 -0
  32. package/dist/context/savings.d.ts.map +1 -0
  33. package/dist/context/savings.js +49 -0
  34. package/dist/context/savings.js.map +1 -0
  35. package/dist/engine.d.ts +1 -0
  36. package/dist/engine.d.ts.map +1 -1
  37. package/dist/engine.js +7 -1
  38. package/dist/engine.js.map +1 -1
  39. package/dist/graph/build.d.ts.map +1 -1
  40. package/dist/graph/build.js +33 -0
  41. package/dist/graph/build.js.map +1 -1
  42. package/dist/graph/map.d.ts +38 -2
  43. package/dist/graph/map.d.ts.map +1 -1
  44. package/dist/graph/map.js +111 -24
  45. package/dist/graph/map.js.map +1 -1
  46. package/dist/graph/scopes.d.ts +15 -0
  47. package/dist/graph/scopes.d.ts.map +1 -1
  48. package/dist/graph/scopes.js +23 -0
  49. package/dist/graph/scopes.js.map +1 -1
  50. package/dist/graph/traverse-cli.d.ts +28 -18
  51. package/dist/graph/traverse-cli.d.ts.map +1 -1
  52. package/dist/graph/traverse-cli.js +68 -43
  53. package/dist/graph/traverse-cli.js.map +1 -1
  54. package/dist/graph/traverse.d.ts +35 -14
  55. package/dist/graph/traverse.d.ts.map +1 -1
  56. package/dist/graph/traverse.js +46 -20
  57. package/dist/graph/traverse.js.map +1 -1
  58. package/dist/graph/types.d.ts +12 -0
  59. package/dist/graph/types.d.ts.map +1 -1
  60. package/dist/graph/workspace-cli.d.ts +37 -0
  61. package/dist/graph/workspace-cli.d.ts.map +1 -0
  62. package/dist/graph/workspace-cli.js +70 -0
  63. package/dist/graph/workspace-cli.js.map +1 -0
  64. package/dist/graph/workspace.d.ts +128 -0
  65. package/dist/graph/workspace.d.ts.map +1 -0
  66. package/dist/graph/workspace.js +395 -0
  67. package/dist/graph/workspace.js.map +1 -0
  68. package/dist/hosts/instructions.d.ts.map +1 -1
  69. package/dist/hosts/instructions.js +7 -3
  70. package/dist/hosts/instructions.js.map +1 -1
  71. package/dist/hosts/mcp-config.d.ts +5 -0
  72. package/dist/hosts/mcp-config.d.ts.map +1 -1
  73. package/dist/hosts/mcp-config.js +2 -2
  74. package/dist/hosts/mcp-config.js.map +1 -1
  75. package/dist/mcp/tools.d.ts.map +1 -1
  76. package/dist/mcp/tools.js +94 -66
  77. package/dist/mcp/tools.js.map +1 -1
  78. package/dist/search/grep-cli.d.ts.map +1 -1
  79. package/dist/search/grep-cli.js +4 -0
  80. package/dist/search/grep-cli.js.map +1 -1
  81. package/dist/search/grep.d.ts +4 -0
  82. package/dist/search/grep.d.ts.map +1 -1
  83. package/dist/search/grep.js +2 -0
  84. package/dist/search/grep.js.map +1 -1
  85. package/package.json +1 -1
package/dist/ask/ask.js CHANGED
@@ -17,8 +17,11 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
18
  import matter from "gray-matter";
19
19
  import { contextDirFor } from "../context/node-file.js";
20
+ import { savingsFooter, savingsFor } from "../context/savings.js";
20
21
  import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
22
+ import { pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph } from "../graph/scopes.js";
21
23
  import { resolveSymbol } from "../graph/traverse.js";
24
+ import { rankScopesAndFuse } from "./fuse.js";
22
25
  import { personalizedPageRank } from "./graphrank.js";
23
26
  import { counts, tokenize } from "./index-file.js";
24
27
  /** Max source lines to inline per hit — a definition longer than this is
@@ -143,11 +146,22 @@ function subjectWords(query) {
143
146
  * last regardless of outcome, so a caller can name *something* in a fallthrough
144
147
  * note even when nothing resolved (e.g. `subjects.length === 0`). This is what
145
148
  * makes a qualified/dotted subject like `Cache.get` resolve, where the old
146
- * plain name-equality lookup (`findSubject`) never matched it. */
147
- function findSubjectNodes(query, graph) {
149
+ * plain name-equality lookup (`findSubject`) never matched it.
150
+ *
151
+ * `inPrefix`, when given, filters each word's `resolveSymbol` matches down to
152
+ * nodes under that path prefix BEFORE checking whether any survived — same
153
+ * "narrow the subject resolution" semantics `callers`/`callees`/`impact --in`
154
+ * already have (segment-aware here, via `pathUnderPrefix`). A word that
155
+ * resolves globally but has nothing under the prefix does NOT count as a
156
+ * match; the next (shorter) word is tried instead, same as an unresolved word
157
+ * today — and if nothing survives for ANY word, `subjects.length === 0`
158
+ * naturally falls through to the existing loud fallthrough note. */
159
+ function findSubjectNodes(query, graph, inPrefix) {
148
160
  const words = subjectWords(query).sort((a, b) => b.length - a.length);
149
161
  for (const w of words) {
150
- const nodes = resolveSymbol(graph, w);
162
+ let nodes = resolveSymbol(graph, w);
163
+ if (inPrefix)
164
+ nodes = nodes.filter((n) => pathUnderPrefix(n.path, inPrefix));
151
165
  if (nodes.length > 0)
152
166
  return { nodes, tried: w };
153
167
  }
@@ -157,12 +171,12 @@ function fallthroughNoteFor(subject) {
157
171
  return (`structural index: no entries for '${subject}' — showing lexical matches; ` +
158
172
  `for exhaustive callers use grep -rn '${subject}'`);
159
173
  }
160
- function structural(query, graph, limit) {
174
+ function structural(query, graph, limit, inPrefix) {
161
175
  const wantsIn = INCOMING.test(query);
162
176
  const wantsOut = OUTGOING.test(query);
163
177
  if (!wantsIn && !wantsOut)
164
178
  return null;
165
- const { nodes: subjects, tried } = findSubjectNodes(query, graph);
179
+ const { nodes: subjects, tried } = findSubjectNodes(query, graph, inPrefix);
166
180
  if (subjects.length === 0)
167
181
  return { fallthroughNote: fallthroughNoteFor(tried) };
168
182
  const ids = new Set(subjects.map((n) => n.id));
@@ -231,6 +245,20 @@ function hasTerm(f, t) {
231
245
  * "write": common → near-weightless even when they collide with a symbol name);
232
246
  * a task prompt matches exactly its rare, discriminating identifiers. Terms the
233
247
  * corpus has never seen take `dfltIdf` (the df=0 weight). */
248
+ /** The match-STRENGTH share behind `coverageStrong`: idf-weighted share of the
249
+ * query matched in the node's NAME field ONLY. The PATH field is deliberately
250
+ * excluded — a coincidental generic directory (`gateway/`) or file basename
251
+ * (`gateway.ts`) in an unrelated repo would otherwise clear the strength gate
252
+ * and float that repo to the top; an on-topic path-organized repo is still
253
+ * rescued by the broad-coverage HIGH_FLOOR clause instead. For the same reason
254
+ * a `kind:"file"` node contributes zero strength: its `name` is a basename (a
255
+ * path component), not a symbol name, so a file whose basename happens to share
256
+ * a query word is not a strong match. */
257
+ export function strongShare(q, node, doc, idf, dfltIdf) {
258
+ if (node.kind === "file")
259
+ return 0;
260
+ return matchedIdfShare(q, [doc.name], idf, dfltIdf);
261
+ }
234
262
  function matchedIdfShare(q, fields, idf, dfltIdf) {
235
263
  let matched = 0;
236
264
  let total = 0;
@@ -242,14 +270,19 @@ function matchedIdfShare(q, fields, idf, dfltIdf) {
242
270
  }
243
271
  return total > 0 ? matched / total : 0;
244
272
  }
245
- function lexical(query, corpus, limit, graphRank) {
273
+ function lexical(query, corpus, limit, graphRank, inPrefix) {
246
274
  // Binary query terms: a word repeated in a pasted issue body counts once, so a
247
275
  // ranty description can't linearly amplify an incidental word.
248
276
  const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
249
277
  const graph = corpus.graph;
250
278
  // ── Pass 1: tokenize every scored field once, and collect per-document token
251
- // bags so IDF can down-weight words that occur across the whole corpus. ──
252
- const conceptDocs = corpus.concepts.map((c) => ({
279
+ // bags so IDF can down-weight words that occur across the whole corpus. `--in`
280
+ // drops a concept unless at least one of its sources falls under the prefix —
281
+ // filtering happens here, before any scoring, so a narrowed query never sees
282
+ // an unrelated concept from outside the prefix. ──
283
+ const conceptDocs = corpus.concepts
284
+ .filter((c) => !inPrefix || c.sources.some((p) => pathUnderPrefix(p, inPrefix)))
285
+ .map((c) => ({
253
286
  c,
254
287
  name: counts(tokenize(c.name)),
255
288
  body: counts(tokenize(c.text)),
@@ -261,6 +294,18 @@ function lexical(query, corpus, limit, graphRank) {
261
294
  // that — missing, unparseable, unknown version, wrong count, mismatched ids —
262
295
  // falls back to live tokenization so results never depend on the sidecar
263
296
  // existing; see `readAskIndex`'s own null-on-anything-off contract.
297
+ //
298
+ // `--in` does NOT disable the sidecar here: `writeGraph` strips `body_text`
299
+ // from every node it serializes (see `write.ts`) — the sidecar's per-doc
300
+ // `docs[].body` bags are the ONLY place a symbol's body tokens survive on a
301
+ // graph loaded from disk. Bypassing the sidecar under `--in` would silently
302
+ // empty out `body` for every node (falling back to `n.body_text ?? ""`,
303
+ // always `""` post-slim-serialization), losing every body-only match under
304
+ // the filtered prefix. What DOES need bypassing under `--in` is the
305
+ // sidecar's GLOBAL `df`/`docCount`/`avgBodyLen` — those cover the whole
306
+ // corpus, not the filtered subset — so each read of those three fields below
307
+ // is separately gated on `!inPrefix`, while the per-doc bags (used to build
308
+ // `symbolDocs` right after this) are used unconditionally.
264
309
  let docById = null;
265
310
  if (corpus.askIndex && graph && corpus.askIndex.docs.length === graph.nodes.length) {
266
311
  const map = new Map(corpus.askIndex.docs.map((d) => [d.id, d]));
@@ -271,8 +316,12 @@ function lexical(query, corpus, limit, graphRank) {
271
316
  // Symbols AND file nodes are scored: a symbol's body_text is its definition;
272
317
  // a file's body_text is the module-level residual (imports/constants not in
273
318
  // any symbol). Including files closes the recall gap where a gold file's only
274
- // query-relevant text lives outside every function/class.
275
- const symbolDocs = (graph?.nodes ?? []).map((n) => {
319
+ // query-relevant text lives outside every function/class. `--in` filters the
320
+ // node set here, at the very top, before any scoring — every downstream stat
321
+ // (idf, avgdl, candidates, the per-scope partition below) then naturally
322
+ // reflects only the filtered set with no other code needing to know about it.
323
+ const graphNodes = inPrefix ? (graph?.nodes ?? []).filter((n) => pathUnderPrefix(n.path, inPrefix)) : (graph?.nodes ?? []);
324
+ const symbolDocs = graphNodes.map((n) => {
276
325
  const d = docById?.get(n.id);
277
326
  if (d)
278
327
  return { n, name: new Map(d.name), path: new Map(d.path), body: new Map(d.body) };
@@ -295,18 +344,25 @@ function lexical(query, corpus, limit, graphRank) {
295
344
  // IDF must see the SAME inputs either way: with a sidecar, its `df` (stored
296
345
  // WITHOUT concepts) plus these live concept bags reproduces exactly what
297
346
  // `computeIdf` below would compute from concept+symbol bags tokenized live.
298
- const idf = askIndex
347
+ // `askIndex.df` is a corpus-GLOBAL count, so `--in` can't use it — the
348
+ // filtered doc set needs its own idf, computed from (already-filtered)
349
+ // `symbolDocs`' own bags, regardless of where those bags came from.
350
+ const idf = askIndex && !inPrefix
299
351
  ? computeIdfFromIndex(askIndex, conceptBags)
300
352
  : computeIdf([...conceptBags, ...symbolDocs.map((d) => new Set([...d.name.keys(), ...d.path.keys(), ...d.body.keys()]))]);
301
353
  // Per-hit idf-weighted coverage, kept out of the public AskHit shape; only
302
354
  // the top hit's value survives, as `coverage` on the result. The df=0 weight
303
355
  // mirrors idfFromDf's formula at df=0 for whichever corpus size idf was
304
- // computed from.
305
- const nDocs = askIndex
356
+ // computed from. Same `--in` reasoning as `idf` above: `askIndex.docCount`
357
+ // is global.
358
+ const nDocs = askIndex && !inPrefix
306
359
  ? askIndex.docCount + conceptBags.length
307
360
  : conceptBags.length + symbolDocs.length;
308
361
  const dfltIdf = Math.log(1 + nDocs);
309
362
  const matchedOf = new Map();
363
+ // Parallel to matchedOf, but over NAME+PATH only (body dropped) — the
364
+ // match-strength signal exported as `coverageStrong`.
365
+ const matchedStrongOf = new Map();
310
366
  // ── Concepts (prose nodes; not in the wiring graph) ──
311
367
  const conceptHits = [];
312
368
  let maxConcept = 0;
@@ -323,6 +379,7 @@ function lexical(query, corpus, limit, graphRank) {
323
379
  score: total,
324
380
  };
325
381
  matchedOf.set(hit, matchedIdfShare(q, [name, body], idf, dfltIdf));
382
+ matchedStrongOf.set(hit, matchedIdfShare(q, [name], idf, dfltIdf)); // concepts have no path field
326
383
  conceptHits.push(hit);
327
384
  }
328
385
  }
@@ -334,56 +391,159 @@ function lexical(query, corpus, limit, graphRank) {
334
391
  s += v;
335
392
  return s;
336
393
  };
337
- const avgBodyLen = askIndex
338
- ? askIndex.avgBodyLen
339
- : symbolDocs.length
340
- ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
341
- : 0;
342
- const lex = new Map(); // node id → lexical score (>0 only)
343
- let maxLex = 0;
344
- for (const { n, name, path, body } of symbolDocs) {
345
- // Name and path are short identifiers → plain idf-weighted overlap; the body
346
- // is length-normalized via BM25 so long definitions don't win on bulk.
347
- const total = score(q, name, idf) * 3 +
348
- score(q, path, idf) * 2 +
349
- bm25(q, body, idf, bodyLen(body), avgBodyLen);
350
- if (total > 0) {
351
- lex.set(n.id, total);
352
- maxLex = Math.max(maxLex, total);
353
- }
354
- }
355
- // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
356
- const pr = graphRank && graph && lex.size > 0
357
- ? personalizedPageRank(graph, lex)
358
- : new Map();
359
- // Candidate symbols = everything the query word-matched, plus nodes the walk
360
- // found strongly central even without a word match (RESCUE_FLOOR).
361
- const candidates = new Set(lex.keys());
362
- for (const [id, p] of pr)
363
- if (p >= RESCUE_FLOOR)
364
- candidates.add(id);
365
394
  const docsById = new Map(symbolDocs.map((d) => [d.n.id, d]));
366
395
  const symbolHits = [];
367
- for (const id of candidates) {
368
- const n = byId.get(id);
369
- if (!n)
370
- continue;
371
- const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
372
- const blended = lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0);
373
- if (blended <= 0)
374
- continue;
375
- const hit = {
376
- kind: "symbol",
377
- title: `${n.name} · ${n.kind}`,
378
- // A file node points at the whole file (locator, no span) so `--source`
379
- // never inlines an entire file; symbol nodes keep their exact span.
380
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
381
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
382
- score: blended,
383
- };
384
- const d = docsById.get(id);
385
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
386
- symbolHits.push(hit);
396
+ // The single-vs-multi-scope branch keys on `scopesOfGraph` ONLY: one scope
397
+ // (or no graph) takes the existing path below completely untouched — that
398
+ // branch is a byte-level regression guarantee for single-scope repos.
399
+ const scopes = graph ? scopesOfGraph(graph) : null;
400
+ let scopeMeta;
401
+ if (scopes && scopes.length > 1) {
402
+ // ── Multi-scope repo: rank each sub-project against its OWN corpus (its
403
+ // IDF, its BM25 length prior, its subgraph walk), then fuse the per-scope
404
+ // orderings by reciprocal rank — the big scope can't drown the small one.
405
+ // Same scoring functions and blend as the single-scope path, just fed
406
+ // per-scope inputs; the fusion itself lives in fuse.ts. ──
407
+ const byScope = new Map();
408
+ for (const d of symbolDocs) {
409
+ const s = scopeOf(d.n.path, scopes).prefix;
410
+ const list = byScope.get(s);
411
+ if (list)
412
+ list.push(d);
413
+ else
414
+ byScope.set(s, [d]);
415
+ }
416
+ const idfOf = new Map();
417
+ const fusion = rankScopesAndFuse([...byScope.keys()].sort(), {
418
+ lex: (s) => {
419
+ const docs = byScope.get(s);
420
+ // Per-scope IDF/BM25 stats: concepts are repo-level so their bags
421
+ // fold in everywhere, symbol bags come from this scope only. (The
422
+ // build sidecar's df/avgBodyLen are corpus-global, so multi-scope
423
+ // always computes these live — from the sidecar's own token maps.)
424
+ const sIdf = computeIdf([
425
+ ...conceptBags,
426
+ ...docs.map((d) => new Set([...d.name.keys(), ...d.path.keys(), ...d.body.keys()])),
427
+ ]);
428
+ idfOf.set(s, { idf: sIdf, dflt: Math.log(1 + conceptBags.length + docs.length) });
429
+ const avg = docs.reduce((a, d) => a + bodyLen(d.body), 0) / docs.length;
430
+ const out = new Map();
431
+ for (const { n, name, path, body } of docs) {
432
+ const total = score(q, name, sIdf) * 3 +
433
+ score(q, path, sIdf) * 2 +
434
+ bm25(q, body, sIdf, bodyLen(body), avg);
435
+ if (total > 0)
436
+ out.set(n.id, total);
437
+ }
438
+ return out;
439
+ },
440
+ // The participation gate's match-STRENGTH signal, evaluated once per
441
+ // scope on that scope's raw-lex top doc — same `strongShare`/
442
+ // `matchedIdfShare` functions and per-scope idf `lex` (above) just
443
+ // computed, so this reads the SAME strength `federateAsk` gates on
444
+ // (see fuse.ts's STRONG_FLOOR/HIGH_FLOOR).
445
+ strength: (s, id) => {
446
+ const d = docsById.get(id);
447
+ const si = idfOf.get(s);
448
+ if (!d || !si)
449
+ return { coverage: 0, coverageStrong: 0 };
450
+ return {
451
+ coverage: matchedIdfShare(q, [d.name, d.path, d.body], si.idf, si.dflt),
452
+ coverageStrong: strongShare(q, d.n, d, si.idf, si.dflt),
453
+ };
454
+ },
455
+ walk: (s, seeds) => graphRank
456
+ ? personalizedPageRank(graph, seeds, {
457
+ nodeFilter: (id) => {
458
+ const path = byId.get(id)?.path ?? "";
459
+ return scopeOf(path, scopes).prefix === s && (!inPrefix || pathUnderPrefix(path, inPrefix));
460
+ },
461
+ })
462
+ : new Map(),
463
+ }, GRAPH_WEIGHT, RESCUE_FLOOR);
464
+ for (const rd of fusion.ranked) {
465
+ const n = byId.get(rd.id);
466
+ if (!n)
467
+ continue;
468
+ const hit = {
469
+ kind: "symbol",
470
+ title: `${n.name} · ${n.kind}`,
471
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
472
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
473
+ score: rd.score,
474
+ scope: rd.scope,
475
+ };
476
+ const d = docsById.get(rd.id);
477
+ const si = idfOf.get(rd.scope);
478
+ matchedOf.set(hit, d && si ? matchedIdfShare(q, [d.name, d.path, d.body], si.idf, si.dflt) : 0);
479
+ matchedStrongOf.set(hit, d && si ? strongShare(q, n, d, si.idf, si.dflt) : 0);
480
+ symbolHits.push(hit);
481
+ }
482
+ // Label + footer only when federation actually happened (or a scope was
483
+ // gated out and is worth mentioning) — a query matching one scope of a
484
+ // multi-scope repo reads exactly like today.
485
+ if (fusion.federated.length > 1 || fusion.alsoMatched.length > 0)
486
+ scopeMeta = { federated: fusion.federated, alsoMatched: fusion.alsoMatched };
487
+ }
488
+ else {
489
+ // Single-scope: the pre-scopes ranking path, byte-identical output when
490
+ // `--in` is absent (Task 3's regression guarantee: `askIndex && !inPrefix`
491
+ // reduces to plain `askIndex` when `inPrefix` is undefined, so this line is
492
+ // a no-op change for every non-`--in` caller). Under `--in`, `askIndex`'s
493
+ // `avgBodyLen` is corpus-global and must not be reused for the filtered set.
494
+ const avgBodyLen = askIndex && !inPrefix
495
+ ? askIndex.avgBodyLen
496
+ : symbolDocs.length
497
+ ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
498
+ : 0;
499
+ const lex = new Map(); // node id → lexical score (>0 only)
500
+ let maxLex = 0;
501
+ for (const { n, name, path, body } of symbolDocs) {
502
+ // Name and path are short identifiers → plain idf-weighted overlap; the body
503
+ // is length-normalized via BM25 so long definitions don't win on bulk.
504
+ const total = score(q, name, idf) * 3 +
505
+ score(q, path, idf) * 2 +
506
+ bm25(q, body, idf, bodyLen(body), avgBodyLen);
507
+ if (total > 0) {
508
+ lex.set(n.id, total);
509
+ maxLex = Math.max(maxLex, total);
510
+ }
511
+ }
512
+ // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
513
+ // `--in` restricts the walk itself, not just the seeds: without a nodeFilter
514
+ // here, a RESCUE_FLOOR-qualifying neighbour OUTSIDE the prefix could still
515
+ // surface, defeating the "filters before scoring" contract.
516
+ const pr = graphRank && graph && lex.size > 0
517
+ ? personalizedPageRank(graph, lex, inPrefix ? { nodeFilter: (id) => pathUnderPrefix(byId.get(id)?.path ?? "", inPrefix) } : {})
518
+ : new Map();
519
+ // Candidate symbols = everything the query word-matched, plus nodes the walk
520
+ // found strongly central even without a word match (RESCUE_FLOOR).
521
+ const candidates = new Set(lex.keys());
522
+ for (const [id, p] of pr)
523
+ if (p >= RESCUE_FLOOR)
524
+ candidates.add(id);
525
+ for (const id of candidates) {
526
+ const n = byId.get(id);
527
+ if (!n)
528
+ continue;
529
+ const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
530
+ const blended = lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0);
531
+ if (blended <= 0)
532
+ continue;
533
+ const hit = {
534
+ kind: "symbol",
535
+ title: `${n.name} · ${n.kind}`,
536
+ // A file node points at the whole file (locator, no span) so `--source`
537
+ // never inlines an entire file; symbol nodes keep their exact span.
538
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
539
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
540
+ score: blended,
541
+ };
542
+ const d = docsById.get(id);
543
+ matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
544
+ matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
545
+ symbolHits.push(hit);
546
+ }
387
547
  }
388
548
  // Concepts and symbols live on comparable 0..~1.5 scales so they merge fairly:
389
549
  // concept scores are normalized to their own max, symbol scores are the
@@ -396,8 +556,14 @@ function lexical(query, corpus, limit, graphRank) {
396
556
  query,
397
557
  mode: scored.length ? "lexical" : "empty",
398
558
  hits: scored.slice(0, limit),
559
+ scopes: scopeMeta,
399
560
  coverage: scored.length && q.size > 0 ? matchedOf.get(scored[0]) ?? 0 : undefined,
400
- note: scored.length ? undefined : "no matching nodes — try different words, or `graft build` if graft/ is empty",
561
+ coverageStrong: scored.length && q.size > 0 ? matchedStrongOf.get(scored[0]) ?? 0 : undefined,
562
+ // Zero hits on a genuinely multi-scope graph names the scopes that exist,
563
+ // so a query that missed everywhere still tells the caller where to look.
564
+ note: scored.length
565
+ ? undefined
566
+ : `no matching nodes — try different words, or \`graft build\` if graft/ is empty${scopesHereClause(scopes ?? [])}`,
401
567
  };
402
568
  }
403
569
  /** Parse a `path:Lx-Ly` pointer into its parts, or null if it isn't one
@@ -475,20 +641,7 @@ function hitFiles(hits) {
475
641
  function baselineFor(hits, graph) {
476
642
  if (!graph)
477
643
  return undefined;
478
- const size = new Map();
479
- for (const n of graph.nodes)
480
- if (n.kind === "file" && typeof n.chars === "number")
481
- size.set(n.path, n.chars);
482
- let baselineChars = 0;
483
- let files = 0;
484
- for (const path of hitFiles(hits)) {
485
- const c = size.get(path);
486
- if (c === undefined)
487
- continue;
488
- baselineChars += c;
489
- files++;
490
- }
491
- return files > 0 ? { files, baselineChars } : undefined;
644
+ return savingsFor(graph, hitFiles(hits));
492
645
  }
493
646
  /** Answer a query from the graft/ graph at `dir`. Deterministic, $0. */
494
647
  export function ask(dir, query, opts = {}) {
@@ -497,14 +650,26 @@ export function ask(dir, query, opts = {}) {
497
650
  const limit = opts.limit ?? 8;
498
651
  const corpus = loadCorpus(outDir);
499
652
  const graphRank = opts.graphRank ?? true;
653
+ // Trailing slash(es) stripped ONCE, up front, and this normalized value used
654
+ // everywhere below (validation, filtering, structural). `ask --in frontend/`
655
+ // must work exactly like `--in frontend` — `scopeLabel`/the footer's own
656
+ // `--in <scope>/` suggestion always includes the slash, so rejecting it
657
+ // would make the tool's own suggested next command fail.
658
+ const inPrefix = opts.in ? opts.in.replace(/\/+$/, "") : undefined;
659
+ // `--in` validated up front, before any mode runs: a prefix matching no
660
+ // indexed node is a caller mistake (typo, wrong sub-project), not a
661
+ // legitimate zero-hit query, so it's a loud error rather than an empty pack.
662
+ if (inPrefix && corpus.graph && !corpus.graph.nodes.some((n) => pathUnderPrefix(n.path, inPrefix))) {
663
+ throw new Error(`nothing indexed under "${inPrefix}/"${scopesHereClause(scopesOfGraph(corpus.graph))} (or any path prefix)`);
664
+ }
500
665
  let result;
501
666
  if (corpus.graph) {
502
- const outcome = structural(query, corpus.graph, limit);
667
+ const outcome = structural(query, corpus.graph, limit, inPrefix);
503
668
  if (outcome && "result" in outcome) {
504
669
  result = outcome.result;
505
670
  }
506
671
  else {
507
- result = lexical(query, corpus, limit, graphRank);
672
+ result = lexical(query, corpus, limit, graphRank, inPrefix);
508
673
  // A structural-intent query that couldn't be answered structurally still
509
674
  // gets a prominent note on the lexical fallback result — never silent.
510
675
  if (outcome && "fallthroughNote" in outcome) {
@@ -513,7 +678,7 @@ export function ask(dir, query, opts = {}) {
513
678
  }
514
679
  }
515
680
  else {
516
- result = lexical(query, corpus, limit, graphRank);
681
+ result = lexical(query, corpus, limit, graphRank, inPrefix);
517
682
  }
518
683
  if (opts.source) {
519
684
  inlineSource(root, result.hits, corpus.graph, opts.full ?? false);
@@ -553,6 +718,7 @@ export function skeleton(dir, file, opts = {}) {
553
718
  signature: n.signature,
554
719
  summary: n.summary?.split("\n")[0].trim() || undefined,
555
720
  })),
721
+ saved: savingsFor(graph, [defs[0].path]),
556
722
  };
557
723
  }
558
724
  /** Render a {@link SkeletonResult} as compact markdown. */
@@ -565,7 +731,8 @@ export function formatSkeleton(r) {
565
731
  const sum = e.summary ? ` — ${e.summary}` : "";
566
732
  return `- ${e.span} ${e.kind} ${e.name}${sig}${sum}`;
567
733
  });
568
- return `${head}\n${lines.join("\n")}\n`;
734
+ const body = `${head}\n${lines.join("\n")}`;
735
+ return body + savingsFooter(body, r.saved) + "\n";
569
736
  }
570
737
  /** Rough tokens for a byte length (≈ 4 chars/token; good enough for an estimate). */
571
738
  function toTokens(chars) {
@@ -602,7 +769,10 @@ export function formatAsk(r) {
602
769
  }
603
770
  else {
604
771
  r.hits.forEach((h, i) => {
605
- lines.push(`${i + 1}. ${h.title} [${h.kind}]`);
772
+ // Multi-scope results label each hit with its sub-project (root scope
773
+ // "" stays unlabeled) so a reviewer can tell WHICH codebase answered.
774
+ const label = r.scopes && h.scope ? `[${h.scope}/] ` : "";
775
+ lines.push(`${i + 1}. ${label}${h.title} [${h.kind}]`);
606
776
  lines.push(` ${h.pointer}`);
607
777
  if (h.snippet)
608
778
  lines.push(` ${h.snippet}`);
@@ -612,9 +782,10 @@ export function formatAsk(r) {
612
782
  lines.push("", "```", h.code, "```");
613
783
  lines.push("");
614
784
  });
785
+ lines.push(...scopeFooterLines(r));
615
786
  }
616
787
  const body = lines.join("\n").trimEnd();
617
- return body + savingsFooter(r, body) + escalationNudge(r) + "\n";
788
+ return body + askSavingsFooter(r, body) + escalationNudge(r) + "\n";
618
789
  }
619
790
  /** When a lexical `ask` returns thin/no results, the productive next move is a
620
791
  * different TOOL, not a re-phrased ask — re-asking is the "over-ask" trap that
@@ -631,7 +802,7 @@ function escalationNudge(r) {
631
802
  /** The one-line token-saving estimate `ask` appends in retriever mode, so the
632
803
  * agent gets the number for free in the tool output — no extra work on its end.
633
804
  * `packChars` is measured from the rendered body: exactly what the agent reads. */
634
- function savingsFooter(r, body) {
805
+ function askSavingsFooter(r, body) {
635
806
  if (!r.saved || r.saved.baselineChars <= 0)
636
807
  return "";
637
808
  const pack = toTokens(body.length);
@@ -644,4 +815,24 @@ function savingsFooter(r, body) {
644
815
  `${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` +
645
816
  `${base.toLocaleString()} tok. Estimate (baseline = those files read in full).`);
646
817
  }
818
+ /** Multi-scope footer: answers a reviewer's two questions — "which
819
+ * sub-projects did this pack come from?" (`matched in:`, displayed-hit counts,
820
+ * biggest first) and "did anything else match that I should know about?"
821
+ * (`also matched:` for scopes gated out of fusion, with the flag to narrow).
822
+ * Empty (zero output) whenever the result isn't a federated multi-scope one. */
823
+ function scopeFooterLines(r) {
824
+ if (!r.scopes)
825
+ return [];
826
+ const perScope = new Map();
827
+ for (const h of r.hits)
828
+ if (h.scope !== undefined)
829
+ perScope.set(h.scope, (perScope.get(h.scope) ?? 0) + 1);
830
+ const parts = [...perScope]
831
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
832
+ .map(([s, c]) => `${scopeLabel(s)} (${c})`);
833
+ const out = parts.length ? [`matched in: ${parts.join(" · ")}`] : [];
834
+ for (const m of r.scopes.alsoMatched)
835
+ out.push(`also matched: ${scopeLabel(m.scope)} — narrow with --in ${scopeLabel(m.scope)}`);
836
+ return out;
837
+ }
647
838
  //# sourceMappingURL=ask.js.map