@cerefox/memory 1.0.5 → 1.1.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.
@@ -0,0 +1,59 @@
1
+ -- 0014_document_relations.sql — typed edges between documents (iteration 29).
2
+ --
3
+ -- Adds the relation graph on top of the existing document model:
4
+ -- * cerefox_document_relations — typed, directed edges (source → target)
5
+ -- * cerefox_documents.lifecycle_status — 'active' | 'superseded' | 'stale' | 'archived'
6
+ --
7
+ -- Design: docs/research/document-relations-and-semantic-graph.md §2.2, §3.
8
+ -- Idempotent: safe to re-run.
9
+
10
+ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
11
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12
+ source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
13
+ target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
14
+ -- Free-text by design: agents define new types without a migration. The
15
+ -- type dictionary (in the RPCs) gives known types behaviour; unknown types
16
+ -- are stored and returned, just without special handling.
17
+ rel_type TEXT NOT NULL,
18
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
19
+ author TEXT NOT NULL DEFAULT 'unknown',
20
+ author_type TEXT NOT NULL DEFAULT 'agent',
21
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
22
+ -- One edge of a given type per ordered pair; different types may coexist.
23
+ UNIQUE (source_id, target_id, rel_type),
24
+ -- A document relating to itself is always a mistake, and self-edges would
25
+ -- make traversal loop.
26
+ CONSTRAINT cerefox_relations_no_self_edge CHECK (source_id <> target_id)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_source ON cerefox_document_relations(source_id);
30
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_target ON cerefox_document_relations(target_id);
31
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_type ON cerefox_document_relations(rel_type);
32
+
33
+ -- Lifecycle status: how a document stands relative to the rest of the graph.
34
+ -- Distinct from review_status (editorial state) and deleted_at (existence).
35
+ ALTER TABLE cerefox_documents
36
+ ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'active';
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_cerefox_docs_lifecycle
39
+ ON cerefox_documents(lifecycle_status)
40
+ WHERE lifecycle_status <> 'active';
41
+
42
+ -- Data-API grants for the new table (migration 0013 / #26: privileges are
43
+ -- explicit now, and a new table gets none by default).
44
+ DO $$
45
+ BEGIN
46
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
47
+ GRANT SELECT, INSERT, UPDATE, DELETE
48
+ ON TABLE public.cerefox_document_relations TO service_role;
49
+ END IF;
50
+ END
51
+ $$;
52
+
53
+ -- Relation writes are auditable operations; widen the audit-log constraint.
54
+ ALTER TABLE cerefox_audit_log DROP CONSTRAINT IF EXISTS cerefox_audit_log_operation_check;
55
+ ALTER TABLE cerefox_audit_log ADD CONSTRAINT cerefox_audit_log_operation_check CHECK (
56
+ operation IN ('create', 'update-content', 'update-metadata', 'delete',
57
+ 'status-change', 'archive', 'unarchive', 'restore',
58
+ 'relation-set', 'relation-delete')
59
+ );
@@ -99,10 +99,13 @@ CREATE OR REPLACE FUNCTION cerefox_hybrid_search(
99
99
  p_query_text TEXT,
100
100
  p_query_embedding VECTOR(768),
101
101
  p_match_count INT DEFAULT 10,
102
- p_alpha FLOAT DEFAULT 0.7,
102
+ p_alpha FLOAT DEFAULT NULL,
103
103
  p_use_upgrade BOOLEAN DEFAULT FALSE,
104
104
  p_project_id UUID DEFAULT NULL,
105
- p_min_score FLOAT DEFAULT 0.0,
105
+ -- NULL means "not specified by the caller" (#133): resolve from
106
+ -- cerefox_config, else the built-in default. Callers that pass a value
107
+ -- still win — the chain is per-call > client .env > DB config > default.
108
+ p_min_score FLOAT DEFAULT NULL,
106
109
  p_metadata_filter JSONB DEFAULT NULL,
107
110
  -- 28I follow-up (v1.0.4): in OR-fallback mode, the unconditional FTS pass
108
111
  -- requires at least this fraction of the query's meaningful (non-stopword,
@@ -110,7 +113,7 @@ CREATE OR REPLACE FUNCTION cerefox_hybrid_search(
110
113
  -- meant 100% of terms — the pass this gate generalizes. Chunks below the
111
114
  -- bar can still pass via the vector threshold, else they are
112
115
  -- below-confidence material. 0 restores the pre-gate OR behavior.
113
- p_min_term_coverage FLOAT DEFAULT 0.5
116
+ p_min_term_coverage FLOAT DEFAULT NULL
114
117
  )
115
118
  RETURNS TABLE (
116
119
  chunk_id UUID,
@@ -164,6 +167,13 @@ DECLARE
164
167
  seen_tokens TEXT[] := '{}';
165
168
  total_tokens INT;
166
169
  candidate_count INT := p_match_count * 5;
170
+ -- #133 resolution: caller value, else deployment config, else built-in.
171
+ v_min_score FLOAT := COALESCE(p_min_score,
172
+ cerefox_config_float('min_search_score', 0.5));
173
+ v_alpha FLOAT := COALESCE(p_alpha,
174
+ cerefox_config_float('search_alpha', 0.7));
175
+ v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
176
+ cerefox_config_float('min_term_coverage', 0.5));
167
177
  BEGIN
168
178
  -- Build the OR-composed query: plainto each whitespace token (so tokens get
169
179
  -- the same normalization/stemming as the AND path), skip stopword-only
@@ -217,7 +227,7 @@ BEGIN
217
227
  WHEN and_matches OR total_tokens = 0 THEN TRUE
218
228
  ELSE (SELECT COUNT(*) FROM unnest(tok_queries) tq
219
229
  WHERE c.fts @@ tq)::FLOAT
220
- >= p_min_term_coverage * total_tokens
230
+ >= v_min_coverage * total_tokens
221
231
  END AS coverage_ok
222
232
  FROM cerefox_chunks c
223
233
  JOIN cerefox_documents d ON c.document_id = d.id
@@ -261,8 +271,8 @@ BEGIN
261
271
  combined AS (
262
272
  SELECT
263
273
  COALESCE(f.id, v.id) AS id,
264
- ( p_alpha * COALESCE(v.vec_score, 0.0) +
265
- (1.0 - p_alpha) * COALESCE(f.fts_score, 0.0)
274
+ ( v_alpha * COALESCE(v.vec_score, 0.0) +
275
+ (1.0 - v_alpha) * COALESCE(f.fts_score, 0.0)
266
276
  ) AS score,
267
277
  COALESCE(v.vec_score, 0.0) AS vec_score,
268
278
  -- TRUE when the chunk matched the @@ FTS operator WITH enough
@@ -282,10 +292,24 @@ BEGIN
282
292
  -- results (no FTS match) are filtered by the cosine threshold.
283
293
  flagged AS (
284
294
  SELECT *,
285
- (combined.has_fts_match OR combined.vec_score >= p_min_score) AS passes
295
+ (combined.has_fts_match OR combined.vec_score >= v_min_score) AS passes
286
296
  FROM combined
287
297
  ),
288
- any_pass AS (SELECT bool_or(fl.passes) AS ok FROM flagged fl)
298
+ any_pass AS (SELECT bool_or(fl.passes) AS ok FROM flagged fl),
299
+ -- v1.0.6: in the below-confidence fallback, rank each candidate WITHIN
300
+ -- its parent document so we can return one chunk per document. The cap
301
+ -- used to apply to chunks, so when a document owned several of the top
302
+ -- chunks the caller saw fewer than 3 results after document-level
303
+ -- de-duplication (cerefox_search_docs, CLI and web) — the count varied
304
+ -- with corpus shape rather than with the cap.
305
+ ranked AS (
306
+ SELECT fl.*,
307
+ ROW_NUMBER() OVER (
308
+ PARTITION BY ch.document_id ORDER BY fl.score DESC
309
+ ) AS rank_in_doc
310
+ FROM flagged fl
311
+ JOIN cerefox_chunks ch ON ch.id = fl.id
312
+ )
289
313
  SELECT
290
314
  c.id AS chunk_id,
291
315
  c.document_id,
@@ -311,11 +335,13 @@ BEGIN
311
335
  -- memory layer can produce. "Truly nothing" (no candidates at all)
312
336
  -- still returns zero rows.
313
337
  NOT ap.ok AS below_confidence
314
- FROM flagged cm
338
+ FROM ranked cm
315
339
  CROSS JOIN any_pass ap
316
340
  JOIN cerefox_chunks c ON c.id = cm.id
317
341
  JOIN cerefox_documents d ON c.document_id = d.id
318
- WHERE cm.passes OR NOT ap.ok
342
+ -- Normal results are unchanged; fallback rows are restricted to each
343
+ -- document's best chunk so the cap below counts documents, not chunks.
344
+ WHERE cm.passes OR (NOT ap.ok AND cm.rank_in_doc = 1)
319
345
  ORDER BY cm.score DESC
320
346
  LIMIT (SELECT CASE WHEN ap2.ok THEN p_match_count
321
347
  ELSE LEAST(p_match_count, 3) END
@@ -333,7 +359,7 @@ CREATE OR REPLACE FUNCTION cerefox_fts_search(
333
359
  p_metadata_filter JSONB DEFAULT NULL,
334
360
  -- v1.0.4: see cerefox_hybrid_search. In OR-fallback mode results must
335
361
  -- match at least this fraction of the query's meaningful terms.
336
- p_min_term_coverage FLOAT DEFAULT 0.5
362
+ p_min_term_coverage FLOAT DEFAULT NULL
337
363
  )
338
364
  RETURNS TABLE (
339
365
  chunk_id UUID,
@@ -367,6 +393,8 @@ DECLARE
367
393
  tok_queries tsquery[] := '{}';
368
394
  seen_tokens TEXT[] := '{}';
369
395
  total_tokens INT;
396
+ v_min_coverage FLOAT := COALESCE(p_min_term_coverage,
397
+ cerefox_config_float('min_term_coverage', 0.5));
370
398
  BEGIN
371
399
  FOR tok IN SELECT unnest(regexp_split_to_array(trim(p_query_text), '\s+')) LOOP
372
400
  tok_q := plainto_tsquery('english', tok);
@@ -427,7 +455,7 @@ BEGIN
427
455
  -- returns only chunks matching enough of the query's terms.
428
456
  AND (and_matches OR total_tokens = 0
429
457
  OR (SELECT COUNT(*) FROM unnest(tok_queries) tq
430
- WHERE c.fts @@ tq)::FLOAT >= p_min_term_coverage * total_tokens)
458
+ WHERE c.fts @@ tq)::FLOAT >= v_min_coverage * total_tokens)
431
459
  AND (p_project_id IS NULL OR EXISTS (
432
460
  SELECT 1 FROM cerefox_document_projects dp
433
461
  WHERE dp.document_id = d.id AND dp.project_id = p_project_id
@@ -733,13 +761,15 @@ CREATE OR REPLACE FUNCTION cerefox_search_docs(
733
761
  p_query_text TEXT,
734
762
  p_query_embedding VECTOR(768),
735
763
  p_match_count INT DEFAULT 5,
736
- p_alpha FLOAT DEFAULT 0.7,
764
+ p_alpha FLOAT DEFAULT NULL,
737
765
  p_project_id UUID DEFAULT NULL,
738
- p_min_score FLOAT DEFAULT 0.0,
766
+ p_min_score FLOAT DEFAULT NULL,
739
767
  p_small_to_big_threshold INT DEFAULT 20000,
740
768
  p_context_window INT DEFAULT 1,
741
769
  p_metadata_filter JSONB DEFAULT NULL,
742
- p_min_term_coverage FLOAT DEFAULT 0.5
770
+ -- NULL flows through to cerefox_hybrid_search, which resolves the
771
+ -- caller > cerefox_config > built-in chain in one place (#133).
772
+ p_min_term_coverage FLOAT DEFAULT NULL
743
773
  )
744
774
  RETURNS TABLE (
745
775
  document_id UUID,
@@ -1724,6 +1754,239 @@ BEGIN
1724
1754
  END;
1725
1755
  $$;
1726
1756
 
1757
+ -- ══ Document relations (iteration 29) ═════════════════════════════════════════
1758
+ -- Typed edges between documents. Design:
1759
+ -- docs/research/document-relations-and-semantic-graph.md
1760
+ --
1761
+ -- Type dictionary: rel_type is free text (any string is accepted and returned),
1762
+ -- but a few KNOWN types carry behaviour. Keeping the dictionary in one place
1763
+ -- here — rather than scattered CASE expressions — is what lets the set/delete
1764
+ -- RPCs stay symmetric with each other.
1765
+ -- symmetric both directions are written/removed together
1766
+ -- supersedes target becomes 'superseded'
1767
+ -- contradicts both documents become 'stale'
1768
+ CREATE OR REPLACE FUNCTION cerefox_relation_is_symmetric(p_rel_type TEXT)
1769
+ RETURNS BOOLEAN
1770
+ LANGUAGE sql
1771
+ IMMUTABLE
1772
+ SET search_path = public, pg_catalog
1773
+ AS $$
1774
+ SELECT p_rel_type IN ('related_to', 'contradicts', 'duplicates');
1775
+ $$;
1776
+
1777
+ -- Create (or update) a relation. Symmetric types write both directions in one
1778
+ -- transaction, so a half-written pair is impossible.
1779
+ CREATE OR REPLACE FUNCTION cerefox_set_relation(
1780
+ p_source_id UUID,
1781
+ p_target_id UUID,
1782
+ p_rel_type TEXT,
1783
+ p_author TEXT DEFAULT 'unknown',
1784
+ p_author_type TEXT DEFAULT 'agent',
1785
+ p_metadata JSONB DEFAULT '{}'::jsonb
1786
+ )
1787
+ RETURNS TABLE (
1788
+ relation_id UUID,
1789
+ source_id UUID,
1790
+ target_id UUID,
1791
+ rel_type TEXT,
1792
+ is_symmetric BOOLEAN
1793
+ )
1794
+ LANGUAGE plpgsql
1795
+ SECURITY DEFINER
1796
+ SET search_path = public, pg_catalog
1797
+ AS $$
1798
+ -- The OUT columns (source_id, target_id, rel_type) share names with the
1799
+ -- table's columns; inside the INSERT/ON CONFLICT below the COLUMN is meant.
1800
+ #variable_conflict use_column
1801
+ DECLARE
1802
+ v_symmetric BOOLEAN := cerefox_relation_is_symmetric(p_rel_type);
1803
+ v_id UUID;
1804
+ BEGIN
1805
+ IF p_source_id = p_target_id THEN
1806
+ RAISE EXCEPTION 'A document cannot relate to itself (%).', p_source_id
1807
+ USING ERRCODE = '22023';
1808
+ END IF;
1809
+ IF p_rel_type IS NULL OR btrim(p_rel_type) = '' THEN
1810
+ RAISE EXCEPTION 'rel_type is required.' USING ERRCODE = '22023';
1811
+ END IF;
1812
+ -- Explicit existence checks give a clear error instead of an FK violation.
1813
+ IF NOT EXISTS (SELECT 1 FROM cerefox_documents d
1814
+ WHERE d.id = p_source_id AND d.deleted_at IS NULL) THEN
1815
+ RAISE EXCEPTION 'Source document % not found (or deleted).', p_source_id
1816
+ USING ERRCODE = '22023';
1817
+ END IF;
1818
+ IF NOT EXISTS (SELECT 1 FROM cerefox_documents d
1819
+ WHERE d.id = p_target_id AND d.deleted_at IS NULL) THEN
1820
+ RAISE EXCEPTION 'Target document % not found (or deleted).', p_target_id
1821
+ USING ERRCODE = '22023';
1822
+ END IF;
1823
+
1824
+ INSERT INTO cerefox_document_relations AS r
1825
+ (source_id, target_id, rel_type, metadata, author, author_type)
1826
+ VALUES (p_source_id, p_target_id, btrim(p_rel_type), COALESCE(p_metadata, '{}'::jsonb),
1827
+ p_author, p_author_type)
1828
+ ON CONFLICT (source_id, target_id, rel_type) DO UPDATE
1829
+ SET metadata = EXCLUDED.metadata,
1830
+ author = EXCLUDED.author,
1831
+ author_type = EXCLUDED.author_type
1832
+ RETURNING r.id INTO v_id;
1833
+
1834
+ IF v_symmetric THEN
1835
+ INSERT INTO cerefox_document_relations
1836
+ (source_id, target_id, rel_type, metadata, author, author_type)
1837
+ VALUES (p_target_id, p_source_id, btrim(p_rel_type),
1838
+ COALESCE(p_metadata, '{}'::jsonb), p_author, p_author_type)
1839
+ ON CONFLICT (source_id, target_id, rel_type) DO UPDATE
1840
+ SET metadata = EXCLUDED.metadata,
1841
+ author = EXCLUDED.author,
1842
+ author_type = EXCLUDED.author_type;
1843
+ END IF;
1844
+
1845
+ -- Lifecycle side effects (type dictionary).
1846
+ IF btrim(p_rel_type) = 'supersedes' THEN
1847
+ UPDATE cerefox_documents SET lifecycle_status = 'superseded'
1848
+ WHERE id = p_target_id;
1849
+ ELSIF btrim(p_rel_type) = 'contradicts' THEN
1850
+ UPDATE cerefox_documents SET lifecycle_status = 'stale'
1851
+ WHERE id IN (p_source_id, p_target_id);
1852
+ END IF;
1853
+
1854
+ INSERT INTO cerefox_audit_log (document_id, operation, author, author_type, description)
1855
+ VALUES (p_source_id, 'relation-set', p_author, p_author_type,
1856
+ format('%s → %s (%s)', p_source_id, p_target_id, btrim(p_rel_type)));
1857
+
1858
+ RETURN QUERY SELECT v_id, p_source_id, p_target_id, btrim(p_rel_type), v_symmetric;
1859
+ END;
1860
+ $$;
1861
+
1862
+ -- Remove a relation. Symmetric types remove both directions. Lifecycle side
1863
+ -- effects are NOT auto-reverted: a document marked superseded may have been
1864
+ -- superseded by something else too, and guessing wrong is worse than leaving
1865
+ -- the operator to set it explicitly.
1866
+ CREATE OR REPLACE FUNCTION cerefox_delete_relation(
1867
+ p_source_id UUID,
1868
+ p_target_id UUID,
1869
+ p_rel_type TEXT,
1870
+ p_author TEXT DEFAULT 'unknown',
1871
+ p_author_type TEXT DEFAULT 'agent'
1872
+ )
1873
+ RETURNS INT
1874
+ LANGUAGE plpgsql
1875
+ SECURITY DEFINER
1876
+ SET search_path = public, pg_catalog
1877
+ AS $$
1878
+ DECLARE
1879
+ v_deleted INT := 0;
1880
+ v_n INT;
1881
+ BEGIN
1882
+ DELETE FROM cerefox_document_relations
1883
+ WHERE source_id = p_source_id AND target_id = p_target_id
1884
+ AND rel_type = btrim(p_rel_type);
1885
+ GET DIAGNOSTICS v_n = ROW_COUNT; v_deleted := v_deleted + v_n;
1886
+
1887
+ IF cerefox_relation_is_symmetric(p_rel_type) THEN
1888
+ DELETE FROM cerefox_document_relations
1889
+ WHERE source_id = p_target_id AND target_id = p_source_id
1890
+ AND rel_type = btrim(p_rel_type);
1891
+ GET DIAGNOSTICS v_n = ROW_COUNT; v_deleted := v_deleted + v_n;
1892
+ END IF;
1893
+
1894
+ IF v_deleted > 0 THEN
1895
+ INSERT INTO cerefox_audit_log (document_id, operation, author, author_type, description)
1896
+ VALUES (p_source_id, 'relation-delete', p_author, p_author_type,
1897
+ format('%s → %s (%s)', p_source_id, p_target_id, btrim(p_rel_type)));
1898
+ END IF;
1899
+ RETURN v_deleted;
1900
+ END;
1901
+ $$;
1902
+
1903
+ -- All edges touching a document, in both directions. `direction` tells the
1904
+ -- caller which way each edge points relative to the document asked about.
1905
+ CREATE OR REPLACE FUNCTION cerefox_get_relations(p_document_id UUID)
1906
+ RETURNS TABLE (
1907
+ relation_id UUID,
1908
+ direction TEXT,
1909
+ rel_type TEXT,
1910
+ other_id UUID,
1911
+ other_title TEXT,
1912
+ other_lifecycle TEXT,
1913
+ metadata JSONB,
1914
+ author TEXT,
1915
+ created_at TIMESTAMPTZ
1916
+ )
1917
+ LANGUAGE sql
1918
+ SECURITY DEFINER
1919
+ STABLE
1920
+ SET search_path = public, pg_catalog
1921
+ AS $$
1922
+ SELECT r.id, 'outbound'::TEXT, r.rel_type, r.target_id, d.title,
1923
+ d.lifecycle_status, r.metadata, r.author, r.created_at
1924
+ FROM cerefox_document_relations r
1925
+ JOIN cerefox_documents d ON d.id = r.target_id
1926
+ WHERE r.source_id = p_document_id AND d.deleted_at IS NULL
1927
+ UNION ALL
1928
+ SELECT r.id, 'inbound'::TEXT, r.rel_type, r.source_id, d.title,
1929
+ d.lifecycle_status, r.metadata, r.author, r.created_at
1930
+ FROM cerefox_document_relations r
1931
+ JOIN cerefox_documents d ON d.id = r.source_id
1932
+ WHERE r.target_id = p_document_id AND d.deleted_at IS NULL
1933
+ ORDER BY 3, 9 DESC;
1934
+ $$;
1935
+
1936
+ -- Walk the graph from a document along ONE relation type. Depth > 1 follows
1937
+ -- chains (meaningful for e.g. `follows` / `reply_to`); a visited-set stops
1938
+ -- cycles, which free-text types make possible.
1939
+ CREATE OR REPLACE FUNCTION cerefox_get_neighbors(
1940
+ p_document_id UUID,
1941
+ p_rel_type TEXT,
1942
+ p_depth INT DEFAULT 1,
1943
+ p_from_time TIMESTAMPTZ DEFAULT NULL,
1944
+ p_to_time TIMESTAMPTZ DEFAULT NULL,
1945
+ p_limit INT DEFAULT 50
1946
+ )
1947
+ RETURNS TABLE (
1948
+ document_id UUID,
1949
+ title TEXT,
1950
+ lifecycle_status TEXT,
1951
+ depth INT,
1952
+ direction TEXT,
1953
+ doc_created_at TIMESTAMPTZ
1954
+ )
1955
+ LANGUAGE sql
1956
+ SECURITY DEFINER
1957
+ STABLE
1958
+ SET search_path = public, pg_catalog
1959
+ AS $$
1960
+ WITH RECURSIVE walk AS (
1961
+ SELECT p_document_id AS id, 0 AS depth, 'self'::TEXT AS direction,
1962
+ ARRAY[p_document_id] AS seen
1963
+ UNION ALL
1964
+ SELECT nxt.id, w.depth + 1, nxt.direction, w.seen || nxt.id
1965
+ FROM walk w
1966
+ CROSS JOIN LATERAL (
1967
+ SELECT r.target_id AS id, 'outbound'::TEXT AS direction
1968
+ FROM cerefox_document_relations r
1969
+ WHERE r.source_id = w.id AND r.rel_type = btrim(p_rel_type)
1970
+ UNION ALL
1971
+ SELECT r.source_id, 'inbound'::TEXT
1972
+ FROM cerefox_document_relations r
1973
+ WHERE r.target_id = w.id AND r.rel_type = btrim(p_rel_type)
1974
+ ) nxt
1975
+ WHERE w.depth < GREATEST(p_depth, 1)
1976
+ AND NOT (nxt.id = ANY(w.seen)) -- cycle guard
1977
+ )
1978
+ SELECT DISTINCT ON (w.id)
1979
+ w.id, d.title, d.lifecycle_status, w.depth, w.direction, d.created_at
1980
+ FROM walk w
1981
+ JOIN cerefox_documents d ON d.id = w.id
1982
+ WHERE w.depth > 0
1983
+ AND d.deleted_at IS NULL
1984
+ AND (p_from_time IS NULL OR d.created_at >= p_from_time)
1985
+ AND (p_to_time IS NULL OR d.created_at <= p_to_time)
1986
+ ORDER BY w.id, w.depth
1987
+ LIMIT GREATEST(p_limit, 1);
1988
+ $$;
1989
+
1727
1990
  -- ── cerefox_get_config / cerefox_set_config ──────────────────────────────────
1728
1991
  -- Read/write key-value config from cerefox_config table.
1729
1992
 
@@ -1737,6 +2000,31 @@ AS $$
1737
2000
  SELECT value FROM cerefox_config WHERE key = p_key;
1738
2001
  $$;
1739
2002
 
2003
+ -- Numeric config reader with a fallback (v1.1.0, #133). Returns p_fallback
2004
+ -- when the key is unset or unparseable, so a malformed row can never break
2005
+ -- search — it just reverts to the built-in default.
2006
+ CREATE OR REPLACE FUNCTION cerefox_config_float(p_key TEXT, p_fallback FLOAT)
2007
+ RETURNS FLOAT
2008
+ LANGUAGE plpgsql
2009
+ SECURITY DEFINER
2010
+ STABLE
2011
+ SET search_path = public, pg_catalog
2012
+ AS $$
2013
+ DECLARE
2014
+ v_raw TEXT;
2015
+ v_num FLOAT;
2016
+ BEGIN
2017
+ SELECT value INTO v_raw FROM cerefox_config WHERE key = p_key;
2018
+ IF v_raw IS NULL OR v_raw = '' THEN RETURN p_fallback; END IF;
2019
+ BEGIN
2020
+ v_num := v_raw::FLOAT;
2021
+ EXCEPTION WHEN others THEN
2022
+ RETURN p_fallback;
2023
+ END;
2024
+ RETURN v_num;
2025
+ END;
2026
+ $$;
2027
+
1740
2028
  CREATE OR REPLACE FUNCTION cerefox_set_config(p_key TEXT, p_value TEXT)
1741
2029
  RETURNS VOID
1742
2030
  LANGUAGE plpgsql
@@ -1744,7 +2032,13 @@ SECURITY DEFINER
1744
2032
  SET search_path = public, pg_catalog
1745
2033
  AS $$
1746
2034
  DECLARE
1747
- v_allowed TEXT[] := ARRAY['usage_tracking_enabled', 'require_requestor_identity', 'requestor_identity_format'];
2035
+ -- Retrieval tunables (#133) join the governance keys: setting one here
2036
+ -- governs EVERY access path (CLI, local + remote MCP, Edge Functions, web),
2037
+ -- because they all resolve through these RPCs.
2038
+ v_allowed TEXT[] := ARRAY[
2039
+ 'usage_tracking_enabled', 'require_requestor_identity', 'requestor_identity_format',
2040
+ 'min_search_score', 'min_term_coverage', 'search_alpha'
2041
+ ];
1748
2042
  BEGIN
1749
2043
  IF NOT (p_key = ANY(v_allowed)) THEN
1750
2044
  RAISE EXCEPTION 'Unknown config key: %. Allowed keys: %', p_key, v_allowed;
@@ -1938,7 +2232,7 @@ SET search_path = public, pg_catalog
1938
2232
  AS $$
1939
2233
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
1940
2234
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
1941
- SELECT '0.9.1'::TEXT;
2235
+ SELECT '0.10.0'::TEXT;
1942
2236
  $$;
1943
2237
 
1944
2238
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -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.9.1
8
+ -- @version: 0.10.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 —
@@ -49,6 +49,11 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
49
49
  -- 'pending_review' = modified by agent, not yet reviewed.
50
50
  -- Content is searchable in both states.
51
51
  review_status TEXT NOT NULL DEFAULT 'approved',
52
+ -- lifecycle_status: where this document stands relative to the graph —
53
+ -- 'active' | 'superseded' | 'stale' | 'archived'. Distinct from
54
+ -- review_status (editorial state) and deleted_at (existence). Maintained by
55
+ -- the relation RPCs (e.g. `supersedes` marks its target superseded).
56
+ lifecycle_status TEXT NOT NULL DEFAULT 'active',
52
57
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
53
58
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
54
59
  -- Soft delete: NULL = active, timestamp = deleted (recoverable).
@@ -118,7 +123,9 @@ CREATE TABLE IF NOT EXISTS cerefox_audit_log (
118
123
 
119
124
  CONSTRAINT cerefox_audit_log_operation_check CHECK (
120
125
  operation IN ('create', 'update-content', 'update-metadata', 'delete',
121
- 'status-change', 'archive', 'unarchive', 'restore')
126
+ 'status-change', 'archive', 'unarchive', 'restore',
127
+ -- iteration 29: graph edges are auditable writes too
128
+ 'relation-set', 'relation-delete')
122
129
  ),
123
130
  CONSTRAINT cerefox_audit_log_author_type_check CHECK (author_type IN ('user', 'agent'))
124
131
  );
@@ -132,6 +139,32 @@ CREATE TABLE IF NOT EXISTS cerefox_document_projects (
132
139
  PRIMARY KEY (document_id, project_id)
133
140
  );
134
141
 
142
+ -- ── Document relations (iteration 29) ─────────────────────────────────────────
143
+ -- Typed, directed edges between documents. rel_type is free text by design so
144
+ -- agents can define new types without a migration; the type dictionary lives in
145
+ -- the RPCs and gives KNOWN types behaviour (symmetry, lifecycle side effects).
146
+ -- Design: docs/research/document-relations-and-semantic-graph.md §2.2.
147
+
148
+ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
149
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
150
+ source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
151
+ target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
152
+ rel_type TEXT NOT NULL,
153
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
154
+ author TEXT NOT NULL DEFAULT 'unknown',
155
+ author_type TEXT NOT NULL DEFAULT 'agent',
156
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
157
+ UNIQUE (source_id, target_id, rel_type),
158
+ CONSTRAINT cerefox_relations_no_self_edge CHECK (source_id <> target_id)
159
+ );
160
+
161
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_source ON cerefox_document_relations(source_id);
162
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_target ON cerefox_document_relations(target_id);
163
+ CREATE INDEX IF NOT EXISTS idx_cerefox_relations_type ON cerefox_document_relations(rel_type);
164
+ CREATE INDEX IF NOT EXISTS idx_cerefox_docs_lifecycle
165
+ ON cerefox_documents(lifecycle_status)
166
+ WHERE lifecycle_status <> 'active';
167
+
135
168
  -- ── Chunks ────────────────────────────────────────────────────────────────────
136
169
  -- One row per chunk of a document. Embeddings and FTS live here.
137
170
  --
@@ -117,18 +117,36 @@ This handles intermittent OpenAI API errors (500s) that would otherwise cause se
117
117
 
118
118
  ## Retrieval
119
119
 
120
+ > **Deployment-wide defaults (v1.1.0+).** `min_search_score`,
121
+ > `min_term_coverage`, and `search_alpha` can also be set **once, in the
122
+ > database**, and every access path obeys — CLI, local and remote MCP, Edge
123
+ > Functions, web — because they all resolve through the same search RPCs:
124
+ >
125
+ > ```bash
126
+ > cerefox config set min_search_score 0.6
127
+ > cerefox config list # shows every settable key
128
+ > ```
129
+ >
130
+ > Resolution order, highest first: **per-call argument** (`--min-score`, the
131
+ > `min_score` MCP parameter) → **client env var** below → **`cerefox_config`**
132
+ > → built-in default. A malformed stored value is ignored in favour of the
133
+ > built-in, so a bad setting can never break search.
134
+
120
135
  > **Which paths read these?** Client-side tunables in this section are read
121
136
  > from *your* `.env` by the **CLI**, the **local MCP server**, and `cerefox
122
- > web`. The **remote MCP / Edge Function path** runs on Supabase and does not
123
- > see your `.env` — it uses the server defaults unless the caller passes the
124
- > per-call parameter (e.g. `min_score`, `min_term_coverage` on
125
- > `cerefox_search`). Setting them as Supabase **Function secrets** may also
126
- > work but is not a tested configuration.
137
+ > web`. Since **v1.0.6** the same values are also honored on the **remote MCP /
138
+ > Edge Function path** when set as Supabase **Function secrets**
139
+ > (`supabase secrets set CEREFOX_MIN_SEARCH_SCORE=0.55 --project-ref <ref>`) —
140
+ > the shared helpers read `Deno.env` as well as `process.env`. Per-call
141
+ > parameters (`min_score`, `min_term_coverage`, `alpha` on `cerefox_search`)
142
+ > override both. A single setting that governs every path without secrets is
143
+ > tracked as issue #133 (DB-backed config).
127
144
 
128
145
  | Variable | Default | Description |
129
146
  |----------|---------|-------------|
130
147
  | `CEREFOX_MAX_RESPONSE_BYTES` | `200000` | Maximum bytes in a single search response (local MCP path). See explanation below. |
131
148
  | `CEREFOX_MIN_SEARCH_SCORE` | `0.50` (`0.60` with the local embedder) | Minimum cosine similarity for hybrid and semantic search results (0.0–1.0). The default is embedder-aware: nomic scores unrelated text higher than OpenAI, so `CEREFOX_EMBEDDER=local` raises the floor to 0.60. In **hybrid search**, chunks that matched the FTS keyword operator (`@@`) always pass through regardless of their vector score — the threshold only filters vector-only results. In **semantic search**, all results are filtered. The pure **FTS search** mode is unaffected. Increase for stricter precision; decrease for wider recall. |
149
+ | `CEREFOX_SEARCH_ALPHA` | `0.7` | Hybrid fusion weight (0.0–1.0): `1.0` = pure semantic, `0.0` = pure keyword. Applies to hybrid and document-mode search. Per-call override: `cerefox search --alpha`, or the `alpha` parameter on the `cerefox_search` MCP tool. |
132
150
  | `CEREFOX_MIN_TERM_COVERAGE` | *(unset — server default `0.5`)* | Confidence bar for the keyword OR-fallback (v1.0.4, schema ≥ 0.9.1): when a strict all-terms match fails and search relaxes to any-term matching, a result counts as a confident hit only if it matches at least this fraction of the query's meaningful terms; weaker matches surface as below-confidence candidates. `0` restores pre-gate behavior (any matching term passes); `1` requires every term. Per-call override: `cerefox search --min-term-coverage`. Leave unset against pre-0.9.1 servers. |
133
151
  | `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. |
134
152
  | `CEREFOX_MODELS_DIR` | `~/.cerefox/models` (in-container: inside the data volume) | Where the local embedder caches downloaded model weights (Cerefox Local; `CEREFOX_EMBEDDER=local`). |
@@ -143,7 +143,9 @@ cerefox-local start # start a stopped container
143
143
  cerefox-local stop # stop it (your data persists in the Docker volume)
144
144
  cerefox-local restart
145
145
  cerefox-local logs -f # follow the logs
146
- cerefox-local upgrade # pull the latest image + recreate (keeps data + OPENAI key)
146
+ cerefox-local upgrade # upgrade to the newest release + recreate (keeps data + OPENAI key)
147
+ cerefox-local upgrade v1.2.3 # pin an exact version (also how you downgrade)
148
+ cerefox-local upgrade --latest # follow the moving :latest tag from now on
147
149
  cerefox-local uninstall # remove the container, KEEP the data volume
148
150
  cerefox-local uninstall --purge # remove the container AND delete the data volume
149
151
  ```
@@ -10,7 +10,7 @@ to re-run.
10
10
  |---|---|
11
11
  | **Installer / npm** (end user, no repo clone) | `cerefox self-update` (or re-run the [installer](quickstart.md#1-install), or `bun/npm update -g @cerefox/memory`). Then `cerefox server deploy` **if the release notes flag a server-side change**. `cerefox doctor` verifies. |
12
12
  | **Source checkout** (`git clone`, contributor) | `git pull`, then `cerefox server deploy` (or the lower-level `bun scripts/db_*.ts` + `npx supabase functions deploy`). Rebuild the SPA if you run `cerefox web` from source. |
13
- | **Local / self-hosted (Docker, World B)** | `cerefox-local upgrade` — pulls the new image and recreates the container (data persists in the volume; OpenAI key + tuning overrides preserved). **No separate `server deploy`/`reindex`**: the CLI, web, PostgREST, and schema all ship together in one versioned image, so they can't drift. See [`setup-local.md`](setup-local.md). |
13
+ | **Local / self-hosted (Docker, World B)** | `cerefox-local upgrade` — resolves the newest release, pulls it, and recreates the container (`upgrade <tag>` pins an exact version; `upgrade --latest` follows the moving tag). Data persists in the volume; OpenAI key + tuning overrides preserved. **No separate `server deploy`/`reindex`**: the CLI, web, PostgREST, and schema all ship together in one versioned image, so they can't drift. See [`setup-local.md`](setup-local.md). |
14
14
 
15
15
  > **On an old pre-installer clone (0.1.x)?** The cleanest upgrade is to stop
16
16
  > running from the repo and install the package: follow the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.0.5",
3
+ "version": "1.1.0-beta.1",
4
4
  "description": "Cerefox — 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",
@@ -43,7 +43,7 @@
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@supabase/supabase-js": "^2.45.0",
45
45
  "cli-progress": "^3.12.0",
46
- "commander": "^12.1.0",
46
+ "commander": "^14.0.3",
47
47
  "hono": "^4.12.34",
48
48
  "mammoth": "^1.9.0",
49
49
  "ora": "^9.4.0",
@@ -54,7 +54,7 @@
54
54
  "zod": "^3.23.0"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@huggingface/transformers": "^3.5.0",
57
+ "@huggingface/transformers": "^4.2.0",
58
58
  "onnxruntime-node": "^1.21.0"
59
59
  },
60
60
  "devDependencies": {