@shadowforge0/aquifer-memory 1.0.3 → 1.3.0

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 (59) hide show
  1. package/README.md +37 -29
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +28 -1
  4. package/consumers/default/daily-entries.js +196 -0
  5. package/consumers/default/index.js +282 -0
  6. package/consumers/default/prompts/summary.js +153 -0
  7. package/consumers/mcp.js +3 -23
  8. package/consumers/miranda/context-inject.js +119 -0
  9. package/consumers/miranda/daily-entries.js +224 -0
  10. package/consumers/miranda/index.js +353 -0
  11. package/consumers/miranda/instance.js +55 -0
  12. package/consumers/miranda/llm.js +99 -0
  13. package/consumers/miranda/profile.json +145 -0
  14. package/consumers/miranda/prompts/summary.js +303 -0
  15. package/consumers/miranda/recall-format.js +74 -0
  16. package/consumers/miranda/render-daily-md.js +186 -0
  17. package/consumers/miranda/workspace-files.js +91 -0
  18. package/consumers/openclaw-ext/index.js +38 -0
  19. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  20. package/consumers/openclaw-ext/package.json +10 -0
  21. package/consumers/openclaw-plugin.js +66 -74
  22. package/consumers/opencode.js +21 -24
  23. package/consumers/shared/autodetect.js +64 -0
  24. package/consumers/shared/entity-parser.js +119 -0
  25. package/consumers/shared/ingest.js +148 -0
  26. package/consumers/shared/llm-autodetect.js +137 -0
  27. package/consumers/shared/normalize.js +129 -0
  28. package/consumers/shared/recall-format.js +110 -0
  29. package/core/aquifer.js +209 -71
  30. package/core/artifacts.js +174 -0
  31. package/core/bundles.js +400 -0
  32. package/core/consolidation.js +340 -0
  33. package/core/decisions.js +164 -0
  34. package/core/entity.js +1 -3
  35. package/core/errors.js +97 -0
  36. package/core/handoff.js +153 -0
  37. package/core/mcp-manifest.js +131 -0
  38. package/core/narratives.js +212 -0
  39. package/core/profiles.js +171 -0
  40. package/core/state.js +163 -0
  41. package/core/storage.js +86 -28
  42. package/core/timeline.js +152 -0
  43. package/docs/postprocess-contract.md +132 -0
  44. package/index.js +23 -1
  45. package/package.json +23 -2
  46. package/pipeline/_http.js +1 -1
  47. package/pipeline/consolidation/apply.js +176 -0
  48. package/pipeline/consolidation/index.js +21 -0
  49. package/pipeline/extract-entities.js +2 -2
  50. package/pipeline/rerank.js +1 -1
  51. package/pipeline/summarize.js +4 -1
  52. package/schema/001-base.sql +61 -24
  53. package/schema/002-entities.sql +17 -3
  54. package/schema/004-completion.sql +375 -0
  55. package/schema/004-facts.sql +67 -0
  56. package/scripts/diagnose-fts-zh.js +168 -134
  57. package/scripts/diagnose-vector.js +188 -0
  58. package/scripts/install-openclaw.sh +59 -0
  59. package/scripts/smoke.mjs +2 -2
