codeep 2.15.0 → 2.16.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.
@@ -13,7 +13,8 @@ import { setProjectContext } from '../api/index.js';
13
13
  import { runSkill, runCommandChain } from './agentExecution.js';
14
14
  import { loadProjectIntelligence, saveProjectIntelligence } from '../utils/projectIntelligence.js';
15
15
  import { ollamaModelHint } from './ollamaHint.js';
16
- import { buildSearchSnippets, parseKeepRecent, joinSessionName, parseTaskAddArgs, formatTaskList } from './commands/helpers.js';
16
+ import { buildSearchSnippets, parseKeepRecent, joinSessionName, parseTaskAddArgs, formatTaskList, formatProfileList, formatMemoryList, formatStatsReport, extractCodeBlocks, resolveBlockIndex, extractFileChanges, formatApplyDiffLine, parsePromptArgs, formatMcpReloadReport, formatMcpResourcesList, formatMcpResourceRead, formatMcpPromptsList, formatMcpPromptResult, formatMcpServerList, parseInsightsDays, formatCloudSessionLabel, formatMeSyncReport, formatMeLearnResult, formatMeInitResult, formatSkillsShow, formatSkillsBrowseEmpty, formatSkillsPublishResult } from './commands/helpers.js';
17
+ import { resolveCommand } from './commands/registry.js';
17
18
  // ─── Helpers ──────────────────────────────────────────────────────────────────
18
19
  /**
19
20
  * Returns a hint for an Ollama model name based on parameter count.
@@ -21,13 +22,17 @@ import { buildSearchSnippets, parseKeepRecent, joinSessionName, parseTaskAddArgs
21
22
  */
22
23
  // ─── Main dispatch ────────────────────────────────────────────────────────────
