tsunami-code 2.6.0 → 2.6.1
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/.tsunami/CODEBASE.md +8 -0
- package/.tsunami/memory/package.json.md +8 -0
- package/.tsunami/memory/schema.ts.md +8 -0
- package/index.js +15 -2
- package/lib/loop.js +48 -61
- package/lib/tools.js +4 -2
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getSessionContext
|
|
21
21
|
} from './lib/memory.js';
|
|
22
22
|
|
|
23
|
-
const VERSION = '2.6.
|
|
23
|
+
const VERSION = '2.6.1';
|
|
24
24
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
25
25
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
26
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -115,7 +115,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
if (argv.includes('--version') || argv.includes('-v')) {
|
|
118
|
-
console.log(`
|
|
118
|
+
console.log(`tsunami v${VERSION}`);
|
|
119
119
|
process.exit(0);
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -255,6 +255,19 @@ async function run() {
|
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
if (sub === 'last') {
|
|
259
|
+
const { getLastSessionSummary } = await import('./lib/memory.js');
|
|
260
|
+
const summary = getLastSessionSummary(cwd);
|
|
261
|
+
if (!summary) {
|
|
262
|
+
console.log(dim(' No previous session summary found.\n'));
|
|
263
|
+
} else {
|
|
264
|
+
console.log(blue('\n Last Session Summary'));
|
|
265
|
+
console.log(dim(' ' + summary.replace(/\n/g, '\n ')));
|
|
266
|
+
console.log();
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
if (sub === 'clear') {
|
|
259
272
|
// Reinitialize session (clear session memory, keep project memory)
|
|
260
273
|
resetSession();
|
package/lib/loop.js
CHANGED
|
@@ -3,11 +3,13 @@ import { ALL_TOOLS } from './tools.js';
|
|
|
3
3
|
import {
|
|
4
4
|
assembleContext,
|
|
5
5
|
extractFilePaths,
|
|
6
|
-
logFileAccess,
|
|
7
6
|
logFileChange,
|
|
8
7
|
appendDecision
|
|
9
8
|
} from './memory.js';
|
|
10
9
|
|
|
10
|
+
// Skip waitForServer after first successful connection
|
|
11
|
+
let _serverVerified = false;
|
|
12
|
+
|
|
11
13
|
// Parse tool calls from any format the model might produce
|
|
12
14
|
function parseToolCalls(content) {
|
|
13
15
|
const calls = [];
|
|
@@ -88,10 +90,6 @@ function normalizeArgs(args) {
|
|
|
88
90
|
return out;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
/**
|
|
92
|
-
* Detect key patterns in Bash commands and return a decision string, or null.
|
|
93
|
-
* We log psql, npm, pm2, git, and deploy-related commands.
|
|
94
|
-
*/
|
|
95
93
|
function bashDecisionHint(cmd) {
|
|
96
94
|
if (!cmd) return null;
|
|
97
95
|
const c = cmd.trim();
|
|
@@ -105,26 +103,31 @@ function bashDecisionHint(cmd) {
|
|
|
105
103
|
return null;
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
// Only log decisions that are actually meaningful
|
|
107
|
+
function meaningfulDecision(toolName, args) {
|
|
108
|
+
if (toolName === 'Note') return `NOTE: ${(args.note || '').slice(0, 120)}`;
|
|
109
|
+
if (toolName === 'Checkpoint') return `CHECKPOINT: ${(args.content || '').split('\n')[0].slice(0, 120)}`;
|
|
110
|
+
if (toolName === 'Write') return `WROTE: ${args.file_path} (${(args.content || '').split('\n').length} lines)`;
|
|
111
|
+
if (toolName === 'Edit') return `EDITED: ${args.file_path} — replaced "${(args.old_string || '').slice(0, 60).replace(/\n/g, '↵')}"`;
|
|
112
|
+
if (toolName === 'Bash') return bashDecisionHint(args.command);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runTool(name, args, sessionInfo, sessionFiles) {
|
|
109
117
|
const tool = ALL_TOOLS.find(t => t.name === name);
|
|
110
118
|
if (!tool) return `Error: Unknown tool "${name}"`;
|
|
111
119
|
try {
|
|
112
120
|
const parsed = typeof args === 'string' ? JSON.parse(args) : args;
|
|
113
121
|
const normalized = normalizeArgs(parsed);
|
|
114
122
|
|
|
115
|
-
// Auto-capture memory BEFORE running the tool (for access/change logging)
|
|
116
|
-
if (sessionInfo) {
|
|
117
|
-
const { sessionDir, cwd } = sessionInfo;
|
|
118
|
-
try {
|
|
119
|
-
if (name === 'Read' && normalized.file_path) {
|
|
120
|
-
logFileAccess(cwd, normalized.file_path);
|
|
121
|
-
}
|
|
122
|
-
} catch {}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
123
|
const result = await tool.run(normalized);
|
|
126
124
|
|
|
127
|
-
// Auto-capture
|
|
125
|
+
// Auto-capture: track files touched for context assembly
|
|
126
|
+
if (sessionFiles && normalized.file_path) {
|
|
127
|
+
sessionFiles.add(normalized.file_path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Auto-capture: write/edit → log to project memory
|
|
128
131
|
if (sessionInfo) {
|
|
129
132
|
const { sessionDir, cwd } = sessionInfo;
|
|
130
133
|
try {
|
|
@@ -134,10 +137,10 @@ async function runTool(name, args, sessionInfo) {
|
|
|
134
137
|
} else if (name === 'Edit' && normalized.file_path) {
|
|
135
138
|
const preview = (normalized.old_string || '').slice(0, 60).replace(/\n/g, '↵');
|
|
136
139
|
logFileChange(cwd, normalized.file_path, `Edited: replaced "${preview}"`);
|
|
137
|
-
} else if (name === 'Bash') {
|
|
138
|
-
const hint = bashDecisionHint(normalized.command);
|
|
139
|
-
if (hint) appendDecision(sessionDir, hint);
|
|
140
140
|
}
|
|
141
|
+
// Log only meaningful decisions — not every Read/Glob/Grep
|
|
142
|
+
const decision = meaningfulDecision(name, normalized);
|
|
143
|
+
if (decision) appendDecision(sessionDir, decision);
|
|
141
144
|
} catch {}
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -147,24 +150,20 @@ async function runTool(name, args, sessionInfo) {
|
|
|
147
150
|
}
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
async function waitForServer(serverUrl, retries =
|
|
153
|
+
async function waitForServer(serverUrl, retries = 5, delay = 1000) {
|
|
151
154
|
for (let i = 0; i < retries; i++) {
|
|
152
155
|
try {
|
|
153
156
|
const r = await fetch(`${serverUrl}/health`);
|
|
154
157
|
const j = await r.json();
|
|
155
|
-
if (j.status === 'ok') return;
|
|
158
|
+
if (j.status === 'ok') { _serverVerified = true; return; }
|
|
156
159
|
} catch {}
|
|
157
160
|
await new Promise(r => setTimeout(r, delay));
|
|
158
161
|
}
|
|
159
162
|
throw new Error(`Model server not responding at ${serverUrl}`);
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
/**
|
|
163
|
-
* Stream a completion from the model server.
|
|
164
|
-
* Injects memoryContext into the system message if provided.
|
|
165
|
-
*/
|
|
166
165
|
async function streamCompletion(serverUrl, messages, onToken, memoryContext = '') {
|
|
167
|
-
await waitForServer(serverUrl
|
|
166
|
+
if (!_serverVerified) await waitForServer(serverUrl);
|
|
168
167
|
|
|
169
168
|
// Inject memory context into the system message (first message with role=system)
|
|
170
169
|
let finalMessages = messages;
|
|
@@ -228,54 +227,40 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
228
227
|
* @param {number} maxIterations
|
|
229
228
|
*/
|
|
230
229
|
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, maxIterations = 15) {
|
|
231
|
-
// Extract the current task from the last user message for context assembly
|
|
232
230
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
233
231
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
234
232
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
let memoryContext = '';
|
|
238
|
-
if (sessionInfo) {
|
|
239
|
-
try {
|
|
240
|
-
const filesToConsider = extractFilePaths(currentTask);
|
|
241
|
-
memoryContext = assembleContext({
|
|
242
|
-
sessionDir: sessionInfo.sessionDir,
|
|
243
|
-
cwd: sessionInfo.cwd,
|
|
244
|
-
currentTask,
|
|
245
|
-
filesToConsider
|
|
246
|
-
});
|
|
247
|
-
} catch {}
|
|
248
|
-
}
|
|
233
|
+
// Files touched during this turn — fed back into context assembly each iteration
|
|
234
|
+
const sessionFiles = new Set(extractFilePaths(currentTask));
|
|
249
235
|
|
|
236
|
+
// Assemble memory context once before the turn, refresh after files are touched
|
|
237
|
+
const buildMemoryContext = () => {
|
|
238
|
+
if (!sessionInfo) return '';
|
|
239
|
+
try {
|
|
240
|
+
return assembleContext({
|
|
241
|
+
sessionDir: sessionInfo.sessionDir,
|
|
242
|
+
cwd: sessionInfo.cwd,
|
|
243
|
+
currentTask,
|
|
244
|
+
filesToConsider: [...sessionFiles]
|
|
245
|
+
});
|
|
246
|
+
} catch { return ''; }
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
let memoryContext = buildMemoryContext();
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
250
252
|
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
251
253
|
const toolCalls = parseToolCalls(content);
|
|
252
254
|
|
|
253
255
|
messages.push({ role: 'assistant', content });
|
|
254
256
|
|
|
255
|
-
if (toolCalls.length === 0)
|
|
256
|
-
// Log final assistant response as a decision if it's substantive
|
|
257
|
-
if (sessionInfo && content.length > 50) {
|
|
258
|
-
try {
|
|
259
|
-
const summary = content.slice(0, 150).replace(/\n/g, ' ');
|
|
260
|
-
appendDecision(sessionInfo.sessionDir, `RESPONSE: ${summary}`);
|
|
261
|
-
} catch {}
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
257
|
+
if (toolCalls.length === 0) break;
|
|
265
258
|
|
|
266
259
|
const results = [];
|
|
267
260
|
for (const tc of toolCalls) {
|
|
268
261
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
269
|
-
const result = await runTool(tc.name, tc.arguments, sessionInfo);
|
|
262
|
+
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
270
263
|
results.push(`[${tc.name} result]\n${String(result).slice(0, 8000)}`);
|
|
271
|
-
|
|
272
|
-
// Log tool execution as a decision entry
|
|
273
|
-
if (sessionInfo) {
|
|
274
|
-
try {
|
|
275
|
-
const argsPreview = JSON.stringify(tc.arguments || {}).slice(0, 80);
|
|
276
|
-
appendDecision(sessionInfo.sessionDir, `TOOL ${tc.name}: ${argsPreview}`);
|
|
277
|
-
} catch {}
|
|
278
|
-
}
|
|
279
264
|
}
|
|
280
265
|
|
|
281
266
|
messages.push({
|
|
@@ -283,6 +268,8 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
283
268
|
content: results.join('\n\n---\n\n') + '\n\nContinue with the task.'
|
|
284
269
|
});
|
|
285
270
|
|
|
271
|
+
// Refresh memory context now that new files may have been touched
|
|
272
|
+
memoryContext = buildMemoryContext();
|
|
286
273
|
onToken('\n');
|
|
287
274
|
}
|
|
288
275
|
}
|
package/lib/tools.js
CHANGED
|
@@ -249,7 +249,9 @@ Set file_path to null for project-wide notes (written to CODEBASE.md).`,
|
|
|
249
249
|
async run({ file_path, note }) {
|
|
250
250
|
try {
|
|
251
251
|
if (!_cwd) return 'Note saved (no project memory initialized yet)';
|
|
252
|
-
|
|
252
|
+
// Normalize: model sometimes passes "null" or "undefined" as a string
|
|
253
|
+
const fp = (!file_path || file_path === 'null' || file_path === 'undefined') ? null : file_path;
|
|
254
|
+
addFileNote(_cwd, fp, note);
|
|
253
255
|
return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
|
|
254
256
|
} catch (e) {
|
|
255
257
|
return `Note recorded (memory write failed silently: ${e.message})`;
|
|
@@ -277,7 +279,7 @@ The content should be a clear summary of:
|
|
|
277
279
|
4. Any blockers or important context
|
|
278
280
|
|
|
279
281
|
EXAMPLE:
|
|
280
|
-
Checkpoint({ content: "Task:
|
|
282
|
+
Checkpoint({ content: "Task: [what the user asked for]\n\nDone:\n- [steps completed so far]\n\nNext: [exact next step]\n\nContext: [any gotchas or decisions made that affect what comes next]" })`,
|
|
281
283
|
input_schema: {
|
|
282
284
|
type: 'object',
|
|
283
285
|
properties: {
|