@cerefox/memory 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,42 @@ interface IngestRequest {
40
40
  update_if_exists?: boolean;
41
41
  author?: string;
42
42
  author_type?: string; // 'user' | 'agent'
43
+ // Optimistic concurrency (iter-32): REQUIRED on content updates — the
44
+ // content_hash of the version this edit was based on. Conflict → HTTP 409.
45
+ expected_content_hash?: string;
46
+ // Explicitly skip the concurrency check (external source of truth).
47
+ last_write_wins?: boolean;
48
+ }
49
+
50
+ // Map the RPC's CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to HTTP
51
+ // responses (409 conflict / 400 missing token). Returns null for other errors.
52
+ function concurrencyErrorResponse(
53
+ message: string,
54
+ headers: Record<string, string>,
55
+ ): Response | null {
56
+ if (message.includes("CEREFOX_CONFLICT")) {
57
+ return new Response(
58
+ JSON.stringify({
59
+ error: "conflict",
60
+ message:
61
+ "Document changed since it was read. Re-read it (getDocument), merge your changes, and retry with the new expected_content_hash.",
62
+ detail: message,
63
+ }),
64
+ { status: 409, headers },
65
+ );
66
+ }
67
+ if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
68
+ return new Response(
69
+ JSON.stringify({
70
+ error: "expected_content_hash required",
71
+ message:
72
+ "Content updates require expected_content_hash (the content_hash returned by getDocument / searchKnowledgeBase / metadataSearch) or last_write_wins=true.",
73
+ detail: message,
74
+ }),
75
+ { status: 400, headers },
76
+ );
77
+ }
78
+ return null;
43
79
  }
44
80
 
45
81
  interface Chunk {
@@ -428,7 +464,7 @@ Deno.serve(async (req: Request) => {
428
464
  });
429
465
  }
430
466
 
431
- const { title, content, document_id = null, project_name, source = "agent", metadata = {}, update_if_exists = false, author = "agent", author_type = "agent" } = body;
467
+ const { title, content, document_id = null, project_name, source = "agent", metadata = {}, update_if_exists = false, author = "agent", author_type = "agent", expected_content_hash = null, last_write_wins = false } = body;
432
468
 
433
469
  // Validate + normalize project_names if provided (full-set destructive form)
434
470
  let project_names: string[] | null = null;
@@ -525,6 +561,16 @@ Deno.serve(async (req: Request) => {
525
561
  );
526
562
  }
527
563
 
564
+ // Optimistic-concurrency fast-fail (iter-32): stale token fails BEFORE
565
+ // the embedding spend. Advisory only — the authoritative race-free check
566
+ // is inside the RPC (SELECT … FOR UPDATE).
567
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
568
+ return concurrencyErrorResponse(
569
+ `CEREFOX_CONFLICT: document ${existingDoc.id} changed since it was read (expected hash ${expected_content_hash}, current hash ${existingDoc.content_hash}).`,
570
+ headers,
571
+ )!;
572
+ }
573
+
528
574
  // Content changed -- re-chunk, re-embed, ingest via RPC
529
575
  const chunks = chunkMarkdown(content);
530
576
  if (chunks.length === 0) {
@@ -562,9 +608,13 @@ Deno.serve(async (req: Request) => {
562
608
  p_author: author,
563
609
  p_author_type: author_type,
564
610
  p_source_label: source,
611
+ p_expected_content_hash: expected_content_hash,
612
+ p_last_write_wins: last_write_wins,
565
613
  });
566
614
 
567
615
  if (ingestErr) {
616
+ const mapped = concurrencyErrorResponse(ingestErr.message ?? "", headers);
617
+ if (mapped) return mapped;
568
618
  return new Response(JSON.stringify({ error: `Ingest RPC failed: ${ingestErr.message}` }), { status: 500, headers });
569
619
  }
570
620
 
@@ -593,6 +643,7 @@ Deno.serve(async (req: Request) => {
593
643
  chunk_count: chunks.length,
594
644
  total_chars: totalChars,
595
645
  updated: true,
646
+ content_hash: contentHash,
596
647
  ...(note && { note }),
597
648
  }),
598
649
  { headers },
@@ -625,6 +676,14 @@ Deno.serve(async (req: Request) => {
625
676
  );
626
677
  }
627
678
 
679
+ // Optimistic-concurrency fast-fail (iter-32) — see ID-based path.
680
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
681
+ return concurrencyErrorResponse(
682
+ `CEREFOX_CONFLICT: document ${existingDoc.id} changed since it was read (expected hash ${expected_content_hash}, current hash ${existingDoc.content_hash}).`,
683
+ headers,
684
+ )!;
685
+ }
686
+
628
687
  // Content changed — re-chunk, re-embed, ingest via RPC
629
688
  const chunks = chunkMarkdown(content);
630
689
  if (chunks.length === 0) {
@@ -667,9 +726,13 @@ Deno.serve(async (req: Request) => {
667
726
  p_author: author,
668
727
  p_author_type: author_type,
669
728
  p_source_label: source,
729
+ p_expected_content_hash: expected_content_hash,
730
+ p_last_write_wins: last_write_wins,
670
731
  });
671
732
 
672
733
  if (ingestErr) {
734
+ const mapped = concurrencyErrorResponse(ingestErr.message ?? "", headers);
735
+ if (mapped) return mapped;
673
736
  return new Response(
674
737
  JSON.stringify({ error: `Ingest RPC failed: ${ingestErr.message}` }),
675
738
  { status: 500, headers },
@@ -701,6 +764,7 @@ Deno.serve(async (req: Request) => {
701
764
  chunk_count: chunks.length,
702
765
  total_chars: totalChars,
703
766
  updated: true,
767
+ content_hash: contentHash,
704
768
  }),
705
769
  { headers },
706
770
  );
@@ -33,6 +33,19 @@ The coordination model is **asynchronous and knowledge-based**:
33
33
 
34
34
  This is not real-time orchestration. It is persistent, searchable shared memory.
35
35
 
36
+ ### Concurrent writers are conflict-guarded (v0.11+)
37
+
38
+ Shared memory means two agents can hold the same document at once. Cerefox
39
+ protects content updates with **optimistic concurrency control**: every read
40
+ surface returns the document's `content_hash`, and a content update must pass
41
+ it back as `expected_content_hash`. If the document changed in between, the
42
+ write fails with a **conflict** instead of silently overwriting the other
43
+ writer's work — the losing agent re-reads, merges, and retries with the fresh
44
+ hash. (Versioning remains the recovery net; the conflict guard is the
45
+ prevention layer.) An explicit `last_write_wins: true` exists for re-sync flows
46
+ where an external source of truth makes conflicts meaningless — agents should
47
+ never use it to silence a conflict. See `AGENT_GUIDE.md → Concurrent writers`.
48
+
36
49
  ---
37
50
 
38
51
  ## Coordination Patterns
@@ -53,7 +66,7 @@ A living document where agents record decisions, experiment outcomes, and lesson
53
66
 
54
67
  **Example**: A coding agent working on a project records "Chose PostgreSQL RPC approach over application-level logic because..." in a decision log document. Next week, a different agent working on a related feature searches Cerefox, finds the decision log, and understands the rationale without re-deriving it.
55
68
 
56
- **How it works**: Create a document with a structured format (date, context, decision, outcome). Use a consistent title or project tag so agents can find it. To add entries over time, re-ingest with `update_if_exists: true` (or `document_id`) — this replaces the document in place, so build the new full content by appending to the prior content you fetched.
69
+ **How it works**: Create a document with a structured format (date, context, decision, outcome). Use a consistent title or project tag so agents can find it. To add entries over time, re-ingest with `update_if_exists: true` (or `document_id`) — this replaces the document in place, so build the new full content by appending to the prior content you fetched, and pass the `content_hash` you fetched as `expected_content_hash` (two agents appending entries concurrently is exactly the conflict the guard catches).
57
70
 
58
71
  **Best for**: Project-level institutional memory, avoiding repeated decisions, onboarding new agent sessions.
59
72
 
@@ -46,6 +46,8 @@ cerefox document ingest --paste --title "<title>" [OPTIONS] # stdin
46
46
  | `--metadata` | `-m` | JSON | `{}` | Extra metadata as a JSON object, e.g. `'{"tags":["work"]}'`. |
47
47
  | `--update-if-exists` | `-u` | flag | off | Title/source-path-based fallback update. Mutually exclusive with `--document-id`. |
48
48
  | `--document-id` | `-i` | UUID | _none_ | Deterministic ID-based update. Errors if the document doesn't exist. |
49
+ | `--expected-content-hash` | — | sha256 | _none_ | **Required on content updates** (v0.11 optimistic concurrency): the `content_hash` of the version this edit is based on, shown by `cerefox document get` / `cerefox search`. Stale → conflict error (re-read, merge, retry). |
50
+ | `--last-write-wins` | — | flag | off | Skip the concurrency check and overwrite regardless of concurrent changes. For re-sync flows where an external source of truth makes conflicts meaningless. Recorded in the audit log. |
49
51
  | `--source` | — | str | `paste` / `file` | Source label recorded on the document. |
50
52
  | `--author` | — | str | `CEREFOX_AUTHOR_NAME` or `unknown` | Audit-log author identity. |
51
53
  | `--author-type` | — | `user`\|`agent` | `CEREFOX_AUTHOR_TYPE` or `user` | Caller type. Agent writes auto-routed to `pending_review`. |
@@ -63,12 +65,19 @@ cerefox document ingest notes.md \
63
65
  --author "claude-code" --author-type "agent" \
64
66
  --project-name "research" --metadata '{"type":"design-doc"}'
65
67
 
66
- # Deterministic update (preferred — agents should search → grab ID → ingest)
68
+ # Deterministic update (preferred — agents should search → grab ID + hash → ingest)
67
69
  cerefox document ingest --paste --title "Same Title" \
68
70
  --document-id "abc12345-..." \
71
+ --expected-content-hash "<hash from `document get`>" \
69
72
  --author "claude-code" --author-type "agent"
70
73
  ```
71
74
 
75
+ > **Concurrency (v0.11+)**: content updates require `--expected-content-hash`
76
+ > (or an explicit `--last-write-wins`). On a conflict, re-run
77
+ > `cerefox document get <id>`, merge your changes into the latest content, and
78
+ > retry with the new hash. `document ingest-dir` and `guides ingest` bypass the
79
+ > check internally (the filesystem / npm package is their source of truth).
80
+
72
81
  **Output**: human-readable summary line(s) — "Ingested" or "Updated" with the document ID, chunk count, character count.
73
82
 
74
83
  **Exit codes**: `0` success, `1` on validation error (missing `--title`, invalid JSON, document-not-found, mutually-exclusive flags, etc.).
@@ -183,7 +192,7 @@ cerefox document get abc12345-... --version-id <version-uuid> # archived
183
192
  cerefox document get abc12345-... | bat -l md # pipe to viewer
184
193
  ```
185
194
 
186
- **Output**: title + metadata line, blank line, then raw markdown.
195
+ **Output**: title + metadata line + `content_hash` line (the optimistic-concurrency token — pass back via `document ingest --expected-content-hash` when updating), blank line, then raw markdown.
187
196
 
188
197
  **MCP equivalent**: [`cerefox_get_document`](../../AGENT_GUIDE.md).
189
198
 
@@ -633,7 +642,7 @@ Every MCP parameter has an exact-name CLI flag (kebab-cased). Short forms exist
633
642
  | MCP tool | CLI command |
634
643
  |---|---|
635
644
  | `cerefox_search(query, match_count, project_name, metadata_filter, requestor)` | `cerefox search "<q>" --match-count N --project-name <name> --metadata-filter '<json>' --requestor <name>` |
636
- | `cerefox_ingest(title, content, project_name, metadata, update_if_exists, document_id, source, author, author_type)` (file) | `cerefox document ingest <path> --title <t> --project-name <n> --metadata '<json>' --update-if-exists\|--document-id <uuid> --source <s> --author <a> --author-type <t>` |
645
+ | `cerefox_ingest(title, content, project_name, metadata, update_if_exists, document_id, expected_content_hash, last_write_wins, source, author, author_type)` (file) | `cerefox document ingest <path> --title <t> --project-name <n> --metadata '<json>' --update-if-exists\|--document-id <uuid> --expected-content-hash <hash>\|--last-write-wins --source <s> --author <a> --author-type <t>` |
637
646
  | `cerefox_ingest(...)` (paste) | `printf '...' \| cerefox document ingest --paste --title "<t>"` (same flags) |
638
647
  | `cerefox_get_document(document_id, version_id, requestor)` | `cerefox document get <id> --version-id <vid> --requestor <name>` |
639
648
  | `cerefox_list_versions(document_id, requestor)` | `cerefox document version list <id> --requestor <name>` |
@@ -709,12 +718,18 @@ cerefox document ingest-dir ./papers --extensions .md \
709
718
  # Step 1: find it
710
719
  cerefox search "the OAuth design doc" --match-count 1
711
720
 
712
- # Step 2: copy the id from `Doc: ... (id: <uuid>)` line
713
- # Step 3: update in place
721
+ # Step 2: read it — note the id AND the `content_hash:` line (the concurrency token)
722
+ cerefox document get "<uuid>"
723
+
724
+ # Step 3: update in place, proving freshness with the hash from step 2
714
725
  printf '%s' "$NEW_CONTENT" | cerefox document ingest --paste \
715
726
  --title "OAuth 2.1 Design Document" \
716
727
  --document-id "<uuid>" \
728
+ --expected-content-hash "<hash>" \
717
729
  --author "claude-code" --author-type "agent"
730
+
731
+ # On a conflict error: repeat from step 2 (fresh content + fresh hash),
732
+ # merge your changes into the latest content, then retry.
718
733
  ```
719
734
 
720
735
  ### Unattended sync job
@@ -568,6 +568,11 @@ You have access to a personal knowledge base via the searchKnowledgeBase action.
568
568
  When the user asks a question, always search the knowledge base first using a
569
569
  relevant query. Present results by document title, citing the source for every claim.
570
570
  Use ingestNote to save any new information the user asks you to remember.
571
+ When UPDATING an existing document, first call getDocument and note its
572
+ content_hash, then pass it as expected_content_hash on ingestNote. If you get a
573
+ 409 conflict, the document changed underneath you: call getDocument again, merge
574
+ your changes into the latest content, and retry with the new hash — never
575
+ overwrite blindly.
571
576
  ```
572
577
 
573
578
  ### Path B verification
@@ -604,7 +609,7 @@ In the action editor, paste this schema (replace `<your-project-ref>`):
604
609
  openapi: 3.1.0
605
610
  info:
606
611
  title: Cerefox Knowledge Base
607
- version: 1.9.0
612
+ version: 2.0.0
608
613
  servers:
609
614
  - url: https://<your-project-ref>.supabase.co/functions/v1
610
615
  paths:
@@ -731,6 +736,23 @@ paths:
731
736
  instead of creating a new one. The previous content is archived
732
737
  as a version. If content is unchanged, the document is skipped
733
738
  (no re-indexing). Ignored when document_id is provided.
739
+ expected_content_hash:
740
+ type: string
741
+ description: >
742
+ REQUIRED on content updates (optimistic concurrency, v2.0.0):
743
+ the content_hash of the version this edit was based on, as
744
+ returned by getDocument / searchKnowledgeBase / metadataSearch.
745
+ If the document changed since it was read, the update fails
746
+ with HTTP 409 — re-read the document, merge your changes,
747
+ retry with the new hash. Not needed when creating.
748
+ last_write_wins:
749
+ type: boolean
750
+ default: false
751
+ description: >
752
+ Explicitly skip the concurrency check and overwrite regardless
753
+ of concurrent changes. Use ONLY when an external source of
754
+ truth makes conflicts meaningless. Recorded in the audit log.
755
+ Never use it to silence a 409 conflict.
734
756
  author:
735
757
  type: string
736
758
  description: >
@@ -753,8 +775,19 @@ paths:
753
775
  project_id?, project_name?, # set when a project was assigned on create
754
776
  skipped?, # true when identical content was deduplicated
755
777
  updated?, # true when an existing doc was updated
778
+ content_hash?, # the NEW hash after an update (the next edit's token)
756
779
  message?, # human note on dedup/skip/update
757
780
  note? } # note when a flag (e.g. update_if_exists) was overridden
781
+ '400':
782
+ description: >
783
+ Missing expected_content_hash on a content update (and
784
+ last_write_wins not set). Read the document first, then retry
785
+ with its content_hash.
786
+ '409':
787
+ description: >
788
+ Conflict — the document changed since it was read. Call getDocument
789
+ for the latest content + content_hash, merge your changes, and
790
+ retry with the new hash. Do not overwrite blindly.
758
791
  /cerefox-metadata:
759
792
  post:
760
793
  operationId: listMetadataKeys
@@ -802,7 +835,9 @@ paths:
802
835
  description: >
803
836
  Document content and metadata:
804
837
  { document_id, doc_title, full_content, chunk_count, total_chars,
805
- is_archived, version_id }
838
+ is_archived, version_id, content_hash }.
839
+ content_hash is the document's CURRENT hash — pass it back as
840
+ expected_content_hash when updating via ingestNote.
806
841
  '404':
807
842
  description: Document not found
808
843
  /cerefox-list-versions:
@@ -952,7 +987,9 @@ paths:
952
987
  Array of matching documents:
953
988
  [{ document_id, title, doc_metadata, review_status, source, created_at,
954
989
  updated_at, total_chars, chunk_count, project_ids, project_names,
955
- version_count, content }]
990
+ version_count, content_hash, content }].
991
+ content_hash is the concurrency token — pass it back as
992
+ expected_content_hash when updating via ingestNote.
956
993
  ```
957
994
 
958
995
  **Step 3 — Configure authentication**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. The local TypeScript runtime: stdio MCP server in v0.4; CLI binary added in v0.5; in-process web server in v0.6; ingestion pipeline in v0.7.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",