@@ -0,0 +1,375 @@
1
+ -- 004-completion.sql — cross-session completion schema (P1 foundation)
2
+ --
3
+ -- Adds the minimal DDL needed for the aquifer-completion capability surface:
4
+ -- * shared set_updated_at() trigger function (reused by narratives, consumer_profiles,
5
+ -- and future completion tables)
6
+ -- * sessions.consolidation_phases JSONB (per-phase state map; see consolidation
7
+ -- orchestration spec)
8
+ -- * narratives table — cross-session state snapshot with supersede chain
9
+ -- * consumer_profiles table — consumer schema registry with composite primary key
10
+ -- (tenant_id, consumer_id, version) for future multi-tenant safety
11
+ --
12
+ -- All identifiers stay parameterised on ${schema} so P4 schema rename
13
+ -- (miranda → aquifer) is a one-line config change rather than a DDL rewrite.
14
+
15
+ -- Ensure pg_trgm available (used by existing migrations; re-declared for independent
16
+ -- run safety).
17
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
18
+
19
+ -- Shared trigger: bump updated_at on row modification.
20
+ CREATE OR REPLACE FUNCTION ${schema}.set_updated_at()
21
+ RETURNS trigger
22
+ LANGUAGE plpgsql
23
+ AS $$
24
+ BEGIN
25
+ NEW.updated_at := now();
26
+ RETURN NEW;
27
+ END;
28
+ $$;
29
+
30
+ -- sessions.consolidation_phases: per-phase state map keyed by phase name.
31
+ -- Shape (documented in spec, enforced at application layer):
32
+ -- {
33
+ -- "<phase>": {
34
+ -- "status": "pending|claimed|running|succeeded|failed|skipped",
35
+ -- "attempts": int,
36
+ -- "idempotencyKey": string?, "claimToken": string?, "workerId": string?,
37
+ -- "startedAt": iso?, "finishedAt": iso?, "retryAfter": iso?,
38
+ -- "errorCode": string?, "errorMessage": string?,
39
+ -- "outputRef": { ... }?
40
+ -- }
41
+ -- }
42
+ ALTER TABLE ${schema}.sessions
43
+ ADD COLUMN IF NOT EXISTS consolidation_phases JSONB NOT NULL DEFAULT '{}'::jsonb;
44
+
45
+ -- narratives: cross-session state snapshots with scope-based addressing and
46
+ -- supersede chain. Only one 'active' row per (tenant, agent, scope, scope_key).
47
+ CREATE TABLE IF NOT EXISTS ${schema}.narratives (
48
+ id BIGSERIAL PRIMARY KEY,
49
+ tenant_id TEXT NOT NULL DEFAULT 'default',
50
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
51
+ source_session_id TEXT,
52
+ agent_id TEXT NOT NULL DEFAULT 'main',
53
+ consumer_profile_id TEXT NOT NULL,
54
+ consumer_profile_version INT NOT NULL,
55
+ consumer_schema_hash TEXT NOT NULL,
56
+ idempotency_key TEXT UNIQUE,
57
+ scope TEXT NOT NULL DEFAULT 'agent'
58
+ CHECK (scope IN ('agent', 'workspace', 'project', 'custom')),
59
+ scope_key TEXT NOT NULL,
60
+ text TEXT NOT NULL,
61
+ status TEXT NOT NULL DEFAULT 'active'
62
+ CHECK (status IN ('active', 'archived', 'superseded')),
63
+ based_on_fact_ids BIGINT[] NOT NULL DEFAULT '{}',
64
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
65
+ superseded_by_narrative_id BIGINT REFERENCES ${schema}.narratives(id) ON DELETE SET NULL,
66
+ effective_at TIMESTAMPTZ NOT NULL DEFAULT now(),
67
+ search_tsv TSVECTOR,
68
+ search_text TEXT,
69
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
70
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
71
+ );
72
+
73
+ -- Only one active narrative per (tenant, agent, scope, scope_key).
74
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_narratives_active_scope
75
+ ON ${schema}.narratives (tenant_id, agent_id, scope, scope_key)
76
+ WHERE status = 'active';
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_narratives_effective_at
79
+ ON ${schema}.narratives (tenant_id, agent_id, effective_at DESC);
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_narratives_search_tsv
82
+ ON ${schema}.narratives USING GIN (search_tsv);
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_narratives_search_text_trgm
85
+ ON ${schema}.narratives USING GIN (search_text gin_trgm_ops);
86
+
87
+ CREATE OR REPLACE FUNCTION ${schema}.narratives_search_tsv_update()
88
+ RETURNS trigger
89
+ LANGUAGE plpgsql
90
+ AS $$
91
+ BEGIN
92
+ NEW.search_text := COALESCE(NEW.text, '') || ' ' || COALESCE(NEW.metadata::text, '');
93
+ NEW.search_tsv := setweight(to_tsvector('simple', COALESCE(NEW.text, '')), 'A');
94
+ RETURN NEW;
95
+ END;
96
+ $$;
97
+
98
+ DROP TRIGGER IF EXISTS trg_narratives_search_tsv ON ${schema}.narratives;
99
+ CREATE TRIGGER trg_narratives_search_tsv
100
+ BEFORE INSERT OR UPDATE OF text, metadata
101
+ ON ${schema}.narratives
102
+ FOR EACH ROW
103
+ EXECUTE FUNCTION ${schema}.narratives_search_tsv_update();
104
+
105
+ DROP TRIGGER IF EXISTS trg_narratives_updated_at ON ${schema}.narratives;
106
+ CREATE TRIGGER trg_narratives_updated_at
107
+ BEFORE UPDATE ON ${schema}.narratives
108
+ FOR EACH ROW
109
+ EXECUTE FUNCTION ${schema}.set_updated_at();
110
+
111
+ -- consumer_profiles: registry for consumer output contracts.
112
+ -- Composite primary key (tenant_id, consumer_id, version) future-proofs multi-tenant.
113
+ -- profile_hash UNIQUE per (consumer_id, version) catches accidental hash drift within
114
+ -- a consumer version.
115
+ CREATE TABLE IF NOT EXISTS ${schema}.consumer_profiles (
116
+ tenant_id TEXT NOT NULL DEFAULT 'default',
117
+ consumer_id TEXT NOT NULL,
118
+ version INT NOT NULL,
119
+ profile_hash TEXT NOT NULL,
120
+ profile_json JSONB NOT NULL,
121
+ loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
122
+ deprecated_at TIMESTAMPTZ,
123
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
124
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
125
+ PRIMARY KEY (tenant_id, consumer_id, version),
126
+ UNIQUE (consumer_id, version, profile_hash)
127
+ );
128
+
129
+ CREATE INDEX IF NOT EXISTS idx_consumer_profiles_active
130
+ ON ${schema}.consumer_profiles (tenant_id, consumer_id, version DESC)
131
+ WHERE deprecated_at IS NULL;
132
+
133
+ DROP TRIGGER IF EXISTS trg_consumer_profiles_updated_at ON ${schema}.consumer_profiles;
134
+ CREATE TRIGGER trg_consumer_profiles_updated_at
135
+ BEFORE UPDATE ON ${schema}.consumer_profiles
136
+ FOR EACH ROW
137
+ EXECUTE FUNCTION ${schema}.set_updated_at();
138
+
139
+ -- timeline_events: append-only event log keyed by (tenant, agent, occurred_at).
140
+ -- category vocabulary is consumer-owned (focus/todo/mood/handoff/narrative/cli
141
+ -- for Miranda default), event shape is strict core. idempotency_key UNIQUE
142
+ -- across the table to make caller-driven dedupe safe.
143
+ CREATE TABLE IF NOT EXISTS ${schema}.timeline_events (
144
+ id BIGSERIAL PRIMARY KEY,
145
+ tenant_id TEXT NOT NULL DEFAULT 'default',
146
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
147
+ source_session_id TEXT,
148
+ agent_id TEXT NOT NULL DEFAULT 'main',
149
+ consumer_profile_id TEXT NOT NULL,
150
+ consumer_profile_version INT NOT NULL,
151
+ consumer_schema_hash TEXT NOT NULL,
152
+ idempotency_key TEXT UNIQUE,
153
+ occurred_at TIMESTAMPTZ NOT NULL,
154
+ source TEXT NOT NULL,
155
+ session_ref TEXT,
156
+ category TEXT NOT NULL,
157
+ text TEXT NOT NULL,
158
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
159
+ search_tsv TSVECTOR,
160
+ search_text TEXT,
161
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
162
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
163
+ );
164
+
165
+ CREATE INDEX IF NOT EXISTS idx_timeline_events_occurred_at
166
+ ON ${schema}.timeline_events (tenant_id, agent_id, occurred_at DESC);
167
+
168
+ CREATE INDEX IF NOT EXISTS idx_timeline_events_category
169
+ ON ${schema}.timeline_events (tenant_id, agent_id, category, occurred_at DESC);
170
+
171
+ CREATE INDEX IF NOT EXISTS idx_timeline_events_search_tsv
172
+ ON ${schema}.timeline_events USING GIN (search_tsv);
173
+
174
+ CREATE INDEX IF NOT EXISTS idx_timeline_events_search_text_trgm
175
+ ON ${schema}.timeline_events USING GIN (search_text gin_trgm_ops);
176
+
177
+ CREATE OR REPLACE FUNCTION ${schema}.timeline_events_search_tsv_update()
178
+ RETURNS trigger
179
+ LANGUAGE plpgsql
180
+ AS $$
181
+ BEGIN
182
+ NEW.search_text :=
183
+ COALESCE(NEW.category, '') || ' ' ||
184
+ COALESCE(NEW.text, '') || ' ' ||
185
+ COALESCE(NEW.metadata::text, '');
186
+
187
+ NEW.search_tsv :=
188
+ setweight(to_tsvector('simple', COALESCE(NEW.category, '')), 'B') ||
189
+ setweight(to_tsvector('simple', COALESCE(NEW.text, '')), 'A');
190
+
191
+ RETURN NEW;
192
+ END;
193
+ $$;
194
+
195
+ DROP TRIGGER IF EXISTS trg_timeline_events_search_tsv ON ${schema}.timeline_events;
196
+ CREATE TRIGGER trg_timeline_events_search_tsv
197
+ BEFORE INSERT OR UPDATE OF category, text, metadata
198
+ ON ${schema}.timeline_events
199
+ FOR EACH ROW
200
+ EXECUTE FUNCTION ${schema}.timeline_events_search_tsv_update();
201
+
202
+ DROP TRIGGER IF EXISTS trg_timeline_events_updated_at ON ${schema}.timeline_events;
203
+ CREATE TRIGGER trg_timeline_events_updated_at
204
+ BEFORE UPDATE ON ${schema}.timeline_events
205
+ FOR EACH ROW
206
+ EXECUTE FUNCTION ${schema}.set_updated_at();
207
+
208
+ -- session_states: latest-snapshot-per-scope with supersede chain.
209
+ -- is_latest + partial unique index enforces at-most-one latest per
210
+ -- (tenant, agent, scope_key); writer supersedes prior latest atomically.
211
+ CREATE TABLE IF NOT EXISTS ${schema}.session_states (
212
+ id BIGSERIAL PRIMARY KEY,
213
+ tenant_id TEXT NOT NULL DEFAULT 'default',
214
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
215
+ source_session_id TEXT,
216
+ agent_id TEXT NOT NULL DEFAULT 'main',
217
+ scope_key TEXT NOT NULL,
218
+ consumer_profile_id TEXT NOT NULL,
219
+ consumer_profile_version INT NOT NULL,
220
+ consumer_schema_hash TEXT NOT NULL,
221
+ idempotency_key TEXT UNIQUE,
222
+ goal TEXT,
223
+ active_work JSONB NOT NULL DEFAULT '[]'::jsonb,
224
+ blockers JSONB NOT NULL DEFAULT '[]'::jsonb,
225
+ affect JSONB NOT NULL DEFAULT '{}'::jsonb,
226
+ payload JSONB NOT NULL,
227
+ is_latest BOOLEAN NOT NULL DEFAULT true,
228
+ supersedes_state_id BIGINT REFERENCES ${schema}.session_states(id) ON DELETE SET NULL,
229
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
230
+ );
231
+
232
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_session_states_latest
233
+ ON ${schema}.session_states (tenant_id, agent_id, scope_key)
234
+ WHERE is_latest = true;
235
+
236
+ CREATE INDEX IF NOT EXISTS idx_session_states_agent
237
+ ON ${schema}.session_states (tenant_id, agent_id, created_at DESC);
238
+
239
+ -- session_handoffs: append-only handoff log. getLatest by (agent) or (agent, session).
240
+ -- No latest-enforcement — every write is a row; retrieval sorts by created_at DESC.
241
+ CREATE TABLE IF NOT EXISTS ${schema}.session_handoffs (
242
+ id BIGSERIAL PRIMARY KEY,
243
+ tenant_id TEXT NOT NULL DEFAULT 'default',
244
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
245
+ source_session_id TEXT NOT NULL,
246
+ agent_id TEXT NOT NULL DEFAULT 'main',
247
+ consumer_profile_id TEXT NOT NULL,
248
+ consumer_profile_version INT NOT NULL,
249
+ consumer_schema_hash TEXT NOT NULL,
250
+ idempotency_key TEXT UNIQUE,
251
+ status TEXT NOT NULL,
252
+ last_step TEXT,
253
+ next_step TEXT,
254
+ blockers JSONB NOT NULL DEFAULT '[]'::jsonb,
255
+ decided JSONB NOT NULL DEFAULT '[]'::jsonb,
256
+ open_loops JSONB NOT NULL DEFAULT '[]'::jsonb,
257
+ payload JSONB NOT NULL,
258
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
259
+ );
260
+
261
+ CREATE INDEX IF NOT EXISTS idx_session_handoffs_agent
262
+ ON ${schema}.session_handoffs (tenant_id, agent_id, created_at DESC);
263
+
264
+ CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
265
+ ON ${schema}.session_handoffs (tenant_id, source_session_id, created_at DESC);
266
+
267
+ -- decisions: append-only decision log. status vocabulary
268
+ -- (proposed/committed/reversed) lives in a CHECK constraint so bad writes
269
+ -- fail at DB boundary. reversed_by_decision_id forms a supersede chain.
270
+ CREATE TABLE IF NOT EXISTS ${schema}.decisions (
271
+ id BIGSERIAL PRIMARY KEY,
272
+ tenant_id TEXT NOT NULL DEFAULT 'default',
273
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
274
+ source_session_id TEXT,
275
+ agent_id TEXT NOT NULL DEFAULT 'main',
276
+ consumer_profile_id TEXT NOT NULL,
277
+ consumer_profile_version INT NOT NULL,
278
+ consumer_schema_hash TEXT NOT NULL,
279
+ idempotency_key TEXT UNIQUE,
280
+ payload JSONB NOT NULL,
281
+ status TEXT NOT NULL
282
+ CHECK (status IN ('proposed', 'committed', 'reversed')),
283
+ decision_text TEXT NOT NULL,
284
+ reason_text TEXT,
285
+ decided_at TIMESTAMPTZ NOT NULL DEFAULT now(),
286
+ reversed_by_decision_id BIGINT REFERENCES ${schema}.decisions(id) ON DELETE SET NULL,
287
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
288
+ search_tsv TSVECTOR,
289
+ search_text TEXT,
290
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
291
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
292
+ );
293
+
294
+ CREATE INDEX IF NOT EXISTS idx_decisions_status
295
+ ON ${schema}.decisions (tenant_id, agent_id, status, decided_at DESC);
296
+
297
+ CREATE INDEX IF NOT EXISTS idx_decisions_session
298
+ ON ${schema}.decisions (tenant_id, source_session_id);
299
+
300
+ CREATE INDEX IF NOT EXISTS idx_decisions_search_tsv
301
+ ON ${schema}.decisions USING GIN (search_tsv);
302
+
303
+ CREATE OR REPLACE FUNCTION ${schema}.decisions_search_tsv_update()
304
+ RETURNS trigger
305
+ LANGUAGE plpgsql
306
+ AS $$
307
+ BEGIN
308
+ NEW.search_text :=
309
+ COALESCE(NEW.decision_text, '') || ' ' ||
310
+ COALESCE(NEW.reason_text, '') || ' ' ||
311
+ COALESCE(NEW.metadata::text, '');
312
+
313
+ NEW.search_tsv :=
314
+ setweight(to_tsvector('simple', COALESCE(NEW.decision_text, '')), 'A') ||
315
+ setweight(to_tsvector('simple', COALESCE(NEW.reason_text, '')), 'B');
316
+
317
+ RETURN NEW;
318
+ END;
319
+ $$;
320
+
321
+ DROP TRIGGER IF EXISTS trg_decisions_search_tsv ON ${schema}.decisions;
322
+ CREATE TRIGGER trg_decisions_search_tsv
323
+ BEFORE INSERT OR UPDATE OF decision_text, reason_text, metadata
324
+ ON ${schema}.decisions
325
+ FOR EACH ROW
326
+ EXECUTE FUNCTION ${schema}.decisions_search_tsv_update();
327
+
328
+ DROP TRIGGER IF EXISTS trg_decisions_updated_at ON ${schema}.decisions;
329
+ CREATE TRIGGER trg_decisions_updated_at
330
+ BEFORE UPDATE ON ${schema}.decisions
331
+ FOR EACH ROW
332
+ EXECUTE FUNCTION ${schema}.set_updated_at();
333
+
334
+ -- artifacts: records producer-declared outputs (daily md, render, export).
335
+ -- Aquifer doesn't interpret payload — producers own shape. status lifecycle
336
+ -- pending → produced|failed|discarded.
337
+ CREATE TABLE IF NOT EXISTS ${schema}.artifacts (
338
+ id BIGSERIAL PRIMARY KEY,
339
+ tenant_id TEXT NOT NULL DEFAULT 'default',
340
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
341
+ source_session_id TEXT,
342
+ agent_id TEXT NOT NULL DEFAULT 'main',
343
+ consumer_profile_id TEXT NOT NULL,
344
+ consumer_profile_version INT NOT NULL,
345
+ consumer_schema_hash TEXT NOT NULL,
346
+ idempotency_key TEXT UNIQUE,
347
+ producer_id TEXT NOT NULL,
348
+ artifact_type TEXT NOT NULL,
349
+ trigger_phase TEXT,
350
+ format TEXT NOT NULL,
351
+ destination TEXT NOT NULL,
352
+ status TEXT NOT NULL DEFAULT 'pending'
353
+ CHECK (status IN ('pending', 'produced', 'failed', 'discarded')),
354
+ content_ref TEXT,
355
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
356
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
357
+ produced_at TIMESTAMPTZ,
358
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
359
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
360
+ );
361
+
362
+ CREATE INDEX IF NOT EXISTS idx_artifacts_lookup
363
+ ON ${schema}.artifacts (tenant_id, agent_id, producer_id, created_at DESC);
364
+
365
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session
366
+ ON ${schema}.artifacts (tenant_id, source_session_id, created_at DESC);
367
+
368
+ CREATE INDEX IF NOT EXISTS idx_artifacts_status
369
+ ON ${schema}.artifacts (tenant_id, status, created_at DESC);
370
+
371
+ DROP TRIGGER IF EXISTS trg_artifacts_updated_at ON ${schema}.artifacts;
372
+ CREATE TRIGGER trg_artifacts_updated_at
373
+ BEFORE UPDATE ON ${schema}.artifacts
374
+ FOR EACH ROW
375
+ EXECUTE FUNCTION ${schema}.set_updated_at();
@@ -0,0 +1,67 @@
1
+ -- Aquifer facts / consolidation extension
2
+ -- Requires: 001-base.sql applied first
3
+ -- Usage: replace ${schema} with actual schema name
4
+ --
5
+ -- Facts store long-lived subject/statement pairs with a lifecycle:
6
+ -- candidate → active → (stale | archived | superseded)
7
+ -- Consumers write candidates during enrich (via writeFactCandidates).
8
+ -- consolidate() then promotes / updates / confirms / archives them.
9
+
10
+ -- =========================================================================
11
+ -- Facts: long-lived current-state statements per (subject, agent)
12
+ -- =========================================================================
13
+ CREATE TABLE IF NOT EXISTS ${schema}.facts (
14
+ id BIGSERIAL PRIMARY KEY,
15
+ tenant_id TEXT NOT NULL DEFAULT 'default',
16
+ subject_key TEXT NOT NULL,
17
+ subject_label TEXT NOT NULL,
18
+ statement TEXT NOT NULL,
19
+ status TEXT NOT NULL DEFAULT 'candidate'
20
+ CHECK (status IN ('candidate','active','stale','archived','superseded')),
21
+ importance SMALLINT NOT NULL DEFAULT 5,
22
+ source_session_id TEXT,
23
+ agent_id TEXT NOT NULL DEFAULT 'main',
24
+ evidence JSONB NOT NULL DEFAULT '[]'::jsonb,
25
+ superseded_by BIGINT REFERENCES ${schema}.facts(id) ON DELETE SET NULL,
26
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ last_confirmed_at TIMESTAMPTZ NOT NULL DEFAULT now()
28
+ );
29
+
30
+ -- Migration: add tenant_id if upgrading from a legacy facts table (no tenant column).
31
+ ALTER TABLE ${schema}.facts ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default';
32
+
33
+ -- At most one active fact per (tenant, subject, agent)
34
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_facts_active_subject
35
+ ON ${schema}.facts (tenant_id, subject_key, agent_id)
36
+ WHERE status = 'active';
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_facts_active_agent
39
+ ON ${schema}.facts (tenant_id, agent_id, importance DESC, last_confirmed_at DESC)
40
+ WHERE status = 'active';
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_facts_status_created
43
+ ON ${schema}.facts (tenant_id, status, first_seen_at DESC);
44
+
45
+ CREATE INDEX IF NOT EXISTS idx_facts_subject
46
+ ON ${schema}.facts (tenant_id, subject_key);
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_facts_source_session
49
+ ON ${schema}.facts (source_session_id)
50
+ WHERE source_session_id IS NOT NULL;
51
+
52
+ COMMENT ON TABLE ${schema}.facts IS 'Fact candidates and active facts per (tenant, subject, agent) with consolidation lifecycle';
53
+
54
+ -- =========================================================================
55
+ -- Fact ↔ Entity join (optional, only when entities enabled)
56
+ -- =========================================================================
57
+ CREATE TABLE IF NOT EXISTS ${schema}.fact_entities (
58
+ id BIGSERIAL PRIMARY KEY,
59
+ fact_id BIGINT NOT NULL REFERENCES ${schema}.facts(id) ON DELETE CASCADE,
60
+ entity_id BIGINT NOT NULL,
61
+ UNIQUE (fact_id, entity_id)
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_fact_entities_entity_id
65
+ ON ${schema}.fact_entities (entity_id);
66
+
67
+ COMMENT ON TABLE ${schema}.fact_entities IS 'Join table linking facts to entities (FK to entities is soft — entities table is optional)';