@davesheffer/hunch 0.25.0 → 0.26.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/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("Distill a reusable runbook (the 'how' of a recurring task) from a commit range. Advisory; refine before relying on it.")
462
- .argument("<range>", "commit range: <base>..<head>, or <base> (→ <base>..HEAD)")
463
- .requiredOption("--task <task>", "the recurring task this runbook answers")
464
- .option("--private", "write into the private overlay (HUNCH_PRIVATE_DIR), not the committed repo")
465
- .action((range, opts) => {
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");
@@ -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
- const hits = await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
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;
@@ -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",
@@ -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 = ?`).all(model, 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.25.0",
3
+ "version": "0.26.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.",