@jhizzard/termdeck 0.9.0 → 0.10.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.
@@ -46,10 +46,28 @@ class RAGIntegration {
46
46
  this.db = db;
47
47
  this.supabaseUrl = config.rag?.supabaseUrl || null;
48
48
  this.supabaseKey = config.rag?.supabaseKey || null;
49
+ this.openaiApiKey = config.rag?.openaiApiKey || process.env.OPENAI_API_KEY || null;
49
50
  this.enabled = !!(config.rag?.enabled && this.supabaseUrl && this.supabaseKey);
50
51
  this.syncInterval = config.rag?.syncIntervalMs || 10000;
51
52
  this._syncTimer = null;
52
53
 
54
+ // Sprint 38 / T3 — graph-aware recall toggle. When true, the recall()
55
+ // method routes through the new memory_recall_graph RPC (vector seed +
56
+ // graph expansion + combined re-rank). When false (default), it
57
+ // delegates to the existing mnestra-bridge vector path. The half-life
58
+ // mirrors the SQL function default (30 days) but is exposed here so
59
+ // callers can override it without re-deploying the migration.
60
+ this.graphRecall = config.rag?.graphRecall === true;
61
+ this.graphRecallDepth = Math.max(1, Math.min(5, config.rag?.graphRecallDepth ?? 2));
62
+ this.graphRecallK = Math.max(1, Math.min(50, config.rag?.graphRecallK ?? 10));
63
+ this.graphRecallRecencyHalflifeDays = config.rag?.graphRecallRecencyHalflifeDays ?? 30;
64
+
65
+ // Bridge reference for the vector-only recall path. Wired in by index.js
66
+ // after the bridge is created so we avoid duplicating the embed → RPC
67
+ // plumbing here. Optional: if absent, recall() with graphRecall=false
68
+ // throws a helpful error instead of silently returning empty.
69
+ this._bridge = null;
70
+
53
71
  // Table configuration matching Josh's multi-layer schema
54
72
  this.tables = {
55
73
  sessionMemory: config.rag?.tables?.session || 'mnestra_session_memory',
@@ -374,6 +392,126 @@ class RAGIntegration {
374
392
  this._statusWriteAt.clear();
375
393
  }
376
394
 
395
+ // Sprint 38 / T3 — wire the mnestra-bridge so vector-only recall delegates
396
+ // to the existing direct/webhook/mcp path instead of duplicating the embed
397
+ // pipeline here.
398
+ setBridge(bridge) {
399
+ this._bridge = bridge || null;
400
+ }
401
+
402
+ // Sprint 38 / T3 — graph-aware recall entry point. Returns the same shape
403
+ // as bridge.queryMnestra: { memories: [...], total }. Routes through the
404
+ // memory_recall_graph RPC when graphRecall is enabled, otherwise falls
405
+ // back to the bridge's vector path.
406
+ //
407
+ // options: { project?, searchAll?, sessionContext?, cwd?, depth?, k? }
408
+ async recall(query, options = {}) {
409
+ if (this.graphRecall) {
410
+ return this._recallViaGraph(query, options);
411
+ }
412
+ return this._recallViaVectorOnly(query, options);
413
+ }
414
+
415
+ async _recallViaVectorOnly(query, options) {
416
+ if (!this._bridge) {
417
+ throw new Error('RAGIntegration.recall: no bridge wired (call setBridge first)');
418
+ }
419
+ return this._bridge.queryMnestra({
420
+ question: query,
421
+ project: options.project,
422
+ searchAll: !!options.searchAll,
423
+ sessionContext: options.sessionContext,
424
+ cwd: options.cwd
425
+ });
426
+ }
427
+
428
+ // Direct REST call to memory_recall_graph (migration 010). Mirrors the
429
+ // bridge.queryDirect pattern: OpenAI embedding → Supabase RPC. Stays in
430
+ // rag.js so callers don't need to know which mnestra mode the bridge is
431
+ // using; graph recall is always direct-against-Postgres because the RPC
432
+ // doesn't ship as a Mnestra MCP tool yet (Sprint 38 / T1 wires the
433
+ // related MCP tools — graph recall lives here for now).
434
+ async _recallViaGraph(query, options) {
435
+ if (!this.supabaseUrl || !this.supabaseKey) {
436
+ throw new Error('graphRecall: supabaseUrl/supabaseKey not configured');
437
+ }
438
+ if (!this.openaiApiKey) {
439
+ throw new Error('graphRecall: OPENAI_API_KEY not configured');
440
+ }
441
+
442
+ const project = options.searchAll ? null : (options.project || null);
443
+ const depth = options.depth ?? this.graphRecallDepth;
444
+ const k = options.k ?? this.graphRecallK;
445
+
446
+ const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
447
+ method: 'POST',
448
+ headers: {
449
+ 'Authorization': `Bearer ${this.openaiApiKey}`,
450
+ 'Content-Type': 'application/json'
451
+ },
452
+ body: JSON.stringify({
453
+ model: 'text-embedding-3-large',
454
+ input: query,
455
+ dimensions: 1536
456
+ })
457
+ });
458
+ if (!embeddingRes.ok) {
459
+ const err = await embeddingRes.text();
460
+ console.error('[rag:graph] embedding failed:', err);
461
+ throw new Error('graphRecall: embedding generation failed');
462
+ }
463
+ const embeddingData = await embeddingRes.json();
464
+ const embedding = embeddingData.data[0].embedding;
465
+
466
+ console.log(`[rag] using graph recall path project=${project ?? 'ALL'} depth=${depth} k=${k}`);
467
+
468
+ const rpcBody = {
469
+ query_embedding: `[${embedding.join(',')}]`,
470
+ project_filter: project,
471
+ max_depth: depth,
472
+ k
473
+ };
474
+ const rpcRes = await fetch(`${this.supabaseUrl}/rest/v1/rpc/memory_recall_graph`, {
475
+ method: 'POST',
476
+ headers: {
477
+ 'Content-Type': 'application/json',
478
+ 'apikey': this.supabaseKey,
479
+ 'Authorization': `Bearer ${this.supabaseKey}`
480
+ },
481
+ body: JSON.stringify(rpcBody)
482
+ });
483
+ if (!rpcRes.ok) {
484
+ const err = await rpcRes.text();
485
+ console.error(`[rag:graph] RPC failed ${rpcRes.status}:`, err);
486
+ throw new Error(`graphRecall: memory_recall_graph RPC failed (${rpcRes.status})`);
487
+ }
488
+ const rows = await rpcRes.json();
489
+ return {
490
+ memories: rows.map((m) => ({
491
+ content: m.content,
492
+ // graph recall doesn't return source_type; preserve the bridge's
493
+ // shape by returning null so consumers don't crash on chip render.
494
+ source_type: m.source_type ?? null,
495
+ project: m.project,
496
+ // The bridge consumers read `similarity`; pass final_score so they
497
+ // see the combined (vector × edge × recency) signal as the badge.
498
+ // Also expose the underlying scores for callers that want to split
499
+ // them out (graph viz, debugging).
500
+ similarity: m.final_score,
501
+ depth: m.depth,
502
+ vector_score: m.vector_score,
503
+ edge_weight: m.edge_weight,
504
+ recency_score: m.recency_score,
505
+ path: m.path,
506
+ // memory_recall_graph doesn't return created_at — depth-N neighbors
507
+ // come from the graph walk, not a direct timestamp pull. Caller can
508
+ // re-fetch via memory_get if they need it.
509
+ created_at: m.created_at ?? null
510
+ })),
511
+ total: rows.length
512
+ };
513
+ }
514
+
377
515
  // Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
