codeep 2.15.0 → 2.17.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 (41) hide show
  1. package/README.md +41 -7
  2. package/dist/acp/serverHandlers.js +1 -1
  3. package/dist/acp/session.js +22 -1
  4. package/dist/config/index.js +20 -4
  5. package/dist/config/providers.d.ts +3 -2
  6. package/dist/config/providers.js +163 -69
  7. package/dist/renderer/App.d.ts +89 -0
  8. package/dist/renderer/App.js +637 -43
  9. package/dist/renderer/Screen.d.ts +1 -0
  10. package/dist/renderer/Screen.js +8 -3
  11. package/dist/renderer/commands/helpers.d.ts +189 -0
  12. package/dist/renderer/commands/helpers.js +345 -0
  13. package/dist/renderer/commands/registry.js +2 -1
  14. package/dist/renderer/commands.js +218 -267
  15. package/dist/renderer/components/AgentTimeline.d.ts +44 -0
  16. package/dist/renderer/components/AgentTimeline.js +157 -0
  17. package/dist/renderer/components/Autocomplete.d.ts +25 -0
  18. package/dist/renderer/components/Autocomplete.js +35 -0
  19. package/dist/renderer/components/Status.d.ts +2 -0
  20. package/dist/renderer/layout.d.ts +5 -1
  21. package/dist/renderer/layout.js +12 -0
  22. package/dist/renderer/main.js +110 -30
  23. package/dist/utils/agent.js +1 -1
  24. package/dist/utils/agents.d.ts +1 -1
  25. package/dist/utils/agents.js +1 -1
  26. package/dist/utils/checkpoints.d.ts +1 -1
  27. package/dist/utils/checkpoints.js +1 -1
  28. package/dist/utils/diffPreview.d.ts +31 -0
  29. package/dist/utils/diffPreview.js +102 -0
  30. package/dist/utils/git.d.ts +28 -0
  31. package/dist/utils/git.js +111 -1
  32. package/dist/utils/mentions.d.ts +195 -0
  33. package/dist/utils/mentions.js +672 -0
  34. package/dist/utils/resourceImpact.d.ts +25 -0
  35. package/dist/utils/resourceImpact.js +54 -0
  36. package/dist/utils/tokenTracker.js +52 -37
  37. package/dist/utils/webFetch.d.ts +101 -0
  38. package/dist/utils/webFetch.js +375 -0
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +2 -1
@@ -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();
@@ -161,6 +166,26 @@ export async function handleCommand(command, args, ctx) {
161
166
  });
162
167
  break;
163
168
  }
169
+ if (providerId === 'modelscope') {
170
+ ctx.app.notify('Fetching ModelScope catalog…');
171
+ const { fetchOpenAiCompatibleModels, getApiKey: _getKey } = await import('../config/index.js');
172
+ const base = 'https://api-inference.modelscope.cn/v1';
173
+ const models = await fetchOpenAiCompatibleModels(base, _getKey('modelscope') || undefined);
174
+ const fallback = getModelsForCurrentProvider();
175
+ const available = models && models.length > 0
176
+ ? models
177
+ : Object.keys(fallback).map(id => ({ id, name: id, description: 'Built-in fallback' }));
178
+ if (!models || models.length === 0) {
179
+ ctx.app.notify('Could not fetch the ModelScope catalog. Using the built-in fallback model.');
180
+ }
181
+ const modelItems = available.map(m => ({ key: m.id, label: m.name, description: m.description }));
182
+ const currentModel = config.get('model');
183
+ ctx.app.showSelect(`Select ModelScope Model (${available.length})`, modelItems, currentModel, (item) => {
184
+ config.set('model', item.key);
185
+ ctx.app.notify(`Model: ${item.key}`);
186
+ });
187
+ break;
188
+ }
164
189
  if (providerId === 'custom') {
165
190
  const base = config.get('customBaseUrl') || 'http://localhost:8000/v1';
166
191
  ctx.app.notify(`Fetching models from ${base}…`);
@@ -299,7 +324,7 @@ export async function handleCommand(command, args, ctx) {
299
324
  const providerId = config.get('provider');
300
325
  const model = config.get('model');
301
326
  const supported = modelSupportsReasoningEffort(providerId, model);
302
- // Tiers THIS model actually distinguishes (e.g. GLM-5.2 → auto/high/max).
327
+ // Tiers THIS model actually distinguishes (e.g. Kimi K3 → auto/low/high/max).
303
328
  const available = availableReasoningTiers(providerId, model);
304
329
  const sub = args[0]?.toLowerCase();
305
330
  if (sub && REASONING_TIERS.includes(sub)) {
@@ -308,11 +333,11 @@ export async function handleCommand(command, args, ctx) {
308
333
  ctx.app.notify('Thinking effort: auto — each model uses its own default.');
309
334
  }
310
335
  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).`);
336
+ 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, Kimi K3).`);
312
337
  }
