@cerefox/memory 0.11.0 → 1.0.0-beta.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 (42) hide show
  1. package/AGENT_GUIDE.md +1 -1
  2. package/AGENT_QUICK_REFERENCE.md +1 -1
  3. package/dist/bin/cerefox.js +1089 -846
  4. package/dist/frontend/assets/index-CCkg5PXt.js +125 -0
  5. package/dist/frontend/assets/index-CCkg5PXt.js.map +1 -0
  6. package/dist/frontend/index.html +1 -1
  7. package/dist/server-assets/_shared/ef-auth/index.ts +134 -0
  8. package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
  9. package/dist/server-assets/_shared/embeddings/index.ts +42 -2
  10. package/dist/server-assets/_shared/ingest/chunker.ts +210 -0
  11. package/dist/server-assets/_shared/ingest/index.ts +32 -0
  12. package/dist/server-assets/_shared/ingest/pipeline-helpers.ts +135 -0
  13. package/dist/server-assets/_shared/mcp-auth/index.ts +352 -0
  14. package/dist/server-assets/_shared/mcp-tools/_chunker.ts +16 -170
  15. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +2 -2
  16. package/dist/server-assets/_shared/mcp-tools/ingest.ts +17 -5
  17. package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
  18. package/dist/server-assets/db/rpcs.sql +88 -13
  19. package/dist/server-assets/db/schema.sql +7 -1
  20. package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +8 -0
  21. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -0
  22. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +29 -173
  23. package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +8 -0
  24. package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +8 -0
  25. package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +54 -0
  26. package/dist/server-assets/supabase/functions/cerefox-mcp/oauth.ts +121 -0
  27. package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +8 -0
  28. package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +8 -0
  29. package/dist/server-assets/supabase/functions/cerefox-search/index.ts +11 -1
  30. package/docs/guides/access-paths.md +81 -30
  31. package/docs/guides/cli.md +32 -3
  32. package/docs/guides/configuration.md +4 -1
  33. package/docs/guides/connect-agents.md +102 -54
  34. package/docs/guides/content-format.md +55 -0
  35. package/docs/guides/migration-1.0.md +87 -0
  36. package/docs/guides/ops-scripts.md +1 -1
  37. package/docs/guides/quickstart.md +20 -0
  38. package/docs/guides/setup-supabase.md +154 -13
  39. package/docs/guides/upgrading.md +4 -3
  40. package/package.json +1 -1
  41. package/dist/frontend/assets/index-ojNhWSxm.js +0 -125
  42. package/dist/frontend/assets/index-ojNhWSxm.js.map +0 -1
@@ -17,7 +17,13 @@
17
17
 
18
18
  import type { MCPSupabaseClient } from "./types.ts";
19
19
 
20
- import { chunkMarkdown, normalizeContent, sha256hex } from "./_chunker.ts";
20
+ import {
21
+ chunkMarkdown,
22
+ embeddingInputFor,
23
+ CONTENT_FORMAT_BLIND_STITCH,
24
+ normalizeContent,
25
+ sha256hex,
26
+ } from "./_chunker.ts";
21
27
  import { embedBatch, OPENAI_MODEL } from "../embeddings/index.ts";
22
28
  import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.ts";
23
29
  import { logUsage } from "./_utils.ts";