378
516
  // Re-evaluates eligibility — flipping `enabled: true` without configured
379
517
  // Supabase creds is a no-op so the live integration never claims to be on
@@ -0,0 +1,126 @@
1
+ -- 009_memory_relationship_metadata.sql
2
+ --
3
+ -- Sprint 38 (T1) — Knowledge graph substrate.
4
+ -- Adds graph-edge metadata columns to memory_relationships, expands the
5
+ -- relationship_type vocabulary from 5 to 8 values, and ships a recursive-CTE
6
+ -- traversal function (expand_memory_neighborhood) for N-hop neighborhood
7
+ -- queries.
8
+ --
9
+ -- Idempotent: safe to re-run.
10
+ --
11
+ -- Pre-existing state (verified against petvetbid 2026-04-27 17:25 ET):
12
+ -- memory_relationships has 749 live edges. The migration adds nullable
13
+ -- columns and a wider CHECK; no existing row violates the new constraint.
14
+ --
15
+ -- The original CHECK on relationship_type is anonymous (defined inline in
16
+ -- migration 001), so its name is auto-generated (e.g., memory_relationships_check1).
17
+ -- We can't rely on a known name for DROP CONSTRAINT; the DO block below
18
+ -- introspects pg_constraint and drops any CHECK on this table whose
19
+ -- definition references "relationship_type IN" — preserving the separate
20
+ -- (source_id <> target_id) CHECK.
21
+
22
+ -- ── 1. Metadata columns ──────────────────────────────────────────────────
23
+
24
+ alter table memory_relationships
25
+ add column if not exists weight float,
26
+ add column if not exists inferred_at timestamptz,
27
+ add column if not exists inferred_by text;
28
+
29
+ -- ── 2. Expand relationship_type CHECK vocabulary ─────────────────────────
30
+
31
+ do $$
32
+ declare
33
+ c record;
34
+ begin
35
+ for c in
36
+ select con.conname
37
+ from pg_constraint con
38
+ join pg_class cls on cls.oid = con.conrelid
39
+ where cls.relname = 'memory_relationships'
40
+ and con.contype = 'c'
41
+ and pg_get_constraintdef(con.oid) ilike '%relationship_type%'
42
+ loop
43
+ execute format('alter table memory_relationships drop constraint %I', c.conname);
44
+ end loop;
45
+ end
46
+ $$;
47
+
48
+ alter table memory_relationships
49
+ add constraint memory_relationships_relationship_type_check
50
+ check (relationship_type in (
51
+ 'supersedes',
52
+ 'relates_to',
53
+ 'contradicts',
54
+ 'elaborates',
55
+ 'caused_by',
56
+ 'blocks',
57
+ 'inspired_by',
58
+ 'cross_project_link'
59
+ ));
60
+
61
+ -- ── 3. Indexes for graph traversal ───────────────────────────────────────
62
+
63
+ create index if not exists memory_relationships_weight_idx
64
+ on memory_relationships(weight)
65
+ where weight is not null;
66
+
67
+ create index if not exists memory_relationships_inferred_at_idx
68
+ on memory_relationships(inferred_at)
69
+ where inferred_at is not null;
70
+
71
+ -- ── 4. expand_memory_neighborhood — recursive-CTE N-hop traversal ────────
72
+ --
73
+ -- Returns one row per (memory_id, depth) reachable from start_id within
74
+ -- max_depth hops. Edges are undirected for traversal purposes (source_id
75
+ -- and target_id treated symmetrically) — agents linking memories don't
76
+ -- always think directionally, and graph-aware recall benefits from
77
+ -- bidirectional reachability.
78
+ --
79
+ -- Cycle-safe: the path[] array tracks visited nodes; we only follow an
80
+ -- edge if its other endpoint is not already in path. Without this check,
81
+ -- the CTE would loop indefinitely on any cycle in the graph.
82
+ --
83
+ -- edge_kinds[] mirrors path: position i in edge_kinds is the relationship_type
84
+ -- of the edge that brought us to position i+1 in path. Caller can filter
85
+ -- downstream by inspecting edge_kinds (e.g., drop rows whose path traversed
86
+ -- a 'contradicts' edge).
87
+ --
88
+ -- Performance note: with ~5K memory_items and ~750 edges (current scale),
89
+ -- recursive expansion to depth 2 returns in <50ms unsharded. At >50K edges
90
+ -- consider materializing precomputed neighborhoods.
91
+
92
+ create or replace function expand_memory_neighborhood(
93
+ start_id uuid,
94
+ max_depth int default 2
95
+ )
96
+ returns table (
97
+ memory_id uuid,
98
+ depth int,
99
+ path uuid[],
100
+ edge_kinds text[]
101
+ )
102
+ language sql stable
103
+ as $$
104
+ with recursive neighborhood as (
105
+ select
106
+ start_id as memory_id,
107
+ 0 as depth,
108
+ array[start_id] as path,
109
+ array[]::text[] as edge_kinds
110
+ union all
111
+ select
112
+ case when r.source_id = n.memory_id then r.target_id else r.source_id end,
113
+ n.depth + 1,
114
+ n.path || (case when r.source_id = n.memory_id then r.target_id else r.source_id end),
115
+ n.edge_kinds || r.relationship_type
116
+ from neighborhood n
117
+ join memory_relationships r
118
+ on (r.source_id = n.memory_id or r.target_id = n.memory_id)
119
+ where n.depth < max_depth
120
+ and not (
121
+ case when r.source_id = n.memory_id then r.target_id else r.source_id end
122
+ = any (n.path)
123
+ )
124
+ )
125
+ select memory_id, depth, path, edge_kinds from neighborhood;
126
+ $$;
@@ -0,0 +1,147 @@
1
+ -- Mnestra v0.3 — memory_recall_graph (Sprint 38 / T3)
2
+ --
3
+ -- Graph-aware recall. Two-stage retrieval:
4
+ --
5
+ -- Stage 1: vector recall via match_memories() — top-K nearest neighbors
6
+ -- in embedding space (existing RPC; tombstone filters inherited).
7
+ -- Stage 2: graph expansion via expand_memory_neighborhood() — for each
8
+ -- stage-1 seed, walk the relationship graph to max_depth.
9
+ --
10
+ -- Re-rank the union of (stage 1 ∪ stage 2) by a combined signal:
11
+ --
12
+ -- final_score = vector_score × edge_weight × recency_score
13
+ --
14
+ -- where:
15
+ -- vector_score = cosine similarity of seed memory to query (0..1)
16
+ -- edge_weight = mean of memory_relationships.weight along the path,
17
+ -- defaulting to 0.5 for edges T2 hasn't classified yet
18
+ -- (the 749 pre-T2 edges all start with weight = NULL).
19
+ -- recency_score = exp(-age_seconds / (30 × 86400)) (30-day half-life)
20
+ --
21
+ -- Initial (depth=0) results are seeded with edge_weight = 1.0 (no path).
22
+ --
23
+ -- Hard dependencies:
24
+ -- • migration 009 must have run (introduces memory_relationships.weight
25
+ -- and the expand_memory_neighborhood function).
26
+ -- • migration 001 (match_memories, memory_items, memory_relationships).
27
+ --
28
+ -- Rerun-safe: CREATE OR REPLACE.
29
+
30
+ create or replace function memory_recall_graph (
31
+ query_embedding vector(1536),
32
+ project_filter text default null,
33
+ max_depth int default 2,
34
+ k int default 10
35
+ )
36
+ returns table (
37
+ memory_id uuid,
38
+ content text,
39
+ project text,
40
+ depth int,
41
+ vector_score float,
42
+ edge_weight float,
43
+ recency_score float,
44
+ final_score float,
45
+ path uuid[]
46
+ )
47
+ language sql stable
48
+ as $$
49
+ with initial as (
50
+ -- Stage 1: top-K vector recall. match_memories already filters
51
+ -- is_active / archived / superseded_by / project, so depth-0 hits
52
+ -- are tombstone-clean. match_threshold=0.0 returns the full top-K
53
+ -- (we rank by combined signal, not by raw similarity threshold).
54
+ select
55
+ mm.id as memory_id,
56
+ mm.content,
57
+ mm.project,
58
+ 0 as depth,
59
+ mm.similarity as vector_score,
60
+ mi.created_at,
61
+ array[mm.id] as path
62
+ from match_memories(query_embedding, 0.0, k, project_filter) mm
63
+ join memory_items mi on mi.id = mm.id
64
+ ),
65
+ expanded as (
66
+ -- Stage 2: graph expansion. For each stage-1 seed, walk the graph to
67
+ -- max_depth via T1's expand_memory_neighborhood RPC. Carry the seed's
68
+ -- vector_score forward (we do NOT re-embed neighbors — the assumption
69
+ -- is "if A is relevant to the query and B is connected to A, then B
70
+ -- inherits some of A's relevance, attenuated by the path weight").
71
+ select
72
+ n.memory_id,
73
+ mi.content,
74
+ mi.project,
75
+ n.depth,
76
+ i.vector_score,
77
+ mi.created_at,
78
+ n.path,
79
+ coalesce(
80
+ (
81
+ -- Average of edge weights along the path (cycle-safe: T1's CTE
82
+ -- guarantees no repeats). Pairwise lookup over consecutive path
83
+ -- elements, treating the relationship as undirected so A→B and
84
+ -- B→A both count.
85
+ select avg(coalesce(r.weight, 0.5))
86
+ from generate_series(1, array_length(n.path, 1) - 1) as g
87
+ join memory_relationships r
88
+ on (r.source_id = n.path[g] and r.target_id = n.path[g + 1])
89
+ or (r.source_id = n.path[g + 1] and r.target_id = n.path[g])
90
+ ),
91
+ 0.5
92
+ ) as edge_weight
93
+ from initial i
94
+ cross join lateral expand_memory_neighborhood(i.memory_id, max_depth) n
95
+ join memory_items mi
96
+ on mi.id = n.memory_id
97
+ and mi.is_active = true
98
+ and mi.archived = false
99
+ and mi.superseded_by is null
100
+ where n.depth > 0
101
+ and (project_filter is null or mi.project = project_filter)
102
+ ),
103
+ unioned as (
104
+ -- depth-0 seeds get edge_weight = 1.0 (no path traversed).
105
+ select memory_id, content, project, depth, vector_score,
106
+ 1.0::float as edge_weight, created_at, path
107
+ from initial
108
+ union all
109
+ select memory_id, content, project, depth, vector_score,
110
+ edge_weight, created_at, path
111
+ from expanded
112
+ ),
113
+ scored as (
114
+ -- Same memory may be reached via multiple paths (vector seed AND graph
115
+ -- expansion, or two different seeds). Keep the strongest path: highest
116
+ -- final_score wins, with depth-0 (vector hit) preferred on ties.
117
+ select distinct on (memory_id)
118
+ memory_id,
119
+ content,
120
+ project,
121
+ depth,
122
+ vector_score,
123
+ edge_weight,
124
+ exp(-extract(epoch from (now() - created_at))::float / (30.0 * 86400.0))
125
+ as recency_score,
126
+ vector_score * edge_weight
127
+ * exp(-extract(epoch from (now() - created_at))::float / (30.0 * 86400.0))
128
+ as final_score,
129
+ path
130
+ from unioned
131
+ order by memory_id, final_score desc, depth asc
132
+ )
133
+ select
134
+ memory_id, content, project, depth, vector_score,
135
+ edge_weight, recency_score, final_score, path
136
+ from scored
137
+ order by final_score desc
138
+ limit 50;
139
+ $$;
140
+
141
+ -- Lightweight grant convention follows existing match_memories. Service-role
142
+ -- and authenticated callers should already have execute by inheritance, but
143
+ -- be explicit for the new function so PostgREST surfaces it without a manual
144
+ -- dashboard step.
145
+
146
+ grant execute on function memory_recall_graph(vector, text, int, int)
147
+ to authenticated, service_role, anon;
@@ -0,0 +1,49 @@
1
+ -- Sprint 38 T2 — Graph-inference cron schedule.
2
+ -- Schedules the graph-inference Supabase Edge Function to run daily at
3
+ -- 03:00 UTC (≈ 23:00 ET, after typical work hours). The Edge Function
4
+ -- scans memory_items for pairs above GRAPH_INFERENCE_THRESHOLD (default
5
+ -- 0.85 cosine similarity), inserts edges into memory_relationships with
6
+ -- inferred_by = 'cron-YYYY-MM-DD' for audit trail, and optionally
7
+ -- classifies edge types via Haiku 4.5 (gated by GRAPH_LLM_CLASSIFY=1).
8
+ --
9
+ -- Coexists with the existing rag-system MCP-side ingest classifier:
10
+ -- this cron handles backfill + cross-project + stale-edge refresh; the
11
+ -- ingest classifier handles fresh per-memory edges in the 0.75-0.92
12
+ -- window. See Sprint 38 T2 FINDING for the full coexistence design.
13
+ --
14
+ -- Before applying:
15
+ -- 1. The pg_cron and pg_net extensions must already be enabled
16
+ -- (rumen migration 002 enables them; 003 assumes that prereq).
17
+ -- 2. Add a Vault secret named 'graph_inference_service_role_key'
18
+ -- containing the project's service-role JWT. Separate from
19
+ -- 'rumen_service_role_key' so a key rotation on one cron doesn't
20
+ -- affect the other.
21
+ -- 3. Replace <project-ref> below with the project's Supabase ref
22
+ -- (stack-installer substitutes this at apply-time, same as 002).
23
+ --
24
+ -- Apply with:
25
+ -- psql "$DIRECT_URL" -f migrations/003_graph_inference_schedule.sql
26
+
27
+ -- Idempotent unschedule.
28
+ SELECT cron.unschedule('graph-inference-tick')
29
+ WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'graph-inference-tick');
30
+
31
+ -- Schedule daily at 03:00 UTC.
32
+ SELECT cron.schedule(
33
+ 'graph-inference-tick',
34
+ '0 3 * * *',
35
+ $$
36
+ SELECT net.http_post(
37
+ url := 'https://<project-ref>.supabase.co/functions/v1/graph-inference',
38
+ headers := jsonb_build_object(
39
+ 'Content-Type', 'application/json',
40
+ 'Authorization', 'Bearer ' || (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'graph_inference_service_role_key')
41
+ ),
42
+ body := '{}'::jsonb
43
+ );
44
+ $$
45
+ );
46
+
47
+ -- Verify:
48
+ -- SELECT jobname, schedule, active FROM cron.job WHERE jobname = 'graph-inference-tick';
49
+ -- SELECT * FROM cron.job_run_details WHERE jobname = 'graph-inference-tick' ORDER BY start_time DESC LIMIT 5;