melchizedek-agents 0.9.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 (111) hide show
  1. package/AGENT_SETUP.md +182 -0
  2. package/DOCUMENTATION.md +614 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +164 -0
  5. package/README.md +151 -0
  6. package/config/agents/README.md +27 -0
  7. package/config/agents/examples/ares.yaml +74 -0
  8. package/config/agents/examples/cartographers.yaml +115 -0
  9. package/config/agents/examples/claude.yaml +17 -0
  10. package/config/agents/examples/council.yaml +65 -0
  11. package/config/agents/examples/critic.yaml +74 -0
  12. package/config/agents/examples/delegation.yaml +29 -0
  13. package/config/agents/examples/hierarchical.yaml +26 -0
  14. package/config/agents/examples/image_production.yaml +112 -0
  15. package/config/agents/examples/librarian.yaml +75 -0
  16. package/config/agents/examples/model_zoo.yaml +81 -0
  17. package/config/agents/examples/patient_advocate.yaml +107 -0
  18. package/config/agents/examples/scriptorium.yaml +96 -0
  19. package/config/agents/examples/style_council.yaml +83 -0
  20. package/config/agents/examples/syndicate.yaml +34 -0
  21. package/config/agents/examples/tutor.yaml +66 -0
  22. package/config/agents/syndicateSchema.yaml +291 -0
  23. package/dist/lib/config.d.ts +108 -0
  24. package/dist/lib/config.js +110 -0
  25. package/dist/lib/dispatch.d.ts +133 -0
  26. package/dist/lib/dispatch.js +182 -0
  27. package/dist/lib/grounding.d.ts +28 -0
  28. package/dist/lib/grounding.js +61 -0
  29. package/dist/lib/index.d.ts +14 -0
  30. package/dist/lib/index.js +13 -0
  31. package/dist/lib/loadEnv.d.ts +21 -0
  32. package/dist/lib/loadEnv.js +58 -0
  33. package/dist/lib/loadSyndicate.d.ts +177 -0
  34. package/dist/lib/loadSyndicate.js +264 -0
  35. package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
  36. package/dist/lib/memory/supabaseMemoryService.js +499 -0
  37. package/dist/lib/models/claudeLlm.d.ts +93 -0
  38. package/dist/lib/models/claudeLlm.js +332 -0
  39. package/dist/lib/models/gptLlm.d.ts +110 -0
  40. package/dist/lib/models/gptLlm.js +497 -0
  41. package/dist/lib/models/grokLlm.d.ts +64 -0
  42. package/dist/lib/models/grokLlm.js +86 -0
  43. package/dist/lib/models/ollamaLlm.d.ts +105 -0
  44. package/dist/lib/models/ollamaLlm.js +134 -0
  45. package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
  46. package/dist/lib/models/openAiCompatibleLlm.js +331 -0
  47. package/dist/lib/models/providerMap.d.ts +28 -0
  48. package/dist/lib/models/providerMap.js +45 -0
  49. package/dist/lib/models/registry.d.ts +71 -0
  50. package/dist/lib/models/registry.js +159 -0
  51. package/dist/lib/models/schemaNormalize.d.ts +22 -0
  52. package/dist/lib/models/schemaNormalize.js +58 -0
  53. package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
  54. package/dist/lib/observability/supabaseSpanExporter.js +115 -0
  55. package/dist/lib/observability/tracer.d.ts +44 -0
  56. package/dist/lib/observability/tracer.js +242 -0
  57. package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
  58. package/dist/lib/persistence/supabaseProvider.js +88 -0
  59. package/dist/lib/session/supabaseSessionService.d.ts +26 -0
  60. package/dist/lib/session/supabaseSessionService.js +193 -0
  61. package/dist/lib/session/transcript.d.ts +193 -0
  62. package/dist/lib/session/transcript.js +309 -0
  63. package/dist/lib/toolRegistry.d.ts +15 -0
  64. package/dist/lib/toolRegistry.js +58 -0
  65. package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
  66. package/dist/lib/tools/collectionsSearchTool.js +86 -0
  67. package/dist/lib/tools/generateImageTool.d.ts +16 -0
  68. package/dist/lib/tools/generateImageTool.js +119 -0
  69. package/dist/lib/tools/inspectImageTool.d.ts +21 -0
  70. package/dist/lib/tools/inspectImageTool.js +97 -0
  71. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  72. package/dist/lib/tools/mcpToolFactory.js +116 -0
  73. package/dist/lib/tools/toolContract.d.ts +78 -0
  74. package/dist/lib/tools/toolContract.js +129 -0
  75. package/dist/lib/tools/webExtractTool.d.ts +70 -0
  76. package/dist/lib/tools/webExtractTool.js +461 -0
  77. package/dist/lib/tools/webSearchTool.d.ts +53 -0
  78. package/dist/lib/tools/webSearchTool.js +126 -0
  79. package/dist/lib/tools/wikiTools.d.ts +112 -0
  80. package/dist/lib/tools/wikiTools.js +697 -0
  81. package/dist/lib/tools/xSearchTool.d.ts +61 -0
  82. package/dist/lib/tools/xSearchTool.js +125 -0
  83. package/dist/lib/wiki/agentRun.d.ts +39 -0
  84. package/dist/lib/wiki/agentRun.js +79 -0
  85. package/dist/lib/wiki/builder.d.ts +71 -0
  86. package/dist/lib/wiki/builder.js +165 -0
  87. package/dist/lib/wiki/dive.d.ts +56 -0
  88. package/dist/lib/wiki/dive.js +208 -0
  89. package/dist/lib/wiki/entities.d.ts +220 -0
  90. package/dist/lib/wiki/entities.js +661 -0
  91. package/dist/lib/wiki/extract.d.ts +97 -0
  92. package/dist/lib/wiki/extract.js +305 -0
  93. package/dist/lib/wiki/fill.d.ts +37 -0
  94. package/dist/lib/wiki/fill.js +175 -0
  95. package/dist/lib/wiki/format.d.ts +132 -0
  96. package/dist/lib/wiki/format.js +177 -0
  97. package/dist/lib/wiki/graph.d.ts +80 -0
  98. package/dist/lib/wiki/graph.js +147 -0
  99. package/dist/lib/wiki/lint.d.ts +58 -0
  100. package/dist/lib/wiki/lint.js +267 -0
  101. package/dist/lib/wiki/markdown.d.ts +127 -0
  102. package/dist/lib/wiki/markdown.js +336 -0
  103. package/dist/lib/wiki/pipeline.d.ts +69 -0
  104. package/dist/lib/wiki/pipeline.js +119 -0
  105. package/dist/lib/wiki/vault.d.ts +84 -0
  106. package/dist/lib/wiki/vault.js +194 -0
  107. package/dist/scripts/a2a_server.d.ts +2 -0
  108. package/dist/scripts/a2a_server.js +997 -0
  109. package/dist/scripts/syndicate_chat.d.ts +2 -0
  110. package/dist/scripts/syndicate_chat.js +485 -0
  111. package/package.json +96 -0
