@cerefox/memory 1.18.0 → 1.19.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.
Files changed (37) hide show
  1. package/AGENT_GUIDE.md +8 -3
  2. package/AGENT_QUICK_REFERENCE.md +3 -3
  3. package/dist/bin/cerefox.js +847 -659
  4. package/dist/frontend/assets/index-01k5C3sG.css +1 -0
  5. package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
  6. package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
  7. package/dist/frontend/index.html +2 -2
  8. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  9. package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
  10. package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
  11. package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
  12. package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
  13. package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
  14. package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
  15. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
  16. package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
  17. package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
  18. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
  19. package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
  20. package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
  21. package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
  22. package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
  23. package/dist/server-assets/db/rpcs.sql +252 -53
  24. package/dist/server-assets/db/schema.sql +41 -4
  25. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
  26. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
  27. package/docs/api/openapi.json +67 -1
  28. package/docs/guides/cli.md +3 -1
  29. package/docs/guides/configuration.md +1 -1
  30. package/docs/guides/connect-agents.md +18 -7
  31. package/docs/guides/ops-scripts.md +1 -1
  32. package/docs/guides/setup-local.md +30 -6
  33. package/docs/guides/upgrading.md +14 -0
  34. package/package.json +6 -6
  35. package/dist/frontend/assets/index-B0H_N4if.css +0 -1
  36. package/dist/frontend/assets/index-CxMT7hjH.js +0 -121
  37. package/dist/frontend/assets/index-CxMT7hjH.js.map +0 -1
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.19.0
8
+ -- @version: 0.21.1
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -40,7 +40,9 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
40
40
  source TEXT NOT NULL DEFAULT 'manual',
41
41
  -- source values: 'file' | 'paste' | 'agent' | 'url' | 'manual'
42
42
  source_path TEXT,
43
- -- SHA-256 of raw markdown content; used for deduplication
43
+ -- SHA-256 of raw markdown content: the optimistic-concurrency token, and
44
+ -- the create-time duplicate check (live documents only, in
45
+ -- cerefox_ingest_document). Deliberately NOT unique (0.20.0, #332).
44
46
  content_hash TEXT NOT NULL,
45
47
  -- #212 (0.12.2): must be a JSON OBJECT — jsonb accepts any JSON value,
46
48
  -- but every reader assumes an object, and non-object values were silently
@@ -51,6 +53,12 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
51
53
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb
52
54
  CONSTRAINT cerefox_documents_metadata_object
53
55
  CHECK (jsonb_typeof(metadata) = 'object'),
56
+ -- #333 (0.21.0): the metadata's optimistic-concurrency token, independent
57
+ -- of content_hash (which covers the body only). Starts at 1 and goes up by
58
+ -- one whenever `metadata` actually changes; maintained by the
59
+ -- trig_cerefox_documents_metadata_revision trigger below, so every writer
60
+ -- is covered and no caller can set it.
61
+ metadata_revision INT NOT NULL DEFAULT 1,
54
62
  chunk_count INT NOT NULL DEFAULT 0,
55
63
  total_chars INT NOT NULL DEFAULT 0,
56
64
  -- review_status: human governance flag. 'approved' = validated by human,
@@ -71,7 +79,6 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
71
79
  -- Search indexes exclude soft-deleted docs. Purge does the real CASCADE DELETE.
72
80
  deleted_at TIMESTAMPTZ DEFAULT NULL,
73
81
 
74
- CONSTRAINT cerefox_documents_hash_unique UNIQUE (content_hash),
75
82
  CONSTRAINT cerefox_documents_review_status_check CHECK (review_status IN ('approved', 'pending_review'))
76
83
  );
77
84
 
@@ -127,6 +134,11 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
127
134
  -- author_type: 'user' (human via web UI/CLI) or 'agent' (AI via MCP/Edge Function)
128
135
  size_before INT,
129
136
  size_after INT,
