@davesheffer/hunch 1.27.0 → 1.29.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 +21 -17
- package/dist/cli/index.js +97 -7
- package/dist/cli/serve.js +1 -1
- package/dist/core/format.js +11 -0
- package/dist/core/groundingLag.js +82 -0
- package/dist/core/stateContract.js +24 -0
- package/dist/core/stateDelivery.js +179 -0
- package/dist/core/stateRecords.js +12 -1
- package/dist/integrations/hooks.js +41 -0
- package/dist/integrations/providers.js +8 -0
- package/dist/mcp/server.js +39 -10
- package/dist/serve/app.js +24 -5
- package/dist/store/changeLedger.js +5 -0
- package/dist/store/hunchStore.js +131 -4
- package/dist/store/schema.js +1 -0
- package/dist/store/stateBinding.js +201 -7
- package/package.json +2 -2
- package/server.json +2 -2
- package/tooling/competitive-watch.mjs +1 -0
|
@@ -97,4 +97,45 @@ export function installPreCommitHook(root, invocation, strict = false) {
|
|
|
97
97
|
chmodSync(hookPath, 0o755);
|
|
98
98
|
return { path: hookPath, action: "appended" };
|
|
99
99
|
}
|
|
100
|
+
const MERGE_MARK = "# >>> hunch post-merge >>>";
|
|
101
|
+
const MERGE_END = "# <<< hunch post-merge <<<";
|
|
102
|
+
/** Install a post-merge hook that re-syncs the committed grounding docs when a merge
|
|
103
|
+
* brought memory in behind them (fnd_c402046ac7). Two branches that each captured a
|
|
104
|
+
* record regenerate the same "N+1" counts line; git merges identical lines silently
|
|
105
|
+
* and the doc ends up one behind the store. The hook regenerates the existing docs
|
|
106
|
+
* from the PUBLIC store right after a local merge/pull that touched .hunch/, so the
|
|
107
|
+
* next commit carries them. Foreground (it rewrites five files), loop-guarded via
|
|
108
|
+
* HUNCH_SYNC, and it can never fail the merge. Preserves any existing hook. */
|
|
109
|
+
export function installPostMergeHook(root, invocation) {
|
|
110
|
+
const dir = hooksDir(root);
|
|
111
|
+
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
112
|
+
mkdirSync(abs, { recursive: true });
|
|
113
|
+
const hookPath = join(abs, "post-merge");
|
|
114
|
+
const blk = [
|
|
115
|
+
MERGE_MARK,
|
|
116
|
+
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
117
|
+
" if ! git diff --quiet ORIG_HEAD HEAD -- .hunch 2>/dev/null; then",
|
|
118
|
+
` ( HUNCH_SYNC=1 ${invocation} grounding --refresh 2>/dev/null || true )`,
|
|
119
|
+
" fi",
|
|
120
|
+
"fi",
|
|
121
|
+
MERGE_END,
|
|
122
|
+
].join("\n");
|
|
123
|
+
if (!existsSync(hookPath)) {
|
|
124
|
+
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
125
|
+
chmodSync(hookPath, 0o755);
|
|
126
|
+
return { path: hookPath, action: "created" };
|
|
127
|
+
}
|
|
128
|
+
const cur = readFileSync(hookPath, "utf8");
|
|
129
|
+
if (cur.includes(MERGE_MARK)) {
|
|
130
|
+
const updated = cur.replace(new RegExp(`${escapeRe(MERGE_MARK)}[\\s\\S]*?${escapeRe(MERGE_END)}`), blk);
|
|
131
|
+
if (updated === cur)
|
|
132
|
+
return { path: hookPath, action: "unchanged" };
|
|
133
|
+
writeFileSync(hookPath, updated);
|
|
134
|
+
chmodSync(hookPath, 0o755);
|
|
135
|
+
return { path: hookPath, action: "updated" };
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
|
|
138
|
+
chmodSync(hookPath, 0o755);
|
|
139
|
+
return { path: hookPath, action: "appended" };
|
|
140
|
+
}
|
|
100
141
|
//# sourceMappingURL=hooks.js.map
|
|
@@ -360,6 +360,14 @@ export function regenerateGrounding(root, store) {
|
|
|
360
360
|
writeWindsurfRule(root, store),
|
|
361
361
|
];
|
|
362
362
|
}
|
|
363
|
+
/** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
|
|
364
|
+
export const GROUNDING_DOC_PATHS = Object.freeze([
|
|
365
|
+
"CLAUDE.md",
|
|
366
|
+
"AGENTS.md",
|
|
367
|
+
".github/copilot-instructions.md",
|
|
368
|
+
".cursor/rules/hunch.mdc",
|
|
369
|
+
".windsurf/rules/hunch.md",
|
|
370
|
+
]);
|
|
363
371
|
function groundingTargets(root, store) {
|
|
364
372
|
return [
|
|
365
373
|
["CLAUDE.md", () => updateClaudeMd(root, store)],
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,8 +13,8 @@ import { z } from "zod";
|
|
|
13
13
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
14
14
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
|
-
import { StateRefusal, SubscribeResponseSchema, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
-
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION } from "../core/stateContract.js";
|
|
16
|
+
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, stateHash } from "../core/stateContract.js";
|
|
18
18
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
19
|
import { decisionId, findingId } from "../core/ids.js";
|
|
20
20
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
@@ -24,7 +24,8 @@ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, c
|
|
|
24
24
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
25
25
|
import { withWriteLock } from "../serve/writelock.js";
|
|
26
26
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
27
|
-
import { formatStructure } from "../core/format.js";
|
|
27
|
+
import { formatSearchHit, formatStructure } from "../core/format.js";
|
|
28
|
+
import { isStateKind, stateSupplements } from "../core/stateDelivery.js";
|
|
28
29
|
import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
|
|
29
30
|
import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
|
|
30
31
|
import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
|
|
@@ -754,7 +755,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
754
755
|
return ok(`No matches for "${query}".`);
|
|
755
756
|
const lines = hits.map((h) => {
|
|
756
757
|
const r = store.resolve(h.ref);
|
|
757
|
-
return
|
|
758
|
+
return `${formatSearchHit(h, r?.record)}${provLine(r?.record)}`;
|
|
758
759
|
});
|
|
759
760
|
return ok(`Top matches for "${query}":\n\n${lines.join("\n")}`);
|
|
760
761
|
});
|
|
@@ -930,7 +931,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
930
931
|
return {
|
|
931
932
|
content: [{
|
|
932
933
|
type: "text",
|
|
933
|
-
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority
|
|
934
|
+
text: `${proof.proof_id} — ${proof.verdict.toUpperCase()}; ${proof.changed_file_count} exact file delta(s), ${proof.blast_radius_count} dependent path(s), ${proof.omissions.length + proof.unknowns.length} explicit gap(s); sealed ${proof.content_hash}. Evidence only; no execution or merge authority.`
|
|
935
|
+
// The chain: a `shipped` receipt rests on this proof as a credential-free pointer.
|
|
936
|
+
+ `\n\nrests_on ref (for a nuryel receipt that shipped this change): ${JSON.stringify({ kind: "external", ref: { system: "hunch", object_type: "change_proof", object_key: proof.proof_id, content_hash: proof.content_hash, observed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z") } })}`,
|
|
934
937
|
}],
|
|
935
938
|
structuredContent: proof,
|
|
936
939
|
};
|
|
@@ -1034,6 +1037,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1034
1037
|
// Git checkout cannot provide DNA; the dedicated DNA tool reports the
|
|
1035
1038
|
// exact derivation error when a caller needs diagnostics.
|
|
1036
1039
|
}
|
|
1040
|
+
// The "State" section (nuryel.state/1): current derived, in-force commitments and the
|
|
1041
|
+
// latest receipts whose subject/text matches the target — bounded, ordered, sharing the
|
|
1042
|
+
// brief's budget as supplements. Withheld on time-travel: state records carry no as-of view.
|
|
1043
|
+
const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
|
|
1037
1044
|
const options = {
|
|
1038
1045
|
root,
|
|
1039
1046
|
symbols: store.recs("symbols"),
|
|
@@ -1041,7 +1048,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1041
1048
|
decisionCorpus: store.recs("decisions"),
|
|
1042
1049
|
historical: !!asOf,
|
|
1043
1050
|
profile: profile ?? "builder",
|
|
1044
|
-
supplements: dnaSupplement ? [dnaSupplement] : [],
|
|
1051
|
+
supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding],
|
|
1045
1052
|
};
|
|
1046
1053
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
1047
1054
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
@@ -1068,8 +1075,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1068
1075
|
...options,
|
|
1069
1076
|
supplements: [
|
|
1070
1077
|
...(dnaSupplement ? [dnaSupplement] : []),
|
|
1078
|
+
...stateGrounding,
|
|
1071
1079
|
...hits
|
|
1072
|
-
|
|
1080
|
+
// State hits are delivered through the State section above, not as raw search lines.
|
|
1081
|
+
.filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind) && !isStateKind(hit.kind))
|
|
1073
1082
|
.map((hit, index) => ({
|
|
1074
1083
|
id: hit.ref,
|
|
1075
1084
|
kind: `search-${hit.kind}`,
|
|
@@ -1525,7 +1534,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1525
1534
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1526
1535
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
1527
1536
|
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1528
|
-
|
|
1537
|
+
// The chain (nuryel.state/1): a `shipped` receipt in an organization drawer rests on
|
|
1538
|
+
// this decision by id + the hash ON FILE + this repository's partition. Hand the ref
|
|
1539
|
+
// over now so the agent never rests on a pre-store hash or re-derives the scope.
|
|
1540
|
+
const onFile = store.getRec("decisions", id) ?? rec;
|
|
1541
|
+
const restsOn = JSON.stringify({ kind: "record", id, record_hash: stateHash(onFile), scope: partitionOf(store) });
|
|
1542
|
+
const chainNote = `\n\nrests_on ref (for a nuryel receipt that implements this decision): ${restsOn}`;
|
|
1543
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${chainNote}${captureNote}${quality}`);
|
|
1529
1544
|
}
|
|
1530
1545
|
catch (e) {
|
|
1531
1546
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -1721,10 +1736,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1721
1736
|
if (ref.facet === "derived")
|
|
1722
1737
|
return `- ${label} derived ${ref.id} · computed ${g("computed_at")} · ${r.dependencies?.length ?? 0} dependencies\n ${g("content").slice(0, 1200)}`;
|
|
1723
1738
|
if (ref.facet === "commitments")
|
|
1724
|
-
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}`;
|
|
1739
|
+
return `- ${label} commitment ${ref.id} · ${g("status")} · due ${g("due")} · owner ${g("owner")}: ${g("title")}${r.closed_by ? ` · closed by ${g("closed_by")}` : ""}`;
|
|
1725
1740
|
if (ref.facet === "receipts") {
|
|
1726
1741
|
const t = (r.target ?? {});
|
|
1727
|
-
|
|
1742
|
+
// The chain: what the action rested on, one pointer per line, so a reader follows
|
|
1743
|
+
// incident → decision → change proof → closure without a second call.
|
|
1744
|
+
const rests = (Array.isArray(r.rests_on) ? r.rests_on : []);
|
|
1745
|
+
const restLines = rests.map((d) => {
|
|
1746
|
+
if (d.kind === "record") {
|
|
1747
|
+
const sc = d.scope;
|
|
1748
|
+
return `\n rests on record ${String(d.id)}${sc ? ` in ${String(sc.kind)}/${String(sc.id)}` : ""}`;
|
|
1749
|
+
}
|
|
1750
|
+
if (d.kind === "external") {
|
|
1751
|
+
const x = (d.ref ?? {});
|
|
1752
|
+
return `\n rests on ${String(x.system ?? "")} ${String(x.object_type ?? "")}:${String(x.object_key ?? "")}`;
|
|
1753
|
+
}
|
|
1754
|
+
return `\n rests on ${String(d.kind)} ${String(d.name ?? "")}`;
|
|
1755
|
+
}).join("");
|
|
1756
|
+
return `- ${label} receipt ${ref.id} · ${g("action_kind")} on ${String(t.system ?? "")} ${String(t.object_type ?? "")}:${String(t.object_key ?? "")} · ${g("state")} at ${g("occurred_at")} by ${g("actor")}${restLines}`;
|
|
1728
1757
|
}
|
|
1729
1758
|
if (ref.facet === "decisions")
|
|
1730
1759
|
return `- ${label} decision ${ref.id} · ${g("status")}: ${g("title")}`;
|
package/dist/serve/app.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Every rule lives in src/store/stateBinding.ts; this file only maps HTTP to it:
|
|
10
10
|
* GET /nuryel/v1/capabilities → capabilities of the partition named by ?scope=kind:id (default: first granted)
|
|
11
|
-
* POST /nuryel/v1/read → readState
|
|
11
|
+
* POST /nuryel/v1/read → readState; with `scopes` a UNION read: readState per granted
|
|
12
|
+
* served partition, merged by mergeReadResponses (primary's envelope)
|
|
12
13
|
* POST /nuryel/v1/write → writeState (under the partition's write lock)
|
|
13
14
|
* POST /nuryel/v1/subscribe → subscribeState
|
|
14
15
|
* POST /nuryel/v1/records → recordsState (by id, grants first)
|
|
@@ -18,8 +19,8 @@ import { createServer } from "node:http";
|
|
|
18
19
|
import { HunchStore } from "../store/hunchStore.js";
|
|
19
20
|
import { hunchPaths } from "../core/paths.js";
|
|
20
21
|
import { flushCapture } from "../integrations/sync.js";
|
|
21
|
-
import { StateRefusal, capabilities, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
22
|
-
import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
22
|
+
import { StateRefusal, capabilities, mergeReadResponses, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
23
|
+
import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadScopesSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
23
24
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
24
25
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
25
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -148,8 +149,26 @@ export function createServeApp(config, opts = {}) {
|
|
|
148
149
|
if (url.pathname === "/nuryel/v1/read") {
|
|
149
150
|
const scope = requireScope(principal, body);
|
|
150
151
|
const { store } = storeFor(scope);
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
if (body.scopes === undefined) {
|
|
153
|
+
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
|
|
154
|
+
return send(res, 200, { ...response, envelope });
|
|
155
|
+
}
|
|
156
|
+
// Union read. The primary `scope` was gated above as always; every extra scope is
|
|
157
|
+
// either granted (read from ITS partition — 404 no-partition if this server lacks it)
|
|
158
|
+
// or named in denied_scopes. One ungranted extra never refuses the whole call.
|
|
159
|
+
const requested = ReadScopesSchema.safeParse(body.scopes);
|
|
160
|
+
if (!requested.success)
|
|
161
|
+
throw problem(400, "invalid-scope", "scopes must be 1..64 entries of { kind, id }");
|
|
162
|
+
const { scopes: _scopes, ...rest } = body;
|
|
163
|
+
const isGranted = (s) => principal.grants.some((g) => scopePath(g) === scopePath(s));
|
|
164
|
+
const ungranted = requested.data.filter((s) => !isGranted(s));
|
|
165
|
+
const others = new Map();
|
|
166
|
+
for (const s of requested.data)
|
|
167
|
+
if (isGranted(s) && scopePath(s) !== scopePath(scope) && !others.has(scopePath(s)))
|
|
168
|
+
others.set(scopePath(s), s);
|
|
169
|
+
const primary = readState(store, { schema: STATE_READ_VERSION, principal, ...rest, scope });
|
|
170
|
+
const merged = mergeReadResponses(primary.response, [...others.values()].map((other) => readState(storeFor(other).store, { schema: STATE_READ_VERSION, principal, ...rest, scope: other }).response), ungranted);
|
|
171
|
+
return send(res, 200, { ...merged, envelope: primary.envelope });
|
|
153
172
|
}
|
|
154
173
|
if (url.pathname === "/nuryel/v1/write") {
|
|
155
174
|
const scope = requireScope(principal, body);
|
|
@@ -19,7 +19,12 @@ export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
|
19
19
|
export const CHANGES_DIR = "changes";
|
|
20
20
|
const IdempotencyEntrySchema = z.object({
|
|
21
21
|
record_id: z.string().min(1),
|
|
22
|
+
/** Hash of the record ON FILE (what reads, events and refs see). */
|
|
22
23
|
record_hash: z.string(),
|
|
24
|
+
/** Hash of the normalized payload as the writer sent it (additive). The store may enrich a
|
|
25
|
+
* record on put (a private-mode decision gains `valid_from`), so a replay is recognized by
|
|
26
|
+
* the payload it re-sends, while `record_hash` stays the truth a reader can verify. */
|
|
27
|
+
payload_hash: z.string().optional(),
|
|
23
28
|
facet: z.string(),
|
|
24
29
|
seq: z.number().int().nonnegative(),
|
|
25
30
|
at: z.string(),
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -26,6 +26,7 @@ import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
|
26
26
|
import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
|
|
27
27
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
28
28
|
import { selectReviewedLandscape, } from "../core/landscapeDelivery.js";
|
|
29
|
+
import { STATE_KINDS, STATE_SLICE_CAPS, compareStateHits, isStateKind, stateLiveness, stateObservedAt, stateSearchDoc, stateSubject, } from "../core/stateDelivery.js";
|
|
29
30
|
/** Git cannot resolve repository identity from a cwd that does not exist yet.
|
|
30
31
|
* Probe the nearest real directory so a planned nested overlay cannot evade the
|
|
31
32
|
* public-repository boundary merely by deferring mkdir until its first write. */
|
|
@@ -485,6 +486,20 @@ export class HunchStore {
|
|
|
485
486
|
fts(f.id, "findings", f.title, `${f.observation} ${f.evidence.join(" ")} ${f.affected_files.join(" ")} ${f.affected_symbols.join(" ")} ${f.triage}`);
|
|
486
487
|
}
|
|
487
488
|
counts.findings = fnds.length;
|
|
489
|
+
// nuryel.state/1 kinds (receipts, commitments, derived, entities, relationships):
|
|
490
|
+
// advisory records on the same FTS-only ride as runbooks/findings — no dedicated
|
|
491
|
+
// SQL table. kind = the store kind; title = the subject key; body = the human words
|
|
492
|
+
// + actor/owner + status label (stateSearchDoc), so a subject id and a phrase both
|
|
493
|
+
// hit. History (superseded/done/failed/retired) is indexed too and demoted at
|
|
494
|
+
// query time (demoteHistoricalState / priorMeta), never dropped.
|
|
495
|
+
for (const kind of STATE_KINDS) {
|
|
496
|
+
const records = this.recs(kind);
|
|
497
|
+
for (const record of records) {
|
|
498
|
+
const doc = stateSearchDoc(kind, record);
|
|
499
|
+
fts(record.id, kind, doc.title, doc.body);
|
|
500
|
+
}
|
|
501
|
+
counts[kind] = records.length;
|
|
502
|
+
}
|
|
488
503
|
void j;
|
|
489
504
|
});
|
|
490
505
|
// Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
|
|
@@ -507,13 +522,104 @@ export class HunchStore {
|
|
|
507
522
|
try {
|
|
508
523
|
const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
|
|
509
524
|
FROM search WHERE search MATCH ? ORDER BY score LIMIT ?`).all(match, limit);
|
|
510
|
-
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
|
|
525
|
+
return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
|
|
511
526
|
}
|
|
512
527
|
catch {
|
|
513
528
|
// Malformed FTS expression — degrade to a LIKE scan over titles/bodies.
|
|
514
529
|
return this.likeSearch(query, limit);
|
|
515
530
|
}
|
|
516
531
|
}
|
|
532
|
+
/** State-of-record ordering for nuryel.state/1 hits (superseded derived, done/cancelled
|
|
533
|
+
* commitments, failed receipts, retired entities): indexed and findable, but ranked BELOW
|
|
534
|
+
* the live record of the same subject. bm25 is negative (lower = better), so a history
|
|
535
|
+
* hit's score is scaled toward 0 by STATE_HISTORY_SCORE_FACTOR and the pool is re-sorted
|
|
536
|
+
* STABLY by score — a store with no state history returns the exact SQL order, and a
|
|
537
|
+
* LIKE-fallback pool (all scores 0) is partitioned live-first in its existing order.
|
|
538
|
+
* Bounded (a dimmer, not an exclusion) so the previous summary stays reachable one query
|
|
539
|
+
* away; hybridSearch/rankedSearch additionally apply the liveness prior via priorMeta. */
|
|
540
|
+
demoteHistoricalState(hits) {
|
|
541
|
+
if (!hits.some((h) => isStateKind(h.kind)))
|
|
542
|
+
return hits;
|
|
543
|
+
const scored = hits.map((h) => {
|
|
544
|
+
const meta = this.stateMeta(h.ref, h.kind);
|
|
545
|
+
if (!meta || meta.live)
|
|
546
|
+
return h;
|
|
547
|
+
return { ...h, score: h.score * STATE_HISTORY_SCORE_FACTOR };
|
|
548
|
+
});
|
|
549
|
+
const allZero = scored.every((h) => h.score === 0);
|
|
550
|
+
if (allZero) {
|
|
551
|
+
const live = scored.filter((h) => { const m = this.stateMeta(h.ref, h.kind); return !m || m.live; });
|
|
552
|
+
const history = scored.filter((h) => !live.includes(h));
|
|
553
|
+
return [...live, ...history];
|
|
554
|
+
}
|
|
555
|
+
return scored.sort((a, b) => a.score - b.score);
|
|
556
|
+
}
|
|
557
|
+
/** Liveness + clock for a state hit (null for every non-state kind). */
|
|
558
|
+
stateMeta(ref, kind) {
|
|
559
|
+
if (!isStateKind(kind))
|
|
560
|
+
return null;
|
|
561
|
+
const record = this.recs(kind).find((r) => r.id === ref);
|
|
562
|
+
if (!record)
|
|
563
|
+
return null;
|
|
564
|
+
const { live, label } = stateLiveness(kind, record);
|
|
565
|
+
return { live, label, at: stateObservedAt(kind, record), provenance: record.provenance.source };
|
|
566
|
+
}
|
|
567
|
+
/** The bounded "State" slice for a context brief (hunch_context / `hunch context`): the
|
|
568
|
+
* current derived summaries, in-force commitments and latest verified receipts whose
|
|
569
|
+
* subject or text matches `target`. Matching is AND over the target's tokens (every token
|
|
570
|
+
* must appear, prefix-tolerant) so a file path such as src/store/x.ts never drags in a
|
|
571
|
+
* summary that merely mentions "store"; an exact subject match always qualifies. Order is
|
|
572
|
+
* deterministic: score (best first), then observed_at DESC, then id. Caps per kind are
|
|
573
|
+
* STATE_SLICE_CAPS. A store with no state records returns three empty lists. */
|
|
574
|
+
stateSlice(target) {
|
|
575
|
+
const empty = { derived: [], commitments: [], receipts: [] };
|
|
576
|
+
const needle = toPosixTarget(target).trim();
|
|
577
|
+
if (!needle)
|
|
578
|
+
return empty;
|
|
579
|
+
const tokens = needle.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
580
|
+
const pick = (kind) => {
|
|
581
|
+
const records = this.recs(kind);
|
|
582
|
+
if (!records.length)
|
|
583
|
+
return [];
|
|
584
|
+
const scoreByRef = new Map();
|
|
585
|
+
for (const hit of this.stateFts(tokens, kind, Math.max(STATE_SLICE_CAPS[kind] * 8, 24)))
|
|
586
|
+
scoreByRef.set(hit.ref, hit.score);
|
|
587
|
+
const hits = [];
|
|
588
|
+
for (const record of records) {
|
|
589
|
+
if (!stateLiveness(kind, record).live)
|
|
590
|
+
continue;
|
|
591
|
+
const id = record.id;
|
|
592
|
+
const exact = stateSubject(kind, record) === needle || id === needle;
|
|
593
|
+
let score = scoreByRef.get(id);
|
|
594
|
+
if (score === undefined) {
|
|
595
|
+
if (!exact)
|
|
596
|
+
continue;
|
|
597
|
+
score = 0;
|
|
598
|
+
}
|
|
599
|
+
else if (!exact && !allTokensPresent(tokens, stateSearchDoc(kind, record))) {
|
|
600
|
+
continue; // the LIKE fallback is OR-shaped; keep the AND contract on every runtime
|
|
601
|
+
}
|
|
602
|
+
hits.push({ kind, record, score: exact ? Math.min(score, STATE_EXACT_SUBJECT_SCORE) : score });
|
|
603
|
+
}
|
|
604
|
+
return hits.sort(compareStateHits).slice(0, STATE_SLICE_CAPS[kind]);
|
|
605
|
+
};
|
|
606
|
+
return { derived: pick("derived"), commitments: pick("commitments"), receipts: pick("receipts") };
|
|
607
|
+
}
|
|
608
|
+
/** AND-shaped FTS over one state kind (every token required, prefix-tolerant); degrades to
|
|
609
|
+
* the kind-scoped LIKE scan (OR-shaped — the caller re-checks AND) without FTS5. */
|
|
610
|
+
stateFts(tokens, kind, limit) {
|
|
611
|
+
if (!tokens.length)
|
|
612
|
+
return [];
|
|
613
|
+
const match = tokens.map((t) => `"${t}"*`).join(" ");
|
|
614
|
+
try {
|
|
615
|
+
const rows = this.db.prepare(`SELECT ref, kind, title, '' AS snip, bm25(search) AS score
|
|
616
|
+
FROM search WHERE search MATCH ? AND kind = ? ORDER BY score, ref LIMIT ?`).all(match, kind, limit);
|
|
617
|
+
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return this.likeSearch(tokens.join(" "), limit, kind);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
517
623
|
/** Portable bounded fallback over titles/bodies. Each natural-language token
|
|
518
624
|
* is an OR candidate, mirroring the high-recall FTS query closely enough for
|
|
519
625
|
* runtimes whose SQLite build omits the optional FTS5 module.
|
|
@@ -547,7 +653,7 @@ export class HunchStore {
|
|
|
547
653
|
WHERE ${where}
|
|
548
654
|
ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
|
|
549
655
|
LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
|
|
550
|
-
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 }));
|
|
656
|
+
return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 })));
|
|
551
657
|
}
|
|
552
658
|
// ---- semantic search (opt-in embeddings) --------------------------------
|
|
553
659
|
/** The exact (ref, kind, title, body) docs that feed FTS — and thus embeddings.
|
|
@@ -825,6 +931,12 @@ export class HunchStore {
|
|
|
825
931
|
// A fixed bug is not "dead" — lineage is the point of keeping it findable.
|
|
826
932
|
return { dead: false, provenance: b.provenance.source };
|
|
827
933
|
}
|
|
934
|
+
// nuryel.state/1 kinds: history (superseded / done / failed / retired) dims exactly like a
|
|
935
|
+
// superseded decision; the record's own clock (computed_at, valid_from, verified_at …)
|
|
936
|
+
// drives recency so the latest summary of a subject outranks last month's.
|
|
937
|
+
const state = this.stateMeta(ref, kind);
|
|
938
|
+
if (state)
|
|
939
|
+
return { dead: !state.live, provenance: state.provenance, at: state.at || undefined };
|
|
828
940
|
return null;
|
|
829
941
|
}
|
|
830
942
|
/** Hybrid search (hunch_query / `hunch query --semantic`): FTS bm25 fused with
|
|
@@ -908,7 +1020,7 @@ export class HunchStore {
|
|
|
908
1020
|
try {
|
|
909
1021
|
const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
|
|
910
1022
|
FROM search WHERE search MATCH ? AND kind = ? ORDER BY score LIMIT ?`).all(match, kind, limit);
|
|
911
|
-
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
|
|
1023
|
+
return this.demoteHistoricalState(rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score })));
|
|
912
1024
|
}
|
|
913
1025
|
catch {
|
|
914
1026
|
return this.likeSearch(query, limit, kind);
|
|
@@ -1878,7 +1990,22 @@ const DECISION_FRESHNESS_PATH_CACHE_CAP = 4_096;
|
|
|
1878
1990
|
* intent. Measured on bench/golden-retrieval.json: Recall@10 70% -> 90%, MRR
|
|
1879
1991
|
* 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
|
|
1880
1992
|
const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
|
|
1881
|
-
const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
|
|
1993
|
+
const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies", ...STATE_KINDS]);
|
|
1994
|
+
/** State-of-record ordering in the RAW search path: a nuryel.state/1 history hit (superseded
|
|
1995
|
+
* derived, done/cancelled commitment, failed receipt, retired entity) keeps this fraction of
|
|
1996
|
+
* its bm25 score (bm25 is negative, so scaling toward 0 demotes). 0.5 keeps the previous
|
|
1997
|
+
* summary of a subject one query away while the current one leads; hybridSearch adds the
|
|
1998
|
+
* bounded liveness prior on top. Set HUNCH_STATE_HISTORY_SCORE_FACTOR=1 to disable. */
|
|
1999
|
+
const STATE_HISTORY_SCORE_FACTOR = Math.max(0, Math.min(1, numEnv("HUNCH_STATE_HISTORY_SCORE_FACTOR", 0.5)));
|
|
2000
|
+
/** An exact subject match in stateSlice() leads regardless of bm25 (which is never below this). */
|
|
2001
|
+
const STATE_EXACT_SUBJECT_SCORE = -1_000_000;
|
|
2002
|
+
/** AND contract for stateSlice(): every query token appears (as a prefix) in the record's doc. */
|
|
2003
|
+
function allTokensPresent(tokens, doc) {
|
|
2004
|
+
if (!tokens.length)
|
|
2005
|
+
return false;
|
|
2006
|
+
const words = `${doc.title} ${doc.body}`.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
2007
|
+
return tokens.every((t) => words.some((w) => w.startsWith(t)));
|
|
2008
|
+
}
|
|
1882
2009
|
function safeFreshnessScope(value) {
|
|
1883
2010
|
const normalized = toPosixTarget(value.trim());
|
|
1884
2011
|
if (!normalized || normalized.length > 1_024 || normalized.includes("\0")
|
package/dist/store/schema.js
CHANGED
|
@@ -113,6 +113,7 @@ export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
|
|
|
113
113
|
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
114
114
|
ref UNINDEXED, -- entity id
|
|
115
115
|
kind UNINDEXED, -- components | resources | edges | symbols | decisions | bugs | constraints | runbooks | findings
|
|
116
|
+
-- | receipts | commitments | derived | entities | relationships (nuryel.state/1; title = subject key)
|
|
116
117
|
title,
|
|
117
118
|
body,
|
|
118
119
|
tokenize = 'porter unicode61'
|