@davesheffer/hunch 1.31.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 CHANGED
@@ -207,11 +207,7 @@ Hunch Memory service into Hunch.
207
207
 
208
208
  As of 1.27.0 a fourth verb, `records`, lists a subject's records for the first writers, and the per-scope ledger compacts and merges across clones. As of 1.28.0 reads are a union across writers, a supersede target must still be open (two racing writers can no longer leave two current records), state records are searchable and delivered by subject, and subjects are keyed by the external record rather than by the agent. Proven on an emulated organization: three agents over ten clinics and a generated year of mail, chat and CRM, one organization drawer, 96 cited summaries, 24 verified receipts, 24 commitments, zero contradictions.
209
209
 
210
- <<<<<<< HEAD
211
- As of 1.30.0 subject identity is by external reference: one active entity per external record per partition, a subject written as an entity's external key refused with the entity id named, reads resolving one explicit hop — so two agents over one CRM record land on one subject. Replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
212
- =======
213
- As of 1.30.0 replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, runs inside `hunch drift` when the partition has a ledger, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
214
- >>>>>>> feat/replay-determinism
210
+ As of 1.30.0 subject identity is by external reference: one active entity per external record per partition, a subject written as an entity's external key refused with the entity id named, reads resolving one explicit hop — so two agents over one CRM record land on one subject. Replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, runs inside `hunch drift` when the partition has a ledger, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
215
211
 
216
212
  Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
217
213
 
@@ -286,4 +282,4 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
286
282
  - [Architecture benchmark](bench/architectural-conformance.md)
287
283
  - [Contributing](CONTRIBUTING.md)
288
284
 
289
- Apache-2.0
285
+ Apache-2.0
@@ -41,6 +41,8 @@ export function createStateClient(opts) {
41
41
  capabilities: (scope) => call("GET", `/nuryel/v1/capabilities${scope ? `?scope=${encodeURIComponent(`${scope.kind}:${scope.id}`)}` : ""}`),
42
42
  read: (request) => call("POST", "/nuryel/v1/read", request),
43
43
  write: (request) => call("POST", "/nuryel/v1/write", request),
44
+ capture: (request) => call("POST", "/nuryel/v1/capture", request),
45
+ captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
44
46
  subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
45
47
  records: (request) => call("POST", "/nuryel/v1/records", request),
46
48
  health: () => call("GET", "/nuryel/v1/health"),
@@ -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({
@@ -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";
@@ -1727,7 +1729,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1727
1729
  });
1728
1730
  server.registerTool("nuryel_read", {
1729
1731
  title: "nuryel.state/1 read — the system-of-record answer for a subject",
1730
- 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.",
1731
1733
  inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
1732
1734
  outputSchema: ReadResponseSchema.shape,
1733
1735
  }, async (input) => {
@@ -1735,7 +1737,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1735
1737
  const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, ...input });
1736
1738
  const sor = response.state_of_record;
1737
1739
  const summary = sor
1738
- ? `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}`
1739
1741
  : "no subject — delivery envelope only";
1740
1742
  const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
1741
1743
  // Render the state of record itself, not only its refs: a consumer answers from this text.
@@ -1774,6 +1776,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1774
1776
  };
1775
1777
  const stateText = sor
1776
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.'] : []),
1777
1782
  ...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
1778
1783
  : "";
1779
1784
  return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
@@ -1784,7 +1789,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1784
1789
  });
