tsunami-memory 1.0.1 → 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.1",
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 };
@@ -5,36 +5,10 @@
5
5
  * and content fingerprinting for deduplication. Schema managed by migration runner.
6
6
  */
7
7
 
8
- import { Database } from 'bun:sqlite';
9
- import { existsSync, mkdirSync } from 'fs';
10
- import { dirname } from 'path';
8
+ import { getDb } from './db';
11
9
  import { BUN_MEMORY_DB_PATH } from './tsunami_storage_paths';
12
- import { runMigrations, getMigrations } from './migration';
13
10
  export { BUN_MEMORY_DB_PATH };
14
11
 
15
- // ── Database initialization ──────────────────────────────────
16
-
17
- function ensureDbDir(): void {
18
- const dir = dirname(BUN_MEMORY_DB_PATH);
19
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20
- }
21
-
22
- let _db: Database | null = null;
23
-
24
- function getDb(): Database {
25
- if (_db) return _db;
26
- ensureDbDir();
27
- _db = new Database(BUN_MEMORY_DB_PATH);
28
- _db.run('PRAGMA journal_mode = WAL');
29
- const applied = runMigrations(_db, getMigrations());
30
- if (applied > 0) {
31
- // First-run or upgrade — log for observability
32
- const v = (_db.prepare('SELECT MAX(version) as v FROM schema_version').get() as any)?.v ?? 0;
33
- console.log(`[TSUNAMI] migrations applied: ${applied}, now at v${v}`);
34
- }
35
- return _db;
36
- }
37
-
38
12
  // ── ID Generation ────────────────────────────────────────────
39
13
 
40
14
  function generateId(): string {
@@ -327,6 +301,102 @@ export function deleteBunMemoryEntry(id: string): boolean {
327
301
  return result.changes > 0;
328
302
  }
329
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
+
330
400
  export function buildBunMemoryPreview(row: BunMemoryRow, maxLen: number): string {
331
401
  const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
332
402
  if (!text) return '';
package/src/db.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * TSUNAMI shared database connection.
3
+ *
4
+ * Single WAL-mode SQLite connection shared by all modules.
5
+ * Schema is managed by the migration runner.
6
+ */
7
+
8
+ import { Database } from 'bun:sqlite';
9
+ import { existsSync, mkdirSync } from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ import { BUN_MEMORY_DB_PATH } from './tsunami_storage_paths';
12
+ import { runMigrations, getMigrations } from './migration';
13
+
14
+ let _db: Database | null = null;
15
+
16
+ /** Get or create the shared database connection. Thread-safe via module-level singleton. */
17
+ export function getDb(): Database {
18
+ if (_db) return _db;
19
+
20
+ const dir = dirname(BUN_MEMORY_DB_PATH);
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+
23
+ _db = new Database(BUN_MEMORY_DB_PATH);
24
+ _db.run('PRAGMA journal_mode = WAL');
25
+
26
+ const applied = runMigrations(_db, getMigrations());
27
+ if (applied > 0) {
28
+ const v = (_db.prepare('SELECT MAX(version) as v FROM schema_version').get() as any)?.v ?? 0;
29
+ console.log(`[TSUNAMI] migrations applied: ${applied}, now at v${v}`);
30
+ }
31
+
32
+ return _db;
33
+ }
34
+
35
+ /** Close the database connection. Mainly useful in tests. */
36
+ export function closeDb(): void {
37
+ if (_db) {
38
+ _db.close();
39
+ _db = null;
40
+ }
41
+ }
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
  }
@@ -6,21 +6,7 @@
6
6
  * BFS traversal, and cross-wing tunnel discovery.
7
7
  */
8
8
 
9
- import { Database } from 'bun:sqlite';
10
- import { BUN_MEMORY_DB_PATH } from './tsunami_storage_paths';
11
- import { runMigrations, getMigrations } from './migration';
12
-
13
- // ── Database initialization ──────────────────────────────────
14
-
15
- let _db: Database | null = null;
16
-
17
- function getDb(): Database {
18
- if (_db) return _db;
19
- _db = new Database(BUN_MEMORY_DB_PATH);
20
- _db.run('PRAGMA journal_mode = WAL');
21
- runMigrations(_db, getMigrations()); // idempotent — applies only pending
22
- return _db;
23
- }
9
+ import { getDb } from './db';
24
10
 
25
11
  // ── ID Generation ────────────────────────────────────────────
26
12
 
@@ -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;