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 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.0.0';
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 from usage field (sent on final chunk by llama.cpp)
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {