@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.
- package/AGENT_GUIDE.md +1 -1
- package/AGENT_QUICK_REFERENCE.md +1 -1
- package/dist/bin/cerefox.js +1089 -846
- package/dist/frontend/assets/index-CCkg5PXt.js +125 -0
- package/dist/frontend/assets/index-CCkg5PXt.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-auth/index.ts +134 -0
- package/dist/server-assets/_shared/ef-meta/index.ts +1 -1
- package/dist/server-assets/_shared/embeddings/index.ts +42 -2
- package/dist/server-assets/_shared/ingest/chunker.ts +210 -0
- package/dist/server-assets/_shared/ingest/index.ts +32 -0
- package/dist/server-assets/_shared/ingest/pipeline-helpers.ts +135 -0
- package/dist/server-assets/_shared/mcp-auth/index.ts +352 -0
- package/dist/server-assets/_shared/mcp-tools/_chunker.ts +16 -170
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +2 -2
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +17 -5
- package/dist/server-assets/db/migrations/0012_content_format.sql +20 -0
- package/dist/server-assets/db/rpcs.sql +88 -13
- package/dist/server-assets/db/schema.sql +7 -1
- package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +29 -173
- package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +54 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/oauth.ts +121 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +8 -0
- package/dist/server-assets/supabase/functions/cerefox-search/index.ts +11 -1
- package/docs/guides/access-paths.md +81 -30
- package/docs/guides/cli.md +32 -3
- package/docs/guides/configuration.md +4 -1
- package/docs/guides/connect-agents.md +102 -54
- package/docs/guides/content-format.md +55 -0
- package/docs/guides/migration-1.0.md +87 -0
- package/docs/guides/ops-scripts.md +1 -1
- package/docs/guides/quickstart.md +20 -0
- package/docs/guides/setup-supabase.md +154 -13
- package/docs/guides/upgrading.md +4 -3
- package/package.json +1 -1
- package/dist/frontend/assets/index-ojNhWSxm.js +0 -125
- 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 {
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) {
|