@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.
@@ -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. An agent that will not do this must not write derived state." },
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({
@@ -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 = { ...source };
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
  }
@@ -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
- const raw = JSON.parse(readFileSync(file, "utf8"));
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(LedgerSchema.parse(ledger), null, 2) + "\n");
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
- const ledger = readLedger(hunchDir, scope);
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
- writeLedger(hunchDir, ledger);
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. */
@@ -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 targetHasRecord = home === "private" ? !!this.privateJson?.get(kind, id) : !!this.json.get(kind, id);
196
- const otherHasRecord = home === "private" ? !!this.json.get(kind, id) : !!this.privateJson?.get(kind, id);
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. */
@@ -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