@davesheffer/hunch 1.26.2 → 1.28.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 +18 -4
- package/dist/cli/serve.js +26 -2
- package/dist/client/state.js +1 -0
- package/dist/core/format.js +11 -0
- package/dist/core/stateContract.js +41 -1
- package/dist/core/stateDelivery.js +179 -0
- package/dist/mcp/server.js +29 -6
- package/dist/serve/app.js +30 -5
- package/dist/store/changeLedger.js +61 -4
- package/dist/store/hunchStore.js +131 -4
- package/dist/store/merge.js +34 -0
- package/dist/store/schema.js +1 -0
- package/dist/store/stateBinding.js +157 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -55,7 +55,8 @@ import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
|
55
55
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
56
56
|
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
|
|
57
57
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
58
|
-
import { formatContext, formatStructure } from "../core/format.js";
|
|
58
|
+
import { formatContext, formatSearchHit, formatStructure } from "../core/format.js";
|
|
59
|
+
import { isStateKind, renderStateLine, stateSupplements } from "../core/stateDelivery.js";
|
|
59
60
|
import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
|
|
60
61
|
import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
|
|
61
62
|
import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
|
|
@@ -1320,7 +1321,7 @@ program
|
|
|
1320
1321
|
else {
|
|
1321
1322
|
console.log(`Top matches for "${q}"${how}:\n`);
|
|
1322
1323
|
for (const h of hits)
|
|
1323
|
-
console.log(
|
|
1324
|
+
console.log(formatSearchHit(h, isStateKind(h.kind) ? store.resolve(h.ref)?.record : undefined));
|
|
1324
1325
|
}
|
|
1325
1326
|
store.close();
|
|
1326
1327
|
});
|
|
@@ -4059,12 +4060,24 @@ program
|
|
|
4059
4060
|
!ctx.findings.length &&
|
|
4060
4061
|
!ctx.landscape?.resources.length &&
|
|
4061
4062
|
!ctx.landscape?.relationships.length;
|
|
4063
|
+
// The "State" section (nuryel.state/1): current derived, in-force commitments, latest
|
|
4064
|
+
// receipts matching the target — the same slice and render as hunch_context.
|
|
4065
|
+
const slice = asOf ? null : store.stateSlice(target);
|
|
4066
|
+
const stateGrounding = slice ? stateSupplements(slice, target) : [];
|
|
4062
4067
|
if (empty && !asOf) {
|
|
4063
|
-
const hits = store.rankedSearch(target, 8);
|
|
4064
|
-
if (hits.length) {
|
|
4068
|
+
const hits = store.rankedSearch(target, 8).filter((h) => !isStateKind(h.kind));
|
|
4069
|
+
if (hits.length || stateGrounding.length) {
|
|
4065
4070
|
console.log(`No file/symbol resolves for "${target}" — closest graph matches instead:\n`);
|
|
4066
4071
|
for (const h of hits)
|
|
4067
4072
|
console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
|
|
4073
|
+
if (slice) {
|
|
4074
|
+
const stateHits = [...slice.derived, ...slice.commitments, ...slice.receipts];
|
|
4075
|
+
if (stateHits.length) {
|
|
4076
|
+
console.log(`${hits.length ? "\n" : ""}State (nuryel.state/1):`);
|
|
4077
|
+
for (const hit of stateHits)
|
|
4078
|
+
console.log(`• ${renderStateLine(hit.kind, hit.record)}`);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4068
4081
|
console.log(`\n(For a file/symbol brief use a concrete target; for free-text this is what \`hunch query\` returns.)`);
|
|
4069
4082
|
store.close();
|
|
4070
4083
|
return;
|
|
@@ -4077,6 +4090,7 @@ program
|
|
|
4077
4090
|
decisionCorpus: store.recs("decisions"),
|
|
4078
4091
|
historical: !!asOf,
|
|
4079
4092
|
profile: opts.profile,
|
|
4093
|
+
supplements: stateGrounding,
|
|
4080
4094
|
}));
|
|
4081
4095
|
store.close();
|
|
4082
4096
|
});
|
package/dist/cli/serve.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { createServeApp } from "../serve/app.js";
|
|
3
|
-
import { initServeConfig, readServeConfig } from "../serve/config.js";
|
|
3
|
+
import { initServeConfig, partitionFor, readServeConfig } from "../serve/config.js";
|
|
4
|
+
import { compactLedger } from "../store/changeLedger.js";
|
|
5
|
+
import { join } from "node:path";
|
|
4
6
|
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
5
7
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
6
8
|
function parseScopeArg(value) {
|
|
@@ -31,9 +33,31 @@ export function registerServeCommands(program) {
|
|
|
31
33
|
process.on("SIGINT", stop);
|
|
32
34
|
process.on("SIGTERM", stop);
|
|
33
35
|
});
|
|
36
|
+
serve.command("compact")
|
|
37
|
+
.description("Compact a served partition's change ledger: keep the newest N events, move the floor up; subscribers below the floor resynchronize")
|
|
38
|
+
.requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
|
|
39
|
+
.option("--keep <n>", "events to keep", "1000")
|
|
40
|
+
.option("--json", "machine-readable output")
|
|
41
|
+
.action((opts) => {
|
|
42
|
+
const parent = serve.opts();
|
|
43
|
+
const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
|
|
44
|
+
const scope = parseScopeArg(opts.partition);
|
|
45
|
+
const partition = partitionFor(config, scope);
|
|
46
|
+
if (!partition)
|
|
47
|
+
throw new Error(`this config does not serve ${scopePath(scope)}`);
|
|
48
|
+
const keep = Number(opts.keep);
|
|
49
|
+
if (!Number.isInteger(keep) || keep < 0)
|
|
50
|
+
throw new Error("--keep must be a non-negative integer");
|
|
51
|
+
const result = compactLedger(join(partition.root, ".hunch"), scope, { keep });
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(result.dropped ? `${scopePath(scope)}: dropped ${result.dropped} event(s); floor ${result.floor_seq}, head ${result.head_seq}` : `${scopePath(scope)}: nothing to compact (${result.head_seq - result.floor_seq} events retained)`);
|
|
57
|
+
});
|
|
34
58
|
serve.command("init")
|
|
35
59
|
.description("Declare a partition directory and mint a principal token (printed once; only its hash is stored)")
|
|
36
|
-
.requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:
|
|
60
|
+
.requiredOption("--partition <kind:id>", "the scope this directory IS, e.g. user:david or organization:acme")
|
|
37
61
|
.requiredOption("--root <dir>", "directory whose .hunch/ holds the partition (created if missing)")
|
|
38
62
|
.option("--config <file>", `serve config to create or extend; default ${DEFAULT_CONFIG}`)
|
|
39
63
|
.option("--principal <id>", "principal to add or rotate, granted this partition")
|
package/dist/client/state.js
CHANGED
|
@@ -42,6 +42,7 @@ export function createStateClient(opts) {
|
|
|
42
42
|
read: (request) => call("POST", "/nuryel/v1/read", request),
|
|
43
43
|
write: (request) => call("POST", "/nuryel/v1/write", request),
|
|
44
44
|
subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
|
|
45
|
+
records: (request) => call("POST", "/nuryel/v1/records", request),
|
|
45
46
|
health: () => call("GET", "/nuryel/v1/health"),
|
|
46
47
|
};
|
|
47
48
|
}
|
package/dist/core/format.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { buildDeliveryEnvelope } from "./delivery.js";
|
|
2
|
+
import { isStateKind, renderStateLine } from "./stateDelivery.js";
|
|
2
3
|
export function formatContext(ctx, options = {}) {
|
|
3
4
|
return buildDeliveryEnvelope(ctx, options).text;
|
|
4
5
|
}
|
|
6
|
+
/** One search hit as `hunch query` / hunch_query print it (headline + indented detail line).
|
|
7
|
+
* Graph records keep their `[kind] id — title` shape; a nuryel.state/1 hit renders through
|
|
8
|
+
* renderStateLine (`[commitment/in_force] customer:Site:7 — "send report" due … (owner …)`)
|
|
9
|
+
* with the record id on the detail line, so both readers say the same thing. */
|
|
10
|
+
export function formatSearchHit(hit, record) {
|
|
11
|
+
if (isStateKind(hit.kind) && record) {
|
|
12
|
+
return `• ${renderStateLine(hit.kind, record)}\n ${hit.ref}`;
|
|
13
|
+
}
|
|
14
|
+
return `• [${hit.kind}] ${hit.ref} — ${hit.title}\n ${hit.snippet}`;
|
|
15
|
+
}
|
|
5
16
|
/** Render a StructureView as a compact orientation brief (hunch_structure). */
|
|
6
17
|
export function formatStructure(v) {
|
|
7
18
|
const NL = "\n";
|
|
@@ -34,10 +34,11 @@ export const STATE_CONTRACT_VERSION = "nuryel.state/1";
|
|
|
34
34
|
export const STATE_READ_VERSION = "nuryel.state.read/1";
|
|
35
35
|
export const STATE_WRITE_VERSION = "nuryel.state.write/1";
|
|
36
36
|
export const STATE_SUBSCRIBE_VERSION = "nuryel.state.subscribe/1";
|
|
37
|
+
export const STATE_RECORDS_VERSION = "nuryel.state.records/1";
|
|
37
38
|
/** Capabilities a server advertises; a client that needs one the server lacks gets a typed
|
|
38
39
|
* `unsupported`, never a compatible-looking degraded answer. */
|
|
39
40
|
export const STATE_CAPABILITIES = [
|
|
40
|
-
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION,
|
|
41
|
+
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION,
|
|
41
42
|
RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION,
|
|
42
43
|
];
|
|
43
44
|
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
@@ -55,10 +56,15 @@ export const PrincipalSchema = z.object({
|
|
|
55
56
|
}).strict();
|
|
56
57
|
export const STATE_FACETS = ["decisions", "constraints", "bugs", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
|
|
57
58
|
// ---- verbs ------------------------------------------------------------------------------
|
|
59
|
+
/** Union read: the partitions a principal wants in ONE answer. `scope` stays required (it is the
|
|
60
|
+
* primary partition; its envelope and receipt lead the response). An entry the principal is not
|
|
61
|
+
* granted is NAMED in `denied_scopes` — it never refuses the whole call, and is never described. */
|
|
62
|
+
export const ReadScopesSchema = z.array(ScopeSchema).min(1).max(64);
|
|
58
63
|
export const ReadRequestSchema = z.object({
|
|
59
64
|
schema: z.literal(STATE_READ_VERSION),
|
|
60
65
|
principal: PrincipalSchema,
|
|
61
66
|
scope: ScopeSchema,
|
|
67
|
+
scopes: ReadScopesSchema.optional(),
|
|
62
68
|
subject: z.string().max(512).optional(),
|
|
63
69
|
task: z.string().max(4096).optional(),
|
|
64
70
|
profile: z.enum(DELIVERY_PROFILES).optional(),
|
|
@@ -91,6 +97,12 @@ export const ReadResponseSchema = z.object({
|
|
|
91
97
|
/** The records behind every ref in `state_of_record`, by id, so a consumer can answer from
|
|
92
98
|
* the drawer without a second lookup. Additive; absent when there is no subject. */
|
|
93
99
|
records: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
|
100
|
+
/** Union read (additive): the partitions actually read, primary first. Absent on a
|
|
101
|
+
* single-partition read. */
|
|
102
|
+
scopes: z.array(ScopeSchema).max(64).optional(),
|
|
103
|
+
/** Union read (additive): one delivery receipt per partition read; `receipt_id` above stays
|
|
104
|
+
* the primary's. */
|
|
105
|
+
receipts: z.array(z.object({ scope: ScopeSchema, receipt_id: z.string().regex(/^hdr_[a-f0-9]{24}$/) }).strict()).max(64).optional(),
|
|
94
106
|
}).strict();
|
|
95
107
|
export const WriteRequestSchema = z.object({
|
|
96
108
|
schema: z.literal(STATE_WRITE_VERSION),
|
|
@@ -110,6 +122,9 @@ export const WriteResultSchema = z.object({
|
|
|
110
122
|
durability: z.enum(DURABILITY),
|
|
111
123
|
outcome: z.enum(["created", "updated", "replayed", "superseded"]),
|
|
112
124
|
conflict: z.object({ incumbent_id: z.string().max(2048), reason: z.string().max(512) }).strict().nullable().default(null),
|
|
125
|
+
/** The record as stored (after normalization and identity derivation), so a writer can verify
|
|
126
|
+
* what landed without a second lookup. Additive. */
|
|
127
|
+
record: z.record(z.string(), z.unknown()).optional(),
|
|
113
128
|
}).strict();
|
|
114
129
|
export const SubscribeRequestSchema = z.object({
|
|
115
130
|
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
@@ -138,6 +153,23 @@ export const ChangeEventSchema = z.object({
|
|
|
138
153
|
z.object({ kind: z.literal("write"), principal: z.string().regex(TOKEN) }).strict(),
|
|
139
154
|
]).optional(),
|
|
140
155
|
}).strict();
|
|
156
|
+
/** records — fetch records by id, grants first. A subscribe event names a record; this is how
|
|
157
|
+
* a consumer gets its body without a subject read. Ids outside the grants are named in
|
|
158
|
+
* `denied`, unknown ids in `missing`; neither is silently dropped. */
|
|
159
|
+
export const RecordsRequestSchema = z.object({
|
|
160
|
+
schema: z.literal(STATE_RECORDS_VERSION),
|
|
161
|
+
principal: PrincipalSchema,
|
|
162
|
+
scope: ScopeSchema,
|
|
163
|
+
ids: z.array(z.string().min(1).max(2048)).min(1).max(256),
|
|
164
|
+
}).strict();
|
|
165
|
+
export const RecordsResponseSchema = z.object({
|
|
166
|
+
schema: z.literal(STATE_RECORDS_VERSION),
|
|
167
|
+
scope: ScopeSchema,
|
|
168
|
+
records: z.record(z.string(), z.record(z.string(), z.unknown())),
|
|
169
|
+
facets: z.record(z.string(), z.enum(STATE_FACETS)),
|
|
170
|
+
missing: z.array(z.string().max(2048)).default([]),
|
|
171
|
+
denied: z.array(z.string().max(2048)).default([]),
|
|
172
|
+
}).strict();
|
|
141
173
|
export const CapabilityNegotiationSchema = z.object({
|
|
142
174
|
protocol: z.literal(STATE_CONTRACT_VERSION),
|
|
143
175
|
capabilities: z.array(z.string().max(128)).max(64),
|
|
@@ -215,6 +247,14 @@ export function assertReadWithinGrants(principal, response) {
|
|
|
215
247
|
if (granted.has(grantKey(denied)))
|
|
216
248
|
throw new Error(`denied scope ${grantKey(denied)} is actually granted — the response is inconsistent`);
|
|
217
249
|
}
|
|
250
|
+
for (const read of response.scopes ?? []) {
|
|
251
|
+
if (!granted.has(grantKey(read)))
|
|
252
|
+
throw new Error(`read scope ${grantKey(read)} is outside the principal's grants`);
|
|
253
|
+
}
|
|
254
|
+
for (const receipt of response.receipts ?? []) {
|
|
255
|
+
if (!granted.has(grantKey(receipt.scope)))
|
|
256
|
+
throw new Error(`receipt for scope ${grantKey(receipt.scope)} is outside the principal's grants`);
|
|
257
|
+
}
|
|
218
258
|
}
|
|
219
259
|
/** provenance-on-every-write + scope agreement between the envelope and the record. */
|
|
220
260
|
export function assertWriteWellFormed(request) {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export const STATE_KINDS = ["receipts", "commitments", "derived", "entities", "relationships"];
|
|
2
|
+
const STATE_KIND_SET = new Set(STATE_KINDS);
|
|
3
|
+
export function isStateKind(kind) {
|
|
4
|
+
return STATE_KIND_SET.has(kind);
|
|
5
|
+
}
|
|
6
|
+
/** Singular facet label used in renders: `[commitment/in_force]`, `[derived/current]`. */
|
|
7
|
+
const FACET_LABEL = {
|
|
8
|
+
receipts: "receipt",
|
|
9
|
+
commitments: "commitment",
|
|
10
|
+
derived: "derived",
|
|
11
|
+
entities: "entity",
|
|
12
|
+
relationships: "relationship",
|
|
13
|
+
};
|
|
14
|
+
/** Liveness per kind, mirroring readState's state_of_record predicates exactly
|
|
15
|
+
* (derived `current` with an open window; commitment open/waiting with an open window;
|
|
16
|
+
* receipt succeeded/verified; entity active; a relationship is always current). */
|
|
17
|
+
export function stateLiveness(kind, record) {
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case "derived": {
|
|
20
|
+
const d = record;
|
|
21
|
+
if (d.valid_to != null)
|
|
22
|
+
return { label: "superseded", live: false };
|
|
23
|
+
return { label: d.state, live: d.state === "current" };
|
|
24
|
+
}
|
|
25
|
+
case "commitments": {
|
|
26
|
+
const c = record;
|
|
27
|
+
if (c.status === "open" || c.status === "waiting") {
|
|
28
|
+
return c.valid_to == null ? { label: "in_force", live: true } : { label: "superseded", live: false };
|
|
29
|
+
}
|
|
30
|
+
return { label: c.status, live: false };
|
|
31
|
+
}
|
|
32
|
+
case "receipts": {
|
|
33
|
+
const r = record;
|
|
34
|
+
return { label: r.state, live: r.state === "succeeded" || r.state === "verified" };
|
|
35
|
+
}
|
|
36
|
+
case "entities": {
|
|
37
|
+
const e = record;
|
|
38
|
+
return { label: e.lifecycle, live: e.lifecycle === "active" };
|
|
39
|
+
}
|
|
40
|
+
case "relationships":
|
|
41
|
+
return { label: "current", live: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The subject key a reader would type: the commitment/derived subject, the receipt's target
|
|
45
|
+
* object (`event:10042`), the entity id, or the relationship's `from` endpoint. */
|
|
46
|
+
export function stateSubject(kind, record) {
|
|
47
|
+
switch (kind) {
|
|
48
|
+
case "derived": return record.subject;
|
|
49
|
+
case "commitments": return record.subject;
|
|
50
|
+
case "receipts": {
|
|
51
|
+
const r = record;
|
|
52
|
+
return `${r.target.object_type}:${r.target.object_key}`;
|
|
53
|
+
}
|
|
54
|
+
case "entities": return record.id;
|
|
55
|
+
case "relationships": return record.from;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The instant that orders "latest first": computed_at, valid_from, verified_at ?? occurred_at,
|
|
59
|
+
* updated_at. Relationships carry no clock and sort last among equals. */
|
|
60
|
+
export function stateObservedAt(kind, record) {
|
|
61
|
+
switch (kind) {
|
|
62
|
+
case "derived": return record.computed_at;
|
|
63
|
+
case "commitments": return record.valid_from;
|
|
64
|
+
case "receipts": {
|
|
65
|
+
const r = record;
|
|
66
|
+
return r.verified_at ?? r.occurred_at;
|
|
67
|
+
}
|
|
68
|
+
case "entities": return record.updated_at;
|
|
69
|
+
case "relationships": return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The FTS document for a state record: title = the subject key (so an id query hits the
|
|
73
|
+
* title column, which the snippet and LIKE fallback both prefer), body = the human words plus
|
|
74
|
+
* the actor/owner, the status label and the dates. Every reader's query — a subject id, a
|
|
75
|
+
* phrase from a summary, an action kind, a principal — lands on one of these. */
|
|
76
|
+
export function stateSearchDoc(kind, record) {
|
|
77
|
+
const { label } = stateLiveness(kind, record);
|
|
78
|
+
const subject = stateSubject(kind, record);
|
|
79
|
+
switch (kind) {
|
|
80
|
+
case "derived": {
|
|
81
|
+
const d = record;
|
|
82
|
+
return { title: subject, body: `${d.content} ${label} ${d.transform_version} ${d.computed_at.slice(0, 10)}` };
|
|
83
|
+
}
|
|
84
|
+
case "commitments": {
|
|
85
|
+
const c = record;
|
|
86
|
+
return { title: subject, body: `${c.title} ${c.evidence_excerpt ?? ""} ${label} ${c.status} owner ${c.owner} due ${c.due}` };
|
|
87
|
+
}
|
|
88
|
+
case "receipts": {
|
|
89
|
+
const r = record;
|
|
90
|
+
return {
|
|
91
|
+
title: subject,
|
|
92
|
+
body: `${r.action_kind} ${r.actor} ${label} ${r.target.system} ${r.target.object_type} ${r.target.object_key} ${r.occurred_at.slice(0, 10)} ${r.invalidates.join(" ")}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
case "entities": {
|
|
96
|
+
const e = record;
|
|
97
|
+
const attrs = Object.entries(e.attributes).map(([k, v]) => `${k} ${v ?? ""}`).join(" ");
|
|
98
|
+
return { title: subject, body: `${e.name} ${e.kind} ${label} ${attrs}` };
|
|
99
|
+
}
|
|
100
|
+
case "relationships": {
|
|
101
|
+
const r = record;
|
|
102
|
+
return { title: subject, body: `${r.type} ${r.to} ${r.reason}` };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const DERIVED_HEADLINE_CHARS = 120;
|
|
107
|
+
function oneLine(value, max) {
|
|
108
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
109
|
+
return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
110
|
+
}
|
|
111
|
+
/** The one-line render shared by hunch_query, `hunch query` and the context "State" section:
|
|
112
|
+
* [commitment/in_force] customer:Site:7 — "send report" due 2026-09-11 (owner sofia)
|
|
113
|
+
* [derived/current] customer:Site:7 — <first 120 chars of the summary>
|
|
114
|
+
* [receipt/verified] event:10042 — events_add_actions by sofia@david 2026-09-08 */
|
|
115
|
+
export function renderStateLine(kind, record) {
|
|
116
|
+
const { label } = stateLiveness(kind, record);
|
|
117
|
+
const head = `[${FACET_LABEL[kind]}/${label}] ${stateSubject(kind, record)} — `;
|
|
118
|
+
switch (kind) {
|
|
119
|
+
case "derived":
|
|
120
|
+
return `${head}${oneLine(record.content, DERIVED_HEADLINE_CHARS)}`;
|
|
121
|
+
case "commitments": {
|
|
122
|
+
const c = record;
|
|
123
|
+
return `${head}"${oneLine(c.title, 100)}" due ${c.due} (owner ${c.owner})`;
|
|
124
|
+
}
|
|
125
|
+
case "receipts": {
|
|
126
|
+
const r = record;
|
|
127
|
+
return `${head}${r.action_kind} by ${r.actor} ${(r.verified_at ?? r.occurred_at).slice(0, 10)}`;
|
|
128
|
+
}
|
|
129
|
+
case "entities": {
|
|
130
|
+
const e = record;
|
|
131
|
+
return `${head}${oneLine(e.name, 100)} (${e.kind})`;
|
|
132
|
+
}
|
|
133
|
+
case "relationships": {
|
|
134
|
+
const r = record;
|
|
135
|
+
return `${head}${r.type} → ${r.to}${r.reason ? ` (${oneLine(r.reason, 80)})` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Bounded caps for the context "State" section: current derived, in-force commitments,
|
|
140
|
+
* latest receipts. Deliberately small — a brief, not a dump; `nuryel_read` is the full view. */
|
|
141
|
+
export const STATE_SLICE_CAPS = { derived: 3, commitments: 5, receipts: 3 };
|
|
142
|
+
/** Deterministic order for a state slice: score (best first), then observed_at DESC (latest
|
|
143
|
+
* first), then id ASC. Applied after liveness filtering, before the cap. */
|
|
144
|
+
export function compareStateHits(a, b) {
|
|
145
|
+
if (a.score !== b.score)
|
|
146
|
+
return a.score - b.score;
|
|
147
|
+
const at = stateObservedAt(b.kind, b.record).localeCompare(stateObservedAt(a.kind, a.record));
|
|
148
|
+
if (at !== 0)
|
|
149
|
+
return at;
|
|
150
|
+
return a.record.id.localeCompare(b.record.id);
|
|
151
|
+
}
|
|
152
|
+
/** Supplement priority band for the State section: above Project DNA (425), below
|
|
153
|
+
* decision-grounding (1000) and the ranked memory records (which are not supplements). */
|
|
154
|
+
const STATE_SUPPLEMENT_PRIORITY = 500;
|
|
155
|
+
/** Render a state slice as delivery supplements (one header + one line per record) so the
|
|
156
|
+
* section shares the context brief's hard budget and receipt like every other grounding.
|
|
157
|
+
* Empty slice → no supplements at all: a store with zero state records is byte-identical. */
|
|
158
|
+
export function stateSupplements(slice, target) {
|
|
159
|
+
const hits = [...slice.derived, ...slice.commitments, ...slice.receipts];
|
|
160
|
+
if (!hits.length)
|
|
161
|
+
return [];
|
|
162
|
+
const out = [{
|
|
163
|
+
id: "state-of-record",
|
|
164
|
+
kind: "state",
|
|
165
|
+
text: `STATE (nuryel.state/1) for "${target}": ${slice.derived.length} current derived, ${slice.commitments.length} in-force commitment(s), ${slice.receipts.length} latest receipt(s). Follow the state of record; nuryel_read(subject) returns the full records.`,
|
|
166
|
+
priority: STATE_SUPPLEMENT_PRIORITY,
|
|
167
|
+
}];
|
|
168
|
+
hits.forEach((hit, index) => {
|
|
169
|
+
out.push({
|
|
170
|
+
id: hit.record.id,
|
|
171
|
+
kind: `state-${FACET_LABEL[hit.kind]}`,
|
|
172
|
+
text: renderStateLine(hit.kind, hit.record),
|
|
173
|
+
// Strictly descending so the sort in buildDeliveryEnvelope keeps slice order.
|
|
174
|
+
priority: STATE_SUPPLEMENT_PRIORITY - 1 - index,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=stateDelivery.js.map
|
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, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
-
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION } from "../core/stateContract.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";
|
|
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
|
});
|
|
@@ -1034,6 +1035,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1034
1035
|
// Git checkout cannot provide DNA; the dedicated DNA tool reports the
|
|
1035
1036
|
// exact derivation error when a caller needs diagnostics.
|
|
1036
1037
|
}
|
|
1038
|
+
// The "State" section (nuryel.state/1): current derived, in-force commitments and the
|
|
1039
|
+
// latest receipts whose subject/text matches the target — bounded, ordered, sharing the
|
|
1040
|
+
// brief's budget as supplements. Withheld on time-travel: state records carry no as-of view.
|
|
1041
|
+
const stateGrounding = asOf ? [] : stateSupplements(store.stateSlice(target), target);
|
|
1037
1042
|
const options = {
|
|
1038
1043
|
root,
|
|
1039
1044
|
symbols: store.recs("symbols"),
|
|
@@ -1041,7 +1046,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1041
1046
|
decisionCorpus: store.recs("decisions"),
|
|
1042
1047
|
historical: !!asOf,
|
|
1043
1048
|
profile: profile ?? "builder",
|
|
1044
|
-
supplements: dnaSupplement ? [dnaSupplement] : [],
|
|
1049
|
+
supplements: [...(dnaSupplement ? [dnaSupplement] : []), ...stateGrounding],
|
|
1045
1050
|
};
|
|
1046
1051
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
1047
1052
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
@@ -1068,8 +1073,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1068
1073
|
...options,
|
|
1069
1074
|
supplements: [
|
|
1070
1075
|
...(dnaSupplement ? [dnaSupplement] : []),
|
|
1076
|
+
...stateGrounding,
|
|
1071
1077
|
...hits
|
|
1072
|
-
|
|
1078
|
+
// State hits are delivered through the State section above, not as raw search lines.
|
|
1079
|
+
.filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind) && !isStateKind(hit.kind))
|
|
1073
1080
|
.map((hit, index) => ({
|
|
1074
1081
|
id: hit.ref,
|
|
1075
1082
|
kind: `search-${hit.kind}`,
|
|
@@ -1777,6 +1784,22 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1777
1784
|
return stateRefusal(e);
|
|
1778
1785
|
}
|
|
1779
1786
|
});
|
|
1787
|
+
server.registerTool("nuryel_records", {
|
|
1788
|
+
title: "nuryel.state/1 records — fetch records by id, grants first",
|
|
1789
|
+
description: "Fetch state records by id (from a subscribe event, a read ref, or a write result). Every id is accounted for: found (with its facet), denied (its scope is outside your grants — named, never described) or missing.",
|
|
1790
|
+
inputSchema: RecordsRequestSchema.omit({ schema: true }).shape,
|
|
1791
|
+
outputSchema: RecordsResponseSchema.shape,
|
|
1792
|
+
}, async (input) => {
|
|
1793
|
+
try {
|
|
1794
|
+
const response = recordsState(store, { schema: STATE_RECORDS_VERSION, ...input });
|
|
1795
|
+
const lines = Object.entries(response.records).map(([id, r]) => `- ${response.facets[id]} ${id}: ${JSON.stringify(r).slice(0, 600)}`);
|
|
1796
|
+
const tail = [...(response.missing.length ? [`missing: ${response.missing.join(", ")}`] : []), ...(response.denied.length ? [`denied: ${response.denied.join(", ")}`] : [])];
|
|
1797
|
+
return stateResult(`${Object.keys(response.records).length} record(s)\n${lines.join("\n")}${tail.length ? `\n${tail.join("\n")}` : ""}`, response);
|
|
1798
|
+
}
|
|
1799
|
+
catch (e) {
|
|
1800
|
+
return stateRefusal(e);
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1780
1803
|
// -- hunch_findings (read: the open-observations ledger) --------------------
|
|
1781
1804
|
server.registerTool("hunch_findings", {
|
|
1782
1805
|
title: "Open findings for a scope",
|
package/dist/serve/app.js
CHANGED
|
@@ -8,17 +8,19 @@
|
|
|
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
|
|
15
|
+
* POST /nuryel/v1/records → recordsState (by id, grants first)
|
|
14
16
|
* Request bodies are the contract's request schemas minus `schema` and `principal`.
|
|
15
17
|
*/
|
|
16
18
|
import { createServer } from "node:http";
|
|
17
19
|
import { HunchStore } from "../store/hunchStore.js";
|
|
18
20
|
import { hunchPaths } from "../core/paths.js";
|
|
19
21
|
import { flushCapture } from "../integrations/sync.js";
|
|
20
|
-
import { StateRefusal, capabilities, readState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
21
|
-
import { STATE_READ_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";
|
|
22
24
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
23
25
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
24
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -147,8 +149,26 @@ export function createServeApp(config, opts = {}) {
|
|
|
147
149
|
if (url.pathname === "/nuryel/v1/read") {
|
|
148
150
|
const scope = requireScope(principal, body);
|
|
149
151
|
const { store } = storeFor(scope);
|
|
150
|
-
|
|
151
|
-
|
|
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 });
|
|
152
172
|
}
|
|
153
173
|
if (url.pathname === "/nuryel/v1/write") {
|
|
154
174
|
const scope = requireScope(principal, body);
|
|
@@ -163,6 +183,11 @@ export function createServeApp(config, opts = {}) {
|
|
|
163
183
|
const { store } = storeFor(scope);
|
|
164
184
|
return send(res, 200, subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }));
|
|
165
185
|
}
|
|
186
|
+
if (url.pathname === "/nuryel/v1/records") {
|
|
187
|
+
const scope = requireScope(principal, body);
|
|
188
|
+
const { store } = storeFor(scope);
|
|
189
|
+
return send(res, 200, recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }));
|
|
190
|
+
}
|
|
166
191
|
throw problem(404, "not-found", `${url.pathname} is not a nuryel.state/1 route`);
|
|
167
192
|
}
|
|
168
193
|
catch (error) {
|
|
@@ -28,6 +28,9 @@ export const LedgerSchema = z.object({
|
|
|
28
28
|
schema: z.literal(LEDGER_SCHEMA_VERSION),
|
|
29
29
|
scope: ScopeSchema,
|
|
30
30
|
head_seq: z.number().int().nonnegative(),
|
|
31
|
+
/** Events below this seq were compacted away. `events` starts at floor_seq + 1. A subscriber
|
|
32
|
+
* whose cursor is below the floor must resynchronize (the contract's gap rule, made explicit). */
|
|
33
|
+
floor_seq: z.number().int().nonnegative().default(0),
|
|
31
34
|
events: z.array(ChangeEventSchema),
|
|
32
35
|
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
33
36
|
}).strict();
|
|
@@ -40,7 +43,7 @@ export function ledgerFile(hunchDir, scope) {
|
|
|
40
43
|
return join(hunchDir, CHANGES_DIR, `${scope.kind}-${safe}-${tag}.json`);
|
|
41
44
|
}
|
|
42
45
|
export function emptyLedger(scope) {
|
|
43
|
-
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, events: [], idempotency: {} };
|
|
46
|
+
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, floor_seq: 0, events: [], idempotency: {} };
|
|
44
47
|
}
|
|
45
48
|
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
46
49
|
* error (never silently treated as empty — that would restart the sequence). */
|
|
@@ -52,14 +55,14 @@ export function readLedger(hunchDir, scope) {
|
|
|
52
55
|
const ledger = LedgerSchema.parse(raw);
|
|
53
56
|
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
54
57
|
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
55
|
-
let expected = 1;
|
|
58
|
+
let expected = ledger.floor_seq + 1;
|
|
56
59
|
for (const event of ledger.events) {
|
|
57
60
|
if (event.seq !== expected)
|
|
58
61
|
throw new Error(`ledger ${file} is not contiguous at seq ${event.seq} (expected ${expected})`);
|
|
59
62
|
expected += 1;
|
|
60
63
|
}
|
|
61
|
-
if (ledger.head_seq !== ledger.events.length)
|
|
62
|
-
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with ${ledger.events.length} events`);
|
|
64
|
+
if (ledger.head_seq !== ledger.floor_seq + ledger.events.length)
|
|
65
|
+
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with floor ${ledger.floor_seq} + ${ledger.events.length} events`);
|
|
63
66
|
return ledger;
|
|
64
67
|
}
|
|
65
68
|
export function writeLedger(hunchDir, ledger) {
|
|
@@ -93,4 +96,58 @@ export function latestSeqFor(ledger, recordId) {
|
|
|
93
96
|
}
|
|
94
97
|
return 0;
|
|
95
98
|
}
|
|
99
|
+
/** Keep the newest `keep` events; everything older is dropped and the floor moves up. The
|
|
100
|
+
* idempotency table is kept whole (it is what makes replays exact); the records themselves are
|
|
101
|
+
* untouched. Returns how many events were dropped. */
|
|
102
|
+
export function compactLedger(hunchDir, scope, opts = {}) {
|
|
103
|
+
const keep = Math.max(0, Math.floor(opts.keep ?? 1000));
|
|
104
|
+
const ledger = readLedger(hunchDir, scope);
|
|
105
|
+
const dropped = Math.max(0, ledger.events.length - keep);
|
|
106
|
+
if (dropped === 0)
|
|
107
|
+
return { dropped: 0, floor_seq: ledger.floor_seq, head_seq: ledger.head_seq };
|
|
108
|
+
ledger.events = ledger.events.slice(dropped);
|
|
109
|
+
ledger.floor_seq = ledger.head_seq - ledger.events.length;
|
|
110
|
+
writeLedger(hunchDir, ledger);
|
|
111
|
+
return { dropped, floor_seq: ledger.floor_seq, head_seq: ledger.head_seq };
|
|
112
|
+
}
|
|
113
|
+
const eventIdentity = (e) => [e.change, e.facet, e.record_id, e.record_hash, e.at, e.cause ? JSON.stringify(e.cause) : ""].join("|");
|
|
114
|
+
/** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
|
|
115
|
+
* appended to the same partition. The union of events is kept (identity = what changed, to
|
|
116
|
+
* which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
|
|
117
|
+
* the higher floor; every subscriber's cursor is therefore invalid after a merge and the gap
|
|
118
|
+
* rule makes it resynchronize. Idempotency entries are unioned; a key both sides used for
|
|
119
|
+
* different records is a conflict the caller must surface (ours is kept). */
|
|
120
|
+
export function mergeLedgers(base, ours, theirs) {
|
|
121
|
+
if (scopePath(ours.scope) !== scopePath(theirs.scope))
|
|
122
|
+
throw new Error("ledgers for different scopes cannot be merged");
|
|
123
|
+
const seen = new Map();
|
|
124
|
+
const order = [];
|
|
125
|
+
const add = (e) => { const k = eventIdentity(e); if (!seen.has(k)) {
|
|
126
|
+
seen.set(k, e);
|
|
127
|
+
order.push(e);
|
|
128
|
+
} };
|
|
129
|
+
for (const e of base?.events ?? [])
|
|
130
|
+
add(e);
|
|
131
|
+
for (const e of ours.events)
|
|
132
|
+
add(e);
|
|
133
|
+
for (const e of theirs.events)
|
|
134
|
+
add(e);
|
|
135
|
+
const ranked = order.map((e, i) => ({ e, i, side: (ours.events.includes(e) ? 0 : 1) }));
|
|
136
|
+
ranked.sort((a, b) => a.e.at.localeCompare(b.e.at) || a.side - b.side || a.i - b.i);
|
|
137
|
+
const floor = Math.max(base?.floor_seq ?? 0, ours.floor_seq, theirs.floor_seq);
|
|
138
|
+
const events = ranked.map(({ e }, i) => ({ ...e, seq: floor + i + 1 }));
|
|
139
|
+
const conflicts = [];
|
|
140
|
+
const idempotency = { ...(base?.idempotency ?? {}), ...theirs.idempotency, ...ours.idempotency };
|
|
141
|
+
for (const [key, entry] of Object.entries(theirs.idempotency)) {
|
|
142
|
+
const mine = ours.idempotency[key];
|
|
143
|
+
if (mine && mine.record_id !== entry.record_id)
|
|
144
|
+
conflicts.push(`idempotency key ${key}: ours ${mine.record_id}, theirs ${entry.record_id} (kept ours)`);
|
|
145
|
+
}
|
|
146
|
+
for (const key of Object.keys(idempotency)) {
|
|
147
|
+
const entry = idempotency[key];
|
|
148
|
+
const at = events.find((e) => e.record_id === entry.record_id && e.record_hash === entry.record_hash);
|
|
149
|
+
idempotency[key] = { ...entry, seq: at ? at.seq : Math.min(entry.seq, floor + events.length) };
|
|
150
|
+
}
|
|
151
|
+
return { ledger: { schema: LEDGER_SCHEMA_VERSION, scope: ours.scope, floor_seq: floor, head_seq: floor + events.length, events, idempotency }, conflicts };
|
|
152
|
+
}
|
|
96
153
|
//# sourceMappingURL=changeLedger.js.map
|
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/merge.js
CHANGED
|
@@ -17,9 +17,15 @@
|
|
|
17
17
|
* content tiebreak (so both developers' merges converge on the same result). Records
|
|
18
18
|
* are pure data here — no filesystem access; the CLI reads/writes the files.
|
|
19
19
|
*/
|
|
20
|
+
import { LEDGER_SCHEMA_VERSION, LedgerSchema, mergeLedgers } from "./changeLedger.js";
|
|
20
21
|
/** Merge three versions of one `.hunch` JSON file (an index array OR a single
|
|
21
22
|
* record object). Returns the merged text, or conflict=true to fall back. */
|
|
22
23
|
export function mergeHunchJson(baseText, oursText, theirsText) {
|
|
24
|
+
// A per-scope change ledger is not a record array: two clones that both appended get the
|
|
25
|
+
// union of their events, re-sequenced, and unioned idempotency tables (see mergeLedgers).
|
|
26
|
+
const ledger = mergeLedgerText(baseText, oursText, theirsText);
|
|
27
|
+
if (ledger)
|
|
28
|
+
return ledger;
|
|
23
29
|
const ours = parseSide(oursText);
|
|
24
30
|
const theirs = parseSide(theirsText);
|
|
25
31
|
const base = parseSide(baseText);
|
|
@@ -42,6 +48,34 @@ export function mergeHunchJson(baseText, oursText, theirsText) {
|
|
|
42
48
|
return { text: oursText, conflict: true };
|
|
43
49
|
return { text: serialize(merged[0]), conflict: false };
|
|
44
50
|
}
|
|
51
|
+
function mergeLedgerText(baseText, oursText, theirsText) {
|
|
52
|
+
const parse = (text) => {
|
|
53
|
+
if (!text.trim())
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const raw = JSON.parse(text);
|
|
57
|
+
return raw && raw.schema === LEDGER_SCHEMA_VERSION ? LedgerSchema.parse(raw) : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const ours = parse(oursText);
|
|
64
|
+
const theirs = parse(theirsText);
|
|
65
|
+
if (!ours && !theirs)
|
|
66
|
+
return null;
|
|
67
|
+
if (!ours || !theirs)
|
|
68
|
+
return null; // one side is not a ledger (or deleted it): let git surface it
|
|
69
|
+
try {
|
|
70
|
+
const { ledger, conflicts } = mergeLedgers(parse(baseText), ours, theirs);
|
|
71
|
+
if (conflicts.length)
|
|
72
|
+
return { text: oursText, conflict: true };
|
|
73
|
+
return { text: JSON.stringify(ledger, null, 2) + "\n", conflict: false };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { text: oursText, conflict: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
45
79
|
/** Three-way merge of record arrays keyed by `id`. Additions on either side are
|
|
46
80
|
* kept; a record changed on one side only takes that side; a delete is honored
|
|
47
81
|
* only if the other side left the record unchanged (a modification beats a delete);
|
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'
|
|
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
26
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
27
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
28
|
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
29
|
-
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
29
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
30
30
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
31
|
export class StateRefusal extends Error {
|
|
32
32
|
code;
|
|
@@ -68,6 +68,11 @@ export const SubscribeResponseSchema = z.object({
|
|
|
68
68
|
/** True when facet / subject filters were applied: `events` is then a subsequence and
|
|
69
69
|
* assertChangeSequence does not apply; `head_seq` remains the cursor. */
|
|
70
70
|
filtered: z.boolean(),
|
|
71
|
+
/** Events below this seq were compacted away. */
|
|
72
|
+
floor_seq: z.number().int().nonnegative().default(0),
|
|
73
|
+
/** True when `after_seq` was below the floor: the caller's cursor is stale, the events returned
|
|
74
|
+
* start at the floor, and the caller must rebuild what it holds from a read. */
|
|
75
|
+
resync: z.boolean().default(false),
|
|
71
76
|
}).strict();
|
|
72
77
|
export function capabilities(store) {
|
|
73
78
|
const own = partitionOf(store);
|
|
@@ -120,6 +125,12 @@ export function readState(store, input) {
|
|
|
120
125
|
let stateOfRecord = null;
|
|
121
126
|
const records = {};
|
|
122
127
|
const denied = new Map();
|
|
128
|
+
// Union read against ONE store: every requested scope the principal lacks is named up front;
|
|
129
|
+
// the partitions actually read are declared so a caller never mistakes this for the union
|
|
130
|
+
// (a multi-partition host merges per-store answers with mergeReadResponses).
|
|
131
|
+
for (const s of request.scopes ?? [])
|
|
132
|
+
if (!granted(request.principal, s))
|
|
133
|
+
denied.set(scopePath(s), s);
|
|
123
134
|
if (request.subject !== undefined) {
|
|
124
135
|
const subject = request.subject;
|
|
125
136
|
const current = [];
|
|
@@ -223,10 +234,86 @@ export function readState(store, input) {
|
|
|
223
234
|
state_of_record: stateOfRecord,
|
|
224
235
|
denied_scopes: [...denied.values()],
|
|
225
236
|
...(stateOfRecord ? { records } : {}),
|
|
237
|
+
...(request.scopes ? { scopes: [request.scope], receipts: [{ scope: request.scope, receipt_id: envelope.receipt_id }] } : {}),
|
|
226
238
|
});
|
|
227
239
|
assertReadWithinGrants(request.principal, response);
|
|
228
240
|
return { response, envelope };
|
|
229
241
|
}
|
|
242
|
+
/** Union read — one state_of_record across several partitions, each read by `readState` against
|
|
243
|
+
* its own store. Pure: no store, no grants decided here (every input already passed its own
|
|
244
|
+
* grant check). The primary's receipt, scope and envelope lead; refs concatenate (each already
|
|
245
|
+
* carries its partition), `depends_on` concatenates, `invalidated_by` is a sorted union, `records`
|
|
246
|
+
* merge by id (first writer wins — ids are identity, two copies are the same record),
|
|
247
|
+
* `denied_scopes` is the union of every partition's denied plus `extraDenied` (requested-but-
|
|
248
|
+
* ungranted scopes the host refused to open), `scopes` names the partitions read and `receipts`
|
|
249
|
+
* carries one delivery receipt per partition. Reusable by any host (HTTP today; MCP or CLI
|
|
250
|
+
* fronting several roots later). */
|
|
251
|
+
export function mergeReadResponses(primary, others, extraDenied = []) {
|
|
252
|
+
const all = [primary, ...others];
|
|
253
|
+
const scopes = new Map();
|
|
254
|
+
const receipts = new Map();
|
|
255
|
+
for (const r of all) {
|
|
256
|
+
for (const s of r.scopes ?? [r.scope])
|
|
257
|
+
if (!scopes.has(scopePath(s)))
|
|
258
|
+
scopes.set(scopePath(s), s);
|
|
259
|
+
for (const x of r.receipts ?? [{ scope: r.scope, receipt_id: r.receipt_id }])
|
|
260
|
+
if (!receipts.has(scopePath(x.scope)))
|
|
261
|
+
receipts.set(scopePath(x.scope), x);
|
|
262
|
+
}
|
|
263
|
+
const denied = new Map();
|
|
264
|
+
for (const s of [...all.flatMap((r) => r.denied_scopes), ...extraDenied])
|
|
265
|
+
if (!scopes.has(scopePath(s)) && !denied.has(scopePath(s)))
|
|
266
|
+
denied.set(scopePath(s), s);
|
|
267
|
+
const sors = all.map((r) => r.state_of_record).filter((s) => s !== null);
|
|
268
|
+
let stateOfRecord = null;
|
|
269
|
+
const records = {};
|
|
270
|
+
if (sors.length) {
|
|
271
|
+
const refKey = (ref) => `${ref.facet}|${scopePath(ref.scope)}|${ref.id}`;
|
|
272
|
+
const dedupeRefs = (pick) => {
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const ref of sors.flatMap(pick)) {
|
|
276
|
+
const k = refKey(ref);
|
|
277
|
+
if (!seen.has(k)) {
|
|
278
|
+
seen.add(k);
|
|
279
|
+
out.push(ref);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
};
|
|
284
|
+
const seenDeps = new Set();
|
|
285
|
+
const dependsOn = [];
|
|
286
|
+
for (const dep of sors.flatMap((s) => s.depends_on)) {
|
|
287
|
+
const k = stateHash(dep);
|
|
288
|
+
if (!seenDeps.has(k)) {
|
|
289
|
+
seenDeps.add(k);
|
|
290
|
+
dependsOn.push(dep);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
stateOfRecord = {
|
|
294
|
+
subject: sors[0].subject,
|
|
295
|
+
current: dedupeRefs((s) => s.current),
|
|
296
|
+
in_force: dedupeRefs((s) => s.in_force),
|
|
297
|
+
done: dedupeRefs((s) => s.done),
|
|
298
|
+
depends_on: dependsOn,
|
|
299
|
+
invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
|
|
300
|
+
};
|
|
301
|
+
for (const r of all)
|
|
302
|
+
for (const [id, record] of Object.entries(r.records ?? {}))
|
|
303
|
+
if (!(id in records))
|
|
304
|
+
records[id] = record;
|
|
305
|
+
}
|
|
306
|
+
return ReadResponseSchema.parse({
|
|
307
|
+
schema: STATE_READ_VERSION,
|
|
308
|
+
receipt_id: primary.receipt_id,
|
|
309
|
+
scope: primary.scope,
|
|
310
|
+
state_of_record: stateOfRecord,
|
|
311
|
+
denied_scopes: [...denied.values()],
|
|
312
|
+
...(stateOfRecord ? { records } : {}),
|
|
313
|
+
scopes: [...scopes.values()],
|
|
314
|
+
receipts: [...receipts.values()],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
230
317
|
/** What a record is ABOUT, for subscribers filtering by subject. Mirrors the read verb's matching. */
|
|
231
318
|
function subjectOf(facet, record) {
|
|
232
319
|
const r = record;
|
|
@@ -243,6 +330,11 @@ function subjectOf(facet, record) {
|
|
|
243
330
|
default: return undefined;
|
|
244
331
|
}
|
|
245
332
|
}
|
|
333
|
+
/** Top-level fields whose canonical hash differs between two records, sorted. */
|
|
334
|
+
function differingFields(a, b) {
|
|
335
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
336
|
+
return [...keys].filter((k) => stateHash(a[k] ?? null) !== stateHash(b[k] ?? null)).sort();
|
|
337
|
+
}
|
|
246
338
|
/** Records the store can close a valid-time window on when superseded. */
|
|
247
339
|
function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
|
|
248
340
|
if (facet === "decisions") {
|
|
@@ -329,14 +421,19 @@ export function writeState(store, input, opts = {}) {
|
|
|
329
421
|
const hash = stateHash(record);
|
|
330
422
|
const ledger = readLedger(hunchDir, request.scope);
|
|
331
423
|
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
332
|
-
const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict });
|
|
424
|
+
const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict, record: store.getRec(facet, rid) ?? record });
|
|
333
425
|
// Idempotency: the same key replays the original; the same key with a different payload
|
|
334
426
|
// is a refusal, never a second record.
|
|
335
427
|
const seen = ledger.idempotency[request.idempotency_key];
|
|
336
428
|
if (seen) {
|
|
337
429
|
if (seen.record_hash === hash && seen.record_id === id)
|
|
338
430
|
return result("replayed");
|
|
339
|
-
|
|
431
|
+
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
432
|
+
// wording) is the trap every writer falls into once; the refusal must teach the way out.
|
|
433
|
+
const stored = store.getRec(facet, seen.record_id);
|
|
434
|
+
const differing = stored ? differingFields(stored, record) : [];
|
|
435
|
+
const where = differing.length ? ` — this payload differs in: ${differing.join(", ")}` : (seen.record_id !== id ? ` — this payload derives a different identity (${id})` : "");
|
|
436
|
+
throw new StateRefusal("idempotency", `idempotency key "${request.idempotency_key}" was already used for ${seen.record_id}${where}. A key names ONE request payload: re-send the original payload to replay it, or use a new key to write this payload (the record keeps its derived id and is updated in place).`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
|
|
340
437
|
}
|
|
341
438
|
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
342
439
|
if (existing && stateHash(existing) === hash) {
|
|
@@ -367,6 +464,24 @@ export function writeState(store, input, opts = {}) {
|
|
|
367
464
|
}
|
|
368
465
|
if (supersedes === id)
|
|
369
466
|
supersedes = null;
|
|
467
|
+
// A supersede target must still be open. Two writers racing to replace the same incumbent
|
|
468
|
+
// would otherwise both succeed and leave two current records for one subject (fnd_eeb8bf3cb8);
|
|
469
|
+
// the loser is told which record is current now, so it can re-read and supersede that one.
|
|
470
|
+
// The writer that closed the incumbent itself (same id, new key) is not a loser.
|
|
471
|
+
if (supersedes && facet !== "decisions") {
|
|
472
|
+
const incumbent = store.getRec(facet, supersedes);
|
|
473
|
+
if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
|
|
474
|
+
const subject = subjectOf(facet, incumbent);
|
|
475
|
+
const open = store.recsInHome(facet, home)
|
|
476
|
+
.filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
|
|
477
|
+
.map((r) => r.id).sort();
|
|
478
|
+
if (!open.includes(id)) {
|
|
479
|
+
const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
|
|
480
|
+
throw new StateRefusal("conflict", `supersedes ${supersedes} was already superseded (window closed ${String(incumbent.valid_to)}); ${current}: re-read and supersede that one`, { incumbent_id: open[0] ?? supersedes, reason: "supersede target already closed" });
|
|
481
|
+
}
|
|
482
|
+
supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
|
|
483
|
+
}
|
|
484
|
+
}
|
|
370
485
|
store.putCapture(facet, record, isPrivate);
|
|
371
486
|
const changes = [];
|
|
372
487
|
const cause = { kind: "write", principal: request.principal.id };
|
|
@@ -396,9 +511,46 @@ export function subscribeState(store, input) {
|
|
|
396
511
|
const facets = request.facets ? new Set(request.facets) : null;
|
|
397
512
|
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
398
513
|
const filtered = !!(facets || subjects);
|
|
399
|
-
const
|
|
514
|
+
const resync = request.after_seq < ledger.floor_seq;
|
|
515
|
+
const after = resync ? ledger.floor_seq : request.after_seq;
|
|
516
|
+
const events = ledger.events.filter((e) => e.seq > after
|
|
400
517
|
&& (!facets || facets.has(e.facet))
|
|
401
518
|
&& (!subjects || subjects.has(e.record_id) || (e.subject !== undefined && subjects.has(e.subject)) || e.invalidates.some((s) => subjects.has(s))));
|
|
402
|
-
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered });
|
|
519
|
+
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered, floor_seq: ledger.floor_seq, resync });
|
|
520
|
+
}
|
|
521
|
+
// ---- records ------------------------------------------------------------------------------
|
|
522
|
+
/** records — fetch by id, grants first. Every id is accounted for: found, denied (its scope is
|
|
523
|
+
* outside the grants — named, never described) or missing. */
|
|
524
|
+
export function recordsState(store, input) {
|
|
525
|
+
const request = RecordsRequestSchema.parse(input);
|
|
526
|
+
if (!granted(request.principal, request.scope))
|
|
527
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
528
|
+
const repo = partitionOf(store);
|
|
529
|
+
const records = {};
|
|
530
|
+
const facets = {};
|
|
531
|
+
const denied = [];
|
|
532
|
+
const missing = [];
|
|
533
|
+
for (const id of new Set(request.ids)) {
|
|
534
|
+
let found = null;
|
|
535
|
+
for (const facet of STATE_FACETS) {
|
|
536
|
+
const record = store.getRec(facet, id);
|
|
537
|
+
if (record) {
|
|
538
|
+
found = { facet, record };
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (!found) {
|
|
543
|
+
missing.push(id);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const scope = recordScope(found.record, repo);
|
|
547
|
+
if (!granted(request.principal, scope)) {
|
|
548
|
+
denied.push(id);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
records[id] = found.record;
|
|
552
|
+
facets[id] = found.facet;
|
|
553
|
+
}
|
|
554
|
+
return RecordsResponseSchema.parse({ schema: STATE_RECORDS_VERSION, scope: request.scope, records, facets, missing, denied });
|
|
403
555
|
}
|
|
404
556
|
//# sourceMappingURL=stateBinding.js.map
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.28.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.28.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|