kongbrain 0.1.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,120 @@
1
+ /**
2
+ * Core memory management tool — CRUD on always-loaded directives.
3
+ * Ported from kongbrain with SurrealStore injection.
4
+ */
5
+
6
+ import { Type } from "@sinclair/typebox";
7
+ import type { GlobalPluginState, SessionState } from "../state.js";
8
+
9
+ const coreMemorySchema = Type.Object({
10
+ action: Type.Union([
11
+ Type.Literal("list"),
12
+ Type.Literal("add"),
13
+ Type.Literal("update"),
14
+ Type.Literal("deactivate"),
15
+ ], { description: "Action to perform on core memory." }),
16
+ tier: Type.Optional(Type.Number({ description: "Filter by tier (0=always loaded, 1=session-pinned). Default: list all." })),
17
+ category: Type.Optional(Type.String({ description: "Category (identity/rules/tools/operations/general)." })),
18
+ text: Type.Optional(Type.String({ description: "Text content for add/update actions." })),
19
+ priority: Type.Optional(Type.Number({ description: "Priority for add/update (higher=loaded first). Default: 50." })),
20
+ id: Type.Optional(Type.String({ description: "Record ID for update/deactivate (e.g. core_memory:abc123)." })),
21
+ session_id: Type.Optional(Type.String({ description: "Session ID for Tier 1 entries." })),
22
+ });
23
+
24
+ export function createCoreMemoryToolDef(state: GlobalPluginState, session: SessionState) {
25
+ return {
26
+ name: "core_memory",
27
+ label: "Core Memory",
28
+ description: "Manage always-loaded core directives (Tier 0) and session-pinned context (Tier 1). Tier 0 entries are present in EVERY turn — use for identity, rules, tool patterns. Tier 1 entries are pinned for the current session.",
29
+ parameters: coreMemorySchema,
30
+ execute: async (_toolCallId: string, params: {
31
+ action: "list" | "add" | "update" | "deactivate";
32
+ tier?: number; category?: string; text?: string;
33
+ priority?: number; id?: string; session_id?: string;
34
+ }) => {
35
+ const { store } = state;
36
+ if (!store.isAvailable()) {
37
+ return { content: [{ type: "text" as const, text: "Database unavailable." }], details: null };
38
+ }
39
+
40
+ try {
41
+ switch (params.action) {
42
+ case "list": {
43
+ const entries = await store.getAllCoreMemory(params.tier);
44
+ if (entries.length === 0) {
45
+ return { content: [{ type: "text" as const, text: "No core memory entries found." }], details: null };
46
+ }
47
+ const formatted = entries.map((e, i) => {
48
+ const sid = e.session_id ? ` session:${e.session_id}` : "";
49
+ return `${i + 1}. [T${e.tier}/${e.category}/p${e.priority}${sid}] ${e.id}\n ${e.text.slice(0, 200)}`;
50
+ }).join("\n\n");
51
+ return {
52
+ content: [{ type: "text" as const, text: `${entries.length} core memory entries:\n\n${formatted}` }],
53
+ details: { count: entries.length },
54
+ };
55
+ }
56
+
57
+ case "add": {
58
+ if (!params.text) {
59
+ return { content: [{ type: "text" as const, text: "Error: 'text' is required for add action." }], details: null };
60
+ }
61
+ const tier = params.tier ?? 0;
62
+ const sid = tier === 1 ? (params.session_id ?? session.sessionId) : undefined;
63
+ const id = await store.createCoreMemory(
64
+ params.text,
65
+ params.category ?? "general",
66
+ params.priority ?? 50,
67
+ tier,
68
+ sid,
69
+ );
70
+ if (!id) {
71
+ return {
72
+ content: [{ type: "text" as const, text: "FAILED: Core memory entry was not created." }],
73
+ details: { error: true },
74
+ };
75
+ }
76
+ return {
77
+ content: [{ type: "text" as const, text: `Created core memory: ${id} (tier ${tier}, ${params.category ?? "general"}, p${params.priority ?? 50})` }],
78
+ details: { id },
79
+ };
80
+ }
81
+
82
+ case "update": {
83
+ if (!params.id) {
84
+ return { content: [{ type: "text" as const, text: "Error: 'id' is required for update action." }], details: null };
85
+ }
86
+ const fields: Record<string, unknown> = {};
87
+ if (params.text !== undefined) fields.text = params.text;
88
+ if (params.category !== undefined) fields.category = params.category;
89
+ if (params.priority !== undefined) fields.priority = params.priority;
90
+ if (params.tier !== undefined) fields.tier = params.tier;
91
+ const updated = await store.updateCoreMemory(params.id, fields);
92
+ if (!updated) {
93
+ return {
94
+ content: [{ type: "text" as const, text: `FAILED: Could not update ${params.id}.` }],
95
+ details: { error: true },
96
+ };
97
+ }
98
+ return {
99
+ content: [{ type: "text" as const, text: `Updated core memory: ${params.id}` }],
100
+ details: { id: params.id },
101
+ };
102
+ }
103
+
104
+ case "deactivate": {
105
+ if (!params.id) {
106
+ return { content: [{ type: "text" as const, text: "Error: 'id' is required for deactivate action." }], details: null };
107
+ }
108
+ await store.deleteCoreMemory(params.id);
109
+ return {
110
+ content: [{ type: "text" as const, text: `Deactivated core memory: ${params.id}` }],
111
+ details: { id: params.id },
112
+ };
113
+ }
114
+ }
115
+ } catch (err) {
116
+ return { content: [{ type: "text" as const, text: `Core memory operation failed: ${err}` }], details: null };
117
+ }
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Introspect tool — inspect the memory database.
3
+ * Ported from kongbrain with SurrealStore injection.
4
+ */
5
+
6
+ import { Type } from "@sinclair/typebox";
7
+ import type { GlobalPluginState, SessionState } from "../state.js";
8
+ import { assertRecordId } from "../surreal.js";
9
+ import { migrateWorkspace } from "../workspace-migrate.js";
10
+ import { checkGraduation, formatGraduationReport, hasSoul } from "../soul.js";
11
+
12
+ const ALLOWED_TABLES = new Set([
13
+ "agent", "project", "task", "artifact", "concept",
14
+ "turn", "identity_chunk", "session", "memory",
15
+ "core_memory", "monologue", "skill", "reflection",
16
+ "retrieval_outcome", "orchestrator_metrics",
17
+ "causal_chain", "compaction_checkpoint", "subagent",
18
+ "memory_utility_cache", "soul", "graduation_event", "maturity_stage",
19
+ ]);
20
+
21
+ const VECTOR_TABLES = new Set([
22
+ "concept", "memory", "artifact", "identity_chunk", "turn", "monologue", "skill", "reflection",
23
+ ]);
24
+
25
+ const COUNT_FILTERS: Record<string, string> = {
26
+ active: "WHERE active = true",
27
+ inactive: "WHERE active = false",
28
+ recent_24h: "WHERE created_at > time::now() - 24h",
29
+ with_embedding: "WHERE embedding != NONE AND array::len(embedding) > 0",
30
+ unresolved: "WHERE status != 'resolved' OR status IS NONE",
31
+ };
32
+
33
+ const QUERY_TEMPLATES: Record<string, { sql: string; description: string; needsTable?: boolean }> = {
34
+ recent: {
35
+ sql: "SELECT id, text, content, description, created_at FROM type::table($t) ORDER BY created_at DESC LIMIT 5",
36
+ description: "Last 5 records by creation time",
37
+ needsTable: true,
38
+ },
39
+ sessions: {
40
+ sql: "SELECT id, started_at, turn_count, total_input_tokens, total_output_tokens, last_active FROM session ORDER BY started_at DESC LIMIT 10",
41
+ description: "Last 10 sessions with stats",
42
+ },
43
+ core_by_category: {
44
+ sql: "SELECT category, count() AS count FROM core_memory WHERE active = true GROUP BY category",
45
+ description: "Core memory entries grouped by category",
46
+ },
47
+ memory_status: {
48
+ sql: "SELECT status, count() AS count FROM memory GROUP BY status",
49
+ description: "Memory counts grouped by status",
50
+ },
51
+ embedding_coverage: {
52
+ sql: "",
53
+ description: "Per-table embedding vs total counts",
54
+ },
55
+ };
56
+
57
+ const introspectSchema = Type.Object({
58
+ action: Type.Union([
59
+ Type.Literal("status"),
60
+ Type.Literal("count"),
61
+ Type.Literal("verify"),
62
+ Type.Literal("query"),
63
+ Type.Literal("migrate"),
64
+ ], { description: "Action: status (health overview), count (row counts), verify (confirm record), query (predefined reports), migrate (ingest workspace .md files into DB — ask user first)." }),
65
+ table: Type.Optional(Type.String({ description: "Table name for count/query actions." })),
66
+ filter: Type.Optional(Type.String({ description: "For count: active, inactive, recent_24h, with_embedding, unresolved. For query: template name." })),
67
+ record_id: Type.Optional(Type.String({ description: "Record ID for verify action (e.g. memory:abc123)." })),
68
+ });
69
+
70
+ export function createIntrospectToolDef(state: GlobalPluginState, session: SessionState) {
71
+ return {
72
+ name: "introspect",
73
+ label: "Memory Introspect",
74
+ description: "Inspect your memory database. Use for ALL database queries — NEVER use curl or bash to access SurrealDB directly. Actions: status (health + table counts), count (filtered row counts), verify (confirm record exists), query (predefined reports).",
75
+ parameters: introspectSchema,
76
+ execute: async (_toolCallId: string, params: {
77
+ action: "status" | "count" | "verify" | "query" | "migrate";
78
+ table?: string; filter?: string; record_id?: string;
79
+ }) => {
80
+ const { store } = state;
81
+ if (!store.isAvailable()) {
82
+ return { content: [{ type: "text" as const, text: "Database unavailable." }], details: null };
83
+ }
84
+
85
+ try {
86
+ switch (params.action) {
87
+ case "status": return await statusAction(store, session.sessionId);
88
+ case "count": return await countAction(store, params.table, params.filter);
89
+ case "verify": return await verifyAction(store, params.record_id);
90
+ case "query": return await queryAction(store, params.table, params.filter);
91
+ case "migrate": return await migrateAction(state);
92
+ }
93
+ } catch (err) {
94
+ return { content: [{ type: "text" as const, text: `Introspect failed: ${err}` }], details: null };
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ // ── Actions ──────────────────────────────────────────────────────────────
101
+
102
+ async function statusAction(store: any, sessionId: string) {
103
+ const info = store.getInfo();
104
+ const alive = await store.ping();
105
+
106
+ const lines: string[] = [];
107
+ lines.push("MEMORY DATABASE STATUS");
108
+ lines.push("═══════════════════════════════════");
109
+ lines.push(`Connection: ${info?.url ?? "unknown"}`);
110
+ lines.push(`Namespace: ${info?.ns ?? "unknown"}`);
111
+ lines.push(`Database: ${info?.db ?? "unknown"}`);
112
+ lines.push(`Ping: ${alive ? "OK" : "FAILED"}`);
113
+ lines.push(`Session: ${sessionId}`);
114
+ lines.push("");
115
+
116
+ const counts: Record<string, number> = {};
117
+ const embCounts: Record<string, number> = {};
118
+
119
+ for (const t of ALLOWED_TABLES) {
120
+ try {
121
+ const rows = await store.queryFirst(
122
+ `SELECT count() AS count FROM type::table($t) GROUP ALL`, { t },
123
+ );
124
+ counts[t] = rows[0]?.count ?? 0;
125
+ } catch { counts[t] = -1; }
126
+ }
127
+
128
+ for (const t of VECTOR_TABLES) {
129
+ try {
130
+ const rows = await store.queryFirst(
131
+ `SELECT count() AS count FROM type::table($t) WHERE embedding != NONE AND array::len(embedding) > 0 GROUP ALL`, { t },
132
+ );
133
+ embCounts[t] = rows[0]?.count ?? 0;
134
+ } catch { embCounts[t] = 0; }
135
+ }
136
+
137
+ for (const t of ALLOWED_TABLES) {
138
+ const c = counts[t];
139
+ const label = (t + ":").padEnd(28);
140
+ const countStr = c === -1 ? "error" : String(c).padStart(5);
141
+ const embStr = VECTOR_TABLES.has(t) ? ` (${embCounts[t] ?? 0} embedded)` : "";
142
+ lines.push(` ${label}${countStr}${embStr}`);
143
+ }
144
+
145
+ const totalNodes = Object.values(counts).filter(c => c >= 0).reduce((a, b) => a + b, 0);
146
+ const totalEmb = Object.values(embCounts).reduce((a, b) => a + b, 0);
147
+ lines.push("");
148
+ lines.push(`Total records: ${totalNodes}`);
149
+ lines.push(`Total embeddings: ${totalEmb}`);
150
+
151
+ // Graduation status
152
+ lines.push("");
153
+ lines.push("SOUL GRADUATION");
154
+ lines.push("═══════════════════════════════════");
155
+ try {
156
+ const soulExists = await hasSoul(store);
157
+ if (soulExists) {
158
+ lines.push("Status: GRADUATED (soul document exists)");
159
+ } else {
160
+ const report = await checkGraduation(store);
161
+ lines.push(formatGraduationReport(report));
162
+ }
163
+ } catch {
164
+ lines.push("Status: Unable to check graduation");
165
+ }
166
+
167
+ return {
168
+ content: [{ type: "text" as const, text: lines.join("\n") }],
169
+ details: { counts, embCounts, alive, totalNodes, totalEmb },
170
+ };
171
+ }
172
+
173
+ async function countAction(store: any, table?: string, filter?: string) {
174
+ if (!table || !ALLOWED_TABLES.has(table)) {
175
+ return {
176
+ content: [{ type: "text" as const, text: `Error: valid 'table' required. Available: ${[...ALLOWED_TABLES].sort().join(", ")}` }],
177
+ details: null,
178
+ };
179
+ }
180
+
181
+ let whereClause = "";
182
+ if (filter) {
183
+ if (!COUNT_FILTERS[filter]) {
184
+ return {
185
+ content: [{ type: "text" as const, text: `Error: unknown filter "${filter}". Available: ${Object.keys(COUNT_FILTERS).join(", ")}` }],
186
+ details: null,
187
+ };
188
+ }
189
+ whereClause = " " + COUNT_FILTERS[filter];
190
+ }
191
+
192
+ const rows = await store.queryFirst(
193
+ `SELECT count() AS count FROM type::table($t)${whereClause} GROUP ALL`, { t: table },
194
+ );
195
+ const count = rows[0]?.count ?? 0;
196
+ return {
197
+ content: [{ type: "text" as const, text: `${table}: ${count} rows${filter ? ` (filter: ${filter})` : ""}` }],
198
+ details: { table, count, filter },
199
+ };
200
+ }
201
+
202
+ async function verifyAction(store: any, recordId?: string) {
203
+ if (!recordId) {
204
+ return { content: [{ type: "text" as const, text: "Error: 'record_id' is required." }], details: null };
205
+ }
206
+ try { assertRecordId(recordId); } catch {
207
+ return { content: [{ type: "text" as const, text: `Error: invalid record ID "${recordId}".` }], details: null };
208
+ }
209
+
210
+ const rows = await store.queryFirst(`SELECT * FROM ${recordId}`);
211
+ if (rows.length === 0) {
212
+ return { content: [{ type: "text" as const, text: `Record not found: ${recordId}` }], details: { exists: false } };
213
+ }
214
+
215
+ const record = rows[0];
216
+ const cleaned: Record<string, unknown> = {};
217
+ for (const [key, val] of Object.entries(record as any)) {
218
+ if (Array.isArray(val) && val.length > 100 && typeof val[0] === "number") {
219
+ cleaned[key] = `[${val.length} dims]`;
220
+ } else {
221
+ cleaned[key] = val;
222
+ }
223
+ }
224
+
225
+ const lines = Object.entries(cleaned)
226
+ .map(([k, v]) => ` ${k}: ${typeof v === "string" ? v.slice(0, 300) : JSON.stringify(v)}`)
227
+ .join("\n");
228
+
229
+ return {
230
+ content: [{ type: "text" as const, text: `Record ${recordId}:\n${lines}` }],
231
+ details: { exists: true, id: recordId, record: cleaned },
232
+ };
233
+ }
234
+
235
+ async function migrateAction(state: GlobalPluginState) {
236
+ const { store, embeddings, workspaceDir } = state;
237
+ if (!workspaceDir) {
238
+ return {
239
+ content: [{ type: "text" as const, text: "No workspace directory configured — cannot migrate." }],
240
+ details: null,
241
+ };
242
+ }
243
+
244
+ const result = await migrateWorkspace(workspaceDir, store, embeddings);
245
+
246
+ const lines: string[] = [];
247
+ lines.push("WORKSPACE MIGRATION REPORT");
248
+ lines.push("═══════════════════════════════════");
249
+ lines.push(`Files ingested: ${result.ingested}`);
250
+ lines.push(`Files skipped: ${result.skipped}`);
251
+ lines.push(`Archived: ${result.archived ? "Yes" : "No"}`);
252
+ if (result.archivePath) lines.push(`Archive path: ${result.archivePath}`);
253
+ lines.push("");
254
+ lines.push("Details:");
255
+ for (const detail of result.details) {
256
+ lines.push(` ${detail}`);
257
+ }
258
+ if (result.ingested > 0) {
259
+ lines.push("");
260
+ lines.push("SOUL.md was left in place — it will be read as a nudge during soul graduation.");
261
+ }
262
+
263
+ return {
264
+ content: [{ type: "text" as const, text: lines.join("\n") }],
265
+ details: result,
266
+ };
267
+ }
268
+
269
+ async function queryAction(store: any, table?: string, template?: string) {
270
+ const tmpl = template ?? "";
271
+ if (!QUERY_TEMPLATES[tmpl]) {
272
+ const available = Object.entries(QUERY_TEMPLATES)
273
+ .map(([k, v]) => ` ${k}${v.needsTable ? " (requires table)" : ""}: ${v.description}`)
274
+ .join("\n");
275
+ return {
276
+ content: [{ type: "text" as const, text: `Available query templates:\n${available}` }],
277
+ details: { templates: Object.keys(QUERY_TEMPLATES) },
278
+ };
279
+ }
280
+
281
+ const spec = QUERY_TEMPLATES[tmpl];
282
+ if (spec.needsTable && (!table || !ALLOWED_TABLES.has(table))) {
283
+ return {
284
+ content: [{ type: "text" as const, text: `Error: "${tmpl}" requires a valid table.` }],
285
+ details: null,
286
+ };
287
+ }
288
+
289
+ // Embedding coverage special case
290
+ if (tmpl === "embedding_coverage") {
291
+ const lines: string[] = [];
292
+ for (const t of VECTOR_TABLES) {
293
+ try {
294
+ const totalRows = await store.queryFirst(
295
+ `SELECT count() AS count FROM type::table($t) GROUP ALL`, { t },
296
+ );
297
+ const embRows = await store.queryFirst(
298
+ `SELECT count() AS count FROM type::table($t) WHERE embedding != NONE AND array::len(embedding) > 0 GROUP ALL`, { t },
299
+ );
300
+ const total = totalRows[0]?.count ?? 0;
301
+ const emb = embRows[0]?.count ?? 0;
302
+ const pct = total > 0 ? Math.round((emb / total) * 100) : 0;
303
+ lines.push(` ${(t + ":").padEnd(20)} ${emb}/${total} (${pct}%)`);
304
+ } catch { /* skip */ }
305
+ }
306
+ return { content: [{ type: "text" as const, text: `Embedding coverage:\n${lines.join("\n")}` }], details: null };
307
+ }
308
+
309
+ const rows = await store.queryFirst(spec.sql, table ? { t: table } : undefined);
310
+ if (rows.length === 0) {
311
+ return { content: [{ type: "text" as const, text: `No results for "${tmpl}".` }], details: null };
312
+ }
313
+
314
+ const formatted = rows.map((r: any, i: number) => {
315
+ const fields = Object.entries(r)
316
+ .filter(([k]) => k !== "embedding")
317
+ .map(([k, v]) => {
318
+ if (typeof v === "string" && v.length > 200) return `${k}: ${v.slice(0, 200)}...`;
319
+ return `${k}: ${JSON.stringify(v)}`;
320
+ })
321
+ .join(", ");
322
+ return `${i + 1}. ${fields}`;
323
+ }).join("\n");
324
+
325
+ return {
326
+ content: [{ type: "text" as const, text: `${tmpl}${table ? ` (${table})` : ""}:\n${formatted}` }],
327
+ details: { count: rows.length },
328
+ };
329
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Recall tool — search the persistent memory graph.
3
+ * Ported from kongbrain with SurrealStore/EmbeddingService injection.
4
+ */
5
+
6
+ import { Type } from "@sinclair/typebox";
7
+ import type { GlobalPluginState, SessionState } from "../state.js";
8
+ import { findRelevantSkills, formatSkillContext } from "../skills.js";
9
+ import { swallow } from "../errors.js";
10
+ import type { VectorSearchResult } from "../surreal.js";
11
+
12
+ const recallSchema = Type.Object({
13
+ query: Type.String({ description: "What to search for in memory. Can be a concept, topic, decision, file path, or natural language description." }),
14
+ scope: Type.Optional(Type.Union([
15
+ Type.Literal("all"),
16
+ Type.Literal("memories"),
17
+ Type.Literal("concepts"),
18
+ Type.Literal("turns"),
19
+ Type.Literal("artifacts"),
20
+ Type.Literal("skills"),
21
+ ], { description: "Limit search to a specific memory type. Default: all." })),
22
+ limit: Type.Optional(Type.Number({ description: "Max results to return. Default: 5, max: 15." })),
23
+ });
24
+
25
+ export function createRecallToolDef(state: GlobalPluginState, session: SessionState) {
26
+ return {
27
+ name: "recall",
28
+ label: "Memory Recall",
29
+ description: "Search your persistent memory graph for past conversations, decisions, concepts, files, and context from previous sessions. Context from past sessions is already auto-injected — check what you have before calling this.",
30
+ parameters: recallSchema,
31
+ execute: async (_toolCallId: string, params: { query: string; scope?: string; limit?: number }) => {
32
+ const { store, embeddings } = state;
33
+ if (!embeddings.isAvailable() || !store.isAvailable()) {
34
+ return { content: [{ type: "text" as const, text: "Memory system unavailable." }], details: null };
35
+ }
36
+
37
+ const maxResults = Math.min(params.limit ?? 5, 15);
38
+
39
+ try {
40
+ const queryVec = await embeddings.embed(params.query);
41
+ const scope = params.scope ?? "all";
42
+
43
+ if (scope === "skills") {
44
+ const skills = await findRelevantSkills(queryVec, maxResults);
45
+ if (skills.length === 0) {
46
+ return { content: [{ type: "text" as const, text: `No skills found matching "${params.query}".` }], details: null };
47
+ }
48
+ return {
49
+ content: [{ type: "text" as const, text: `Found ${skills.length} relevant skills:\n${formatSkillContext(skills)}` }],
50
+ details: { count: skills.length, ids: skills.map((s) => s.id) },
51
+ };
52
+ }
53
+
54
+ const limits = {
55
+ turn: scope === "all" || scope === "turns" ? maxResults : 0,
56
+ identity: 0,
57
+ concept: scope === "all" || scope === "concepts" ? maxResults : 0,
58
+ memory: scope === "all" || scope === "memories" ? maxResults : 0,
59
+ artifact: scope === "all" || scope === "artifacts" ? maxResults : 0,
60
+ };
61
+
62
+ const results = await store.vectorSearch(queryVec, session.sessionId, limits);
63
+
64
+ const topIds = results
65
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
66
+ .slice(0, 5)
67
+ .map((r) => r.id);
68
+
69
+ let neighbors: VectorSearchResult[] = [];
70
+ if (topIds.length > 0) {
71
+ try {
72
+ const expanded = await store.graphExpand(topIds, queryVec);
73
+ const existingIds = new Set(results.map((r) => r.id));
74
+ neighbors = expanded.filter((n) => !existingIds.has(n.id));
75
+ } catch (e) { swallow("recall:graphExpand", e); }
76
+ }
77
+
78
+ const all = [...results, ...neighbors]
79
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
80
+ .slice(0, maxResults);
81
+
82
+ if (all.length === 0) {
83
+ return { content: [{ type: "text" as const, text: `No memories found matching "${params.query}".` }], details: null };
84
+ }
85
+
86
+ const formatted = all.map((r, i) => {
87
+ const tag = r.table === "turn" ? `[${r.role ?? "turn"}]` : `[${r.table}]`;
88
+ const time = r.timestamp ? ` (${new Date(r.timestamp).toLocaleDateString()})` : "";
89
+ const score = r.score ? ` score:${r.score.toFixed(2)}` : "";
90
+ return `${i + 1}. ${tag}${time}${score}\n ${(r.text ?? "").slice(0, 500)}`;
91
+ }).join("\n\n");
92
+
93
+ return {
94
+ content: [{ type: "text" as const, text: `Found ${all.length} results for "${params.query}":\n\n${formatted}` }],
95
+ details: { count: all.length, ids: all.map((r) => r.id) },
96
+ };
97
+ } catch (err) {
98
+ return { content: [{ type: "text" as const, text: `Memory search failed: ${err}` }], details: null };
99
+ }
100
+ },
101
+ };
102
+ }