313
338
  else {
314
339
  // Tell the user what THIS model will actually run (the tier may
315
- // collapse onto a level the model distinguishes, e.g. low→high on GLM).
340
+ // collapse onto a level the model distinguishes, e.g. medium→high on Kimi K3).
316
341
  const resolved = resolveReasoningTier(providerId, model, sub);
317
342
  const note = resolved === sub ? '' : ` (${model} runs this as "${resolved}")`;
318
343
  ctx.app.notify(`Thinking effort: ${sub}${note} — sending ${JSON.stringify(reasoningParamsFor(providerId, model, sub))}.`);
@@ -341,7 +366,7 @@ export async function handleCommand(command, args, ctx) {
341
366
  if (supported)
342
367
  tLines.push(`**Available** ${available.join(' · ')}`);
343
368
  tLines.push('');
344
- tLines.push('Sets how hard the model reasons. Each model offers only the levels it distinguishes (GLM-5.2 / DeepSeek → high · max; Gemini → low · high; Opus/Sonnet & GPT-5.x → the full set). The setting is global and clamps to the active model, so it never sends a value the API rejects. `/effort` is an alias.');
369
+ tLines.push('Sets how hard the model reasons. Each model offers only the levels it distinguishes (DeepSeek → high · max; Kimi K3 → low · high · max; Gemini → low · high; Opus/Sonnet & GPT-5.x → the full set). The setting is global and clamps to the active model, so it never sends a value the API rejects. `/effort` is an alias.');
345
370
  ctx.app.addMessage({ role: 'system', content: tLines.join('\n') });
346
371
  break;
347
372
  }
@@ -424,21 +449,7 @@ export async function handleCommand(command, args, ctx) {
424
449
  }
425
450
  case 'insights': {
426
451
  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
- }
452
+ const days = parseInsightsDays(args);
442
453
  ctx.app.addMessage({ role: 'system', content: formatInsights({ days }) });
443
454
  break;
444
455
  }
@@ -513,12 +524,7 @@ export async function handleCommand(command, args, ctx) {
513
524
  break;
514
525
  }
515
526
  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
- });
527
+ ctx.app.addMessage({ role: 'system', content: formatMeLearnResult(scope, file, res) });
522
528
  break;
523
529
  }
524
530
  if (sub === 'forget') {
@@ -537,14 +543,7 @@ export async function handleCommand(command, args, ctx) {
537
543
  ctx.app.notify('Syncing your profile with codeep.dev…');
538
544
  const pushed = await pushUserProfile();
539
545
  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')}` });
546
+ ctx.app.addMessage({ role: 'system', content: formatMeSyncReport(pushed, pulled) });
548
547
  break;
549
548
  }
550
549
  if (sub === 'init') {
@@ -558,12 +557,7 @@ export async function handleCommand(command, args, ctx) {
558
557
  ctx.app.notify('Could not create the profile file.');
559
558
  break;
560
559
  }
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
- });
560
+ ctx.app.addMessage({ role: 'system', content: formatMeInitResult(scope, res) });
567
561
  break;
568
562
  }
569
563
  // Default: show the profile view.
@@ -710,15 +704,8 @@ export async function handleCommand(command, args, ctx) {
710
704
  // Non-null binding for the picker closure — TS can't carry the null
711
705
  // narrowing of a reassigned `let` into the callback.
712
706
  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
707
  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
- });
708
+ const labels = summaries.map(formatCloudSessionLabel);
722
709
  if (scopedToProject)
723
710
  labels.push(SHOW_ALL);
724
711
  // Named so the "Show all" branch can re-present the picker with the
