@davesheffer/hunch 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/types.js +1 -1
- package/dist/mcp/server.js +7 -3
- package/dist/store/hunchStore.js +98 -23
- package/package.json +1 -1
package/dist/core/types.js
CHANGED
|
@@ -79,7 +79,7 @@ export const RetiredSignalSchema = z.object({
|
|
|
79
79
|
* existed in code, so its prose is turned into a testable set/regex. Carries its
|
|
80
80
|
* OWN provenance, separate from the decision's: an LLM may DRAFT a tripwire
|
|
81
81
|
* (advisory only); only a `human_confirmed` tripwire may BLOCK a commit — for every
|
|
82
|
-
* tier. One predictable rule (dec_a466655539).
|
|
82
|
+
* tier. One predictable rule (dec_a466655539). */
|
|
83
83
|
export const RejectedTripwireSchema = z.object({
|
|
84
84
|
alternative: z.string().describe("the rejected approach's human text — printed verbatim in the receipt"),
|
|
85
85
|
scope: z.array(z.string()).default([]).describe("glob(s) it applies to, e.g. vscode-extension/**"),
|
package/dist/mcp/server.js
CHANGED
|
@@ -288,9 +288,13 @@ export function buildServer(root) {
|
|
|
288
288
|
else
|
|
289
289
|
store.json.put("decisions", rec);
|
|
290
290
|
// Invalidate, don't delete: closing the superseded decision's valid-time window
|
|
291
|
-
// (+ a supersedes edge) preserves the why-it-changed trail.
|
|
292
|
-
//
|
|
293
|
-
|
|
291
|
+
// (+ a supersedes edge) preserves the why-it-changed trail. Route the close to the
|
|
292
|
+
// same store the new record landed in — a private decision supersedes within the
|
|
293
|
+
// private overlay; a public one in the committed store. A private write never
|
|
294
|
+
// mutates the public store.
|
|
295
|
+
const superseded = decision.supersedes
|
|
296
|
+
? (decision.private ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
|
|
297
|
+
: null;
|
|
294
298
|
store.reindex();
|
|
295
299
|
// Auto-flush the private repo when configured (hunch private --auto-commit), so a
|
|
296
300
|
// record made via MCP between public commits is committed+pushed immediately.
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -296,23 +296,32 @@ export class HunchStore {
|
|
|
296
296
|
* the lean install and fallback regressions are unaffected. Pass
|
|
297
297
|
* `embedder: null` to FORCE FTS-only without auto-selecting. */
|
|
298
298
|
async hybridSearch(query, limit = 12, opts = {}) {
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
// Explicit `embedder: null` forces pure FTS-only (no semantic, no graph) — the
|
|
300
|
+
// documented escape hatch and the lean-fallback regression guard.
|
|
301
|
+
if (opts.embedder === null)
|
|
301
302
|
return this.search(query, limit);
|
|
303
|
+
const embedder = opts.embedder !== undefined ? opts.embedder : await selectEmbedder();
|
|
302
304
|
const fts = this.search(query, Math.max(limit, 50));
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
305
|
+
let sem = [];
|
|
306
|
+
if (embedder && this.semanticReady(embedder)) {
|
|
307
|
+
try {
|
|
308
|
+
// The semantic leg (query embedding + decode + cosine) is guarded: any failure —
|
|
309
|
+
// model load, a corrupt/dim-mismatched vector — degrades to lexical + graph.
|
|
310
|
+
const [qvec] = await embedder.embed([query]);
|
|
311
|
+
if (qvec)
|
|
312
|
+
sem = this.cosineRank(qvec, embedder.id, 50);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
sem = [];
|
|
316
|
+
}
|
|
312
317
|
}
|
|
313
|
-
|
|
318
|
+
// The graph stream is model-free, so it contributes even on a lean (no-embeddings)
|
|
319
|
+
// install. With neither semantic nor graph signal, return pure FTS so the
|
|
320
|
+
// zero-fusion-overhead fast path is preserved.
|
|
321
|
+
const graph = this.graphExpand([...fts, ...sem], 50);
|
|
322
|
+
if (!sem.length && !graph.length)
|
|
314
323
|
return fts.slice(0, limit);
|
|
315
|
-
|
|
324
|
+
return this.rrfFuse(fts, sem, graph, limit);
|
|
316
325
|
}
|
|
317
326
|
/** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
|
|
318
327
|
* pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
|
|
@@ -347,10 +356,12 @@ export class HunchStore {
|
|
|
347
356
|
return { ref: s.ref, kind: s.kind, title: m?.title ?? s.ref, snippet: (m?.body ?? "").slice(0, 120), score: s.score };
|
|
348
357
|
});
|
|
349
358
|
}
|
|
350
|
-
/** Rank-based Reciprocal Rank Fusion of the FTS and
|
|
351
|
-
* raw scores) erase the bm25-vs-cosine scale mismatch; a small lexical
|
|
352
|
-
* keeps exact symbol/path matches from being displaced by paraphrase
|
|
353
|
-
|
|
359
|
+
/** Rank-based Reciprocal Rank Fusion of the FTS, semantic, and graph lists. Ranks
|
|
360
|
+
* (not raw scores) erase the bm25-vs-cosine-vs-graph scale mismatch; a small lexical
|
|
361
|
+
* weight keeps exact symbol/path matches from being displaced by paraphrase or
|
|
362
|
+
* neighbor hits. An empty list contributes nothing, so 2-stream behavior is exactly
|
|
363
|
+
* preserved when graph (or sem) is absent. */
|
|
364
|
+
rrfFuse(fts, sem, graph, limit) {
|
|
354
365
|
const acc = new Map();
|
|
355
366
|
const add = (list, weight) => list.forEach((hit, i) => {
|
|
356
367
|
const e = acc.get(hit.ref) ?? { hit, score: 0 };
|
|
@@ -359,8 +370,51 @@ export class HunchStore {
|
|
|
359
370
|
});
|
|
360
371
|
add(fts, RRF_W_FTS);
|
|
361
372
|
add(sem, RRF_W_SEM);
|
|
373
|
+
add(graph, RRF_W_GRAPH);
|
|
362
374
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
|
|
363
375
|
}
|
|
376
|
+
/** Graph retrieval stream (roadmap #1): 1-hop expansion over the dependency graph
|
|
377
|
+
* from the lexical/semantic seed hits. For each seed SYMBOL, surface its direct
|
|
378
|
+
* neighbors (callers/callees, importers/imported, container) — the cross-file
|
|
379
|
+
* evidence a "why" question needs but that neither bm25 nor cosine reaches. Each
|
|
380
|
+
* neighbor accrues GAMMA-decayed support per linking seed (one pulled in by several
|
|
381
|
+
* top seeds ranks higher); seeds themselves are excluded, so this only ADDS context.
|
|
382
|
+
* Deterministic, model-free (runs on a lean install too), one indexed query per seed. */
|
|
383
|
+
graphExpand(seeds, n) {
|
|
384
|
+
if (RRF_W_GRAPH <= 0)
|
|
385
|
+
return [];
|
|
386
|
+
const symSeeds = seeds.filter((h) => h.ref.startsWith("sym_"));
|
|
387
|
+
if (!symSeeds.length)
|
|
388
|
+
return [];
|
|
389
|
+
const seen = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
390
|
+
const nbStmt = this.db.prepare(
|
|
391
|
+
/* sql */ `
|
|
392
|
+
SELECT e."to" AS nb FROM edges e WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
393
|
+
UNION
|
|
394
|
+
SELECT e."from" AS nb FROM edges e WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')`);
|
|
395
|
+
const score = new Map();
|
|
396
|
+
symSeeds.forEach((h, i) => {
|
|
397
|
+
const contrib = GRAPH_GAMMA / (RRF_K + i + 1);
|
|
398
|
+
for (const r of nbStmt.all(h.ref, h.ref)) {
|
|
399
|
+
if (seen.has(r.nb))
|
|
400
|
+
continue;
|
|
401
|
+
score.set(r.nb, (score.get(r.nb) ?? 0) + contrib);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
if (!score.size)
|
|
405
|
+
return [];
|
|
406
|
+
const top = [...score.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
407
|
+
// Hydrate title/snippet from the FTS table in ONE query (mirrors cosineRank).
|
|
408
|
+
const placeholders = top.map(() => "?").join(",");
|
|
409
|
+
const meta = new Map();
|
|
410
|
+
for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
411
|
+
meta.set(row.ref, { title: row.title, body: row.body });
|
|
412
|
+
}
|
|
413
|
+
return top.map(([ref, s]) => {
|
|
414
|
+
const m = meta.get(ref);
|
|
415
|
+
return { ref, kind: ref.startsWith("cmp_") ? "component" : "symbol", title: m?.title ?? ref, snippet: (m?.body ?? "").slice(0, 120), score: s };
|
|
416
|
+
});
|
|
417
|
+
}
|
|
364
418
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
365
419
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
366
420
|
* return only decisions/constraints whose valid-time window contained that
|
|
@@ -615,7 +669,22 @@ export class HunchStore {
|
|
|
615
669
|
* and write a `supersedes` edge. Returns the updated old decision, or null if it
|
|
616
670
|
* doesn't exist. All writes are atomic via json.put (con_902759b3dc). */
|
|
617
671
|
supersede(oldId, by) {
|
|
618
|
-
|
|
672
|
+
return this.supersedeIn(this.json, oldId, by);
|
|
673
|
+
}
|
|
674
|
+
/** Private-overlay counterpart of `supersede`: close + link the old decision inside
|
|
675
|
+
* the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
|
|
676
|
+
* one (the MCP record path is private→private). A private write never mutates the
|
|
677
|
+
* committed public store. Returns null if no private store is configured or the old
|
|
678
|
+
* record isn't in it. */
|
|
679
|
+
supersedePrivate(oldId, by) {
|
|
680
|
+
if (!this.privateJson)
|
|
681
|
+
return null;
|
|
682
|
+
this.privateJson.ensureDirs();
|
|
683
|
+
return this.supersedeIn(this.privateJson, oldId, by);
|
|
684
|
+
}
|
|
685
|
+
/** Shared body for supersede / supersedePrivate against a specific store. */
|
|
686
|
+
supersedeIn(json, oldId, by) {
|
|
687
|
+
const old = json.get("decisions", oldId);
|
|
619
688
|
if (!old || old.id === by.id)
|
|
620
689
|
return null;
|
|
621
690
|
const closed = {
|
|
@@ -624,7 +693,7 @@ export class HunchStore {
|
|
|
624
693
|
superseded_by: by.id,
|
|
625
694
|
valid_to: old.valid_to ?? by.valid_from ?? null,
|
|
626
695
|
};
|
|
627
|
-
|
|
696
|
+
json.put("decisions", closed);
|
|
628
697
|
const edge = {
|
|
629
698
|
id: edgeId(by.id, oldId, "supersedes"),
|
|
630
699
|
from: by.id,
|
|
@@ -634,7 +703,7 @@ export class HunchStore {
|
|
|
634
703
|
strength: 1,
|
|
635
704
|
provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
|
|
636
705
|
};
|
|
637
|
-
|
|
706
|
+
json.put("edges", edge);
|
|
638
707
|
return closed;
|
|
639
708
|
}
|
|
640
709
|
/** Regression Guard: detect a change RE-INTRODUCING something an in-force
|
|
@@ -690,7 +759,7 @@ export class HunchStore {
|
|
|
690
759
|
* never did. Precision-first ladder (dep > symbol > pattern); the semantic tier is
|
|
691
760
|
* advisory and lives elsewhere. A hit `blocks` only when isVetoBlocker passes (a
|
|
692
761
|
* human-confirmed tripwire on an in-force, non-stale decision — dec_a466655539).
|
|
693
|
-
* Read-only; shared by buildCheckReport.
|
|
762
|
+
* Read-only; shared by buildCheckReport. */
|
|
694
763
|
vetoHits(an, files, staleDecisions = new Set()) {
|
|
695
764
|
const addedDeps = new Set(an.addedDeps);
|
|
696
765
|
const out = [];
|
|
@@ -956,11 +1025,17 @@ function round(n) {
|
|
|
956
1025
|
return Math.round(n * 100) / 100;
|
|
957
1026
|
}
|
|
958
1027
|
// --- semantic-search helpers ---------------------------------------------
|
|
959
|
-
/** RRF tuning (env-overridable). Lexical weight ≥ semantic
|
|
960
|
-
* ties
|
|
1028
|
+
/** RRF tuning (env-overridable). Lexical weight ≥ semantic ≥ graph: exact matches
|
|
1029
|
+
* win ties, semantic adds paraphrase recall, and the graph stream adds the
|
|
1030
|
+
* dependency-neighbor evidence a "why" question needs across files. The graph
|
|
1031
|
+
* weight is conservative + measurement-gated (set HUNCH_RRF_W_GRAPH=0 to disable);
|
|
1032
|
+
* GAMMA only decays a neighbor's cross-seed support, so its absolute value is
|
|
1033
|
+
* normalized away by the rank-based fusion. */
|
|
961
1034
|
const RRF_K = numEnv("HUNCH_RRF_K", 60);
|
|
962
1035
|
const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
|
|
963
1036
|
const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
|
|
1037
|
+
const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
1038
|
+
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
964
1039
|
function numEnv(name, dflt) {
|
|
965
1040
|
const v = Number(process.env[name]);
|
|
966
1041
|
return Number.isFinite(v) && v > 0 ? v : dflt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|