@blockrun/franklin 3.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.
Files changed (138) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +256 -0
  3. package/dist/agent/commands.d.ts +27 -0
  4. package/dist/agent/commands.js +659 -0
  5. package/dist/agent/compact.d.ts +31 -0
  6. package/dist/agent/compact.js +366 -0
  7. package/dist/agent/context.d.ts +11 -0
  8. package/dist/agent/context.js +184 -0
  9. package/dist/agent/error-classifier.d.ts +10 -0
  10. package/dist/agent/error-classifier.js +61 -0
  11. package/dist/agent/llm.d.ts +63 -0
  12. package/dist/agent/llm.js +448 -0
  13. package/dist/agent/loop.d.ts +12 -0
  14. package/dist/agent/loop.js +346 -0
  15. package/dist/agent/optimize.d.ts +53 -0
  16. package/dist/agent/optimize.js +262 -0
  17. package/dist/agent/permissions.d.ts +39 -0
  18. package/dist/agent/permissions.js +226 -0
  19. package/dist/agent/reduce.d.ts +49 -0
  20. package/dist/agent/reduce.js +317 -0
  21. package/dist/agent/streaming-executor.d.ts +36 -0
  22. package/dist/agent/streaming-executor.js +149 -0
  23. package/dist/agent/tokens.d.ts +53 -0
  24. package/dist/agent/tokens.js +185 -0
  25. package/dist/agent/types.d.ts +125 -0
  26. package/dist/agent/types.js +5 -0
  27. package/dist/banner.d.ts +1 -0
  28. package/dist/banner.js +27 -0
  29. package/dist/commands/balance.d.ts +1 -0
  30. package/dist/commands/balance.js +40 -0
  31. package/dist/commands/config.d.ts +14 -0
  32. package/dist/commands/config.js +107 -0
  33. package/dist/commands/daemon.d.ts +3 -0
  34. package/dist/commands/daemon.js +117 -0
  35. package/dist/commands/history.d.ts +5 -0
  36. package/dist/commands/history.js +31 -0
  37. package/dist/commands/init.d.ts +3 -0
  38. package/dist/commands/init.js +92 -0
  39. package/dist/commands/logs.d.ts +5 -0
  40. package/dist/commands/logs.js +89 -0
  41. package/dist/commands/models.d.ts +1 -0
  42. package/dist/commands/models.js +56 -0
  43. package/dist/commands/plugin.d.ts +14 -0
  44. package/dist/commands/plugin.js +176 -0
  45. package/dist/commands/proxy.d.ts +13 -0
  46. package/dist/commands/proxy.js +106 -0
  47. package/dist/commands/setup.d.ts +1 -0
  48. package/dist/commands/setup.js +49 -0
  49. package/dist/commands/start.d.ts +8 -0
  50. package/dist/commands/start.js +292 -0
  51. package/dist/commands/stats.d.ts +10 -0
  52. package/dist/commands/stats.js +94 -0
  53. package/dist/commands/uninit.d.ts +1 -0
  54. package/dist/commands/uninit.js +63 -0
  55. package/dist/config.d.ts +9 -0
  56. package/dist/config.js +41 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +179 -0
  59. package/dist/mcp/client.d.ts +44 -0
  60. package/dist/mcp/client.js +147 -0
  61. package/dist/mcp/config.d.ts +20 -0
  62. package/dist/mcp/config.js +138 -0
  63. package/dist/plugin-sdk/channel.d.ts +100 -0
  64. package/dist/plugin-sdk/channel.js +10 -0
  65. package/dist/plugin-sdk/index.d.ts +14 -0
  66. package/dist/plugin-sdk/index.js +9 -0
  67. package/dist/plugin-sdk/plugin.d.ts +87 -0
  68. package/dist/plugin-sdk/plugin.js +7 -0
  69. package/dist/plugin-sdk/search.d.ts +13 -0
  70. package/dist/plugin-sdk/search.js +4 -0
  71. package/dist/plugin-sdk/tracker.d.ts +27 -0
  72. package/dist/plugin-sdk/tracker.js +5 -0
  73. package/dist/plugin-sdk/workflow.d.ts +126 -0
  74. package/dist/plugin-sdk/workflow.js +11 -0
  75. package/dist/plugins/registry.d.ts +33 -0
  76. package/dist/plugins/registry.js +155 -0
  77. package/dist/plugins/runner.d.ts +21 -0
  78. package/dist/plugins/runner.js +453 -0
  79. package/dist/plugins-bundled/social/index.d.ts +10 -0
  80. package/dist/plugins-bundled/social/index.js +363 -0
  81. package/dist/plugins-bundled/social/plugin.json +14 -0
  82. package/dist/plugins-bundled/social/prompts.d.ts +19 -0
  83. package/dist/plugins-bundled/social/prompts.js +67 -0
  84. package/dist/plugins-bundled/social/types.d.ts +58 -0
  85. package/dist/plugins-bundled/social/types.js +16 -0
  86. package/dist/pricing.d.ts +21 -0
  87. package/dist/pricing.js +91 -0
  88. package/dist/proxy/fallback.d.ts +38 -0
  89. package/dist/proxy/fallback.js +144 -0
  90. package/dist/proxy/server.d.ts +18 -0
  91. package/dist/proxy/server.js +576 -0
  92. package/dist/proxy/sse-translator.d.ts +29 -0
  93. package/dist/proxy/sse-translator.js +270 -0
  94. package/dist/router/index.d.ts +22 -0
  95. package/dist/router/index.js +269 -0
  96. package/dist/session/search.d.ts +33 -0
  97. package/dist/session/search.js +229 -0
  98. package/dist/session/storage.d.ts +48 -0
  99. package/dist/session/storage.js +173 -0
  100. package/dist/stats/insights.d.ts +55 -0
  101. package/dist/stats/insights.js +195 -0
  102. package/dist/stats/tracker.d.ts +54 -0
  103. package/dist/stats/tracker.js +165 -0
  104. package/dist/tools/askuser.d.ts +6 -0
  105. package/dist/tools/askuser.js +76 -0
  106. package/dist/tools/bash.d.ts +5 -0
  107. package/dist/tools/bash.js +336 -0
  108. package/dist/tools/edit.d.ts +5 -0
  109. package/dist/tools/edit.js +148 -0
  110. package/dist/tools/glob.d.ts +5 -0
  111. package/dist/tools/glob.js +158 -0
  112. package/dist/tools/grep.d.ts +5 -0
  113. package/dist/tools/grep.js +194 -0
  114. package/dist/tools/imagegen.d.ts +6 -0
  115. package/dist/tools/imagegen.js +172 -0
  116. package/dist/tools/index.d.ts +17 -0
  117. package/dist/tools/index.js +30 -0
  118. package/dist/tools/read.d.ts +11 -0
  119. package/dist/tools/read.js +90 -0
  120. package/dist/tools/subagent.d.ts +5 -0
  121. package/dist/tools/subagent.js +116 -0
  122. package/dist/tools/task.d.ts +5 -0
  123. package/dist/tools/task.js +91 -0
  124. package/dist/tools/webfetch.d.ts +5 -0
  125. package/dist/tools/webfetch.js +166 -0
  126. package/dist/tools/websearch.d.ts +5 -0
  127. package/dist/tools/websearch.js +103 -0
  128. package/dist/tools/write.d.ts +5 -0
  129. package/dist/tools/write.js +114 -0
  130. package/dist/ui/app.d.ts +26 -0
  131. package/dist/ui/app.js +545 -0
  132. package/dist/ui/model-picker.d.ts +14 -0
  133. package/dist/ui/model-picker.js +161 -0
  134. package/dist/ui/terminal.d.ts +35 -0
  135. package/dist/ui/terminal.js +337 -0
  136. package/dist/wallet/manager.d.ts +10 -0
  137. package/dist/wallet/manager.js +23 -0
  138. package/package.json +79 -0
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Slash command registry for runcode.
3
+ * Extracted from loop.ts for maintainability.
4
+ *
5
+ * Two types of commands:
6
+ * 1. "Handled" — execute directly, emit events, return { handled: true }
7
+ * 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten }
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { execSync } from 'node:child_process';
12
+ import { BLOCKRUN_DIR, VERSION } from '../config.js';
13
+ import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js';
14
+ import { forceCompact } from './compact.js';
15
+ import { getStatsSummary } from '../stats/tracker.js';
16
+ import { resolveModel } from '../ui/model-picker.js';
17
+ import { listSessions, loadSessionHistory, } from '../session/storage.js';
18
+ // ─── Git helpers ──────────────────────────────────────────────────────────
19
+ function gitExec(cmd, cwd, timeout = 5000, maxBuffer) {
20
+ return execSync(cmd, {
21
+ cwd,
22
+ encoding: 'utf-8',
23
+ timeout,
24
+ maxBuffer: maxBuffer || 1024 * 1024,
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ }).trim();
27
+ }
28
+ function gitCmd(ctx, cmd, timeout, maxBuffer) {
29
+ try {
30
+ return gitExec(cmd, ctx.config.workingDir || process.cwd(), timeout, maxBuffer);
31
+ }
32
+ catch (e) {
33
+ // Prefer stderr (actual git error message) over the noisy "Command failed: ..." header
34
+ const errObj = e;
35
+ const stderr = errObj.stderr ? String(errObj.stderr).trim() : '';
36
+ // Take only the first meaningful line (git sometimes dumps full usage on errors)
37
+ const firstLine = (stderr || errObj.message || 'unknown').split('\n')[0].trim();
38
+ ctx.onEvent({ kind: 'text_delta', text: `Git: ${firstLine}\n` });
39
+ return null;
40
+ }
41
+ }
42
+ function emitDone(ctx) {
43
+ ctx.onEvent({ kind: 'turn_done', reason: 'completed' });
44
+ }
45
+ function buildExchanges(history) {
46
+ const exchanges = [];
47
+ let i = 0;
48
+ while (i < history.length) {
49
+ const msg = history[i];
50
+ if (msg.role !== 'user') {
51
+ i++;
52
+ continue;
53
+ }
54
+ const userText = extractText(msg);
55
+ if (!userText) {
56
+ i++;
57
+ continue;
58
+ } // skip tool_result-only user messages
59
+ const startIdx = i;
60
+ let endIdx = i;
61
+ let assistantText = '';
62
+ const toolNames = [];
63
+ let j = i + 1;
64
+ while (j < history.length) {
65
+ const next = history[j];
66
+ if (next.role === 'user' && extractText(next))
67
+ break; // next exchange
68
+ if (next.role === 'assistant') {
69
+ const t = extractText(next);
70
+ if (t && !assistantText)
71
+ assistantText = t;
72
+ if (Array.isArray(next.content)) {
73
+ for (const p of next.content) {
74
+ if (p.type === 'tool_use' && !toolNames.includes(p.name))
75
+ toolNames.push(p.name);
76
+ }
77
+ }
78
+ }
79
+ endIdx = j;
80
+ j++;
81
+ }
82
+ exchanges.push({
83
+ userText: userText.slice(0, 120) + (userText.length > 120 ? '…' : ''),
84
+ assistantText: (assistantText.slice(0, 80) + (assistantText.length > 80 ? '…' : '')) || '(no text)',
85
+ toolNames,
86
+ startIdx,
87
+ endIdx,
88
+ });
89
+ i = j;
90
+ }
91
+ return exchanges;
92
+ }
93
+ function extractText(msg) {
94
+ if (typeof msg.content === 'string')
95
+ return msg.content.trim();
96
+ if (!Array.isArray(msg.content))
97
+ return '';
98
+ for (const p of msg.content) {
99
+ if (p.type === 'text' && p.text.trim())
100
+ return p.text.trim();
101
+ }
102
+ return '';
103
+ }
104
+ // ─── Command Definitions ──────────────────────────────────────────────────
105
+ // Direct-handled commands (don't go to agent)
106
+ const DIRECT_COMMANDS = {
107
+ '/stash': (ctx) => {
108
+ const r = gitCmd(ctx, 'git stash push -m "runcode auto-stash"', 10000);
109
+ if (r !== null)
110
+ ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'No changes to stash.\n' });
111
+ emitDone(ctx);
112
+ },
113
+ '/unstash': (ctx) => {
114
+ const r = gitCmd(ctx, 'git stash pop', 10000);
115
+ if (r !== null)
116
+ ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'Stash applied.\n' });
117
+ emitDone(ctx);
118
+ },
119
+ '/log': (ctx) => {
120
+ const r = gitCmd(ctx, 'git log --oneline -15 --no-color');
121
+ if (r !== null)
122
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No commits yet.\n' });
123
+ emitDone(ctx);
124
+ },
125
+ '/status': (ctx) => {
126
+ const r = gitCmd(ctx, 'git status --short --branch');
127
+ if (r !== null)
128
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Working tree clean.\n' });
129
+ emitDone(ctx);
130
+ },
131
+ '/diff': (ctx) => {
132
+ // git diff with stat header then full diff
133
+ const stat = gitCmd(ctx, 'git diff --stat --no-color');
134
+ if (stat === null) {
135
+ emitDone(ctx);
136
+ return;
137
+ }
138
+ const full = gitCmd(ctx, 'git diff --no-color');
139
+ if (full === null) {
140
+ emitDone(ctx);
141
+ return;
142
+ }
143
+ if (!stat && !full) {
144
+ ctx.onEvent({ kind: 'text_delta', text: 'No unstaged changes.\n' });
145
+ }
146
+ else {
147
+ ctx.onEvent({ kind: 'text_delta', text: `\`\`\`diff\n${[stat, full].filter(Boolean).join('\n---\n')}\n\`\`\`\n` });
148
+ }
149
+ emitDone(ctx);
150
+ },
151
+ '/undo': (ctx) => {
152
+ const r = gitCmd(ctx, 'git reset --soft HEAD~1');
153
+ if (r !== null)
154
+ ctx.onEvent({ kind: 'text_delta', text: `Last commit undone. Changes preserved in staging.\n` });
155
+ emitDone(ctx);
156
+ },
157
+ '/tokens': (ctx) => {
158
+ const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history);
159
+ const contextWindow = getContextWindow(ctx.config.model);
160
+ const pct = (estimated / contextWindow) * 100;
161
+ // Count tool results and thinking blocks
162
+ let toolResults = 0;
163
+ let thinkingBlocks = 0;
164
+ let totalToolChars = 0;
165
+ for (const msg of ctx.history) {
166
+ if (typeof msg.content === 'string')
167
+ continue;
168
+ if (!Array.isArray(msg.content))
169
+ continue;
170
+ for (const part of msg.content) {
171
+ if ('type' in part) {
172
+ if (part.type === 'tool_result') {
173
+ toolResults++;
174
+ const c = typeof part.content === 'string' ? part.content : JSON.stringify(part.content);
175
+ totalToolChars += c.length;
176
+ }
177
+ if (part.type === 'thinking')
178
+ thinkingBlocks++;
179
+ }
180
+ }
181
+ }
182
+ ctx.onEvent({ kind: 'text_delta', text: `**Token Usage**\n` +
183
+ ` Estimated: ~${estimated.toLocaleString()} tokens ${apiAnchored ? '(API-anchored)' : '(estimated)'}\n` +
184
+ ` Context: ${(contextWindow / 1000).toFixed(0)}k window (${pct.toFixed(1)}% used)\n` +
185
+ ` Messages: ${ctx.history.length}\n` +
186
+ ` Tool results: ${toolResults} (${(totalToolChars / 1024).toFixed(0)}KB)\n` +
187
+ ` Thinking: ${thinkingBlocks} blocks\n` +
188
+ (pct > 80 ? ' ⚠ Near limit — run /compact\n' : '') +
189
+ (pct > 60 ? '' : ' ✓ Healthy\n')
190
+ });
191
+ emitDone(ctx);
192
+ },
193
+ '/help': (ctx) => {
194
+ const ultrathinkOn = ctx.config.ultrathink;
195
+ ctx.onEvent({ kind: 'text_delta', text: `**RunCode Commands**\n\n` +
196
+ ` **Coding:** /commit /review /test /fix /debug /explain /search /find /refactor /scaffold\n` +
197
+ ` **Git:** /push /pr /undo /status /diff /log /branch /stash /unstash\n` +
198
+ ` **Analysis:** /security /lint /optimize /todo /deps /clean /migrate /doc\n` +
199
+ ` **Session:** /plan /ultraplan /execute /compact /retry /sessions /resume /context /tasks\n` +
200
+ ` **Power:** /ultrathink [query] /ultraplan /dump\n` +
201
+ ` **Info:** /model /wallet /cost /tokens /mcp /doctor /version /bug /help\n` +
202
+ ` **UI:** /clear /exit\n` +
203
+ (ultrathinkOn ? `\n Ultrathink: ON\n` : '')
204
+ });
205
+ emitDone(ctx);
206
+ },
207
+ '/history': (ctx) => {
208
+ const { history, config } = ctx;
209
+ const modelName = config.model.split('/').pop() || config.model;
210
+ const exchanges = buildExchanges(history);
211
+ let output = '**Conversation History**\n\n';
212
+ if (exchanges.length === 0) {
213
+ output += 'No history in the current session yet.\n';
214
+ }
215
+ else {
216
+ for (let i = 0; i < exchanges.length; i++) {
217
+ const ex = exchanges[i];
218
+ const tools = ex.toolNames.length > 0 ? ` · used: ${ex.toolNames.join(', ')}` : '';
219
+ output += `[${i + 1}] [user] ${ex.userText}\n`;
220
+ output += ` [${modelName}] ${ex.assistantText}${tools}\n\n`;
221
+ }
222
+ }
223
+ output += 'Use `/delete <number>` to remove exchanges (e.g., `/delete 2` or `/delete 3-5`).\n';
224
+ ctx.onEvent({ kind: 'text_delta', text: output });
225
+ emitDone(ctx);
226
+ },
227
+ '/bug': (ctx) => {
228
+ ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' });
229
+ emitDone(ctx);
230
+ },
231
+ '/version': (ctx) => {
232
+ ctx.onEvent({ kind: 'text_delta', text: `RunCode v${VERSION}\n` });
233
+ emitDone(ctx);
234
+ },
235
+ '/mcp': async (ctx) => {
236
+ const { listMcpServers } = await import('../mcp/client.js');
237
+ const servers = listMcpServers();
238
+ if (servers.length === 0) {
239
+ ctx.onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' });
240
+ }
241
+ else {
242
+ let text = `**${servers.length} MCP server(s) connected:**\n\n`;
243
+ for (const s of servers) {
244
+ text += ` **${s.name}** — ${s.toolCount} tools\n`;
245
+ for (const t of s.tools)
246
+ text += ` · ${t}\n`;
247
+ }
248
+ ctx.onEvent({ kind: 'text_delta', text });
249
+ }
250
+ emitDone(ctx);
251
+ },
252
+ '/context': async (ctx) => {
253
+ const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history);
254
+ const contextWindow = getContextWindow(ctx.config.model);
255
+ const pct = (estimated / contextWindow) * 100;
256
+ const usagePct = pct.toFixed(1);
257
+ const warning = pct > 80 ? ' ⚠ Near limit — consider /compact\n' : '';
258
+ ctx.onEvent({ kind: 'text_delta', text: `**Session Context**\n` +
259
+ ` Model: ${ctx.config.model}\n` +
260
+ ` Mode: ${ctx.config.permissionMode || 'default'}\n` +
261
+ ` Messages: ${ctx.history.length}\n` +
262
+ ` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` +
263
+ warning +
264
+ ` Session: ${ctx.sessionId}\n` +
265
+ ` Directory: ${ctx.config.workingDir || process.cwd()}\n`
266
+ });
267
+ emitDone(ctx);
268
+ },
269
+ '/doctor': async (ctx) => {
270
+ const checks = [];
271
+ try {
272
+ execSync('git --version', { stdio: 'pipe' });
273
+ checks.push('✓ git available');
274
+ }
275
+ catch {
276
+ checks.push('✗ git not found');
277
+ }
278
+ try {
279
+ execSync('rg --version', { stdio: 'pipe' });
280
+ checks.push('✓ ripgrep available');
281
+ }
282
+ catch {
283
+ checks.push('⚠ ripgrep not found (using native grep fallback)');
284
+ }
285
+ const hasWallet = fs.existsSync(path.join(BLOCKRUN_DIR, 'wallet.json'))
286
+ || fs.existsSync(path.join(BLOCKRUN_DIR, 'solana-wallet.json'));
287
+ checks.push(hasWallet ? '✓ wallet configured' : '⚠ no wallet — run: runcode setup');
288
+ checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'runcode-config.json')) ? '✓ config file exists' : '⚠ no config — using defaults');
289
+ // Check MCP
290
+ const { listMcpServers } = await import('../mcp/client.js');
291
+ const mcpServers = listMcpServers();
292
+ checks.push(mcpServers.length > 0
293
+ ? `✓ MCP: ${mcpServers.length} server(s), ${mcpServers.reduce((a, s) => a + s.toolCount, 0)} tools`
294
+ : '⚠ no MCP servers connected');
295
+ checks.push(`✓ model: ${ctx.config.model}`);
296
+ checks.push(`✓ history: ${ctx.history.length} messages, ~${estimateHistoryTokens(ctx.history).toLocaleString()} tokens`);
297
+ checks.push(`✓ session: ${ctx.sessionId}`);
298
+ checks.push(`✓ version: v${VERSION}`);
299
+ ctx.onEvent({ kind: 'text_delta', text: `**Health Check**\n${checks.map(c => ' ' + c).join('\n')}\n` });
300
+ emitDone(ctx);
301
+ },
302
+ '/plan': (ctx) => {
303
+ if (ctx.config.permissionMode === 'plan') {
304
+ ctx.onEvent({ kind: 'text_delta', text: 'Already in plan mode. Use /execute to exit.\n' });
305
+ }
306
+ else {
307
+ ctx.config.permissionMode = 'plan';
308
+ ctx.onEvent({ kind: 'text_delta', text: '**Plan mode active.** Tools restricted to read-only. Use /execute when ready to implement.\n' });
309
+ }
310
+ emitDone(ctx);
311
+ },
312
+ '/ultrathink': (ctx) => {
313
+ const cfg = ctx.config;
314
+ cfg.ultrathink = !cfg.ultrathink;
315
+ if (cfg.ultrathink) {
316
+ ctx.onEvent({ kind: 'text_delta', text: '**Ultrathink mode ON.** Extended reasoning active — the model will think deeply before responding.\n' +
317
+ 'Use `/ultrathink` again to disable, or `/ultrathink <query>` to send a one-shot deep analysis.\n'
318
+ });
319
+ }
320
+ else {
321
+ ctx.onEvent({ kind: 'text_delta', text: '**Ultrathink mode OFF.** Normal response mode restored.\n' });
322
+ }
323
+ emitDone(ctx);
324
+ },
325
+ '/dump': (ctx) => {
326
+ const instructions = ctx.config.systemInstructions;
327
+ const joined = instructions.join('\n\n---\n\n');
328
+ ctx.onEvent({ kind: 'text_delta', text: `**System Prompt** (${instructions.length} section${instructions.length !== 1 ? 's' : ''}):\n\n` +
329
+ `\`\`\`\n${joined.slice(0, 4000)}${joined.length > 4000 ? `\n... (${joined.length - 4000} chars truncated)` : ''}\n\`\`\`\n`
330
+ });
331
+ emitDone(ctx);
332
+ },
333
+ '/execute': (ctx) => {
334
+ if (ctx.config.permissionMode !== 'plan') {
335
+ ctx.onEvent({ kind: 'text_delta', text: 'Not in plan mode. Use /plan to enter.\n' });
336
+ }
337
+ else {
338
+ ctx.config.permissionMode = 'default';
339
+ ctx.onEvent({ kind: 'text_delta', text: '**Execution mode.** All tools enabled with permissions.\n' });
340
+ }
341
+ emitDone(ctx);
342
+ },
343
+ '/sessions': (ctx) => {
344
+ const sessions = listSessions();
345
+ if (sessions.length === 0) {
346
+ ctx.onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' });
347
+ }
348
+ else {
349
+ let text = `**${sessions.length} saved sessions:**\n\n`;
350
+ for (const s of sessions.slice(0, 10)) {
351
+ const date = new Date(s.updatedAt).toLocaleString();
352
+ const dir = s.workDir ? ` — ${s.workDir.split('/').pop()}` : '';
353
+ text += ` ${s.id} ${s.model} ${s.turnCount} turns ${date}${dir}\n`;
354
+ }
355
+ if (sessions.length > 10)
356
+ text += ` ... and ${sessions.length - 10} more\n`;
357
+ text += '\nUse /resume <session-id> to continue a session.\n';
358
+ ctx.onEvent({ kind: 'text_delta', text });
359
+ }
360
+ emitDone(ctx);
361
+ },
362
+ '/cost': async (ctx) => {
363
+ const { stats, saved } = getStatsSummary();
364
+ ctx.onEvent({ kind: 'text_delta', text: `**Session Cost**\n` +
365
+ ` Requests: ${stats.totalRequests}\n` +
366
+ ` Cost: $${stats.totalCostUsd.toFixed(4)} USDC\n` +
367
+ ` Saved: $${saved.toFixed(2)} vs Claude Opus\n` +
368
+ ` Tokens: ${stats.totalInputTokens.toLocaleString()} in / ${stats.totalOutputTokens.toLocaleString()} out\n`
369
+ });
370
+ emitDone(ctx);
371
+ },
372
+ '/wallet': async (ctx) => {
373
+ const chain = (await import('../config.js')).loadChain();
374
+ try {
375
+ let address;
376
+ let balance;
377
+ const fetchTimeout = (ms) => new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms));
378
+ if (chain === 'solana') {
379
+ const { getOrCreateSolanaWallet, setupAgentSolanaWallet } = await import('@blockrun/llm');
380
+ const w = await getOrCreateSolanaWallet();
381
+ address = w.address;
382
+ try {
383
+ const client = await setupAgentSolanaWallet({ silent: true });
384
+ const bal = await Promise.race([client.getBalance(), fetchTimeout(5000)]);
385
+ balance = `$${bal.toFixed(2)} USDC`;
386
+ }
387
+ catch {
388
+ balance = '(unavailable)';
389
+ }
390
+ }
391
+ else {
392
+ const { getOrCreateWallet, setupAgentWallet } = await import('@blockrun/llm');
393
+ const w = getOrCreateWallet();
394
+ address = w.address;
395
+ try {
396
+ const client = setupAgentWallet({ silent: true });
397
+ const bal = await Promise.race([client.getBalance(), fetchTimeout(5000)]);
398
+ balance = `$${bal.toFixed(2)} USDC`;
399
+ }
400
+ catch {
401
+ balance = '(unavailable)';
402
+ }
403
+ }
404
+ ctx.onEvent({ kind: 'text_delta', text: `**Wallet**\n` +
405
+ ` Chain: ${chain}\n` +
406
+ ` Address: ${address}\n` +
407
+ ` Balance: ${balance}\n`
408
+ });
409
+ }
410
+ catch (err) {
411
+ ctx.onEvent({ kind: 'text_delta', text: `Wallet error: ${err.message}\n` });
412
+ }
413
+ emitDone(ctx);
414
+ },
415
+ '/clear': (ctx) => {
416
+ ctx.history.length = 0;
417
+ resetTokenAnchor();
418
+ ctx.onEvent({ kind: 'text_delta', text: 'Conversation history cleared.\n' });
419
+ emitDone(ctx);
420
+ },
421
+ '/compact': async (ctx) => {
422
+ const beforeTokens = estimateHistoryTokens(ctx.history);
423
+ const { history: compacted, compacted: didCompact } = await forceCompact(ctx.history, ctx.config.model, ctx.client, ctx.config.debug);
424
+ if (didCompact) {
425
+ ctx.history.length = 0;
426
+ ctx.history.push(...compacted);
427
+ resetTokenAnchor();
428
+ const afterTokens = estimateHistoryTokens(ctx.history);
429
+ const saved = beforeTokens - afterTokens;
430
+ const pct = Math.round((saved / beforeTokens) * 100);
431
+ ctx.onEvent({ kind: 'text_delta', text: `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens (saved ${pct}%)\n`
432
+ });
433
+ }
434
+ else {
435
+ ctx.onEvent({ kind: 'text_delta', text: `Nothing to compact — history is already minimal (${beforeTokens.toLocaleString()} tokens, ${ctx.history.length} messages).\n`
436
+ });
437
+ }
438
+ emitDone(ctx);
439
+ },
440
+ };
441
+ // Prompt-rewrite commands (transformed into agent prompts)
442
+ const REWRITE_COMMANDS = {
443
+ '/commit': 'Review the current git diff and staged changes. Stage relevant files with `git add`, then create a commit with a concise message summarizing the changes. Do NOT push to remote.',
444
+ '/push': 'Push the current branch to the remote repository using `git push`. Show the result.',
445
+ '/pr': 'Create a pull request for the current branch. First check `git log --oneline main..HEAD` to see commits, then use `gh pr create` with a descriptive title and body summarizing the changes. If gh CLI is not available, show the manual steps.',
446
+ '/review': 'Review the current git diff. For each changed file, check for: bugs, security issues, missing error handling, performance problems, and style issues. Provide a brief summary of findings.',
447
+ '/fix': 'Look at the most recent error or issue we discussed and fix it. Check the relevant files, identify the root cause, and apply the fix.',
448
+ '/test': 'Detect the project test framework (look for package.json scripts, pytest, etc.) and run the test suite. Show a summary of results.',
449
+ '/debug': 'Look at the most recent error in this session. Read the relevant source files, analyze the root cause, and suggest a fix with specific code changes.',
450
+ '/init': 'Read the project structure: check package.json (or equivalent), README, and key config files. Summarize: what this project is, main language/framework, entry points, and how to run/test it.',
451
+ '/todo': 'Search the codebase for TODO, FIXME, HACK, and XXX comments using Grep. Show the results grouped by file.',
452
+ '/deps': 'Read the project dependency file (package.json, requirements.txt, go.mod, Cargo.toml, etc.) and list key dependencies with their versions.',
453
+ '/optimize': 'Analyze the codebase for performance issues. Check for: unnecessary re-renders, N+1 queries, missing indexes, unoptimized loops, large bundle sizes, and memory leaks. Provide specific recommendations.',
454
+ '/security': 'Audit the codebase for security issues. Check for: SQL injection, XSS, command injection, hardcoded secrets, insecure dependencies, OWASP top 10 vulnerabilities. Report findings with severity.',
455
+ '/lint': 'Check for code quality issues: unused imports, inconsistent naming, missing type annotations, long functions, duplicated code. Suggest improvements.',
456
+ '/migrate': 'Check for pending database migrations, outdated dependencies, or breaking changes that need addressing. List required migration steps.',
457
+ '/clean': 'Find and remove dead code: unused imports, unreachable code, commented-out blocks, unused variables and functions. Show what would be removed before making changes.',
458
+ '/tasks': 'List all current tasks using the Task tool.',
459
+ '/ultraplan': 'Enter ultraplan mode: create a detailed, step-by-step implementation plan before writing any code. ' +
460
+ 'First, thoroughly read ALL relevant files. Map out every dependency and potential side effect. ' +
461
+ 'Identify edge cases, security considerations, and performance implications. ' +
462
+ 'Then produce a numbered implementation plan with specific file paths, function names, and code changes. ' +
463
+ 'Do NOT write any code yet — only the plan.',
464
+ };
465
+ // Commands with arguments (prefix match → rewrite)
466
+ const ARG_COMMANDS = [
467
+ { prefix: '/ultrathink ', rewrite: (a) => `Think deeply, carefully, and thoroughly before responding. ` +
468
+ `Consider multiple approaches, check edge cases, reason through implications step by step, ` +
469
+ `and challenge your initial assumptions. Take your time — quality of reasoning matters more than speed. ` +
470
+ `Now respond to: ${a}`
471
+ },
472
+ { prefix: '/explain ', rewrite: (a) => `Read and explain the code in ${a}. Cover: what it does, key functions/classes, how it connects to the rest of the codebase.` },
473
+ { prefix: '/search ', rewrite: (a) => `Search the codebase for "${a}" using Grep. Show the matching files and relevant code context.` },
474
+ { prefix: '/find ', rewrite: (a) => `Find files matching the pattern "${a}" using Glob. Show the results.` },
475
+ { prefix: '/refactor ', rewrite: (a) => `Refactor: ${a}. Read the relevant code first, then make targeted changes. Explain each change.` },
476
+ { prefix: '/scaffold ', rewrite: (a) => `Create the scaffolding/boilerplate for: ${a}. Generate the file structure and initial code. Ask me if you need clarification on requirements.` },
477
+ { prefix: '/doc ', rewrite: (a) => `Generate documentation for ${a}. Include: purpose, API/interface description, usage examples, and important notes.` },
478
+ ];
479
+ // ─── Main dispatch ────────────────────────────────────────────────────────
480
+ /**
481
+ * Handle a slash command. Returns result indicating what happened.
482
+ */
483
+ export async function handleSlashCommand(input, ctx) {
484
+ // Direct-handled commands
485
+ if (input in DIRECT_COMMANDS) {
486
+ await DIRECT_COMMANDS[input](ctx);
487
+ return { handled: true };
488
+ }
489
+ // /search <query> — full-text search past sessions
490
+ if (input === '/search' || input.startsWith('/search ')) {
491
+ const query = input === '/search' ? '' : input.slice('/search '.length).trim();
492
+ if (!query) {
493
+ ctx.onEvent({ kind: 'text_delta', text: 'Usage: /search <query>\n' +
494
+ 'Finds past sessions whose messages match the query.\n' +
495
+ 'Use quotes for phrase search: /search "payment loop"\n'
496
+ });
497
+ emitDone(ctx);
498
+ return { handled: true };
499
+ }
500
+ const { searchSessions, formatSearchResults } = await import('../session/search.js');
501
+ const matches = searchSessions(query, { limit: 10 });
502
+ ctx.onEvent({ kind: 'text_delta', text: formatSearchResults(matches, query) });
503
+ emitDone(ctx);
504
+ return { handled: true };
505
+ }
506
+ // /insights [--days N] — rich usage insights
507
+ if (input === '/insights' || input.startsWith('/insights ')) {
508
+ const daysMatch = input.match(/--days\s+(\d+)/);
509
+ const days = daysMatch ? parseInt(daysMatch[1], 10) : 30;
510
+ const { generateInsights, formatInsights } = await import('../stats/insights.js');
511
+ const report = generateInsights(days);
512
+ ctx.onEvent({ kind: 'text_delta', text: formatInsights(report, days) });
513
+ emitDone(ctx);
514
+ return { handled: true };
515
+ }
516
+ // /model — show current model or switch with /model <name>
517
+ if (input === '/model' || input.startsWith('/model ')) {
518
+ if (input === '/model') {
519
+ ctx.onEvent({ kind: 'text_delta', text: `Current model: **${ctx.config.model}**\n` +
520
+ `Switch with: \`/model <name>\` (e.g. \`/model sonnet\`, \`/model free\`, \`/model gemini\`)\n`
521
+ });
522
+ }
523
+ else {
524
+ const newModel = resolveModel(input.slice(7).trim());
525
+ ctx.config.model = newModel;
526
+ ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**\n` });
527
+ }
528
+ emitDone(ctx);
529
+ return { handled: true };
530
+ }
531
+ // /branch has both no-arg and with-arg forms
532
+ if (input === '/branch' || input.startsWith('/branch ')) {
533
+ const cwd = ctx.config.workingDir || process.cwd();
534
+ if (input === '/branch') {
535
+ const r = gitCmd(ctx, 'git branch -v --no-color');
536
+ if (r !== null)
537
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No branches yet.\n' });
538
+ }
539
+ else {
540
+ const branchName = input.slice(8).trim();
541
+ const r = gitCmd(ctx, `git checkout -b ${branchName}`);
542
+ if (r !== null)
543
+ ctx.onEvent({ kind: 'text_delta', text: `Created and switched to branch: **${branchName}**\n` });
544
+ }
545
+ emitDone(ctx);
546
+ return { handled: true };
547
+ }
548
+ // /delete <...>
549
+ if (input.startsWith('/delete ')) {
550
+ const arg = input.slice('/delete '.length).trim();
551
+ if (!arg) {
552
+ ctx.onEvent({ kind: 'text_delta', text: 'Usage: /delete <exchange> (e.g., /delete 3, /delete 2,5, /delete 4-7)\n' });
553
+ emitDone(ctx);
554
+ return { handled: true };
555
+ }
556
+ // Parse exchange numbers (1-based) into a set of 0-based exchange indices
557
+ const exchangeIndicesToDelete = new Set();
558
+ const parts = arg.split(',').map(p => p.trim());
559
+ for (const part of parts) {
560
+ if (part.includes('-')) {
561
+ const [start, end] = part.split('-').map(n => parseInt(n, 10));
562
+ if (!isNaN(start) && !isNaN(end) && start <= end) {
563
+ for (let i = start; i <= end; i++)
564
+ exchangeIndicesToDelete.add(i - 1);
565
+ }
566
+ }
567
+ else {
568
+ const n = parseInt(part, 10);
569
+ if (!isNaN(n))
570
+ exchangeIndicesToDelete.add(n - 1);
571
+ }
572
+ }
573
+ if (exchangeIndicesToDelete.size === 0) {
574
+ ctx.onEvent({ kind: 'text_delta', text: 'No valid exchange numbers provided.\n' });
575
+ emitDone(ctx);
576
+ return { handled: true };
577
+ }
578
+ // Map exchange indices → raw history index ranges, then delete descending.
579
+ // This preserves valid user/assistant alternation — each exchange covers the
580
+ // full unit: user prompt + all tool calls/results + assistant replies.
581
+ const exchanges = buildExchanges(ctx.history);
582
+ const rawToDelete = new Set();
583
+ const deletedNums = [];
584
+ for (const exIdx of exchangeIndicesToDelete) {
585
+ const ex = exchanges[exIdx];
586
+ if (!ex)
587
+ continue;
588
+ for (let i = ex.startIdx; i <= ex.endIdx; i++)
589
+ rawToDelete.add(i);
590
+ deletedNums.push(exIdx + 1);
591
+ }
592
+ const sorted = Array.from(rawToDelete).sort((a, b) => b - a);
593
+ for (const idx of sorted) {
594
+ if (idx >= 0 && idx < ctx.history.length)
595
+ ctx.history.splice(idx, 1);
596
+ }
597
+ if (deletedNums.length > 0) {
598
+ resetTokenAnchor();
599
+ ctx.onEvent({ kind: 'text_delta', text: `Deleted exchange(s) ${deletedNums.sort((a, b) => a - b).join(', ')} from history.\n` });
600
+ }
601
+ else {
602
+ ctx.onEvent({ kind: 'text_delta', text: 'No matching exchanges found to delete.\n' });
603
+ }
604
+ emitDone(ctx);
605
+ return { handled: true };
606
+ }
607
+ // /resume <id>
608
+ if (input.startsWith('/resume ')) {
609
+ const targetId = input.slice(8).trim();
610
+ const restored = loadSessionHistory(targetId);
611
+ if (restored.length === 0) {
612
+ ctx.onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` });
613
+ }
614
+ else {
615
+ ctx.history.length = 0;
616
+ ctx.history.push(...restored);
617
+ resetTokenAnchor();
618
+ ctx.onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` });
619
+ }
620
+ emitDone(ctx);
621
+ return { handled: true };
622
+ }
623
+ // Simple rewrite commands (exact match)
624
+ if (input in REWRITE_COMMANDS) {
625
+ return { handled: false, rewritten: REWRITE_COMMANDS[input] };
626
+ }
627
+ // Argument-based rewrite commands (prefix match)
628
+ for (const { prefix, rewrite } of ARG_COMMANDS) {
629
+ if (input.startsWith(prefix)) {
630
+ const arg = input.slice(prefix.length).trim();
631
+ return { handled: false, rewritten: rewrite(arg) };
632
+ }
633
+ }
634
+ // Not a recognized command — suggest closest match
635
+ const allCommands = [
636
+ ...Object.keys(DIRECT_COMMANDS),
637
+ ...Object.keys(REWRITE_COMMANDS),
638
+ ...ARG_COMMANDS.map(c => c.prefix.trim()),
639
+ '/branch', '/resume', '/model', '/wallet', '/cost', '/help', '/clear', '/retry', '/exit',
640
+ ];
641
+ const cmd = input.split(/\s/)[0];
642
+ const close = allCommands.filter(c => {
643
+ // Simple distance: share >= 50% of characters
644
+ const shorter = Math.min(cmd.length, c.length);
645
+ let matches = 0;
646
+ for (let i = 0; i < shorter; i++) {
647
+ if (cmd[i] === c[i])
648
+ matches++;
649
+ }
650
+ return matches >= shorter * 0.5 && matches >= 3;
651
+ });
652
+ if (close.length > 0) {
653
+ ctx.onEvent({ kind: 'text_delta', text: `Unknown command: ${cmd}. Did you mean: ${close.slice(0, 3).join(', ')}?\n` });
654
+ emitDone(ctx);
655
+ return { handled: true };
656
+ }
657
+ // Truly unknown — pass through as regular input
658
+ return { handled: false };
659
+ }