tsunami-code 2.9.0 → 3.1.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/index.js +114 -8
- package/lib/loop.js +67 -2
- package/lib/prompt.js +4 -0
- package/lib/skills.js +103 -0
- package/lib/tools.js +196 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -4,7 +4,9 @@ import chalk from 'chalk';
|
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
-
import { agentLoop, quickCompletion, setModel, getModel } from './lib/loop.js';
|
|
7
|
+
import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
|
|
8
|
+
import { injectServerContext } from './lib/tools.js';
|
|
9
|
+
import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
|
|
8
10
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
9
11
|
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
10
12
|
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
@@ -20,7 +22,7 @@ import {
|
|
|
20
22
|
getSessionContext
|
|
21
23
|
} from './lib/memory.js';
|
|
22
24
|
|
|
23
|
-
const VERSION = '
|
|
25
|
+
const VERSION = '3.1.0';
|
|
24
26
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
25
27
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
26
28
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -172,8 +174,34 @@ async function run() {
|
|
|
172
174
|
initProjectMemory(cwd);
|
|
173
175
|
setSession({ sessionDir, cwd });
|
|
174
176
|
|
|
177
|
+
// Load skills
|
|
178
|
+
let skills = loadSkills(cwd);
|
|
179
|
+
|
|
180
|
+
// Inject server context into AgentTool
|
|
181
|
+
injectServerContext(serverUrl, buildSystemPrompt());
|
|
182
|
+
|
|
175
183
|
printBanner(serverUrl);
|
|
176
184
|
|
|
185
|
+
// Persistent history — load previous commands
|
|
186
|
+
const HISTORY_FILE = join(CONFIG_DIR, 'history.jsonl');
|
|
187
|
+
function appendHistory(line) {
|
|
188
|
+
try {
|
|
189
|
+
const entry = JSON.stringify({ cmd: line, ts: Date.now(), cwd }) + '\n';
|
|
190
|
+
import('fs').then(({ appendFileSync }) => { try { appendFileSync(HISTORY_FILE, entry, 'utf8'); } catch {} });
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
function loadHistory() {
|
|
194
|
+
try {
|
|
195
|
+
if (!existsSync(HISTORY_FILE)) return [];
|
|
196
|
+
return readFileSync(HISTORY_FILE, 'utf8')
|
|
197
|
+
.trim().split('\n').filter(Boolean)
|
|
198
|
+
.map(l => { try { return JSON.parse(l).cmd; } catch { return null; } })
|
|
199
|
+
.filter(Boolean).reverse().slice(0, 200);
|
|
200
|
+
} catch { return []; }
|
|
201
|
+
}
|
|
202
|
+
const historyEntries = loadHistory();
|
|
203
|
+
let historyIdx = -1;
|
|
204
|
+
|
|
177
205
|
// Preflight checks
|
|
178
206
|
process.stdout.write(dim(' Checking server connection...'));
|
|
179
207
|
const { errors, warnings } = await runPreflight(serverUrl);
|
|
@@ -356,15 +384,57 @@ async function run() {
|
|
|
356
384
|
console.log(red(` Unknown memory subcommand: ${sub}\n Try: /memory, /memory files, /memory view <file>, /memory clear\n`));
|
|
357
385
|
}
|
|
358
386
|
|
|
387
|
+
// Frustration detection — from leaked userPromptKeywords.ts pattern
|
|
388
|
+
const FRUSTRATION_PATTERNS = [
|
|
389
|
+
/\b(wtf|fuck|shit|damn|idiot|stupid|useless|broken|wrong|garbage|trash|terrible|awful|hate)\b/i,
|
|
390
|
+
/\b(not working|still broken|still wrong|same error|again|ugh|argh)\b/i,
|
|
391
|
+
/!{2,}/,
|
|
392
|
+
/\b(why (would|did|is|are|does|do) you|you (keep|always|never|can't|cannot|won't|don't))\b/i,
|
|
393
|
+
/\b(i (said|told|asked)|stop|listen|pay attention)\b/i
|
|
394
|
+
];
|
|
395
|
+
function detectFrustration(text) {
|
|
396
|
+
return FRUSTRATION_PATTERNS.some(p => p.test(text));
|
|
397
|
+
}
|
|
398
|
+
|
|
359
399
|
rl.on('line', async (input) => {
|
|
360
400
|
const line = input.trim();
|
|
361
401
|
if (!line) { rl.prompt(); return; }
|
|
362
402
|
|
|
403
|
+
// Append to persistent history
|
|
404
|
+
if (!line.startsWith('/')) {
|
|
405
|
+
appendHistory(line);
|
|
406
|
+
historyIdx = -1;
|
|
407
|
+
}
|
|
408
|
+
|
|
363
409
|
if (line.startsWith('/')) {
|
|
364
410
|
const parts = line.slice(1).split(' ');
|
|
365
411
|
const cmd = parts[0].toLowerCase();
|
|
366
412
|
const rest = parts.slice(1);
|
|
367
413
|
|
|
414
|
+
// Skills: check if this is a skill command before built-ins
|
|
415
|
+
const skillMatch = getSkillCommand(skills, line);
|
|
416
|
+
if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills'].includes(cmd)) {
|
|
417
|
+
// Run the skill prompt as a user message
|
|
418
|
+
const userContent = skillMatch.args
|
|
419
|
+
? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
|
|
420
|
+
: skillMatch.prompt;
|
|
421
|
+
messages.push({ role: 'user', content: userContent });
|
|
422
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
423
|
+
rl.pause(); isProcessing = true;
|
|
424
|
+
process.stdout.write('\n' + dim(` ◈ Running skill: ${skillMatch.skill.name}\n\n`));
|
|
425
|
+
let firstToken = true;
|
|
426
|
+
try {
|
|
427
|
+
await agentLoop(currentServerUrl, fullMessages, (token) => {
|
|
428
|
+
if (firstToken) { process.stdout.write(' '); firstToken = false; }
|
|
429
|
+
process.stdout.write(token);
|
|
430
|
+
}, (name, args) => { printToolCall(name, args); firstToken = true; },
|
|
431
|
+
{ sessionDir, cwd, planMode }, makeConfirmCallback(rl));
|
|
432
|
+
process.stdout.write('\n\n');
|
|
433
|
+
} catch(e) { console.error(red(` Error: ${e.message}\n`)); }
|
|
434
|
+
isProcessing = false; rl.resume(); rl.prompt();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
368
438
|
switch (cmd) {
|
|
369
439
|
case 'help':
|
|
370
440
|
console.log(blue('\n Tsunami Code CLI — Commands\n'));
|
|
@@ -384,6 +454,7 @@ async function run() {
|
|
|
384
454
|
['/status', 'Show context size and server'],
|
|
385
455
|
['/server <url>', 'Change model server URL'],
|
|
386
456
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
457
|
+
['/history', 'Show recent command history'],
|
|
387
458
|
['/exit', 'Exit'],
|
|
388
459
|
];
|
|
389
460
|
for (const [c, desc] of cmds) {
|
|
@@ -430,13 +501,22 @@ async function run() {
|
|
|
430
501
|
console.log(dim(` CWD : ${cwd}\n`));
|
|
431
502
|
break;
|
|
432
503
|
}
|
|
433
|
-
case 'cost':
|
|
434
|
-
|
|
435
|
-
console.log(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
504
|
+
case 'cost': {
|
|
505
|
+
const hasReal = tokenStats.requests > 0;
|
|
506
|
+
console.log(blue('\n Session Token Usage'));
|
|
507
|
+
if (hasReal) {
|
|
508
|
+
console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
|
|
509
|
+
console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
|
|
510
|
+
console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
|
|
511
|
+
console.log(dim(` Requests: ${tokenStats.requests}`));
|
|
512
|
+
} else {
|
|
513
|
+
console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
|
|
514
|
+
console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
|
|
515
|
+
console.log(dim(` Total : ~${(_inputTokens + _outputTokens).toLocaleString()} (estimated)`));
|
|
516
|
+
}
|
|
517
|
+
console.log();
|
|
439
518
|
break;
|
|
519
|
+
}
|
|
440
520
|
case 'clear':
|
|
441
521
|
resetSession();
|
|
442
522
|
console.log(green(' Session cleared.\n'));
|
|
@@ -463,6 +543,27 @@ async function run() {
|
|
|
463
543
|
case 'memory':
|
|
464
544
|
await handleMemoryCommand(rest);
|
|
465
545
|
break;
|
|
546
|
+
case 'skills':
|
|
547
|
+
case 'skill-list':
|
|
548
|
+
console.log(dim('\n' + listSkills(skills) + '\n'));
|
|
549
|
+
break;
|
|
550
|
+
case 'skill-create': {
|
|
551
|
+
const [skillName, ...skillBody] = rest;
|
|
552
|
+
if (!skillName) { console.log(red(' Usage: /skill-create <name> <prompt...>\n')); break; }
|
|
553
|
+
const body = skillBody.join(' ') || 'Do the following: {{ARGS}}';
|
|
554
|
+
const { slug } = createSkill(cwd, skillName, body, 'project');
|
|
555
|
+
skills = loadSkills(cwd);
|
|
556
|
+
console.log(green(` Skill created: /${slug}\n`));
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 'history': {
|
|
560
|
+
const recent = historyEntries.slice(0, 20);
|
|
561
|
+
if (recent.length === 0) { console.log(dim(' No history yet.\n')); break; }
|
|
562
|
+
console.log(blue(`\n Recent commands (${recent.length}):`));
|
|
563
|
+
recent.forEach((h, i) => console.log(dim(` ${String(i + 1).padStart(2)} ${h.slice(0, 100)}`)));
|
|
564
|
+
console.log();
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
466
567
|
case 'exit': case 'quit':
|
|
467
568
|
gracefulExit(0);
|
|
468
569
|
return;
|
|
@@ -479,6 +580,11 @@ async function run() {
|
|
|
479
580
|
userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
|
|
480
581
|
}
|
|
481
582
|
|
|
583
|
+
// Frustration injection — tell the model to acknowledge and course-correct
|
|
584
|
+
if (detectFrustration(line)) {
|
|
585
|
+
userContent += '\n\n[system: User appears frustrated. Acknowledge the issue directly, do not repeat the same approach. Be concise and action-focused.]';
|
|
586
|
+
}
|
|
587
|
+
|
|
482
588
|
const fullMessages = [
|
|
483
589
|
{ role: 'system', content: systemPrompt },
|
|
484
590
|
...messages,
|
package/lib/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fetch from 'node-fetch';
|
|
2
|
-
import { ALL_TOOLS } from './tools.js';
|
|
2
|
+
import { ALL_TOOLS, injectAgentLoop, injectServerContext } from './tools.js';
|
|
3
3
|
import {
|
|
4
4
|
assembleContext,
|
|
5
5
|
extractFilePaths,
|
|
@@ -29,6 +29,9 @@ function isDangerous(cmd) {
|
|
|
29
29
|
// Skip waitForServer after first successful connection
|
|
30
30
|
let _serverVerified = false;
|
|
31
31
|
|
|
32
|
+
// Real token tracking from API responses
|
|
33
|
+
export const tokenStats = { input: 0, output: 0, requests: 0 };
|
|
34
|
+
|
|
32
35
|
// Current model identifier — changeable at runtime via /model command
|
|
33
36
|
let _currentModel = 'local';
|
|
34
37
|
export function setModel(model) { _currentModel = model; }
|
|
@@ -234,6 +237,16 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
234
237
|
fullContent += token;
|
|
235
238
|
onToken(token);
|
|
236
239
|
}
|
|
240
|
+
// Capture real token counts + finish reason
|
|
241
|
+
if (parsed.usage) {
|
|
242
|
+
tokenStats.input += parsed.usage.prompt_tokens || 0;
|
|
243
|
+
tokenStats.output += parsed.usage.completion_tokens || 0;
|
|
244
|
+
tokenStats.requests++;
|
|
245
|
+
tokenStats.lastPromptTokens = parsed.usage.prompt_tokens || 0;
|
|
246
|
+
}
|
|
247
|
+
if (parsed.choices?.[0]?.finish_reason) {
|
|
248
|
+
tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
|
|
249
|
+
}
|
|
237
250
|
}
|
|
238
251
|
}
|
|
239
252
|
|
|
@@ -278,6 +291,12 @@ export async function quickCompletion(serverUrl, systemPrompt, userMessage) {
|
|
|
278
291
|
}
|
|
279
292
|
}
|
|
280
293
|
|
|
294
|
+
// Self-register into tools.js so AgentTool can call back into us
|
|
295
|
+
// (done here at module load time to avoid circular import at parse time)
|
|
296
|
+
import('./tools.js').then(m => {
|
|
297
|
+
m.injectAgentLoop(agentLoop);
|
|
298
|
+
}).catch(() => {});
|
|
299
|
+
|
|
281
300
|
export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessionInfo = null, confirmCallback = null, maxIterations = 15) {
|
|
282
301
|
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
283
302
|
const currentTask = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
@@ -300,12 +319,39 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
300
319
|
|
|
301
320
|
let memoryContext = buildMemoryContext();
|
|
302
321
|
|
|
322
|
+
// Max tokens recovery — mirrors Claude Code's MAX_OUTPUT_TOKENS_RECOVERY_LIMIT=3
|
|
323
|
+
let maxTokensRecoveryCount = 0;
|
|
324
|
+
const MAX_TOKENS_RECOVERY = 3;
|
|
325
|
+
|
|
326
|
+
// Token-aware auto-compact — compact at 80% of context window (4096 default)
|
|
327
|
+
const CONTEXT_WINDOW = 4096;
|
|
328
|
+
const COMPACT_THRESHOLD = Math.floor(CONTEXT_WINDOW * 0.80);
|
|
329
|
+
|
|
303
330
|
for (let i = 0; i < maxIterations; i++) {
|
|
331
|
+
// Pre-flight: if last prompt consumed >80% of context, auto-compact before next call
|
|
332
|
+
if (tokenStats.lastPromptTokens > COMPACT_THRESHOLD && messages.length > 4) {
|
|
333
|
+
onToken('\n[auto-compact: context at ' + Math.round(tokenStats.lastPromptTokens / CONTEXT_WINDOW * 100) + '%]\n');
|
|
334
|
+
// Keep system + last 4 messages only
|
|
335
|
+
const sys = messages[0];
|
|
336
|
+
messages.length = 0;
|
|
337
|
+
messages.push(sys, ...messages.slice(-4));
|
|
338
|
+
tokenStats.lastPromptTokens = 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
304
341
|
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
305
342
|
const toolCalls = parseToolCalls(content);
|
|
306
343
|
|
|
307
344
|
messages.push({ role: 'assistant', content });
|
|
308
345
|
|
|
346
|
+
// Max tokens recovery: if model hit length limit and produced no tool calls, continue
|
|
347
|
+
if (toolCalls.length === 0 && tokenStats.lastFinishReason === 'length' && maxTokensRecoveryCount < MAX_TOKENS_RECOVERY) {
|
|
348
|
+
maxTokensRecoveryCount++;
|
|
349
|
+
tokenStats.lastFinishReason = null;
|
|
350
|
+
messages.push({ role: 'user', content: 'Continue from exactly where you left off. Do not repeat anything.' });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
maxTokensRecoveryCount = 0;
|
|
354
|
+
|
|
309
355
|
if (toolCalls.length === 0) break;
|
|
310
356
|
|
|
311
357
|
const results = [];
|
|
@@ -347,7 +393,26 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
347
393
|
|
|
348
394
|
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
349
395
|
const result = await runTool(tc.name, tc.arguments, sessionInfo, sessionFiles);
|
|
350
|
-
|
|
396
|
+
const resultStr = String(result);
|
|
397
|
+
|
|
398
|
+
// Handle Snip signal — surgically remove message indices from context
|
|
399
|
+
if (tc.name === 'Snip') {
|
|
400
|
+
try {
|
|
401
|
+
const sig = JSON.parse(resultStr);
|
|
402
|
+
if (sig.__snip__ && Array.isArray(sig.indices)) {
|
|
403
|
+
const toRemove = new Set(sig.indices.map(Number));
|
|
404
|
+
// messages[0] is system prompt, indices are 1-based user/assistant turns
|
|
405
|
+
const kept = messages.filter((_, i) => i === 0 || !toRemove.has(i - 1));
|
|
406
|
+
const removed = messages.length - kept.length;
|
|
407
|
+
messages.length = 0;
|
|
408
|
+
kept.forEach(m => messages.push(m));
|
|
409
|
+
results.push(`[Snip result]\nRemoved ${removed} messages from context. Reason: ${sig.reason}`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
results.push(`[${tc.name} result]\n${resultStr.slice(0, 8000)}`);
|
|
351
416
|
}
|
|
352
417
|
|
|
353
418
|
messages.push({
|
package/lib/prompt.js
CHANGED
|
@@ -67,8 +67,12 @@ Available tools: Bash, Read, Write, Edit, Glob, Grep, Note, Checkpoint. Use them
|
|
|
67
67
|
- **Note**: Save a permanent discovery to project memory (.tsunami/). Use liberally for traps, patterns, architectural decisions.
|
|
68
68
|
- **Checkpoint**: Save current task progress to session memory so work is resumable if the session ends.
|
|
69
69
|
- **WebFetch**: Fetch any URL and get the page content as text. Use for docs, GitHub files, APIs.
|
|
70
|
+
- **WebSearch**: Search the web via DuckDuckGo. Returns titles, URLs, snippets. Follow up with WebFetch.
|
|
70
71
|
- **TodoWrite**: Manage a persistent task list (add/complete/delete/list). Use for any multi-step task.
|
|
71
72
|
- **AskUser**: Ask the user a clarifying question when genuinely blocked. Use sparingly.
|
|
73
|
+
- **Agent**: Spawn a sub-agent to handle an independent task. Call multiple times in one response for parallel execution.
|
|
74
|
+
- **Snip**: Surgically remove specific messages from context to free space without losing everything.
|
|
75
|
+
- **Brief**: Write a working-memory note to yourself. Injected into next turn — ensures nothing is forgotten on long tasks.
|
|
72
76
|
</tools>
|
|
73
77
|
|
|
74
78
|
<reasoning_protocol>
|
package/lib/skills.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills system — user-defined slash commands stored as markdown files.
|
|
3
|
+
* Mirrors Claude Code's SkillTool / commands system.
|
|
4
|
+
*
|
|
5
|
+
* Skills live in:
|
|
6
|
+
* ~/.tsunami-code/skills/*.md (global)
|
|
7
|
+
* <project>/.tsunami/skills/*.md (project-local, higher priority)
|
|
8
|
+
*
|
|
9
|
+
* File format:
|
|
10
|
+
* # Skill Name
|
|
11
|
+
* Optional description line
|
|
12
|
+
*
|
|
13
|
+
* Prompt content here. Can reference {{ARGS}} for user-supplied arguments.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
17
|
+
import { join, basename } from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
|
|
20
|
+
const GLOBAL_SKILLS_DIR = join(os.homedir(), '.tsunami-code', 'skills');
|
|
21
|
+
|
|
22
|
+
function ensureSkillsDir(dir) {
|
|
23
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slugify(name) {
|
|
27
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSkillFile(filePath, content) {
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
const titleLine = lines.find(l => l.startsWith('# '));
|
|
33
|
+
const name = titleLine ? titleLine.slice(2).trim() : basename(filePath, '.md');
|
|
34
|
+
const slug = slugify(name);
|
|
35
|
+
|
|
36
|
+
// Description: first non-empty line after the title that starts with lowercase
|
|
37
|
+
const titleIdx = lines.indexOf(titleLine);
|
|
38
|
+
const descLine = lines.slice(titleIdx + 1).find(l => l.trim() && !l.startsWith('#'));
|
|
39
|
+
const description = descLine?.trim() || `Run ${name} skill`;
|
|
40
|
+
|
|
41
|
+
// Prompt: everything after the first blank line after title
|
|
42
|
+
const bodyStart = lines.findIndex((l, i) => i > titleIdx && l.trim() === '') + 1;
|
|
43
|
+
const prompt = bodyStart > 0 ? lines.slice(bodyStart).join('\n').trim() : content;
|
|
44
|
+
|
|
45
|
+
return { name, slug, description, prompt, filePath };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadSkills(cwd) {
|
|
49
|
+
const skills = new Map(); // slug → skill
|
|
50
|
+
|
|
51
|
+
// Load global skills first
|
|
52
|
+
ensureSkillsDir(GLOBAL_SKILLS_DIR);
|
|
53
|
+
for (const dir of [GLOBAL_SKILLS_DIR, join(cwd, '.tsunami', 'skills')]) {
|
|
54
|
+
if (!existsSync(dir)) continue;
|
|
55
|
+
try {
|
|
56
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
57
|
+
const filePath = join(dir, file);
|
|
58
|
+
const content = readFileSync(filePath, 'utf8');
|
|
59
|
+
const skill = parseSkillFile(filePath, content);
|
|
60
|
+
skills.set(skill.slug, skill); // project skills override global
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return skills;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getSkillCommand(skills, input) {
|
|
69
|
+
// Match /skill-slug [args...]
|
|
70
|
+
const parts = input.slice(1).split(' ');
|
|
71
|
+
const slug = parts[0].toLowerCase();
|
|
72
|
+
const args = parts.slice(1).join(' ');
|
|
73
|
+
|
|
74
|
+
const skill = skills.get(slug);
|
|
75
|
+
if (!skill) return null;
|
|
76
|
+
|
|
77
|
+
const prompt = args
|
|
78
|
+
? skill.prompt.replace(/\{\{ARGS\}\}/g, args).replace(/\{\{args\}\}/g, args)
|
|
79
|
+
: skill.prompt;
|
|
80
|
+
|
|
81
|
+
return { skill, prompt, args };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createSkill(cwd, name, content, scope = 'project') {
|
|
85
|
+
const dir = scope === 'global'
|
|
86
|
+
? GLOBAL_SKILLS_DIR
|
|
87
|
+
: join(cwd, '.tsunami', 'skills');
|
|
88
|
+
ensureSkillsDir(dir);
|
|
89
|
+
const slug = slugify(name);
|
|
90
|
+
const filePath = join(dir, `${slug}.md`);
|
|
91
|
+
const fileContent = `# ${name}\n\n${content}\n`;
|
|
92
|
+
writeFileSync(filePath, fileContent, 'utf8');
|
|
93
|
+
return { slug, filePath };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listSkills(skills) {
|
|
97
|
+
if (skills.size === 0) return 'No skills defined. Create one with /skill-create <name>.';
|
|
98
|
+
const lines = ['Available skills:'];
|
|
99
|
+
for (const s of skills.values()) {
|
|
100
|
+
lines.push(` /${s.slug.padEnd(20)} ${s.description}`);
|
|
101
|
+
}
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -453,4 +453,199 @@ Do NOT use for:
|
|
|
453
453
|
}
|
|
454
454
|
};
|
|
455
455
|
|
|
456
|
-
|
|
456
|
+
// ── WEB SEARCH ────────────────────────────────────────────────────────────────
|
|
457
|
+
export const WebSearchTool = {
|
|
458
|
+
name: 'WebSearch',
|
|
459
|
+
description: `Search the web and return results. Use when you need current information, documentation, error solutions, or anything not in the codebase.
|
|
460
|
+
|
|
461
|
+
Returns titles, URLs, and snippets for the top results. Follow up with WebFetch on a specific result to get the full content.`,
|
|
462
|
+
input_schema: {
|
|
463
|
+
type: 'object',
|
|
464
|
+
properties: {
|
|
465
|
+
query: { type: 'string', description: 'Search query' },
|
|
466
|
+
num_results: { type: 'number', description: 'Number of results to return (default 8, max 20)' }
|
|
467
|
+
},
|
|
468
|
+
required: ['query']
|
|
469
|
+
},
|
|
470
|
+
async run({ query, num_results = 8 }) {
|
|
471
|
+
try {
|
|
472
|
+
const n = Math.min(num_results, 20);
|
|
473
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
476
|
+
const res = await fetch(url, {
|
|
477
|
+
signal: controller.signal,
|
|
478
|
+
headers: {
|
|
479
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
|
480
|
+
'Accept': 'text/html'
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
if (!res.ok) return `Error: HTTP ${res.status}`;
|
|
485
|
+
const html = await res.text();
|
|
486
|
+
|
|
487
|
+
// Parse results from DuckDuckGo HTML
|
|
488
|
+
const results = [];
|
|
489
|
+
const resultBlocks = html.match(/<div class="result[^"]*"[\s\S]*?<\/div>\s*<\/div>/g) || [];
|
|
490
|
+
for (const block of resultBlocks.slice(0, n)) {
|
|
491
|
+
const titleMatch = block.match(/<a[^>]+class="result__a"[^>]*>([\s\S]*?)<\/a>/);
|
|
492
|
+
const urlMatch = block.match(/href="([^"]+)"/);
|
|
493
|
+
const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
494
|
+
|
|
495
|
+
const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
496
|
+
const href = urlMatch ? urlMatch[1] : '';
|
|
497
|
+
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
498
|
+
|
|
499
|
+
// DuckDuckGo uses redirect URLs — extract the actual URL
|
|
500
|
+
const actualUrl = href.includes('uddg=')
|
|
501
|
+
? decodeURIComponent(href.match(/uddg=([^&]+)/)?.[1] || href)
|
|
502
|
+
: href;
|
|
503
|
+
|
|
504
|
+
if (title && actualUrl) {
|
|
505
|
+
results.push(`${results.length + 1}. ${title}\n ${actualUrl}\n ${snippet}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (results.length === 0) return `No results found for: ${query}`;
|
|
510
|
+
return `Search results for "${query}":\n\n${results.join('\n\n')}`;
|
|
511
|
+
} catch (e) {
|
|
512
|
+
if (e.name === 'AbortError') return 'Error: Search timed out';
|
|
513
|
+
return `Error: ${e.message}`;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// ── AGENT TOOL ────────────────────────────────────────────────────────────────
|
|
519
|
+
// Circular import prevention — agentLoop is injected at runtime by loop.js
|
|
520
|
+
let _agentLoopRef = null;
|
|
521
|
+
export function injectAgentLoop(fn) { _agentLoopRef = fn; }
|
|
522
|
+
|
|
523
|
+
export const AgentTool = {
|
|
524
|
+
name: 'Agent',
|
|
525
|
+
description: `Launch a sub-agent to handle an independent task in parallel. The sub-agent has access to all the same tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, etc.) and works autonomously until done.
|
|
526
|
+
|
|
527
|
+
USE THIS FOR:
|
|
528
|
+
- Independent subtasks that don't depend on each other (run multiple Agents in one response)
|
|
529
|
+
- Long-running research tasks you don't want in the main context
|
|
530
|
+
- Isolated operations (e.g. "analyze file A" + "analyze file B" simultaneously)
|
|
531
|
+
|
|
532
|
+
The agent returns a summary of what it did and what it found. Keep the task description specific and self-contained — the sub-agent has no knowledge of the current conversation.
|
|
533
|
+
|
|
534
|
+
IMPORTANT: You can call Agent multiple times in one response to run tasks truly in parallel.`,
|
|
535
|
+
input_schema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
task: { type: 'string', description: 'Complete, self-contained task description for the sub-agent' },
|
|
539
|
+
serverUrl: { type: 'string', description: 'Override server URL (optional)' }
|
|
540
|
+
},
|
|
541
|
+
required: ['task']
|
|
542
|
+
},
|
|
543
|
+
async run({ task, serverUrl }) {
|
|
544
|
+
if (!_agentLoopRef) return 'Error: AgentTool not initialized (no agent loop reference)';
|
|
545
|
+
if (!_currentServerUrl) return 'Error: AgentTool not initialized (no server URL)';
|
|
546
|
+
|
|
547
|
+
const url = serverUrl || _currentServerUrl;
|
|
548
|
+
const subMessages = [
|
|
549
|
+
{ role: 'system', content: _agentSystemPrompt || 'You are a capable software engineering sub-agent. Complete the given task fully and return a summary of what you did.' },
|
|
550
|
+
{ role: 'user', content: task }
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const outputTokens = [];
|
|
554
|
+
let done = false;
|
|
555
|
+
try {
|
|
556
|
+
await _agentLoopRef(
|
|
557
|
+
url,
|
|
558
|
+
subMessages,
|
|
559
|
+
(token) => { outputTokens.push(token); },
|
|
560
|
+
() => {}, // tool call display — silent in sub-agent
|
|
561
|
+
null, // no session info for sub-agents
|
|
562
|
+
null, // no confirm callback
|
|
563
|
+
10 // max iterations
|
|
564
|
+
);
|
|
565
|
+
done = true;
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return `Sub-agent error: ${e.message}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Find the last assistant message as the result
|
|
571
|
+
const lastAssistant = [...subMessages].reverse().find(m => m.role === 'assistant');
|
|
572
|
+
const result = lastAssistant?.content || outputTokens.join('');
|
|
573
|
+
return `[Sub-agent result]\n${String(result).slice(0, 6000)}`;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Server URL + system prompt injected by loop.js at startup
|
|
578
|
+
let _currentServerUrl = null;
|
|
579
|
+
let _agentSystemPrompt = null;
|
|
580
|
+
export function injectServerContext(serverUrl, systemPrompt) {
|
|
581
|
+
_currentServerUrl = serverUrl;
|
|
582
|
+
_agentSystemPrompt = systemPrompt;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── SNIP TOOL ─────────────────────────────────────────────────────────────────
|
|
586
|
+
// The model calls this to surgically remove specific turns from context
|
|
587
|
+
// loop.js handles the actual splice — this is a signal tool
|
|
588
|
+
export const SnipTool = {
|
|
589
|
+
name: 'Snip',
|
|
590
|
+
description: `Remove specific conversation turns from context to free up space, without losing the whole conversation like /compact does.
|
|
591
|
+
|
|
592
|
+
Use when:
|
|
593
|
+
- A specific tool result was very large and is no longer needed (e.g. a full file read you've already processed)
|
|
594
|
+
- An early exploration phase produced lots of output that's no longer relevant
|
|
595
|
+
- You want to keep recent context but drop stale earlier parts
|
|
596
|
+
|
|
597
|
+
The 'indices' are 0-based positions in the conversation (0 = first user message after system prompt).
|
|
598
|
+
Use /status to see current message count, then pick which to snip.`,
|
|
599
|
+
input_schema: {
|
|
600
|
+
type: 'object',
|
|
601
|
+
properties: {
|
|
602
|
+
indices: {
|
|
603
|
+
type: 'array',
|
|
604
|
+
items: { type: 'number' },
|
|
605
|
+
description: 'Array of 0-based message indices to remove from context'
|
|
606
|
+
},
|
|
607
|
+
reason: { type: 'string', description: 'Why you are snipping these (logged for transparency)' }
|
|
608
|
+
},
|
|
609
|
+
required: ['indices']
|
|
610
|
+
},
|
|
611
|
+
async run({ indices, reason }) {
|
|
612
|
+
// Actual snipping happens in agentLoop — this signals intent
|
|
613
|
+
return JSON.stringify({ __snip__: true, indices, reason: reason || 'context management' });
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// ── BRIEF TOOL ────────────────────────────────────────────────────────────────
|
|
618
|
+
export const BriefTool = {
|
|
619
|
+
name: 'Brief',
|
|
620
|
+
description: `Write a self-briefing note to yourself about the current task state. This gets injected into context on the next turn, ensuring nothing is forgotten even if context is trimmed.
|
|
621
|
+
|
|
622
|
+
Use after major milestones in long tasks. Different from Checkpoint — Brief is for your own working memory, not user-visible progress.
|
|
623
|
+
|
|
624
|
+
Example:
|
|
625
|
+
Brief({ content: "Working on auth refactor. Done: DB schema updated, sessions table migrated. Current: updating login route. Not done: logout, password reset. Key discovery: sessions table uses TEXT not UUID for user_id — do not cast." })`,
|
|
626
|
+
input_schema: {
|
|
627
|
+
type: 'object',
|
|
628
|
+
properties: {
|
|
629
|
+
content: { type: 'string', description: 'Your working memory note — what you know, what is done, what is next, what to watch out for' }
|
|
630
|
+
},
|
|
631
|
+
required: ['content']
|
|
632
|
+
},
|
|
633
|
+
async run({ content }) {
|
|
634
|
+
try {
|
|
635
|
+
if (!_sessionDir) return 'Brief recorded (no session initialized)';
|
|
636
|
+
// Reuse checkpoint mechanism — both go to session context
|
|
637
|
+
updateContext(_sessionDir, `[BRIEF]\n${content}`);
|
|
638
|
+
return 'Brief saved to working memory.';
|
|
639
|
+
} catch (e) {
|
|
640
|
+
return `Brief recorded (write failed: ${e.message})`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
export const ALL_TOOLS = [
|
|
646
|
+
BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool,
|
|
647
|
+
NoteTool, CheckpointTool,
|
|
648
|
+
WebFetchTool, WebSearchTool,
|
|
649
|
+
TodoWriteTool, AskUserTool,
|
|
650
|
+
AgentTool, SnipTool, BriefTool
|
|
651
|
+
];
|