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.
- package/LICENSE +21 -0
- package/README.md +501 -0
- package/README.zh-CN.md +485 -0
- package/package.json +46 -0
- package/server/api.ts +125 -0
- package/server/mcp.ts +221 -0
- package/src/bun_memory_store.ts +340 -0
- package/src/classifier_keywords.ts +115 -0
- package/src/core/project_state.ts +88 -0
- package/src/index.ts +54 -0
- package/src/legacy_compat/tsunami_compat.ts +22 -0
- package/src/legacy_compat/tsunami_legacy_identity.ts +13 -0
- package/src/legacy_compat/tsunami_legacy_taxonomy.ts +197 -0
- package/src/memory_audit.ts +32 -0
- package/src/memory_conflict_resolver.ts +14 -0
- package/src/memory_fabric.ts +31 -0
- package/src/memory_manager.ts +10 -0
- package/src/memory_promotion.ts +22 -0
- package/src/memory_recovery.ts +14 -0
- package/src/memory_runtime.ts +7 -0
- package/src/migration.ts +163 -0
- package/src/provider.ts +68 -0
- package/src/runtime/checkpoints/durable_recovery.ts +24 -0
- package/src/runtime/paths.ts +11 -0
- package/src/storm/basins.ts +57 -0
- package/src/storm/boundary.ts +52 -0
- package/src/storm/budget.ts +42 -0
- package/src/storm/center.ts +396 -0
- package/src/storm/confidence.ts +88 -0
- package/src/storm/coverage.ts +44 -0
- package/src/storm/directive.ts +94 -0
- package/src/storm/gate.ts +43 -0
- package/src/storm/helpers.ts +172 -0
- package/src/storm/horizon.ts +52 -0
- package/src/storm/intake.ts +80 -0
- package/src/storm/mode.ts +21 -0
- package/src/storm/pressure.ts +56 -0
- package/src/storm/readiness.ts +29 -0
- package/src/storm/saturation.ts +45 -0
- package/src/storm/selection.ts +49 -0
- package/src/storm/signals.ts +105 -0
- package/src/storm/types.ts +216 -0
- package/src/tsunami_bun_backend.ts +705 -0
- package/src/tsunami_chinese_dialect.ts +19 -0
- package/src/tsunami_classifier.ts +137 -0
- package/src/tsunami_client.ts +710 -0
- package/src/tsunami_execution_gate.ts +232 -0
- package/src/tsunami_graph_runtime.ts +359 -0
- package/src/tsunami_identity.ts +35 -0
- package/src/tsunami_routing.ts +169 -0
- package/src/tsunami_runtime_graph_sync.ts +17 -0
- package/src/tsunami_schema.ts +403 -0
- package/src/tsunami_storage_paths.ts +8 -0
- package/src/tsunami_storm_center.ts +53 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildTsunamiStormCenter, type TsunamiStormCenter } from './tsunami_storm_center';
|
|
4
|
+
|
|
5
|
+
function isHarnessLikeProjectDir(projectDir: string | undefined): boolean {
|
|
6
|
+
const normalized = String(projectDir ?? '').replace(/\\/g, '/').trim();
|
|
7
|
+
if (!normalized) return false;
|
|
8
|
+
return normalized.startsWith('/tmp/ats-')
|
|
9
|
+
|| normalized.startsWith('/private/tmp/ats-')
|
|
10
|
+
|| normalized.includes('/.ats/artifacts/evals/')
|
|
11
|
+
|| normalized.includes('/.ats/artifacts/test_runtime/')
|
|
12
|
+
|| normalized.includes('/.ats/artifacts/bench/')
|
|
13
|
+
|| normalized.includes('/.ats/artifacts/release_pressure/')
|
|
14
|
+
|| normalized.includes('runtime-harness');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TsunamiExecutionGate = {
|
|
18
|
+
gateVerdict: 'hold' | 'guarded' | 'proceed' | 'expand' | 'unknown';
|
|
19
|
+
budgetMode: 'frozen' | 'minimal' | 'guided' | 'open' | 'unknown';
|
|
20
|
+
budgetSteps: number;
|
|
21
|
+
selectionProfile: 'frozen' | 'tight' | 'focused' | 'broad' | 'unknown';
|
|
22
|
+
saturationLevel: 'clear' | 'near_limit' | 'saturated' | 'unknown';
|
|
23
|
+
saturationLanes: Array<'signals' | 'evidence' | 'relations'>;
|
|
24
|
+
signalLimit: number;
|
|
25
|
+
evidenceLimit: number;
|
|
26
|
+
relationLimit: number;
|
|
27
|
+
intakeMode: 'hold' | 'rebalance' | 'widen' | 'steady' | 'unknown';
|
|
28
|
+
intakeTarget: 'signals' | 'evidence' | 'relations' | 'balanced' | 'unknown';
|
|
29
|
+
nextSignalLimit: number;
|
|
30
|
+
nextEvidenceLimit: number;
|
|
31
|
+
nextRelationLimit: number;
|
|
32
|
+
allowDelegation: boolean;
|
|
33
|
+
maxDelegationParallel: number;
|
|
34
|
+
actionLabel?: string;
|
|
35
|
+
actionTarget?: string;
|
|
36
|
+
readinessLevel?: string;
|
|
37
|
+
boundaryMode?: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type TsunamiExecutionGateToolResult = {
|
|
42
|
+
blocked: boolean;
|
|
43
|
+
reason?: string;
|
|
44
|
+
args: Record<string, unknown>;
|
|
45
|
+
notes: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function deriveTsunamiLoopStepLimit(baseMaxSteps: number, gate: TsunamiExecutionGate | null): number {
|
|
49
|
+
const normalizedBase = Math.max(1, Math.floor(Number(baseMaxSteps) || 1));
|
|
50
|
+
if (!gate) return normalizedBase;
|
|
51
|
+
|
|
52
|
+
let gateSteps = Math.max(0, Math.floor(Number(gate.budgetSteps) || 0));
|
|
53
|
+
if (gate.budgetMode === 'frozen' || gate.gateVerdict === 'hold') {
|
|
54
|
+
gateSteps = 1;
|
|
55
|
+
} else if (gateSteps <= 0) {
|
|
56
|
+
if (gate.budgetMode === 'minimal') gateSteps = Math.min(3, normalizedBase);
|
|
57
|
+
else if (gate.budgetMode === 'guided') gateSteps = Math.min(12, normalizedBase);
|
|
58
|
+
else if (gate.budgetMode === 'open') gateSteps = normalizedBase;
|
|
59
|
+
else gateSteps = normalizedBase; // Unknown mode: trust base config, don't cripple
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Math.max(1, Math.min(normalizedBase, gateSteps));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clampPositive(value: unknown, fallback: number): number {
|
|
66
|
+
const num = Number(value);
|
|
67
|
+
if (!Number.isFinite(num)) return fallback;
|
|
68
|
+
return Math.max(1, Math.floor(num));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function deriveSelectionLimits(center: TsunamiStormCenter) {
|
|
72
|
+
const selection = center.stormSelection;
|
|
73
|
+
const intake = center.stormIntake;
|
|
74
|
+
let signalLimit = clampPositive(selection?.signalLimit, 3);
|
|
75
|
+
let evidenceLimit = clampPositive(selection?.evidenceLimit, 2);
|
|
76
|
+
let relationLimit = clampPositive(selection?.relationLimit, 6);
|
|
77
|
+
|
|
78
|
+
if (intake?.mode === 'widen') {
|
|
79
|
+
if (intake.target === 'signals' || intake.target === 'balanced') signalLimit = Math.max(signalLimit, clampPositive(intake.nextSignalLimit, signalLimit));
|
|
80
|
+
if (intake.target === 'evidence' || intake.target === 'balanced') evidenceLimit = Math.max(evidenceLimit, clampPositive(intake.nextEvidenceLimit, evidenceLimit));
|
|
81
|
+
if (intake.target === 'relations' || intake.target === 'balanced') relationLimit = Math.max(relationLimit, clampPositive(intake.nextRelationLimit, relationLimit));
|
|
82
|
+
} else if (intake?.mode === 'rebalance') {
|
|
83
|
+
if (intake.target === 'signals' || intake.target === 'balanced') signalLimit = clampPositive(intake.nextSignalLimit, signalLimit);
|
|
84
|
+
if (intake.target === 'evidence' || intake.target === 'balanced') evidenceLimit = clampPositive(intake.nextEvidenceLimit, evidenceLimit);
|
|
85
|
+
if (intake.target === 'relations' || intake.target === 'balanced') relationLimit = clampPositive(intake.nextRelationLimit, relationLimit);
|
|
86
|
+
} else if (intake?.mode === 'hold') {
|
|
87
|
+
signalLimit = Math.min(signalLimit, clampPositive(intake.nextSignalLimit, signalLimit));
|
|
88
|
+
evidenceLimit = Math.min(evidenceLimit, clampPositive(intake.nextEvidenceLimit, evidenceLimit));
|
|
89
|
+
relationLimit = Math.min(relationLimit, clampPositive(intake.nextRelationLimit, relationLimit));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { signalLimit, evidenceLimit, relationLimit };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function deriveMaxDelegationParallel(center: TsunamiStormCenter): number {
|
|
96
|
+
const gateVerdict = String(center.stormGate?.verdict ?? '');
|
|
97
|
+
const budgetMode = String(center.stormBudget?.mode ?? '');
|
|
98
|
+
const selectionProfile = String(center.stormSelection?.profile ?? '');
|
|
99
|
+
const intakeMode = String(center.stormIntake?.mode ?? '');
|
|
100
|
+
const saturationLevel = String(center.stormSaturation?.level ?? '');
|
|
101
|
+
|
|
102
|
+
if (gateVerdict === 'hold' || budgetMode === 'frozen') return 1;
|
|
103
|
+
if (saturationLevel === 'saturated') return 1;
|
|
104
|
+
if (budgetMode === 'minimal' || selectionProfile === 'tight' || intakeMode === 'hold') return 1;
|
|
105
|
+
if (saturationLevel === 'near_limit' && (intakeMode === 'rebalance' || selectionProfile === 'focused')) return 1;
|
|
106
|
+
if (budgetMode === 'guided' || selectionProfile === 'focused' || intakeMode === 'rebalance' || intakeMode === 'steady') return 2;
|
|
107
|
+
if (gateVerdict === 'expand' || budgetMode === 'open' || selectionProfile === 'broad' || intakeMode === 'widen') return 3;
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildTsunamiExecutionGate(input: {
|
|
112
|
+
projectDir?: string;
|
|
113
|
+
sessionId?: string;
|
|
114
|
+
query?: string;
|
|
115
|
+
}): TsunamiExecutionGate | null {
|
|
116
|
+
if (!input.projectDir?.trim()) return null;
|
|
117
|
+
if (isHarnessLikeProjectDir(input.projectDir)) return null;
|
|
118
|
+
// Skip gate for projects without TSUNAMI data
|
|
119
|
+
const hasData = existsSync(join(input.projectDir, '.tsunami')) || existsSync(join(input.projectDir, 'README.md'));
|
|
120
|
+
if (!hasData) return null;
|
|
121
|
+
const center = buildTsunamiStormCenter({
|
|
122
|
+
projectDir: input.projectDir,
|
|
123
|
+
sessionId: input.sessionId,
|
|
124
|
+
query: input.query || 'continue this project',
|
|
125
|
+
refreshGraph: false,
|
|
126
|
+
signalLimit: 3,
|
|
127
|
+
evidenceLimit: 2,
|
|
128
|
+
relationLimit: 6,
|
|
129
|
+
});
|
|
130
|
+
// Default to 'proceed'/'open' for unknown/unfamiliar projects — don't cripple execution
|
|
131
|
+
const rawVerdict = String(center.stormGate?.verdict ?? '');
|
|
132
|
+
const rawBudgetMode = String(center.stormBudget?.mode ?? '');
|
|
133
|
+
const gateVerdict: TsunamiExecutionGate['gateVerdict'] =
|
|
134
|
+
(rawVerdict === 'hold' || rawVerdict === 'guarded' || rawVerdict === 'proceed' || rawVerdict === 'expand')
|
|
135
|
+
? rawVerdict : 'proceed';
|
|
136
|
+
const budgetMode: TsunamiExecutionGate['budgetMode'] =
|
|
137
|
+
(rawBudgetMode === 'frozen' || rawBudgetMode === 'minimal' || rawBudgetMode === 'guided' || rawBudgetMode === 'open')
|
|
138
|
+
? rawBudgetMode : 'open';
|
|
139
|
+
const selectionProfile = String(center.stormSelection?.profile ?? 'unknown') as TsunamiExecutionGate['selectionProfile'];
|
|
140
|
+
const saturationLevel = String(center.stormSaturation?.level ?? 'unknown') as TsunamiExecutionGate['saturationLevel'];
|
|
141
|
+
const intakeMode = String(center.stormIntake?.mode ?? 'unknown') as TsunamiExecutionGate['intakeMode'];
|
|
142
|
+
const intakeTarget = String(center.stormIntake?.target ?? 'unknown') as TsunamiExecutionGate['intakeTarget'];
|
|
143
|
+
const limits = deriveSelectionLimits(center);
|
|
144
|
+
const allowDelegation = gateVerdict !== 'hold' && budgetMode !== 'frozen';
|
|
145
|
+
const maxDelegationParallel = deriveMaxDelegationParallel(center);
|
|
146
|
+
const reason = [
|
|
147
|
+
center.stormGate?.reason,
|
|
148
|
+
center.stormBudget?.reason,
|
|
149
|
+
center.stormSelection?.reason,
|
|
150
|
+
center.stormIntake?.reason,
|
|
151
|
+
].map((item) => String(item ?? '').trim()).find(Boolean) || 'storm gate requires a tighter execution surface';
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
gateVerdict,
|
|
155
|
+
budgetMode,
|
|
156
|
+
budgetSteps: Math.max(0, Math.floor(Number(center.stormBudget?.steps ?? 0))),
|
|
157
|
+
selectionProfile,
|
|
158
|
+
saturationLevel,
|
|
159
|
+
saturationLanes: Array.isArray(center.stormSaturation?.hitLanes)
|
|
160
|
+
? center.stormSaturation.hitLanes.filter((item): item is 'signals' | 'evidence' | 'relations' => ['signals', 'evidence', 'relations'].includes(String(item)))
|
|
161
|
+
: [],
|
|
162
|
+
signalLimit: limits.signalLimit,
|
|
163
|
+
evidenceLimit: limits.evidenceLimit,
|
|
164
|
+
relationLimit: limits.relationLimit,
|
|
165
|
+
intakeMode,
|
|
166
|
+
intakeTarget,
|
|
167
|
+
nextSignalLimit: clampPositive(center.stormIntake?.nextSignalLimit, limits.signalLimit),
|
|
168
|
+
nextEvidenceLimit: clampPositive(center.stormIntake?.nextEvidenceLimit, limits.evidenceLimit),
|
|
169
|
+
nextRelationLimit: clampPositive(center.stormIntake?.nextRelationLimit, limits.relationLimit),
|
|
170
|
+
allowDelegation,
|
|
171
|
+
maxDelegationParallel,
|
|
172
|
+
actionLabel: String(center.stormAction?.label ?? '').trim() || undefined,
|
|
173
|
+
actionTarget: String(center.stormAction?.target ?? '').trim() || undefined,
|
|
174
|
+
readinessLevel: String(center.stormReadiness?.level ?? '').trim() || undefined,
|
|
175
|
+
boundaryMode: String(center.stormBoundary?.mode ?? '').trim() || undefined,
|
|
176
|
+
reason,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function formatTsunamiExecutionGateSummary(gate: TsunamiExecutionGate | null): string {
|
|
181
|
+
if (!gate) return 'tsunami execution gate unavailable';
|
|
182
|
+
return [
|
|
183
|
+
`gate=${gate.gateVerdict}`,
|
|
184
|
+
`budget=${gate.budgetMode}:${gate.budgetSteps}`,
|
|
185
|
+
`selection=${gate.selectionProfile}:${gate.signalLimit}/${gate.evidenceLimit}/${gate.relationLimit}`,
|
|
186
|
+
`saturation=${gate.saturationLevel}:${gate.saturationLanes.join('|') || 'clear'}`,
|
|
187
|
+
`intake=${gate.intakeMode}:${gate.intakeTarget}:${gate.nextSignalLimit}/${gate.nextEvidenceLimit}/${gate.nextRelationLimit}`,
|
|
188
|
+
`delegation=${gate.allowDelegation ? `allow:${gate.maxDelegationParallel}` : 'hold'}`,
|
|
189
|
+
].join(' · ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function applyTsunamiExecutionGateToTool(
|
|
193
|
+
tool: string,
|
|
194
|
+
args: Record<string, unknown> | undefined,
|
|
195
|
+
gate: TsunamiExecutionGate | null,
|
|
196
|
+
): TsunamiExecutionGateToolResult {
|
|
197
|
+
const nextArgs = { ...(args || {}) };
|
|
198
|
+
if (!gate) return { blocked: false, args: nextArgs, notes: [] };
|
|
199
|
+
const notes: string[] = [];
|
|
200
|
+
|
|
201
|
+
if (tool === 'delegate_task' || tool === 'swarm_run') {
|
|
202
|
+
if (!gate.allowDelegation) {
|
|
203
|
+
return {
|
|
204
|
+
blocked: true,
|
|
205
|
+
reason: `TSUNAMI gate=${gate.gateVerdict} budget=${gate.budgetMode}; tighten storm surface first using ${gate.actionLabel || 'mainline repair'}, then decide whether to expand delegation.${gate.reason ? ` ${gate.reason}` : ''}`.trim(),
|
|
206
|
+
args: nextArgs,
|
|
207
|
+
notes,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const currentParallel = clampPositive(nextArgs.max_parallel, tool === 'swarm_run' ? 3 : 2);
|
|
211
|
+
const clampedParallel = Math.min(currentParallel, gate.maxDelegationParallel);
|
|
212
|
+
if (clampedParallel !== currentParallel) {
|
|
213
|
+
nextArgs.max_parallel = clampedParallel;
|
|
214
|
+
notes.push(`TSUNAMI execution gate reduced ${tool} max_parallel from ${currentParallel} to ${clampedParallel}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (tool === 'tsunami' && String(nextArgs.cmd ?? '').trim() === 'storm_center') {
|
|
219
|
+
const currentSignals = clampPositive(nextArgs.signal_limit, gate.signalLimit);
|
|
220
|
+
const currentEvidence = clampPositive(nextArgs.evidence_limit, gate.evidenceLimit);
|
|
221
|
+
const currentRelations = clampPositive(nextArgs.relation_limit, gate.relationLimit);
|
|
222
|
+
const nextSignals = Math.min(currentSignals, gate.signalLimit);
|
|
223
|
+
const nextEvidence = Math.min(currentEvidence, gate.evidenceLimit);
|
|
224
|
+
const nextRelations = Math.min(currentRelations, gate.relationLimit);
|
|
225
|
+
nextArgs.signal_limit = nextSignals;
|
|
226
|
+
nextArgs.evidence_limit = nextEvidence;
|
|
227
|
+
nextArgs.relation_limit = nextRelations;
|
|
228
|
+
notes.push(`TSUNAMI execution gate constrained storm_center to ${nextSignals}/${nextEvidence}/${nextRelations}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { blocked: false, args: nextArgs, notes };
|
|
232
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TSUNAMI Knowledge Graph Runtime — SQLite-backed triple store
|
|
3
|
+
*
|
|
4
|
+
* Adds graph tables to the TSUNAMI memory database for storing and querying
|
|
5
|
+
* subject-predicate-object triples. Supports temporal validity tracking,
|
|
6
|
+
* BFS traversal, and cross-wing tunnel discovery.
|
|
7
|
+
*/
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── ID Generation ────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function generateId(): string {
|
|
28
|
+
const ts = Date.now().toString(36);
|
|
29
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
30
|
+
return `kg_${ts}_${rand}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Types ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface GraphTriple {
|
|
36
|
+
id: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
predicate: string;
|
|
39
|
+
object: string;
|
|
40
|
+
subject_type: string | null;
|
|
41
|
+
object_type: string | null;
|
|
42
|
+
subject_properties: string;
|
|
43
|
+
object_properties: string;
|
|
44
|
+
confidence: number;
|
|
45
|
+
valid_from: string | null;
|
|
46
|
+
valid_to: string | null;
|
|
47
|
+
source_file: string | null;
|
|
48
|
+
created_at: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GraphAddTripleInput {
|
|
52
|
+
subject: string;
|
|
53
|
+
subjectType?: string;
|
|
54
|
+
subjectProperties?: Record<string, unknown>;
|
|
55
|
+
predicate: string;
|
|
56
|
+
object: string;
|
|
57
|
+
objectType?: string;
|
|
58
|
+
objectProperties?: Record<string, unknown>;
|
|
59
|
+
validFrom?: string;
|
|
60
|
+
validTo?: string;
|
|
61
|
+
confidence?: number;
|
|
62
|
+
sourceCloset?: string;
|
|
63
|
+
sourceFile?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GraphInvalidateTripleInput {
|
|
67
|
+
subject: string;
|
|
68
|
+
predicate: string;
|
|
69
|
+
object: string;
|
|
70
|
+
ended?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface GraphTraverseResult {
|
|
74
|
+
startRoom: string;
|
|
75
|
+
maxHops: number;
|
|
76
|
+
visited: string[];
|
|
77
|
+
nodes: Array<{
|
|
78
|
+
entity: string;
|
|
79
|
+
depth: number;
|
|
80
|
+
relation: string;
|
|
81
|
+
confidence: number;
|
|
82
|
+
}>;
|
|
83
|
+
error?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface GraphTunnelResult {
|
|
87
|
+
wingA: string;
|
|
88
|
+
wingB: string;
|
|
89
|
+
tunnels: Array<{
|
|
90
|
+
subject: string;
|
|
91
|
+
predicate: string;
|
|
92
|
+
object: string;
|
|
93
|
+
confidence: number;
|
|
94
|
+
}>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Public API ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export function tsunamiGraphAddTriple(input: GraphAddTripleInput): string {
|
|
100
|
+
const db = getDb();
|
|
101
|
+
const id = generateId();
|
|
102
|
+
|
|
103
|
+
const stmt = db.prepare(`
|
|
104
|
+
INSERT INTO graph_triples (id, subject, predicate, object, subject_type, object_type, subject_properties, object_properties, confidence, valid_from, valid_to, source_file)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
106
|
+
`);
|
|
107
|
+
|
|
108
|
+
stmt.run(
|
|
109
|
+
id,
|
|
110
|
+
input.subject,
|
|
111
|
+
input.predicate,
|
|
112
|
+
input.object,
|
|
113
|
+
input.subjectType || null,
|
|
114
|
+
input.objectType || null,
|
|
115
|
+
JSON.stringify(input.subjectProperties || {}),
|
|
116
|
+
JSON.stringify(input.objectProperties || {}),
|
|
117
|
+
input.confidence ?? 1.0,
|
|
118
|
+
input.validFrom || null,
|
|
119
|
+
input.validTo || null,
|
|
120
|
+
input.sourceFile || null,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function tsunamiGraphQueryEntity(
|
|
127
|
+
entity: string,
|
|
128
|
+
asOf?: string,
|
|
129
|
+
direction: 'outgoing' | 'incoming' | 'both' = 'outgoing',
|
|
130
|
+
): GraphTriple[] {
|
|
131
|
+
const db = getDb();
|
|
132
|
+
|
|
133
|
+
const conditions: string[] = [];
|
|
134
|
+
const params: any[] = [];
|
|
135
|
+
|
|
136
|
+
if (direction === 'outgoing' || direction === 'both') {
|
|
137
|
+
conditions.push('(subject = ?)');
|
|
138
|
+
params.push(entity);
|
|
139
|
+
}
|
|
140
|
+
if (direction === 'incoming' || direction === 'both') {
|
|
141
|
+
conditions.push('(object = ?)');
|
|
142
|
+
params.push(entity);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (asOf) {
|
|
146
|
+
conditions.push('(valid_from IS NULL OR valid_from <= ?)');
|
|
147
|
+
params.push(asOf);
|
|
148
|
+
conditions.push('(valid_to IS NULL OR valid_to >= ?)');
|
|
149
|
+
params.push(asOf);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' OR ')}` : '';
|
|
153
|
+
const sql = `SELECT * FROM graph_triples ${whereClause} ORDER BY confidence DESC, created_at DESC`;
|
|
154
|
+
|
|
155
|
+
const stmt = db.prepare(sql);
|
|
156
|
+
return stmt.all(...params) as GraphTriple[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function tsunamiGraphInvalidateTriple(input: GraphInvalidateTripleInput): number {
|
|
160
|
+
const db = getDb();
|
|
161
|
+
const ended = input.ended || new Date().toISOString();
|
|
162
|
+
|
|
163
|
+
const stmt = db.prepare(`
|
|
164
|
+
UPDATE graph_triples
|
|
165
|
+
SET valid_to = ?
|
|
166
|
+
WHERE subject = ? AND predicate = ? AND object = ? AND valid_to IS NULL
|
|
167
|
+
`);
|
|
168
|
+
|
|
169
|
+
const result = stmt.run(ended, input.subject, input.predicate, input.object);
|
|
170
|
+
return result.changes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function tsunamiGraphStats(): Record<string, unknown> {
|
|
174
|
+
const db = getDb();
|
|
175
|
+
|
|
176
|
+
const total = (db.prepare('SELECT COUNT(*) as count FROM graph_triples').get() as { count: number }).count;
|
|
177
|
+
const active = (db.prepare('SELECT COUNT(*) as count FROM graph_triples WHERE valid_to IS NULL').get() as { count: number }).count;
|
|
178
|
+
const subjects = (db.prepare('SELECT COUNT(DISTINCT subject) as count FROM graph_triples').get() as { count: number }).count;
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
total,
|
|
182
|
+
active,
|
|
183
|
+
subjects,
|
|
184
|
+
backend: 'bun_native',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function tsunamiGraphCompatStats(): Record<string, unknown> {
|
|
189
|
+
// Alias for graph_stats compatibility
|
|
190
|
+
return tsunamiGraphStats();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function tsunamiGraphTimeline(entity?: string, limit = 20): GraphTriple[] {
|
|
194
|
+
const db = getDb();
|
|
195
|
+
const max = Math.max(1, Math.min(200, Number(limit ?? 20)));
|
|
196
|
+
|
|
197
|
+
let sql: string;
|
|
198
|
+
let params: any[];
|
|
199
|
+
|
|
200
|
+
if (entity) {
|
|
201
|
+
sql = `
|
|
202
|
+
SELECT * FROM graph_triples
|
|
203
|
+
WHERE subject = ? OR object = ?
|
|
204
|
+
ORDER BY created_at DESC
|
|
205
|
+
LIMIT ?
|
|
206
|
+
`;
|
|
207
|
+
params = [entity, entity, max];
|
|
208
|
+
} else {
|
|
209
|
+
sql = `
|
|
210
|
+
SELECT * FROM graph_triples
|
|
211
|
+
ORDER BY created_at DESC
|
|
212
|
+
LIMIT ?
|
|
213
|
+
`;
|
|
214
|
+
params = [max];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const stmt = db.prepare(sql);
|
|
218
|
+
return stmt.all(...params) as GraphTriple[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function tsunamiGraphTraverse(startRoom: string, maxHops = 2): GraphTraverseResult {
|
|
222
|
+
const db = getDb();
|
|
223
|
+
const start = String(startRoom ?? '').trim();
|
|
224
|
+
|
|
225
|
+
if (!start) {
|
|
226
|
+
return { startRoom: '', maxHops, visited: [], nodes: [], error: 'startRoom is required' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const visited = new Set<string>();
|
|
230
|
+
const nodes: GraphTraverseResult['nodes'] = [];
|
|
231
|
+
let queue: Array<{ entity: string; depth: number }> = [{ entity: start, depth: 0 }];
|
|
232
|
+
const hops = Math.max(1, Math.min(10, Number(maxHops ?? 2)));
|
|
233
|
+
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const current = queue.shift()!;
|
|
236
|
+
if (visited.has(current.entity)) continue;
|
|
237
|
+
visited.add(current.entity);
|
|
238
|
+
|
|
239
|
+
// Find outgoing edges
|
|
240
|
+
const outgoing = db.prepare(
|
|
241
|
+
'SELECT * FROM graph_triples WHERE subject = ? AND valid_to IS NULL ORDER BY confidence DESC',
|
|
242
|
+
).all(current.entity) as GraphTriple[];
|
|
243
|
+
|
|
244
|
+
for (const triple of outgoing) {
|
|
245
|
+
if (!visited.has(triple.object)) {
|
|
246
|
+
nodes.push({
|
|
247
|
+
entity: triple.object,
|
|
248
|
+
depth: current.depth + 1,
|
|
249
|
+
relation: triple.predicate,
|
|
250
|
+
confidence: triple.confidence,
|
|
251
|
+
});
|
|
252
|
+
if (current.depth + 1 < hops) {
|
|
253
|
+
queue.push({ entity: triple.object, depth: current.depth + 1 });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Find incoming edges
|
|
259
|
+
const incoming = db.prepare(
|
|
260
|
+
'SELECT * FROM graph_triples WHERE object = ? AND valid_to IS NULL ORDER BY confidence DESC',
|
|
261
|
+
).all(current.entity) as GraphTriple[];
|
|
262
|
+
|
|
263
|
+
for (const triple of incoming) {
|
|
264
|
+
if (!visited.has(triple.subject)) {
|
|
265
|
+
nodes.push({
|
|
266
|
+
entity: triple.subject,
|
|
267
|
+
depth: current.depth + 1,
|
|
268
|
+
relation: triple.predicate,
|
|
269
|
+
confidence: triple.confidence,
|
|
270
|
+
});
|
|
271
|
+
if (current.depth + 1 < hops) {
|
|
272
|
+
queue.push({ entity: triple.subject, depth: current.depth + 1 });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
startRoom: start,
|
|
280
|
+
maxHops: hops,
|
|
281
|
+
visited: Array.from(visited),
|
|
282
|
+
nodes,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function tsunamiGraphFindTunnels(wingA?: string, wingB?: string): GraphTunnelResult[] {
|
|
287
|
+
const db = getDb();
|
|
288
|
+
|
|
289
|
+
// Find cross-wing tunnels: triples where subject and object belong to different wings
|
|
290
|
+
const results: GraphTunnelResult[] = [];
|
|
291
|
+
const limit = 20;
|
|
292
|
+
|
|
293
|
+
let sql: string;
|
|
294
|
+
let params: any[];
|
|
295
|
+
|
|
296
|
+
if (wingA && wingB) {
|
|
297
|
+
sql = `
|
|
298
|
+
SELECT * FROM graph_triples
|
|
299
|
+
WHERE ((subject LIKE ? AND object LIKE ?) OR (subject LIKE ? AND object LIKE ?))
|
|
300
|
+
AND valid_to IS NULL
|
|
301
|
+
ORDER BY confidence DESC
|
|
302
|
+
LIMIT ?
|
|
303
|
+
`;
|
|
304
|
+
params = [`${wingA}:%`, `${wingB}:%`, `${wingB}:%`, `${wingA}:%`, limit];
|
|
305
|
+
} else if (wingA) {
|
|
306
|
+
sql = `
|
|
307
|
+
SELECT * FROM graph_triples
|
|
308
|
+
WHERE (subject LIKE ? OR object LIKE ?)
|
|
309
|
+
AND valid_to IS NULL
|
|
310
|
+
ORDER BY confidence DESC
|
|
311
|
+
LIMIT ?
|
|
312
|
+
`;
|
|
313
|
+
params = [`${wingA}:%`, `${wingA}:%`, limit];
|
|
314
|
+
} else {
|
|
315
|
+
sql = `
|
|
316
|
+
SELECT * FROM graph_triples
|
|
317
|
+
WHERE valid_to IS NULL
|
|
318
|
+
AND ((subject LIKE 'task:%' AND object LIKE 'memory:%')
|
|
319
|
+
OR (subject LIKE 'decision:%' AND object LIKE 'task:%')
|
|
320
|
+
OR (subject LIKE 'ats:%' AND object LIKE 'decision:%')
|
|
321
|
+
OR (subject LIKE 'memory:%' AND object LIKE 'people:%'))
|
|
322
|
+
ORDER BY confidence DESC
|
|
323
|
+
LIMIT ?
|
|
324
|
+
`;
|
|
325
|
+
params = [limit];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const triples = db.prepare(sql).all(...params) as GraphTriple[];
|
|
329
|
+
|
|
330
|
+
// Group by wing pairs
|
|
331
|
+
const tunnelMap = new Map<string, GraphTriple[]>();
|
|
332
|
+
|
|
333
|
+
for (const triple of triples) {
|
|
334
|
+
const sWing = triple.subject.split('/')[0] || triple.subject.split(':')[0];
|
|
335
|
+
const oWing = triple.object.split('/')[0] || triple.object.split(':')[0];
|
|
336
|
+
|
|
337
|
+
if (sWing && oWing && sWing !== oWing) {
|
|
338
|
+
const key = [sWing, oWing].sort().join('--');
|
|
339
|
+
if (!tunnelMap.has(key)) tunnelMap.set(key, []);
|
|
340
|
+
tunnelMap.get(key)!.push(triple);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const [key, tunnels] of tunnelMap) {
|
|
345
|
+
const [wa, wb] = key.split('--');
|
|
346
|
+
results.push({
|
|
347
|
+
wingA: wa,
|
|
348
|
+
wingB: wb,
|
|
349
|
+
tunnels: tunnels.slice(0, 10).map((t) => ({
|
|
350
|
+
subject: t.subject,
|
|
351
|
+
predicate: t.predicate,
|
|
352
|
+
object: t.object,
|
|
353
|
+
confidence: t.confidence,
|
|
354
|
+
})),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TSUNAMI_LEGACY_IDENTITY_REPLACEMENTS,
|
|
5
|
+
hasTsunamiLegacyIdentityTerms,
|
|
6
|
+
} from './legacy_compat/tsunami_legacy_identity';
|
|
7
|
+
import { TSUNAMI_IDENTITY_FILE } from './tsunami_storage_paths';
|
|
8
|
+
|
|
9
|
+
const NO_IDENTITY = 'No identity configured.';
|
|
10
|
+
|
|
11
|
+
export function normalizeTsunamiIdentityText(raw: string): string {
|
|
12
|
+
let next = String(raw ?? '').trim();
|
|
13
|
+
if (!next) return NO_IDENTITY;
|
|
14
|
+
for (const [pattern, replacement] of TSUNAMI_LEGACY_IDENTITY_REPLACEMENTS) {
|
|
15
|
+
next = next.replace(pattern, replacement);
|
|
16
|
+
}
|
|
17
|
+
if (!/TSUNAMI/.test(next)) {
|
|
18
|
+
next = `${next}\n\nPrimary memory system: TSUNAMI (Bun-native ocean memory runtime)`;
|
|
19
|
+
}
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readTsunamiIdentity(path = TSUNAMI_IDENTITY_FILE): string {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(path)) return NO_IDENTITY;
|
|
26
|
+
return normalizeTsunamiIdentityText(readFileSync(path, 'utf8'));
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
console.warn(`[TSUNAMI] failed to read identity file at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
29
|
+
return NO_IDENTITY;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasLegacyIdentityTerms(raw: string): boolean {
|
|
34
|
+
return hasTsunamiLegacyIdentityTerms(raw);
|
|
35
|
+
}
|