memory-provider-supabase.mjs
203 lines 5.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Supabase-backed memory provider: stores events in PostgreSQL (via @supabase/supabase-js)
3 * with optional pgvector for semantic search.
4 *
5 * Table: knowtation_memory_events (see scripts/supabase-memory-migration.sql)
6 *
7 * Dual-write: stores to file provider (local JSONL) for offline fallback,
8 * and to Supabase asynchronously for cloud persistence and pgvector search.
9 */
10
11 import { FileMemoryProvider } from './memory-provider-file.mjs';
12
13 let _supabase = null;
14
15 async function getClient(url, key) {
16 if (_supabase) return _supabase;
17 const { createClient } = await import('@supabase/supabase-js');
18 _supabase = createClient(url, key);
19 return _supabase;
20 }
21
22 const TABLE = 'knowtation_memory_events';
23
24 export class SupabaseMemoryProvider {
25 #fileProvider;
26 #url;
27 #key;
28 #vaultId;
29 #embedFn;
30
31 /**
32 * @param {string} baseDir — local memory directory for file fallback
33 * @param {{ url: string, key: string, vaultId?: string, embedFn?: (text: string) => Promise<number[]|null> }} opts
34 */
35 constructor(baseDir, opts) {
36 this.#fileProvider = new FileMemoryProvider(baseDir);
37 this.#url = opts.url;
38 this.#key = opts.key;
39 this.#vaultId = opts.vaultId || 'default';
40 this.#embedFn = opts.embedFn || null;
41 }
42
43 get baseDir() {
44 return this.#fileProvider.baseDir;
45 }
46
47 async #client() {
48 return getClient(this.#url, this.#key);
49 }
50
51 #eventToText(event) {
52 const parts = [event.type];
53 const d = event.data;
54 if (d.query) parts.push(d.query);
55 if (d.key) parts.push(d.key);
56 if (d.text) parts.push(d.text);
57 if (d.path) parts.push(d.path);
58 if (d.source_type) parts.push(d.source_type);
59 if (d.summary_text) parts.push(d.summary_text);
60 const extra = JSON.stringify(d);
61 if (extra.length < 500) parts.push(extra);
62 return parts.join(' ').slice(0, 2000);
63 }
64
65 storeEvent(event) {
66 const result = this.#fileProvider.storeEvent(event);
67 this.#storeToSupabaseAsync(event).catch(() => {});
68 return result;
69 }
70
71 async #storeToSupabaseAsync(event) {
72 if (!this.#url || !this.#key) return;
73 try {
74 const client = await this.#client();
75 const row = {
76 id: event.id,
77 type: event.type,
78 ts: event.ts,
79 vault_id: event.vault_id || this.#vaultId,
80 data: event.data,
81 ttl: event.ttl || null,
82 air_id: event.air_id || null,
83 };
84
85 if (this.#embedFn) {
86 try {
87 const text = this.#eventToText(event);
88 const embedding = await this.#embedFn(text);
89 if (embedding) row.embedding = embedding;
90 } catch (_) {}
91 }
92
93 await client.from(TABLE).insert(row);
94 } catch (_) {}
95 }
96
97 getLatest(type) {
98 return this.#fileProvider.getLatest(type);
99 }
100
101 listEvents(opts) {
102 return this.#fileProvider.listEvents(opts);
103 }
104
105 /**
106 * Semantic search via pgvector (if embeddings are stored) or text match.
107 * Falls back to empty if Supabase is unreachable.
108 * @param {string} query
109 * @param {{ limit?: number }} [opts]
110 * @returns {Promise<object[]>}
111 */
112 async searchEvents(query, opts = {}) {
113 const limit = opts.limit ?? 10;
114 if (!this.#url || !this.#key) return [];
115
116 try {
117 const client = await this.#client();
118
119 if (this.#embedFn) {
120 const embedding = await this.#embedFn(query);
121 if (embedding) {
122 const { data, error } = await client.rpc('match_memory_events', {
123 query_embedding: embedding,
124 match_count: limit,
125 filter_vault_id: this.#vaultId,
126 });
127 if (!error && data && data.length > 0) {
128 return data.map((row) => ({
129 id: row.id,
130 type: row.type,
131 ts: row.ts,
132 data: row.data,
133 vault_id: row.vault_id,
134 score: row.similarity,
135 }));
136 }
137 }
138 }
139
140 const { data, error } = await client
141 .from(TABLE)
142 .select('*')
143 .eq('vault_id', this.#vaultId)
144 .textSearch('data', query, { type: 'plain' })
145 .order('ts', { ascending: false })
146 .limit(limit);
147
148 if (error || !data) return [];
149 return data.map((row) => ({
150 id: row.id,
151 type: row.type,
152 ts: row.ts,
153 data: row.data,
154 vault_id: row.vault_id,
155 score: null,
156 }));
157 } catch (_) {
158 return [];
159 }
160 }
161
162 supportsSearch() {
163 return Boolean(this.#url && this.#key);
164 }
165
166 clearEvents(opts) {
167 const result = this.#fileProvider.clearEvents(opts);
168 this.#clearFromSupabaseAsync(opts).catch(() => {});
169 return result;
170 }
171
172 async #clearFromSupabaseAsync(opts = {}) {
173 if (!this.#url || !this.#key) return;
174 try {
175 const client = await this.#client();
176 let query = client.from(TABLE).delete().eq('vault_id', this.#vaultId);
177 if (opts.type) query = query.eq('type', opts.type);
178 if (opts.before) query = query.lt('ts', opts.before);
179 await query;
180 } catch (_) {}
181 }
182
183 pruneExpired(retentionDays) {
184 const result = this.#fileProvider.pruneExpired(retentionDays);
185 if (retentionDays && retentionDays > 0) {
186 this.#pruneFromSupabaseAsync(retentionDays).catch(() => {});
187 }
188 return result;
189 }
190
191 async #pruneFromSupabaseAsync(retentionDays) {
192 if (!this.#url || !this.#key) return;
193 try {
194 const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
195 const client = await this.#client();
196 await client.from(TABLE).delete().eq('vault_id', this.#vaultId).lt('ts', cutoff);
197 } catch (_) {}
198 }
199
200 getStats() {
201 return this.#fileProvider.getStats();
202 }
203 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago