@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.
- package/README.md +29 -7
- package/dist/ask/ask.d.ts +52 -1
- package/dist/ask/ask.d.ts.map +1 -1
- package/dist/ask/ask.js +274 -83
- package/dist/ask/ask.js.map +1 -1
- package/dist/ask/fuse.d.ts +59 -17
- package/dist/ask/fuse.d.ts.map +1 -1
- package/dist/ask/fuse.js +51 -25
- package/dist/ask/fuse.js.map +1 -1
- package/dist/ask/graphrank.d.ts +6 -0
- package/dist/ask/graphrank.d.ts.map +1 -1
- package/dist/ask/graphrank.js +5 -1
- package/dist/ask/graphrank.js.map +1 -1
- package/dist/claude/format.js +1 -1
- package/dist/claude/format.js.map +1 -1
- package/dist/claude/hooks.d.ts +16 -0
- package/dist/claude/hooks.d.ts.map +1 -1
- package/dist/claude/hooks.js +57 -2
- package/dist/claude/hooks.js.map +1 -1
- package/dist/claude/init.d.ts +3 -0
- package/dist/claude/init.d.ts.map +1 -1
- package/dist/claude/init.js +6 -1
- package/dist/claude/init.js.map +1 -1
- package/dist/claude/skill-template.d.ts.map +1 -1
- package/dist/claude/skill-template.js +20 -9
- package/dist/claude/skill-template.js.map +1 -1
- package/dist/cli.d.ts +3 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +99 -70
- package/dist/cli.js.map +1 -1
- package/dist/context/savings.d.ts +34 -0
- package/dist/context/savings.d.ts.map +1 -0
- package/dist/context/savings.js +49 -0
- package/dist/context/savings.js.map +1 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -1
- package/dist/engine.js.map +1 -1
- package/dist/graph/build.d.ts.map +1 -1
- package/dist/graph/build.js +33 -0
- package/dist/graph/build.js.map +1 -1
- package/dist/graph/map.d.ts +38 -2
- package/dist/graph/map.d.ts.map +1 -1
- package/dist/graph/map.js +111 -24
- package/dist/graph/map.js.map +1 -1
- package/dist/graph/scopes.d.ts +15 -0
- package/dist/graph/scopes.d.ts.map +1 -1
- package/dist/graph/scopes.js +23 -0
- package/dist/graph/scopes.js.map +1 -1
- package/dist/graph/traverse-cli.d.ts +28 -18
- package/dist/graph/traverse-cli.d.ts.map +1 -1
- package/dist/graph/traverse-cli.js +68 -43
- package/dist/graph/traverse-cli.js.map +1 -1
- package/dist/graph/traverse.d.ts +35 -14
- package/dist/graph/traverse.d.ts.map +1 -1
- package/dist/graph/traverse.js +46 -20
- package/dist/graph/traverse.js.map +1 -1
- package/dist/graph/types.d.ts +12 -0
- package/dist/graph/types.d.ts.map +1 -1
- package/dist/graph/workspace-cli.d.ts +37 -0
- package/dist/graph/workspace-cli.d.ts.map +1 -0
- package/dist/graph/workspace-cli.js +70 -0
- package/dist/graph/workspace-cli.js.map +1 -0
- package/dist/graph/workspace.d.ts +128 -0
- package/dist/graph/workspace.d.ts.map +1 -0
- package/dist/graph/workspace.js +395 -0
- package/dist/graph/workspace.js.map +1 -0
- package/dist/hosts/instructions.d.ts.map +1 -1
- package/dist/hosts/instructions.js +7 -3
- package/dist/hosts/instructions.js.map +1 -1
- package/dist/hosts/mcp-config.d.ts +5 -0
- package/dist/hosts/mcp-config.d.ts.map +1 -1
- package/dist/hosts/mcp-config.js +2 -2
- package/dist/hosts/mcp-config.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +94 -66
- package/dist/mcp/tools.js.map +1 -1
- package/dist/search/grep-cli.d.ts.map +1 -1
- package/dist/search/grep-cli.js +4 -0
- package/dist/search/grep-cli.js.map +1 -1
- package/dist/search/grep.d.ts +4 -0
- package/dist/search/grep.d.ts.map +1 -1
- package/dist/search/grep.js +2 -0
- package/dist/search/grep.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|