pilotswarm-horizon-store 0.2.2

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 (71) hide show
  1. package/README.md +154 -0
  2. package/dist/src/agent-tools.d.ts +32 -0
  3. package/dist/src/agent-tools.d.ts.map +1 -0
  4. package/dist/src/agent-tools.js +263 -0
  5. package/dist/src/agent-tools.js.map +1 -0
  6. package/dist/src/config.d.ts +93 -0
  7. package/dist/src/config.d.ts.map +1 -0
  8. package/dist/src/config.js +120 -0
  9. package/dist/src/config.js.map +1 -0
  10. package/dist/src/db-retry.d.ts +21 -0
  11. package/dist/src/db-retry.d.ts.map +1 -0
  12. package/dist/src/db-retry.js +94 -0
  13. package/dist/src/db-retry.js.map +1 -0
  14. package/dist/src/embedding-client.d.ts +13 -0
  15. package/dist/src/embedding-client.d.ts.map +1 -0
  16. package/dist/src/embedding-client.js +79 -0
  17. package/dist/src/embedding-client.js.map +1 -0
  18. package/dist/src/graph-model.d.ts +49 -0
  19. package/dist/src/graph-model.d.ts.map +1 -0
  20. package/dist/src/graph-model.js +112 -0
  21. package/dist/src/graph-model.js.map +1 -0
  22. package/dist/src/graph-queries.d.ts +83 -0
  23. package/dist/src/graph-queries.d.ts.map +1 -0
  24. package/dist/src/graph-queries.js +727 -0
  25. package/dist/src/graph-queries.js.map +1 -0
  26. package/dist/src/graph-store.d.ts +57 -0
  27. package/dist/src/graph-store.d.ts.map +1 -0
  28. package/dist/src/graph-store.js +327 -0
  29. package/dist/src/graph-store.js.map +1 -0
  30. package/dist/src/horizon-migrator.d.ts +49 -0
  31. package/dist/src/horizon-migrator.d.ts.map +1 -0
  32. package/dist/src/horizon-migrator.js +231 -0
  33. package/dist/src/horizon-migrator.js.map +1 -0
  34. package/dist/src/horizon-store.d.ts +116 -0
  35. package/dist/src/horizon-store.d.ts.map +1 -0
  36. package/dist/src/horizon-store.js +563 -0
  37. package/dist/src/horizon-store.js.map +1 -0
  38. package/dist/src/index.d.ts +13 -0
  39. package/dist/src/index.d.ts.map +1 -0
  40. package/dist/src/index.js +21 -0
  41. package/dist/src/index.js.map +1 -0
  42. package/dist/src/preconditions.d.ts +16 -0
  43. package/dist/src/preconditions.d.ts.map +1 -0
  44. package/dist/src/preconditions.js +99 -0
  45. package/dist/src/preconditions.js.map +1 -0
  46. package/dist/src/query-builder.d.ts +37 -0
  47. package/dist/src/query-builder.d.ts.map +1 -0
  48. package/dist/src/query-builder.js +82 -0
  49. package/dist/src/query-builder.js.map +1 -0
  50. package/dist/src/sql-util.d.ts +20 -0
  51. package/dist/src/sql-util.d.ts.map +1 -0
  52. package/dist/src/sql-util.js +40 -0
  53. package/dist/src/sql-util.js.map +1 -0
  54. package/dist/src/types.d.ts +16 -0
  55. package/dist/src/types.d.ts.map +1 -0
  56. package/dist/src/types.js +35 -0
  57. package/dist/src/types.js.map +1 -0
  58. package/migrations/0001_facts_table.sql +77 -0
  59. package/migrations/0002_indexes.sql +27 -0
  60. package/migrations/0003_age_bootstrap.sql +13 -0
  61. package/migrations/0004_facts_procs.sql +253 -0
  62. package/migrations/0005_embedder_workflow.sql +119 -0
  63. package/migrations/0006_facts_read_uncrawled_embedded_gate.sql +33 -0
  64. package/migrations/0007_embed_key_value_text.sql +122 -0
  65. package/migrations/0008_embedder_failure_recovery.sql +409 -0
  66. package/migrations/0009_search_text_full_value.sql +52 -0
  67. package/migrations/0010_minimal_two_loop_embedder.sql +392 -0
  68. package/migrations/0011_unified_api_embedder_workflow.sql +273 -0
  69. package/migrations/0012_facts_soft_delete.sql +587 -0
  70. package/migrations/0013_graph_namespaces.sql +60 -0
  71. package/package.json +69 -0