@@ -0,0 +1,499 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { EMBEDDING_MODEL, EMBEDDING_DIMENSIONS, MEMORY_EXTRACTION_MODEL } from "../config.js";
3
+ /**
4
+ * Harness-injected blocks the Discord surface prefixes to a user message
5
+ * (nihilistic-penguin stocks.py): the date marker and the portfolio payload.
6
+ * Neither is something the USER said. Stripped from the memory SEARCH query
7
+ * (a portfolio JSON would dominate the embedding and drown the question) and
8
+ * from the extraction transcript (a snapshot that changes daily would be
9
+ * re-stored as "what the user holds" every session — the analyst reads the
10
+ * live payload directly, memory only needs what the user SAID about it).
11
+ */
12
+ export function stripHarnessBlocks(text) {
13
+ return text
14
+ .replace(/\[System Context:[^\]]*\]\s*/g, '')
15
+ .replace(/\[(?:Portfolio Performance Payload|Stock Trade Sheet)\]\s*```[\s\S]*?```\s*/g, '')
16
+ .trim();
17
+ }
18
+ const FACT_EXTRACTION_PROMPT = `You are a precise record-keeping engine. Your job is to analyze a conversation transcript and distill it into structured, self-contained memory records that can be trusted and evolved in future conversations.
19
+
20
+ Today's date (the session date) is {session_date}.
21
+
22
+ OUTPUT FORMAT — one record per line, nothing else:
23
+
24
+ [TAG | date: YYYY-MM-DD or n/a | source: <who asserted it> | status: active or historical | keys: <1-5 comma-separated index keys>] <the record text>
25
+
26
+ A record that corrects or replaces an earlier record adds one more header field before the closing bracket:
27
+
28
+ [CORRECTION | date: ... | source: ... | status: active | keys: ... | supersedes: <short quote of the outdated claim>] <the corrected record text>
29
+
30
+ TAGS: [FACT], [PREFERENCE], [DECISION], [ACTION], [CONTEXT], [INSIGHT], [CORRECTION], [EPISODE].
31
+
32
+ RULES:
33
+ 1. Each record must be self-contained — it must make sense without the original conversation. Include the user's identity or reference when relevant ("The user's father...", not "He...").
34
+ 2. VALUES KEEP THEIR UNITS, EXACTLY AS STATED: "25 mg twice daily", "1.4 mg/dL", "132/85 mmHg". Never round, convert, or restate a number from your own knowledge.
35
+ 3. DATES ARE ABSOLUTE: the date field is the date the record is ABOUT (not today, unless it is about today). Convert every relative date using the session date — "last Tuesday" becomes its YYYY-MM-DD. Use n/a only when genuinely undated. Repeat any date inside the record text as YYYY-MM-DD too.
36
+ 4. SOURCE names who asserted the record: "user", "user's cardiologist Dr. Osei", "discharge orders 2026-07-03", "lab report", "unknown". Never store your own general knowledge as a record — the store holds THIS user's history, not the model's training data.
37
+ 5. STATUS is "active" for things currently true, "historical" for things explicitly ended, discontinued, or replaced within this same transcript.
38
+ 6. KEYS are 1-5 lowercase index terms (names, medications, metrics, topics) that connect this record to future questions: "metoprolol, heart rate, dr-osei".
39
+ 7. CORRECTIONS: when the user corrects or updates something previously established, emit a [CORRECTION] record with a supersedes field quoting the outdated claim, so the store can retire it.
40
+ 8. CONTRADICTIONS: if the transcript contains an unresolved contradiction, store BOTH records and add one [CONTEXT] record naming the conflict, with "contradiction" among its keys. Never silently pick a side.
41
+ 9. EPISODE: emit exactly one [EPISODE] record per transcript — a 1-3 sentence narrative of what happened this session (what was discussed, decided, and left open). This is the semantic thread connecting the discrete records.
42
+ 10. Do NOT store conversational filler, greetings, meta-commentary, or anything the model could re-derive from general knowledge.
43
+ 11. Do NOT fabricate or infer records not explicitly present in the transcript.
44
+ 12. If the conversation contains no extractable records, respond with exactly: NO_FACTS_EXTRACTED
45
+
46
+ Example output:
47
+ [FACT | date: 2026-07-03 | source: discharge orders 2026-07-03 | status: active | keys: metoprolol, dosing, cardiac event] The user's father takes metoprolol tartrate 25 mg twice daily, started 2026-07-03 after his cardiac event.
48
+ [FACT | date: 2026-07-08 | source: lab report | status: active | keys: creatinine, kidney, labs] The father's creatinine on 2026-07-08 was 1.4 mg/dL, up from 1.1 mg/dL in 2026-05.
49
+ [CORRECTION | date: 2026-07-10 | source: user | status: active | keys: metoprolol, dosing | supersedes: metoprolol tartrate 25 mg twice daily] Per the user, the cardiologist raised the father's metoprolol to 50 mg twice daily on 2026-07-10.
50
+ [EPISODE | date: {session_date} | source: session | status: active | keys: follow-up, medications] The user reviewed the father's record ahead of Tuesday's cardiology follow-up and updated the metoprolol dose; baseline blood pressure readings are still missing.
51
+
52
+ {domain_rules}Now distill the following conversation transcript:
53
+
54
+ ---
55
+ {transcript}
56
+ ---`;
57
+ const RECORD_RE = /^\[([A-Z]+)((?:\s*\|[^\]]*)?)\]\s*(.+)$/;
58
+ /**
59
+ * Cosine similarity at or above which a new record is treated as a restatement
60
+ * of one already stored, and dropped.
61
+ *
62
+ * Deliberately high. The extraction model rephrases the same fact on every
63
+ * pass ("The user has $831 in a forgotten Roth IRA" / "…an $831 balance in a
64
+ * forgotten Roth IRA account"), and those land around 0.95+; genuinely
65
+ * different facts about one subject ("NVDA closed at $217.50" vs "Wells Fargo
66
+ * put a $315 target on NVDA") sit far below. Tuning this DOWN risks silently
67
+ * discarding a real fact, which is the worse failure — a duplicate only costs
68
+ * a shortlist slot.
69
+ */
70
+ export const MEMORY_DEDUP_SIMILARITY = 0.93;
71
+ /**
72
+ * Which of a session's events still need ingesting.
73
+ *
74
+ * The A2A server is stateless, so `addSessionToMemory` runs after EVERY task
75
+ * with the WHOLE session — an N-turn conversation was being extracted N times
76
+ * over a growing transcript. That produced 13 EPISODE records narrating the
77
+ * same session and one balance stored six ways. A high-water mark per session
78
+ * means each turn is distilled once.
79
+ */
80
+ export function eventsToIngest(events, alreadyIngested) {
81
+ if (!Array.isArray(events) || events.length <= alreadyIngested)
82
+ return [];
83
+ return events.slice(Math.max(0, alreadyIngested));
84
+ }
85
+ /**
86
+ * True when a candidate record restates something already stored.
87
+ *
88
+ * Same tag is required: an EPISODE narrating a discussion of the user's Roth
89
+ * IRA is not the FACT of its balance, however close the wording sits. Retired
90
+ * rows never suppress a new one — a superseded record is history, and the
91
+ * fact it was corrected to may legitimately be re-asserted later.
92
+ */
93
+ export function isSemanticDuplicate(candidate, matches, threshold = MEMORY_DEDUP_SIMILARITY) {
94
+ return matches.some(m => (m.status ?? 'active') === 'active'
95
+ && (m.tag ?? '') === candidate.tag
96
+ && m.similarity >= threshold);
97
+ }
98
+ const MONTH_NAMES = [
99
+ 'january', 'february', 'march', 'april', 'may', 'june',
100
+ 'july', 'august', 'september', 'october', 'november', 'december',
101
+ ];
102
+ export class SupabaseVectorMemoryService {
103
+ genai;
104
+ supabase;
105
+ /**
106
+ * How many of each session's events have already been distilled.
107
+ * Keyed `{userKey}::{sessionId}`.
108
+ *
109
+ * In-process on purpose: a restart resets it, so the next turn re-reads
110
+ * that session once. Semantic dedup absorbs that batch, which is why this
111
+ * needs no table of its own — the two fixes cover each other's edges.
112
+ */
113
+ ingestedEventCount = new Map();
114
+ constructor(config, supabaseClient) {
115
+ this.genai = new GoogleGenAI({ apiKey: config.apiKey });
116
+ this.supabase = supabaseClient;
117
+ console.log(`[MemoryService] Initialized — backend: Supabase Vector Search (pgvector), structured records`);
118
+ }
119
+ /**
120
+ * @param extractionRules Optional domain rules appended to the shared
121
+ * extraction prompt for THIS consumer only. The prompt is global (the
122
+ * patient advocate uses the same one), so anything domain-specific —
123
+ * "never store a market quote" — arrives here rather than being edited
124
+ * into it. Declared as `memory_extraction_rules` on the served syndicate.
125
+ */
126
+ async addSessionToMemory(session, extractionRules) {
127
+ const userKey = `${session.appName}/${session.userId}`;
128
+ const watermarkKey = `${userKey}::${session.id}`;
129
+ const alreadyIngested = this.ingestedEventCount.get(watermarkKey) ?? 0;
130
+ const fresh = eventsToIngest(session.events ?? [], alreadyIngested);
131
+ if (fresh.length === 0) {
132
+ console.log(`[MemoryService] Session ${session.id}: no new turns since last ingestion — skipping.`);
133
+ return;
134
+ }
135
+ const transcript = this.serializeEvents(fresh);
136
+ if (!transcript.trim()) {
137
+ console.log(`[MemoryService] Session ${session.id} has no content — skipping ingestion.`);
138
+ return;
139
+ }
140
+ console.log(`[MemoryService] Extracting records from session: ${session.id} `
141
+ + `(${fresh.length} new turn(s) of ${(session.events ?? []).length})`);
142
+ const records = await this.extractRecords(transcript, extractionRules);
143
+ // Advance only after extraction SUCCEEDS — a throw leaves the turns
144
+ // pending so the next task retries them rather than losing them.
145
+ this.ingestedEventCount.set(watermarkKey, (session.events ?? []).length);
146
+ if (records.length === 0) {
147
+ console.log(`[MemoryService] No records extracted from session ${session.id}.`);
148
+ return;
149
+ }
150
+ console.log(`[MemoryService] Extracted ${records.length} record(s) from session ${session.id}.`);
151
+ const embeddings = await this.embedTexts(records.map(r => r.line));
152
+ const inserted = await this.upsertToSupabase(userKey, records, embeddings);
153
+ await this.applySupersessions(userKey, records, inserted);
154
+ console.log(`[MemoryService] Stored ${inserted.size} record(s) for user key: ${userKey}`);
155
+ }
156
+ /**
157
+ * Right-to-erasure: permanently deletes every stored fact for a user key
158
+ * (`{appName}/{userId}`). Returns the number of rows removed. Throws on
159
+ * database error so callers can surface the failure — a silent no-op on
160
+ * a deletion request is unacceptable for user data.
161
+ *
162
+ * NOTE: this clears `adk_memory_facts` only. Session transcripts live in
163
+ * `adk_sessions` and must be cleared separately if full erasure is needed.
164
+ */
165
+ async deleteUserMemory(userKey) {
166
+ const { count, error } = await this.supabase
167
+ .from('adk_memory_facts')
168
+ .delete({ count: 'exact' })
169
+ .eq('user_key', userKey);
170
+ if (error) {
171
+ throw new Error(`Memory deletion failed for ${userKey}: ${error.message}`);
172
+ }
173
+ console.log(`[MemoryService] Deleted ${count ?? 0} fact(s) for user key: ${userKey}`);
174
+ return count ?? 0;
175
+ }
176
+ async searchMemory(request) {
177
+ const query = stripHarnessBlocks(request.query) || request.query;
178
+ console.log(`[MemoryService] Searching memory for: "${query}"`);
179
+ const userKey = `${request.appName}/${request.userId}`;
180
+ return this.searchSupabase(userKey, query);
181
+ }
182
+ async extractRecords(transcript, extractionRules) {
183
+ const sessionDate = new Date().toISOString().slice(0, 10);
184
+ // Empty by default, so a consumer that declares no rules gets exactly
185
+ // the prompt it got before this slot existed.
186
+ const domainRules = extractionRules?.trim()
187
+ ? `DOMAIN RULES — these narrow the rules above for this deployment and win on conflict:\n${extractionRules.trim()}\n\n`
188
+ : '';
189
+ const prompt = FACT_EXTRACTION_PROMPT
190
+ .replaceAll('{session_date}', sessionDate)
191
+ .replace('{domain_rules}', domainRules)
192
+ .replace('{transcript}', transcript);
193
+ try {
194
+ const response = await this.genai.models.generateContent({
195
+ model: MEMORY_EXTRACTION_MODEL,
196
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
197
+ config: {
198
+ temperature: 0.1,
199
+ maxOutputTokens: 4096,
200
+ }
201
+ });
202
+ const text = response.text?.trim() ?? '';
203
+ if (!text || text === 'NO_FACTS_EXTRACTED')
204
+ return [];
205
+ return text
206
+ .split('\n')
207
+ .map(line => this.parseRecord(line.trim()))
208
+ .filter((r) => r !== null);
209
+ }
210
+ catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ console.error(`[MemoryService] Record extraction failed: ${msg}`);
213
+ return [];
214
+ }
215
+ }
216
+ /**
217
+ * Parses one structured record line. Lines that don't match the format
218
+ * are dropped (extraction is best-effort; a malformed line must never
219
+ * poison the store). Header fields are optional individually — a bare
220
+ * `[FACT] text` line still parses, with defaults.
221
+ */
222
+ parseRecord(line) {
223
+ const m = RECORD_RE.exec(line);
224
+ if (!m)
225
+ return null;
226
+ const [, tag, headerRest, body] = m;
227
+ if (!body.trim())
228
+ return null;
229
+ let factDate = null;
230
+ let source = null;
231
+ let status = 'active';
232
+ let keys = [];
233
+ let supersedes = null;
234
+ for (const field of headerRest.split('|')) {
235
+ const idx = field.indexOf(':');
236
+ if (idx === -1)
237
+ continue;
238
+ const name = field.slice(0, idx).trim().toLowerCase();
239
+ const value = field.slice(idx + 1).trim();
240
+ if (!value)
241
+ continue;
242
+ switch (name) {
243
+ case 'date':
244
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value))
245
+ factDate = value;
246
+ break;
247
+ case 'source':
248
+ source = value;
249
+ break;
250
+ case 'status':
251
+ if (value.toLowerCase() === 'historical')
252
+ status = 'historical';
253
+ break;
254
+ case 'keys':
255
+ keys = value.split(',').map(k => k.trim().toLowerCase()).filter(k => k.length > 0).slice(0, 8);
256
+ break;
257
+ case 'supersedes':
258
+ supersedes = value;
259
+ break;
260
+ }
261
+ }
262
+ return { line, tag, factDate, source, status, keys, supersedes };
263
+ }
264
+ async embedTexts(texts) {
265
+ const embeddings = [];
266
+ for (const text of texts) {
267
+ try {
268
+ const response = await this.genai.models.embedContent({
269
+ model: EMBEDDING_MODEL,
270
+ contents: text,
271
+ config: {
272
+ outputDimensionality: EMBEDDING_DIMENSIONS
273
+ }
274
+ });
275
+ if (response.embeddings && response.embeddings.length > 0) {
276
+ embeddings.push(response.embeddings[0].values ?? []);
277
+ }
278
+ else {
279
+ console.warn(`[MemoryService] Empty embedding returned for: "${text.slice(0, 50)}..."`);
280
+ embeddings.push([]);
281
+ }
282
+ }
283
+ catch (err) {
284
+ const msg = err instanceof Error ? err.message : String(err);
285
+ console.error(`[MemoryService] Embedding failed: ${msg}`);
286
+ embeddings.push([]);
287
+ }
288
+ }
289
+ return embeddings;
290
+ }
291
+ serializeEvents(events) {
292
+ const lines = [];
293
+ for (const event of events) {
294
+ if (!event.content?.parts)
295
+ continue;
296
+ const textParts = [];
297
+ for (const p of event.content.parts) {
298
+ if ('text' in p && typeof p.text === 'string' && p.text.trim().length > 0) {
299
+ const cleaned = stripHarnessBlocks(p.text);
300
+ if (cleaned)
301
+ textParts.push(cleaned);
302
+ }
303
+ }
304
+ if (textParts.length === 0)
305
+ continue;
306
+ const role = event.author ?? event.content.role ?? 'unknown';
307
+ lines.push(`[${role}]: ${textParts.join(' ')}`);
308
+ }
309
+ return lines.join('\n');
310
+ }
311
+ /**
312
+ * Inserts new records and returns a map of record line → inserted row id
313
+ * (needed to link superseded rows to their replacement).
314
+ */
315
+ async upsertToSupabase(userKey, records, embeddings) {
316
+ const insertedIds = new Map();
317
+ // First pass: byte-identical lines already stored. Cheap, and catches
318
+ // a genuinely repeated extraction. It is NOT sufficient on its own —
319
+ // the extraction model rephrases, so the semantic pass below is what
320
+ // actually stops "the user has $831 in a forgotten Roth IRA" being
321
+ // stored six ways.
322
+ const { data: existing } = await this.supabase
323
+ .from('adk_memory_facts')
324
+ .select('fact')
325
+ .eq('user_key', userKey)
326
+ .in('fact', records.map(r => r.line));
327
+ const known = new Set((existing ?? []).map((row) => row.fact));
328
+ // Second pass: semantic. One similarity probe per surviving candidate —
329
+ // the embedding is already computed, and the same RPC the supersession
330
+ // path uses. A restatement is dropped; the stored row keeps its
331
+ // original date, which is the earliest the user asserted it.
332
+ const payload = [];
333
+ for (let i = 0; i < records.length; i++) {
334
+ const record = records[i];
335
+ if (embeddings[i].length === 0 || known.has(record.line))
336
+ continue;
337
+ const { data: near, error: nearErr } = await this.supabase.rpc('match_memory_facts', {
338
+ query_embedding: embeddings[i],
339
+ match_count: 5,
340
+ filter_user_key: userKey,
341
+ });
342
+ if (nearErr) {
343
+ // Fail OPEN: a dedup probe that errors must not silently drop a
344
+ // record. A duplicate is recoverable; a lost fact is not.
345
+ console.error(`[MemoryService] Dedup probe failed (storing anyway):`, nearErr.message);
346
+ }
347
+ else if (isSemanticDuplicate(record, (near ?? []))) {
348
+ console.log(`[MemoryService] Duplicate skipped [${record.tag}]: "${record.line.slice(0, 70)}..."`);
349
+ continue;
350
+ }
351
+ payload.push({
352
+ user_key: userKey,
353
+ fact: record.line,
354
+ embedding: embeddings[i],
355
+ tag: record.tag,
356
+ fact_date: record.factDate,
357
+ source: record.source,
358
+ status: record.status,
359
+ keys: record.keys,
360
+ });
361
+ }
362
+ if (payload.length === 0)
363
+ return insertedIds;
364
+ const { data, error } = await this.supabase
365
+ .from('adk_memory_facts')
366
+ .insert(payload)
367
+ .select('id, fact');
368
+ if (error) {
369
+ console.error(`[MemoryService] Failed to upsert to Supabase:`, error.message);
370
+ return insertedIds;
371
+ }
372
+ for (const row of data ?? []) {
373
+ insertedIds.set(row.fact, row.id);
374
+ }
375
+ return insertedIds;
376
+ }
377
+ /**
378
+ * The record evolves: a stored record carrying a `supersedes` header
379
+ * retires the older rows it corrects. Candidates are found by embedding
380
+ * the superseded quote and similarity-searching the user's ACTIVE rows;
381
+ * a candidate is retired when it is both semantically close and shares
382
+ * an index key with the correction (or is a near-exact match). Retired
383
+ * rows are kept — marked status='superseded' and linked via
384
+ * superseded_by — so history and contradictions stay inspectable.
385
+ */
386
+ async applySupersessions(userKey, records, insertedIds) {
387
+ const corrections = records.filter(r => r.supersedes && insertedIds.has(r.line));
388
+ if (corrections.length === 0)
389
+ return;
390
+ const newIds = new Set(insertedIds.values());
391
+ const targetEmbeddings = await this.embedTexts(corrections.map(r => r.supersedes));
392
+ for (let i = 0; i < corrections.length; i++) {
393
+ const correction = corrections[i];
394
+ const vec = targetEmbeddings[i];
395
+ if (!vec || vec.length === 0)
396
+ continue;
397
+ const { data, error } = await this.supabase.rpc('match_memory_facts', {
398
+ query_embedding: vec,
399
+ match_count: 5,
400
+ filter_user_key: userKey,
401
+ });
402
+ if (error) {
403
+ console.error(`[MemoryService] Supersession lookup failed:`, error.message);
404
+ continue;
405
+ }
406
+ const correctionId = insertedIds.get(correction.line);
407
+ for (const row of (data ?? [])) {
408
+ if (newIds.has(row.id))
409
+ continue; // never retire a record from this same ingestion
410
+ if ((row.status ?? 'active') !== 'active')
411
+ continue;
412
+ const sharesKey = (row.keys ?? []).some(k => correction.keys.includes(k));
413
+ const retire = row.similarity >= 0.85 || (row.similarity >= 0.6 && sharesKey);
414
+ if (!retire)
415
+ continue;
416
+ const { error: updateError } = await this.supabase
417
+ .from('adk_memory_facts')
418
+ .update({ status: 'superseded', superseded_by: correctionId })
419
+ .eq('id', row.id)
420
+ .eq('user_key', userKey);
421
+ if (updateError) {
422
+ console.error(`[MemoryService] Failed to retire superseded record:`, updateError.message);
423
+ }
424
+ else {
425
+ console.log(`[MemoryService] Superseded: "${row.fact.slice(0, 60)}..." → ${correctionId}`);
426
+ }
427
+ }
428
+ }
429
+ }
430
+ /**
431
+ * Hybrid recall: candidates come from pgvector cosine similarity, then
432
+ * are re-ranked by the query's connection to the record's index keys and
433
+ * dates. Active records outrank retired ones; retired records that still
434
+ * surface are relabeled so the model sees them as history, never as the
435
+ * current state.
436
+ */
437
+ async searchSupabase(userKey, query) {
438
+ const queryEmbeddings = await this.embedTexts([query]);
439
+ const queryVec = queryEmbeddings[0];
440
+ if (!queryVec || queryVec.length === 0) {
441
+ return { memories: [] };
442
+ }
443
+ try {
444
+ // Calls the Postgres RPC function `match_memory_facts` created in Supabase
445
+ const { data, error } = await this.supabase.rpc('match_memory_facts', {
446
+ query_embedding: queryVec,
447
+ match_count: 24,
448
+ filter_user_key: userKey,
449
+ });
450
+ if (error) {
451
+ console.error(`[MemoryService] Failed to query Supabase Vector Search:`, error.message);
452
+ return { memories: [] };
453
+ }
454
+ const q = query.toLowerCase();
455
+ const queryYears = q.match(/\b20\d{2}\b/g) ?? [];
456
+ const queryMonths = MONTH_NAMES.filter(mn => q.includes(mn));
457
+ const scored = (data ?? []).map(row => {
458
+ let score = row.similarity;
459
+ const status = row.status ?? 'active';
460
+ // Index-key channel: the query names an entity the record is filed under.
461
+ if ((row.keys ?? []).some(k => k.length > 2 && q.includes(k)))
462
+ score += 0.12;
463
+ // Date channel: the query names a month/year the record is dated to.
464
+ if (row.fact_date) {
465
+ const [year, month] = row.fact_date.split('-');
466
+ if (queryYears.includes(year))
467
+ score += 0.08;
468
+ if (queryMonths.includes(MONTH_NAMES[parseInt(month, 10) - 1]))
469
+ score += 0.1;
470
+ }
471
+ // The current record outranks its retired ancestors.
472
+ if (status === 'active')
473
+ score += 0.05;
474
+ return { row, score, status };
475
+ }).sort((a, b) => b.score - a.score).slice(0, 10);
476
+ const memories = scored.map(({ row, status }) => {
477
+ let text = row.fact;
478
+ if (status === 'superseded') {
479
+ // The stored header says "status: active" as of writing; relabel
480
+ // so a retired record can never masquerade as the current state.
481
+ text = text.includes('status: active')
482
+ ? text.replace('status: active', 'status: SUPERSEDED by a later correction')
483
+ : `[SUPERSEDED by a later correction] ${text}`;
484
+ }
485
+ return {
486
+ content: { role: 'user', parts: [{ text }] },
487
+ author: 'memory_service',
488
+ timestamp: row.created_at || new Date().toISOString()
489
+ };
490
+ });
491
+ console.log(`[MemoryService] Found ${memories.length} relevant memories (Supabase pgvector, hybrid re-rank).`);
492
+ return { memories };
493
+ }
494
+ catch (error) {
495
+ console.error(`[MemoryService] Exception calling Supabase Vector Search:`, error.message);
496
+ return { memories: [] };
497
+ }
498
+ }
499
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * lib/models/claudeLlm.ts — Anthropic Claude provider for the ADK LLMRegistry.
3
+ *
4
+ * WHY this file exists:
5
+ * The ADK JS package (@google/adk v1.x) is Gemini-native. It does not ship a
6
+ * Claude adapter the way the ADK Java SDK does. However, the ADK exposes a
7
+ * clean extension point: subclass BaseLlm, implement generateContentAsync(),
8
+ * and register the class via LLMRegistry.register(). The registry maps model
9
+ * name patterns to provider classes, so once registered, any LlmAgent with
10
+ * model: "claude-*" will automatically route through this class.
11
+ *
12
+ * HOW TO ENABLE:
13
+ * 1. Install the Anthropic SDK:
14
+ * npm install @anthropic-ai/sdk
15
+ * 2. Add your API key to .env:
16
+ * ANTHROPIC_API_KEY=sk-ant-...
17
+ * 3. registerAvailableProviders() (lib/models/registry.ts) registers this
18
+ * adapter automatically when the key is set.
19
+ * 4. Set model: "claude-sonnet-4-6" (or any claude-* id) in your YAML.
20
+ *
21
+ * CAPABILITIES:
22
+ * - Tools: FunctionTools AND MCP-discovered tools work. Tool schemas are
23
+ * normalized from the ADK/Gemini UPPERCASE dialect to the lowercase
24
+ * JSON-Schema types Anthropic requires (lib/models/schemaNormalize.ts).
25
+ * - web_search: declaring the `web_search` tool in YAML enables Anthropic's
26
+ * native web_search server tool — searches run on Anthropic's side.
27
+ * - Extended thinking: generateContentConfig.thinkingConfig.thinkingBudget
28
+ * maps to Anthropic's thinking parameter. Thinking blocks are surfaced
29
+ * as { text, thought: true } parts (display-only; kept out of session
30
+ * history). NOTE: thinking + tool use in the same agent is NOT supported
31
+ * this pass — Anthropic requires signed thinking blocks to be replayed
32
+ * on tool loops; the raw blocks are stashed in customMetadata
33
+ * ['anthropic.thinking'] for a future upgrade.
34
+ * - Token usage: response.usage is mapped to LlmResponse.usageMetadata, so
35
+ * traceAgentRun / llm.request spans count Claude tokens like Gemini's.
36
+ *
37
+ * LIMITATIONS vs Gemini:
38
+ * - generate_image tool uses the Gemini image model directly and is not
39
+ * affected by the inference model choice.
40
+ * - Live/bidirectional streaming (connect) is not supported.
41
+ *
42
+ * CONTENT FORMAT TRANSLATION:
43
+ * The ADK uses Google GenAI Content/Part objects internally. This adapter
44
+ * translates them to Anthropic MessageParam format and maps the response back
45
+ * to LlmResponse. Tool calls (function_call / function_response parts) are
46
+ * translated to Anthropic's tool_use / tool_result blocks.
47
+ */
48
+ import { BaseLlm } from '@google/adk';
49
+ import type { LlmRequest, LlmResponse } from '@google/adk';
50
+ import type { BaseLlmConnection } from '@google/adk';
51
+ /** ADK toolsDict → Anthropic tool definitions (lowercase schemas; the
52
+ * web_search sentinel becomes Anthropic's native server tool). */
53
+ export declare function buildAnthropicTools(llmRequest: LlmRequest): any[];
54
+ export declare class ClaudeLlm extends BaseLlm {
55
+ /**
56
+ * Regex patterns that map model name strings to this provider.
57
+ * Any model: "claude-*" in a YAML config will route here after registration.
58
+ */
59
+ static readonly supportedModels: Array<string | RegExp>;
60
+ private apiKey?;
61
+ constructor({ model, apiKey }: {
62
+ model: string;
63
+ apiKey?: string;
64
+ });
65
+ /**
66
+ * Main generation method called by the ADK Runner for every turn.
67
+ *
68
+ * We translate ADK's LlmRequest (Google Content format) → Anthropic
69
+ * MessageParam format, call the Anthropic Messages API, then translate
70
+ * the response back to LlmResponse. The whole call is wrapped in an
71
+ * llm.request OpenTelemetry span (provider/model/tokens/latency).
72
+ */
73
+ generateContentAsync(llmRequest: LlmRequest, stream?: boolean): AsyncGenerator<LlmResponse, void>;
74
+ private generateInner;
75
+ /** Maps a complete Anthropic message → the final LlmResponse. */
76
+ private finalResponse;
77
+ /**
78
+ * Live/bidirectional streaming connection — not supported by the Anthropic
79
+ * Messages API. Throws to surface this clearly rather than silently failing.
80
+ */
81
+ connect(_llmRequest: LlmRequest): Promise<BaseLlmConnection>;
82
+ }
83
+ /**
84
+ * Registers ClaudeLlm with the ADK LLMRegistry.
85
+ *
86
+ * WHY a separate function instead of auto-registering at import time:
87
+ * Auto-registration at module load would cause every consumer of this file
88
+ * to unconditionally register Claude, even if ANTHROPIC_API_KEY is absent.
89
+ * By making registration explicit, registerAvailableProviders() can
90
+ * conditionally call this only when the key is present, and log a clear
91
+ * message about which provider is active.
92
+ */
93
+ export declare function registerClaudeLlm(): void;