@mnexium/core 0.1.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.
@@ -0,0 +1,1017 @@
1
+ import type { Pool } from "pg";
2
+ import { randomUUID } from "crypto";
3
+ import type {
4
+ Claim,
5
+ ClaimAssertion,
6
+ ClaimEdge,
7
+ Memory,
8
+ MemoryRecallEvent,
9
+ MemoryRecallStats,
10
+ ResolvedTruthSlot,
11
+ } from "../../contracts/types";
12
+ import type {
13
+ CoreStore,
14
+ CreateClaimInput,
15
+ CreateMemoryInput,
16
+ UpdateMemoryInput,
17
+ } from "../../contracts/storage";
18
+
19
+ function clampInt(value: number | undefined, min: number, max: number, fallback: number): number {
20
+ const n = Number(value);
21
+ if (!Number.isFinite(n)) return fallback;
22
+ if (n < min) return min;
23
+ if (n > max) return max;
24
+ return Math.floor(n);
25
+ }
26
+
27
+ function clampFloat(value: number | undefined, min: number, max: number, fallback: number): number {
28
+ const n = Number(value);
29
+ if (!Number.isFinite(n)) return fallback;
30
+ if (n < min) return min;
31
+ if (n > max) return max;
32
+ return n;
33
+ }
34
+
35
+ function toVectorLiteral(embedding: number[] | null | undefined): string | null {
36
+ if (!Array.isArray(embedding) || embedding.length === 0) return null;
37
+ const safe = embedding
38
+ .map((v) => Number(v))
39
+ .filter((v) => Number.isFinite(v))
40
+ .map((v) => (Object.is(v, -0) ? 0 : v));
41
+ if (safe.length === 0) return null;
42
+ return `[${safe.join(",")}]`;
43
+ }
44
+
45
+ const SEARCH_STOP_WORDS = new Set<string>([
46
+ "a",
47
+ "an",
48
+ "and",
49
+ "are",
50
+ "as",
51
+ "at",
52
+ "be",
53
+ "but",
54
+ "by",
55
+ "does",
56
+ "for",
57
+ "from",
58
+ "how",
59
+ "i",
60
+ "in",
61
+ "is",
62
+ "it",
63
+ "me",
64
+ "my",
65
+ "of",
66
+ "on",
67
+ "or",
68
+ "our",
69
+ "personal",
70
+ "preference",
71
+ "preferences",
72
+ "the",
73
+ "to",
74
+ "user",
75
+ "users",
76
+ "what",
77
+ "where",
78
+ "who",
79
+ "why",
80
+ "you",
81
+ "your",
82
+ ]);
83
+
84
+ function buildSearchTokens(query: string): string[] {
85
+ const raw = String(query || "")
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9\s]/g, " ")
88
+ .split(/\s+/)
89
+ .map((v) => v.trim())
90
+ .filter(Boolean);
91
+ const filtered = raw.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token));
92
+ const seen = new Set<string>();
93
+ const out: string[] = [];
94
+ for (const token of filtered) {
95
+ if (seen.has(token)) continue;
96
+ seen.add(token);
97
+ out.push(token);
98
+ }
99
+ return out.slice(0, 10);
100
+ }
101
+
102
+ function inferSlot(predicate: string): string {
103
+ return String(predicate || "").trim();
104
+ }
105
+
106
+ function inferClaimType(predicate: string): string {
107
+ const p = String(predicate || "").trim();
108
+ if (!p) return "fact";
109
+ if (p.startsWith("favorite_") || p.startsWith("likes_") || p.startsWith("dislikes_")) return "preference";
110
+ if (p.includes("goal") || p.startsWith("wants_")) return "goal";
111
+ if (p.startsWith("did_") || p.startsWith("event_")) return "event";
112
+ return "fact";
113
+ }
114
+
115
+ export class PostgresCoreStore implements CoreStore {
116
+ private pool: Pool;
117
+
118
+ constructor(pool: Pool) {
119
+ this.pool = pool;
120
+ }
121
+
122
+ async listMemories(args: {
123
+ project_id: string;
124
+ subject_id: string;
125
+ limit: number;
126
+ offset: number;
127
+ include_deleted?: boolean;
128
+ include_superseded?: boolean;
129
+ }): Promise<Memory[]> {
130
+ const limit = clampInt(args.limit, 1, 200, 50);
131
+ const offset = clampInt(args.offset, 0, 1_000_000, 0);
132
+ const includeDeleted = args.include_deleted === true;
133
+ const includeSuperseded = args.include_superseded === true;
134
+
135
+ const result = await this.pool.query(
136
+ `
137
+ SELECT *
138
+ FROM memories
139
+ WHERE project_id = $1
140
+ AND subject_id = $2
141
+ AND ($3::boolean = TRUE OR is_deleted = FALSE)
142
+ AND ($4::boolean = TRUE OR status = 'active')
143
+ ORDER BY created_at DESC
144
+ LIMIT $5 OFFSET $6
145
+ `,
146
+ [args.project_id, args.subject_id, includeDeleted, includeSuperseded, limit, offset],
147
+ );
148
+
149
+ return result.rows;
150
+ }
151
+
152
+ async searchMemories(args: {
153
+ project_id: string;
154
+ subject_id: string;
155
+ q: string;
156
+ query_embedding: number[] | null;
157
+ limit: number;
158
+ min_score: number;
159
+ }): Promise<Array<Memory & { score: number; effective_score: number }>> {
160
+ const limit = clampInt(args.limit, 1, 200, 25);
161
+ const minScore = clampFloat(args.min_score, 0, 100, 30);
162
+ const q = String(args.q || "").trim();
163
+ const searchTokens = buildSearchTokens(q);
164
+ const vector = toVectorLiteral(args.query_embedding);
165
+
166
+ if (vector) {
167
+ const result = await this.pool.query(
168
+ `
169
+ SELECT
170
+ m.*,
171
+ (
172
+ CASE
173
+ WHEN m.embedding IS NULL THEN 0
174
+ ELSE ((1 - (m.embedding <=> $3::vector)) * 100)
175
+ END
176
+ ) AS score,
177
+ ((0.60 * (
178
+ CASE
179
+ WHEN m.embedding IS NULL THEN 0
180
+ ELSE ((1 - (m.embedding <=> $3::vector)) * 100)
181
+ END
182
+ ))
183
+ + (0.25 * m.importance)
184
+ + (0.15 * m.confidence * 100)
185
+ + (
186
+ CASE
187
+ WHEN $4::text <> '' AND m.text ILIKE ('%' || $4 || '%') THEN 20
188
+ WHEN EXISTS (
189
+ SELECT 1
190
+ FROM unnest($6::text[]) AS tok(token)
191
+ WHERE m.text ILIKE ('%' || tok.token || '%')
192
+ ) THEN 16
193
+ ELSE 0
194
+ END
195
+ )
196
+ ) AS effective_score
197
+ FROM memories m
198
+ WHERE m.project_id = $1
199
+ AND m.subject_id = $2
200
+ AND m.is_deleted = FALSE
201
+ AND m.status = 'active'
202
+ AND (
203
+ $4::text = ''
204
+ OR m.text ILIKE ('%' || $4 || '%')
205
+ OR EXISTS (
206
+ SELECT 1
207
+ FROM unnest($6::text[]) AS tok(token)
208
+ WHERE m.text ILIKE ('%' || tok.token || '%')
209
+ )
210
+ OR (
211
+ m.embedding IS NOT NULL
212
+ AND ((1 - (m.embedding <=> $3::vector)) * 100) >= $5
213
+ )
214
+ )
215
+ ORDER BY effective_score DESC, score DESC
216
+ LIMIT $7
217
+ `,
218
+ [args.project_id, args.subject_id, vector, q, minScore, searchTokens, limit],
219
+ );
220
+ return result.rows;
221
+ }
222
+
223
+ const result = await this.pool.query(
224
+ `
225
+ SELECT
226
+ m.*,
227
+ 0::double precision AS score,
228
+ (0.25 * m.importance + 0.15 * m.confidence * 100)::double precision AS effective_score
229
+ FROM memories m
230
+ WHERE m.project_id = $1
231
+ AND m.subject_id = $2
232
+ AND m.is_deleted = FALSE
233
+ AND m.status = 'active'
234
+ AND (
235
+ $3::text = ''
236
+ OR m.text ILIKE ('%' || $3 || '%')
237
+ OR EXISTS (
238
+ SELECT 1
239
+ FROM unnest($5::text[]) AS tok(token)
240
+ WHERE m.text ILIKE ('%' || tok.token || '%')
241
+ )
242
+ )
243
+ ORDER BY m.importance DESC, m.created_at DESC
244
+ LIMIT $4
245
+ `,
246
+ [args.project_id, args.subject_id, q, limit, searchTokens],
247
+ );
248
+ return result.rows;
249
+ }
250
+
251
+ async createMemory(input: CreateMemoryInput): Promise<Memory> {
252
+ const result = await this.pool.query(
253
+ `
254
+ INSERT INTO memories (
255
+ id, project_id, subject_id, text, kind, visibility, importance,
256
+ confidence, is_temporal, tags, metadata, embedding, source_type,
257
+ status, superseded_by, is_deleted, last_reinforced_at
258
+ )
259
+ VALUES (
260
+ $1, $2, $3, $4, $5, $6, $7,
261
+ $8, $9, $10, $11::jsonb, $12::vector, $13,
262
+ 'active', NULL, FALSE, NOW()
263
+ )
264
+ RETURNING *
265
+ `,
266
+ [
267
+ input.id,
268
+ input.project_id,
269
+ input.subject_id,
270
+ String(input.text || "").trim(),
271
+ input.kind || "fact",
272
+ input.visibility || "private",
273
+ clampInt(input.importance, 0, 100, 50),
274
+ clampFloat(input.confidence, 0, 1, 0.95),
275
+ input.is_temporal === true,
276
+ Array.isArray(input.tags) ? input.tags.map(String) : [],
277
+ JSON.stringify(input.metadata || {}),
278
+ toVectorLiteral(input.embedding),
279
+ input.source_type || "explicit",
280
+ ],
281
+ );
282
+ return result.rows[0];
283
+ }
284
+
285
+ async getMemory(args: { project_id: string; id: string }): Promise<Memory | null> {
286
+ const result = await this.pool.query(
287
+ `
288
+ SELECT *
289
+ FROM memories
290
+ WHERE project_id = $1
291
+ AND id = $2
292
+ LIMIT 1
293
+ `,
294
+ [args.project_id, args.id],
295
+ );
296
+ return result.rows[0] || null;
297
+ }
298
+
299
+ async getMemoryClaims(args: { project_id: string; memory_id: string }): Promise<ClaimAssertion[]> {
300
+ const result = await this.pool.query(
301
+ `
302
+ SELECT
303
+ ca.assertion_id,
304
+ ca.project_id,
305
+ ca.subject_id,
306
+ ca.claim_id,
307
+ ca.memory_id,
308
+ ca.predicate,
309
+ ca.object_type,
310
+ ca.value_string,
311
+ ca.value_number,
312
+ ca.value_date,
313
+ ca.value_json,
314
+ ca.confidence,
315
+ ca.status,
316
+ ca.first_seen_at,
317
+ ca.last_seen_at
318
+ FROM claim_assertions ca
319
+ WHERE ca.project_id = $1
320
+ AND ca.memory_id = $2
321
+ ORDER BY ca.last_seen_at DESC
322
+ `,
323
+ [args.project_id, args.memory_id],
324
+ );
325
+ return result.rows;
326
+ }
327
+
328
+ async updateMemory(args: { project_id: string; id: string; patch: UpdateMemoryInput }): Promise<Memory | null> {
329
+ const existing = await this.getMemory({ project_id: args.project_id, id: args.id });
330
+ if (!existing) return null;
331
+
332
+ const patch = args.patch || {};
333
+ const merged = {
334
+ text: patch.text !== undefined ? String(patch.text).trim() : existing.text,
335
+ kind: patch.kind !== undefined ? patch.kind : existing.kind,
336
+ visibility: patch.visibility !== undefined ? patch.visibility : existing.visibility,
337
+ importance: patch.importance !== undefined ? clampInt(patch.importance, 0, 100, existing.importance) : existing.importance,
338
+ confidence: patch.confidence !== undefined ? clampFloat(patch.confidence, 0, 1, existing.confidence) : existing.confidence,
339
+ is_temporal: patch.is_temporal !== undefined ? patch.is_temporal : existing.is_temporal,
340
+ tags: patch.tags !== undefined ? patch.tags.map(String) : existing.tags,
341
+ metadata: patch.metadata !== undefined ? patch.metadata : existing.metadata,
342
+ embedding: patch.embedding !== undefined ? patch.embedding : null,
343
+ };
344
+
345
+ const result = await this.pool.query(
346
+ `
347
+ UPDATE memories
348
+ SET
349
+ text = $3,
350
+ kind = $4,
351
+ visibility = $5,
352
+ importance = $6,
353
+ confidence = $7,
354
+ is_temporal = $8,
355
+ tags = $9,
356
+ metadata = $10::jsonb,
357
+ embedding = COALESCE($11::vector, embedding)
358
+ WHERE project_id = $1
359
+ AND id = $2
360
+ RETURNING *
361
+ `,
362
+ [
363
+ args.project_id,
364
+ args.id,
365
+ merged.text,
366
+ merged.kind,
367
+ merged.visibility,
368
+ merged.importance,
369
+ merged.confidence,
370
+ merged.is_temporal,
371
+ merged.tags,
372
+ JSON.stringify(merged.metadata || {}),
373
+ toVectorLiteral(merged.embedding),
374
+ ],
375
+ );
376
+
377
+ return result.rows[0] || null;
378
+ }
379
+
380
+ async deleteMemory(args: { project_id: string; id: string }): Promise<{ ok: true; deleted: boolean }> {
381
+ const result = await this.pool.query(
382
+ `
383
+ UPDATE memories
384
+ SET is_deleted = TRUE
385
+ WHERE project_id = $1
386
+ AND id = $2
387
+ AND is_deleted = FALSE
388
+ `,
389
+ [args.project_id, args.id],
390
+ );
391
+ return { ok: true, deleted: result.rowCount > 0 };
392
+ }
393
+
394
+ async listSupersededMemories(args: {
395
+ project_id: string;
396
+ subject_id: string;
397
+ limit: number;
398
+ offset: number;
399
+ }): Promise<Memory[]> {
400
+ const limit = clampInt(args.limit, 1, 200, 50);
401
+ const offset = clampInt(args.offset, 0, 1_000_000, 0);
402
+ const result = await this.pool.query(
403
+ `
404
+ SELECT *
405
+ FROM memories
406
+ WHERE project_id = $1
407
+ AND subject_id = $2
408
+ AND is_deleted = FALSE
409
+ AND status = 'superseded'
410
+ ORDER BY created_at DESC
411
+ LIMIT $3 OFFSET $4
412
+ `,
413
+ [args.project_id, args.subject_id, limit, offset],
414
+ );
415
+ return result.rows;
416
+ }
417
+
418
+ async restoreMemory(args: { project_id: string; id: string }): Promise<Memory | null> {
419
+ const result = await this.pool.query(
420
+ `
421
+ UPDATE memories
422
+ SET status = 'active', superseded_by = NULL
423
+ WHERE project_id = $1
424
+ AND id = $2
425
+ AND is_deleted = FALSE
426
+ RETURNING *
427
+ `,
428
+ [args.project_id, args.id],
429
+ );
430
+ return result.rows[0] || null;
431
+ }
432
+
433
+ async findDuplicateMemory(args: {
434
+ project_id: string;
435
+ subject_id: string;
436
+ embedding: number[];
437
+ threshold: number;
438
+ }): Promise<{ id: string; similarity: number } | null> {
439
+ const vector = toVectorLiteral(args.embedding);
440
+ if (!vector) return null;
441
+ const threshold = clampFloat(args.threshold, 0, 100, 85);
442
+ const result = await this.pool.query(
443
+ `
444
+ SELECT
445
+ m.id,
446
+ ((1 - (m.embedding <=> $3::vector)) * 100) AS similarity
447
+ FROM memories m
448
+ WHERE m.project_id = $1
449
+ AND m.subject_id = $2
450
+ AND m.is_deleted = FALSE
451
+ AND m.status = 'active'
452
+ AND m.embedding IS NOT NULL
453
+ AND ((1 - (m.embedding <=> $3::vector)) * 100) >= $4
454
+ ORDER BY similarity DESC
455
+ LIMIT 1
456
+ `,
457
+ [args.project_id, args.subject_id, vector, threshold],
458
+ );
459
+ return result.rows[0] || null;
460
+ }
461
+
462
+ async findConflictingMemories(args: {
463
+ project_id: string;
464
+ subject_id: string;
465
+ embedding: number[];
466
+ min_similarity: number;
467
+ max_similarity: number;
468
+ limit: number;
469
+ }): Promise<Array<{ id: string; similarity: number }>> {
470
+ const vector = toVectorLiteral(args.embedding);
471
+ if (!vector) return [];
472
+ const minSimilarity = clampFloat(args.min_similarity, 0, 100, 60);
473
+ const maxSimilarity = clampFloat(args.max_similarity, minSimilarity, 100, 85);
474
+ const limit = clampInt(args.limit, 1, 200, 25);
475
+ const result = await this.pool.query(
476
+ `
477
+ SELECT
478
+ m.id,
479
+ ((1 - (m.embedding <=> $3::vector)) * 100) AS similarity
480
+ FROM memories m
481
+ WHERE m.project_id = $1
482
+ AND m.subject_id = $2
483
+ AND m.is_deleted = FALSE
484
+ AND m.status = 'active'
485
+ AND m.embedding IS NOT NULL
486
+ AND ((1 - (m.embedding <=> $3::vector)) * 100) >= $4
487
+ AND ((1 - (m.embedding <=> $3::vector)) * 100) < $5
488
+ ORDER BY similarity DESC
489
+ LIMIT $6
490
+ `,
491
+ [args.project_id, args.subject_id, vector, minSimilarity, maxSimilarity, limit],
492
+ );
493
+ return result.rows;
494
+ }
495
+
496
+ async supersedeMemories(args: {
497
+ project_id: string;
498
+ subject_id: string;
499
+ memory_ids: string[];
500
+ superseded_by: string;
501
+ }): Promise<number> {
502
+ const ids = Array.isArray(args.memory_ids) ? args.memory_ids.map((v) => String(v || "").trim()).filter(Boolean) : [];
503
+ if (ids.length === 0) return 0;
504
+ const result = await this.pool.query(
505
+ `
506
+ UPDATE memories
507
+ SET status = 'superseded', superseded_by = $4
508
+ WHERE project_id = $1
509
+ AND subject_id = $2
510
+ AND id = ANY($3::text[])
511
+ AND is_deleted = FALSE
512
+ AND status = 'active'
513
+ `,
514
+ [args.project_id, args.subject_id, ids, args.superseded_by],
515
+ );
516
+ return Number(result.rowCount || 0);
517
+ }
518
+
519
+ async getRecallEventsByChat(args: { project_id: string; chat_id: string }): Promise<MemoryRecallEvent[]> {
520
+ const result = await this.pool.query(
521
+ `
522
+ SELECT *
523
+ FROM memory_recall_events
524
+ WHERE project_id = $1
525
+ AND chat_id = $2
526
+ ORDER BY recalled_at ASC, message_index ASC
527
+ `,
528
+ [args.project_id, args.chat_id],
529
+ );
530
+ return result.rows;
531
+ }
532
+
533
+ async getRecallEventsByMemory(args: {
534
+ project_id: string;
535
+ memory_id: string;
536
+ limit: number;
537
+ }): Promise<MemoryRecallEvent[]> {
538
+ const limit = clampInt(args.limit, 1, 1000, 100);
539
+ const result = await this.pool.query(
540
+ `
541
+ SELECT *
542
+ FROM memory_recall_events
543
+ WHERE project_id = $1
544
+ AND memory_id = $2
545
+ ORDER BY recalled_at DESC
546
+ LIMIT $3
547
+ `,
548
+ [args.project_id, args.memory_id, limit],
549
+ );
550
+ return result.rows;
551
+ }
552
+
553
+ async getMemoryRecallStats(args: {
554
+ project_id: string;
555
+ memory_id: string;
556
+ }): Promise<MemoryRecallStats> {
557
+ const result = await this.pool.query(
558
+ `
559
+ SELECT
560
+ COUNT(*)::int AS total_recalls,
561
+ COUNT(DISTINCT chat_id)::int AS unique_chats,
562
+ COUNT(DISTINCT subject_id)::int AS unique_subjects,
563
+ COALESCE(AVG(similarity_score), 0)::double precision AS avg_score,
564
+ MIN(recalled_at) AS first_recalled_at,
565
+ MAX(recalled_at) AS last_recalled_at
566
+ FROM memory_recall_events
567
+ WHERE project_id = $1
568
+ AND memory_id = $2
569
+ `,
570
+ [args.project_id, args.memory_id],
571
+ );
572
+ return (
573
+ result.rows[0] || {
574
+ total_recalls: 0,
575
+ unique_chats: 0,
576
+ unique_subjects: 0,
577
+ avg_score: 0,
578
+ first_recalled_at: null,
579
+ last_recalled_at: null,
580
+ }
581
+ );
582
+ }
583
+
584
+ async createClaim(input: CreateClaimInput): Promise<Claim> {
585
+ const slot = input.slot || inferSlot(input.predicate);
586
+ const claimType = input.claim_type || inferClaimType(input.predicate);
587
+ const confidence = clampFloat(input.confidence, 0, 1, 0.8);
588
+ const importance = clampFloat(input.importance, 0, 1, 0.5);
589
+
590
+ const client = await this.pool.connect();
591
+ try {
592
+ await client.query("BEGIN");
593
+
594
+ const claimResult = await client.query(
595
+ `
596
+ INSERT INTO claims (
597
+ claim_id, project_id, subject_id, predicate, object_value, slot, claim_type,
598
+ confidence, importance, tags, source_memory_id, source_observation_id,
599
+ subject_entity, status, embedding, valid_from, valid_until
600
+ )
601
+ VALUES (
602
+ $1, $2, $3, $4, $5, $6, $7,
603
+ $8, $9, $10, $11, $12,
604
+ $13, 'active', $14::vector, $15::timestamptz, $16::timestamptz
605
+ )
606
+ RETURNING *
607
+ `,
608
+ [
609
+ input.claim_id,
610
+ input.project_id,
611
+ input.subject_id,
612
+ input.predicate,
613
+ input.object_value,
614
+ slot,
615
+ claimType,
616
+ confidence,
617
+ importance,
618
+ Array.isArray(input.tags) ? input.tags.map(String) : [],
619
+ input.source_memory_id || null,
620
+ input.source_observation_id || null,
621
+ input.subject_entity || "self",
622
+ toVectorLiteral(input.embedding),
623
+ input.valid_from || null,
624
+ input.valid_until || null,
625
+ ],
626
+ );
627
+
628
+ const assertionId = `ast_${randomUUID()}`;
629
+ await client.query(
630
+ `
631
+ INSERT INTO claim_assertions (
632
+ assertion_id, project_id, subject_id, claim_id, memory_id,
633
+ predicate, object_type, value_string, confidence, status
634
+ )
635
+ VALUES ($1, $2, $3, $4, $5, $6, 'string', $7, $8, 'active')
636
+ `,
637
+ [
638
+ assertionId,
639
+ input.project_id,
640
+ input.subject_id,
641
+ input.claim_id,
642
+ input.source_memory_id || null,
643
+ input.predicate,
644
+ input.object_value,
645
+ confidence,
646
+ ],
647
+ );
648
+
649
+ await client.query(
650
+ `
651
+ INSERT INTO slot_state (
652
+ project_id, subject_id, slot, active_claim_id, status, replaced_by_claim_id
653
+ )
654
+ VALUES ($1, $2, $3, $4, 'active', NULL)
655
+ ON CONFLICT (project_id, subject_id, slot)
656
+ DO UPDATE SET
657
+ active_claim_id = EXCLUDED.active_claim_id,
658
+ status = 'active',
659
+ replaced_by_claim_id = NULL,
660
+ updated_at = NOW()
661
+ `,
662
+ [input.project_id, input.subject_id, slot, input.claim_id],
663
+ );
664
+
665
+ await client.query("COMMIT");
666
+ return claimResult.rows[0];
667
+ } catch (err) {
668
+ await client.query("ROLLBACK");
669
+ throw err;
670
+ } finally {
671
+ client.release();
672
+ }
673
+ }
674
+
675
+ async getClaim(args: { project_id: string; claim_id: string }): Promise<Claim | null> {
676
+ const result = await this.pool.query(
677
+ `
678
+ SELECT *
679
+ FROM claims
680
+ WHERE project_id = $1
681
+ AND claim_id = $2
682
+ LIMIT 1
683
+ `,
684
+ [args.project_id, args.claim_id],
685
+ );
686
+ return result.rows[0] || null;
687
+ }
688
+
689
+ async getAssertionsForClaim(args: { project_id: string; claim_id: string }): Promise<ClaimAssertion[]> {
690
+ const result = await this.pool.query(
691
+ `
692
+ SELECT *
693
+ FROM claim_assertions
694
+ WHERE project_id = $1
695
+ AND claim_id = $2
696
+ ORDER BY last_seen_at DESC
697
+ `,
698
+ [args.project_id, args.claim_id],
699
+ );
700
+ return result.rows;
701
+ }
702
+
703
+ async getEdgesForClaim(args: { project_id: string; claim_id: string }): Promise<ClaimEdge[]> {
704
+ const result = await this.pool.query(
705
+ `
706
+ SELECT *
707
+ FROM claim_edges
708
+ WHERE project_id = $1
709
+ AND (from_claim_id = $2 OR to_claim_id = $2)
710
+ ORDER BY created_at DESC
711
+ `,
712
+ [args.project_id, args.claim_id],
713
+ );
714
+ return result.rows;
715
+ }
716
+
717
+ async getCurrentTruth(args: { project_id: string; subject_id: string }): Promise<ResolvedTruthSlot[]> {
718
+ const result = await this.pool.query(
719
+ `
720
+ SELECT
721
+ ss.slot,
722
+ ss.active_claim_id,
723
+ c.predicate,
724
+ c.object_value,
725
+ c.claim_type,
726
+ c.confidence,
727
+ c.tags,
728
+ ss.updated_at,
729
+ c.source_memory_id,
730
+ c.source_observation_id
731
+ FROM slot_state ss
732
+ INNER JOIN claims c
733
+ ON c.project_id = ss.project_id
734
+ AND c.claim_id = ss.active_claim_id
735
+ WHERE ss.project_id = $1
736
+ AND ss.subject_id = $2
737
+ AND ss.status = 'active'
738
+ AND c.status = 'active'
739
+ ORDER BY ss.updated_at DESC
740
+ `,
741
+ [args.project_id, args.subject_id],
742
+ );
743
+ return result.rows;
744
+ }
745
+
746
+ async getCurrentSlot(args: {
747
+ project_id: string;
748
+ subject_id: string;
749
+ slot: string;
750
+ }): Promise<ResolvedTruthSlot | null> {
751
+ const result = await this.pool.query(
752
+ `
753
+ SELECT
754
+ ss.slot,
755
+ ss.active_claim_id,
756
+ c.predicate,
757
+ c.object_value,
758
+ c.claim_type,
759
+ c.confidence,
760
+ c.tags,
761
+ ss.updated_at,
762
+ c.source_memory_id,
763
+ c.source_observation_id
764
+ FROM slot_state ss
765
+ INNER JOIN claims c
766
+ ON c.project_id = ss.project_id
767
+ AND c.claim_id = ss.active_claim_id
768
+ WHERE ss.project_id = $1
769
+ AND ss.subject_id = $2
770
+ AND ss.slot = $3
771
+ AND ss.status = 'active'
772
+ AND c.status = 'active'
773
+ LIMIT 1
774
+ `,
775
+ [args.project_id, args.subject_id, args.slot],
776
+ );
777
+ return result.rows[0] || null;
778
+ }
779
+
780
+ async getSlots(args: {
781
+ project_id: string;
782
+ subject_id: string;
783
+ limit: number;
784
+ }): Promise<Array<ResolvedTruthSlot & { status: string }>> {
785
+ const limit = clampInt(args.limit, 1, 500, 100);
786
+ const result = await this.pool.query(
787
+ `
788
+ SELECT
789
+ ss.slot,
790
+ ss.active_claim_id,
791
+ COALESCE(c.predicate, '') AS predicate,
792
+ COALESCE(c.object_value, '') AS object_value,
793
+ COALESCE(c.claim_type, '') AS claim_type,
794
+ COALESCE(c.confidence, 0) AS confidence,
795
+ COALESCE(c.tags, '{}') AS tags,
796
+ ss.updated_at,
797
+ c.source_memory_id,
798
+ c.source_observation_id,
799
+ ss.status
800
+ FROM slot_state ss
801
+ LEFT JOIN claims c
802
+ ON c.project_id = ss.project_id
803
+ AND c.claim_id = ss.active_claim_id
804
+ WHERE ss.project_id = $1
805
+ AND ss.subject_id = $2
806
+ ORDER BY ss.updated_at DESC
807
+ LIMIT $3
808
+ `,
809
+ [args.project_id, args.subject_id, limit],
810
+ );
811
+ return result.rows;
812
+ }
813
+
814
+ async getClaimGraph(args: {
815
+ project_id: string;
816
+ subject_id: string;
817
+ limit: number;
818
+ }): Promise<{ claims: Claim[]; edges: ClaimEdge[] }> {
819
+ const limit = clampInt(args.limit, 1, 200, 50);
820
+ const claimResult = await this.pool.query(
821
+ `
822
+ SELECT *
823
+ FROM claims
824
+ WHERE project_id = $1
825
+ AND subject_id = $2
826
+ ORDER BY asserted_at DESC
827
+ LIMIT $3
828
+ `,
829
+ [args.project_id, args.subject_id, limit],
830
+ );
831
+ const claims = claimResult.rows as Claim[];
832
+ if (claims.length === 0) return { claims: [], edges: [] };
833
+
834
+ const claimIds = claims.map((c) => c.claim_id);
835
+ const edgeResult = await this.pool.query(
836
+ `
837
+ SELECT *
838
+ FROM claim_edges
839
+ WHERE project_id = $1
840
+ AND (from_claim_id = ANY($2::text[]) OR to_claim_id = ANY($2::text[]))
841
+ ORDER BY created_at DESC
842
+ `,
843
+ [args.project_id, claimIds],
844
+ );
845
+
846
+ return { claims, edges: edgeResult.rows };
847
+ }
848
+
849
+ async getClaimHistory(args: {
850
+ project_id: string;
851
+ subject_id: string;
852
+ slot?: string | null;
853
+ limit: number;
854
+ }): Promise<{ claims: Claim[]; edges: ClaimEdge[]; by_slot: Record<string, Claim[]> }> {
855
+ const limit = clampInt(args.limit, 1, 500, 100);
856
+ const hasSlot = !!(args.slot && String(args.slot).trim());
857
+ const claimResult = await this.pool.query(
858
+ `
859
+ SELECT *
860
+ FROM claims
861
+ WHERE project_id = $1
862
+ AND subject_id = $2
863
+ AND ($3::boolean = FALSE OR slot = $4)
864
+ ORDER BY asserted_at DESC
865
+ LIMIT $5
866
+ `,
867
+ [args.project_id, args.subject_id, hasSlot, args.slot || null, limit],
868
+ );
869
+ const claims = claimResult.rows as Claim[];
870
+ const claimIds = claims.map((c) => c.claim_id);
871
+
872
+ const bySlot: Record<string, Claim[]> = {};
873
+ for (const claim of claims) {
874
+ const slot = claim.slot || "_unknown";
875
+ if (!bySlot[slot]) bySlot[slot] = [];
876
+ bySlot[slot].push(claim);
877
+ }
878
+
879
+ if (claimIds.length === 0) return { claims, edges: [], by_slot: bySlot };
880
+
881
+ const edgeResult = await this.pool.query(
882
+ `
883
+ SELECT *
884
+ FROM claim_edges
885
+ WHERE project_id = $1
886
+ AND edge_type = 'supersedes'
887
+ AND (from_claim_id = ANY($2::text[]) OR to_claim_id = ANY($2::text[]))
888
+ ORDER BY created_at DESC
889
+ `,
890
+ [args.project_id, claimIds],
891
+ );
892
+
893
+ return { claims, edges: edgeResult.rows, by_slot: bySlot };
894
+ }
895
+
896
+ async retractClaim(args: {
897
+ project_id: string;
898
+ claim_id: string;
899
+ reason: string;
900
+ }): Promise<{
901
+ success: boolean;
902
+ claim_id: string;
903
+ slot: string;
904
+ previous_claim_id: string | null;
905
+ restored_previous: boolean;
906
+ }> {
907
+ const client = await this.pool.connect();
908
+ try {
909
+ await client.query("BEGIN");
910
+
911
+ const currentResult = await client.query(
912
+ `
913
+ SELECT *
914
+ FROM claims
915
+ WHERE project_id = $1
916
+ AND claim_id = $2
917
+ LIMIT 1
918
+ `,
919
+ [args.project_id, args.claim_id],
920
+ );
921
+ const claim = currentResult.rows[0] as Claim | undefined;
922
+ if (!claim) {
923
+ await client.query("ROLLBACK");
924
+ return {
925
+ success: false,
926
+ claim_id: args.claim_id,
927
+ slot: "",
928
+ previous_claim_id: null,
929
+ restored_previous: false,
930
+ };
931
+ }
932
+
933
+ await client.query(
934
+ `
935
+ UPDATE claims
936
+ SET status = 'retracted',
937
+ retracted_at = NOW(),
938
+ retract_reason = $3
939
+ WHERE project_id = $1
940
+ AND claim_id = $2
941
+ `,
942
+ [args.project_id, args.claim_id, args.reason],
943
+ );
944
+
945
+ const previousResult = await client.query(
946
+ `
947
+ SELECT claim_id
948
+ FROM claims
949
+ WHERE project_id = $1
950
+ AND subject_id = $2
951
+ AND slot = $3
952
+ AND status = 'active'
953
+ AND claim_id <> $4
954
+ ORDER BY asserted_at DESC
955
+ LIMIT 1
956
+ `,
957
+ [args.project_id, claim.subject_id, claim.slot, args.claim_id],
958
+ );
959
+ const previous = previousResult.rows[0]?.claim_id || null;
960
+
961
+ await client.query(
962
+ `
963
+ INSERT INTO slot_state (
964
+ project_id, subject_id, slot, active_claim_id, status, replaced_by_claim_id
965
+ )
966
+ VALUES (
967
+ $1, $2, $3, $4, $5, $6
968
+ )
969
+ ON CONFLICT (project_id, subject_id, slot)
970
+ DO UPDATE SET
971
+ active_claim_id = EXCLUDED.active_claim_id,
972
+ status = EXCLUDED.status,
973
+ replaced_by_claim_id = EXCLUDED.replaced_by_claim_id,
974
+ updated_at = NOW()
975
+ `,
976
+ [
977
+ args.project_id,
978
+ claim.subject_id,
979
+ claim.slot,
980
+ previous,
981
+ previous ? "active" : "retracted",
982
+ args.claim_id,
983
+ ],
984
+ );
985
+
986
+ if (previous) {
987
+ await client.query(
988
+ `
989
+ INSERT INTO claim_edges (
990
+ project_id, subject_id, from_claim_id, to_claim_id, edge_type, weight, reason_code, reason_text
991
+ )
992
+ VALUES ($1, $2, $3, $4, 'retracts', 1, 'manual_retraction', $5)
993
+ ON CONFLICT (project_id, from_claim_id, to_claim_id, edge_type) DO NOTHING
994
+ `,
995
+ [args.project_id, claim.subject_id, args.claim_id, previous, args.reason],
996
+ );
997
+ }
998
+
999
+ await client.query("COMMIT");
1000
+ return {
1001
+ success: true,
1002
+ claim_id: args.claim_id,
1003
+ slot: claim.slot,
1004
+ previous_claim_id: previous,
1005
+ restored_previous: !!previous,
1006
+ };
1007
+ } catch (err) {
1008
+ await client.query("ROLLBACK");
1009
+ throw err;
1010
+ } finally {
1011
+ client.release();
1012
+ }
1013
+ }
1014
+
1015
+ }
1016
+
1017
+ export type { CoreStore, CreateClaimInput, CreateMemoryInput, UpdateMemoryInput };