@davesheffer/hunch 1.30.0 → 1.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -6
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +37 -4
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +3 -1
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/client/state.js +2 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/stateContract.js +87 -4
- package/dist/core/stateRecords.js +16 -1
- package/dist/extractors/git.js +3 -1
- package/dist/mcp/server.js +42 -4
- package/dist/serve/app.js +20 -0
- package/dist/store/changeLedger.js +40 -9
- package/dist/store/hunchStore.js +7 -2
- package/dist/store/jsonStore.js +15 -0
- package/dist/store/stateBinding.js +110 -15
- package/dist/store/stateCapture.js +145 -0
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +78 -46
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -29,17 +29,22 @@ import { z } from "zod";
|
|
|
29
29
|
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
30
30
|
import { DELIVERY_PROFILES } from "./delivery.js";
|
|
31
31
|
import { isHumanConfirmed as sourceIsHumanConfirmed } from "./strictgate.js";
|
|
32
|
-
import { ScopeSchema, scopePath, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
|
|
32
|
+
import { ScopeSchema, scopePath, externalKey, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
|
|
33
33
|
export * from "./stateRecords.js";
|
|
34
34
|
export const STATE_CONTRACT_VERSION = "nuryel.state/1";
|
|
35
35
|
export const STATE_READ_VERSION = "nuryel.state.read/1";
|
|
36
36
|
export const STATE_WRITE_VERSION = "nuryel.state.write/1";
|
|
37
37
|
export const STATE_SUBSCRIBE_VERSION = "nuryel.state.subscribe/1";
|
|
38
38
|
export const STATE_RECORDS_VERSION = "nuryel.state.records/1";
|
|
39
|
+
export const STATE_CAPTURE_VERSION = "nuryel.state.capture/1";
|
|
40
|
+
export const STATE_CAPTURE_BATCH_VERSION = "nuryel.state.capture-batch/1";
|
|
41
|
+
export const STATE_OBSERVATION_LINKS_VERSION = "nuryel.observation-links/1";
|
|
42
|
+
export const STATE_OBSERVATION_REVIEW_VERSION = "nuryel.observation-review/1";
|
|
43
|
+
export const STATE_OBSERVATION_PAGES_VERSION = "nuryel.observation-pages/1";
|
|
39
44
|
/** Capabilities a server advertises; a client that needs one the server lacks gets a typed
|
|
40
45
|
* `unsupported`, never a compatible-looking degraded answer. */
|
|
41
46
|
export const STATE_CAPABILITIES = [
|
|
42
|
-
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION,
|
|
47
|
+
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION, STATE_OBSERVATION_LINKS_VERSION, STATE_OBSERVATION_REVIEW_VERSION, STATE_OBSERVATION_PAGES_VERSION,
|
|
43
48
|
RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION,
|
|
44
49
|
];
|
|
45
50
|
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
@@ -55,12 +60,52 @@ export const PrincipalSchema = z.object({
|
|
|
55
60
|
display: z.string().max(256).optional(),
|
|
56
61
|
grants: z.array(ScopeSchema).min(1).max(64),
|
|
57
62
|
}).strict();
|
|
63
|
+
/** One relevant assertion, never a whole conversation. Source text is transient input:
|
|
64
|
+
* only its exact supporting excerpt and a hashed external pointer may reach the store. */
|
|
65
|
+
export const CaptureRequestSchema = z.object({
|
|
66
|
+
schema: z.literal(STATE_CAPTURE_VERSION),
|
|
67
|
+
principal: PrincipalSchema,
|
|
68
|
+
scope: ScopeSchema,
|
|
69
|
+
subject: z.string().min(1).max(512),
|
|
70
|
+
statement: z.string().trim().min(1).max(1200),
|
|
71
|
+
relevance: z.object({
|
|
72
|
+
use: z.enum(["decision", "constraint", "preference", "operational_fact", "ongoing_issue"]),
|
|
73
|
+
reason: z.string().trim().min(1).max(600),
|
|
74
|
+
}).strict(),
|
|
75
|
+
evidence: z.array(z.object({
|
|
76
|
+
ref: ExternalRefSchema,
|
|
77
|
+
source_text: z.string().min(1).max(64000),
|
|
78
|
+
excerpt: z.string().trim().min(1).max(1200),
|
|
79
|
+
}).strict()).min(1).max(8),
|
|
80
|
+
}).strict();
|
|
81
|
+
/** Sources cross the transport once; assertions name only their supporting excerpts. */
|
|
82
|
+
export const CaptureBatchRequestSchema = z.object({
|
|
83
|
+
schema: z.literal(STATE_CAPTURE_BATCH_VERSION), principal: PrincipalSchema, scope: ScopeSchema,
|
|
84
|
+
sources: z.array(CaptureRequestSchema.shape.evidence.element.omit({ excerpt: true })).min(1).max(8),
|
|
85
|
+
observations: z.array(CaptureRequestSchema.pick({ subject: true, statement: true, relevance: true }).extend({
|
|
86
|
+
evidence: z.array(z.object({ source: z.number().int().min(0).max(7), excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
87
|
+
})).min(0).max(32),
|
|
88
|
+
reviews: z.array(z.object({
|
|
89
|
+
record_id: z.string().regex(/^nds_[a-f0-9]{24}$/), expected_hash: z.string().regex(SHA256),
|
|
90
|
+
reason: z.string().trim().min(1).max(600),
|
|
91
|
+
evidence: z.array(z.object({ source: z.number().int().min(0).max(7), excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
92
|
+
}).strict()).min(1).max(32).optional(),
|
|
93
|
+
}).strict();
|
|
94
|
+
export const CAPTURE_TRANSFORM = "agent-capture/1:";
|
|
95
|
+
export const normalizeAssertion = (text) => text.normalize("NFC").replace(/\r\n?/g, "\n").trim();
|
|
96
|
+
export function captureTransform(scope, subject, statement, evidence) {
|
|
97
|
+
const identities = [...new Set(evidence.map(e => stateHash({ source: e.source, excerpt: normalizeAssertion(e.excerpt) })))].sort();
|
|
98
|
+
return CAPTURE_TRANSFORM + stateHash({ scope, subject, statement: normalizeAssertion(statement), evidence: identities }).slice(7);
|
|
99
|
+
}
|
|
58
100
|
export const STATE_FACETS = ["decisions", "constraints", "bugs", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
|
|
59
101
|
// ---- verbs ------------------------------------------------------------------------------
|
|
60
102
|
/** Union read: the partitions a principal wants in ONE answer. `scope` stays required (it is the
|
|
61
103
|
* primary partition; its envelope and receipt lead the response). An entry the principal is not
|
|
62
104
|
* granted is NAMED in `denied_scopes` — it never refuses the whole call, and is never described. */
|
|
63
105
|
export const ReadScopesSchema = z.array(ScopeSchema).min(1).max(64);
|
|
106
|
+
export const ObservationCursorSchema = z.object({
|
|
107
|
+
snapshot_hash: z.string().regex(SHA256), offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
108
|
+
}).strict();
|
|
64
109
|
export const ReadRequestSchema = z.object({
|
|
65
110
|
schema: z.literal(STATE_READ_VERSION),
|
|
66
111
|
principal: PrincipalSchema,
|
|
@@ -71,6 +116,8 @@ export const ReadRequestSchema = z.object({
|
|
|
71
116
|
profile: z.enum(DELIVERY_PROFILES).optional(),
|
|
72
117
|
budget_tokens: z.number().int().min(200).max(200_000).optional(),
|
|
73
118
|
facets: z.array(z.enum(STATE_FACETS)).max(STATE_FACETS.length).optional(),
|
|
119
|
+
/** Explicit single-partition pagination; default subject reads keep their existing bound. */
|
|
120
|
+
observed_page: z.object({ cursor: ObservationCursorSchema.optional() }).strict().optional(),
|
|
74
121
|
}).strict();
|
|
75
122
|
const StateRefSchema = z.object({
|
|
76
123
|
facet: z.enum(STATE_FACETS),
|
|
@@ -85,6 +132,11 @@ export const StateOfRecordSchema = z.object({
|
|
|
85
132
|
current: z.array(StateRefSchema).max(256),
|
|
86
133
|
in_force: z.array(StateRefSchema).max(256),
|
|
87
134
|
done: z.array(StateRefSchema).max(256),
|
|
135
|
+
/** Source-backed observations, not a claim of currentness. Additive; absent on old hosts. */
|
|
136
|
+
observed: z.array(StateRefSchema).max(64).optional(),
|
|
137
|
+
observed_truncated: z.boolean().optional(),
|
|
138
|
+
observed_page: z.object({ snapshot_hash: z.string().regex(SHA256), total: z.number().int().nonnegative(), next_cursor: ObservationCursorSchema.nullable() }).strict().optional(),
|
|
139
|
+
relationships_truncated: z.boolean().optional(),
|
|
88
140
|
depends_on: z.array(DependencyRefSchema).max(1024),
|
|
89
141
|
invalidated_by: z.array(z.string().max(512)).max(256),
|
|
90
142
|
}).strict();
|
|
@@ -131,6 +183,17 @@ export const WriteResultSchema = z.object({
|
|
|
131
183
|
* what landed without a second lookup. Additive. */
|
|
132
184
|
record: z.record(z.string(), z.unknown()).optional(),
|
|
133
185
|
}).strict();
|
|
186
|
+
export const CaptureBatchResultSchema = z.object({
|
|
187
|
+
schema: z.literal(STATE_CAPTURE_BATCH_VERSION),
|
|
188
|
+
results: z.array(z.discriminatedUnion("status", [
|
|
189
|
+
z.object({ index: z.number().int(), status: z.literal("saved"), result: WriteResultSchema }).strict(),
|
|
190
|
+
z.object({ index: z.number().int(), status: z.literal("refused"), code: z.string(), message: z.string() }).strict(),
|
|
191
|
+
])).max(32),
|
|
192
|
+
reviews: z.array(z.discriminatedUnion("status", [
|
|
193
|
+
z.object({ index: z.number().int(), status: z.literal("saved"), result: WriteResultSchema }).strict(),
|
|
194
|
+
z.object({ index: z.number().int(), status: z.literal("refused"), code: z.string(), message: z.string() }).strict(),
|
|
195
|
+
])).max(32).optional(),
|
|
196
|
+
}).strict();
|
|
134
197
|
export const SubscribeRequestSchema = z.object({
|
|
135
198
|
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
136
199
|
principal: PrincipalSchema,
|
|
@@ -224,6 +287,11 @@ export function commitmentId(c) {
|
|
|
224
287
|
return idFrom("ncm", { scope: c.scope, subject: c.subject, title: c.title.trim(), owner: c.owner, due: c.due });
|
|
225
288
|
}
|
|
226
289
|
export function derivedId(d) {
|
|
290
|
+
// Capture's reserved transform includes assertion/evidence identity, independent of
|
|
291
|
+
// read time and unrelated source edits. Ordinary summary identity is unchanged.
|
|
292
|
+
if (/^agent-capture\/1:[a-f0-9]{64}$/.test(d.transform_version)) {
|
|
293
|
+
return idFrom("nds", { scope: d.scope, subject: d.subject, transform_version: d.transform_version });
|
|
294
|
+
}
|
|
227
295
|
return idFrom("nds", { scope: d.scope, subject: d.subject, transform_version: d.transform_version, dependencies: d.dependencies.map((dep) => stateHash(dep)).sort(compareCodeUnits) });
|
|
228
296
|
}
|
|
229
297
|
// ---- invariants --------------------------------------------------------------------------
|
|
@@ -237,7 +305,7 @@ export const STATE_INVARIANTS = [
|
|
|
237
305
|
{ id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
|
|
238
306
|
{ id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
|
|
239
307
|
{ id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
|
|
240
|
-
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds.
|
|
308
|
+
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness." },
|
|
241
309
|
];
|
|
242
310
|
const grantKey = (scope) => scopePath(scope);
|
|
243
311
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
@@ -252,7 +320,7 @@ export function assertReadWithinGrants(principal, response) {
|
|
|
252
320
|
const granted = new Set(principal.grants.map(grantKey));
|
|
253
321
|
if (!granted.has(grantKey(response.scope)))
|
|
254
322
|
throw new Error(`read response scope ${grantKey(response.scope)} is outside the principal's grants`);
|
|
255
|
-
const refs = response.state_of_record ? [...response.state_of_record.current, ...response.state_of_record.in_force, ...response.state_of_record.done] : [];
|
|
323
|
+
const refs = response.state_of_record ? [...response.state_of_record.current, ...response.state_of_record.in_force, ...response.state_of_record.done, ...(response.state_of_record.observed ?? [])] : [];
|
|
256
324
|
for (const ref of refs) {
|
|
257
325
|
if (!granted.has(grantKey(ref.scope)))
|
|
258
326
|
throw new Error(`state ref ${ref.id} in scope ${grantKey(ref.scope)} leaked outside the principal's grants`);
|
|
@@ -289,6 +357,21 @@ export function assertDerivedState(d) {
|
|
|
289
357
|
throw new Error("derived state without dependencies is not state");
|
|
290
358
|
if (stateHash(d.content) !== d.content_hash)
|
|
291
359
|
throw new Error("derived state content hash does not match its content");
|
|
360
|
+
if (d.transform_version.startsWith(CAPTURE_TRANSFORM)) {
|
|
361
|
+
const content = z.object({
|
|
362
|
+
schema: z.literal("nuryel.observation-content/1"),
|
|
363
|
+
statement: CaptureRequestSchema.shape.statement, relevance: CaptureRequestSchema.shape.relevance,
|
|
364
|
+
evidence: z.array(z.object({ source: z.string(), excerpt: z.string().min(1).max(1200) }).strict()).min(1).max(8),
|
|
365
|
+
captured_by: PrincipalSchema.shape.id,
|
|
366
|
+
}).strict().parse(JSON.parse(d.content));
|
|
367
|
+
if (d.state === "current")
|
|
368
|
+
throw new Error("capture observations cannot assert currentness; publish a separately revalidated summary");
|
|
369
|
+
if (captureTransform(d.scope, d.subject, content.statement, content.evidence) !== d.transform_version)
|
|
370
|
+
throw new Error("capture identity does not match its assertion and evidence");
|
|
371
|
+
const pointers = new Set(d.dependencies.filter(dep => dep.kind === "external").map(dep => externalKey(dep.ref)));
|
|
372
|
+
if (content.evidence.some(e => !pointers.has(e.source)))
|
|
373
|
+
throw new Error("capture evidence lacks its external dependency");
|
|
374
|
+
}
|
|
292
375
|
}
|
|
293
376
|
/** Subscribe streams are strictly ordered per scope; a gap or regression means the caller must
|
|
294
377
|
* resynchronize instead of trusting what it holds. */
|
|
@@ -127,8 +127,16 @@ export const DerivedStateSchema = z.object({
|
|
|
127
127
|
computed_at: z.string().regex(ISO),
|
|
128
128
|
valid_to: z.string().regex(ISO).nullable().default(null),
|
|
129
129
|
state: z.enum(["current", "stale", "unknown"]),
|
|
130
|
+
/** Per-observation withdrawal. Original assertion, author and dependencies stay intact. */
|
|
131
|
+
review: z.object({
|
|
132
|
+
by: z.string().regex(TOKEN), at: z.string().regex(ISO), previous_hash: z.string().regex(SHA256), reason: z.string().trim().min(1).max(600),
|
|
133
|
+
evidence: z.array(z.object({ ref: ExternalRefSchema, excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
134
|
+
}).strict().optional(),
|
|
130
135
|
provenance: ProvenanceSchema,
|
|
131
|
-
}).strict()
|
|
136
|
+
}).strict().superRefine((record, ctx) => {
|
|
137
|
+
if (record.review && record.state !== 'stale')
|
|
138
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['review'], message: 'withdrawal review belongs only to a stale observation' });
|
|
139
|
+
});
|
|
132
140
|
const AttributeValue = z.union([z.string().max(2048), z.number().finite(), z.boolean(), z.null()]);
|
|
133
141
|
/** entity — a customer, an incident, a thread: a non-code node, Landscape-shaped (kind-qualified
|
|
134
142
|
* id, lifecycle, provenance), with provenance pointers instead of mirrored content. Stored in
|
|
@@ -175,10 +183,17 @@ export const StateRelationshipSchema = z.object({
|
|
|
175
183
|
type: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
|
|
176
184
|
scope: ScopeSchema,
|
|
177
185
|
reason: z.string().max(1024).default(""),
|
|
186
|
+
/** observation_about is a one-hop projection, never an entity merge or a copied fact. */
|
|
187
|
+
observation_hash: z.string().regex(SHA256).optional(),
|
|
188
|
+
evidence: ExternalRefSchema.optional(),
|
|
189
|
+
lifecycle: z.enum(["active", "retired"]).optional(),
|
|
178
190
|
provenance: ProvenanceSchema,
|
|
179
191
|
}).strict().superRefine((rel, ctx) => {
|
|
180
192
|
if (rel.id !== edgeId(rel.from, rel.to, rel.type))
|
|
181
193
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "relationship id must derive from its endpoints and type" });
|
|
194
|
+
if (rel.type === "observation_about" && (!/^nds_[a-f0-9]{24}$/.test(rel.from) || !rel.observation_hash || !rel.evidence?.content_hash || !rel.reason.trim() || rel.to.length > 512 || rel.to === rel.from)) {
|
|
195
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "observation_about requires an observation id/hash, a subject, reason and hashed external evidence of the explicit association" });
|
|
196
|
+
}
|
|
182
197
|
});
|
|
183
198
|
/** DNA facet: existing Project DNA profiles, keyed by scope. No new schema — the reference only. */
|
|
184
199
|
export const DnaFacetRefSchema = z.object({
|
package/dist/extractors/git.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { MEMLOG_FORMAT } from "../core/memorylog.js";
|
|
10
10
|
import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
|
|
11
11
|
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
12
|
+
import { initiatorChildEnv } from "../synthesis/initiator.js";
|
|
12
13
|
// `git` exports these repository-local variables to hooks. They outrank cwd/-C,
|
|
13
14
|
// so carrying them from the code repository into a command for the memory
|
|
14
15
|
// overlay can target the wrong index/object store. This is the documented set
|
|
@@ -21,7 +22,7 @@ const LOCAL_GIT_ENV_VARS = [
|
|
|
21
22
|
"GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
|
22
23
|
];
|
|
23
24
|
export function foreignRepoEnv(source) {
|
|
24
|
-
const env =
|
|
25
|
+
const env = initiatorChildEnv(source);
|
|
25
26
|
for (const key of LOCAL_GIT_ENV_VARS)
|
|
26
27
|
delete env[key];
|
|
27
28
|
for (const key of Object.keys(env)) {
|
|
@@ -58,6 +59,7 @@ function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
|
58
59
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
59
60
|
return execFileSync("git", args, {
|
|
60
61
|
cwd, encoding: "utf8", maxBuffer,
|
|
62
|
+
env: initiatorChildEnv(),
|
|
61
63
|
stdio: ["ignore", "pipe", "ignore"],
|
|
62
64
|
}).trim();
|
|
63
65
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -14,6 +14,8 @@ 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
16
|
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { captureState, captureBatchState } from "../store/stateCapture.js";
|
|
18
|
+
import { CaptureRequestSchema, CaptureBatchRequestSchema, CaptureBatchResultSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
|
|
17
19
|
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
20
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
21
|
import { decisionId, findingId } from "../core/ids.js";
|
|
@@ -57,6 +59,7 @@ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken }
|
|
|
57
59
|
import { randomUUID } from "node:crypto";
|
|
58
60
|
import { existsSync } from "node:fs";
|
|
59
61
|
import { join } from "node:path";
|
|
62
|
+
import { initiatorFromClient, withInitiator } from "../synthesis/initiator.js";
|
|
60
63
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
61
64
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
62
65
|
/** Error classes as text prefixes — client-agnostic, no schema change, so any MCP client
|
|
@@ -741,7 +744,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
741
744
|
refreshIndex();
|
|
742
745
|
}
|
|
743
746
|
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
744
|
-
const result = await callback(...args);
|
|
747
|
+
const result = await withInitiator(initiatorFromClient(server.server.getClientVersion()?.name), () => callback(...args));
|
|
745
748
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
746
749
|
return refused("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
|
747
750
|
}
|
|
@@ -1726,7 +1729,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1726
1729
|
});
|
|
1727
1730
|
server.registerTool("nuryel_read", {
|
|
1728
1731
|
title: "nuryel.state/1 read — the system-of-record answer for a subject",
|
|
1729
|
-
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped.",
|
|
1732
|
+
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
|
|
1730
1733
|
inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
|
|
1731
1734
|
outputSchema: ReadResponseSchema.shape,
|
|
1732
1735
|
}, async (input) => {
|
|
@@ -1734,7 +1737,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1734
1737
|
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, ...input });
|
|
1735
1738
|
const sor = response.state_of_record;
|
|
1736
1739
|
const summary = sor
|
|
1737
|
-
? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
|
|
1740
|
+
? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · observed ${sor.observed?.length ?? 0} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
|
|
1738
1741
|
: "no subject — delivery envelope only";
|
|
1739
1742
|
const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
|
|
1740
1743
|
// Render the state of record itself, not only its refs: a consumer answers from this text.
|
|
@@ -1773,6 +1776,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1773
1776
|
};
|
|
1774
1777
|
const stateText = sor
|
|
1775
1778
|
? [...sor.current.map((r) => line("current", r)), ...sor.in_force.map((r) => line("in force", r)), ...sor.done.map((r) => line("done", r)),
|
|
1779
|
+
...(sor.observed ?? []).map(r => line("observed; verify currentness before relying on it", r)),
|
|
1780
|
+
...(sor.observed_page ? [`- Observation page: ${sor.observed_page.total} total; next_cursor: ${JSON.stringify(sor.observed_page.next_cursor)}`]
|
|
1781
|
+
: sor.observed_truncated ? ['- More observations exist; read this subject with observed_page:{} in one partition, then follow next_cursor.'] : []),
|
|
1776
1782
|
...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
|
|
1777
1783
|
: "";
|
|
1778
1784
|
return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
|
|
@@ -1783,7 +1789,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1783
1789
|
});
|
|
1784
1790
|
server.registerTool("nuryel_write", {
|
|
1785
1791
|
title: "nuryel.state/1 write — provenance + idempotency in, durability out",
|
|
1786
|
-
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay.",
|
|
1792
|
+
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version.",
|
|
1787
1793
|
inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1788
1794
|
outputSchema: WriteResultSchema.shape,
|
|
1789
1795
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
@@ -1799,6 +1805,38 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1799
1805
|
return stateRefusal(e);
|
|
1800
1806
|
}
|
|
1801
1807
|
});
|
|
1808
|
+
server.registerTool("nuryel_capture", {
|
|
1809
|
+
title: "nuryel.state/1 capture — one relevant assertion with exact source excerpts",
|
|
1810
|
+
description: "Save ONE relevant atomic assertion learned during the task. First split mixed source material into independent assertions; check each one, retaining new relevant details inside otherwise known passages. Exclude chatter, speculation, unsupported conclusions and transient tool output. Supply a concrete future-use reason and exact excerpts from the source text. Whole source text is transient and is never stored. Deduplication is per assertion + subject + source excerpt, independent of agent and read time; never skip a whole document because some of it is known. Records are observations, not verified current summaries or execution receipts. Call after substantive learning without waiting for the user to say remember. Read back the returned record before claiming it was saved.",
|
|
1811
|
+
inputSchema: { ...CaptureRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1812
|
+
outputSchema: WriteResultSchema.shape,
|
|
1813
|
+
}, async ({ cwd: _cwd, ...input }) => {
|
|
1814
|
+
try {
|
|
1815
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
|
|
1816
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1817
|
+
}));
|
|
1818
|
+
return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
|
|
1819
|
+
}
|
|
1820
|
+
catch (e) {
|
|
1821
|
+
return stateRefusal(e);
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
server.registerTool("nuryel_capture_batch", {
|
|
1825
|
+
title: "nuryel.state/1 capture batch — save relevant atomic observations",
|
|
1826
|
+
description: "Preferred capture for multiple facts learned during the task. Split source material into independent relevant assertions, select exact supporting excerpts and give each a concrete future-use reason. Exclude chatter, unsupported inference and transient output. Check every assertion even in a known paragraph: deduplication never discards a whole passage. Send each source once and reference its zero-based index. At most 32 observations and 8 sources; split larger work into batches. One partition lock and index update, no extra model call. Results preserve input indexes; inspect every refusal and stored record. Saved observations have unknown currentness, not verified receipts or current summaries. Use your own initiating agent identity automatically after substantive learning. Optional reviews withdraw specific prior observations: supply record_id, expected_hash, a reason, and exact excerpts from a changed source that observation depends on. Mere hash changes, missing text or uncertain interpretation never suffice; the initiating agent must identify explicit contradiction or withdrawal. Original facts remain in history with the review author and evidence. Review results are separately indexed; inspect every refusal.",
|
|
1827
|
+
inputSchema: { ...CaptureBatchRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1828
|
+
outputSchema: CaptureBatchResultSchema.shape,
|
|
1829
|
+
}, async ({ cwd: _cwd, ...input }) => {
|
|
1830
|
+
try {
|
|
1831
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
|
|
1832
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1833
|
+
}));
|
|
1834
|
+
return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
|
|
1835
|
+
}
|
|
1836
|
+
catch (e) {
|
|
1837
|
+
return stateRefusal(e);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1802
1840
|
server.registerTool("nuryel_subscribe", {
|
|
1803
1841
|
title: "nuryel.state/1 subscribe — the scope's ordered change stream after a cursor",
|
|
1804
1842
|
description: "Return the change events for a scope with seq > after_seq, strictly ordered. Unfiltered, the events are contiguous (a gap means resynchronize); with facets/subjects filters the response is a subsequence and head_seq is still your next cursor. Each event names the record, its hash, what changed, what it invalidates, and the cause.",
|
package/dist/serve/app.js
CHANGED
|
@@ -24,6 +24,8 @@ import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STA
|
|
|
24
24
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
25
25
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
|
+
import { captureState, captureBatchState } from "../store/stateCapture.js";
|
|
28
|
+
import { STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
|
|
27
29
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
28
30
|
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
29
31
|
export class HttpProblem extends Error {
|
|
@@ -148,6 +150,8 @@ export function createServeApp(config, opts = {}) {
|
|
|
148
150
|
delete body.schema;
|
|
149
151
|
if (url.pathname === "/nuryel/v1/read") {
|
|
150
152
|
const scope = requireScope(principal, body);
|
|
153
|
+
if (body.observed_page !== undefined && body.scopes !== undefined)
|
|
154
|
+
throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
|
|
151
155
|
const { store } = storeFor(scope);
|
|
152
156
|
if (body.scopes === undefined) {
|
|
153
157
|
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
|
|
@@ -178,6 +182,22 @@ export function createServeApp(config, opts = {}) {
|
|
|
178
182
|
}));
|
|
179
183
|
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
180
184
|
}
|
|
185
|
+
if (url.pathname === "/nuryel/v1/capture") {
|
|
186
|
+
const scope = requireScope(principal, body);
|
|
187
|
+
const { store } = storeFor(scope);
|
|
188
|
+
const result = await withWriteLock(hunchPaths(store.publicRoot).hunch, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
|
|
189
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(store.publicRoot).hunch, isPrivate, message),
|
|
190
|
+
}));
|
|
191
|
+
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
192
|
+
}
|
|
193
|
+
if (url.pathname === "/nuryel/v1/capture-batch") {
|
|
194
|
+
const scope = requireScope(principal, body);
|
|
195
|
+
const { store, root } = storeFor(scope);
|
|
196
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
|
|
197
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
198
|
+
}));
|
|
199
|
+
return send(res, 200, result);
|
|
200
|
+
}
|
|
181
201
|
if (url.pathname === "/nuryel/v1/subscribe") {
|
|
182
202
|
const scope = requireScope(principal, body);
|
|
183
203
|
const { store } = storeFor(scope);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* here (see docs/nuryel-state-contract.md, "Not decided here").
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { writeFileAtomic } from "../core/io.js";
|
|
@@ -39,6 +39,12 @@ export const LedgerSchema = z.object({
|
|
|
39
39
|
events: z.array(ChangeEventSchema),
|
|
40
40
|
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
41
41
|
}).strict();
|
|
42
|
+
// Process-local acceleration only: compare the actual bytes on EVERY read, never
|
|
43
|
+
// timestamps or a TTL. Separate processes and same-size replacements stay visible.
|
|
44
|
+
// Keep only four small snapshots; oversized ledgers follow the uncached path.
|
|
45
|
+
const validatedSnapshots = new Map();
|
|
46
|
+
const MAX_CACHED_LEDGERS = 4;
|
|
47
|
+
const MAX_CACHED_CHARACTERS = 1024 * 1024;
|
|
42
48
|
/** Scope ids may carry `:` `@` `+` (safe in the contract, not in every file system), so
|
|
43
49
|
* the file name is the sanitized id plus a short hash of the exact id — readable AND
|
|
44
50
|
* collision-free. The scope inside the file is authoritative, the name is a locator. */
|
|
@@ -53,10 +59,21 @@ export function emptyLedger(scope) {
|
|
|
53
59
|
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
54
60
|
* error (never silently treated as empty — that would restart the sequence). */
|
|
55
61
|
export function readLedger(hunchDir, scope) {
|
|
56
|
-
const file = ledgerFile(hunchDir, scope);
|
|
57
|
-
if (!existsSync(file))
|
|
62
|
+
const file = resolve(ledgerFile(hunchDir, scope));
|
|
63
|
+
if (!existsSync(file)) {
|
|
64
|
+
validatedSnapshots.delete(file);
|
|
58
65
|
return emptyLedger(scope);
|
|
59
|
-
|
|
66
|
+
}
|
|
67
|
+
const text = readFileSync(file, "utf8");
|
|
68
|
+
const cached = validatedSnapshots.get(file);
|
|
69
|
+
if (cached?.text === text && cached.scope === scopePath(scope)) {
|
|
70
|
+
validatedSnapshots.delete(file);
|
|
71
|
+
validatedSnapshots.set(file, cached);
|
|
72
|
+
// append/compaction callers mutate their copy. Never expose the cached object.
|
|
73
|
+
return JSON.parse(cached.normalized);
|
|
74
|
+
}
|
|
75
|
+
validatedSnapshots.delete(file);
|
|
76
|
+
const raw = JSON.parse(text);
|
|
60
77
|
const ledger = LedgerSchema.parse(raw);
|
|
61
78
|
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
62
79
|
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
@@ -68,18 +85,30 @@ export function readLedger(hunchDir, scope) {
|
|
|
68
85
|
}
|
|
69
86
|
if (ledger.head_seq !== ledger.floor_seq + ledger.events.length)
|
|
70
87
|
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with floor ${ledger.floor_seq} + ${ledger.events.length} events`);
|
|
88
|
+
if (text.length <= MAX_CACHED_CHARACTERS) {
|
|
89
|
+
while (validatedSnapshots.size >= MAX_CACHED_LEDGERS)
|
|
90
|
+
validatedSnapshots.delete(validatedSnapshots.keys().next().value);
|
|
91
|
+
validatedSnapshots.set(file, { text, normalized: JSON.stringify(ledger), scope: scopePath(scope) });
|
|
92
|
+
}
|
|
71
93
|
return ledger;
|
|
72
94
|
}
|
|
73
95
|
export function writeLedger(hunchDir, ledger) {
|
|
96
|
+
writeValidatedLedger(hunchDir, LedgerSchema.parse(ledger));
|
|
97
|
+
}
|
|
98
|
+
function writeValidatedLedger(hunchDir, ledger) {
|
|
74
99
|
const file = ledgerFile(hunchDir, ledger.scope);
|
|
75
100
|
mkdirSync(join(hunchDir, CHANGES_DIR), { recursive: true });
|
|
76
|
-
writeFileAtomic(file, JSON.stringify(
|
|
101
|
+
writeFileAtomic(file, JSON.stringify(ledger, null, 2) + "\n");
|
|
77
102
|
}
|
|
78
103
|
/** Append events (in order) and remember an idempotency key in ONE atomic write, so a
|
|
79
104
|
* crash between "record written" and "event appended" can be detected by the next
|
|
80
105
|
* writer (record present, ledger silent) rather than producing a half-applied write. */
|
|
81
|
-
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString()
|
|
82
|
-
|
|
106
|
+
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString(),
|
|
107
|
+
/** Already validated under the same uninterrupted partition lock; batch-local only. */
|
|
108
|
+
current) {
|
|
109
|
+
const ledger = current ?? readLedger(hunchDir, scope);
|
|
110
|
+
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
111
|
+
throw new Error("cached ledger belongs to another scope");
|
|
83
112
|
const appended = [];
|
|
84
113
|
for (const change of changes) {
|
|
85
114
|
const event = ChangeEventSchema.parse({ schema: "nuryel.state.subscribe/1", seq: ledger.head_seq + 1, at, scope, ...change });
|
|
@@ -88,9 +117,11 @@ export function appendChanges(hunchDir, scope, changes, idempotency, at = new Da
|
|
|
88
117
|
appended.push(event);
|
|
89
118
|
}
|
|
90
119
|
if (idempotency) {
|
|
91
|
-
ledger.idempotency[idempotency.key] = { ...idempotency.entry, seq: ledger.head_seq, at };
|
|
120
|
+
ledger.idempotency[idempotency.key] = IdempotencyEntrySchema.parse({ ...idempotency.entry, seq: ledger.head_seq, at });
|
|
92
121
|
}
|
|
93
|
-
|
|
122
|
+
// The initial ledger and each new event/entry are validated; don't re-validate the
|
|
123
|
+
// full history per assertion. Each append still reaches disk atomically before return.
|
|
124
|
+
writeValidatedLedger(hunchDir, ledger);
|
|
94
125
|
return appended;
|
|
95
126
|
}
|
|
96
127
|
/** The latest seq that touched a record in this scope, or 0 when the ledger never saw it. */
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -192,8 +192,10 @@ export class HunchStore {
|
|
|
192
192
|
putCapture(kind, record, isPrivate = false) {
|
|
193
193
|
const home = this.captureHome(isPrivate);
|
|
194
194
|
const id = record.id;
|
|
195
|
-
const
|
|
196
|
-
|
|
195
|
+
const lookup = (json) => kind === "derived" || kind === "receipts" || kind === "commitments"
|
|
196
|
+
? json?.getDirect(kind, id) : json?.get(kind, id);
|
|
197
|
+
const targetHasRecord = !!lookup(home === "private" ? this.privateJson : this.json);
|
|
198
|
+
const otherHasRecord = !!lookup(home === "private" ? this.json : this.privateJson);
|
|
197
199
|
// Legacy repositories can already contain twins, so an idempotent update in
|
|
198
200
|
// the selected home remains possible. A new capture must never CREATE that
|
|
199
201
|
// ambiguous state: merged/private-first reads would make later writers and
|
|
@@ -244,6 +246,9 @@ export class HunchStore {
|
|
|
244
246
|
getPrivateRec(kind, id) {
|
|
245
247
|
return this.privateJson?.get(kind, id);
|
|
246
248
|
}
|
|
249
|
+
getStateDirect(kind, id, home) {
|
|
250
|
+
return (home === "private" ? this.privateJson : this.json)?.getDirect(kind, id);
|
|
251
|
+
}
|
|
247
252
|
/** Update an EXISTING record in the store that holds it — an overlay record must never
|
|
248
253
|
* fork a public copy on update (and vice versa). Falls back to captureHome routing for
|
|
249
254
|
* a record that exists nowhere yet. */
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -559,6 +559,21 @@ export class JsonStore {
|
|
|
559
559
|
get(kind, id) {
|
|
560
560
|
return this.loadAll(kind).find((r) => r.id === id);
|
|
561
561
|
}
|
|
562
|
+
/** Direct authoritative state lookup. No directory enumeration or stale cache;
|
|
563
|
+
* migration precedes validation and corrupt files fail closed. */
|
|
564
|
+
getDirect(kind, id) {
|
|
565
|
+
const file = this.fileFor(kind, id);
|
|
566
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
567
|
+
if (!directory)
|
|
568
|
+
return undefined;
|
|
569
|
+
const text = this.readContainedFile(directory, file, this.maxBytes(kind));
|
|
570
|
+
if (text === null || text.trim() === "")
|
|
571
|
+
return undefined;
|
|
572
|
+
const record = SCHEMAS[kind].parse(this.migrate(kind, JSON.parse(text), this.schemaVersion()));
|
|
573
|
+
if (record.id !== id)
|
|
574
|
+
throw new Error(`record identity does not match ${kind} file name`);
|
|
575
|
+
return record;
|
|
576
|
+
}
|
|
562
577
|
/** On-disk record count, independent of validation: per-record kinds count
|
|
563
578
|
* every non-tombstone .json file (a 0-byte merge tombstone is an intentional
|
|
564
579
|
* absence), single-file kinds count raw array entries (a corrupt index file
|