tsunami-memory 1.0.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +501 -0
  3. package/README.zh-CN.md +485 -0
  4. package/package.json +46 -0
  5. package/server/api.ts +125 -0
  6. package/server/mcp.ts +221 -0
  7. package/src/bun_memory_store.ts +340 -0
  8. package/src/classifier_keywords.ts +115 -0
  9. package/src/core/project_state.ts +88 -0
  10. package/src/index.ts +54 -0
  11. package/src/legacy_compat/tsunami_compat.ts +22 -0
  12. package/src/legacy_compat/tsunami_legacy_identity.ts +13 -0
  13. package/src/legacy_compat/tsunami_legacy_taxonomy.ts +197 -0
  14. package/src/memory_audit.ts +32 -0
  15. package/src/memory_conflict_resolver.ts +14 -0
  16. package/src/memory_fabric.ts +31 -0
  17. package/src/memory_manager.ts +10 -0
  18. package/src/memory_promotion.ts +22 -0
  19. package/src/memory_recovery.ts +14 -0
  20. package/src/memory_runtime.ts +7 -0
  21. package/src/migration.ts +163 -0
  22. package/src/provider.ts +68 -0
  23. package/src/runtime/checkpoints/durable_recovery.ts +24 -0
  24. package/src/runtime/paths.ts +11 -0
  25. package/src/storm/basins.ts +57 -0
  26. package/src/storm/boundary.ts +52 -0
  27. package/src/storm/budget.ts +42 -0
  28. package/src/storm/center.ts +396 -0
  29. package/src/storm/confidence.ts +88 -0
  30. package/src/storm/coverage.ts +44 -0
  31. package/src/storm/directive.ts +94 -0
  32. package/src/storm/gate.ts +43 -0
  33. package/src/storm/helpers.ts +172 -0
  34. package/src/storm/horizon.ts +52 -0
  35. package/src/storm/intake.ts +80 -0
  36. package/src/storm/mode.ts +21 -0
  37. package/src/storm/pressure.ts +56 -0
  38. package/src/storm/readiness.ts +29 -0
  39. package/src/storm/saturation.ts +45 -0
  40. package/src/storm/selection.ts +49 -0
  41. package/src/storm/signals.ts +105 -0
  42. package/src/storm/types.ts +216 -0
  43. package/src/tsunami_bun_backend.ts +705 -0
  44. package/src/tsunami_chinese_dialect.ts +19 -0
  45. package/src/tsunami_classifier.ts +137 -0
  46. package/src/tsunami_client.ts +710 -0
  47. package/src/tsunami_execution_gate.ts +232 -0
  48. package/src/tsunami_graph_runtime.ts +359 -0
  49. package/src/tsunami_identity.ts +35 -0
  50. package/src/tsunami_routing.ts +169 -0
  51. package/src/tsunami_runtime_graph_sync.ts +17 -0
  52. package/src/tsunami_schema.ts +403 -0
  53. package/src/tsunami_storage_paths.ts +8 -0
  54. package/src/tsunami_storm_center.ts +53 -0
