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,409 @@
1
+ -- 0008_embedder_failure_recovery — isolate failed embedding rows so one bad
2
+ -- fact cannot block the durable embedder backlog.
3
+ --
4
+ -- Behaviour:
5
+ -- - facts.last_embed_error stores a numeric terminal error code for the
6
+ -- current row value. NULL means the row remains eligible for embedding.
7
+ -- - facts.embed_retry_at marks a row selected from a failed batch for one-row
8
+ -- retry. It is transient workflow state on the fact row itself.
9
+ -- - facts_touch clears last_embed_error and embed_retry_at on INSERT/content
10
+ -- change, and clears the embedding itself so edits reset the row to normal
11
+ -- pending embedding without content/embedding hash columns.
12
+ -- - A failed batch marks its rows with embed_retry_at. The loop then processes
13
+ -- those rows one at a time before selecting new batches. A single-row failure
14
+ -- marks that fact terminally failed and stamps last_crawled_at so an
15
+ -- embedded-only graph harvester is not starved forever.
16
+ -- - facts_embedding_failures exposes counts and samples for the Facts Manager.
17
+ --
18
+ -- Error code convention:
19
+ -- 1001 input_too_large
20
+ -- 1400 provider_bad_request
21
+ -- 1401 provider_authentication_failed
22
+ -- 1403 provider_authorization_failed
23
+ -- 1429 provider_rate_limited
24
+ -- 1500 provider_server_error
25
+ -- 1901 provider_malformed_response
26
+ -- 9999 unknown_embedding_error
27
+ --
28
+ -- Tokens: {{SCHEMA}}.
29
+
30
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS last_embed_error INT;
31
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS last_embed_error_at TIMESTAMPTZ;
32
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN IF NOT EXISTS embed_retry_at TIMESTAMPTZ;
33
+
34
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS last_embedded_hash;
35
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS content_hash;
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_facts_last_embed_error
38
+ ON "{{SCHEMA}}".facts (last_embed_error, id) WHERE last_embed_error IS NOT NULL;
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_facts_embed_retry
41
+ ON "{{SCHEMA}}".facts (embed_retry_at, id) WHERE embed_retry_at IS NOT NULL;
42
+
43
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embed_error_label(p_code INT) RETURNS TEXT
44
+ LANGUAGE sql IMMUTABLE AS $$
45
+ SELECT CASE p_code
46
+ WHEN 1001 THEN 'input_too_large'
47
+ WHEN 1400 THEN 'provider_bad_request'
48
+ WHEN 1401 THEN 'provider_authentication_failed'
49
+ WHEN 1403 THEN 'provider_authorization_failed'
50
+ WHEN 1429 THEN 'provider_rate_limited'
51
+ WHEN 1500 THEN 'provider_server_error'
52
+ WHEN 1901 THEN 'provider_malformed_response'
53
+ WHEN 9999 THEN 'unknown_embedding_error'
54
+ ELSE 'unknown_embedding_error'
55
+ END;
56
+ $$;
57
+
58
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embed_error_code(p_status INT, p_body TEXT) RETURNS INT
59
+ LANGUAGE sql IMMUTABLE AS $$
60
+ SELECT CASE
61
+ WHEN p_body ILIKE '%maximum input length%' OR p_body ILIKE '%too many tokens%' OR p_body ILIKE '%input%too%long%'
62
+ THEN 1001
63
+ WHEN p_status = 400 THEN 1400
64
+ WHEN p_status = 401 THEN 1401
65
+ WHEN p_status = 403 THEN 1403
66
+ WHEN p_status = 429 THEN 1429
67
+ WHEN p_status BETWEEN 500 AND 599 THEN 1500
68
+ ELSE 9999
69
+ END;
70
+ $$;
71
+
72
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embed_response_data_count(p_resp JSONB) RETURNS INT
73
+ LANGUAGE plpgsql IMMUTABLE AS $$
74
+ DECLARE
75
+ j JSONB;
76
+ BEGIN
77
+ IF NOT coalesce((p_resp->>'ok')::boolean, false) THEN
78
+ RETURN -1;
79
+ END IF;
80
+ IF coalesce(CASE WHEN (p_resp->>'status') ~ '^\d+$' THEN (p_resp->>'status')::int ELSE 0 END, 0) NOT BETWEEN 200 AND 299 THEN
81
+ RETURN -1;
82
+ END IF;
83
+ j := (p_resp->>'body')::jsonb;
84
+ IF jsonb_typeof(j->'data') <> 'array' THEN
85
+ RETURN -2;
86
+ END IF;
87
+ RETURN jsonb_array_length(j->'data');
88
+ EXCEPTION WHEN OTHERS THEN
89
+ RETURN -2;
90
+ END $$;
91
+
92
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embed_response_terminal_code(p_resp JSONB) RETURNS INT
93
+ LANGUAGE sql IMMUTABLE AS $$
94
+ SELECT CASE
95
+ WHEN "{{SCHEMA}}".embed_response_data_count(p_resp) = -2 THEN 1901
96
+ ELSE "{{SCHEMA}}".embed_error_code(
97
+ coalesce(CASE WHEN (p_resp->>'status') ~ '^\d+$' THEN (p_resp->>'status')::int ELSE 0 END, 0),
98
+ coalesce(p_resp->>'body', ''))
99
+ END;
100
+ $$;
101
+
102
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_touch() RETURNS trigger
103
+ LANGUAGE plpgsql AS $$
104
+ BEGIN
105
+ IF TG_OP = 'INSERT' OR NEW.key IS DISTINCT FROM OLD.key OR NEW.value IS DISTINCT FROM OLD.value THEN
106
+ NEW.updated_at := now();
107
+ NEW.last_crawled_at := NULL;
108
+ NEW.embedding := NULL;
109
+ NEW.embedded_at := NULL;
110
+ NEW.embedding_model := NULL;
111
+ NEW.last_embed_error := NULL;
112
+ NEW.last_embed_error_at := NULL;
113
+ NEW.embed_retry_at := NULL;
114
+ END IF;
115
+ RETURN NEW;
116
+ END $$;
117
+
118
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_embedding_failures(
119
+ p_ns_prefix TEXT DEFAULT NULL,
120
+ p_error_codes INT[] DEFAULT NULL,
121
+ p_limit INT DEFAULT 20
122
+ ) RETURNS TABLE (
123
+ row_kind TEXT,
124
+ code INT,
125
+ label TEXT,
126
+ count BIGINT,
127
+ oldest_at TIMESTAMPTZ,
128
+ newest_at TIMESTAMPTZ,
129
+ max_input_chars BIGINT,
130
+ scope_key TEXT,
131
+ key TEXT,
132
+ value JSONB,
133
+ agent_id TEXT,
134
+ session_id TEXT,
135
+ shared BOOLEAN,
136
+ tags TEXT[],
137
+ created_at TIMESTAMPTZ,
138
+ updated_at TIMESTAMPTZ,
139
+ last_embed_error INT,
140
+ last_embed_error_at TIMESTAMPTZ,
141
+ input_chars BIGINT
142
+ )
143
+ LANGUAGE sql STABLE AS $$
144
+ WITH failed AS (
145
+ SELECT f.*,
146
+ char_length(coalesce(f.key, '') || E'\n' || coalesce(f.value::text, ''))::bigint AS input_chars
147
+ FROM "{{SCHEMA}}".facts f
148
+ WHERE f.last_embed_error IS NOT NULL
149
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
150
+ AND (p_error_codes IS NULL OR f.last_embed_error = ANY(p_error_codes))
151
+ ), stats AS (
152
+ SELECT f.last_embed_error AS code,
153
+ "{{SCHEMA}}".embed_error_label(f.last_embed_error) AS label,
154
+ count(*)::bigint AS count,
155
+ min(f.last_embed_error_at) AS oldest_at,
156
+ max(f.last_embed_error_at) AS newest_at,
157
+ coalesce(max(f.input_chars), 0)::bigint AS max_input_chars
158
+ FROM failed f
159
+ GROUP BY f.last_embed_error
160
+ ), samples AS (
161
+ SELECT f.*
162
+ FROM failed f
163
+ ORDER BY f.last_embed_error_at DESC NULLS LAST, f.id DESC
164
+ LIMIT greatest(0, least(coalesce(p_limit, 20), 500))
165
+ )
166
+ SELECT 'stat'::text AS row_kind,
167
+ s.code, s.label, s.count, s.oldest_at, s.newest_at, s.max_input_chars,
168
+ NULL::text AS scope_key, NULL::text AS key, NULL::jsonb AS value,
169
+ NULL::text AS agent_id, NULL::text AS session_id, NULL::boolean AS shared,
170
+ NULL::text[] AS tags, NULL::timestamptz AS created_at, NULL::timestamptz AS updated_at,
171
+ NULL::int AS last_embed_error, NULL::timestamptz AS last_embed_error_at,
172
+ NULL::bigint AS input_chars
173
+ FROM stats s
174
+ UNION ALL
175
+ SELECT 'fact'::text AS row_kind,
176
+ f.last_embed_error AS code,
177
+ "{{SCHEMA}}".embed_error_label(f.last_embed_error) AS label,
178
+ NULL::bigint AS count, NULL::timestamptz AS oldest_at, NULL::timestamptz AS newest_at,
179
+ NULL::bigint AS max_input_chars,
180
+ f.scope_key, f.key, f.value, f.agent_id, f.session_id, f.shared, f.tags,
181
+ f.created_at, f.updated_at, f.last_embed_error,
182
+ f.last_embed_error_at, f.input_chars
183
+ FROM samples f
184
+ ORDER BY row_kind DESC, count DESC NULLS LAST, newest_at DESC NULLS LAST, last_embed_error_at DESC NULLS LAST;
185
+ $$;
186
+
187
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_mark_crawled(JSONB);
188
+
189
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_mark_crawled(p_stamps JSONB)
190
+ RETURNS TABLE (marked INT, skipped INT)
191
+ LANGUAGE sql AS $$
192
+ WITH stamps AS (
193
+ SELECT e->>'scopeKey' AS scope_key
194
+ FROM jsonb_array_elements(p_stamps) e
195
+ ),
196
+ upd AS (
197
+ UPDATE "{{SCHEMA}}".facts f
198
+ SET last_crawled_at = now()
199
+ FROM stamps s
200
+ WHERE f.scope_key = s.scope_key
201
+ AND f.last_crawled_at IS NULL
202
+ RETURNING f.scope_key
203
+ )
204
+ SELECT (SELECT count(*) FROM upd)::int AS marked,
205
+ ((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
206
+ $$;
207
+
208
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_interval int, p_batch int)
209
+ RETURNS text
210
+ LANGUAGE plpgsql AS $fn$
211
+ DECLARE
212
+ v_url text;
213
+ v_model text;
214
+ v_key text;
215
+ v_keyhdr text;
216
+ v_bearer text;
217
+ v_inputfield text;
218
+ v_timeout int;
219
+ v_headers jsonb;
220
+ v_batch_sql text;
221
+ v_resp_ok_sql text;
222
+ v_batch_fail_sql text;
223
+ v_zip_sql text;
224
+ v_retry_sql text;
225
+ v_retry_exists_sql text;
226
+ v_retry_resp_ok_sql text;
227
+ v_retry_zip_sql text;
228
+ v_retry_fail_sql text;
229
+ BEGIN
230
+ v_url := df.getvar('hz_{{SCHEMA}}_url');
231
+ v_model := df.getvar('hz_{{SCHEMA}}_model');
232
+ v_key := df.getvar('hz_{{SCHEMA}}_key');
233
+ v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
234
+ v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
235
+ v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
236
+ v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
237
+
238
+ IF v_url IS NULL OR v_model IS NULL THEN
239
+ RAISE EXCEPTION 'embedder not configured: call configureEmbedder first (durable vars hz_{{SCHEMA}}_url / hz_{{SCHEMA}}_model missing)';
240
+ END IF;
241
+
242
+ v_headers := jsonb_build_object('content-type', 'application/json');
243
+ IF v_key IS NOT NULL AND v_key <> '' THEN
244
+ v_headers := v_headers || jsonb_build_object(
245
+ v_keyhdr,
246
+ CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
247
+ );
248
+ END IF;
249
+
250
+ v_batch_sql := format(
251
+ $q$WITH retry AS (
252
+ SELECT id
253
+ FROM "{{SCHEMA}}".facts
254
+ WHERE embed_retry_at IS NOT NULL
255
+ AND last_embed_error IS NULL
256
+ ORDER BY embed_retry_at, id
257
+ LIMIT 1
258
+ )
259
+ SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
260
+ array_agg(t.id ORDER BY t.id)::text AS ids,
261
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats
262
+ FROM (SELECT id, updated_at,
263
+ coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt
264
+ FROM "{{SCHEMA}}".facts
265
+ WHERE last_embed_error IS NULL
266
+ AND embed_retry_at IS NULL
267
+ AND NOT EXISTS (SELECT 1 FROM retry)
268
+ AND (embedding IS NULL
269
+ OR embedding_model IS DISTINCT FROM %L)
270
+ ORDER BY id
271
+ LIMIT %s) t
272
+ HAVING count(*) > 0$q$,
273
+ v_inputfield, v_model, v_model, greatest(p_batch, 1));
274
+
275
+ v_resp_ok_sql := $q$SELECT 1
276
+ WHERE "{{SCHEMA}}".embed_response_data_count($resp::jsonb) = cardinality(($batch.ids)::bigint[])$q$;
277
+
278
+ v_batch_fail_sql := $q$WITH selected AS (
279
+ SELECT u.id, u.seen_updated_at
280
+ FROM unnest(($batch.ids)::bigint[], ($batch.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
281
+ )
282
+ UPDATE "{{SCHEMA}}".facts f
283
+ SET embed_retry_at = now()
284
+ FROM selected s
285
+ WHERE f.id = s.id
286
+ AND f.updated_at IS NOT DISTINCT FROM s.seen_updated_at
287
+ AND f.last_embed_error IS NULL$q$;
288
+
289
+ v_zip_sql := format(
290
+ $q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
291
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
292
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
293
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
294
+ FROM unnest(($batch.ids)::bigint[], ($batch.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
295
+ upd AS (
296
+ UPDATE "{{SCHEMA}}".facts f
297
+ SET embedding = (emb.vec)::text::vector,
298
+ embedded_at = now(),
299
+ embedding_model = %L,
300
+ last_embed_error = NULL,
301
+ last_embed_error_at = NULL,
302
+ embed_retry_at = NULL
303
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
304
+ WHERE f.id = tgt.id
305
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
306
+ RETURNING f.id)
307
+ SELECT count(*) FROM upd$q$,
308
+ v_model);
309
+
310
+ v_retry_sql := format(
311
+ $q$WITH picked AS (
312
+ SELECT id, updated_at
313
+ FROM "{{SCHEMA}}".facts
314
+ WHERE embed_retry_at IS NOT NULL
315
+ AND last_embed_error IS NULL
316
+ ORDER BY embed_retry_at, id
317
+ LIMIT 1
318
+ )
319
+ SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
320
+ array_agg(t.id ORDER BY t.id)::text AS ids,
321
+ array_agg(t.updated_at ORDER BY t.id)::text AS updated_ats
322
+ FROM (SELECT f.id, f.updated_at,
323
+ coalesce(f.key, '') || E'\n' || coalesce(f.value::text, '') AS txt
324
+ FROM "{{SCHEMA}}".facts f
325
+ JOIN picked p ON p.id = f.id AND p.updated_at IS NOT DISTINCT FROM f.updated_at) t
326
+ HAVING count(*) > 0$q$,
327
+ v_inputfield, v_model);
328
+
329
+ v_retry_exists_sql := $q$SELECT 1
330
+ FROM "{{SCHEMA}}".facts
331
+ WHERE embed_retry_at IS NOT NULL
332
+ AND last_embed_error IS NULL
333
+ LIMIT 1$q$;
334
+
335
+ v_retry_resp_ok_sql := $q$SELECT 1
336
+ WHERE "{{SCHEMA}}".embed_response_data_count($retry_resp::jsonb) = cardinality(($retry.ids)::bigint[])$q$;
337
+
338
+ v_retry_zip_sql := format(
339
+ $q$WITH resp AS (SELECT ($retry_resp::jsonb->>'body')::jsonb AS j),
340
+ emb AS (SELECT v.ord, v.e->'embedding' AS vec
341
+ FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
342
+ tgt AS (SELECT u.id, u.seen_updated_at, u.ord
343
+ FROM unnest(($retry.ids)::bigint[], ($retry.updated_ats)::timestamptz[]) WITH ORDINALITY AS u(id, seen_updated_at, ord)),
344
+ upd AS (
345
+ UPDATE "{{SCHEMA}}".facts f
346
+ SET embedding = (emb.vec)::text::vector,
347
+ embedded_at = now(),
348
+ embedding_model = %L,
349
+ last_embed_error = NULL,
350
+ last_embed_error_at = NULL,
351
+ embed_retry_at = NULL
352
+ FROM emb JOIN tgt ON tgt.ord = emb.ord
353
+ WHERE f.id = tgt.id
354
+ AND f.updated_at IS NOT DISTINCT FROM tgt.seen_updated_at
355
+ RETURNING f.id)
356
+ SELECT count(*) FROM upd$q$,
357
+ v_model);
358
+
359
+ v_retry_fail_sql := $q$WITH resp AS (
360
+ SELECT $retry_resp::jsonb AS r
361
+ ), tgt AS (
362
+ SELECT u.id, u.seen_updated_at
363
+ FROM unnest(($retry.ids)::bigint[], ($retry.updated_ats)::timestamptz[]) AS u(id, seen_updated_at)
364
+ ), classified AS (
365
+ SELECT tgt.id, tgt.seen_updated_at,
366
+ "{{SCHEMA}}".embed_response_terminal_code(resp.r) AS code
367
+ FROM tgt, resp
368
+ ), upd AS (
369
+ UPDATE "{{SCHEMA}}".facts f
370
+ SET last_embed_error = classified.code,
371
+ last_embed_error_at = now(),
372
+ last_crawled_at = now(),
373
+ embed_retry_at = NULL
374
+ FROM classified
375
+ WHERE f.id = classified.id
376
+ AND f.updated_at IS NOT DISTINCT FROM classified.seen_updated_at
377
+ RETURNING f.id)
378
+ SELECT count(*) FROM upd$q$;
379
+
380
+ RETURN df.loop(
381
+ df.seq(
382
+ df.sleep(greatest(p_interval, 1)::bigint),
383
+ df.seq(
384
+ df.as(df.sql(v_batch_sql), 'batch'),
385
+ df.seq(
386
+ df.if_rows('batch',
387
+ df.seq(
388
+ df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
389
+ df.seq(
390
+ df.as(df.sql(v_resp_ok_sql), 'resp_ok'),
391
+ df.if_rows('resp_ok',
392
+ df.sql(v_zip_sql),
393
+ df.sql(v_batch_fail_sql)))),
394
+ df.sql('SELECT 1')),
395
+ df.loop(
396
+ df.seq(
397
+ df.as(df.sql(v_retry_sql), 'retry'),
398
+ df.if_rows('retry',
399
+ df.seq(
400
+ df.as(df.http(v_url, 'POST', '$retry.body', v_headers, v_timeout), 'retry_resp'),
401
+ df.seq(
402
+ df.as(df.sql(v_retry_resp_ok_sql), 'retry_resp_ok'),
403
+ df.if_rows('retry_resp_ok',
404
+ df.sql(v_retry_zip_sql),
405
+ df.sql(v_retry_fail_sql)))),
406
+ df.sql('SELECT 1'))),
407
+ df.sql(v_retry_exists_sql))))),
408
+ NULL);
409
+ END $fn$;
@@ -0,0 +1,52 @@
1
+ -- 0009_search_text_full_value — make lexical/BM25 search follow the public
2
+ -- store_fact contract.
3
+ --
4
+ -- The original generated search_text column indexed only a handful of
5
+ -- conventional JSON fields (name, description, text, body, subject). The
6
+ -- store_fact tool accepts any JSON-serializable value, and migration 0007
7
+ -- already moved embedding input to key + value::text for that reason. This
8
+ -- migration brings lexical search into the same shape so facts with fields such
9
+ -- as content, summary, detail, problem, or arbitrary nested JSON are searchable.
10
+ --
11
+ -- Tokens: {{SCHEMA}}.
12
+
13
+ DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_search_lexical(TEXT, TEXT, TEXT[], BOOLEAN, TEXT, TEXT, TEXT[], INT);
14
+ DROP INDEX IF EXISTS "{{SCHEMA}}".idx_facts_lexical;
15
+
16
+ ALTER TABLE "{{SCHEMA}}".facts DROP COLUMN IF EXISTS search_text;
17
+ ALTER TABLE "{{SCHEMA}}".facts ADD COLUMN search_text TEXT GENERATED ALWAYS AS (
18
+ coalesce(key, '') || E'\n' || coalesce(value::text, '')
19
+ ) STORED;
20
+
21
+ CREATE EXTENSION IF NOT EXISTS pg_textsearch;
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_facts_lexical
24
+ ON "{{SCHEMA}}".facts USING bm25 (search_text) WITH (text_config = 'english');
25
+
26
+ CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_lexical(
27
+ p_query TEXT,
28
+ p_reader TEXT,
29
+ p_granted TEXT[],
30
+ p_unrestricted BOOLEAN,
31
+ p_scope TEXT,
32
+ p_ns_prefix TEXT,
33
+ p_tags TEXT[],
34
+ p_pool INT
35
+ ) RETURNS TABLE (
36
+ scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
37
+ shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
38
+ rank DOUBLE PRECISION
39
+ )
40
+ LANGUAGE sql STABLE AS $$
41
+ SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
42
+ f.shared, f.tags, f.created_at, f.updated_at,
43
+ (-(f.search_text <@> q.bq))::double precision AS rank
44
+ FROM "{{SCHEMA}}".facts f,
45
+ (SELECT to_bm25query(p_query, '{{SCHEMA}}.idx_facts_lexical') AS bq) q
46
+ WHERE (f.search_text <@> q.bq) < 0
47
+ AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
48
+ AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
49
+ AND (p_tags IS NULL OR f.tags @> p_tags)
50
+ ORDER BY f.search_text <@> q.bq ASC
51
+ LIMIT p_pool;
52
+ $$;