1785
1790
  server.registerTool("nuryel_write", {
1786
1791
  title: "nuryel.state/1 write — provenance + idempotency in, durability out",
1787
- 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.",
1788
1793
  inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
1789
1794
  outputSchema: WriteResultSchema.shape,
1790
1795
  }, async ({ cwd: _cwd, ...input }) => {
@@ -1800,6 +1805,38 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1800
1805
  return stateRefusal(e);
1801
1806
  }
1802
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
+ });
1803
1840
  server.registerTool("nuryel_subscribe", {
1804
1841
  title: "nuryel.state/1 subscribe — the scope's ordered change stream after a cursor",
1805
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
@@ -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, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } 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, relationshipId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } 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;
@@ -169,6 +169,9 @@ export function readState(store, input) {
169
169
  const request = ReadRequestSchema.parse(input);
170
170
  if (!granted(request.principal, request.scope))
171
171
  throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
172
+ if (request.observed_page && (request.subject === undefined || request.scopes !== undefined || (request.facets && !request.facets.includes('derived')))) {
173
+ throw new StateRefusal('malformed', 'observation pages require a subject, the derived facet and a single partition without scopes');
174
+ }
172
175
  const repo = partitionOf(store);
173
176
  const facets = new Set(request.facets ?? STATE_FACETS);
174
177
  const target = request.task ?? request.subject ?? scopePath(request.scope);
@@ -196,9 +199,23 @@ export function readState(store, input) {
196
199
  // read for the entity id finds what was filed under its keys — one explicit hop, grants first.
197
200
  const aliases = subjectAliases(store, request.principal, repo, subject);
198
201
  const isSubject = (s) => s !== undefined && aliases.has(s);
202
+ // One hop only. Do not broaden aliases: a linked observation does not merge subjects,
203
+ // bring unrelated facts, receipts or commitments, or traverse another relationship.
204
+ const linkedObservations = new Map();
205
+ for (const r of store.recs("relationships")) {
206
+ if (!granted(request.principal, r.scope) || scopePath(r.scope) !== scopePath(request.scope))
207
+ continue;
208
+ if (r.type !== "observation_about" || r.lifecycle === "retired" || !isSubject(r.to) || !r.observation_hash)
209
+ continue;
210
+ const hashes = linkedObservations.get(r.from) ?? new Set();
211
+ hashes.add(r.observation_hash);
212
+ linkedObservations.set(r.from, hashes);
213
+ }
199
214
  const current = [];
200
215
  const inForce = [];
201
216
  const done = [];
217
+ const observations = [];
218
+ let relationshipsTruncated = false;
202
219
  const dependsOn = [];
203
220
  const invalidatedBy = new Set();
204
221
  /** authorization-before-retrieval: the grant check runs before the record is examined. */
@@ -265,15 +282,26 @@ export function readState(store, input) {
265
282
  }
266
283
  if (facets.has("derived"))
267
284
  for (const d of store.recs("derived")) {
268
- if (!isSubject(d.subject) && d.id !== subject)
269
- continue;
270
285
  const scope = admit("derived", d);
271
286
  if (!scope)
272
287
  continue;
288
+ if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
289
+ continue;
290
+ const direct = isSubject(d.subject) || d.id === subject;
291
+ const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
292
+ if (!direct && !linked)
293
+ continue;
294
+ if (!direct) {
295
+ if (d.state === "unknown" && d.valid_to == null)
296
+ observations.push(d);
297
+ continue;
298
+ }
273
299
  if (d.state === "current" && d.valid_to == null) {
274
300
  current.push(keep("derived", d, scope));
275
301
  dependsOn.push(...d.dependencies);
276
302
  }
303
+ else if (d.state === "unknown" && d.valid_to == null)
304
+ observations.push(d);
277
305
  }
278
306
  if (facets.has("entities"))
279
307
  for (const e of store.recs("entities")) {
@@ -287,14 +315,39 @@ export function readState(store, input) {
287
315
  }
288
316
  if (facets.has("relationships"))
289
317
  for (const r of store.recs("relationships")) {
318
+ if (r.lifecycle === "retired")
319
+ continue;
290
320
  if (!isSubject(r.from) && !isSubject(r.to))
291
321
  continue;
292
322
  const scope = admit("relationships", r);
293
323
  if (!scope)
294
324
  continue;
325
+ if (current.length >= 256) {
326
+ relationshipsTruncated = true;
327
+ continue;
328
+ }
295
329
  current.push(keep("relationships", r, scope));
296
330
  }
297
- stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
331
+ observations.sort((a, b) => Date.parse(b.computed_at) - Date.parse(a.computed_at) || a.id.localeCompare(b.id));
332
+ let offset = 0;
333
+ let page;
334
+ if (request.observed_page) {
335
+ // Fingerprint the authorized membership AND record contents. A change between
336
+ // pages is a conflict, never a silently skipped or duplicated observation.
337
+ const snapshot_hash = stateHash({ scope: request.scope, subject, observations });
338
+ const cursor = request.observed_page.cursor;
339
+ if (cursor && cursor.snapshot_hash !== snapshot_hash)
340
+ throw new StateRefusal('conflict', 'observations changed between pages; restart from the first page');
341
+ offset = cursor?.offset ?? 0;
342
+ if (offset > observations.length)
343
+ throw new StateRefusal('malformed', 'observation cursor is outside this snapshot');
344
+ page = { snapshot_hash, total: observations.length, next_cursor: offset + 64 < observations.length ? { snapshot_hash, offset: offset + 64 } : null };
345
+ }
346
+ const observed = observations.slice(offset, offset + 64).map(d => keep("derived", d, recordScope(d, repo)));
347
+ stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort(),
348
+ ...(relationshipsTruncated ? { relationships_truncated: true } : {}),
349
+ ...(observed.length || page ? { observed, observed_truncated: observations.length > offset + observed.length } : {}),
350
+ ...(page ? { observed_page: page } : {}) };
298
351
  }
299
352
  const response = ReadResponseSchema.parse({
300
353
  schema: STATE_READ_VERSION,
@@ -364,6 +417,9 @@ export function mergeReadResponses(primary, others, extraDenied = []) {
364
417
  current: dedupeRefs((s) => s.current),
365
418
  in_force: dedupeRefs((s) => s.in_force),
366
419
  done: dedupeRefs((s) => s.done),
420
+ ...(sors.some(s => s.relationships_truncated) ? { relationships_truncated: true } : {}),
421
+ ...(sors.some(s => s.observed?.length) ? { observed: dedupeRefs(s => s.observed ?? []).slice(0, 64),
422
+ observed_truncated: sors.some(s => s.observed_truncated) || dedupeRefs(s => s.observed ?? []).length > 64 } : {}),
367
423
  depends_on: dependsOn,
368
424
  invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
369
425
  };
@@ -390,7 +446,7 @@ function subjectOf(facet, record) {
390
446
  case "commitments":
391
447
  case "derived": return typeof r.subject === "string" ? r.subject : undefined;
392
448
  case "entities": return typeof r.id === "string" ? r.id : undefined;
393
- case "relationships": return typeof r.from === "string" ? r.from : undefined;
449
+ case "relationships": return r.type === "observation_about" && typeof r.to === "string" ? r.to : typeof r.from === "string" ? r.from : undefined;
394
450
  case "receipts": {
395
451
  const t = r.target;
396
452
  return t?.object_type && t.object_key ? `${t.object_type}:${t.object_key}` : undefined;
@@ -565,6 +621,8 @@ function normalizeRecord(facet, scope, raw, principal) {
565
621
  expectedId = commitmentId(record);
566
622
  else if (facet === "derived")
567
623
  expectedId = derivedId(record);
624
+ else if (facet === "relationships")
625
+ expectedId = relationshipId(String(record.from), String(record.to), String(record.type));
568
626
  else if (facet === "decisions" && typeof record.id !== "string")
569
627
  expectedId = decisionId(String(record.topic ?? record.title ?? ""));
570
628
  }
@@ -602,20 +660,46 @@ export function writeState(store, input, opts = {}) {
602
660
  const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
603
661
  const now = (opts.now ?? (() => new Date()))().toISOString();
604
662
  const facet = request.facet;
663
+ const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
664
+ ? store.getStateDirect(facet, id, home)
665
+ : home === "private" ? store.getPrivateRec(facet, id) : store.json.get(facet, id);
605
666
  if (!ENTITY_KINDS.includes(facet))
606
667
  throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
607
668
  const record = normalizeRecord(facet, request.scope, request.record, request.principal);
669
+ let replayLink;
670
+ if (facet === "relationships" && record.type === "observation_about") {
671
+ const link = record;
672
+ const observation = store.getStateDirect("derived", link.from, home);
673
+ if (!observation || scopePath(observation.scope) !== scopePath(request.scope) || !granted(request.principal, observation.scope))
674
+ throw new StateRefusal("conflict", "observation is absent from the granted partition");
675
+ if (!observation.transform_version.startsWith("agent-capture/1:"))
676
+ throw new StateRefusal("malformed", "only captured observations can be linked");
677
+ if (link.lifecycle !== "retired" && (observation.state !== "unknown" || observation.valid_to != null || stateHash(observation) !== link.observation_hash))
678
+ throw new StateRefusal("conflict", "observation changed or is no longer eligible; re-read before linking");
679
+ const prior = getHere(link.id);
680
+ if (prior?.lifecycle === "retired" && link.lifecycle !== "retired" && request.expected_version === null)
681
+ throw new StateRefusal("conflict", "retired observation link requires an explicit expected_version to reactivate");
682
+ // Repeated reads and different agents do not rewrite an identical association.
683
+ // Preserve its first author/evidence time and avoid index/Git work on replay.
684
+ if (prior && prior.from === link.from && prior.to === link.to && prior.type === link.type && prior.reason === link.reason
685
+ && prior.observation_hash === link.observation_hash && prior.lifecycle === link.lifecycle
686
+ && prior.evidence && link.evidence && externalKey(prior.evidence) === externalKey(link.evidence) && prior.evidence.content_hash === link.evidence.content_hash) {
687
+ replayLink = prior;
688
+ }
689
+ }
608
690
  const id = record.id;
609
691
  assertExternalIdentity(store, request.principal, request.scope, facet, record);
610
692
  /** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
611
693
  const hash = stateHash(record);
612
- const ledger = readLedger(hunchDir, request.scope);
694
+ const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
695
+ if (opts.ledgerCache)
696
+ opts.ledgerCache.ledger = ledger;
613
697
  const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
614
698
  /** The result reports the record ON FILE and its hash — the store may enrich a record on put
615
699
  * (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
616
700
  * on this record must hold the hash a reader will verify, never a pre-store one. */
617
701
  const result = (outcome, conflict = null, rid = id) => {
618
- const onFile = store.getRec(facet, rid) ?? record;
702
+ const onFile = getHere(rid) ?? record;
619
703
  return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
620
704
  };
621
705
  // Idempotency: the same key replays the original; the same key with a different payload
@@ -631,9 +715,14 @@ export function writeState(store, input, opts = {}) {
631
715
  const where = differing.length ? ` — this payload differs in: ${differing.join(", ")}` : (seen.record_id !== id ? ` — this payload derives a different identity (${id})` : "");
632
716
  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" });
633
717
  }
634
- const existing = store.recsInHome(facet, home).find((r) => r.id === id);
718
+ const existing = getHere(id);
719
+ if (facet === 'derived') {
720
+ const review = record.review;
721
+ if (review && stateHash(review) !== stateHash(existing?.review ?? null) && review.by !== request.principal.id)
722
+ throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
723
+ }
635
724
  if (existing && stateHash(existing) === hash) {
636
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
725
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
637
726
  return result("replayed");
638
727
  }
639
728
  if (existing && request.expected_version !== null) {
@@ -643,6 +732,11 @@ export function writeState(store, input, opts = {}) {
643
732
  if (!ok)
644
733
  throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
645
734
  }
735
+ if (replayLink) {
736
+ const recordHash = stateHash(replayLink);
737
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
738
+ return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
739
+ }
646
740
  // human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
647
741
  // Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
648
742
  // written back stale with the external cause that moved (the writer's currentness duty), a
@@ -670,7 +764,7 @@ export function writeState(store, input, opts = {}) {
670
764
  };
671
765
  const verdict = guard(existing, "overwrite");
672
766
  if (verdict === "replay") {
673
- appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now);
767
+ appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
674
768
  return result("replayed");
675
769
  }
676
770
  if (verdict === "keep-provenance")
@@ -719,15 +813,15 @@ export function writeState(store, input, opts = {}) {
719
813
  const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
720
814
  store.putCapture(facet, record, isPrivate);
721
815
  /** What is on file now — the hash every event, ref and result carries. */
722
- const onFileHash = stateHash(store.getRec(facet, id) ?? record);
816
+ const onFileHash = stateHash(getHere(id) ?? record);
723
817
  const changes = [];
724
818
  const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
725
819
  // A current derived statement written back as stale is an INVALIDATION, not an update: the
726
820
  // ledger says so, and names the external pointer that moved when the writer gives one.
727
- const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
821
+ const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
728
822
  const invalidates = facet === "receipts" ? record.invalidates : [];
729
823
  // An entity leaving service is a `retired` change (a merge names the survivor in the record).
730
- const retired = facet === "entities" && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
824
+ const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
731
825
  const subject = subjectOf(facet, record);
732
826
  if (supersedes) {
733
827
  const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
@@ -737,8 +831,9 @@ export function writeState(store, input, opts = {}) {
737
831
  }
738
832
  }
739
833
  changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
740
- appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
741
- store.reindex();
834
+ appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
835
+ if (!opts.deferReindex)
836
+ store.reindex();
742
837
  return result(supersedes ? "superseded" : existing ? "updated" : "created");
743
838
  }
744
839
  // ---- subscribe -----------------------------------------------------------------------------
@@ -0,0 +1,145 @@
1
+ import { CaptureRequestSchema, CaptureBatchRequestSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION, STATE_WRITE_VERSION, assertDerivedState, captureTransform, derivedId, normalizeAssertion, canonicalObjectKey, externalKey, scopePath, stateHash } from "../core/stateContract.js";
2
+ import { isCredentialFreeValue } from "../core/provenance.js";
3
+ import { StateRefusal, stateHomeFor, writeState } from "./stateBinding.js";
4
+ /** The caller selects relevant atomic claims; this deterministic boundary checks evidence
5
+ * fidelity and deduplication. It does not pretend to prove semantic entailment or relevance.
6
+ * Both bindings hold the partition write lock over lookup AND write. */
7
+ export function captureState(store, input, opts = {}) {
8
+ const request = CaptureRequestSchema.parse(input);
9
+ if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
10
+ throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
11
+ const { home } = stateHomeFor(store, request.scope);
12
+ const statement = normalizeAssertion(request.statement);
13
+ if (![statement, request.relevance.reason, ...request.evidence.map(e => e.excerpt)].every(isCredentialFreeValue))
14
+ throw new StateRefusal("malformed", "captured content must not contain credential material");
15
+ const evidence = request.evidence.map(e => {
16
+ if (!e.source_text.includes(e.excerpt))
17
+ throw new StateRefusal("malformed", "every excerpt must occur exactly in its supplied source text");
18
+ const hash = opts.sourceHashes?.get(e.source_text) ?? stateHash(e.source_text);
19
+ opts.sourceHashes?.set(e.source_text, hash);
20
+ if (e.ref.content_hash && e.ref.content_hash !== hash)
21
+ throw new StateRefusal("malformed", "source text does not match its declared content hash");
22
+ return { ref: { ...e.ref, object_key: canonicalObjectKey(e.ref.object_key), content_hash: hash }, excerpt: e.excerpt };
23
+ });
24
+ // Ignore read time, writer and unrelated source text when deduplicating an assertion.
25
+ // A different excerpt, statement, source identity or subject is a distinct observation.
26
+ const transform = captureTransform(request.scope, request.subject, statement, evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })));
27
+ const id = derivedId({ scope: request.scope, subject: request.subject, transform_version: transform, dependencies: [] });
28
+ const incumbent = store.getStateDirect("derived", id, home);
29
+ if (incumbent) {
30
+ assertDerivedState(incumbent);
31
+ if (incumbent.transform_version !== transform || scopePath(incumbent.scope) !== scopePath(request.scope) || incumbent.subject !== request.subject)
32
+ throw new StateRefusal("conflict", "capture identity collision; incumbent preserved");
33
+ // Never revive stale or human-corrected evidence, replace its author, or claim a new
34
+ // observation because another agent saw the same excerpt. Return what actually exists.
35
+ return { schema: STATE_WRITE_VERSION, record_id: incumbent.id, record_hash: stateHash(incumbent), durability: "local", outcome: "replayed", conflict: null, record: incumbent };
36
+ }
37
+ const refs = [...new Map(evidence.map(e => [stateHash(e.ref), e.ref])).entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ref]) => ref);
38
+ const content = JSON.stringify({ schema: "nuryel.observation-content/1", statement, relevance: request.relevance,
39
+ evidence: evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })), captured_by: request.principal.id });
40
+ const record = {
41
+ schema: "nuryel.derived/1", scope: request.scope, subject: request.subject, content, content_hash: stateHash(content),
42
+ dependencies: refs.map(ref => ({ kind: "external", ref })), transform_version: transform,
43
+ computed_at: (opts.now ?? (() => new Date()))().toISOString(), valid_to: null, state: "unknown",
44
+ provenance: { source: "agent_recorded", confidence: 0.8, evidence: [`captured by ${request.principal.id}`, ...refs.map(externalKey)] },
45
+ };
46
+ return writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: "derived", record, idempotency_key: transform }, opts);
47
+ }
48
+ /** Bounded partial-success batch. The caller holds the same partition lock as writeState.
49
+ * Every result has its input index; a refused claim never hides a later valid new detail.
50
+ * A duplicate-only batch performs no record, ledger, index or Git writes. */
51
+ export function captureBatchState(store, input, opts = {}) {
52
+ const request = CaptureBatchRequestSchema.parse(input);
53
+ if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
54
+ throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
55
+ if (!request.observations.length && !request.reviews?.length)
56
+ throw new StateRefusal("malformed", "capture batch must contain observations or reviews");
57
+ const { home, isPrivate } = stateHomeFor(store, request.scope);
58
+ const sourceHashes = new Map();
59
+ const ledgerCache = {};
60
+ const results = [];
61
+ const reviews = [];
62
+ let changed = false;
63
+ try {
64
+ // Review before capture: a replay in the same batch must see the withdrawn state.
65
+ for (const [index, review] of (request.reviews ?? []).entries()) {
66
+ try {
67
+ const record = store.getStateDirect("derived", review.record_id, home);
68
+ if (!record || scopePath(record.scope) !== scopePath(request.scope) || !record.transform_version.startsWith('agent-capture/1:'))
69
+ throw new StateRefusal('conflict', 'captured observation is absent from this partition');
70
+ if (!isCredentialFreeValue(review.reason))
71
+ throw new StateRefusal('malformed', 'review reason contains credential material');
72
+ const evidence = review.evidence.map(e => {
73
+ const source = request.sources[e.source];
74
+ if (!source || !source.source_text.includes(e.excerpt) || !isCredentialFreeValue(e.excerpt))
75
+ throw new StateRefusal('malformed', 'review excerpt must occur exactly in the supplied source');
76
+ const hash = sourceHashes.get(source.source_text) ?? stateHash(source.source_text);
77
+ sourceHashes.set(source.source_text, hash);
78
+ if (source.ref.content_hash && source.ref.content_hash !== hash)
79
+ throw new StateRefusal('malformed', 'review source hash mismatch');
80
+ const ref = { ...source.ref, object_key: canonicalObjectKey(source.ref.object_key), content_hash: hash };
81
+ const original = record.dependencies.find(d => d.kind === 'external' && externalKey(d.ref) === externalKey(ref));
82
+ if (!original || original.kind !== 'external' || original.ref.content_hash === hash)
83
+ throw new StateRefusal('conflict', 'review must cite a changed source the observation actually depends on');
84
+ return { ref, excerpt: e.excerpt };
85
+ });
86
+ const identity = stateHash({ record_id: record.id, expected_hash: review.expected_hash, reason: review.reason, evidence: evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })) });
87
+ // A safe retry returns the same withdrawal without changing its reviewer/time.
88
+ const old = record.review;
89
+ if (record.state === 'stale' && old && old.previous_hash === review.expected_hash && old.reason === review.reason && stateHash(old.evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt }))) === stateHash(evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })))) {
90
+ reviews.push({ index, status: 'saved', result: { schema: STATE_WRITE_VERSION, record_id: record.id, record_hash: stateHash(record), record: record, durability: 'local', outcome: 'replayed', conflict: null } });
91
+ continue;
92
+ }
93
+ if (record.state !== 'unknown' || record.valid_to != null || stateHash(record) !== review.expected_hash)
94
+ throw new StateRefusal('conflict', 'observation changed since review; read it again');
95
+ const at = (opts.now ?? (() => new Date()))().toISOString();
96
+ const result = writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: 'derived',
97
+ record: { ...record, state: 'stale', review: { by: request.principal.id, at, previous_hash: review.expected_hash, reason: review.reason, evidence } },
98
+ expected_version: review.expected_hash, idempotency_key: `observation-review:${identity}`, cause: { kind: 'external', ref: evidence[0].ref } }, { now: opts.now, ledgerCache, deferReindex: true });
99
+ changed ||= result.outcome !== 'replayed';
100
+ reviews.push({ index, status: 'saved', result });
101
+ }
102
+ catch (error) {
103
+ if (!(error instanceof StateRefusal))
104
+ throw error;
105
+ reviews.push({ index, status: 'refused', code: error.code, message: error.message });
106
+ }
107
+ }
108
+ for (const [index, observation] of request.observations.entries()) {
109
+ try {
110
+ const evidence = observation.evidence.map(e => {
111
+ const source = request.sources[e.source];
112
+ if (!source)
113
+ throw new StateRefusal("malformed", `evidence source index ${e.source} is absent`);
114
+ return { ...source, excerpt: e.excerpt };
115
+ });
116
+ const result = captureState(store, { schema: STATE_CAPTURE_VERSION, principal: request.principal, scope: request.scope, ...observation, evidence }, { now: opts.now, sourceHashes, ledgerCache, deferReindex: true });
117
+ changed ||= result.outcome !== "replayed";
118
+ results.push({ index, status: "saved", result });
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof StateRefusal))
122
+ throw error;
123
+ results.push({ index, status: "refused", code: error.code, message: error.message });
124
+ }
125
+ }
126
+ }
127
+ catch (error) {
128
+ // A filesystem failure may occur after an atomic record write but before its
129
+ // result; refresh the derived index before propagating the failure to the caller.
130
+ changed = true;
131
+ throw error;
132
+ }
133
+ finally {
134
+ if (changed)
135
+ store.reindex();
136
+ }
137
+ if (changed) {
138
+ const durability = opts.flush?.(isPrivate, `nuryel: capture ${results.filter(r => r.status === "saved" && r.result.outcome === "created").length} observations`) ?? "local";
139
+ for (const item of [...results, ...reviews])
140
+ if (item.status === "saved")
141
+ item.result.durability = durability;
142
+ }
143
+ return { schema: STATE_CAPTURE_BATCH_VERSION, results, ...(request.reviews ? { reviews } : {}) };
144
+ }
145
+ //# sourceMappingURL=stateCapture.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.31.0",
3
+ "version": "1.31.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.31.0",
10
+ "version": "1.31.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.31.0",
16
+ "version": "1.31.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {