@cerefox/memory 0.10.4 → 0.11.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.
@@ -17,7 +17,9 @@ import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/inde
17
17
  * content string required Markdown content
18
18
  * project_name string optional Project to assign to (looked up by name, created if absent)
19
19
  * source string optional Origin label (default: "agent")
20
- * metadata object optional Arbitrary JSONB metadata
20
+ * metadata object optional Arbitrary JSONB metadata. Omitted on an
21
+ * update → existing metadata is KEPT
22
+ * (v0.11.1); pass {} explicitly to clear.
21
23
  *
22
24
  * Response: { document_id, title, chunk_count, project_id? }
23
25
  */
@@ -40,6 +42,42 @@ interface IngestRequest {
40
42
  update_if_exists?: boolean;
41
43
  author?: string;
42
44
  author_type?: string; // 'user' | 'agent'
45
+ // Optimistic concurrency (iter-32): REQUIRED on content updates — the
46
+ // content_hash of the version this edit was based on. Conflict → HTTP 409.
47
+ expected_content_hash?: string;
48
+ // Explicitly skip the concurrency check (external source of truth).
49
+ last_write_wins?: boolean;
50
+ }
51
+
52
+ // Map the RPC's CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to HTTP
53
+ // responses (409 conflict / 400 missing token). Returns null for other errors.
54
+ function concurrencyErrorResponse(
55
+ message: string,
56
+ headers: Record<string, string>,
57
+ ): Response | null {
58
+ if (message.includes("CEREFOX_CONFLICT")) {
59
+ return new Response(
60
+ JSON.stringify({
61
+ error: "conflict",
62
+ message:
63
+ "Document changed since it was read. Re-read it (getDocument), merge your changes, and retry with the new expected_content_hash.",
64
+ detail: message,
65
+ }),
66
+ { status: 409, headers },
67
+ );
68
+ }
69
+ if (message.includes("CEREFOX_TOKEN_REQUIRED")) {
70
+ return new Response(
71
+ JSON.stringify({
72
+ error: "expected_content_hash required",
73
+ message:
74
+ "Content updates require expected_content_hash (the content_hash returned by getDocument / searchKnowledgeBase / metadataSearch) or last_write_wins=true.",
75
+ detail: message,
76
+ }),
77
+ { status: 400, headers },
78
+ );
79
+ }
80
+ return null;
43
81
  }
44
82
 
45
83
  interface Chunk {
@@ -428,7 +466,10 @@ Deno.serve(async (req: Request) => {
428
466
  });
429
467
  }
430
468
 
431
- const { title, content, document_id = null, project_name, source = "agent", metadata = {}, update_if_exists = false, author = "agent", author_type = "agent" } = body;
469
+ // metadata: null = "not provided" — the RPC keeps existing metadata on
470
+ // update and uses {} on create (v0.11.1; a `= {}` default here used to wipe
471
+ // a document's tags on every content update that didn't re-pass them).
472
+ const { title, content, document_id = null, project_name, source = "agent", metadata = null, update_if_exists = false, author = "agent", author_type = "agent", expected_content_hash = null, last_write_wins = false } = body;
432
473
 
433
474
  // Validate + normalize project_names if provided (full-set destructive form)
434
475
  let project_names: string[] | null = null;
@@ -525,6 +566,16 @@ Deno.serve(async (req: Request) => {
525
566
  );
526
567
  }
527
568
 
569
+ // Optimistic-concurrency fast-fail (iter-32): stale token fails BEFORE
570
+ // the embedding spend. Advisory only — the authoritative race-free check
571
+ // is inside the RPC (SELECT … FOR UPDATE).
572
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
573
+ return concurrencyErrorResponse(
574
+ `CEREFOX_CONFLICT: document ${existingDoc.id} changed since it was read (expected hash ${expected_content_hash}, current hash ${existingDoc.content_hash}).`,
575
+ headers,
576
+ )!;
577
+ }
578
+
528
579
  // Content changed -- re-chunk, re-embed, ingest via RPC
529
580
  const chunks = chunkMarkdown(content);