@@ -0,0 +1,710 @@
1
+ /**
2
+ * TSUNAMI Client — Bun-native operations
3
+ */
4
+
5
+ import { spawn } from 'bun';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { dirname } from 'path';
8
+ import {
9
+ insertBunMemoryEntry,
10
+ } from './bun_memory_store';
11
+ import { tryHandleTsunamiBunRequest } from './tsunami_bun_backend';
12
+ import {
13
+ describeTsunamiRoutingMatrix,
14
+ resolveTsunamiRoutingPolicy,
15
+ } from './tsunami_routing';
16
+ import {
17
+ readTsunamiIdentity,
18
+ } from './tsunami_identity';
19
+ import {
20
+ TSUNAMI_IDENTITY_FILE,
21
+ TSUNAMI_LEGACY_FALLBACK_FILE,
22
+ } from './tsunami_storage_paths';
23
+ import {
24
+ TSUNAMI_COMPAT_WRAPPER,
25
+ isTsunamiLegacyWrapperExplicitlyEnabled,
26
+ listTsunamiCompatPythonCandidates,
27
+ } from './legacy_compat/tsunami_compat';
28
+
29
+ const PROJECT_ROOT = process.env.TSUNAMI_HOME || process.cwd();
30
+ const PRIMARY_TSUNAMI_PYTHON = process.env.TSUNAMI_PYTHON || '';
31
+ const TMP_DIR = process.env.TSUNAMI_TMP || '/tmp/tsunami';
32
+
33
+ type FallbackDrawer = {
34
+ id: string;
35
+ wing: string;
36
+ room: string;
37
+ content: string;
38
+ importance: number;
39
+ ts: number;
40
+ };
41
+
42
+ type FallbackStore = {
43
+ version: number;
44
+ updatedAt: number;
45
+ drawers: FallbackDrawer[];
46
+ };
47
+
48
+ type TsunamiCacheEntry = {
49
+ value: any;
50
+ ts: number;
51
+ };
52
+
53
+ let warnedPrimaryDown = false;
54
+ let warnedFallbackOn = false;
55
+ let warnedPrimaryStderr = false;
56
+ let lastBackend: 'primary' | 'fallback' | 'bun_native' = 'bun_native';
57
+ const tsunamiReadCache = new Map<string, TsunamiCacheEntry>();
58
+ const tsunamiInflight = new Map<string, Promise<any>>();
59
+ // Cache TTL constants (ms) — per-command freshness windows
60
+ const CACHE_TTL_WAKEUP = 30_000; // identity + wing overviews change slowly
61
+ const CACHE_TTL_SEARCH = 18_000; // search results stale faster than overviews
62
+ const CACHE_TTL_RECALL = 12_000; // recently-added entries invalidate recall quickly
63
+ const CACHE_TTL_STATUS = 6_000; // stats/status are highly volatile
64
+ const CACHE_TTL_TIMELINE = 8_000; // timeline changes faster than overview, slower than recall
65
+ const CACHE_TTL_DEFAULT = 15_000; // safe default for unlisted read commands
66
+ const MAX_TSUNAMI_CACHE_ENTRIES = 64;
67
+ const FALLBACK_STORE_MAX_ENTRIES = 5_000; // cap legacy JSON fallback to prevent unbounded growth
68
+ const PRIMARY_TIMEOUT_MS = 20_000; // max wait for Python wrapper before falling back
69
+ const DUPLICATE_DEFAULT_THRESHOLD = 0.9; // Jaccard similarity threshold for dedup
70
+
71
+ const READ_ONLY_CMDS = new Set([
72
+ 'wakeup',
73
+ 'search',
74
+ 'recall',
75
+ 'status',
76
+ 'timeline',
77
+ 'kg_timeline',
78
+ 'kg_query',
79
+ 'kg_stats',
80
+ 'list_wings',
81
+ 'list_rooms',
82
+ 'get_taxonomy',
83
+ 'check_duplicate',
84
+ 'traverse_graph',
85
+ 'find_tunnels',
86
+ 'graph_stats',
87
+ 'diary_read',
88
+ 'get_aaak_spec',
89
+ 'classify',
90
+ ]);
91
+ const WRITE_CMDS = new Set([
92
+ 'add',
93
+ 'diary',
94
+ 'kg_add',
95
+ 'kg_invalidate',
96
+ 'delete_drawer',
97
+ 'mine',
98
+ ]);
99
+
100
+ function getReadCacheTtl(cmd: string): number {
101
+ switch (cmd) {
102
+ case 'wakeup': return CACHE_TTL_WAKEUP;
103
+ case 'search': return CACHE_TTL_SEARCH;
104
+ case 'recall': return CACHE_TTL_RECALL;
105
+ case 'status': case 'kg_stats': return CACHE_TTL_STATUS;
106
+ case 'timeline': case 'kg_timeline': return CACHE_TTL_TIMELINE;
107
+ default: return CACHE_TTL_DEFAULT;
108
+ }
109
+ }
110
+
111
+ function buildCacheKey(req: Record<string, unknown>): string {
112
+ return JSON.stringify(req);
113
+ }
114
+
115
+ function getCachedRead(req: Record<string, unknown>): any | null {
116
+ const cmd = String(req.cmd ?? '').trim();
117
+ if (!READ_ONLY_CMDS.has(cmd)) return null;
118
+ const key = buildCacheKey(req);
119
+ const hit = tsunamiReadCache.get(key);
120
+ if (!hit) return null;
121
+ if (Date.now() - hit.ts > getReadCacheTtl(cmd)) {
122
+ tsunamiReadCache.delete(key);
123
+ return null;
124
+ }
125
+ return hit.value;
126
+ }
127
+
128
+ function setCachedRead(req: Record<string, unknown>, value: any) {
129
+ const cmd = String(req.cmd ?? '').trim();
130
+ if (!READ_ONLY_CMDS.has(cmd)) return;
131
+ if (tsunamiReadCache.size >= MAX_TSUNAMI_CACHE_ENTRIES) {
132
+ const firstKey = tsunamiReadCache.keys().next().value;
133
+ if (firstKey) tsunamiReadCache.delete(firstKey);
134
+ }
135
+ tsunamiReadCache.set(buildCacheKey(req), {
136
+ value,
137
+ ts: Date.now(),
138
+ });
139
+ }
140
+
141
+ function clearReadCaches() {
142
+ tsunamiReadCache.clear();
143
+ }
144
+
145
+ function tokenize(text: string): string[] {
146
+ return (text.toLowerCase().match(/[\u4e00-\u9fa5a-z0-9_]+/g) ?? []).filter(Boolean);
147
+ }
148
+
149
+ function loadFallbackStore(): FallbackStore {
150
+ try {
151
+ if (!existsSync(TSUNAMI_LEGACY_FALLBACK_FILE)) {
152
+ return { version: 1, updatedAt: Date.now(), drawers: [] };
153
+ }
154
+ const raw = JSON.parse(readFileSync(TSUNAMI_LEGACY_FALLBACK_FILE, 'utf8'));
155
+ const drawers = Array.isArray(raw?.drawers) ? raw.drawers : [];
156
+ return {
157
+ version: 1,
158
+ updatedAt: Number(raw?.updatedAt ?? Date.now()),
159
+ drawers: drawers
160
+ .filter((d: any) => d && typeof d.content === 'string')
161
+ .map((d: any) => ({
162
+ id: String(d.id ?? ''),
163
+ wing: String(d.wing ?? 'ats'),
164
+ room: String(d.room ?? 'ats/general'),
165
+ content: String(d.content ?? ''),
166
+ importance: Number(d.importance ?? 3),
167
+ ts: Number(d.ts ?? Date.now()),
168
+ })),
169
+ };
170
+ } catch (err: unknown) {
171
+ const msg = err instanceof Error ? err.message : String(err);
172
+ console.warn(`[TSUNAMI fallback] failed to parse legacy archive (${msg}); resetting store`);
173
+ return { version: 1, updatedAt: Date.now(), drawers: [] };
174
+ }
175
+ }
176
+
177
+ function saveFallbackStore(store: FallbackStore) {
178
+ const dir = dirname(TSUNAMI_LEGACY_FALLBACK_FILE);
179
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
180
+ store.updatedAt = Date.now();
181
+ writeFileSync(TSUNAMI_LEGACY_FALLBACK_FILE, JSON.stringify(store, null, 2), 'utf8');
182
+ }
183
+
184
+ function scoreMemory(query: string, drawer: FallbackDrawer): number {
185
+ const qTokens = tokenize(query);
186
+ if (!qTokens.length) return 0;
187
+ const text = `${drawer.content} ${drawer.wing} ${drawer.room}`.toLowerCase();
188
+ let hit = 0;
189
+ for (const t of qTokens) if (text.includes(t)) hit++;
190
+ const hitScore = hit / qTokens.length;
191
+ const importanceBoost = Math.min(drawer.importance, 5) * 0.08;
192
+ const recencyBoost = Math.max(0, 1 - (Date.now() - drawer.ts) / (1000 * 60 * 60 * 24 * 30)) * 0.05;
193
+ return hitScore + importanceBoost + recencyBoost;
194
+ }
195
+
196
+ function topDrawers(
197
+ store: FallbackStore,
198
+ opts: { wing?: string; room?: string; query?: string; limit?: number } = {},
199
+ ): Array<{ d: FallbackDrawer; score: number }> {
200
+ const wing = opts.wing?.trim();
201
+ const room = opts.room?.trim();
202
+ const limit = Math.max(1, Number(opts.limit ?? 5));
203
+ const candidates = store.drawers.filter((d) => {
204
+ if (wing && d.wing !== wing) return false;
205
+ if (room && d.room !== room) return false;
206
+ return true;
207
+ });
208
+ const scored = candidates.map((d) => ({
209
+ d,
210
+ score: opts.query ? scoreMemory(opts.query, d) : (d.importance * 0.1 + d.ts / 1e15),
211
+ }));
212
+ scored.sort((a, b) => b.score - a.score || b.d.ts - a.d.ts);
213
+ return scored.slice(0, limit);
214
+ }
215
+
216
+ function fallbackWakeup(wing?: string): string {
217
+ const store = loadFallbackStore();
218
+ const identity = readTsunamiIdentity(TSUNAMI_IDENTITY_FILE);
219
+ const target = wing?.trim();
220
+ const selected = target ? store.drawers.filter((d) => d.wing === target) : store.drawers;
221
+ const wingCounts: Record<string, number> = {};
222
+ for (const d of selected) wingCounts[d.wing] = (wingCounts[d.wing] ?? 0) + 1;
223
+ const topWings = Object.entries(wingCounts)
224
+ .sort((a, b) => b[1] - a[1])
225
+ .slice(0, 5)
226
+ .map(([w, c]) => `${w}:${c}`)
227
+ .join(' / ') || 'none';
228
+ const highlights = topDrawers(
229
+ { ...store, drawers: selected },
230
+ { limit: 5 },
231
+ ).map(({ d }, i) => ` [${i + 1}] ${d.wing}/${d.room} ${d.content.slice(0, 120)}`);
232
+
233
+ return [
234
+ '## L0 — IDENTITY',
235
+ identity,
236
+ '',
237
+ '## L1 — ESSENTIAL STORY',
238
+ `backend=fallback-json`,
239
+ `total=${selected.length} (global=${store.drawers.length})`,
240
+ `top_wings=${topWings}`,
241
+ ...(highlights.length ? ['', ...highlights] : ['', ' (no memories yet)']),
242
+ ].join('\n');
243
+ }
244
+
245
+ function fallbackSearch(query: string, wing?: string, room?: string, limit = 5): string {
246
+ const store = loadFallbackStore();
247
+ const rows = topDrawers(store, { query, wing, room, limit });
248
+ if (!rows.length) {
249
+ return `## L3 — SEARCH RESULTS for "${query}"\n (no results)`;
250
+ }
251
+ const lines = [`## L3 — SEARCH RESULTS for "${query}"`];
252
+ rows.forEach(({ d, score }, i) => {
253
+ lines.push(` [${i + 1}] ${d.wing}/${d.room} (sim=${score.toFixed(3)})`);
254
+ lines.push(` ${d.content.slice(0, 200)}`);
255
+ if (d.content.length > 200) lines.push(' ...');
256
+ });
257
+ return lines.join('\n');
258
+ }
259
+
260
+ function fallbackRecall(wing?: string, room?: string, limit = 10): string {
261
+ const store = loadFallbackStore();
262
+ const rows = topDrawers(store, { wing, room, limit });
263
+ if (!rows.length) {
264
+ return '## L2 — RECALL\n (no memories)';
265
+ }
266
+ const lines = ['## L2 — RECALL'];
267
+ rows.forEach(({ d }, i) => {
268
+ lines.push(` [${i + 1}] ${d.wing}/${d.room}`);
269
+ lines.push(` ${d.content.slice(0, 220)}`);
270
+ if (d.content.length > 220) lines.push(' ...');
271
+ });
272
+ return lines.join('\n');
273
+ }
274
+
275
+ function fallbackAdd(wing: string, room: string, content: string, importance = 3): string {
276
+ const store = loadFallbackStore();
277
+ const id = `fb_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
278
+ store.drawers.push({
279
+ id,
280
+ wing: wing || 'ats',
281
+ room: room || 'ats/general',
282
+ content: content || '',
283
+ importance: Number(importance ?? 3),
284
+ ts: Date.now(),
285
+ });
286
+ if (store.drawers.length > FALLBACK_STORE_MAX_ENTRIES) {
287
+ store.drawers = store.drawers.slice(-FALLBACK_STORE_MAX_ENTRIES);
288
+ }
289
+ saveFallbackStore(store);
290
+ return id;
291
+ }
292
+
293
+ function fallbackListWings(): Record<string, number> {
294
+ const store = loadFallbackStore();
295
+ const wings: Record<string, number> = {};
296
+ for (const d of store.drawers) wings[d.wing] = (wings[d.wing] ?? 0) + 1;
297
+ return wings;
298
+ }
299
+
300
+ function fallbackListRooms(wing?: string): Record<string, number> {
301
+ const store = loadFallbackStore();
302
+ const rooms: Record<string, number> = {};
303
+ for (const d of store.drawers) {
304
+ if (wing && d.wing !== wing) continue;
305
+ rooms[d.room] = (rooms[d.room] ?? 0) + 1;
306
+ }
307
+ return rooms;
308
+ }
309
+
310
+ function fallbackStatus() {
311
+ const store = loadFallbackStore();
312
+ return {
313
+ backend: 'fallback-json',
314
+ total_drawers: store.drawers.length,
315
+ wings: fallbackListWings(),
316
+ updated_at: new Date(store.updatedAt).toISOString(),
317
+ path: TSUNAMI_LEGACY_FALLBACK_FILE,
318
+ };
319
+ }
320
+
321
+ function fallbackCheckDuplicate(content: string, threshold = 0.9) {
322
+ const store = loadFallbackStore();
323
+ const cTok = new Set(tokenize(content));
324
+ const matches: Array<{ id: string; wing: string; room: string; similarity: number; content: string }> = [];
325
+ for (const d of store.drawers) {
326
+ const dTok = new Set(tokenize(d.content));
327
+ const inter = [...cTok].filter((t) => dTok.has(t)).length;
328
+ const union = new Set([...cTok, ...dTok]).size || 1;
329
+ const sim = inter / union;
330
+ if (sim >= threshold) {
331
+ matches.push({
332
+ id: d.id,
333
+ wing: d.wing,
334
+ room: d.room,
335
+ similarity: Number(sim.toFixed(3)),
336
+ content: d.content.slice(0, 200),
337
+ });
338
+ }
339
+ }
340
+ matches.sort((a, b) => b.similarity - a.similarity);
341
+ return { is_duplicate: matches.length > 0, matches: matches.slice(0, 5) };
342
+ }
343
+
344
+ function fallbackRaw(req: Record<string, unknown>, cause: Error): any {
345
+ if (!warnedFallbackOn) {
346
+ console.warn(`[TSUNAMI] fallback backend enabled: ${cause.message}`);
347
+ warnedFallbackOn = true;
348
+ }
349
+ const cmd = String(req.cmd ?? '');
350
+ if (cmd === 'wakeup') return { ok: true, data: fallbackWakeup(String(req.wing ?? '') || undefined) };
351
+ if (cmd === 'search') return { ok: true, data: fallbackSearch(String(req.query ?? ''), String(req.wing ?? '') || undefined, String(req.room ?? '') || undefined, Number(req.limit ?? 5)) };
352
+ if (cmd === 'recall') return { ok: true, data: fallbackRecall(String(req.wing ?? '') || undefined, String(req.room ?? '') || undefined, Number(req.limit ?? 10)) };
353
+ if (cmd === 'add') return { ok: true, id: fallbackAdd(String(req.wing ?? 'ats'), String(req.room ?? 'ats/general'), String(req.content ?? ''), Number(req.importance ?? 3)) };
354
+ if (cmd === 'diary') {
355
+ const room = `diary-${String(req.agent ?? 'ats')}`;
356
+ return { ok: true, id: fallbackAdd(String(req.wing ?? 'ats'), room, String(req.entry ?? ''), Number(req.importance ?? 3)) };
357
+ }
358
+ if (cmd === 'status') return { ok: true, data: fallbackStatus() };
359
+ if (cmd === 'list_wings') return { ok: true, wings: fallbackListWings() };
360
+ if (cmd === 'list_rooms') return { ok: true, wing: req.wing ?? 'all', rooms: fallbackListRooms(String(req.wing ?? '') || undefined) };
361
+ if (cmd === 'get_taxonomy') {
362
+ const store = loadFallbackStore();
363
+ const taxonomy: Record<string, Record<string, number>> = {};
364
+ for (const d of store.drawers) {
365
+ taxonomy[d.wing] = taxonomy[d.wing] ?? {};
366
+ taxonomy[d.wing][d.room] = (taxonomy[d.wing][d.room] ?? 0) + 1;
367
+ }
368
+ return { ok: true, taxonomy };
369
+ }
370
+ if (cmd === 'check_duplicate') {
371
+ const result = fallbackCheckDuplicate(String(req.content ?? ''), Number(req.threshold ?? DUPLICATE_DEFAULT_THRESHOLD));
372
+ return { ok: true, ...result };
373
+ }
374
+ if (cmd === 'delete_drawer') {
375
+ const drawerId = String(req.drawer_id ?? '');
376
+ if (!drawerId) return { ok: false, error: 'drawer_id required (fallback)' };
377
+ const store = loadFallbackStore();
378
+ const before = store.drawers.length;
379
+ store.drawers = store.drawers.filter((d) => d.id !== drawerId);
380
+ if (store.drawers.length === before) return { ok: false, error: `Drawer not found: ${drawerId}` };
381
+ saveFallbackStore(store);
382
+ return { ok: true, deleted_id: drawerId };
383
+ }
384
+ return { ok: false, error: `TSUNAMI backend unavailable and fallback does not support cmd=${cmd}` };
385
+ }
386
+
387
+ function pickPythonExecutable(): string {
388
+ const envPython = String(process.env.TSUNAMI_PYTHON ?? '').trim();
389
+ if (envPython && existsSync(envPython)) return envPython;
390
+ if (existsSync(PRIMARY_TSUNAMI_PYTHON)) return PRIMARY_TSUNAMI_PYTHON;
391
+ for (const candidate of listTsunamiCompatPythonCandidates()) {
392
+ if (existsSync(candidate)) return candidate;
393
+ }
394
+ return 'python3';
395
+ }
396
+
397
+ async function callPrimary(req: Record<string, unknown>, timeoutMs = 20000): Promise<any> {
398
+ if (!existsSync(TMP_DIR)) mkdirSync(TMP_DIR, { recursive: true });
399
+ const pythonBin = pickPythonExecutable();
400
+ const proc = spawn({
401
+ cmd: [pythonBin, '-u', TSUNAMI_COMPAT_WRAPPER],
402
+ cwd: PROJECT_ROOT,
403
+ env: {
404
+ ...process.env,
405
+ TMPDIR: TMP_DIR,
406
+ TMP: TMP_DIR,
407
+ TEMP: TMP_DIR,
408
+ },
409
+ stdout: 'pipe',
410
+ stderr: 'pipe',
411
+ stdin: 'pipe',
412
+ });
413
+
414
+ const input = JSON.stringify(req) + '\n';
415
+ const stdin = proc.stdin as { write: (chunk: string) => void; end: () => void } | null;
416
+ stdin?.write(input);
417
+ stdin?.end();
418
+
419
+ const timer = setTimeout(() => {
420
+ try {
421
+ proc.kill();
422
+ } catch (error) {
423
+ const fallbackMode = 'continue_with_primary_wrapper_timeout_cleanup';
424
+ console.warn(`[TSUNAMI] failed to terminate primary wrapper after timeout; fallback ${fallbackMode}:`, error);
425
+ }
426
+ }, timeoutMs);
427
+
428
+ try {
429
+ const [stdout, stderr] = await Promise.all([
430
+ new Response(proc.stdout).text(),
431
+ new Response(proc.stderr).text(),
432
+ ]);
433
+ clearTimeout(timer);
434
+
435
+ if (stderr.trim() && !warnedPrimaryStderr) {
436
+ console.warn('[TSUNAMI stderr]', stderr.trim().slice(0, 300));
437
+ warnedPrimaryStderr = true;
438
+ }
439
+
440
+ const lines = stdout.split('\n').filter(l => l.trim());
441
+ if (!lines.length) throw new Error('empty stdout');
442
+ const lastLine = lines[lines.length - 1];
443
+ try {
444
+ const parsed = JSON.parse(lastLine);
445
+ if (parsed && typeof parsed === 'object' && !('__backend' in parsed)) {
446
+ (parsed as Record<string, unknown>).__backend = 'primary';
447
+ }
448
+ lastBackend = 'primary';
449
+ return parsed;
450
+ } catch {
451
+ throw new Error('invalid json from tsunami wrapper: ' + lastLine.slice(0, 200));
452
+ }
453
+ } catch (e: any) {
454
+ clearTimeout(timer);
455
+ try {
456
+ proc.kill();
457
+ } catch (error) {
458
+ const fallbackMode = 'continue_with_primary_wrapper_error_cleanup';
459
+ console.warn(`[TSUNAMI] failed to terminate primary wrapper after error; fallback ${fallbackMode}:`, error);
460
+ }
461
+ throw e;
462
+ }
463
+ }
464
+
465
+ async function call(req: Record<string, unknown>, timeoutMs = 20000): Promise<any> {
466
+ const cmd = String(req.cmd ?? '').trim();
467
+ const key = buildCacheKey(req);
468
+ const routingPolicy = resolveTsunamiRoutingPolicy(req);
469
+ const legacyWrapperEnabled = isTsunamiLegacyWrapperExplicitlyEnabled(req);
470
+ const cached = getCachedRead(req);
471
+ if (cached !== null) {
472
+ return cached;
473
+ }
474
+ const bunNative = tryHandleTsunamiBunRequest(req);
475
+ if (bunNative !== null) {
476
+ lastBackend = 'bun_native';
477
+ if (WRITE_CMDS.has(cmd)) clearReadCaches();
478
+ setCachedRead(req, bunNative);
479
+ return bunNative;
480
+ }
481
+ if (routingPolicy === 'bun_native') {
482
+ return {
483
+ ok: false,
484
+ error: `bun_native handler returned no result for cmd=${cmd}`,
485
+ expected_route: 'bun_native',
486
+ routing_summary: describeTsunamiRoutingMatrix(),
487
+ __backend: 'bun_native',
488
+ };
489
+ }
490
+ if (!legacyWrapperEnabled) {
491
+ return {
492
+ ok: false,
493
+ error: `TSUNAMI Bun-native routing does not expose cmd=${cmd}; legacy wrapper shell is dormant`,
494
+ expected_route: routingPolicy === 'unknown' ? 'unsupported' : routingPolicy,
495
+ routing_summary: describeTsunamiRoutingMatrix(),
496
+ compatibility_shell: 'python_wrapper_dormant',
497
+ compatibility_route: 'opt_in_only',
498
+ __backend: 'bun_native',
499
+ };
500
+ }
501
+ if (READ_ONLY_CMDS.has(cmd)) {
502
+ const inflight = tsunamiInflight.get(key);
503
+ if (inflight) return inflight;
504
+ }
505
+
506
+ const task = (async () => {
507
+ try {
508
+ const value = await callPrimary(req, timeoutMs);
509
+ if (cmd === 'add' && !req.skip_bun_hot_mirror) {
510
+ insertBunMemoryEntry({
511
+ wing: String(req.wing ?? 'ats'),
512
+ room: String(req.room ?? 'ats/general'),
513
+ content: String(req.content ?? ''),
514
+ importance: Number(req.importance ?? 3),
515
+ source: String(req.source ?? 'tsunami_direct').trim() || 'tsunami_direct',
516
+ sessionId: String(req.session_id ?? '').trim() || undefined,
517
+ projectDir: String(req.project_dir ?? '').trim() || undefined,
518
+ fingerprint: String(req.fingerprint ?? '').trim() || undefined,
519
+ });
520
+ } else if (cmd === 'diary' && !req.skip_bun_hot_mirror) {
521
+ insertBunMemoryEntry({
522
+ wing: String(req.wing ?? 'ats'),
523
+ room: `diary-${String(req.agent ?? 'ats')}`,
524
+ content: String(req.entry ?? ''),
525
+ importance: Number(req.importance ?? 3),
526
+ source: 'tsunami_diary',
527
+ });
528
+ }
529
+ setCachedRead(req, value);
530
+ if (WRITE_CMDS.has(cmd)) clearReadCaches();
531
+ return value;
532
+ } catch (e: any) {
533
+ if (!warnedPrimaryDown) {
534
+ console.warn(`[TSUNAMI] primary backend failed, fallback mode: ${e?.message ?? e}`);
535
+ warnedPrimaryDown = true;
536
+ }
537
+ const fb = fallbackRaw(req, e instanceof Error ? e : new Error(String(e)));
538
+ if (fb && typeof fb === 'object' && !('__backend' in fb)) {
539
+ fb.__backend = 'fallback';
540
+ }
541
+ lastBackend = 'fallback';
542
+ setCachedRead(req, fb);
543
+ if (WRITE_CMDS.has(cmd)) clearReadCaches();
544
+ return fb;
545
+ }
546
+ })();
547
+
548
+ if (READ_ONLY_CMDS.has(cmd)) {
549
+ tsunamiInflight.set(key, task);
550
+ }
551
+
552
+ try {
553
+ return await task;
554
+ } finally {
555
+ if (READ_ONLY_CMDS.has(cmd)) {
556
+ tsunamiInflight.delete(key);
557
+ }
558
+ }
559
+ }
560
+
561
+ // ── Public API ────────────────────────────────────────
562
+
563
+ export async function tsunamiWakeUp(wing?: string): Promise<string> {
564
+ const resp = await call({ cmd: 'wakeup', wing });
565
+ if (!resp.ok) throw new Error(resp.error);
566
+ return resp.data ?? '';
567
+ }
568
+
569
+ export async function tsunamiSearch(query: string, wing?: string, room?: string, limit = 5): Promise<string> {
570
+ const resp = await call({ cmd: 'search', query, wing, room, limit });
571
+ if (!resp.ok) throw new Error(resp.error);
572
+ return resp.data ?? '';
573
+ }
574
+
575
+ export async function tsunamiRecall(wing?: string, room?: string, limit = 10): Promise<string> {
576
+ const resp = await call({ cmd: 'recall', wing, room, limit });
577
+ if (!resp.ok) throw new Error(resp.error);
578
+ return resp.data ?? '';
579
+ }
580
+
581
+ export interface TsunamiAddOptions {
582
+ skipBunHotMirror?: boolean;
583
+ sessionId?: string;
584
+ projectDir?: string;
585
+ source?: string;
586
+ fingerprint?: string;
587
+ date?: string;
588
+ }
589
+
590
+ export async function tsunamiAdd(
591
+ wing: string,
592
+ room: string,
593
+ content: string,
594
+ importance = 3,
595
+ options?: TsunamiAddOptions,
596
+ ): Promise<string> {
597
+ const resp = await call({
598
+ cmd: 'add',
599
+ wing,
600
+ room,
601
+ content,
602
+ importance,
603
+ skip_bun_hot_mirror: options?.skipBunHotMirror === true,
604
+ session_id: options?.sessionId,
605
+ project_dir: options?.projectDir,
606
+ source: options?.source,
607
+ fingerprint: options?.fingerprint,
608
+ date: options?.date,
609
+ });
610
+ if (!resp.ok) throw new Error(resp.error);
611
+ return resp.id ?? '';
612
+ }
613
+
614
+ export async function tsunamiDiary(entry: string, agent = 'ats', wing = 'ats', importance = 3, options?: { skipBunHotMirror?: boolean }): Promise<string> {
615
+ const resp = await call({ cmd: 'diary', entry, agent, wing, importance, skip_bun_hot_mirror: options?.skipBunHotMirror === true });
616
+ if (!resp.ok) throw new Error(resp.error);
617
+ return resp.id ?? '';
618
+ }
619
+
620
+ export async function tsunamiKgQuery(entity: string): Promise<string> {
621
+ const resp = await call({ cmd: 'kg_query', entity });
622
+ if (!resp.ok) throw new Error(resp.error);
623
+ if (typeof resp.data === 'string') return resp.data;
624
+ return JSON.stringify(resp.data ?? [], null, 2);
625
+ }
626
+
627
+ export async function tsunamiKgAdd(subject: string, predicate: string, object: string, validFrom?: string): Promise<void> {
628
+ const resp = await call({ cmd: 'kg_add', subject, predicate, object, valid_from: validFrom });
629
+ if (!resp.ok) throw new Error(resp.error);
630
+ }
631
+
632
+ export async function tsunamiKgAddTyped(options: {
633
+ subject: string;
634
+ subjectType?: string;
635
+ subjectProperties?: Record<string, unknown>;
636
+ predicate: string;
637
+ object: string;
638
+ objectType?: string;
639
+ objectProperties?: Record<string, unknown>;
640
+ validFrom?: string;
641
+ validTo?: string;
642
+ confidence?: number;
643
+ sourceFile?: string;
644
+ }): Promise<void> {
645
+ const resp = await call({
646
+ cmd: 'kg_add',
647
+ subject: options.subject,
648
+ subject_type: options.subjectType,
649
+ subject_properties: options.subjectProperties,
650
+ predicate: options.predicate,
651
+ object: options.object,
652
+ object_type: options.objectType,
653
+ object_properties: options.objectProperties,
654
+ valid_from: options.validFrom,
655
+ valid_to: options.validTo,
656
+ confidence: options.confidence,
657
+ source_file: options.sourceFile,
658
+ });
659
+ if (!resp.ok) throw new Error(resp.error);
660
+ }
661
+
662
+ export async function tsunamiKgStats(): Promise<any> {
663
+ const resp = await call({ cmd: 'kg_stats' });
664
+ if (!resp.ok) throw new Error(resp.error);
665
+ return resp.data;
666
+ }
667
+
668
+ export async function tsunamiKgTimeline(entity?: string, limit = 20): Promise<any> {
669
+ const resp = await call({ cmd: 'kg_timeline', entity, limit });
670
+ if (!resp.ok) throw new Error(resp.error);
671
+ return resp.data;
672
+ }
673
+
674
+ export async function tsunamiTimeline(limit = 20): Promise<any> {
675
+ const resp = await call({ cmd: 'timeline', limit });
676
+ if (!resp.ok) throw new Error(resp.error);
677
+ return resp.data;
678
+ }
679
+
680
+ export async function tsunamiStatus(): Promise<any> {
681
+ const resp = await call({ cmd: 'status' });
682
+ if (!resp.ok) throw new Error(resp.error);
683
+ if (resp.data && typeof resp.data === 'object') {
684
+ return {
685
+ ...resp.data,
686
+ runtime_backend: resp.runtime_backend ?? resp.__backend ?? lastBackend,
687
+ routing_summary: resp.data.routing_summary ?? describeTsunamiRoutingMatrix(),
688
+ python: pickPythonExecutable(),
689
+ wrapper: TSUNAMI_COMPAT_WRAPPER,
690
+ wrapper_route_mode: 'opt_in_only',
691
+ };
692
+ }
693
+ return {
694
+ value: resp.data,
695
+ runtime_backend: resp.__backend ?? lastBackend,
696
+ routing_summary: describeTsunamiRoutingMatrix(),
697
+ python: pickPythonExecutable(),
698
+ wrapper: TSUNAMI_COMPAT_WRAPPER,
699
+ wrapper_route_mode: 'opt_in_only',
700
+ };
701
+ }
702
+
703
+ // Export call for internal tool use
704
+ export { call as tsunamiRaw };
705
+
706
+ export async function tsunamiListWings(): Promise<Record<string, number>> {
707
+ const resp = await call({ cmd: 'list_wings' });
708
+ if (!resp.ok) throw new Error(resp.error);
709
+ return resp.wings ?? {};
710
+ }