137
+ -- #333 (0.21.0): on 'update-metadata' entries, the document's metadata
138
+ -- before and after the change, so every metadata change is recoverable
139
+ -- (content versions never captured metadata). NULL on other operations.
140
+ metadata_before JSONB,
141
+ metadata_after JSONB,
130
142
  description TEXT NOT NULL DEFAULT '',
131
143
  -- description: free-text explaining what changed and why.
132
144
  -- Auto-generated for system actions (approval, archival, retention cleanup).
@@ -289,7 +301,7 @@ CREATE INDEX IF NOT EXISTS idx_cerefox_chunks_version
289
301
  CREATE INDEX IF NOT EXISTS idx_cerefox_docs_metadata
290
302
  ON cerefox_documents USING GIN(metadata);
291
303
 
292
- -- Documents by content_hash (unique constraint covers equality; this covers range/prefix)
304
+ -- Documents by content_hash (the create-time duplicate check and hash lookups)
293
305
  CREATE INDEX IF NOT EXISTS idx_cerefox_docs_hash
294
306
  ON cerefox_documents(content_hash);
295
307
 
@@ -344,6 +356,31 @@ CREATE OR REPLACE TRIGGER trig_cerefox_chunks_updated
344
356
  BEFORE UPDATE ON cerefox_chunks
345
357
  FOR EACH ROW EXECUTE FUNCTION cerefox_set_updated_at();
346
358
 