530
581
  if (chunks.length === 0) {
@@ -562,9 +613,13 @@ Deno.serve(async (req: Request) => {
562
613
  p_author: author,
563
614
  p_author_type: author_type,
564
615
  p_source_label: source,
616
+ p_expected_content_hash: expected_content_hash,
617
+ p_last_write_wins: last_write_wins,
565
618
  });
566
619
 
567
620
  if (ingestErr) {
621
+ const mapped = concurrencyErrorResponse(ingestErr.message ?? "", headers);
622
+ if (mapped) return mapped;
568
623
  return new Response(JSON.stringify({ error: `Ingest RPC failed: ${ingestErr.message}` }), { status: 500, headers });
569
624
  }
570
625
 
@@ -593,6 +648,7 @@ Deno.serve(async (req: Request) => {
593
648
  chunk_count: chunks.length,
594
649
  total_chars: totalChars,
595
650
  updated: true,
651
+ content_hash: contentHash,
596
652
  ...(note && { note }),
597
653
  }),
598
654
  { headers },
@@ -625,6 +681,14 @@ Deno.serve(async (req: Request) => {
625
681
  );
626
682
  }
627
683
 
684
+ // Optimistic-concurrency fast-fail (iter-32) — see ID-based path.
685
+ if (!last_write_wins && expected_content_hash && expected_content_hash !== existingDoc.content_hash) {
686
+ return concurrencyErrorResponse(
687
+ `CEREFOX_CONFLICT: document ${existingDoc.id} changed since it was read (expected hash ${expected_content_hash}, current hash ${existingDoc.content_hash}).`,
688
+ headers,
689
+ )!;
690
+ }
691
+
628
692
  // Content changed — re-chunk, re-embed, ingest via RPC
629
693
  const chunks = chunkMarkdown(content);
630
694
  if (chunks.length === 0) {
@@ -667,9 +731,13 @@ Deno.serve(async (req: Request) => {
667
731
  p_author: author,
668
732
  p_author_type: author_type,
669
733
  p_source_label: source,
734
+ p_expected_content_hash: expected_content_hash,
735
+ p_last_write_wins: last_write_wins,
670
736
  });
671
737
 
672
738
  if (ingestErr) {
739
+ const mapped = concurrencyErrorResponse(ingestErr.message ?? "", headers);
740
+ if (mapped) return mapped;
673
741
  return new Response(
674
742
  JSON.stringify({ error: `Ingest RPC failed: ${ingestErr.message}` }),
675
743
  { status: 500, headers },
@@ -701,6 +769,7 @@ Deno.serve(async (req: Request) => {
701
769
  chunk_count: chunks.length,
702
770
  total_chars: totalChars,
703
771
  updated: true,
772
+ content_hash: contentHash,
704
773
  }),
705
774
  { headers },
706
775
  );
@@ -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
 
@@ -43,9 +43,11 @@ cerefox document ingest --paste --title "<title>" [OPTIONS] # stdin
43
43
  | `--title` | `-t` | str | filename stem | Document title. Required with `--paste`. |
44
44
  | `--project-name` | `--project`, `-p` | str | _none_ | Project name to assign the document to (created if missing). |
45
45
  | `--paste` | — | flag | off | Read markdown from stdin. Requires `--title`. |
46
- | `--metadata` | `-m` | JSON | `{}` | Extra metadata as a JSON object, e.g. `'{"tags":["work"]}'`. |
46
+ | `--metadata` | `-m` | JSON | _not provided_ | Extra metadata as a JSON object, e.g. `'{"tags":["work"]}'`. **On update, omitting this keeps the document's existing metadata** (v0.11.1); pass `'{}'` to deliberately clear all metadata. |
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
 
@@ -401,8 +410,8 @@ cerefox metadata search --metadata-filter '<json>' [OPTIONS]
401
410
 
402
411
  | Flag | Type | Default | Description |
403
412
  |---|---|---|---|
404
- | `--metadata-filter <json>` (`-f`) | JSON | **required** | Metadata filter, e.g. `'{"type":"decision-log"}'`. |
405
- | `--project-name <name>` (`-p`) | str | _none_ | Filter by project name. |
413
+ | `--metadata-filter <json>` (`-f`) | JSON | _none_ | Metadata filter, e.g. `'{"type":"decision-log"}'`. Optional since v0.11.1 — at least one of filter / `--project-name` / `--updated-since` / `--created-since` is required (parity with the MCP tool). |
414
+ | `--project-name <name>` (`-p`) | str | _none_ | Filter by project name. Sufficient on its own to list that project's documents. |
406
415
  | `--updated-since TEXT` | ISO-8601 | _none_ | Documents updated after this timestamp. |
407
416
  | `--created-since TEXT` | ISO-8601 | _none_ | Documents created after this timestamp. |
408
417
  | `--limit INTEGER` | int | `10` | Max results. |
@@ -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.1.0
608
613
  servers:
609
614
  - url: https://<your-project-ref>.supabase.co/functions/v1
610
615
  paths:
@@ -723,6 +728,10 @@ paths:
723
728
  default: agent
724
729
  metadata:
725
730
  type: object
731
+ description: >
732
+ Arbitrary JSON metadata. On an UPDATE, omitting this keeps
733
+ the document's existing metadata (v2.1.0); pass {} to
734
+ deliberately clear all tags.
726
735
  update_if_exists:
727
736
  type: boolean
728
737
  default: false
@@ -731,6 +740,23 @@ paths:
731
740
  instead of creating a new one. The previous content is archived
732
741
  as a version. If content is unchanged, the document is skipped
733
742
  (no re-indexing). Ignored when document_id is provided.
743
+ expected_content_hash:
744
+ type: string
745
+ description: >
746
+ REQUIRED on content updates (optimistic concurrency, v2.0.0):
747
+ the content_hash of the version this edit was based on, as
748
+ returned by getDocument / searchKnowledgeBase / metadataSearch.
749
+ If the document changed since it was read, the update fails
750
+ with HTTP 409 — re-read the document, merge your changes,
751
+ retry with the new hash. Not needed when creating.
752
+ last_write_wins:
753
+ type: boolean
754
+ default: false
755
+ description: >
756
+ Explicitly skip the concurrency check and overwrite regardless
757
+ of concurrent changes. Use ONLY when an external source of
758
+ truth makes conflicts meaningless. Recorded in the audit log.
759
+ Never use it to silence a 409 conflict.
734
760
  author:
735
761
  type: string
736
762
  description: >
@@ -753,8 +779,19 @@ paths:
753
779
  project_id?, project_name?, # set when a project was assigned on create
754
780
  skipped?, # true when identical content was deduplicated
755
781
  updated?, # true when an existing doc was updated
782
+ content_hash?, # the NEW hash after an update (the next edit's token)
756
783
  message?, # human note on dedup/skip/update
757
784
  note? } # note when a flag (e.g. update_if_exists) was overridden
785
+ '400':
786
+ description: >
787
+ Missing expected_content_hash on a content update (and
788
+ last_write_wins not set). Read the document first, then retry
789
+ with its content_hash.
790
+ '409':
791
+ description: >
792
+ Conflict — the document changed since it was read. Call getDocument
793
+ for the latest content + content_hash, merge your changes, and
794
+ retry with the new hash. Do not overwrite blindly.
758
795
  /cerefox-metadata:
759
796
  post:
760
797
  operationId: listMetadataKeys
@@ -802,7 +839,9 @@ paths:
802
839
  description: >
803
840
  Document content and metadata:
804
841
  { document_id, doc_title, full_content, chunk_count, total_chars,
805
- is_archived, version_id }
842
+ is_archived, version_id, content_hash }.
843
+ content_hash is the document's CURRENT hash — pass it back as
844
+ expected_content_hash when updating via ingestNote.
806
845
  '404':
807
846
  description: Document not found
808
847
  /cerefox-list-versions:
@@ -952,7 +991,9 @@ paths:
952
991
  Array of matching documents:
953
992
  [{ document_id, title, doc_metadata, review_status, source, created_at,
954
993
  updated_at, total_chars, chunk_count, project_ids, project_names,
955
- version_count, content }]
994
+ version_count, content_hash, content }].
995
+ content_hash is the concurrency token — pass it back as
996
+ expected_content_hash when updating via ingestNote.
956
997
  ```
957
998
 
958
999
  **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.1",
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",