@@ -71,7 +77,10 @@ async function handler(
71
77
  const project_name = args.project_name as string | undefined;
72
78
  const project_names_raw = args.project_names;
73
79
  const source = (args.source as string | undefined) ?? "agent";
74
- const metadata = (args.metadata as Record<string, unknown> | undefined) ?? {};
80
+ // null = "not provided": the RPC keeps existing metadata on update and uses
81
+ // {} on create (v0.11.1 — defaulting to {} here used to wipe a document's
82
+ // tags on every content update that didn't re-pass them).
83
+ const metadata = (args.metadata as Record<string, unknown> | undefined) ?? null;
75
84
  const update_if_exists = (args.update_if_exists as boolean | undefined) ?? false;
76
85
  const author = (args.author as string | undefined) ?? "mcp-agent";
77
86
  const author_type = "agent"; // MCP path is always agent
@@ -134,7 +143,7 @@ async function handler(
134
143
  const chunks = chunkMarkdown(content);
135
144
  if (chunks.length === 0) throw new Error("Content produced no chunks");
136
145
 
137
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
146
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
138
147
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
139
148
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
140
149
 
@@ -162,6 +171,7 @@ async function handler(
162
171
  p_source_label: source,
163
172
  p_expected_content_hash: expected_content_hash,
164
173
  p_last_write_wins: last_write_wins,
174
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
165
175
  });
166
176
 
167
177
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -211,7 +221,7 @@ async function handler(
211
221
  const chunks = chunkMarkdown(content);
212
222
  if (chunks.length === 0) throw new Error("Content produced no chunks");
213
223
 
214
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
224
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
215
225
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
216
226
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
217
227
 
@@ -239,6 +249,7 @@ async function handler(
239
249
  p_source_label: source,
240
250
  p_expected_content_hash: expected_content_hash,
241
251
  p_last_write_wins: last_write_wins,
252
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
242
253
  });
243
254
 
244
255
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -276,7 +287,7 @@ async function handler(
276
287
  const chunks = chunkMarkdown(content);
277
288
  if (chunks.length === 0) throw new Error("Content produced no chunks");
278
289
 
279
- const texts = chunks.map((c) => `# ${title}\n${c.content}`);
290
+ const texts = chunks.map((c) => embeddingInputFor(title, c));
280
291
  const embeddings = await embedBatch(texts, ctx.openaiApiKey);
281
292
  const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
282
293
 
@@ -301,6 +312,7 @@ async function handler(
301
312
  p_chunks: chunkData,
302
313
  p_author: author,
303
314
  p_author_type: author_type,
315
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
304
316
  });
305
317
 
306
318
  if (ingestErr || !ingestResult?.length) {
@@ -0,0 +1,20 @@
1
+ -- Migration 0012: content_format on cerefox_chunks (iter-28D)
2
+ --
3
+ -- Records how each chunk's content reconstructs into full document text:
4
+ -- 1 = legacy — chunk contents were trimmed sections; reconstruction re-joins
5
+ -- them with E'\n\n' (the pre-28D behaviour). All existing chunks.
6
+ -- 2 = blind-stitch — chunk contents are an exact, gapless partition of the
7
+ -- document; reconstruction is a plain concatenation (no separator
8
+ -- synthesized on read). Written by the exact-partition chunker.
9
+ --
10
+ -- Placed on the CHUNK (not the document) so an archived version reconstructs with
11
+ -- its OWN format, since Cerefox uses chunks-anchored versioning
12
+ -- (cerefox_chunks.version_id). The reconstruction RPCs branch on
13
+ -- MAX(content_format) >= 2 per aggregated group.
14
+ --
15
+ -- Adding a NOT NULL column with a constant default is a metadata-only change in
16
+ -- PostgreSQL 11+ (no rewrite of the chunks table). Existing rows read back as 1.
17
+ -- Explanation for users: docs/guides/content-format.md.
18
+
19
+ ALTER TABLE cerefox_chunks
20
+ ADD COLUMN IF NOT EXISTS content_format SMALLINT NOT NULL DEFAULT 1;
@@ -403,7 +403,7 @@ AS $$
403
403
  ARRAY(SELECT p.name FROM cerefox_projects p
404
404
  JOIN cerefox_document_projects dp ON p.id = dp.project_id
405
405
  WHERE dp.document_id = d.id) AS doc_project_names,
406
- STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) AS full_content,
406
+ CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END AS full_content,
407
407
  COUNT(*)::INT AS chunk_count,
408
408
  SUM(c.char_count)::INT AS total_chars,
409
409
  (SELECT COUNT(*)::INT FROM cerefox_document_versions dv
@@ -680,17 +680,18 @@ AS $$
680
680
  large_doc_content AS (
681
681
  SELECT
682
682
  e.document_id,
683
- STRING_AGG(e.content, E'\n\n' ORDER BY e.chunk_index) AS full_content,
683
+ CASE WHEN MAX(ch.content_format) >= 2 THEN STRING_AGG(e.content, '' ORDER BY e.chunk_index) ELSE STRING_AGG(e.content, E'\n\n' ORDER BY e.chunk_index) END AS full_content,
684
684
  COUNT(*)::INT AS chunk_count,
685
685
  TRUE AS is_partial
686
686
  FROM expanded e
687
+ JOIN cerefox_chunks ch ON ch.id = e.chunk_id
687
688
  GROUP BY e.document_id
688
689
  ),
689
690
  -- Full content for small documents (is_partial = FALSE).
690
691
  small_doc_content AS (
691
692
  SELECT
692
693
  c.document_id,
693
- STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) AS full_content,
694
+ CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END AS full_content,
694
695
  COUNT(*)::INT AS chunk_count,
695
696
  FALSE AS is_partial
696
697
  FROM cerefox_chunks c
@@ -866,7 +867,7 @@ AS $$
866
867
  JOIN cerefox_document_projects dp ON p.id = dp.project_id
867
868
  WHERE dp.document_id = d.id) AS doc_project_names,
868
869
  p_version_id AS version_id,
869
- STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) AS full_content,
870
+ CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END AS full_content,
870
871
  COUNT(*)::INT AS chunk_count,
