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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-memory",
3
- "version": "1.0.2",
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 };
@@ -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
  }
@@ -44,6 +44,8 @@ export const TSUNAMI_BUN_NATIVE_CMDS = [
44
44
  'traverse_graph',
45
45
  'find_tunnels',
46
46
  'graph_stats',
47
+ 'add_embedding',
48
+ 'search_vector',
47
49
  ] as const;
48
50
 
49
51
  export const TSUNAMI_WRAPPER_BRIDGE_CMDS = [] as const;