tsunami-memory 1.0.2 → 1.0.3
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/package.json +1 -1
- package/server/api.ts +13 -1
- package/server/mcp.ts +22 -0
- package/src/bun_memory_store.ts +96 -0
- package/src/migration.ts +10 -1
- package/src/tsunami_bun_backend.ts +25 -0
- package/src/tsunami_routing.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tsunami-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "TSUNAMI — Bun-native oceanic memory system with basin/current flow, storm center, hot+cold retrieval, knowledge graph sync, and evidence linking.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"files": [
|
package/server/api.ts
CHANGED
|
@@ -89,6 +89,18 @@ const server = Bun.serve({
|
|
|
89
89
|
return json({ ok: true, timeline: result }, cors);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// ── POST /search/semantic ────────────────────────
|
|
93
|
+
if (url.pathname === '/search/semantic' && req.method === 'POST') {
|
|
94
|
+
const body = await req.json().catch(() => ({}));
|
|
95
|
+
const { embedding, wing, limit } = body;
|
|
96
|
+
if (!Array.isArray(embedding) || embedding.length === 0) {
|
|
97
|
+
return json({ error: 'embedding vector required (number[])' }, cors, 400);
|
|
98
|
+
}
|
|
99
|
+
const store = await import('../src/bun_memory_store');
|
|
100
|
+
const results = store.searchByVector(embedding, limit || 5, wing);
|
|
101
|
+
return json({ ok: true, results }, cors);
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
// ── POST /diary ─────────────────────────────────
|
|
93
105
|
if (url.pathname === '/diary' && req.method === 'POST') {
|
|
94
106
|
const body = await req.json().catch(() => ({}));
|
|
@@ -103,7 +115,7 @@ const server = Bun.serve({
|
|
|
103
115
|
return json({
|
|
104
116
|
service: 'TSUNAMI Memory API',
|
|
105
117
|
version: '1.0.0',
|
|
106
|
-
endpoints: ['/health', '/add', '/search', '/recall', '/storm', '/status', '/timeline', '/diary'],
|
|
118
|
+
endpoints: ['/health', '/add', '/search', '/search/semantic', '/recall', '/storm', '/status', '/timeline', '/diary'],
|
|
107
119
|
}, cors);
|
|
108
120
|
}
|
|
109
121
|
|
package/server/mcp.ts
CHANGED
|
@@ -113,6 +113,19 @@ const TOOLS = [
|
|
|
113
113
|
required: ['entry'],
|
|
114
114
|
},
|
|
115
115
|
},
|
|
116
|
+
{
|
|
117
|
+
name: 'tsunami_search_semantic',
|
|
118
|
+
description: 'Search memories by embedding vector similarity. The caller must provide a pre-computed embedding (e.g. from OpenAI / ollama / local model). Returns top-K results ranked by cosine similarity.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
embedding: { type: 'array', items: { type: 'number' }, description: 'Pre-computed embedding vector (float array)' },
|
|
123
|
+
wing: { type: 'string', description: 'Filter by wing/basin' },
|
|
124
|
+
limit: { type: 'number', description: 'Max results (default: 5)', default: 5 },
|
|
125
|
+
},
|
|
126
|
+
required: ['embedding'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
116
129
|
{
|
|
117
130
|
name: 'tsunami_wings',
|
|
118
131
|
description: 'List all available memory wings/basins and their memory counts.',
|
|
@@ -164,6 +177,15 @@ async function handleTool(name: string, args: Record<string, any>): Promise<any>
|
|
|
164
177
|
case 'tsunami_diary':
|
|
165
178
|
return { result: await client.tsunamiDiary(args.entry, args.agent || 'external', args.wing || 'diary', 3) };
|
|
166
179
|
|
|
180
|
+
case 'tsunami_search_semantic': {
|
|
181
|
+
if (!Array.isArray(args.embedding) || args.embedding.length === 0) {
|
|
182
|
+
throw new Error('embedding vector required');
|
|
183
|
+
}
|
|
184
|
+
const store = await import('../src/bun_memory_store');
|
|
185
|
+
const results = store.searchByVector(args.embedding, args.limit || 5, args.wing);
|
|
186
|
+
return { results };
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
case 'tsunami_wings': {
|
|
168
190
|
const wings = await client.tsunamiListWings();
|
|
169
191
|
return { wings };
|
package/src/bun_memory_store.ts
CHANGED
|
@@ -301,6 +301,102 @@ export function deleteBunMemoryEntry(id: string): boolean {
|
|
|
301
301
|
return result.changes > 0;
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
// ── Vector / Semantic Search ─────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/** Store a memory with an embedding vector for semantic search. */
|
|
307
|
+
export function addWithEmbedding(
|
|
308
|
+
wing: string,
|
|
309
|
+
room: string,
|
|
310
|
+
content: string,
|
|
311
|
+
importance: number,
|
|
312
|
+
embedding: number[],
|
|
313
|
+
): string {
|
|
314
|
+
const id = generateId();
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
const blob = Buffer.from(new Float32Array(embedding).buffer);
|
|
317
|
+
|
|
318
|
+
getDb().prepare(`
|
|
319
|
+
INSERT INTO memory_entries (id, wing, room, content, importance, source, embedding, created_at, updated_at)
|
|
320
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
321
|
+
`).run(
|
|
322
|
+
id,
|
|
323
|
+
wing || 'general',
|
|
324
|
+
room || 'inbox',
|
|
325
|
+
content,
|
|
326
|
+
Math.min(5, Math.max(1, importance ?? 3)),
|
|
327
|
+
'embedding',
|
|
328
|
+
blob as any, // Buffer is valid SQLite BLOB but bun:sqlite types don't recognize it
|
|
329
|
+
now,
|
|
330
|
+
now,
|
|
331
|
+
);
|
|
332
|
+
return id;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
336
|
+
if (a.length !== b.length || a.length === 0) return 0;
|
|
337
|
+
let dot = 0, normA = 0, normB = 0;
|
|
338
|
+
for (let i = 0; i < a.length; i++) {
|
|
339
|
+
dot += a[i] * b[i];
|
|
340
|
+
normA += a[i] * a[i];
|
|
341
|
+
normB += b[i] * b[i];
|
|
342
|
+
}
|
|
343
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
344
|
+
return denom === 0 ? 0 : dot / denom;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function blobToArray(blob: unknown): number[] | null {
|
|
348
|
+
if (!blob) return null;
|
|
349
|
+
try {
|
|
350
|
+
// bun:sqlite returns BLOBs as Uint8Array; Node better-sqlite3 returns Buffer
|
|
351
|
+
if (blob instanceof Uint8Array) {
|
|
352
|
+
return Array.from(new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength)));
|
|
353
|
+
}
|
|
354
|
+
if (Buffer.isBuffer(blob)) {
|
|
355
|
+
return Array.from(new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength)));
|
|
356
|
+
}
|
|
357
|
+
if (blob instanceof ArrayBuffer) {
|
|
358
|
+
return Array.from(new Float32Array(blob));
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Search memories by vector similarity. Returns top-K with similarity scores. */
|
|
367
|
+
export function searchByVector(
|
|
368
|
+
embedding: number[],
|
|
369
|
+
topK = 5,
|
|
370
|
+
wing?: string,
|
|
371
|
+
): Array<{ id: string; wing: string; room: string; content: string; similarity: number }> {
|
|
372
|
+
const db = getDb();
|
|
373
|
+
let sql = 'SELECT id, wing, room, content, embedding FROM memory_entries WHERE embedding IS NOT NULL';
|
|
374
|
+
const params: any[] = [];
|
|
375
|
+
if (wing) { sql += ' AND wing = ?'; params.push(wing); }
|
|
376
|
+
sql += ' ORDER BY created_at DESC LIMIT 500';
|
|
377
|
+
|
|
378
|
+
const rows = db.prepare(sql).all(...params) as Array<{
|
|
379
|
+
id: string; wing: string; room: string; content: string; embedding: unknown;
|
|
380
|
+
}>;
|
|
381
|
+
|
|
382
|
+
const scored = rows
|
|
383
|
+
.map(row => {
|
|
384
|
+
const vec = blobToArray(row.embedding);
|
|
385
|
+
if (!vec) return null;
|
|
386
|
+
return {
|
|
387
|
+
id: row.id,
|
|
388
|
+
wing: row.wing,
|
|
389
|
+
room: row.room,
|
|
390
|
+
content: row.content,
|
|
391
|
+
similarity: Number(cosineSimilarity(embedding, vec).toFixed(4)),
|
|
392
|
+
};
|
|
393
|
+
})
|
|
394
|
+
.filter((r): r is NonNullable<typeof r> => r !== null && r.similarity > 0.3)
|
|
395
|
+
.sort((a, b) => b.similarity - a.similarity);
|
|
396
|
+
|
|
397
|
+
return scored.slice(0, Math.max(1, topK));
|
|
398
|
+
}
|
|
399
|
+
|
|
304
400
|
export function buildBunMemoryPreview(row: BunMemoryRow, maxLen: number): string {
|
|
305
401
|
const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
|
|
306
402
|
if (!text) return '';
|
package/src/migration.ts
CHANGED
|
@@ -157,7 +157,16 @@ const v1_initial_schema: Migration = {
|
|
|
157
157
|
},
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
+
/** v2 — Add embedding column for vector search. */
|
|
161
|
+
const v2_add_embedding_column: Migration = {
|
|
162
|
+
version: 2,
|
|
163
|
+
name: 'add_embedding_column',
|
|
164
|
+
up(db) {
|
|
165
|
+
db.run(`ALTER TABLE memory_entries ADD COLUMN embedding BLOB`);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
160
169
|
/** All migrations in version order. Add new entries at the end. */
|
|
161
170
|
export function getMigrations(): Migration[] {
|
|
162
|
-
return [v1_initial_schema];
|
|
171
|
+
return [v1_initial_schema, v2_add_embedding_column];
|
|
163
172
|
}
|
|
@@ -3,6 +3,7 @@ import { dirname } from 'path';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
BUN_MEMORY_DB_PATH,
|
|
6
|
+
addWithEmbedding,
|
|
6
7
|
buildBunMemoryPreview,
|
|
7
8
|
checkBunMemoryDuplicate,
|
|
8
9
|
countBunMemoryEntries,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
listBunMemoryWingCounts,
|
|
16
17
|
recallBunMemoryRows,
|
|
17
18
|
searchBunMemoryRows,
|
|
19
|
+
searchByVector,
|
|
18
20
|
wakeBunMemoryRows,
|
|
19
21
|
} from './bun_memory_store';
|
|
20
22
|
import {
|
|
@@ -701,5 +703,28 @@ export function tryHandleTsunamiBunRequest(req: Record<string, unknown>): any |
|
|
|
701
703
|
};
|
|
702
704
|
}
|
|
703
705
|
|
|
706
|
+
if (cmd === 'add_embedding') {
|
|
707
|
+
const embedding = Array.isArray(req.embedding) ? (req.embedding as number[]) : [];
|
|
708
|
+
if (embedding.length === 0) {
|
|
709
|
+
return { ok: false, error: 'embedding vector required (number[])', __backend: 'bun_native' };
|
|
710
|
+
}
|
|
711
|
+
const id = addWithEmbedding(
|
|
712
|
+
wing || 'general', room || 'inbox',
|
|
713
|
+
String(req.content ?? '').trim(),
|
|
714
|
+
Number(req.importance ?? 3),
|
|
715
|
+
embedding,
|
|
716
|
+
);
|
|
717
|
+
return { ok: true, id, __backend: 'bun_native' };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (cmd === 'search_vector') {
|
|
721
|
+
const vector = Array.isArray(req.embedding) ? (req.embedding as number[]) : [];
|
|
722
|
+
if (vector.length === 0) {
|
|
723
|
+
return { ok: false, error: 'embedding vector required for search_vector', __backend: 'bun_native' };
|
|
724
|
+
}
|
|
725
|
+
const results = searchByVector(vector, Number(req.limit ?? 5), wing || undefined);
|
|
726
|
+
return { ok: true, results, __backend: 'bun_native' };
|
|
727
|
+
}
|
|
728
|
+
|
|
704
729
|
return null;
|
|
705
730
|
}
|