23
24
  export async function handleCommand(command, args, ctx) {
25
+ // Resolve command aliases (e.g. `webcache` → `web-cache`) to their
26
+ // canonical name before dispatching.
27
+ const resolved = resolveCommand(command);
28
+ const canonical = resolved?.name ?? command;
24
29
  // Handle skill chaining (e.g., /commit+push)
25
- if (command.includes('+')) {
26
- const commands = command.split('+').filter(c => c.trim());
30
+ if (canonical.includes('+')) {
31
+ const commands = canonical.split('+').filter(c => c.trim());
27
32
  runCommandChain(commands, 0, ctx);
28
33
  return;
29
34
  }
30
- switch (command) {
35
+ switch (canonical) {
31
36
  case 'version': {
32
37
  const version = getCurrentVersion();
33
38
  const provider = getCurrentProvider();
@@ -308,7 +313,7 @@ export async function handleCommand(command, args, ctx) {
308
313
  ctx.app.notify('Thinking effort: auto — each model uses its own default.');
309
314
  }
310
315
  else if (!supported) {
311
- ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 4.8, GPT-5.x, Gemini 3, DeepSeek V4, GLM-5.2).`);
316
+ ctx.app.notify(`Thinking effort set to "${sub}", but ${model} has no graded thinking control — it will be ignored until you switch to a model that does (e.g. Opus 5, GPT-5.x, Gemini 3, DeepSeek V4, GLM-5.2).`);
312
317
  }
313
318
  else {
314
319
  // Tell the user what THIS model will actually run (the tier may
@@ -424,21 +429,7 @@ export async function handleCommand(command, args, ctx) {
424
429
  }
425
430
  case 'insights': {
426
431
  const { formatInsights } = await import('../utils/insights.js');
427
- // Parse `--days N` (default 7). Accept both `--days 30` and `--days=30`.
428
- let days = 7;
429
- for (let i = 0; i < args.length; i++) {
430
- const a = args[i];
431
- if (a === '--days' && args[i + 1]) {
432
- const n = parseInt(args[i + 1], 10);
433
- if (Number.isFinite(n))
434
- days = n;
435
- }
436
- else if (a.startsWith('--days=')) {
437
- const n = parseInt(a.slice('--days='.length), 10);
438
- if (Number.isFinite(n))
439
- days = n;
440
- }
441
- }
432
+ const days = parseInsightsDays(args);
442
433
  ctx.app.addMessage({ role: 'system', content: formatInsights({ days }) });
443
434
  break;
444
435
  }
@@ -513,12 +504,7 @@ export async function handleCommand(command, args, ctx) {
513
504
  break;
514
505
  }
515
506
  const file = scope === 'global' ? '~/.codeep/profile.learned.md' : '.codeep/profile.learned.md';
516
- ctx.app.addMessage({
517
- role: 'system',
518
- content: res.updated
519
- ? `Updated your ${scope} learned profile (\`${file}\`):\n\n${res.facts}\n\nClear it anytime with \`/me forget\`.`
520
- : `No changes — your ${scope} learned profile already covers this:\n\n${res.facts}`,
521
- });
507
+ ctx.app.addMessage({ role: 'system', content: formatMeLearnResult(scope, file, res) });
522
508
  break;
523
509
  }
524
510
  if (sub === 'forget') {
@@ -537,14 +523,7 @@ export async function handleCommand(command, args, ctx) {
537
523
  ctx.app.notify('Syncing your profile with codeep.dev…');
538
524
  const pushed = await pushUserProfile();
539
525
  const pulled = await pullUserProfile();
540
- const lines = [];
541
- if (pushed)
542
- lines.push('✓ Profile pushed to the dashboard');
543
- if (pulled === 1)
544
- lines.push('✓ Profile pulled to this machine');
545
- if (lines.length === 0)
546
- lines.push('Nothing to sync yet — run `/me init` and fill in your profile first.');
547
- ctx.app.addMessage({ role: 'system', content: `## Profile sync\n\n${lines.join('\n')}` });
526
+ ctx.app.addMessage({ role: 'system', content: formatMeSyncReport(pushed, pulled) });
548
527
  break;
549
528
  }
550
529
  if (sub === 'init') {
@@ -558,12 +537,7 @@ export async function handleCommand(command, args, ctx) {
558
537
  ctx.app.notify('Could not create the profile file.');
559
538
  break;
560
539
  }
561
- ctx.app.addMessage({
562
- role: 'system',
563
- content: res.created
564
- ? `Created ${scope} profile: \`${res.path}\`\n\nEdit it in your editor — Codeep uses it automatically. View anytime with \`/me\`.`
565
- : `${scope === 'global' ? 'Global' : 'Project'} profile already exists: \`${res.path}\`\n\nEdit it directly, or view it with \`/me\`.`,
566
- });
540
+ ctx.app.addMessage({ role: 'system', content: formatMeInitResult(scope, res) });
567
541
  break;
568
542
  }
569
543
  // Default: show the profile view.
@@ -710,15 +684,8 @@ export async function handleCommand(command, args, ctx) {
710
684
  // Non-null binding for the picker closure — TS can't carry the null
711
685
  // narrowing of a reassigned `let` into the callback.
712
686
  let sessionList = summaries;
713
- // Render each row as: title · date · N msg · [project?]
714
- // Mirrors the /sessions list layout so the picker feels familiar.
715
687
  const SHOW_ALL = 'Show all cloud sessions…';
716
- const labels = summaries.map(s => {
717
- const date = new Date(s.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
718
- const title = s.sessionName || s.sessionId.slice(0, 8);
719
- const projectTag = s.projectName ? ` · ${s.projectName}` : '';
720
- return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
721
- });
688
+ const labels = summaries.map(formatCloudSessionLabel);
722
689
  if (scopedToProject)
723
690
  labels.push(SHOW_ALL);
724
691
  // Named so the "Show all" branch can re-present the picker with the
@@ -1186,18 +1153,13 @@ Format: use headers per category, only include categories where you found issues
1186
1153
  case 'copy': {
1187
1154
  const blockNum = args[0] ? parseInt(args[0], 10) : -1;
1188
1155
  const messages = ctx.app.getMessages();
1189
- const codeBlocks = [];
1190
- for (const msg of messages) {
1191
- for (const match of msg.content.matchAll(/```[\w]*\n([\s\S]*?)```/g)) {
1192
- codeBlocks.push(match[1]);
1193
- }
1194
- }
1156
+ const codeBlocks = messages.flatMap(m => extractCodeBlocks(m.content));
1195
1157
  if (codeBlocks.length === 0) {
1196
1158
  ctx.app.notify('No code blocks found');
1197
1159
  return;
1198
1160
  }
1199
- const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
1200
- if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
1161
+ const index = resolveBlockIndex(blockNum, codeBlocks.length);
1162
+ if (index === null) {
1201
1163
  ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
1202
1164
  return;
1203
1165
  }
@@ -1235,20 +1197,7 @@ Format: use headers per category, only include categories where you found issues
1235
1197
  ctx.app.notify('No assistant response to apply');
1236
1198
  return;
1237
1199
  }
1238
- const changes = [];
1239
- const fenceFilePattern = /```\w*\s+([\w./\\-]+(?:\.\w+))\n([\s\S]*?)```/g;
1240
- let match;
1241
- while ((match = fenceFilePattern.exec(lastAssistant.content)) !== null) {
1242
- const p = match[1].trim();
1243
- if (p.includes('.') && !p.includes(' '))
1244
- changes.push({ path: p, content: match[2] });
1245
- }
1246
- if (changes.length === 0) {
1247
- const commentPattern = /```(\w+)?\s*\n(?:\/\/|#|--|\/\*)\s*(?:File|Path|file|path):\s*([^\n*]+)\n([\s\S]*?)```/g;
1248
- while ((match = commentPattern.exec(lastAssistant.content)) !== null) {
1249
- changes.push({ path: match[2].trim(), content: match[3] });
1250
- }
1251
- }
1200
+ const changes = extractFileChanges(lastAssistant.content);
1252
1201
  if (changes.length === 0) {
1253
1202
  ctx.app.notify('No file changes found in response');
1254
1203
  return;
@@ -1257,64 +1206,168 @@ Format: use headers per category, only include categories where you found issues
1257
1206
  ctx.app.notify('Write access required. Use /grant first.');
1258
1207
  return;
1259
1208
  }
1209
+ // Parse optional selective hunk spec: /apply --only file.ts:0,1 other.ts:2
1210
+ // Without --only, all hunks are applied (existing behavior).
1211
+ const selective = args.includes('--only') || args.includes('-o');
1212
+ const interactive = args.includes('--interactive') || args.includes('-i');
1213
+ const hunkSpecs = new Map();
1214
+ if (selective) {
1215
+ for (const a of args) {
1216
+ if (a === '--only' || a === '-o')
1217
+ continue;
1218
+ const m = a.match(/^(.+):([\d,]+)$/);
1219
+ if (m) {
1220
+ const [, file, idxStr] = m;
1221
+ const idxs = new Set(idxStr.split(',').map((n) => parseInt(n, 10)).filter((n) => !isNaN(n)));
1222
+ hunkSpecs.set(file, idxs);
1223
+ }
1224
+ }
1225
+ // A spec that parsed to nothing must NOT fall through to the
1226
+ // apply-everything branch below — the user asked to restrict the
1227
+ // apply, so writing every change is the opposite of the request.
1228
+ if (hunkSpecs.size === 0) {
1229
+ ctx.app.notify('Invalid --only spec. Expected `--only <file>:<hunk>[,<hunk>]` (e.g. --only src/a.ts:0,2). Nothing applied.');
1230
+ return;
1231
+ }
1232
+ }
1260
1233
  import('fs').then(async (fs) => {
1261
1234
  import('path').then(async (pathModule) => {
1235
+ const { createFileDiff, applyHunksToFiles, countChangeHunks } = await import('../utils/diffPreview.js');
1262
1236
  const diffLines = [];
1237
+ const fileDiffs = [];
1263
1238
  for (const change of changes) {
1264
1239
  const fullPath = pathModule.isAbsolute(change.path)
1265
1240
  ? change.path
1266
1241
  : pathModule.join(ctx.projectPath, change.path);
1267
- const shortPath = change.path.length > 40 ? '...' + change.path.slice(-37) : change.path;
1268
1242
  let existingContent = '';
1269
1243
  try {
1270
1244
  existingContent = await fs.promises.readFile(fullPath, 'utf-8');
1271
1245
  }
1272
1246
  catch { }
1273
- if (!existingContent) {
1274
- diffLines.push(`+ CREATE: ${shortPath}`);
1275
- diffLines.push(` (${change.content.split('\n').length} lines)`);
1247
+ const fd = createFileDiff(change.path, change.content, ctx.projectPath);
1248
+ fileDiffs.push(fd);
1249
+ const hunkCount = countChangeHunks(fd);
1250
+ diffLines.push(...formatApplyDiffLine(change, existingContent));
1251
+ if (hunkCount > 0) {
1252
+ diffLines.push(` ↳ ${hunkCount} hunk(s) — use /apply --only ${change.path}:0,1 to select`);
1276
1253
  }
1277
- else {
1278
- const oldLines = existingContent.split('\n').length;
1279
- const newLines = change.content.split('\n').length;
1280
- const lineDiff = newLines - oldLines;
1281
- diffLines.push(`~ MODIFY: ${shortPath}`);
1282
- diffLines.push(` ${oldLines} → ${newLines} lines (${lineDiff >= 0 ? '+' : ''}${lineDiff})`);
1254
+ }
1255
+ // Interactive mode: open the hunk picker (`git add -p` style).
1256
+ if (interactive) {
1257
+ const items = [];
1258
+ for (const fd of fileDiffs) {
1259
+ for (let hi = 0; hi < fd.hunks.length; hi++) {
1260
+ const hunk = fd.hunks[hi];
1261
+ // Skip pure-context hunks (no add/remove).
1262
+ if (!hunk.lines.some((l) => l.type === 'add' || l.type === 'remove'))
1263
+ continue;
1264
+ const header = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
1265
+ const lines = hunk.lines.map((l) => {
1266
+ if (l.type === 'add')
1267
+ return `+${l.content}`;
1268
+ if (l.type === 'remove')
1269
+ return `-${l.content}`;
1270
+ return ` ${l.content}`;
1271
+ });
1272
+ items.push({ path: fd.path, hunkIndex: hi, header, lines });
1273
+ }
1274
+ }
1275
+ if (items.length === 0) {
1276
+ ctx.app.notify('No change hunks to review');
1277
+ return;
1283
1278
  }
1279
+ ctx.app.showHunkPicker({
1280
+ title: '🎯 Review hunks',
1281
+ items,
1282
+ onComplete: (accepted) => {
1283
+ if (accepted.length === 0) {
1284
+ ctx.app.notify('No hunks applied');
1285
+ return;
1286
+ }
1287
+ (async () => {
1288
+ // Group accepted hunk indices by file path.
1289
+ const byPath = new Map();
1290
+ for (const a of accepted) {
1291
+ let set = byPath.get(a.path);
1292
+ if (!set) {
1293
+ set = new Set();
1294
+ byPath.set(a.path, set);
1295
+ }
1296
+ set.add(a.hunkIndex);
1297
+ }
1298
+ const results = applyHunksToFiles(fileDiffs, byPath);
1299
+ let applied = 0;
1300
+ for (const r of results) {
1301
+ try {
1302
+ const fullPath = pathModule.isAbsolute(r.path)
1303
+ ? r.path
1304
+ : pathModule.join(ctx.projectPath, r.path);
1305
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1306
+ await fs.promises.writeFile(fullPath, r.content);
1307
+ applied++;
1308
+ }
1309
+ catch { }
1310
+ }
1311
+ ctx.app.notify(`Applied ${accepted.length} hunk(s) across ${applied} file(s)`);
1312
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1313
+ },
1314
+ });
1315
+ return;
1284
1316
  }
1317
+ const summary = selective && hunkSpecs.size > 0
1318
+ ? `Selective apply (${hunkSpecs.size} file(s) with chosen hunks)`
1319
+ : `Found ${changes.length} file(s) to apply`;
1285
1320
  ctx.app.showConfirm({
1286
1321
  title: '📝 Apply Changes',
1287
1322
  message: [
1288
- `Found ${changes.length} file(s) to apply:`,
1323
+ summary,
1289
1324
  '',
1290
- ...diffLines.slice(0, 10),
1291
- ...(diffLines.length > 10 ? [` ...and ${diffLines.length - 10} more`] : []),
1325
+ ...diffLines.slice(0, 12),
1326
+ ...(diffLines.length > 12 ? [` ...and ${diffLines.length - 12} more`] : []),
1292
1327
  '',
1293
- 'Apply these changes?',
1328
+ selective && hunkSpecs.size > 0 ? 'Apply selected hunks?' : 'Apply these changes?',
1294
1329
  ],
1295
1330
  confirmLabel: 'Apply',
1296
1331
  cancelLabel: 'Cancel',
1297
1332
  onConfirm: () => {
1298
1333
  (async () => {
1299
1334
  let applied = 0;
1300
- for (const change of changes) {
1301
- try {
1302
- const fullPath = pathModule.isAbsolute(change.path)
1303
- ? change.path
1304
- : pathModule.join(ctx.projectPath, change.path);
1305
- await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1306
- await fs.promises.writeFile(fullPath, change.content);
1307
- applied++;
1335
+ if (selective && hunkSpecs.size > 0) {
1336
+ // Per-hunk selective apply.
1337
+ const results = applyHunksToFiles(fileDiffs, hunkSpecs);
1338
+ for (const r of results) {
1339
+ try {
1340
+ const fullPath = pathModule.isAbsolute(r.path)
1341
+ ? r.path
1342
+ : pathModule.join(ctx.projectPath, r.path);
1343
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1344
+ await fs.promises.writeFile(fullPath, r.content);
1345
+ applied++;
1346
+ }
1347
+ catch { }
1308
1348
  }
1309
- catch { }
1310
1349
  }
1311
- ctx.app.notify(`Applied ${applied}/${changes.length} file(s)`);
1312
- })();
1350
+ else {
1351
+ // All-or-nothing apply (original behavior).
1352
+ for (const change of changes) {
1353
+ try {
1354
+ const fullPath = pathModule.isAbsolute(change.path)
1355
+ ? change.path
1356
+ : pathModule.join(ctx.projectPath, change.path);
1357
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1358
+ await fs.promises.writeFile(fullPath, change.content);
1359
+ applied++;
1360
+ }
1361
+ catch { }
1362
+ }
1363
+ }
1364
+ ctx.app.notify(`Applied ${applied}/${selective ? hunkSpecs.size : changes.length} file(s)`);
1365
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1313
1366
  },
1314
1367
  onCancel: () => ctx.app.notify('Apply cancelled'),
1315
1368
  });
1316
1369
  });
1317
- });
1370
+ }).catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1318
1371
  break;
1319
1372
  }
1320
1373
  case 'add': {
@@ -1769,10 +1822,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1769
1822
  ctx.app.notify(`Skill ${name} not found`);
1770
1823
  break;
1771
1824
  }
1772
- ctx.app.addMessage({
1773
- role: 'system',
1774
- content: `# ${bundle.name}\n_${bundle.description}_\n\n**Source:** ${bundle.source}\n\n---\n\n${bundle.body}`,
1775
- });
1825
+ ctx.app.addMessage({ role: 'system', content: formatSkillsShow(bundle) });
1776
1826
  break;
1777
1827
  }
1778
1828
  // Marketplace operations against codeep.dev.
@@ -1790,10 +1840,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1790
1840
  ctx.app.notify(`Publish failed: ${result.error}`);
1791
1841
  break;
1792
1842
  }
1793
- ctx.app.addMessage({
1794
- role: 'system',
1795
- content: `Published \`${slug}\` (${isPublic ? 'public' : 'private'}) to codeep.dev. Install elsewhere with \`/skills install ${result.skill?.owner_username ?? '<you>'}/${slug}\`.`,
1796
- });
1843
+ ctx.app.addMessage({ role: 'system', content: formatSkillsPublishResult(slug, isPublic, result.skill?.owner_username) });
1797
1844
  break;
1798
1845
  }
1799
1846
  if (sub === 'install') {
@@ -1823,7 +1870,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1823
1870
  }
1824
1871
  const skills = result.skills ?? [];
1825
1872
  if (skills.length === 0) {
1826
- ctx.app.addMessage({ role: 'system', content: query ? `_No public skills matching "${query}"._` : '_No public skills published yet._' });
1873
+ ctx.app.addMessage({ role: 'system', content: formatSkillsBrowseEmpty(query) });
1827
1874
  break;
1828
1875
  }
1829
1876
  const lines = [`# ${query ? `Skills matching "${query}"` : 'Public skills'}`, ''];
@@ -2074,7 +2121,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2074
2121
  ctx.app.notify('No profiles saved. Use /profile save <name>');
2075
2122
  }
2076
2123
  else {
2077
- ctx.app.addMessage({ role: 'system', content: `## Profiles\n\n${profiles.map(p => `- ${p}`).join('\n')}\n\nUse /profile load <name> to apply.` });
2124
+ ctx.app.addMessage({ role: 'system', content: formatProfileList(profiles) });
2078
2125
  }
2079
2126
  break;
2080
2127
  }
@@ -2215,54 +2262,15 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2215
2262
  case 'stats': {
2216
2263
  const { getCostBreakdown, getSessionStats, formatTokenCount, getPricingTable, getCacheStats } = await import('../utils/tokenTracker.js');
2217
2264
  const stats = getSessionStats();
2218
- const lines = ['## Session Cost', ''];
2219
- if (stats.requestCount === 0) {
2220
- lines.push('*No API calls made yet this session.*');
2221
- lines.push('');
2222
- }
2223
- else {
2224
- lines.push(`Requests: ${stats.requestCount}`);
2225
- lines.push(`Tokens: ${formatTokenCount(stats.totalTokens)} total (${formatTokenCount(stats.totalPromptTokens)} in / ${formatTokenCount(stats.totalCompletionTokens)} out)`);
2226
- const breakdown = getCostBreakdown();
2227
- if (breakdown.length > 0) {
2228
- lines.push('');
2229
- lines.push('### By model');
2230
- for (const b of breakdown) {
2231
- const isFree = b.provider === 'ollama';
2232
- const costStr = isFree ? 'free' : b.estimatedCost > 0 ? `~$${b.estimatedCost.toFixed(4)}` : '(no pricing data)';
2233
- lines.push(`- **${b.model}** (${b.provider}): ${formatTokenCount(b.promptTokens)} in / ${formatTokenCount(b.completionTokens)} out — ${costStr}`);
2234
- }
2235
- lines.push('');
2236
- const currentProvider = config.get('provider');
2237
- if (currentProvider === 'ollama') {
2238
- lines.push(`**Total: free · ${formatTokenCount(stats.totalTokens)} tokens**`);
2239
- }
2240
- else if (stats.estimatedCost > 0) {
2241
- lines.push(`**Total: ~$${stats.estimatedCost.toFixed(4)}**`);
2242
- }
2243
- }
2244
- // Prompt caching — parity with /cost (the 2.0.2 caching section was
2245
- // only wired into formatCostReport). Shown only when caching landed.
2246
- const cache = getCacheStats();
2247
- if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
2248
- lines.push('', '### Prompt caching');
2249
- lines.push(`Cache reads: ${formatTokenCount(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
2250
- if (cache.cacheCreationTokens > 0) {
2251
- lines.push(`Cache writes: ${formatTokenCount(cache.cacheCreationTokens)} tokens (billed at 1.25× input rate)`);
2252
- }
2253
- if (cache.estimatedSavingsUsd > 0) {
2254
- lines.push(`Estimated savings vs no caching: $${cache.estimatedSavingsUsd.toFixed(4)}`);
2255
- }
2256
- }
2257
- lines.push('');
2258
- }
2259
- lines.push('### Pricing (per 1M tokens)');
2260
- lines.push('| Model | Input | Output |');
2261
- lines.push('|---|---|---|');
2262
- for (const p of getPricingTable()) {
2263
- lines.push(`| ${p.model} | $${p.inputPer1M.toFixed(3)} | $${p.outputPer1M.toFixed(3)} |`);
2264
- }
2265
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2265
+ const content = formatStatsReport({
2266
+ totals: stats,
2267
+ breakdown: getCostBreakdown(),
2268
+ cache: getCacheStats(),
2269
+ pricing: getPricingTable(),
2270
+ currentProvider: config.get('provider'),
2271
+ fmt: formatTokenCount,
2272
+ });
2273
+ ctx.app.addMessage({ role: 'system', content });
2266
2274
  break;
2267
2275
  }
2268
2276
  case 'memory': {
@@ -2280,8 +2288,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2280
2288
  ctx.app.notify('No memory notes. Add one with: /memory <note>');
2281
2289
  }
2282
2290
  else {
2283
- const lines = intelligence.notes.map((n, i) => ` ${i + 1}. ${n}`).join('\n');
2284
- ctx.app.addMessage({ role: 'assistant', content: `**Project memory notes:**\n${lines}` });
2291
+ ctx.app.addMessage({ role: 'assistant', content: formatMemoryList(intelligence.notes) });
2285
2292
  }
2286
2293
  break;
2287
2294
  }
@@ -2322,6 +2329,29 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2322
2329
  ctx.app.addMessage({ role: 'system', content: formatCommandList(loadCustomCommands(ctx.projectPath)) });
2323
2330
  break;
2324
2331
  }
2332
+ case 'web-cache': {
2333
+ const sub = args[0]?.toLowerCase();
2334
+ const { clearWebCache, webCacheStats } = await import('../utils/webFetch.js');
2335
+ if (sub === 'clear' || sub === 'reset' || sub === 'flush') {
2336
+ clearWebCache();
2337
+ ctx.app.notify('Web cache cleared');
2338
+ }
2339
+ else {
2340
+ const stats = webCacheStats();
2341
+ ctx.app.addMessage({
2342
+ role: 'system',
2343
+ content: [
2344
+ '🌐 Web fetch cache',
2345
+ '',
2346
+ ` Entries: ${stats.entries}/${stats.maxEntries}`,
2347
+ ` TTL: ${stats.ttlMinutes} min`,
2348
+ '',
2349
+ 'Usage: /web-cache clear',
2350
+ ].join('\n'),
2351
+ });
2352
+ }
2353
+ break;
2354
+ }
2325
2355
  case 'mcp': {
2326
2356
  // Mirrors the ACP `/mcp` handler in src/acp/commands.ts. In TUI the
2327
2357
  // session id is the constant `codeep-tui` (the same one main.ts uses
@@ -2480,35 +2510,14 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2480
2510
  ctx.app.notify('Reloading MCP server config…');
2481
2511
  const merged = loadMcpServerConfig(projectPath);
2482
2512
  const { registered, errors } = await registerSessionServers(TUI_SESSION, merged, { workspaceRoot: projectPath });
2483
- const lines = [`## MCP reloaded`, '', `**${registered.length}** tool${registered.length === 1 ? '' : 's'} from **${merged.length}** server${merged.length === 1 ? '' : 's'}.`];
2484
- if (errors.length > 0) {
2485
- lines.push('', '### Failed servers');
2486
- for (const e of errors)
2487
- lines.push(`- **${e.server}** — \`${e.error}\``);
2488
- }
2489
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2513
+ ctx.app.addMessage({ role: 'system', content: formatMcpReloadReport(registered.length, merged.length, errors) });
2490
2514
  break;
2491
2515
  }
2492
2516
  if (sub === 'resources') {
2493
2517
  const { getSessionResources, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2494
2518
  await awaitSessionReady(TUI_SESSION);
2495
2519
  const groups = await getSessionResources(TUI_SESSION);
2496
- if (groups.length === 0) {
2497
- ctx.app.addMessage({ role: 'system', content: '_No MCP server in this session exposes resources._' });
2498
- break;
2499
- }
2500
- const lines = ['## MCP resources', ''];
2501
- for (const g of groups) {
2502
- lines.push(`**${g.serverName}** — ${g.resources.length} resource${g.resources.length === 1 ? '' : 's'}`);
2503
- for (const r of g.resources) {
2504
- const label = r.name ? `${r.name} — ` : '';
2505
- const mime = r.mimeType ? ` (${r.mimeType})` : '';
2506
- lines.push(`- ${label}\`${r.uri}\`${mime}${r.description ? ` — ${r.description}` : ''}`);
2507
- }
2508
- lines.push('');
2509
- }
2510
- lines.push('Read one with `/mcp read <uri>`.');
2511
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2520
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourcesList(groups) });
2512
2521
  break;
2513
2522
  }
2514
2523
  if (sub === 'read') {
@@ -2520,23 +2529,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2520
2529
  const { readSessionResource } = await import('../utils/mcpRegistry.js');
2521
2530
  try {
2522
2531
  const contents = await readSessionResource(TUI_SESSION, uri);
2523
- if (contents.length === 0) {
2524
- ctx.app.addMessage({ role: 'system', content: `_No content returned for \`${uri}\`._` });
2525
- break;
2526
- }
2527
- const lines = [`## Resource: \`${uri}\``, ''];
2528
- for (const c of contents) {
2529
- if (c.text !== undefined) {
2530
- const fence = c.mimeType?.includes('json') ? 'json' : c.mimeType?.includes('markdown') ? 'markdown' : '';
2531
- lines.push('```' + fence);
2532
- lines.push(c.text);
2533
- lines.push('```');
2534
- }
2535
- else if (c.blob) {
2536
- lines.push(`_(${c.mimeType ?? 'binary'} blob, ${c.blob.length} base64 chars — not rendered)_`);
2537
- }
2538
- }
2539
- ctx.app.addMessage({ role: 'system', content: lines.join('\n') });
2532
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourceRead(uri, contents) });
2540
2533
  }
2541
2534
  catch (err) {
2542
2535
  ctx.app.addMessage({ role: 'system', content: `Failed to read \`${uri}\`: ${err.message}` });
@@ -2547,23 +2540,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2547
2540
  const { getSessionPrompts, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2548
2541
  await awaitSessionReady(TUI_SESSION);
2549
2542
  const groups = await getSessionPrompts(TUI_SESSION);
2550
- if (groups.length === 0) {
2551
- ctx.app.addMessage({ role: 'system', content: '_No MCP server in this session exposes prompt templates._' });
2552
- break;
2553
- }
2554
- const lines = ['## MCP prompt templates', ''];
2555
- for (const g of groups) {
2556
- lines.push(`**${g.serverName}** — ${g.prompts.length} prompt${g.prompts.length === 1 ? '' : 's'}`);
2557
- for (const p of g.prompts) {
2558
- const argList = p.arguments?.length
2559
- ? ` (${p.arguments.map(a => a.required ? a.name : `[${a.name}]`).join(', ')})`
2560
- : '';
2561
- lines.push(`- \`${p.name}\`${argList}${p.description ? ` — ${p.description}` : ''}`);
2562
- }
2563
- lines.push('');
2564
- }
2565
- lines.push('Materialise one with `/mcp prompt <server> <name> [key=value...]`.');
2566
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2543
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptsList(groups) });
2567
2544
  break;
2568
2545
  }
2569
2546
  if (sub === 'prompt') {
@@ -2573,25 +2550,11 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2573
2550
  ctx.app.addMessage({ role: 'system', content: 'Usage: `/mcp prompt <server> <name> [key=value ...]`' });
2574
2551
  break;
2575
2552
  }
2576
- const promptArgs = {};
2577
- for (const tok of args.slice(3)) {
2578
- const eq = tok.indexOf('=');
2579
- if (eq > 0)
2580
- promptArgs[tok.slice(0, eq)] = tok.slice(eq + 1);
2581
- }
2553
+ const promptArgs = parsePromptArgs(args.slice(3));
2582
2554
  const { getSessionPrompt } = await import('../utils/mcpRegistry.js');
2583
2555
  try {
2584
2556
  const { description, messages } = await getSessionPrompt(TUI_SESSION, serverName, name, promptArgs);
2585
- const lines = [`## Prompt \`${serverName}/${name}\``];
2586
- if (description)
2587
- lines.push(`_${description}_`);
2588
- lines.push('');
2589
- for (const m of messages) {
2590
- const text = typeof m.content?.text === 'string' ? m.content.text : JSON.stringify(m.content);
2591
- lines.push(`**${m.role}:** ${text}`);
2592
- lines.push('');
2593
- }
2594
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2557
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptResult(serverName, name, description, messages) });
2595
2558
  }
2596
2559
  catch (err) {
2597
2560
  ctx.app.addMessage({ role: 'system', content: `Failed to materialise prompt: ${err.message}` });
@@ -2603,41 +2566,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2603
2566
  await awaitSessionReady(TUI_SESSION);
2604
2567
  const tools = await getSessionTools(TUI_SESSION);
2605
2568
  const mcpErrors = getSessionRegistrationErrors(TUI_SESSION);
2606
- if (tools.length === 0 && mcpErrors.length === 0) {
2607
- ctx.app.addMessage({
2608
- role: 'system',
2609
- content: [
2610
- '_No MCP servers connected to this session._',
2611
- '',
2612
- 'Add one with `/mcp add <name> <command> [args...]` — it persists to `.codeep/mcp_servers.json`.',
2613
- 'Or browse the marketplace with `/mcp browse` and install with `/mcp install <id>`.',
2614
- ].join('\n'),
2615
- });
2616
- break;
2617
- }
2618
- const lines = ['## MCP servers', ''];
2619
- if (tools.length > 0) {
2620
- const byServer = new Map();
2621
- for (const t of tools) {
2622
- if (!byServer.has(t.serverName))
2623
- byServer.set(t.serverName, []);
2624
- byServer.get(t.serverName).push(t);
2625
- }
2626
- for (const [serverName, serverTools] of byServer) {
2627
- lines.push(`**${serverName}** — ${serverTools.length} tool${serverTools.length === 1 ? '' : 's'}`);
2628
- for (const t of serverTools) {
2629
- const desc = t.description ? ` — ${t.description}` : '';
2630
- lines.push(`- \`${t.agentName}\`${desc}`);
2631
- }
2632
- lines.push('');
2633
- }
2634
- }
2635
- if (mcpErrors.length > 0) {
2636
- lines.push('### Failed servers');
2637
- for (const e of mcpErrors)
2638
- lines.push(`- **${e.server}** — \`${e.error}\``);
2639
- }
2640
- ctx.app.addMessage({ role: 'system', content: lines.join('\n').trim() });
2569
+ ctx.app.addMessage({ role: 'system', content: formatMcpServerList(tools, mcpErrors) });
2641
2570
  break;
2642
2571
  }
2643
2572
  default: {