@@ -0,0 +1,253 @@
1
+ -- 0004_facts_procs — ALL relational + vector data access (03-design §1: no
2
+ -- inline SQL in the provider; the 04 §6 M1 grep guard enforces it).
3
+ --
4
+ -- ACL convention (every read): the access predicate is part of the WHERE
5
+ -- clause, evaluated BEFORE ranking/LIMIT (01 §4.2 — never a post-filter).
6
+ -- Parameters: p_reader (caller session), p_granted (spawn-tree session ids),
7
+ -- p_unrestricted, p_scope ('accessible'|'shared'|'session'|'descendants').
8
+ --
9
+ -- Tokens: {{SCHEMA}}.
10
+
11
+ -- ── write ────────────────────────────────────────────────────────────────────
12
+
13
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store(
14
+ p_scope_key TEXT,
15
+ p_key TEXT,
16
+ p_value JSONB,
17
+ p_agent_id TEXT,
18
+ p_session_id TEXT,
19
+ p_shared BOOLEAN,
20
+ p_tags TEXT[]
21
+ ) RETURNS void
22
+ LANGUAGE sql AS $$
23
+ INSERT INTO "{{SCHEMA}}".facts (scope_key, key, value, agent_id, session_id, shared, transient, tags, updated_at)
24
+ VALUES (p_scope_key, p_key, p_value, p_agent_id, p_session_id, p_shared, NOT p_shared, coalesce(p_tags, '{}'), now())
25
+ ON CONFLICT (scope_key) DO UPDATE SET
26
+ value = EXCLUDED.value, agent_id = EXCLUDED.agent_id, tags = EXCLUDED.tags,
27
+ shared = EXCLUDED.shared, transient = EXCLUDED.transient, updated_at = now();
28
+ $$;
29
+
30
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete(p_scope_key TEXT) RETURNS boolean
31
+ LANGUAGE sql AS $$
32
+ WITH del AS (DELETE FROM "{{SCHEMA}}".facts WHERE scope_key = p_scope_key RETURNING 1)
33
+ SELECT count(*) > 0 FROM del;
34
+ $$;
35
+
36
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete_session(p_session_id TEXT) RETURNS int
37
+ LANGUAGE sql AS $$
38
+ WITH del AS (DELETE FROM "{{SCHEMA}}".facts WHERE shared = FALSE AND session_id = p_session_id RETURNING 1)
39
+ SELECT count(*)::int FROM del;
40
+ $$;
41
+
42
+ -- ── ACL predicate (shared by the read/search procs) ─────────────────────────
43
+
44
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_acl(
45
+ f_shared BOOLEAN, f_session_id TEXT,
46
+ p_reader TEXT, p_granted TEXT[], p_unrestricted BOOLEAN, p_scope TEXT
47
+ ) RETURNS boolean
48
+ LANGUAGE sql IMMUTABLE AS $$
49
+ SELECT CASE
50
+ WHEN p_unrestricted THEN TRUE
51
+ WHEN p_scope = 'shared' THEN f_shared
52
+ WHEN p_scope = 'session' THEN (NOT f_shared AND f_session_id = p_reader)
53
+ WHEN p_scope = 'descendants' THEN (NOT f_shared AND f_session_id = ANY (coalesce(p_granted, '{}')))
54
+ ELSE (f_shared OR f_session_id = p_reader OR f_session_id = ANY (coalesce(p_granted, '{}')))
55
+ END;
56
+ $$;
57
+
58
+ -- ── read ─────────────────────────────────────────────────────────────────────
59
+
60
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read(
61
+ p_reader TEXT,
62
+ p_granted TEXT[],
63
+ p_unrestricted BOOLEAN,
64
+ p_scope TEXT,
65
+ p_key_pattern TEXT,
66
+ p_scope_keys TEXT[],
67
+ p_tags TEXT[],
68
+ p_agent_id TEXT,
69
+ p_limit INT
70
+ ) RETURNS SETOF "{{SCHEMA}}".facts
71
+ LANGUAGE sql STABLE AS $$
72
+ SELECT f.* FROM "{{SCHEMA}}".facts f
73
+ WHERE "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
74
+ AND (p_key_pattern IS NULL OR f.key LIKE p_key_pattern)
75
+ AND (p_scope_keys IS NULL OR f.scope_key = ANY (p_scope_keys))
76
+ AND (p_tags IS NULL OR f.tags @> p_tags)
77
+ AND (p_agent_id IS NULL OR f.agent_id = p_agent_id)
78
+ ORDER BY f.updated_at DESC
79
+ LIMIT p_limit;
80
+ $$;
81
+
82
+ -- ── stats (namespace-bucketed, in SQL — not in Node) ─────────────────────────
83
+
84
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_stats(
85
+ p_mode TEXT, -- 'session' | 'sessions' | 'shared'
86
+ p_session_ids TEXT[]
87
+ ) RETURNS TABLE (
88
+ namespace TEXT, fact_count BIGINT, total_value_bytes BIGINT,
89
+ oldest_created_at TIMESTAMPTZ, newest_updated_at TIMESTAMPTZ
90
+ )
91
+ LANGUAGE sql STABLE AS $$
92
+ SELECT
93
+ CASE WHEN split_part(f.key, '/', 1) IN ('skills', 'asks', 'intake', 'config')
94
+ THEN split_part(f.key, '/', 1) ELSE '(other)' END AS namespace,
95
+ count(*) AS fact_count,
96
+ sum(octet_length(f.value::text))::bigint AS total_value_bytes,
97
+ min(f.created_at) AS oldest_created_at,
98
+ max(f.updated_at) AS newest_updated_at
99
+ FROM "{{SCHEMA}}".facts f
100
+ WHERE CASE p_mode
101
+ WHEN 'shared' THEN f.shared
102
+ WHEN 'session' THEN (NOT f.shared AND f.session_id = p_session_ids[1])
103
+ WHEN 'sessions' THEN (NOT f.shared AND f.session_id = ANY (coalesce(p_session_ids, '{}')))
104
+ ELSE FALSE
105
+ END
106
+ GROUP BY 1;
107
+ $$;
108
+
109
+ -- ── lexical search (BM25 via pg_textsearch — verified on live HorizonDB) ────
110
+ -- `search_text <@> to_bm25query(q, idx)` yields the NEGATIVE BM25 score
111
+ -- (0 = no match), so: match ⇔ d < 0, rank = -d, best-first = ORDER BY d ASC.
112
+ -- The ACL predicate sits in the same WHERE — before ranking and LIMIT.
113
+
114
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_lexical(
115
+ p_query TEXT,
116
+ p_reader TEXT,
117
+ p_granted TEXT[],
118
+ p_unrestricted BOOLEAN,
119
+ p_scope TEXT,
120
+ p_ns_prefix TEXT,
121
+ p_tags TEXT[],
122
+ p_pool INT
123
+ ) RETURNS TABLE (
124
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
125
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
126
+ rank DOUBLE PRECISION
127
+ )
128
+ LANGUAGE sql STABLE AS $$
129
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
130
+ f.shared, f.tags, f.created_at, f.updated_at,
131
+ (-(f.search_text <@> q.bq))::double precision AS rank
132
+ FROM "{{SCHEMA}}".facts f,
133
+ (SELECT to_bm25query(p_query, '{{SCHEMA}}.idx_facts_lexical') AS bq) q
134
+ WHERE (f.search_text <@> q.bq) < 0
135
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
136
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
137
+ AND (p_tags IS NULL OR f.tags @> p_tags)
138
+ ORDER BY f.search_text <@> q.bq ASC
139
+ LIMIT p_pool;
140
+ $$;
141
+
142
+ -- ── semantic search (pgvector cosine; model-matched rows only — 01 §5.2) ────
143
+
144
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_semantic(
145
+ p_vec vector,
146
+ p_model TEXT,
147
+ p_reader TEXT,
148
+ p_granted TEXT[],
149
+ p_unrestricted BOOLEAN,
150
+ p_scope TEXT,
151
+ p_ns_prefix TEXT,
152
+ p_tags TEXT[],
153
+ p_min DOUBLE PRECISION,
154
+ p_pool INT
155
+ ) RETURNS TABLE (
156
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
157
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
158
+ sim DOUBLE PRECISION
159
+ )
160
+ LANGUAGE sql STABLE AS $$
161
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
162
+ f.shared, f.tags, f.created_at, f.updated_at,
163
+ (1 - (f.embedding <=> p_vec))::double precision AS sim
164
+ FROM "{{SCHEMA}}".facts f
165
+ WHERE f.embedding IS NOT NULL
166
+ AND f.embedding_model IS NOT DISTINCT FROM p_model -- mismatched model ⇒ treated as NULL embedding
167
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
168
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
169
+ AND (p_tags IS NULL OR f.tags @> p_tags)
170
+ AND (1 - (f.embedding <=> p_vec)) >= coalesce(p_min, 0)
171
+ ORDER BY f.embedding <=> p_vec ASC
172
+ LIMIT p_pool;
173
+ $$;
174
+
175
+ -- ── similarFacts (kNN of a known fact; anchor ACL-checked IN the proc) ──────
176
+ -- An existing-but-inaccessible anchor yields zero rows, byte-identical to an
177
+ -- unknown key (01 §4.3 — no similarity oracle). Neighbours must carry the
178
+ -- anchor's embedding_model (cross-model vectors are not comparable); when
179
+ -- p_model is supplied (an endpoint is configured) the anchor must match it too.
180
+
181
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_similar(
182
+ p_scope_key TEXT,
183
+ p_model TEXT,
184
+ p_reader TEXT,
185
+ p_granted TEXT[],
186
+ p_unrestricted BOOLEAN,
187
+ p_scope TEXT,
188
+ p_ns_prefix TEXT,
189
+ p_min DOUBLE PRECISION,
190
+ p_k INT
191
+ ) RETURNS TABLE (
192
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
193
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
194
+ sim DOUBLE PRECISION
195
+ )
196
+ LANGUAGE sql STABLE AS $$
197
+ WITH anchor AS (
198
+ SELECT a.embedding, a.embedding_model
199
+ FROM "{{SCHEMA}}".facts a
200
+ WHERE a.scope_key = p_scope_key
201
+ AND a.embedding IS NOT NULL
202
+ AND (p_model IS NULL OR a.embedding_model IS NOT DISTINCT FROM p_model)
203
+ AND "{{SCHEMA}}".facts_acl(a.shared, a.session_id, p_reader, p_granted, p_unrestricted, p_scope)
204
+ )
205
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
206
+ f.shared, f.tags, f.created_at, f.updated_at,
207
+ (1 - (f.embedding <=> a.embedding))::double precision AS sim
208
+ FROM "{{SCHEMA}}".facts f, anchor a
209
+ WHERE f.scope_key <> p_scope_key
210
+ AND f.embedding IS NOT NULL
211
+ AND f.embedding_model IS NOT DISTINCT FROM a.embedding_model
212
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
213
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
214
+ AND (1 - (f.embedding <=> a.embedding)) >= coalesce(p_min, 0)
215
+ ORDER BY f.embedding <=> a.embedding ASC
216
+ LIMIT p_k;
217
+ $$;
218
+
219
+ -- ── crawl tracking (PRIVILEGED harvester surface — 01 §6.6) ─────────────────
220
+ -- No access context: the crawler reads everything by design. The mark is
221
+ -- guarded against the read→mark TOCTOU race: a stamp applies only WHERE
222
+ -- content_hash still equals the supplied hash; mismatches are skipped.
223
+
224
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read_uncrawled(
225
+ p_ns_prefix TEXT,
226
+ p_limit INT
227
+ ) RETURNS SETOF "{{SCHEMA}}".facts
228
+ LANGUAGE sql STABLE AS $$
229
+ SELECT f.* FROM "{{SCHEMA}}".facts f
230
+ WHERE f.last_crawled_at IS NULL
231
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
232
+ ORDER BY f.id
233
+ LIMIT p_limit;
234
+ $$;
235
+
236
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_mark_crawled(p_stamps JSONB)
237
+ RETURNS TABLE (marked INT, skipped INT)
238
+ LANGUAGE sql AS $$
239
+ WITH stamps AS (
240
+ SELECT e->>'scopeKey' AS scope_key, e->>'contentHash' AS content_hash
241
+ FROM jsonb_array_elements(p_stamps) e
242
+ ),
243
+ upd AS (
244
+ UPDATE "{{SCHEMA}}".facts f
245
+ SET last_crawled_at = now()
246
+ FROM stamps s
247
+ WHERE f.scope_key = s.scope_key
248
+ AND f.content_hash = s.content_hash
249
+ RETURNING f.scope_key
250
+ )
251
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
252
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
253
+ $$;
@@ -0,0 +1,119 @@
1
+ -- 0005_embedder_workflow — pg_durable grant + the eternal batch-embedding
2
+ -- loop builder (03-design §3).
3
+ --
4
+ -- The loop is ONE durable instance per schema (label hz-embed-cron:<schema>):
5
+ --
6
+ -- df.loop( df.seq( df.sleep(interval),
7
+ -- df.as(df.sql(<batch select>), 'batch'),
8
+ -- df.if_rows('batch',
9
+ -- df.seq(df.as(df.http(url, POST, '$batch.body', hdrs), 'resp'),
10
+ -- df.sql(<zip-back update>)),
11
+ -- df.sql('SELECT 1')) ) )
12
+ --
13
+ -- One HTTP request per BATCH (array-input API), never per fact — no df-in-df.
14
+ --
15
+ -- Config snapshot semantics: endpoint config lives in durable vars
16
+ -- (hz_<schema>_url/_model/...; written by configureEmbedder). This builder
17
+ -- reads them via df.getvar() and bakes LITERALS into the workflow definition,
18
+ -- so the running instance captures config at df.start exactly like {var}
19
+ -- substitution would — and configureEmbedder restarts the loop to apply
20
+ -- changes. (Literal-baking avoids the whole-body '{var}' escaping risk
21
+ -- flagged in 03-design §7 for everything except the body, which must be the
22
+ -- runtime step result '$batch.body'.)
23
+ --
24
+ -- Write-back guard: last_embedded_hash is set from the hashes captured at
25
+ -- SELECT time ($batch.hashes), never the row's current content_hash — a fact
26
+ -- edited mid-flight stays pending (01 §5.3).
27
+ --
28
+ -- Tokens: {{SCHEMA}}.
29
+
30
+ CREATE EXTENSION IF NOT EXISTS pg_durable;
31
+
32
+ DO $$
33
+ BEGIN
34
+ PERFORM df.grant_usage(current_user, true, false);
35
+ EXCEPTION WHEN OTHERS THEN
36
+ NULL; -- may already be granted / insufficient privilege; surfaced by preconditions if it matters
37
+ END $$;
38
+
39
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_interval int, p_batch int)
40
+ RETURNS text
41
+ LANGUAGE plpgsql AS $fn$
42
+ DECLARE
43
+ v_url text;
44
+ v_model text;
45
+ v_key text;
46
+ v_keyhdr text;
47
+ v_bearer text;
48
+ v_inputfield text;
49
+ v_timeout int;
50
+ v_headers jsonb;
51
+ v_batch_sql text;
52
+ v_zip_sql text;
53
+ BEGIN
54
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
55
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
56
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
57
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
58
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
59
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
60
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
61
+
62
+ IF v_url IS NULL OR v_model IS NULL THEN
63
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first (durable vars hz_{{SCHEMA}}_url / hz_{{SCHEMA}}_model missing)';
64
+ END IF;
65
+
66
+ v_headers := jsonb_build_object('content-type', 'application/json');
67
+ IF v_key IS NOT NULL AND v_key <> '' THEN
68
+ v_headers := v_headers || jsonb_build_object(
69
+ v_keyhdr,
70
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
71
+ );
72
+ END IF;
73
+
74
+ -- Batch select: ONE row (or zero when nothing pending — HAVING) carrying
75
+ -- the ready-to-send body plus positional ids/hashes for the zip-back.
76
+ -- Pending = NULL embedding, stale hash, or model mismatch (01 §5.2).
77
+ v_batch_sql := format(
78
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
79
+ array_agg(t.id ORDER BY t.id)::text AS ids,
80
+ array_agg(t.content_hash ORDER BY t.id)::text AS hashes
81
+ FROM (SELECT id, content_hash, coalesce(search_text, '') AS txt
82
+ FROM "{{SCHEMA}}".facts
83
+ WHERE embedding IS NULL
84
+ OR last_embedded_hash IS DISTINCT FROM content_hash
85
+ OR embedding_model IS DISTINCT FROM %L
86
+ ORDER BY id
87
+ LIMIT %s) t
88
+ HAVING count(*) > 0$q$,
89
+ v_inputfield, v_model, v_model, greatest(p_batch, 1));
90
+
91
+ -- Zip-back: data[i] ↔ ids[i] positionally (WITH ORDINALITY); write the
92
+ -- SELECT-time hash, stamp the model.
93
+ v_zip_sql := format(
94
+ $q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
95
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
96
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
97
+ tgt AS (SELECT u.id, u.h, u.ord
98
+ FROM unnest(($batch.ids)::bigint[], ($batch.hashes)::text[]) WITH ORDINALITY AS u(id, h, ord))
99
+ UPDATE "{{SCHEMA}}".facts f
100
+ SET embedding = (emb.vec)::text::vector,
101
+ embedded_at = now(),
102
+ embedding_model = %L,
103
+ last_embedded_hash = tgt.h
104
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
105
+ WHERE f.id = tgt.id$q$,
106
+ v_model);
107
+
108
+ RETURN df.loop(
109
+ df.seq(
110
+ df.sleep(greatest(p_interval, 1)::bigint),
111
+ df.seq(
112
+ df.as(df.sql(v_batch_sql), 'batch'),
113
+ df.if_rows('batch',
114
+ df.seq(
115
+ df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
116
+ df.sql(v_zip_sql)),
117
+ df.sql('SELECT 1')))),
118
+ NULL);
119
+ END $fn$;
@@ -0,0 +1,33 @@
1
+ -- 0006_facts_read_uncrawled_embedded_gate — add an OPTIONAL embedding gate to
2
+ -- the crawl-queue read (01 §5.2 / §6.6 harvester surface).
3
+ --
4
+ -- Why: the harvester now refines the graph with similarity search over each
5
+ -- fact's STORED embedding (facts_similar). A fact with no embedding yet cannot
6
+ -- be similarity-refined, so handing it to the harvester early produces a
7
+ -- thinner graph. With p_embedded_only => TRUE the queue read SKIPS facts whose
8
+ -- embedding is still NULL; they stay uncrawled and reappear on a later turn
9
+ -- once the in-DB embed loop (0005) has filled them in. The ungated 2-arg
10
+ -- behaviour is preserved as the default (FALSE) so the done-check and any
11
+ -- embedding-less deployment keep draining the whole queue.
12
+ --
13
+ -- Signature change (added param) cannot be done with CREATE OR REPLACE, so the
14
+ -- old 2-arg function is dropped first; the new 3-arg form has a DEFAULT so the
15
+ -- prior 2-arg call sites bind unchanged.
16
+ --
17
+ -- Tokens: {{SCHEMA}}.
18
+
19
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_read_uncrawled(TEXT, INT);
20
+
21
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read_uncrawled(
22
+ p_ns_prefix TEXT,
23
+ p_limit INT,
24
+ p_embedded_only BOOLEAN DEFAULT FALSE
25
+ ) RETURNS SETOF "{{SCHEMA}}".facts
26
+ LANGUAGE sql STABLE AS $$
27
+ SELECT f.* FROM "{{SCHEMA}}".facts f
28
+ WHERE f.last_crawled_at IS NULL
29
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
30
+ AND (NOT p_embedded_only OR f.embedding IS NOT NULL)
31
+ ORDER BY f.id
32
+ LIMIT p_limit;
33
+ $$;
@@ -0,0 +1,122 @@
1
+ -- 0007_embed_key_value_text — embed the KEY + the FULL VALUE, not the
2
+ -- cherry-picked search_text columns (03-design §3, embed-input correctness).
3
+ --
4
+ -- WHY. The 0005 embedder built its per-fact input from `search_text`, a STORED
5
+ -- generated column that concatenates only a fixed set of conventional value
6
+ -- fields:
7
+ --
8
+ -- coalesce(key,'') || ' ' || value->>'name' || ' ' || value->>'description'
9
+ -- || ' ' || value->>'text' || ' ' || value->>'body'
10
+ -- || ' ' || value->>'subject'
11
+ --
12
+ -- For a fact whose value uses ANY other shape — e.g. a harvested document
13
+ -- stored as { "title": ..., "content": ... } — none of those fields match, so
14
+ -- search_text collapses to essentially just the key and the embedding carries
15
+ -- almost no signal. The vector column then exists but is useless for semantic /
16
+ -- hybrid retrieval and for the harvester's facts_similar refinement (0006).
17
+ --
18
+ -- FIX. Embed `key` + a delimiter + the WHOLE value rendered as text, so the
19
+ -- embedding represents the entire fact regardless of value shape:
20
+ --
21
+ -- coalesce(key,'') || E'\n' || coalesce(value::text,'')
22
+ --
23
+ -- This mirrors how content_hash already keys off `key || value::text` (0001):
24
+ -- the embed input now covers exactly what the hash covers, so "content changed
25
+ -- ⇒ re-embed" stays coherent. `search_text` (lexical/BM25) is intentionally
26
+ -- left unchanged — this migration only changes the EMBEDDING input text.
27
+ --
28
+ -- Only the `t.txt` projection inside v_batch_sql changes versus 0005; the loop
29
+ -- shape, batching, config-var reads, and zip-back write-back are identical. This
30
+ -- is a CREATE OR REPLACE of the workflow BUILDER. A loop instance that is
31
+ -- already running was baked from the old function body at df.start and will keep
32
+ -- using the old text until it is cancelled and restarted (configureEmbedder
33
+ -- restart, stopEmbedder + startEmbedder, or a fresh deploy after the schema +
34
+ -- durable instance are dropped). New deployments pick it up immediately.
35
+ --
36
+ -- Tokens: {{SCHEMA}}.
37
+
38
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_interval int, p_batch int)
39
+ RETURNS text
40
+ LANGUAGE plpgsql AS $fn$
41
+ DECLARE
42
+ v_url text;
43
+ v_model text;
44
+ v_key text;
45
+ v_keyhdr text;
46
+ v_bearer text;
47
+ v_inputfield text;
48
+ v_timeout int;
49
+ v_headers jsonb;
50
+ v_batch_sql text;
51
+ v_zip_sql text;
52
+ BEGIN
53
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
54
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
55
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
56
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
57
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
58
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
59
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
60
+
61
+ IF v_url IS NULL OR v_model IS NULL THEN
62
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first (durable vars hz_{{SCHEMA}}_url / hz_{{SCHEMA}}_model missing)';
63
+ END IF;
64
+
65
+ v_headers := jsonb_build_object('content-type', 'application/json');
66
+ IF v_key IS NOT NULL AND v_key <> '' THEN
67
+ v_headers := v_headers || jsonb_build_object(
68
+ v_keyhdr,
69
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
70
+ );
71
+ END IF;
72
+
73
+ -- Batch select: ONE row (or zero when nothing pending — HAVING) carrying
74
+ -- the ready-to-send body plus positional ids/hashes for the zip-back.
75
+ -- Pending = NULL embedding, stale hash, or model mismatch (01 §5.2).
76
+ --
77
+ -- Embed input (CHANGED in 0007): key + delimiter + the FULL value as text,
78
+ -- so any value shape is represented — not just the search_text fields.
79
+ v_batch_sql := format(
80
+ $q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
81
+ array_agg(t.id ORDER BY t.id)::text AS ids,
82
+ array_agg(t.content_hash ORDER BY t.id)::text AS hashes
83
+ FROM (SELECT id, content_hash,
84
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt
85
+ FROM "{{SCHEMA}}".facts
86
+ WHERE embedding IS NULL
87
+ OR last_embedded_hash IS DISTINCT FROM content_hash
88
+ OR embedding_model IS DISTINCT FROM %L
89
+ ORDER BY id
90
+ LIMIT %s) t
91
+ HAVING count(*) > 0$q$,
92
+ v_inputfield, v_model, v_model, greatest(p_batch, 1));
93
+
94
+ -- Zip-back: data[i] ↔ ids[i] positionally (WITH ORDINALITY); write the
95
+ -- SELECT-time hash, stamp the model. (Unchanged from 0005.)
96
+ v_zip_sql := format(
97
+ $q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
98
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
99
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
100
+ tgt AS (SELECT u.id, u.h, u.ord
101
+ FROM unnest(($batch.ids)::bigint[], ($batch.hashes)::text[]) WITH ORDINALITY AS u(id, h, ord))
102
+ UPDATE "{{SCHEMA}}".facts f
103
+ SET embedding = (emb.vec)::text::vector,
104
+ embedded_at = now(),
105
+ embedding_model = %L,
106
+ last_embedded_hash = tgt.h
107
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
108
+ WHERE f.id = tgt.id$q$,
109
+ v_model);
110
+
111
+ RETURN df.loop(
112
+ df.seq(
113
+ df.sleep(greatest(p_interval, 1)::bigint),
114
+ df.seq(
115
+ df.as(df.sql(v_batch_sql), 'batch'),
116
+ df.if_rows('batch',
117
+ df.seq(
118
+ df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
119
+ df.sql(v_zip_sql)),
120
+ df.sql('SELECT 1')))),
121
+ NULL);
122
+ END $fn$;