@jhizzard/termdeck 0.11.0 → 0.13.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,381 @@
1
+ // Sprint 38 T2 — graph-inference Supabase Edge Function.
2
+ //
3
+ // Runs daily via pg_cron (see TermDeck migration 003_graph_inference_schedule.sql).
4
+ // Scans memory_items for pairs above GRAPH_INFERENCE_THRESHOLD cosine
5
+ // similarity, inserts/refreshes edges in memory_relationships, and
6
+ // optionally classifies edge types via Haiku 4.5.
7
+ //
8
+ // Coexists with the rag-system MCP-side ingest classifier — this cron
9
+ // fills cross-project edges and refreshes stale ingest-time edges that
10
+ // have NULL weight. Per-edge inferred_by = 'cron-YYYY-MM-DD' for audit.
11
+ //
12
+ // Deno runtime, NOT Node. Excluded from root tsconfig; canonical type
13
+ // check is `deno check` and `supabase functions deploy`.
14
+ //
15
+ // Deployment:
16
+ // supabase functions deploy graph-inference
17
+ // supabase secrets set DATABASE_URL="$DATABASE_URL"
18
+ // # Optional, gates LLM classification of new edges:
19
+ // supabase secrets set GRAPH_LLM_CLASSIFY=1 ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"
20
+ // # Optional tuning (defaults shown):
21
+ // supabase secrets set GRAPH_INFERENCE_THRESHOLD=0.85
22
+ // supabase secrets set GRAPH_INFERENCE_MAX_LLM_CALLS=200
23
+ // supabase secrets set GRAPH_INFERENCE_MAX_PAIRS=5000
24
+ // supabase secrets set GRAPH_INFERENCE_PER_ROW_K=8 # Sprint 42: HNSW LATERAL top-K width
25
+
26
+ // @ts-ignore Deno std import resolved at runtime.
27
+ import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
28
+ // @ts-ignore npm specifier resolved at runtime.
29
+ // Deno-friendly postgres client (postgres.js). npm:pg@8.x has Node-native
30
+ // crypto/net deps that don't bundle in the Supabase Edge Runtime — caused
31
+ // BOOT_ERROR on first deploy 2026-04-27 ~19:35 ET. postgres.js is pure JS,
32
+ // works in Deno without polyfills.
33
+ import postgres from 'npm:postgres@3.4.4';
34
+
35
+ // Minimal API surface we use, typed loosely so the @ts-ignore stays narrow.
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ type Sql = any;
38
+
39
+ // @ts-ignore Deno global available at runtime.
40
+ declare const Deno: { env: { get: (k: string) => string | undefined } };
41
+
42
+ const VALID_TYPES = new Set([
43
+ 'supersedes',
44
+ 'relates_to',
45
+ 'contradicts',
46
+ 'elaborates',
47
+ 'caused_by',
48
+ 'blocks',
49
+ 'inspired_by',
50
+ 'cross_project_link',
51
+ ]);
52
+
53
+ const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
54
+
55
+ interface InferenceSummary {
56
+ ok: boolean;
57
+ since: string | null;
58
+ candidates_scanned: number;
59
+ edges_inserted: number;
60
+ edges_refreshed: number;
61
+ llm_classifications: number;
62
+ llm_failures: number;
63
+ ms_total: number;
64
+ error?: string;
65
+ }
66
+
67
+ function inferredByTag(now: Date): string {
68
+ const yyyy = now.getUTCFullYear();
69
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
70
+ const dd = String(now.getUTCDate()).padStart(2, '0');
71
+ return `cron-${yyyy}-${mm}-${dd}`;
72
+ }
73
+
74
+ function parseFloatEnv(name: string, fallback: number): number {
75
+ const raw = Deno.env.get(name);
76
+ if (!raw) return fallback;
77
+ const parsed = Number.parseFloat(raw);
78
+ return Number.isFinite(parsed) ? parsed : fallback;
79
+ }
80
+
81
+ function parseIntEnv(name: string, fallback: number): number {
82
+ const raw = Deno.env.get(name);
83
+ if (!raw) return fallback;
84
+ const parsed = Number.parseInt(raw, 10);
85
+ return Number.isFinite(parsed) ? parsed : fallback;
86
+ }
87
+
88
+ interface CandidatePair {
89
+ source_id: string;
90
+ target_id: string;
91
+ similarity: number;
92
+ source_content: string;
93
+ target_content: string;
94
+ source_project: string | null;
95
+ target_project: string | null;
96
+ }
97
+
98
+ async function fetchSince(sql: Sql): Promise<string | null> {
99
+ const result = await sql.unsafe(
100
+ `SELECT max(inferred_at) AS since FROM memory_relationships WHERE inferred_by ILIKE 'cron-%'`,
101
+ );
102
+ const row = result[0];
103
+ if (!row || !row.since) return null;
104
+ return new Date(row.since).toISOString();
105
+ }
106
+
107
+ async function fetchCandidatePairs(
108
+ sql: Sql,
109
+ threshold: number,
110
+ since: string | null,
111
+ maxPairs: number,
112
+ perRowK: number,
113
+ ): Promise<CandidatePair[]> {
114
+ // Sprint 42 T1 rewrite — HNSW-accelerated pairwise self-join.
115
+ //
116
+ // The pre-Sprint 42 query (`m1 JOIN m2 ON m1.id < m2.id AND (m1.embedding
117
+ // <=> m2.embedding) <= cutoff`) timed out at the 150s Edge Function
118
+ // wall-clock on >5K memory_items because cosine-distance constraints in
119
+ // a join's ON/WHERE clause cannot engage HNSW — they're post-join
120
+ // filters, evaluated for every candidate pair (~3.5M for 5K rows).
121
+ //
122
+ // The fix: switch to a CROSS JOIN LATERAL with `ORDER BY m2.embedding
123
+ // <=> m1.embedding LIMIT K` inside the lateral. HNSW serves the per-row
124
+ // top-K query in ~2ms each, so the work is O(N log K) ≈ N × HNSW-lookup
125
+ // rather than O(N²) cosine evaluations.
126
+ //
127
+ // Symmetry: each pair (A, B) may be found twice (once as A's neighbor
128
+ // of B, once as B's neighbor of A). LEAST/GREATEST canonicalizes the
129
+ // orientation; DISTINCT ON dedupes. This is more correct than filtering
130
+ // `m1.id < nbr.id` outside the lateral, which would lose pairs where
131
+ // only one direction's top-K contained the other.
132
+ //
133
+ // `since` filter: applied only to the outer m1. If m1 is old but m2
134
+ // was recently updated, the pair is still found on the iteration where
135
+ // m2 is the outer m1 (which IS recent). So filtering only m1 by `since`
136
+ // is sufficient and saves ~99% of work in steady state.
137
+ //
138
+ // EXPLAIN ANALYZE on petvetbid corpus (5,822 active rows, 2026-04-28):
139
+ // 13.5s cold start (since=NULL), HNSW correctly engaged, 718 raw
140
+ // matches → 359 unique pairs at threshold 0.85.
141
+ const result = await sql.unsafe(
142
+ `
143
+ SELECT DISTINCT ON (LEAST(m1.id, nbr.id), GREATEST(m1.id, nbr.id))
144
+ LEAST(m1.id, nbr.id) AS source_id,
145
+ GREATEST(m1.id, nbr.id) AS target_id,
146
+ 1 - (m1.embedding <=> nbr.embedding) AS similarity,
147
+ CASE WHEN m1.id < nbr.id THEN m1.content ELSE nbr.content END AS source_content,
148
+ CASE WHEN m1.id < nbr.id THEN nbr.content ELSE m1.content END AS target_content,
149
+ CASE WHEN m1.id < nbr.id THEN m1.project ELSE nbr.project END AS source_project,
150
+ CASE WHEN m1.id < nbr.id THEN nbr.project ELSE m1.project END AS target_project
151
+ FROM memory_items m1
152
+ CROSS JOIN LATERAL (
153
+ SELECT id, embedding, content, project, updated_at
154
+ FROM memory_items m2
155
+ WHERE m2.is_active = true
156
+ AND m2.archived = false
157
+ AND m2.superseded_by IS NULL
158
+ AND m2.id <> m1.id
159
+ ORDER BY m2.embedding <=> m1.embedding
160
+ LIMIT $4
161
+ ) nbr
162
+ WHERE m1.is_active = true
163
+ AND m1.archived = false
164
+ AND m1.superseded_by IS NULL
165
+ AND 1 - (m1.embedding <=> nbr.embedding) >= $1
166
+ AND ($2::timestamptz IS NULL OR m1.updated_at > $2::timestamptz)
167
+ ORDER BY LEAST(m1.id, nbr.id),
168
+ GREATEST(m1.id, nbr.id),
169
+ 1 - (m1.embedding <=> nbr.embedding) DESC
170
+ LIMIT $3
171
+ `,
172
+ [threshold, since, maxPairs, perRowK],
173
+ );
174
+ return result as unknown as CandidatePair[];
175
+ }
176
+
177
+ async function classifyPair(
178
+ apiKey: string,
179
+ pair: CandidatePair,
180
+ ): Promise<string | null> {
181
+ const prompt = `You are classifying the relationship between two memories from the same developer.
182
+
183
+ Memory A: ${pair.source_content}
184
+ Memory B: ${pair.target_content}
185
+
186
+ Classify their relationship as exactly ONE of:
187
+ - supersedes — A replaces B (B is older/wrong/outdated)
188
+ - relates_to — A and B are about the same topic/system
189
+ - contradicts — A and B claim conflicting facts
190
+ - elaborates — A provides more detail about something B mentions
191
+ - caused_by — A is a consequence of something described in B
192
+ - blocks — A's resolution depends on B
193
+ - inspired_by — A's idea originated from B
194
+ - cross_project_link — A and B are in different projects but reference shared infrastructure
195
+
196
+ Return ONLY the type token, no explanation.`;
197
+
198
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
199
+ method: 'POST',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ 'x-api-key': apiKey,
203
+ 'anthropic-version': '2023-06-01',
204
+ },
205
+ body: JSON.stringify({
206
+ model: HAIKU_MODEL,
207
+ max_tokens: 32,
208
+ messages: [{ role: 'user', content: prompt }],
209
+ }),
210
+ });
211
+
212
+ if (!response.ok) {
213
+ return null;
214
+ }
215
+
216
+ const payload = await response.json();
217
+ const block = payload?.content?.[0];
218
+ if (!block || block.type !== 'text') return null;
219
+ const token = String(block.text).trim().toLowerCase().split(/\s+/)[0];
220
+ return VALID_TYPES.has(token) ? token : null;
221
+ }
222
+
223
+ async function upsertEdge(
224
+ sql: Sql,
225
+ pair: CandidatePair,
226
+ relationshipType: string,
227
+ inferredBy: string,
228
+ ): Promise<'inserted' | 'refreshed' | 'skipped'> {
229
+ const result = await sql.unsafe(
230
+ `
231
+ INSERT INTO memory_relationships (
232
+ source_id, target_id, relationship_type, weight, inferred_at, inferred_by
233
+ ) VALUES ($1, $2, $3, $4, now(), $5)
234
+ ON CONFLICT (source_id, target_id, relationship_type) DO UPDATE
235
+ SET weight = EXCLUDED.weight,
236
+ inferred_at = EXCLUDED.inferred_at,
237
+ inferred_by = EXCLUDED.inferred_by
238
+ WHERE memory_relationships.weight IS NULL
239
+ OR memory_relationships.inferred_at IS NULL
240
+ OR memory_relationships.inferred_at < now() - interval '7 days'
241
+ RETURNING (xmax = 0) AS inserted
242
+ `,
243
+ [pair.source_id, pair.target_id, relationshipType, pair.similarity, inferredBy],
244
+ );
245
+ if (result.length === 0) return 'skipped';
246
+ return result[0].inserted ? 'inserted' : 'refreshed';
247
+ }
248
+
249
+ function isMissingColumnError(err: unknown): boolean {
250
+ if (!(err instanceof Error)) return false;
251
+ const message = err.message.toLowerCase();
252
+ return (
253
+ message.includes('column') &&
254
+ (message.includes('inferred_at') ||
255
+ message.includes('inferred_by') ||
256
+ message.includes('weight'))
257
+ );
258
+ }
259
+
260
+ export async function runGraphInference(sql: Sql): Promise<InferenceSummary> {
261
+ const start = Date.now();
262
+ const summary: InferenceSummary = {
263
+ ok: false,
264
+ since: null,
265
+ candidates_scanned: 0,
266
+ edges_inserted: 0,
267
+ edges_refreshed: 0,
268
+ llm_classifications: 0,
269
+ llm_failures: 0,
270
+ ms_total: 0,
271
+ };
272
+
273
+ const threshold = parseFloatEnv('GRAPH_INFERENCE_THRESHOLD', 0.85);
274
+ const maxPairs = parseIntEnv('GRAPH_INFERENCE_MAX_PAIRS', 5000);
275
+ const maxLlmCalls = parseIntEnv('GRAPH_INFERENCE_MAX_LLM_CALLS', 200);
276
+ // GRAPH_INFERENCE_PER_ROW_K — top-K HNSW lookup width for the LATERAL
277
+ // self-join (Sprint 42 T1 rewrite). 8 is a recall/perf sweet spot at
278
+ // threshold 0.85: it captures the high-similarity tail without paying
279
+ // for many post-filter rejections. Raise to 12 if recall drops.
280
+ const perRowK = parseIntEnv('GRAPH_INFERENCE_PER_ROW_K', 8);
281
+ const llmEnabled = Deno.env.get('GRAPH_LLM_CLASSIFY') === '1';
282
+ const apiKey = Deno.env.get('ANTHROPIC_API_KEY') ?? '';
283
+ const inferredBy = inferredByTag(new Date());
284
+
285
+ try {
286
+ summary.since = await fetchSince(sql);
287
+ } catch (err) {
288
+ if (isMissingColumnError(err)) {
289
+ summary.error = 'awaiting migration 009';
290
+ summary.ms_total = Date.now() - start;
291
+ return summary;
292
+ }
293
+ throw err;
294
+ }
295
+
296
+ const candidates = await fetchCandidatePairs(sql, threshold, summary.since, maxPairs, perRowK);
297
+ summary.candidates_scanned = candidates.length;
298
+
299
+ for (const pair of candidates) {
300
+ let relationshipType = 'relates_to';
301
+ let isNewEdge = false;
302
+
303
+ try {
304
+ const outcome = await upsertEdge(sql, pair, relationshipType, inferredBy);
305
+ if (outcome === 'skipped') continue;
306
+ isNewEdge = outcome === 'inserted';
307
+ if (outcome === 'inserted') summary.edges_inserted++;
308
+ if (outcome === 'refreshed') summary.edges_refreshed++;
309
+ } catch (err) {
310
+ if (isMissingColumnError(err)) {
311
+ summary.error = 'awaiting migration 009';
312
+ summary.ms_total = Date.now() - start;
313
+ return summary;
314
+ }
315
+ throw err;
316
+ }
317
+
318
+ if (
319
+ llmEnabled &&
320
+ apiKey &&
321
+ isNewEdge &&
322
+ summary.llm_classifications + summary.llm_failures < maxLlmCalls
323
+ ) {
324
+ const classified = await classifyPair(apiKey, pair);
325
+ if (classified && classified !== relationshipType) {
326
+ try {
327
+ await upsertEdge(sql, pair, classified, inferredBy);
328
+ summary.llm_classifications++;
329
+ } catch {
330
+ summary.llm_failures++;
331
+ }
332
+ } else if (classified) {
333
+ summary.llm_classifications++;
334
+ } else {
335
+ summary.llm_failures++;
336
+ }
337
+ }
338
+ }
339
+
340
+ summary.ok = true;
341
+ summary.ms_total = Date.now() - start;
342
+ return summary;
343
+ }
344
+
345
+ serve(async (_req: Request) => {
346
+ const url = Deno.env.get('DATABASE_URL');
347
+ if (!url) {
348
+ console.error('[graph-inference] DATABASE_URL not set in Edge Function secrets');
349
+ return new Response(
350
+ JSON.stringify({ ok: false, error: 'DATABASE_URL not set' }),
351
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
352
+ );
353
+ }
354
+
355
+ const sql = postgres(url, { max: 4, prepare: false });
356
+
357
+ try {
358
+ console.log('[graph-inference] tick starting');
359
+ const summary = await runGraphInference(sql);
360
+ console.log(
361
+ `[graph-inference] tick complete inserted=${summary.edges_inserted} refreshed=${summary.edges_refreshed} ms=${summary.ms_total}`,
362
+ );
363
+ return new Response(JSON.stringify(summary), {
364
+ status: summary.ok ? 200 : 500,
365
+ headers: { 'Content-Type': 'application/json' },
366
+ });
367
+ } catch (err) {
368
+ const message = err instanceof Error ? err.message : String(err);
369
+ console.error('[graph-inference] tick threw:', err);
370
+ return new Response(
371
+ JSON.stringify({ ok: false, error: message }),
372
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
373
+ );
374
+ } finally {
375
+ try {
376
+ await sql.end();
377
+ } catch (err) {
378
+ console.error('[graph-inference] sql.end() failed:', err);
379
+ }
380
+ }
381
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": false,
11
+ "types": []
12
+ },
13
+ "include": ["index.ts"]
14
+ }