@@ -1186,18 +1173,13 @@ Format: use headers per category, only include categories where you found issues
1186
1173
  case 'copy': {
1187
1174
  const blockNum = args[0] ? parseInt(args[0], 10) : -1;
1188
1175
  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
- }
1176
+ const codeBlocks = messages.flatMap(m => extractCodeBlocks(m.content));
1195
1177
  if (codeBlocks.length === 0) {
1196
1178
  ctx.app.notify('No code blocks found');
1197
1179
  return;
1198
1180
  }
1199
- const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
1200
- if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
1181
+ const index = resolveBlockIndex(blockNum, codeBlocks.length);
1182
+ if (index === null) {
1201
1183
  ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
1202
1184
  return;
1203
1185
  }
@@ -1235,20 +1217,7 @@ Format: use headers per category, only include categories where you found issues
1235
1217
  ctx.app.notify('No assistant response to apply');
1236
1218
  return;
1237
1219
  }
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
- }
1220
+ const changes = extractFileChanges(lastAssistant.content);
1252
1221
  if (changes.length === 0) {
1253
1222
  ctx.app.notify('No file changes found in response');
1254
1223
  return;
@@ -1257,64 +1226,168 @@ Format: use headers per category, only include categories where you found issues
1257
1226
  ctx.app.notify('Write access required. Use /grant first.');
1258
1227
  return;
1259
1228
  }
1229
+ // Parse optional selective hunk spec: /apply --only file.ts:0,1 other.ts:2
1230
+ // Without --only, all hunks are applied (existing behavior).
1231
+ const selective = args.includes('--only') || args.includes('-o');
1232
+ const interactive = args.includes('--interactive') || args.includes('-i');
1233
+ const hunkSpecs = new Map();
1234
+ if (selective) {
1235
+ for (const a of args) {
1236
+ if (a === '--only' || a === '-o')
1237
+ continue;
1238
+ const m = a.match(/^(.+):([\d,]+)$/);
1239
+ if (m) {
1240
+ const [, file, idxStr] = m;
1241
+ const idxs = new Set(idxStr.split(',').map((n) => parseInt(n, 10)).filter((n) => !isNaN(n)));
1242
+ hunkSpecs.set(file, idxs);
1243
+ }
1244
+ }
1245
+ // A spec that parsed to nothing must NOT fall through to the
1246
+ // apply-everything branch below — the user asked to restrict the
1247
+ // apply, so writing every change is the opposite of the request.
1248
+ if (hunkSpecs.size === 0) {
1249
+ ctx.app.notify('Invalid --only spec. Expected `--only <file>:<hunk>[,<hunk>]` (e.g. --only src/a.ts:0,2). Nothing applied.');
1250
+ return;
1251
+ }
1252
+ }
1260
1253
  import('fs').then(async (fs) => {
1261
1254
  import('path').then(async (pathModule) => {
1255
+ const { createFileDiff, applyHunksToFiles, countChangeHunks } = await import('../utils/diffPreview.js');
1262
1256
  const diffLines = [];
1257
+ const fileDiffs = [];
1263
1258
  for (const change of changes) {
1264
1259
  const fullPath = pathModule.isAbsolute(change.path)
1265
1260
  ? change.path
1266
1261
  : pathModule.join(ctx.projectPath, change.path);
1267
- const shortPath = change.path.length > 40 ? '...' + change.path.slice(-37) : change.path;
1268
1262
  let existingContent = '';
1269
1263
  try {
1270
1264
  existingContent = await fs.promises.readFile(fullPath, 'utf-8');
1271
1265
  }
1272
1266
  catch { }
1273
- if (!existingContent) {
1274
- diffLines.push(`+ CREATE: ${shortPath}`);
1275
- diffLines.push(` (${change.content.split('\n').length} lines)`);
1267
+ const fd = createFileDiff(change.path, change.content, ctx.projectPath);
1268
+ fileDiffs.push(fd);
1269
+ const hunkCount = countChangeHunks(fd);
1270
+ diffLines.push(...formatApplyDiffLine(change, existingContent));
1271
+ if (hunkCount > 0) {
1272
+ diffLines.push(` ↳ ${hunkCount} hunk(s) — use /apply --only ${change.path}:0,1 to select`);
1276
1273
  }
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})`);
1274
+ }
1275
+ // Interactive mode: open the hunk picker (`git add -p` style).
1276
+ if (interactive) {
1277
+ const items = [];
1278
+ for (const fd of fileDiffs) {
1279
+ for (let hi = 0; hi < fd.hunks.length; hi++) {
1280
+ const hunk = fd.hunks[hi];
1281
+ // Skip pure-context hunks (no add/remove).
1282
+ if (!hunk.lines.some((l) => l.type === 'add' || l.type === 'remove'))
1283
+ continue;
1284
+ const header = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
1285
+ const lines = hunk.lines.map((l) => {
1286
+ if (l.type === 'add')
1287
+ return `+${l.content}`;
1288
+ if (l.type === 'remove')
1289
+ return `-${l.content}`;
1290
+ return ` ${l.content}`;
1291
+ });
1292
+ items.push({ path: fd.path, hunkIndex: hi, header, lines });
1293
+ }
1294
+ }
1295
+ if (items.length === 0) {
1296
+ ctx.app.notify('No change hunks to review');
1297
+ return;
1283
1298
  }
1299
+ ctx.app.showHunkPicker({
1300
+ title: '🎯 Review hunks',
1301
+ items,
1302
+ onComplete: (accepted) => {
1303
+ if (accepted.length === 0) {
1304
+ ctx.app.notify('No hunks applied');
1305
+ return;
1306
+ }
1307
+ (async () => {
1308
+ // Group accepted hunk indices by file path.
1309
+ const byPath = new Map();
1310
+ for (const a of accepted) {
1311
+ let set = byPath.get(a.path);
1312
+ if (!set) {
1313
+ set = new Set();
1314
+ byPath.set(a.path, set);
1315
+ }
1316
+ set.add(a.hunkIndex);
1317
+ }
1318
+ const results = applyHunksToFiles(fileDiffs, byPath);
1319
+ let applied = 0;
1320
+ for (const r of results) {
1321
+ try {
1322
+ const fullPath = pathModule.isAbsolute(r.path)
1323
+ ? r.path
1324
+ : pathModule.join(ctx.projectPath, r.path);
1325
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1326
+ await fs.promises.writeFile(fullPath, r.content);
1327
+ applied++;
1328
+ }
1329
+ catch { }
1330
+ }
1331
+ ctx.app.notify(`Applied ${accepted.length} hunk(s) across ${applied} file(s)`);
1332
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1333
+ },
1334
+ });
1335
+ return;
1284
1336
  }
1337
+ const summary = selective && hunkSpecs.size > 0
1338
+ ? `Selective apply (${hunkSpecs.size} file(s) with chosen hunks)`
1339
+ : `Found ${changes.length} file(s) to apply`;
1285
1340
  ctx.app.showConfirm({
1286
1341
  title: '📝 Apply Changes',
1287
1342
  message: [
1288
- `Found ${changes.length} file(s) to apply:`,
1343
+ summary,
1289
1344
  '',
1290
- ...diffLines.slice(0, 10),
1291
- ...(diffLines.length > 10 ? [` ...and ${diffLines.length - 10} more`] : []),
1345
+ ...diffLines.slice(0, 12),
1346
+ ...(diffLines.length > 12 ? [` ...and ${diffLines.length - 12} more`] : []),
1292
1347
  '',
1293
- 'Apply these changes?',
1348
+ selective && hunkSpecs.size > 0 ? 'Apply selected hunks?' : 'Apply these changes?',
1294
1349
  ],
1295
1350
  confirmLabel: 'Apply',
1296
1351
  cancelLabel: 'Cancel',
1297
1352
  onConfirm: () => {
1298
1353
  (async () => {
1299
1354
  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++;
1355
+ if (selective && hunkSpecs.size > 0) {
1356
+ // Per-hunk selective apply.
1357
+ const results = applyHunksToFiles(fileDiffs, hunkSpecs);
1358
+ for (const r of results) {
1359
+ try {
1360
+ const fullPath = pathModule.isAbsolute(r.path)
1361
+ ? r.path
1362
+ : pathModule.join(ctx.projectPath, r.path);
1363
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1364
+ await fs.promises.writeFile(fullPath, r.content);
1365
+ applied++;
1366
+ }
1367
+ catch { }
1368
+ }
1369
+ }
1370
+ else {
1371
+ // All-or-nothing apply (original behavior).
1372
+ for (const change of changes) {
1373
+ try {
1374
+ const fullPath = pathModule.isAbsolute(change.path)
1375
+ ? change.path
1376
+ : pathModule.join(ctx.projectPath, change.path);
1377
+ await fs.promises.mkdir(pathModule.dirname(fullPath), { recursive: true });
1378
+ await fs.promises.writeFile(fullPath, change.content);
1379
+ applied++;
1380
+ }
1381
+ catch { }
1308
1382
  }
1309
- catch { }
1310
1383
  }
1311
- ctx.app.notify(`Applied ${applied}/${changes.length} file(s)`);
1312
- })();
1384
+ ctx.app.notify(`Applied ${applied}/${selective ? hunkSpecs.size : changes.length} file(s)`);
1385
+ })().catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1313
1386
  },
1314
1387
  onCancel: () => ctx.app.notify('Apply cancelled'),
1315
1388
  });
1316
1389
  });
1317
- });
1390
+ }).catch((e) => ctx.app.notify(`Apply failed: ${e instanceof Error ? e.message : String(e)}`));
1318
1391
  break;
1319
1392
  }
1320
1393
  case 'add': {
@@ -1769,10 +1842,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1769
1842
  ctx.app.notify(`Skill ${name} not found`);
1770
1843
  break;
1771
1844
  }
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
- });
1845
+ ctx.app.addMessage({ role: 'system', content: formatSkillsShow(bundle) });
1776
1846
  break;
1777
1847
  }
1778
1848
  // Marketplace operations against codeep.dev.
@@ -1790,10 +1860,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1790
1860
  ctx.app.notify(`Publish failed: ${result.error}`);
1791
1861
  break;
1792
1862
  }
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
- });
1863
+ ctx.app.addMessage({ role: 'system', content: formatSkillsPublishResult(slug, isPublic, result.skill?.owner_username) });
1797
1864
  break;
1798
1865
  }
1799
1866
  if (sub === 'install') {
@@ -1823,7 +1890,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
1823
1890
  }
1824
1891
  const skills = result.skills ?? [];
1825
1892
  if (skills.length === 0) {
1826
- ctx.app.addMessage({ role: 'system', content: query ? `_No public skills matching "${query}"._` : '_No public skills published yet._' });
1893
+ ctx.app.addMessage({ role: 'system', content: formatSkillsBrowseEmpty(query) });
1827
1894
  break;
1828
1895
  }
1829
1896
  const lines = [`# ${query ? `Skills matching "${query}"` : 'Public skills'}`, ''];
@@ -2074,7 +2141,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2074
2141
  ctx.app.notify('No profiles saved. Use /profile save <name>');
2075
2142
  }
2076
2143
  else {
2077
- ctx.app.addMessage({ role: 'system', content: `## Profiles\n\n${profiles.map(p => `- ${p}`).join('\n')}\n\nUse /profile load <name> to apply.` });
2144
+ ctx.app.addMessage({ role: 'system', content: formatProfileList(profiles) });
2078
2145
  }
2079
2146
  break;
2080
2147
  }
@@ -2214,55 +2281,18 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2214
2281
  // case no longer also claims 'cost' (which always hit the handler above).
2215
2282
  case 'stats': {
2216
2283
  const { getCostBreakdown, getSessionStats, formatTokenCount, getPricingTable, getCacheStats } = await import('../utils/tokenTracker.js');
2284
+ const { formatResourceImpactReport } = await import('../utils/resourceImpact.js');
2217
2285
  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') });
2286
+ const content = formatStatsReport({
2287
+ totals: stats,
2288
+ breakdown: getCostBreakdown(),
2289
+ cache: getCacheStats(),
2290
+ pricing: getPricingTable(),
2291
+ currentProvider: config.get('provider'),
2292
+ fmt: formatTokenCount,
2293
+ impactLines: formatResourceImpactReport(stats.totalTokens),
2294
+ });
2295
+ ctx.app.addMessage({ role: 'system', content });
2266
2296
  break;
2267
2297
  }
2268
2298
  case 'memory': {
@@ -2280,8 +2310,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2280
2310
  ctx.app.notify('No memory notes. Add one with: /memory <note>');
2281
2311
  }
2282
2312
  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}` });
2313
+ ctx.app.addMessage({ role: 'assistant', content: formatMemoryList(intelligence.notes) });
2285
2314
  }
2286
2315
  break;
2287
2316
  }
@@ -2322,6 +2351,29 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2322
2351
  ctx.app.addMessage({ role: 'system', content: formatCommandList(loadCustomCommands(ctx.projectPath)) });
2323
2352
  break;
2324
2353
  }
2354
+ case 'web-cache': {
2355
+ const sub = args[0]?.toLowerCase();
2356
+ const { clearWebCache, webCacheStats } = await import('../utils/webFetch.js');
2357
+ if (sub === 'clear' || sub === 'reset' || sub === 'flush') {
2358
+ clearWebCache();
2359
+ ctx.app.notify('Web cache cleared');
2360
+ }
2361
+ else {
2362
+ const stats = webCacheStats();
2363
+ ctx.app.addMessage({
2364
+ role: 'system',
2365
+ content: [
2366
+ '🌐 Web fetch cache',
2367
+ '',
2368
+ ` Entries: ${stats.entries}/${stats.maxEntries}`,
2369
+ ` TTL: ${stats.ttlMinutes} min`,
2370
+ '',
2371
+ 'Usage: /web-cache clear',
2372
+ ].join('\n'),
2373
+ });
2374
+ }
2375
+ break;
2376
+ }
2325
2377
  case 'mcp': {
2326
2378
  // Mirrors the ACP `/mcp` handler in src/acp/commands.ts. In TUI the
2327
2379
  // session id is the constant `codeep-tui` (the same one main.ts uses
@@ -2480,35 +2532,14 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2480
2532
  ctx.app.notify('Reloading MCP server config…');
2481
2533
  const merged = loadMcpServerConfig(projectPath);
2482
2534
  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') });
2535
+ ctx.app.addMessage({ role: 'system', content: formatMcpReloadReport(registered.length, merged.length, errors) });
2490
2536
  break;
2491
2537
  }
2492
2538
  if (sub === 'resources') {
2493
2539
  const { getSessionResources, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2494
2540
  await awaitSessionReady(TUI_SESSION);
2495
2541
  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() });
2542
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourcesList(groups) });
2512
2543
  break;
2513
2544
  }
2514
2545
  if (sub === 'read') {
@@ -2520,23 +2551,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2520
2551
  const { readSessionResource } = await import('../utils/mcpRegistry.js');
2521
2552
  try {
2522
2553
  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') });
2554
+ ctx.app.addMessage({ role: 'system', content: formatMcpResourceRead(uri, contents) });
2540
2555
  }
2541
2556
  catch (err) {
2542
2557
  ctx.app.addMessage({ role: 'system', content: `Failed to read \`${uri}\`: ${err.message}` });
@@ -2547,23 +2562,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2547
2562
  const { getSessionPrompts, awaitSessionReady } = await import('../utils/mcpRegistry.js');
2548
2563
  await awaitSessionReady(TUI_SESSION);
2549
2564
  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() });
2565
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptsList(groups) });
2567
2566
  break;
2568
2567
  }
2569
2568
  if (sub === 'prompt') {
@@ -2573,25 +2572,11 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2573
2572
  ctx.app.addMessage({ role: 'system', content: 'Usage: `/mcp prompt <server> <name> [key=value ...]`' });
2574
2573
  break;
2575
2574
  }
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
- }
2575
+ const promptArgs = parsePromptArgs(args.slice(3));
2582
2576
  const { getSessionPrompt } = await import('../utils/mcpRegistry.js');
2583
2577
  try {
2584
2578
  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() });
2579
+ ctx.app.addMessage({ role: 'system', content: formatMcpPromptResult(serverName, name, description, messages) });
2595
2580
  }
2596
2581
  catch (err) {
2597
2582
  ctx.app.addMessage({ role: 'system', content: `Failed to materialise prompt: ${err.message}` });
@@ -2603,41 +2588,7 @@ Describe what this skill does. The agent reads this body verbatim when it invoke
2603
2588
  await awaitSessionReady(TUI_SESSION);
2604
2589
  const tools = await getSessionTools(TUI_SESSION);
2605
2590
  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() });
2591
+ ctx.app.addMessage({ role: 'system', content: formatMcpServerList(tools, mcpErrors) });
2641
2592
  break;
2642
2593
  }
2643
2594
  default: {