359
+ -- ── metadata_revision trigger (#333, 0.21.0) ─────────────────────────────────
360
+ -- Bumps metadata_revision when, and only when, metadata actually changes. A
361
+ -- trigger rather than per-RPC code because metadata has several writers (the
362
+ -- metadata RPC, the ingest RPC, direct table updates from older clients), and
363
+ -- a counter that one of them forgets to bump is a token that lies. It also
364
+ -- pins the column on every other UPDATE, so no caller can set it directly.
365
+ CREATE OR REPLACE FUNCTION cerefox_bump_metadata_revision()
366
+ RETURNS TRIGGER
367
+ LANGUAGE plpgsql
368
+ SET search_path = public, pg_catalog
369
+ AS $$
370
+ BEGIN
371
+ IF NEW.metadata IS DISTINCT FROM OLD.metadata THEN
372
+ NEW.metadata_revision := OLD.metadata_revision + 1;
373
+ ELSE
374
+ NEW.metadata_revision := OLD.metadata_revision;
375
+ END IF;
376
+ RETURN NEW;
377
+ END;
378
+ $$;
379
+
380
+ CREATE OR REPLACE TRIGGER trig_cerefox_documents_metadata_revision
381
+ BEFORE UPDATE ON cerefox_documents
382
+ FOR EACH ROW EXECUTE FUNCTION cerefox_bump_metadata_revision();
383
+
347
384
 
348
385
  -- ── Live-database migration ───────────────────────────────────────────────────
349
386
  -- When applying this schema to an existing database that still has the old
@@ -19,7 +19,8 @@ import { callerIdentity } from "../../../_shared/mcp-tools/identity.ts";
19
19
  * { document_id: string, version_id?: string | null }
20
20
  *
21
21
  * Response (200):
22
- * { document_id, doc_title, full_content, chunk_count, total_chars, is_archived, version_id, content_hash }
22
+ * { document_id, doc_title, full_content, chunk_count, total_chars, is_archived, version_id, content_hash,
23
+ * metadata, metadata_revision }
23
24
  * Response (404):
24
25
  * { error: "Document not found" }
25
26
  * Response (400):
@@ -108,6 +109,8 @@ Deno.serve(async (req: Request): Promise<Response> => {
108
109
  chunk_count?: number;
109
110
  total_chars?: number;
110
111
  content_hash?: string;
112
+ doc_metadata?: Record<string, unknown> | null;
113
+ metadata_revision?: number | null;
111
114
  } | undefined;
112
115
 
113
116
  if (!row) {
@@ -138,6 +141,10 @@ Deno.serve(async (req: Request): Promise<Response> => {
138
141
  // Optimistic-concurrency token (iter-32): always the CURRENT hash —
139
142
  // pass back as expected_content_hash when updating via ingest.
140
143
  content_hash: row.content_hash ?? null,
144
+ // #333: the metadata and its own token — pass metadata_revision back as
145
+ // expected_metadata_revision when changing metadata via ingest.
146
+ metadata: row.doc_metadata ?? {},
147
+ metadata_revision: version_id === null ? (row.metadata_revision ?? null) : null,
141
148
  }),
142
149
  { status: 200, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } },
143
150
  );
@@ -3,6 +3,7 @@ import { createClient } from "jsr:@supabase/supabase-js@2";
3
3
  import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/index.ts";
4
4
  import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
5
5
  import { callerIdentity } from "../../../_shared/mcp-tools/identity.ts";
6
+ import { duplicateContentDocumentId, legacyContentUniqueError } from "../../../_shared/mcp-tools/_utils.ts";
6
7
  import { capEmbeddingInput } from "../../../_shared/embeddings/index.ts";
7
8
  import {
8
9
  ensureDocumentInProject,
@@ -61,24 +62,26 @@ interface IngestRequest {
61
62
  expected_content_hash?: string;
62
63
  // Explicitly skip the concurrency check (external source of truth).
63
64
  last_write_wins?: boolean;
65
+ // #333: the metadata's token, checked when `metadata` is passed on an update
66
+ // (metadata merges; a null value removes a key).
67
+ expected_metadata_revision?: number;
64
68
  }
65
69
 
66
70
  // Map the RPC's expected/validation errors to proper HTTP responses:
67
71
  // CEREFOX_CONFLICT → 409 (stale optimistic-concurrency token)
68
72
  // CEREFOX_TOKEN_REQUIRED → 400 (missing expected_content_hash)
69
- // cerefox_documents_hash_unique → 409 (content de-dup: another doc already
70
- // holds identical content)
73
+ // CEREFOX_METADATA_CONFLICT → 409 (stale expected_metadata_revision, #333)
71
74
  // Returns null for genuinely unexpected errors (→ 500 at the call site).
72
75
  function concurrencyErrorResponse(
73
76
  message: string,
74
77
  headers: Record<string, string>,
75
78
  ): Response | null {
76
- if (message.includes("cerefox_documents_hash_unique")) {
79
+ if (message.includes("CEREFOX_METADATA_CONFLICT")) {
77
80
  return new Response(
78
81
  JSON.stringify({
79
- error: "duplicate_content",
82
+ error: "metadata_conflict",
80
83
  message:
81
- "Another document already has identical content. Cerefox de-duplicates by content hash — update that document instead of creating or editing a second copy to match it.",
84
+ "The document's metadata changed since it was read. Re-read it (getDocument), reapply your change, and retry with the new expected_metadata_revision.",
82
85
  detail: message,
83
86
  }),
84
87
  { status: 409, headers },
@@ -278,7 +281,7 @@ Deno.serve(async (req: Request) => {
278
281
  // metadata: null = "not provided" — the RPC keeps existing metadata on
279
282
  // update and uses {} on create (v0.11.1; a `= {}` default here used to wipe
280
283
  // a document's tags on every content update that didn't re-pass them).
281
- const { title, content, document_id = null, project_name, source = "agent", metadata = null, update_if_exists = false, author_type = "agent", expected_content_hash = null, last_write_wins = false } = body;
284
+ const { title, content, document_id = null, project_name, source = "agent", metadata = null, update_if_exists = false, author_type = "agent", expected_content_hash = null, last_write_wins = false, expected_metadata_revision = null } = body;
282
285
  const callerName = callerIdentity(body as unknown as Record<string, unknown>);
283
286
  const author = callerName ?? "agent";
284
287
 
@@ -436,6 +439,9 @@ Deno.serve(async (req: Request) => {
436
439
  p_source_label: source,
437
440
  p_expected_content_hash: expected_content_hash,
438
441
  p_last_write_wins: last_write_wins,
442
+ // Only when set (#333 review): a null would name a signature a pre-0.21.0
443
+ // schema lacks, failing the whole update.
444
+ ...(expected_metadata_revision != null ? { p_expected_metadata_revision: expected_metadata_revision } : {}),
439
445
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
440
446
  });
441
447
 
@@ -554,6 +560,9 @@ Deno.serve(async (req: Request) => {
554
560
  p_source_label: source,
555
561
  p_expected_content_hash: expected_content_hash,
556
562
  p_last_write_wins: last_write_wins,
563
+ // Only when set (#333 review): a null would name a signature a pre-0.21.0
564
+ // schema lacks, failing the whole update.
565
+ ...(expected_metadata_revision != null ? { p_expected_metadata_revision: expected_metadata_revision } : {}),
557
566
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
558
567
  });
559
568
 
@@ -600,10 +609,14 @@ Deno.serve(async (req: Request) => {
600
609
  }
601
610
 
602
611
  // ── Hash deduplication (normal create path) ────────────────────────────────
612
+ // Creates only, against LIVE documents (#332). Saves the embedding spend;
613
+ // the RPC repeats the check authoritatively.
603
614
  const { data: hashMatch } = await supabase
604
615
  .from("cerefox_documents")
605
616
  .select("id, title")
606
617
  .eq("content_hash", contentHash)
618
+ .is("deleted_at", null)
619
+ .order("created_at", { ascending: true })
607
620
  .limit(1);
608
621
 
609
622
  if (hashMatch?.length) {
@@ -665,7 +678,30 @@ Deno.serve(async (req: Request) => {
665
678
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
666
679
  });
667
680
 
681
+ // The RPC's duplicate check caught an identical create that landed after
682
+ // the pre-check above. Nothing was written; answer as the pre-check does.
683
+ const duplicateId = duplicateContentDocumentId(ingestErr?.message ?? "");
684
+ if (duplicateId) {
685
+ const { data: existing } = await supabase
686
+ .from("cerefox_documents")
687
+ .select("title")
688
+ .eq("id", duplicateId)
689
+ .maybeSingle();
690
+ return new Response(
691
+ JSON.stringify({
692
+ document_id: duplicateId,
693
+ title: existing?.title ?? title.trim(),
694
+ skipped: true,
695
+ message: "Document already exists (content hash match)",
696
+ }),
697
+ { headers },
698
+ );
699
+ }
668
700
  if (ingestErr || !ingestResult?.length) {
701
+ // Functions deployed ahead of the schema still meet the pre-0.20.0
702
+ // store-wide content constraint.
703
+ const legacy = legacyContentUniqueError(ingestErr?.message ?? "");
704
+ if (legacy) return new Response(JSON.stringify({ error: legacy.message }), { status: 409, headers });
669
705
  return new Response(
670
706
  JSON.stringify({ error: `Ingest RPC failed: ${ingestErr?.message ?? "no data returned"}` }),
671
707
  { status: 500, headers },
@@ -206,6 +206,34 @@
206
206
  "created_at": {
207
207
  "default": "",
208
208
  "type": "string"
209
+ },
210
+ "metadata_before": {
211
+ "anyOf": [
212
+ {
213
+ "type": "object",
214
+ "propertyNames": {
215
+ "type": "string"
216
+ },
217
+ "additionalProperties": {}
218
+ },
219
+ {
220
+ "type": "null"
221
+ }
222
+ ]
223
+ },
224
+ "metadata_after": {
225
+ "anyOf": [
226
+ {
227
+ "type": "object",
228
+ "propertyNames": {
229
+ "type": "string"
230
+ },
231
+ "additionalProperties": {}
232
+ },
233
+ {
234
+ "type": "null"
235
+ }
236
+ ]
209
237
  }
210
238
  },
211
239
  "required": [
@@ -1498,6 +1526,18 @@
1498
1526
  "null"
1499
1527
  ]
1500
1528
  },
1529
+ "metadata_revision": {
1530
+ "anyOf": [
1531
+ {
1532
+ "type": "integer",
1533
+ "minimum": -9007199254740991,
1534
+ "maximum": 9007199254740991
1535
+ },
1536
+ {
1537
+ "type": "null"
1538
+ }
1539
+ ]
1540
+ },
1501
1541
  "versions": {
1502
1542
  "default": [],
1503
1543
  "type": "array",
@@ -1880,6 +1920,12 @@
1880
1920
  },
1881
1921
  "projectsChanged": {
1882
1922
  "type": "boolean"
1923
+ },
1924
+ "warnings": {
1925
+ "type": "array",
1926
+ "items": {
1927
+ "type": "string"
1928
+ }
1883
1929
  }
1884
1930
  },
1885
1931
  "required": [
@@ -2007,7 +2053,7 @@
2007
2053
  }
2008
2054
  },
2009
2055
  "metadata": {
2010
- "description": "Replaces the metadata. `{}` clears every key; omit to leave it unchanged.",
2056
+ "description": "Replaces the metadata (this route edits it as a whole form). `{}` clears every key; omit to leave it unchanged. Send expected_metadata_revision with it.",
2011
2057
  "type": "object",
2012
2058
  "propertyNames": {
2013
2059
  "type": "string"
@@ -2021,6 +2067,19 @@
2021
2067
  "null"
2022
2068
  ]
2023
2069
  },
2070
+ "expected_metadata_revision": {
2071
+ "description": "The metadata_revision you read (GET /documents/{id}). Required with `metadata` for an identified caller; a stale one is a 409.",
2072
+ "anyOf": [
2073
+ {
2074
+ "type": "integer",
2075
+ "minimum": -9007199254740991,
2076
+ "maximum": 9007199254740991
2077
+ },
2078
+ {
2079
+ "type": "null"
2080
+ }
2081
+ ]
2082
+ },
2024
2083
  "last_write_wins": {
2025
2084
  "description": "Skip the concurrency check. Only when an external source of truth makes a conflict meaningless.",
2026
2085
  "type": "boolean"
@@ -3471,12 +3530,19 @@
3471
3530
  "type": "string"
3472
3531
  },
3473
3532
  "metadata": {
3533
+ "description": "On an update it MERGES into the stored metadata: a null value removes a key, omitted keys are kept.",
3474
3534
  "type": "object",
3475
3535
  "propertyNames": {
3476
3536
  "type": "string"
3477
3537
  },
3478
3538
  "additionalProperties": {}
3479
3539
  },
3540
+ "expected_metadata_revision": {
3541
+ "description": "Optional, with metadata on an update: the metadata_revision you read. A stale one is a 409.",
3542
+ "type": "integer",
3543
+ "minimum": -9007199254740991,
3544
+ "maximum": 9007199254740991
3545
+ },
3480
3546
  "source": {
3481
3547
  "description": "Origin label. Defaults to \"paste\".",
3482
3548
  "type": "string"
@@ -278,7 +278,9 @@ cerefox document set-metadata [OPTIONS] DOCUMENT_ID
278
278
  | `-s, --set <key=value...>` | Set a key. Repeatable. |
279
279
  | `-r, --remove <key...>` | Remove a key. Repeatable. |
280
280
  | `--json <object>` | A JSON object of keys to set; a `null` value removes that key. |
281
- | `--replace` | Set the metadata to **exactly** what was given, discarding every key not listed. |
281
+ | `--replace` | Set the metadata to **exactly** what was given, discarding every key not listed. Needs `--expected-metadata-revision` or `--last-write-wins` (v1.19.0). |
282
+ | `--expected-metadata-revision <n>` | The `metadata_revision` you read (`document get` shows it). The write fails if the metadata changed since. Optional on a merge, required with `--replace`. |
283
+ | `--last-write-wins` | Replace without a revision, regardless of concurrent changes (recorded in the audit log). |
282
284
  | `-a, --author <name>` | Caller identity (audit log). |
283
285
  | `--author-type <type>` | `user` (default) or `agent`. |
284
286
  | `--json-out` | Emit the result as JSON. |
@@ -183,7 +183,7 @@ This handles intermittent OpenAI API errors (500s) that would otherwise cause se
183
183
  | `CEREFOX_MIN_TERM_COVERAGE` | **Retired in v1.1.0 — no longer read.** | Now a store setting: `cerefox config set min_term_coverage <value>`, or the **Settings** page. A per-call override still works (`cerefox search --min-term-coverage`, the MCP `min_term_coverage` param). `cerefox doctor` and the Settings page report the variable if it is still set. |
184
184
  | `CEREFOX_EMBED_MAX_INPUT_CHARS` | `20000` | Safety cap on the characters sent to the embedding model per input. The full chunk content is always stored and reconstructed untouched; only the embedding uses the (rare) truncated prefix, so an oversized chunk can never fail an ingest. |
185
185
  | `CEREFOX_MODELS_DIR` | `~/.cerefox/models` (in-container: inside the data volume) | Where the local embedder caches downloaded model weights (Cerefox Local; `CEREFOX_EMBEDDER=local`). |
186
- | `CEREFOX_ONNX_BATCH` | `4` | Texts per local-embedder inference call. Peak memory scales with this; the small default keeps ingest/reindex safe on small Docker VMs. |
186
+ | `CEREFOX_ONNX_BATCH` | `1` | Texts per local-embedder inference call. Peak memory grows with this (each batch is padded to its longest chunk), and on CPU a larger batch is not faster, so leave it at 1. Was 4 before v1.19.0 (#335). Cerefox Local forwards it from its `.env` (from v1.19.0; after upgrading, run `cerefox-local init` once for a value set there to apply). |
187
187
 
188
188
  ### Metadata filter
189
189
 
@@ -661,7 +661,7 @@ In the action editor, paste this schema (replace `<your-project-ref>`):
661
661
  openapi: 3.1.0
662
662
  info:
663
663
  title: Cerefox Knowledge Base
664
- version: 4.3.0
664
+ version: 4.4.0
665
665
  servers:
666
666
  - url: https://<your-project-ref>.supabase.co/functions/v1
667
667
  paths:
@@ -793,9 +793,10 @@ paths:
793
793
  metadata:
794
794
  type: object
795
795
  description: >
796
- Arbitrary JSON metadata. On an UPDATE, omitting this keeps
797
- the document's existing metadata (v2.1.0); pass {} to
798
- deliberately clear all tags.
796
+ Arbitrary JSON metadata. On an UPDATE it is MERGED into the
797
+ existing metadata (v4.4.0): keys you pass are set, a null
798
+ value removes that key, keys you omit are kept. Omitting
799
+ the field keeps the metadata as it is.
799
800
  update_if_exists:
800
801
  type: boolean
801
802
  default: false
@@ -813,6 +814,13 @@ paths:
813
814
  If the document changed since it was read, the update fails
814
815
  with HTTP 409 — re-read the document, merge your changes,
815
816
  retry with the new hash. Not needed when creating.
817
+ expected_metadata_revision:
818
+ type: integer
819
+ description: >
820
+ Optional, with metadata on an update (v4.4.0): the
821
+ metadata_revision returned by getDocument. If the metadata
822
+ changed since, the update fails with HTTP 409
823
+ (error "metadata_conflict").
816
824
  last_write_wins:
817
825
  type: boolean
818
826
  default: false
@@ -862,7 +870,9 @@ paths:
862
870
  changes, and retry with the new hash — do not overwrite blindly),
863
871
  or the target document is soft-deleted (error
864
872
  "document_deleted") — restore it first, then retry, or create a
865
- new document.
873
+ new document, or the metadata changed since it was read (error
874
+ "metadata_conflict") — re-read with getDocument and retry with
875
+ the new metadata_revision.
866
876
  '422':
867
877
  description: >
868
878
  Unresolved document links (error "unresolved_links") — the content
@@ -922,7 +932,8 @@ paths:
922
932
  description: >
923
933
  Document content and metadata:
924
934
  { document_id, doc_title, full_content, chunk_count, total_chars,
925
- is_archived, version_id, content_hash }.
935
+ is_archived, version_id, content_hash, metadata,
936
+ metadata_revision }.
926
937
  content_hash is the document's CURRENT hash — pass it back as
927
938
  expected_content_hash when updating via ingestNote.
928
939
  '404':
@@ -1155,7 +1166,7 @@ curl -s -X POST \
1155
1166
  }'
1156
1167
  ```
1157
1168
 
1158
- If the same content was already ingested (SHA-256 hash match), returns `"skipped": true`.
1169
+ If a live document already has the same content (SHA-256 hash match), a create returns that document with `"skipped": true` instead of a copy. Documents in the trash do not count, and updates never de-duplicate.
1159
1170
 
1160
1171
  **Edge Function parameters — `cerefox-search`:**
1161
1172
 
@@ -177,7 +177,7 @@ Output: `backup-data/cerefox-20260308T143022Z-before-v2-migration.json`
177
177
 
178
178
  ## backup_restore.ts — Restore from a backup
179
179
 
180
- Restores documents and chunks from a previously created backup file. Idempotent — documents with the same content hash are skipped. (End users use `cerefox backup restore`.)
180
+ Restores documents and chunks from a previously created backup file. Idempotent — a document is skipped when its id is already present, or when a live document outside the backup already has the same content. (End users use `cerefox backup restore`.)
181
181
 
182
182
  ```bash
183
183
  bun scripts/backup_restore.ts BACKUP_FILE [OPTIONS]
@@ -4,9 +4,11 @@ Run Cerefox **fully on your own machine** — no hosted Supabase, no cloud datab
4
4
  Docker container bundles everything: Postgres + pgvector, the PostgREST Data API, and the
5
5
  Cerefox web server. You get the same web UI, CLI, and MCP server as the cloud setup.
6
6
 
7
- > **Embeddings still use the OpenAI API.** An `OPENAI_API_KEY` is required even for a
8
- > local setup (the database and web server are local; embedding generation is not). A
9
- > fully offline embedder is on the roadmap.
7
+ > **Two embedding choices.** By default embeddings use the OpenAI API (an
8
+ > `OPENAI_API_KEY` is needed). Or choose the **local embedder**
9
+ > (`--local-embedder`, `CEREFOX_EMBEDDER=local`): a model that runs inside the
10
+ > container, fully offline, no API key. See
11
+ > [Choose your embedder](#choose-your-embedder-openai-vs-fully-local).
10
12
 
11
13
  ## Cloud vs. Local — pick one
12
14
 
@@ -49,9 +51,14 @@ install; switching later requires a re-index (see below).
49
51
  The local model (~130 MB) downloads once — at install/init when selected — into the
50
52
  data volume, so it survives `cerefox-local upgrade`.
51
53
 
52
- > **Memory**: give the Docker VM **≥ 4 GB** for comfortable local-embedder use
53
- > (Colima defaults to 2 GB: `colima start --memory 4`). Inference is
54
- > sub-batched to keep peak memory flat, so smaller VMs work — just slower.
54
+ > **Memory**: with the local embedder, each Cerefox Local instance's web
55
+ > process sits around **0.3–0.5 GB** idle and reaches about **1.5 GB** while
56
+ > ingesting long documents dense with tables, ids or code (measured on
57
+ > synthetic 20,000-character documents; Postgres and PostgREST add a little
58
+ > more). Give the Docker VM room for **each** instance you run, plus everything
59
+ > else in it: **4 GB is comfortable for one instance**, not two. Colima defaults
60
+ > to 2 GB (`colima start --memory 4`, or `--memory 8` for two instances). If the
61
+ > VM runs out, the kernel kills the web process; see Troubleshooting.
55
62
 
56
63
  > **Switching embedders on existing data is breaking**: the two models produce
57
64
  > incompatible vector spaces, so documents embedded with one are invisible to
@@ -193,6 +200,23 @@ Re-run the installer with the key, or set it and `cerefox-local upgrade`.
193
200
 
194
201
  **Port already in use** — re-install with a free port: `PORT=8017 sh -c "$(curl -fsSL …/install-local.sh)"`.
195
202
 
203
+ **Requests fail with "socket connection closed", or the web process restarts on
204
+ its own** — usually the Docker VM ran out of memory and the kernel killed the
205
+ web process (most often during ingest with the local embedder). s6 restarts it
206
+ within seconds, so the container keeps running. Since v1.19.0 the container log
207
+ says so (`docker logs cerefox-local` shows "killed (SIGKILL), most likely by the
208
+ kernel's out-of-memory killer"); on older versions the log shows only the
209
+ restart. Confirm it in the VM's kernel log:
210
+
211
+ ```sh
212
+ colima ssh -- sudo dmesg | grep -i "out of memory" # Colima
213
+ ```
214
+
215
+ (Docker Desktop: the same `dmesg` from a privileged container, e.g.
216
+ `docker run --rm --privileged alpine dmesg | grep -i "out of memory"`.) The fix is
217
+ more memory for the VM, or fewer instances in it; see **Memory** above.
218
+ `CEREFOX_ONNX_BATCH` (default 1) trades memory for nothing useful above 1, so leave it.
219
+
196
220
  ---
197
221
 
198
222
  ## Contributor notes
@@ -194,6 +194,20 @@ knowing about:
194
194
  document must be restored before its content can be updated.
195
195
  - **v1.13.0 — the review workflow is a store setting.** Upgraded stores keep
196
196
  it on; fresh installs start with it off. See the callout above.
197
+ - **Schema 0.20.0 — content no longer has to be unique.** Updates that match
198
+ another document, or a document in the trash, are accepted; only creating a
199
+ document de-duplicates, against live documents. Needs `cerefox server deploy`.
200
+ Until the server is deployed, a 1.19.0 client writing content another
201
+ document (trashed ones included) already has gets an error telling it to
202
+ deploy. A client older than 1.19.0 against the new server is safe: when two
203
+ identical creates race, the loser gets an error, as it did before.
204
+ - **Schema 0.21.0 — metadata merges on content updates and has its own token.**
205
+ A content update's `metadata` now merges (`null` removes a key) instead of
206
+ replacing, so `{}` no longer clears tags; `replace` needs
207
+ `expected_metadata_revision`. Scripts that relied on replace-on-update should
208
+ use `document set-metadata --replace`. Needs `cerefox server deploy`; then
209
+ restart MCP clients and `cerefox web`, since an older client's metadata
210
+ `replace` is refused for lack of the token.
197
211
  - **v1.13.1 — the flag hides, it does not rewrite.** Agent writes are
198
212
  recorded `pending_review` whether or not the workflow is shown (v1.13.0
199
213
  stored `approved` while off). Redeploy to pick it up.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "Cerefox \u2014 user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",
@@ -39,12 +39,12 @@
39
39
  "CHANGELOG.md"
40
40
  ],
41
41
  "dependencies": {
42
- "@hono/node-server": "^2.1.1",
42
+ "@hono/node-server": "^2.1.4",
43
43
  "@modelcontextprotocol/sdk": "^1.32.1",
44
- "@supabase/supabase-js": "^2.117.2",
44
+ "@supabase/supabase-js": "^2.117.3",
45
45
  "cli-progress": "^3.12.0",
46
46
  "commander": "^15.0.0",
47
- "hono": "^4.13.9",
47
+ "hono": "^4.13.13",
48
48
  "mammoth": "^1.13.0",
49
49
  "ora": "^9.4.0",
50
50
  "picocolors": "^1.0.0",
@@ -54,13 +54,13 @@
54
54
  "zod": "^4.6.5"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@huggingface/transformers": "^4.3.0",
57
+ "@huggingface/transformers": "^4.3.1",
58
58
  "onnxruntime-node": "^1.21.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/bun": "^1.4.2",
62
62
  "@types/cli-progress": "^3.11.5",
63
- "@types/node": "^24.19.0",
63
+ "@types/node": "^24.19.1",
64
64
  "@types/prompts": "^2.4.9",
65
65
  "typescript": "^6.0.3"
66
66
  },