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.
- package/AGENT_SETUP.md +182 -0
- package/DOCUMENTATION.md +614 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +164 -0
- package/README.md +151 -0
- package/config/agents/README.md +27 -0
- package/config/agents/examples/ares.yaml +74 -0
- package/config/agents/examples/cartographers.yaml +115 -0
- package/config/agents/examples/claude.yaml +17 -0
- package/config/agents/examples/council.yaml +65 -0
- package/config/agents/examples/critic.yaml +74 -0
- package/config/agents/examples/delegation.yaml +29 -0
- package/config/agents/examples/hierarchical.yaml +26 -0
- package/config/agents/examples/image_production.yaml +112 -0
- package/config/agents/examples/librarian.yaml +75 -0
- package/config/agents/examples/model_zoo.yaml +81 -0
- package/config/agents/examples/patient_advocate.yaml +107 -0
- package/config/agents/examples/scriptorium.yaml +96 -0
- package/config/agents/examples/style_council.yaml +83 -0
- package/config/agents/examples/syndicate.yaml +34 -0
- package/config/agents/examples/tutor.yaml +66 -0
- package/config/agents/syndicateSchema.yaml +291 -0
- package/dist/lib/config.d.ts +108 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/dispatch.d.ts +133 -0
- package/dist/lib/dispatch.js +182 -0
- package/dist/lib/grounding.d.ts +28 -0
- package/dist/lib/grounding.js +61 -0
- package/dist/lib/index.d.ts +14 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/loadEnv.d.ts +21 -0
- package/dist/lib/loadEnv.js +58 -0
- package/dist/lib/loadSyndicate.d.ts +177 -0
- package/dist/lib/loadSyndicate.js +264 -0
- package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
- package/dist/lib/memory/supabaseMemoryService.js +499 -0
- package/dist/lib/models/claudeLlm.d.ts +93 -0
- package/dist/lib/models/claudeLlm.js +332 -0
- package/dist/lib/models/gptLlm.d.ts +110 -0
- package/dist/lib/models/gptLlm.js +497 -0
- package/dist/lib/models/grokLlm.d.ts +64 -0
- package/dist/lib/models/grokLlm.js +86 -0
- package/dist/lib/models/ollamaLlm.d.ts +105 -0
- package/dist/lib/models/ollamaLlm.js +134 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
- package/dist/lib/models/openAiCompatibleLlm.js +331 -0
- package/dist/lib/models/providerMap.d.ts +28 -0
- package/dist/lib/models/providerMap.js +45 -0
- package/dist/lib/models/registry.d.ts +71 -0
- package/dist/lib/models/registry.js +159 -0
- package/dist/lib/models/schemaNormalize.d.ts +22 -0
- package/dist/lib/models/schemaNormalize.js +58 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
- package/dist/lib/observability/supabaseSpanExporter.js +115 -0
- package/dist/lib/observability/tracer.d.ts +44 -0
- package/dist/lib/observability/tracer.js +242 -0
- package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
- package/dist/lib/persistence/supabaseProvider.js +88 -0
- package/dist/lib/session/supabaseSessionService.d.ts +26 -0
- package/dist/lib/session/supabaseSessionService.js +193 -0
- package/dist/lib/session/transcript.d.ts +193 -0
- package/dist/lib/session/transcript.js +309 -0
- package/dist/lib/toolRegistry.d.ts +15 -0
- package/dist/lib/toolRegistry.js +58 -0
- package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
- package/dist/lib/tools/collectionsSearchTool.js +86 -0
- package/dist/lib/tools/generateImageTool.d.ts +16 -0
- package/dist/lib/tools/generateImageTool.js +119 -0
- package/dist/lib/tools/inspectImageTool.d.ts +21 -0
- package/dist/lib/tools/inspectImageTool.js +97 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
- package/dist/lib/tools/mcpToolFactory.js +116 -0
- package/dist/lib/tools/toolContract.d.ts +78 -0
- package/dist/lib/tools/toolContract.js +129 -0
- package/dist/lib/tools/webExtractTool.d.ts +70 -0
- package/dist/lib/tools/webExtractTool.js +461 -0
- package/dist/lib/tools/webSearchTool.d.ts +53 -0
- package/dist/lib/tools/webSearchTool.js +126 -0
- package/dist/lib/tools/wikiTools.d.ts +112 -0
- package/dist/lib/tools/wikiTools.js +697 -0
- package/dist/lib/tools/xSearchTool.d.ts +61 -0
- package/dist/lib/tools/xSearchTool.js +125 -0
- package/dist/lib/wiki/agentRun.d.ts +39 -0
- package/dist/lib/wiki/agentRun.js +79 -0
- package/dist/lib/wiki/builder.d.ts +71 -0
- package/dist/lib/wiki/builder.js +165 -0
- package/dist/lib/wiki/dive.d.ts +56 -0
- package/dist/lib/wiki/dive.js +208 -0
- package/dist/lib/wiki/entities.d.ts +220 -0
- package/dist/lib/wiki/entities.js +661 -0
- package/dist/lib/wiki/extract.d.ts +97 -0
- package/dist/lib/wiki/extract.js +305 -0
- package/dist/lib/wiki/fill.d.ts +37 -0
- package/dist/lib/wiki/fill.js +175 -0
- package/dist/lib/wiki/format.d.ts +132 -0
- package/dist/lib/wiki/format.js +177 -0
- package/dist/lib/wiki/graph.d.ts +80 -0
- package/dist/lib/wiki/graph.js +147 -0
- package/dist/lib/wiki/lint.d.ts +58 -0
- package/dist/lib/wiki/lint.js +267 -0
- package/dist/lib/wiki/markdown.d.ts +127 -0
- package/dist/lib/wiki/markdown.js +336 -0
- package/dist/lib/wiki/pipeline.d.ts +69 -0
- package/dist/lib/wiki/pipeline.js +119 -0
- package/dist/lib/wiki/vault.d.ts +84 -0
- package/dist/lib/wiki/vault.js +194 -0
- package/dist/scripts/a2a_server.d.ts +2 -0
- package/dist/scripts/a2a_server.js +997 -0
- package/dist/scripts/syndicate_chat.d.ts +2 -0
- package/dist/scripts/syndicate_chat.js +485 -0
- 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;
|