871
872
  SUM(c.char_count)::INT AS total_chars,
872
873
  d.created_at,
@@ -1045,7 +1046,10 @@ $$;
1045
1046
  --
1046
1047
  -- Parameters:
1047
1048
  -- p_document_id : NULL for create, UUID for update
1048
- -- p_title, p_source, p_source_path, p_content_hash, p_metadata : document fields
1049
+ -- p_title, p_source, p_source_path, p_content_hash : document fields
1050
+ -- p_metadata : JSONB metadata. NULL = "not provided" → create uses '{}',
1051
+ -- update keeps the existing metadata (v0.11.1). Pass '{}'
1052
+ -- explicitly to clear all metadata.
1049
1053
  -- p_review_status : 'approved' or 'pending_review' (based on author_type)
1050
1054
  -- p_chunks : JSONB array of chunk objects, each with:
1051
1055
  -- chunk_index, heading_path, heading_level, title,
@@ -1069,13 +1073,17 @@ $$;
1069
1073
 
1070
1074
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN);
1071
1075
  DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN);
1076
+ DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN, SMALLINT);
1072
1077
  CREATE FUNCTION cerefox_ingest_document(
1073
1078
  p_document_id UUID DEFAULT NULL,
1074
1079
  p_title TEXT DEFAULT 'Untitled',
1075
1080
  p_source TEXT DEFAULT 'agent',
1076
1081
  p_source_path TEXT DEFAULT NULL,
1077
1082
  p_content_hash TEXT DEFAULT '',
1078
- p_metadata JSONB DEFAULT '{}',
1083
+ -- NULL = "not provided": create uses '{}', update KEEPS existing metadata
1084
+ -- (v0.11.1 fix — content updates without metadata used to wipe tags).
1085
+ -- Pass '{}' explicitly to deliberately clear all metadata.
1086
+ p_metadata JSONB DEFAULT NULL,
1079
1087
  p_review_status TEXT DEFAULT 'approved',
1080
1088
  p_chunks JSONB DEFAULT '[]',
1081
1089
  p_author TEXT DEFAULT 'unknown',
@@ -1084,7 +1092,11 @@ CREATE FUNCTION cerefox_ingest_document(
1084
1092
  p_retention_hours INT DEFAULT 48,
1085
1093
  p_cleanup_enabled BOOLEAN DEFAULT TRUE,
1086
1094
  p_expected_content_hash TEXT DEFAULT NULL,
1087
- p_last_write_wins BOOLEAN DEFAULT FALSE
1095
+ p_last_write_wins BOOLEAN DEFAULT FALSE,
1096
+ -- content_format for the chunks being written (iter-28D). 2 = exact-partition
1097
+ -- (blind-stitch reconstruction); default 1 = legacy (E'\n\n'-join). Stamped on
1098
+ -- every chunk this call inserts.
1099
+ p_content_format SMALLINT DEFAULT 1
1088
1100
  )
1089
1101
  RETURNS TABLE (
1090
1102
  document_id UUID,
@@ -1181,13 +1193,14 @@ BEGIN
1181
1193
  SELECT sv.version_id INTO v_version_id
1182
1194
  FROM cerefox_snapshot_version(v_doc_id, p_source_label, p_retention_hours, p_cleanup_enabled) sv;
1183
1195
 
1184
- -- Update document record
1196
+ -- Update document record. metadata: NULL = keep existing (v0.11.1 —
1197
+ -- a content update without metadata must not wipe the document's tags).
1185
1198
  UPDATE cerefox_documents SET
1186
1199
  title = p_title,
1187
1200
  source = p_source,
1188
1201
  source_path = COALESCE(p_source_path, source_path),
1189
1202
  content_hash = p_content_hash,
1190
- metadata = p_metadata,
1203
+ metadata = COALESCE(p_metadata, metadata),
1191
1204
  chunk_count = v_chunk_count,
1192
1205
  total_chars = v_total_chars,
1193
1206
  review_status = v_status,
@@ -1202,7 +1215,7 @@ BEGIN
1202
1215
  title, source, source_path, content_hash, metadata,
1203
1216
  chunk_count, total_chars, review_status
1204
1217
  ) VALUES (
1205
- p_title, p_source, p_source_path, p_content_hash, p_metadata,
1218
+ p_title, p_source, p_source_path, p_content_hash, COALESCE(p_metadata, '{}'::JSONB),
1206
1219
  v_chunk_count, v_total_chars, v_status
1207
1220
  )
1208
1221
  RETURNING id INTO v_doc_id;
@@ -1215,7 +1228,7 @@ BEGIN
1215
1228
  -- Formula: doc_title (A) || chunk_heading (A) || body_content (B)
1216
1229
  INSERT INTO cerefox_chunks (
1217
1230
  document_id, chunk_index, heading_path, heading_level,
1218
- title, content, char_count, embedding_primary, embedder_primary, fts
1231
+ title, content, char_count, content_format, embedding_primary, embedder_primary, fts
1219
1232
  )
1220
1233
  SELECT
1221
1234
  v_doc_id,
@@ -1225,6 +1238,7 @@ BEGIN
1225
1238
  c->>'title',
1226
1239
  c->>'content',
1227
1240
  (c->>'char_count')::INT,
1241
+ p_content_format,
1228
1242
  (SELECT array_agg(e::FLOAT)::VECTOR(768) FROM jsonb_array_elements_text(c->'embedding') AS e),
1229
1243
  c->>'embedder',
1230
1244
  setweight(to_tsvector('english', COALESCE(p_title, '')), 'A') ||
@@ -1502,7 +1516,7 @@ BEGIN
1502
1516
  WHERE dv.document_id = d.id) AS version_count,
1503
1517
  d.content_hash,
1504
1518
  CASE WHEN p_include_content THEN
1505
- (SELECT STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index)
1519
+ (SELECT CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END
1506
1520
  FROM cerefox_chunks c
1507
1521
  WHERE c.document_id = d.id AND c.version_id IS NULL)
1508
1522
  ELSE NULL END AS content
@@ -1760,7 +1774,30 @@ SET search_path = public, pg_catalog
1760
1774
  AS $$
1761
1775
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
1762
1776
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
1763
- SELECT '0.5.0'::TEXT;
1777
+ SELECT '0.8.0'::TEXT;
1778
+ $$;
1779
+
1780
+ -- ── cerefox_content_format_stats ─────────────────────────────────────────────
1781
+ -- Counts how many (non-deleted) documents still use the legacy chunk
1782
+ -- reconstruction format (content_format = 1) vs the total. Powers the
1783
+ -- informational `cerefox doctor` line. See docs/guides/content-format.md.
1784
+ CREATE OR REPLACE FUNCTION cerefox_content_format_stats()
1785
+ RETURNS TABLE (legacy_docs INT, total_docs INT)
1786
+ LANGUAGE sql
1787
+ STABLE
1788
+ SECURITY DEFINER
1789
+ SET search_path = public, pg_catalog
1790
+ AS $$
1791
+ SELECT
1792
+ COUNT(*) FILTER (WHERE cf.min_format < 2)::INT AS legacy_docs,
1793
+ COUNT(*)::INT AS total_docs
1794
+ FROM cerefox_documents d
1795
+ LEFT JOIN LATERAL (
1796
+ SELECT MIN(c.content_format) AS min_format
1797
+ FROM cerefox_chunks c
1798
+ WHERE c.document_id = d.id AND c.version_id IS NULL
1799
+ ) cf ON TRUE
1800
+ WHERE d.deleted_at IS NULL;
1764
1801
  $$;
1765
1802
 
1766
1803
 
@@ -1840,3 +1877,41 @@ AS $$
1840
1877
  WHERE a.document_id = ANY(p_doc_ids)
1841
1878
  ORDER BY a.document_id, a.created_at DESC;
1842
1879
  $$;
1880
+
1881
+ -- ── Function privilege lockdown (schema 0.7.0) ────────────────────────────────
1882
+ -- SECURITY (critical): every cerefox_* RPC is SECURITY DEFINER, so it bypasses the
1883
+ -- Row Level Security enabled on the tables. PostgreSQL grants EXECUTE to PUBLIC by
1884
+ -- default, and Supabase's PostgREST exposes public-schema functions at
1885
+ -- /rest/v1/rpc/<name>. WITHOUT this block, the anon key (and the new sb_publishable_
1886
+ -- key, which maps to the same anon role) could call every RPC directly via the Data
1887
+ -- API — reading and writing the entire KB, bypassing BOTH the Edge Functions and
1888
+ -- RLS. Every legitimate caller uses the service_role key instead (Edge Functions via
1889
+ -- SUPABASE_SERVICE_ROLE_KEY; the CLI/web via the secret key; local World B via a
1890
+ -- container-minted service_role JWT), so we REVOKE EXECUTE from PUBLIC/anon/
1891
+ -- authenticated and GRANT only to service_role.
1892
+ --
1893
+ -- Applied over ALL cerefox_* functions (so new functions are covered on the next
1894
+ -- deploy) and idempotent — re-applied each time rpcs.sql is deployed. Guarded on
1895
+ -- role existence so it is safe on non-Supabase Postgres (e.g. World B bootstrap).
1896
+ DO $$
1897
+ DECLARE
1898
+ fn regprocedure;
1899
+ r text;
1900
+ BEGIN
1901
+ FOR fn IN
1902
+ SELECT p.oid::regprocedure
1903
+ FROM pg_proc p
1904
+ JOIN pg_namespace n ON n.oid = p.pronamespace
1905
+ WHERE n.nspname = 'public' AND p.proname LIKE 'cerefox\_%'
1906
+ LOOP
1907
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC', fn);
1908
+ FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP
1909
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
1910
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM %I', fn, r);
1911
+ END IF;
1912
+ END LOOP;
1913
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
1914
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
1915
+ END IF;
1916
+ END LOOP;
1917
+ END $$;
@@ -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.5.0
8
+ -- @version: 0.8.0
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 —
@@ -157,6 +157,12 @@ CREATE TABLE IF NOT EXISTS cerefox_chunks (
157
157
  title TEXT,
158
158
  content TEXT NOT NULL,
159
159
  char_count INT NOT NULL,
160
+ -- content_format: how this chunk's content reconstructs into full document text
161
+ -- (iter-28D). 1 = legacy (chunk contents re-joined with E'\n\n' on read); 2 =
162
+ -- blind-stitch (chunk contents are an exact, gapless partition, reconstructed by
163
+ -- plain concat). It lives on the chunk (not the document) so an archived version
164
+ -- reconstructs with its OWN format. See docs/guides/content-format.md.
165
+ content_format SMALLINT NOT NULL DEFAULT 1,
160
166
 
161
167
  -- Primary embedding: always computed, cloud API (default: OpenAI text-embedding-3-small)
162
168
  embedding_primary VECTOR(768) NOT NULL,
@@ -1,6 +1,7 @@
1
1
  import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
2
  import { createClient } from "jsr:@supabase/supabase-js@2";
3
3
  import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/index.ts";
4
+ import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
4
5
 
5
6
  /**
6
7
  * cerefox-get-audit-log -- Supabase Edge Function
@@ -35,6 +36,13 @@ Deno.serve(async (req: Request): Promise<Response> => {
35
36
  return new Response(null, { status: 200, headers: CORS_HEADERS });
36
37
  }
37
38
 
39
+ const authFail = efAuthGate(
40
+ req.headers.get("Authorization"),
41
+ Deno.env.get("CEREFOX_ACCESS_TOKENS"),
42
+ { ...CORS_HEADERS, "Content-Type": "application/json" },
43
+ );
44
+ if (authFail) return authFail;
45
+
38
46
  if (isVersionRequest(req)) {
39
47
  return versionResponse("cerefox-get-audit-log", { ...CORS_HEADERS, "Content-Type": "application/json" });
40
48
  }
@@ -1,6 +1,7 @@
1
1
  import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
2
  import { createClient } from "jsr:@supabase/supabase-js@2";
3
3
  import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/index.ts";
4
+ import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
4
5
 
5
6
  /**
6
7
  * cerefox-get-document — Supabase Edge Function
@@ -35,6 +36,13 @@ Deno.serve(async (req: Request): Promise<Response> => {
35
36
  return new Response(null, { status: 200, headers: CORS_HEADERS });
36
37
  }
37
38
 
39
+ const authFail = efAuthGate(
40
+ req.headers.get("Authorization"),
41
+ Deno.env.get("CEREFOX_ACCESS_TOKENS"),
42
+ { ...CORS_HEADERS, "Content-Type": "application/json" },
43
+ );
44
+ if (authFail) return authFail;
45
+
38
46
  if (isVersionRequest(req)) {
39
47
  return versionResponse("cerefox-get-document", { ...CORS_HEADERS, "Content-Type": "application/json" });
40
48
  }
@@ -1,6 +1,13 @@
1
1
  import "jsr:@supabase/functions-js/edge-runtime.d.ts";
2
2
  import { createClient } from "jsr:@supabase/supabase-js@2";
3
3
  import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/index.ts";
4
+ import { efAuthGate } from "../../../_shared/ef-auth/index.ts";
5
+ import { capEmbeddingInput } from "../../../_shared/embeddings/index.ts";
6
+ import {
7
+ chunkMarkdown,
8
+ embeddingInputFor,
9
+ CONTENT_FORMAT_BLIND_STITCH,
10
+ } from "../../../_shared/ingest/chunker.ts";
4
11
 
5
12
  /**
6
13
  * cerefox-ingest — Supabase Edge Function
@@ -17,7 +24,9 @@ import { isVersionRequest, versionResponse } from "../../../_shared/ef-meta/inde
17
24
  * content string required Markdown content
18
25
  * project_name string optional Project to assign to (looked up by name, created if absent)
19
26
  * source string optional Origin label (default: "agent")
20
- * metadata object optional Arbitrary JSONB metadata
27
+ * metadata object optional Arbitrary JSONB metadata. Omitted on an
28
+ * update → existing metadata is KEPT
29
+ * (v0.11.1); pass {} explicitly to clear.
21
30
  *
22
31
  * Response: { document_id, title, chunk_count, project_id? }
23
32
  */
@@ -78,173 +87,6 @@ function concurrencyErrorResponse(
78
87
  return null;
79
88
  }
80
89
 
81
- interface Chunk {
82
- heading_path: string[];
83
- heading_level: number;
84
- title: string;
85
- content: string;
86
- char_count: number;
87
- }
88
-
89
- // ── Heading-aware chunker (mirrors Python logic) ───────────────────────────
90
- //
91
- // Design notes:
92
- // • Short-circuit for small documents: if the entire document fits within
93
- // MAX_CHUNK_CHARS, it is returned as a single chunk with no splitting.
94
- // • Greedy accumulation: sections are collected into a buffer until adding
95
- // the next would exceed MAX_CHUNK_CHARS. This keeps chunks close to the
96
- // target size and avoids many tiny fragments at every heading boundary.
97
- // All heading levels (H1/H2/H3) are treated equally — size alone controls
98
- // when a chunk is flushed; there are no hard heading-level boundaries.
99
- // • Oversized sections (> MAX_CHUNK_CHARS) are paragraph-split with no overlap.
100
- // • The first section's heading metadata anchors each chunk's breadcrumb.
101
- // • No overlaps between chunks — the heading breadcrumb in the content
102
- // provides sufficient context. Overlaps caused duplication on reconstruction.
103
-
104
- interface Section {
105
- level: number;
106
- headings: string[]; // full heading stack at this section
107
- heading: string; // just the current heading text
108
- content: string; // heading line + body
109
- body: string; // body only (no heading line)
110
- }
111
-
112
- function parseSections(text: string): Section[] {
113
- const lines = text.split("\n");
114
- const sections: Section[] = [];
115
- let currentHeadings: string[] = [];
116
- let currentLevel = 0;
117
- let bodyLines: string[] = [];
118
-
119
- function collectSection() {
120
- const body = bodyLines.join("\n").trim();
121
- bodyLines = [];
122
- let content: string;
123
- if (currentLevel > 0) {
124
- const headerLine = "#".repeat(currentLevel) + " " + (currentHeadings[currentHeadings.length - 1] ?? "");
125
- content = body ? headerLine + "\n\n" + body : headerLine;
126
- } else {
127
- content = body;
128
- }
129
- if (!content.trim()) return;
130
- sections.push({
131
- level: currentLevel,
132
- headings: [...currentHeadings],
133
- heading: currentHeadings[currentHeadings.length - 1] ?? "",
134
- content,
135
- body,
136
- });
137
- }
138
-
139
- for (const line of lines) {
140
- const h1 = line.match(/^# (.+)/);
141
- const h2 = line.match(/^## (.+)/);
142
- const h3 = line.match(/^### (.+)/);
143
-
144
- if (h1) {
145
- collectSection();
146
- currentHeadings = [h1[1].trim()];
147
- currentLevel = 1;
148
- } else if (h2) {
149
- collectSection();
150
- currentHeadings = [currentHeadings[0] ?? "", h2[1].trim()].filter(Boolean);
151
- currentLevel = 2;
152
- } else if (h3) {
153
- collectSection();
154
- currentHeadings = [
155
- currentHeadings[0] ?? "",
156
- currentHeadings[1] ?? "",
157
- h3[1].trim(),
158
- ].filter(Boolean);
159
- currentLevel = 3;
160
- } else {
161
- bodyLines.push(line);
162
- }
163
- }
164
- collectSection();
165
- return sections;
166
- }
167
-
168
- function chunkMarkdown(text: string): Chunk[] {
169
- const trimmed = text.trim();
170
- if (!trimmed) return [];
171
-
172
- // Short-circuit: entire document fits in one chunk — skip heading splitting.
173
- if (trimmed.length <= MAX_CHUNK_CHARS) {
174
- return [makeChunk([], 0, trimmed)];
175
- }
176
-
177
- const sections = parseSections(trimmed);
178
- const chunks: Chunk[] = [];
179
-
180
- // Greedy accumulation buffer
181
- let bufParts: string[] = [];
182
- let bufHeadings: string[] = [];
183
- let bufLevel = 0;
184
- let bufChars = 0;
185
-
186
- function flushBuf() {
187
- if (bufParts.length === 0) return;
188
- chunks.push(makeChunk(bufHeadings, bufLevel, bufParts.join("\n\n")));
189
- bufParts = [];
190
- bufHeadings = [];
191
- bufLevel = 0;
192
- bufChars = 0;
193
- }
194
-
195
- for (const section of sections) {
196
- const { level, headings, heading, content, body } = section;
197
-
198
- // Oversized section: flush buffer, then paragraph-split.
199
- if (content.length > MAX_CHUNK_CHARS) {
200
- flushBuf();
201
- const headerPrefix = level > 0 ? "#".repeat(level) + " " + heading + "\n\n" : "";
202
- const bodyToSplit = body || content;
203
- const paragraphs = bodyToSplit.split(/\n\n+/);
204
- let sub = "";
205
- let isFirst = true;
206
- for (const para of paragraphs) {
207
- const prefix = isFirst ? headerPrefix : "";
208
- if (sub.length + prefix.length + para.length + 2 > MAX_CHUNK_CHARS && sub.length > 0) {
209
- chunks.push(makeChunk(headings, level, sub.trim()));
210
- sub = para;
211
- isFirst = false;
212
- } else {
213
- sub = sub ? sub + "\n\n" + para : prefix + para;
214
- isFirst = false;
215
- }
216
- }
217
- if (sub.trim()) chunks.push(makeChunk(headings, level, sub.trim()));
218
- continue;
219
- }
220
-
221
- // Section fits. Try to accumulate into the buffer.
222
- const addition = content.length + (bufParts.length > 0 ? 2 : 0);
223
-
224
- if (bufChars + addition <= MAX_CHUNK_CHARS) {
225
- if (bufParts.length === 0) {
226
- bufHeadings = headings;
227
- bufLevel = level;
228
- }
229
- bufParts.push(content);
230
- bufChars += addition;
231
- } else {
232
- flushBuf();
233
- bufParts = [content];
234
- bufHeadings = headings;
235
- bufLevel = level;
236
- bufChars = content.length;
237
- }
238
- }
239
-
240
- flushBuf();
241
- return chunks;
242
- }
243
-
244
- function makeChunk(headings: string[], level: number, content: string): Chunk {
245
- const title = headings[headings.length - 1] ?? "";
246
- return { heading_path: [...headings], heading_level: level, title, content, char_count: content.length };
247
- }
248
90
 
249
91
  // ── Embedding ──────────────────────────────────────────────────────────────
250
92
 
@@ -253,6 +95,7 @@ const EMBEDDING_INITIAL_BACKOFF_MS = 500; // 500ms, 1s, 2s exponential backoff
253
95
 
254
96
  async function embedBatch(texts: string[], apiKey: string): Promise<number[][]> {
255
97
  let lastError: Error | null = null;
98
+ const inputs = texts.map(capEmbeddingInput); // iter-28D Phase 0: cap oversized inputs
256
99
 
257
100
  for (let attempt = 0; attempt < EMBEDDING_MAX_RETRIES; attempt++) {
258
101
  try {
@@ -264,7 +107,7 @@ async function embedBatch(texts: string[], apiKey: string): Promise<number[][]>
264
107
  },
265
108
  body: JSON.stringify({
266
109
  model: OPENAI_MODEL,
267
- input: texts,
110
+ input: inputs,
268
111
  dimensions: EMBEDDING_DIMENSIONS,
269
112
  }),
270
113
  });
@@ -440,6 +283,13 @@ Deno.serve(async (req: Request) => {
440
283
  });
441
284
  }
442
285
 
286
+ const authFail = efAuthGate(
287
+ req.headers.get("Authorization"),
288
+ Deno.env.get("CEREFOX_ACCESS_TOKENS"),
289
+ { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
290
+ );
291
+ if (authFail) return authFail;
292
+
443
293
  if (isVersionRequest(req)) {
444
294
  return versionResponse("cerefox-ingest", {
445
295
  "Content-Type": "application/json",
@@ -464,7 +314,10 @@ Deno.serve(async (req: Request) => {
464
314
  });
465
315
  }
466
316
 
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;
317
+ // metadata: null = "not provided" — the RPC keeps existing metadata on
318
+ // update and uses {} on create (v0.11.1; a `= {}` default here used to wipe
319
+ // a document's tags on every content update that didn't re-pass them).
320
+ 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;
468
321
 
469
322
  // Validate + normalize project_names if provided (full-set destructive form)
470
323
  let project_names: string[] | null = null;
@@ -577,7 +430,7 @@ Deno.serve(async (req: Request) => {
577
430
  return new Response(JSON.stringify({ error: "Content produced no chunks" }), { status: 422, headers });
578
431
  }
579
432
 
580
- const texts = chunks.map((c) => `# ${title.trim()}\n${c.content}`);
433
+ const texts = chunks.map((c) => embeddingInputFor(title.trim(), c));
581
434
  let embeddings: number[][];
582
435
  try {
583
436
  embeddings = await embedBatch(texts, openaiKey);
@@ -610,6 +463,7 @@ Deno.serve(async (req: Request) => {
610
463
  p_source_label: source,
611
464
  p_expected_content_hash: expected_content_hash,
612
465
  p_last_write_wins: last_write_wins,
466
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
613
467
  });
614
468
 
615
469
  if (ingestErr) {
@@ -693,7 +547,7 @@ Deno.serve(async (req: Request) => {
693
547
  }
694
548
 
695
549
  // Prepend document title for contextual enrichment (stored content unchanged)
696
- const texts = chunks.map((c) => `# ${title.trim()}\n${c.content}`);
550
+ const texts = chunks.map((c) => embeddingInputFor(title.trim(), c));
697
551
  let embeddings: number[][];
698
552
  try {
699
553
  embeddings = await embedBatch(texts, openaiKey);
@@ -728,6 +582,7 @@ Deno.serve(async (req: Request) => {
728
582
  p_source_label: source,
729
583
  p_expected_content_hash: expected_content_hash,
730
584
  p_last_write_wins: last_write_wins,
585
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
731
586
  });
732
587
 
733
588
  if (ingestErr) {
@@ -801,7 +656,7 @@ Deno.serve(async (req: Request) => {
801
656
  }
802
657
 
803
658
  // Embed all chunks with title prefix for contextual enrichment (stored content unchanged)
804
- const texts = chunks.map((c) => `# ${title.trim()}\n${c.content}`);
659
+ const texts = chunks.map((c) => embeddingInputFor(title.trim(), c));
805
660
  let embeddings: number[][];
806
661
  try {
807
662
  embeddings = await embedBatch(texts, openaiKey);
@@ -836,6 +691,7 @@ Deno.serve(async (req: Request) => {
836
691
  p_chunks: chunkData,
837
692
  p_author: author,
838
693
  p_author_type: author_type,
694
+ p_content_format: CONTENT_FORMAT_BLIND_STITCH,
839
695
  });
840
696
 
841
697
  if (ingestErr || !ingestResult?.length) {