tsunami-code 3.0.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 +42 -1
- package/lib/loop.js +32 -1
- package/lib/skills.js +103 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'path';
|
|
|
6
6
|
import os from 'os';
|
|
7
7
|
import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
|
|
8
8
|
import { injectServerContext } from './lib/tools.js';
|
|
9
|
+
import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
|
|
9
10
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
10
11
|
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
11
12
|
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
getSessionContext
|
|
22
23
|
} from './lib/memory.js';
|
|
23
24
|
|
|
24
|
-
const VERSION = '3.
|
|
25
|
+
const VERSION = '3.1.0';
|
|
25
26
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
26
27
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
27
28
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -173,6 +174,9 @@ async function run() {
|
|
|
173
174
|
initProjectMemory(cwd);
|
|
174
175
|
setSession({ sessionDir, cwd });
|
|
175
176
|
|
|
177
|
+
// Load skills
|
|
178
|
+
let skills = loadSkills(cwd);
|
|
179
|
+
|
|
176
180
|
// Inject server context into AgentTool
|
|
177
181
|
injectServerContext(serverUrl, buildSystemPrompt());
|
|
178
182
|
|
|
@@ -407,6 +411,30 @@ async function run() {
|
|
|
407
411
|
const cmd = parts[0].toLowerCase();
|
|
408
412
|
const rest = parts.slice(1);
|
|
409
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
|
+
|
|
410
438
|
switch (cmd) {
|
|
411
439
|
case 'help':
|
|
412
440
|
console.log(blue('\n Tsunami Code CLI — Commands\n'));
|
|
@@ -515,6 +543,19 @@ async function run() {
|
|
|
515
543
|
case 'memory':
|
|
516
544
|
await handleMemoryCommand(rest);
|
|
517
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
|
+
}
|
|
518
559
|
case 'history': {
|
|
519
560
|
const recent = historyEntries.slice(0, 20);
|
|
520
561
|
if (recent.length === 0) { console.log(dim(' No history yet.\n')); break; }
|
package/lib/loop.js
CHANGED
|
@@ -237,11 +237,15 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
|
|
|
237
237
|
fullContent += token;
|
|
238
238
|
onToken(token);
|
|
239
239
|
}
|
|
240
|
-
// Capture real token counts
|
|
240
|
+
// Capture real token counts + finish reason
|
|
241
241
|
if (parsed.usage) {
|
|
242
242
|
tokenStats.input += parsed.usage.prompt_tokens || 0;
|
|
243
243
|
tokenStats.output += parsed.usage.completion_tokens || 0;
|
|
244
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;
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
}
|
|
@@ -315,12 +319,39 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
315
319
|
|
|
316
320
|
let memoryContext = buildMemoryContext();
|
|
317
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
|
+
|
|
318
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
|
+
|
|
319
341
|
const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
|
|
320
342
|
const toolCalls = parseToolCalls(content);
|
|
321
343
|
|
|
322
344
|
messages.push({ role: 'assistant', content });
|
|
323
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
|
+
|
|
324
355
|
if (toolCalls.length === 0) break;
|
|
325
356
|
|
|
326
357
|
const results = [];
|
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
|
+
}
|