@davesheffer/hunch 0.25.0 → 0.26.1
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/cli/index.js +28 -6
- package/dist/eval/harness.js +6 -1
- package/dist/integrations/claudemd.js +3 -2
- package/dist/mcp/server.js +19 -0
- package/dist/store/hunchStore.js +48 -2
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -419,6 +419,7 @@ program
|
|
|
419
419
|
.requiredOption("--file <path>", "golden set JSON: [{ query, expected: [refs], note? }]")
|
|
420
420
|
.option("--k <n>", "top-k cutoff", "10")
|
|
421
421
|
.option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
|
|
422
|
+
.option("--kind <kind>", "restrict scoring to one record kind (e.g. runbooks) — scoped retrieval")
|
|
422
423
|
.action(async (opts) => {
|
|
423
424
|
const { store } = storeFor();
|
|
424
425
|
store.reindex(); // reflect any out-of-band JSON edits before scoring
|
|
@@ -438,7 +439,7 @@ program
|
|
|
438
439
|
// Default is deterministic (FTS + graph, no model). --semantic only adds the
|
|
439
440
|
// semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
|
|
440
441
|
const embedder = opts.semantic ? await selectEmbedder() : undefined;
|
|
441
|
-
const lift = await evaluateGraphLift(store, cases, { k, embedder });
|
|
442
|
+
const lift = await evaluateGraphLift(store, cases, { k, embedder, kind: opts.kind });
|
|
442
443
|
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
443
444
|
const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
|
|
444
445
|
const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
|
|
@@ -458,12 +459,33 @@ program
|
|
|
458
459
|
// ---- runbook (distill reusable "how" from a commit range; roadmap #5) ------
|
|
459
460
|
program
|
|
460
461
|
.command("runbook")
|
|
461
|
-
.description("
|
|
462
|
-
.argument("
|
|
463
|
-
.
|
|
464
|
-
.option("--
|
|
465
|
-
.
|
|
462
|
+
.description("Capture a runbook (the 'how' of a recurring task) from a commit range, or --find one. Advisory.")
|
|
463
|
+
.argument("[range]", "commit range for capture: <base>..<head>, or <base> (→ <base>..HEAD)")
|
|
464
|
+
.option("--task <task>", "the recurring task this runbook answers (capture mode)")
|
|
465
|
+
.option("--find <query>", "look up the runbooks that best match a task/intent (scoped retrieval)")
|
|
466
|
+
.option("--semantic", "use semantic retrieval for --find (requires `hunch embed`)")
|
|
467
|
+
.option("--private", "capture into the private overlay (HUNCH_PRIVATE_DIR), not the committed repo")
|
|
468
|
+
.action(async (range, opts) => {
|
|
466
469
|
const { store, root } = storeFor();
|
|
470
|
+
// Lookup mode: scoped runbook retrieval (search within runbooks, not the whole graph).
|
|
471
|
+
if (opts.find) {
|
|
472
|
+
const emb = opts.semantic ? await selectEmbedder() : undefined;
|
|
473
|
+
const hits = await store.searchRunbooks(opts.find, 5, { embedder: emb });
|
|
474
|
+
if (!hits.length)
|
|
475
|
+
console.log(`No runbook matches "${opts.find}".`);
|
|
476
|
+
else {
|
|
477
|
+
console.log(`Runbooks for "${opts.find}":\n`);
|
|
478
|
+
for (const h of hits)
|
|
479
|
+
console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
|
|
480
|
+
}
|
|
481
|
+
store.close();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Capture mode.
|
|
485
|
+
if (!range || !opts.task) {
|
|
486
|
+
store.close();
|
|
487
|
+
return fail("capture needs a <range> and --task (or use --find <query> to look up)");
|
|
488
|
+
}
|
|
467
489
|
if (opts.private && !store.hasPrivate) {
|
|
468
490
|
store.close();
|
|
469
491
|
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
package/dist/eval/harness.js
CHANGED
|
@@ -3,7 +3,12 @@ export async function evaluateRetrieval(store, cases, opts = {}) {
|
|
|
3
3
|
const k = opts.k ?? 10;
|
|
4
4
|
const perCase = [];
|
|
5
5
|
for (const c of cases) {
|
|
6
|
-
|
|
6
|
+
// A kind-scoped eval uses true scoped retrieval (candidate pool restricted to the
|
|
7
|
+
// kind from the start), not a whole-corpus fetch + filter — the latter's top-50 cap
|
|
8
|
+
// buries terse records before any filter.
|
|
9
|
+
const hits = opts.kind
|
|
10
|
+
? await store.searchScoped(c.query, opts.kind, k, { embedder: opts.embedder })
|
|
11
|
+
: await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
|
|
7
12
|
const top = hits.slice(0, k).map((h) => h.ref);
|
|
8
13
|
const expected = new Set(c.expected);
|
|
9
14
|
let found = 0;
|
|
@@ -30,8 +30,9 @@ export function renderHunchSection(store) {
|
|
|
30
30
|
lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints).");
|
|
31
31
|
lines.push("- `hunch_check_constraints(scope)` — invariants you must not break. **Always run before editing.**");
|
|
32
32
|
lines.push("- `hunch_get_dependents(symbol)` — blast radius before a change.");
|
|
33
|
-
lines.push("- `hunch_bug_lineage(
|
|
34
|
-
lines.push("- `hunch_query(
|
|
33
|
+
lines.push("- `hunch_bug_lineage(symptom_or_symbol)` — has this bug happened before? what was the root cause?");
|
|
34
|
+
lines.push("- `hunch_query(query)` — free-text search across all of Hunch.");
|
|
35
|
+
lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task (e.g. \"add an MCP tool\", \"cut a release\").");
|
|
35
36
|
lines.push("- `hunch_record_decision(...)` — write back a decision after a non-trivial choice.");
|
|
36
37
|
if (constraints.length) {
|
|
37
38
|
lines.push("");
|
package/dist/mcp/server.js
CHANGED
|
@@ -77,6 +77,25 @@ export function buildServer(root) {
|
|
|
77
77
|
});
|
|
78
78
|
return ok(`Top matches for "${query}":\n\n${lines.join("\n")}`);
|
|
79
79
|
});
|
|
80
|
+
// -- hunch_runbook --------------------------------------------------------
|
|
81
|
+
server.registerTool("hunch_runbook", {
|
|
82
|
+
title: "Find a runbook for a task",
|
|
83
|
+
description: "Look up the proven 'how-to' (ordered steps + files) for a recurring task — runbook-SCOPED retrieval (searches within runbooks, not the whole graph). Use at the START of a task to reuse a known procedure instead of re-deriving it. Advisory.",
|
|
84
|
+
inputSchema: { task: z.string().describe("The task/intent, e.g. 'add an MCP tool' or 'cut a release'.") },
|
|
85
|
+
}, async ({ task }) => {
|
|
86
|
+
const hits = await store.searchRunbooks(task, 5, { embedder: await embedderReady });
|
|
87
|
+
if (!hits.length)
|
|
88
|
+
return ok(`No runbook for "${task}" yet. Capture one with: hunch runbook <base>..<head> --task "${task}"`);
|
|
89
|
+
const lines = hits.map((h) => {
|
|
90
|
+
const r = store.resolve(h.ref)?.record;
|
|
91
|
+
if (!r)
|
|
92
|
+
return `• ${h.ref} — ${h.title}`;
|
|
93
|
+
const steps = r.steps.length ? `\n steps: ${r.steps.map((s, i) => `${i + 1}. ${s}`).join(" ")}` : "";
|
|
94
|
+
const files = r.files.length ? `\n files: ${r.files.slice(0, 8).join(", ")}` : "";
|
|
95
|
+
return `• ${r.id} — ${r.task}${steps}${files}${provLine(r)}`;
|
|
96
|
+
});
|
|
97
|
+
return ok(`Runbooks for "${task}" (advisory — a proven 'how', refine to fit):\n\n${lines.join("\n\n")}`);
|
|
98
|
+
});
|
|
80
99
|
// -- hunch_why ------------------------------------------------------------
|
|
81
100
|
server.registerTool("hunch_why", {
|
|
82
101
|
title: "Explain why a file/symbol is the way it is",
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -333,14 +333,60 @@ export class HunchStore {
|
|
|
333
333
|
return fts.slice(0, limit);
|
|
334
334
|
return this.rrfFuse(fts, sem, graph, limit, gw);
|
|
335
335
|
}
|
|
336
|
+
/** Runbook-scoped retrieval (roadmap #5): the same FTS+graph(+semantic) fusion,
|
|
337
|
+
* restricted to the `runbooks` kind — so a "what's the procedure for X" query
|
|
338
|
+
* competes only with other runbooks, not the whole graph. Measurement showed
|
|
339
|
+
* whole-corpus retrieval buries terse runbooks (33% recall); scoping + semantic
|
|
340
|
+
* lifted recall@5 to 83% (dec_1239efae54 follow-up). Pass an embedder for the
|
|
341
|
+
* semantic leg; omit for keyword+graph. */
|
|
342
|
+
async searchRunbooks(query, limit = 5, opts = {}) {
|
|
343
|
+
return this.searchScoped(query, "runbooks", limit, opts);
|
|
344
|
+
}
|
|
345
|
+
/** Kind-SCOPED retrieval: FTS + (optional) semantic fused, but the candidate pool is
|
|
346
|
+
* restricted to one record kind from the START — not over-fetched from a whole-corpus
|
|
347
|
+
* ranking (whose top-50 cap can bury a terse record before any filter). This is what
|
|
348
|
+
* lifted runbook recall@5 from 33% → 83% in the measurement (dec_1239efae54 follow-up). */
|
|
349
|
+
async searchScoped(query, kind, limit = 5, opts = {}) {
|
|
350
|
+
const fts = this.scopedFts(query, kind, Math.max(limit, 20));
|
|
351
|
+
const embedder = opts.embedder !== undefined ? opts.embedder : await selectEmbedder();
|
|
352
|
+
let sem = [];
|
|
353
|
+
if (embedder && this.semanticReady(embedder)) {
|
|
354
|
+
try {
|
|
355
|
+
const [qvec] = await embedder.embed([query]);
|
|
356
|
+
if (qvec)
|
|
357
|
+
sem = this.cosineRank(qvec, embedder.id, Math.max(limit, 20), kind);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
sem = [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!sem.length)
|
|
364
|
+
return fts.slice(0, limit);
|
|
365
|
+
return this.rrfFuse(fts, sem, [], limit);
|
|
366
|
+
}
|
|
367
|
+
/** FTS bm25 over a single kind (the `kind` column is UNINDEXED, so a plain `=`
|
|
368
|
+
* constraint composes with MATCH). Empty when the query has no FTS-able terms. */
|
|
369
|
+
scopedFts(query, kind, limit) {
|
|
370
|
+
const match = toFtsQuery(query);
|
|
371
|
+
if (!match)
|
|
372
|
+
return [];
|
|
373
|
+
try {
|
|
374
|
+
const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
|
|
375
|
+
FROM search WHERE search MATCH ? AND kind = ? ORDER BY score LIMIT ?`).all(match, kind, limit);
|
|
376
|
+
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
336
382
|
/** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
|
|
337
383
|
* pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
|
|
338
384
|
* row stored at a different dimension (model id reused at a new dim) can never
|
|
339
385
|
* drive an out-of-bounds BLOB read; any with an unexpected byte length are
|
|
340
386
|
* skipped defensively rather than crashing the query. */
|
|
341
|
-
cosineRank(qvec, model, n) {
|
|
387
|
+
cosineRank(qvec, model, n, kind) {
|
|
342
388
|
const dim = qvec.length;
|
|
343
|
-
const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ? AND dim =
|
|
389
|
+
const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ? AND dim = ?${kind ? " AND kind = ?" : ""}`).all(...(kind ? [model, dim, kind] : [model, dim]));
|
|
344
390
|
const scored = [];
|
|
345
391
|
for (const r of rows) {
|
|
346
392
|
if (r.vec.byteLength !== dim * 4)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
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.",
|