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
@@ -15,6 +15,7 @@ export declare class Screen {
15
15
  private cursorY;
16
16
  private cursorVisible;
17
17
  private resizeCallback;
18
+ private readonly resizeHandler;
18
19
  constructor();
19
20
  /**
20
21
  * Register a callback to be called on terminal resize
@@ -12,13 +12,15 @@ export class Screen {
12
12
  cursorY = 0;
13
13
  cursorVisible = true;
14
14
  resizeCallback = null;
15
+ resizeHandler;
15
16
  constructor() {
16
17
  this.width = process.stdout.columns || 80;
17
18
  this.height = process.stdout.rows || 24;
18
19
  this.buffer = this.createEmptyBuffer();
19
20
  this.rendered = this.createEmptyBuffer();
20
- // Handle resize
21
- process.stdout.on('resize', () => {
21
+ // Keep the exact handler so cleanup can detach it. This matters for
22
+ // embedders/tests that create more than one Screen in the same process.
23
+ this.resizeHandler = () => {
22
24
  this.width = process.stdout.columns || 80;
23
25
  this.height = process.stdout.rows || 24;
24
26
  this.buffer = this.createEmptyBuffer();
@@ -28,7 +30,8 @@ export class Screen {
28
30
  if (this.resizeCallback) {
29
31
  this.resizeCallback();
30
32
  }
31
- });
33
+ };
34
+ process.stdout.on('resize', this.resizeHandler);
32
35
  }
33
36
  /**
34
37
  * Register a callback to be called on terminal resize
@@ -318,6 +321,8 @@ export class Screen {
318
321
  * Cleanup (show cursor, clear)
319
322
  */
320
323
  cleanup() {
324
+ process.stdout.removeListener('resize', this.resizeHandler);
325
+ this.resizeCallback = null;
321
326
  process.stdout.write(style.reset + screen.clear + cursor.home + cursor.show);
322
327
  }
323
328
  }
@@ -61,3 +61,192 @@ export declare function formatTaskList(tasks: Array<{
61
61
  description?: string | null;
62
62
  project_name?: string | null;
63
63
  }>, scopeProjectName?: string): string;
64
+ /** Render the `/profile list` Markdown message from saved profile names. */
65
+ export declare function formatProfileList(profiles: string[]): string;
66
+ /** Render the `/memory list` Markdown message from saved notes. */
67
+ export declare function formatMemoryList(notes: string[]): string;
68
+ export interface StatsModelRow {
69
+ model: string;
70
+ provider: string;
71
+ promptTokens: number;
72
+ completionTokens: number;
73
+ estimatedCost: number;
74
+ }
75
+ export interface StatsTotals {
76
+ requestCount: number;
77
+ totalTokens: number;
78
+ totalPromptTokens: number;
79
+ totalCompletionTokens: number;
80
+ estimatedCost: number;
81
+ }
82
+ export interface StatsCache {
83
+ cacheReadTokens: number;
84
+ cacheCreationTokens: number;
85
+ estimatedSavingsUsd: number;
86
+ }
87
+ export interface PricingRow {
88
+ model: string;
89
+ inputPer1M: number;
90
+ outputPer1M: number;
91
+ }
92
+ /** A formatter for token counts (injected so this module stays pure). */
93
+ export type TokenFormatter = (n: number) => string;
94
+ /** Format a single model-row's cost string, mirroring the inline logic. */
95
+ export declare function formatModelCost(provider: string, estimatedCost: number): string;
96
+ /**
97
+ * Build the full `/stats` Markdown report. `currentProvider` controls
98
+ * whether the total shows "free" (ollama) or a dollar figure.
99
+ */
100
+ export declare function formatStatsReport(args: {
101
+ totals: StatsTotals;
102
+ breakdown: StatsModelRow[];
103
+ cache: StatsCache;
104
+ pricing: PricingRow[];
105
+ currentProvider: string;
106
+ fmt: TokenFormatter;
107
+ impactLines?: string[];
108
+ }): string;
109
+ /**
110
+ * Extract every fenced code block body (the text inside ```…```) from a
111
+ * string, mirroring the `/copy` loop. Language fences (```ts) are ignored —
112
+ * only the body is captured.
113
+ */
114
+ export declare function extractCodeBlocks(text: string): string[];
115
+ /**
116
+ * Validate a 1-based block index against the available block list, as used
117
+ * by `/copy <n>`. Returns the 0-based index, or `null` when the index is
118
+ * out of range (the caller shows an error).
119
+ */
120
+ export declare function resolveBlockIndex(blockNum: number, blockCount: number): number | null;
121
+ export interface FileChange {
122
+ path: string;
123
+ content: string;
124
+ }
125
+ /**
126
+ * Extract file-change pairs from an assistant message, mirroring `/apply`.
127
+ * Two patterns are tried in order:
128
+ * 1. fence with a filename header: ```ts\nsrc/foo.ts\n<body>```
129
+ * 2. fence with a `// File:` / `# Path:` comment header.
130
+ * A path is only accepted when it contains a dot and no spaces.
131
+ */
132
+ export declare function extractFileChanges(text: string): FileChange[];
133
+ /** Truncate a path for display: keep the last 37 chars, prefixed with “…”. */
134
+ export declare function shortPathForDisplay(path: string, max?: number): string;
135
+ /**
136
+ * Build a single diff-line summary for a file change, mirroring `/apply`.
137
+ * Returns `null` when `existingContent` is empty (a CREATE), otherwise a
138
+ * MODIFY line with the line-count delta.
139
+ */
140
+ export declare function formatApplyDiffLine(change: {
141
+ path: string;
142
+ content: string;
143
+ }, existingContent: string): string[];
144
+ /**
145
+ * Parse `key=value` tokens (as used by `/mcp prompt <server> <name> [k=v...]`)
146
+ * into a record. Tokens without an `=` (or with `=` at position 0) are
147
+ * skipped. Mirrors the inline loop.
148
+ */
149
+ export declare function parsePromptArgs(tokens: string[]): Record<string, string>;
150
+ /** Pluralise "tool"/"tools" based on the count. */
151
+ export declare function pluralTools(n: number): string;
152
+ /**
153
+ * Group a flat list of tools by `serverName`, preserving first-seen order.
154
+ * Used by `/mcp` (default), `/mcp reload`, and the install report.
155
+ */
156
+ export declare function groupToolsByServer<T extends {
157
+ serverName: string;
158
+ }>(tools: T[]): Array<{
159
+ serverName: string;
160
+ serverTools: T[];
161
+ }>;
162
+ /** Format the `/mcp` default server/tool listing. */
163
+ export declare function formatMcpServerList<T extends {
164
+ serverName: string;
165
+ agentName: string;
166
+ description?: string;
167
+ }>(tools: T[], errors: Array<{
168
+ server: string;
169
+ error: string;
170
+ }>): string;
171
+ /** Format the `/mcp reload` report. */
172
+ export declare function formatMcpReloadReport(toolCount: number, serverCount: number, errors: Array<{
173
+ server: string;
174
+ error: string;
175
+ }>): string;
176
+ /** Format the `/mcp resources` listing. */
177
+ export declare function formatMcpResourcesList(groups: Array<{
178
+ serverName: string;
179
+ resources: Array<{
180
+ uri: string;
181
+ name?: string;
182
+ mimeType?: string;
183
+ description?: string;
184
+ }>;
185
+ }>): string;
186
+ /** Format the `/mcp read` output for a list of resource contents. */
187
+ export declare function formatMcpResourceRead(uri: string, contents: Array<{
188
+ text?: string;
189
+ blob?: string;
190
+ mimeType?: string;
191
+ }>): string;
192
+ /** Format the `/mcp prompts` listing. */
193
+ export declare function formatMcpPromptsList(groups: Array<{
194
+ serverName: string;
195
+ prompts: Array<{
196
+ name: string;
197
+ description?: string;
198
+ arguments?: Array<{
199
+ name: string;
200
+ required?: boolean;
201
+ }>;
202
+ }>;
203
+ }>): string;
204
+ /** Format the `/mcp prompt` materialised output. */
205
+ export declare function formatMcpPromptResult(serverName: string, name: string, description: string | undefined, messages: Array<{
206
+ role: string;
207
+ content?: {
208
+ text?: string;
209
+ };
210
+ }>): string;
211
+ /**
212
+ * Parse the `--days N` / `--days=N` flag from `/insights` args. Returns the
213
+ * default (7) when absent or unparseable; clamps negatives to 0.
214
+ */
215
+ export declare function parseInsightsDays(args: string[], fallback?: number): number;
216
+ /**
217
+ * Format a single cloud-session row for the `/cloud` picker, mirroring the
218
+ * inline template: `title · date · N msg · [project?]`.
219
+ */
220
+ export declare function formatCloudSessionLabel(s: {
221
+ sessionId: string;
222
+ sessionName?: string | null;
223
+ updatedAt: string;
224
+ messageCount: number;
225
+ projectName?: string | null;
226
+ }): string;
227
+ /** Format the `/me sync` result list. `pulled` is `1` on success, `0` or `null` otherwise. */
228
+ export declare function formatMeSyncReport(pushed: boolean, pulled: number | null): string;
229
+ /**
230
+ * Format the `/me learn` result. `updated` distinguishes "new facts written"
231
+ * from "already covered"; `file` is the human-readable path.
232
+ */
233
+ export declare function formatMeLearnResult(scope: 'global' | 'project', file: string, res: {
234
+ updated: boolean;
235
+ facts: string;
236
+ }): string;
237
+ /** Format the `/me init` result. */
238
+ export declare function formatMeInitResult(scope: 'global' | 'project', res: {
239
+ created: boolean;
240
+ path: string;
241
+ }): string;
242
+ /** Format the `/skills show` detail view from a skill bundle. */
243
+ export declare function formatSkillsShow(bundle: {
244
+ name: string;
245
+ description: string;
246
+ source: string;
247
+ body: string;
248
+ }): string;
249
+ /** Format the `/skills browse` empty-state message. */
250
+ export declare function formatSkillsBrowseEmpty(query: string): string;
251
+ /** Format the `/skills publish` success message. */
252
+ export declare function formatSkillsPublishResult(slug: string, isPublic: boolean, owner: string | null | undefined): string;
@@ -106,3 +106,348 @@ export function formatTaskList(tasks, scopeProjectName) {
106
106
  lines.push('*Tasks loaded into agent context — agent will see them in the next message.*');
107
107
  return lines.join('\n');
108
108
  }
109
+ // ─── /profile formatting ──────────────────────────────────────────────────────
110
+ /** Render the `/profile list` Markdown message from saved profile names. */
111
+ export function formatProfileList(profiles) {
112
+ return `## Profiles\n\n${profiles.map((p) => `- ${p}`).join('\n')}\n\nUse /profile load <name> to apply.`;
113
+ }
114
+ // ─── /memory list formatting ──────────────────────────────────────────────────
115
+ /** Render the `/memory list` Markdown message from saved notes. */
116
+ export function formatMemoryList(notes) {
117
+ const lines = notes.map((n, i) => ` ${i + 1}. ${n}`).join('\n');
118
+ return `**Project memory notes:**\n${lines}`;
119
+ }
120
+ /** Format a single model-row's cost string, mirroring the inline logic. */
121
+ export function formatModelCost(provider, estimatedCost) {
122
+ if (provider === 'ollama')
123
+ return 'free';
124
+ return estimatedCost > 0 ? `~$${estimatedCost.toFixed(4)}` : '(no pricing data)';
125
+ }
126
+ /**
127
+ * Build the full `/stats` Markdown report. `currentProvider` controls
128
+ * whether the total shows "free" (ollama) or a dollar figure.
129
+ */
130
+ export function formatStatsReport(args) {
131
+ const { totals, breakdown, cache, pricing, currentProvider, fmt, impactLines = [] } = args;
132
+ const lines = ['## Session Cost', ''];
133
+ if (totals.requestCount === 0) {
134
+ lines.push('*No API calls made yet this session.*', '');
135
+ }
136
+ else {
137
+ lines.push(`Requests: ${totals.requestCount}`);
138
+ lines.push(`Tokens: ${fmt(totals.totalTokens)} total (${fmt(totals.totalPromptTokens)} in / ${fmt(totals.totalCompletionTokens)} out)`);
139
+ if (breakdown.length > 0) {
140
+ lines.push('', '### By model');
141
+ for (const b of breakdown) {
142
+ const costStr = formatModelCost(b.provider, b.estimatedCost);
143
+ lines.push(`- **${b.model}** (${b.provider}): ${fmt(b.promptTokens)} in / ${fmt(b.completionTokens)} out — ${costStr}`);
144
+ }
145
+ lines.push('');
146
+ if (currentProvider === 'ollama') {
147
+ lines.push(`**Total: free · ${fmt(totals.totalTokens)} tokens**`);
148
+ }
149
+ else if (totals.estimatedCost > 0) {
150
+ lines.push(`**Total: ~$${totals.estimatedCost.toFixed(4)}**`);
151
+ }
152
+ }
153
+ if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
154
+ lines.push('', '### Prompt caching');
155
+ lines.push(`Cache reads: ${fmt(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
156
+ if (cache.cacheCreationTokens > 0) {
157
+ lines.push(`Cache writes: ${fmt(cache.cacheCreationTokens)} tokens (billed at 1.25× input rate)`);
158
+ }
159
+ if (cache.estimatedSavingsUsd > 0) {
160
+ lines.push(`Estimated savings vs no caching: $${cache.estimatedSavingsUsd.toFixed(4)}`);
161
+ }
162
+ }
163
+ if (impactLines.length > 0) {
164
+ lines.push('', ...impactLines);
165
+ }
166
+ lines.push('');
167
+ }
168
+ lines.push('### Pricing (per 1M tokens)');
169
+ lines.push('| Model | Input | Output |');
170
+ lines.push('|---|---|---|');
171
+ for (const p of pricing) {
172
+ lines.push(`| ${p.model} | $${p.inputPer1M.toFixed(3)} | $${p.outputPer1M.toFixed(3)} |`);
173
+ }
174
+ return lines.join('\n');
175
+ }
176
+ // ─── /copy and /apply code-block extraction ───────────────────────────────────
177
+ /**
178
+ * Extract every fenced code block body (the text inside ```…```) from a
179
+ * string, mirroring the `/copy` loop. Language fences (```ts) are ignored —
180
+ * only the body is captured.
181
+ */
182
+ export function extractCodeBlocks(text) {
183
+ const blocks = [];
184
+ for (const match of text.matchAll(/```[\w]*\n([\s\S]*?)```/g)) {
185
+ blocks.push(match[1]);
186
+ }
187
+ return blocks;
188
+ }
189
+ /**
190
+ * Validate a 1-based block index against the available block list, as used
191
+ * by `/copy <n>`. Returns the 0-based index, or `null` when the index is
192
+ * out of range (the caller shows an error).
193
+ */
194
+ export function resolveBlockIndex(blockNum, blockCount) {
195
+ if (blockCount === 0)
196
+ return null;
197
+ const index = blockNum === -1 ? blockCount - 1 : blockNum - 1;
198
+ if (Number.isNaN(index) || index < 0 || index >= blockCount)
199
+ return null;
200
+ return index;
201
+ }
202
+ /**
203
+ * Extract file-change pairs from an assistant message, mirroring `/apply`.
204
+ * Two patterns are tried in order:
205
+ * 1. fence with a filename header: ```ts\nsrc/foo.ts\n<body>```
206
+ * 2. fence with a `// File:` / `# Path:` comment header.
207
+ * A path is only accepted when it contains a dot and no spaces.
208
+ */
209
+ export function extractFileChanges(text) {
210
+ const changes = [];
211
+ const fenceFilePattern = /```\w*\s+([\w./\\-]+(?:\.\w+))\n([\s\S]*?)```/g;
212
+ let match;
213
+ while ((match = fenceFilePattern.exec(text)) !== null) {
214
+ const p = match[1].trim();
215
+ if (p.includes('.') && !p.includes(' '))
216
+ changes.push({ path: p, content: match[2] });
217
+ }
218
+ if (changes.length > 0)
219
+ return changes;
220
+ const commentPattern = /```(\w+)?\s*\n(?:\/\/|#|--|\/\*)\s*(?:File|Path|file|path):\s*([^\n*]+)\n([\s\S]*?)```/g;
221
+ while ((match = commentPattern.exec(text)) !== null) {
222
+ changes.push({ path: match[2].trim(), content: match[3] });
223
+ }
224
+ return changes;
225
+ }
226
+ /** Truncate a path for display: keep the last 37 chars, prefixed with “…”. */
227
+ export function shortPathForDisplay(path, max = 40) {
228
+ return path.length > max ? '...' + path.slice(-(max - 3)) : path;
229
+ }
230
+ /**
231
+ * Build a single diff-line summary for a file change, mirroring `/apply`.
232
+ * Returns `null` when `existingContent` is empty (a CREATE), otherwise a
233
+ * MODIFY line with the line-count delta.
234
+ */
235
+ export function formatApplyDiffLine(change, existingContent) {
236
+ const shortPath = shortPathForDisplay(change.path);
237
+ if (!existingContent) {
238
+ return [`+ CREATE: ${shortPath}`, ` (${change.content.split('\n').length} lines)`];
239
+ }
240
+ const oldLines = existingContent.split('\n').length;
241
+ const newLines = change.content.split('\n').length;
242
+ const lineDiff = newLines - oldLines;
243
+ return [`~ MODIFY: ${shortPath}`, ` ${oldLines} → ${newLines} lines (${lineDiff >= 0 ? '+' : ''}${lineDiff})`];
244
+ }
245
+ // ─── /mcp helpers ─────────────────────────────────────────────────────────────
246
+ /**
247
+ * Parse `key=value` tokens (as used by `/mcp prompt <server> <name> [k=v...]`)
248
+ * into a record. Tokens without an `=` (or with `=` at position 0) are
249
+ * skipped. Mirrors the inline loop.
250
+ */
251
+ export function parsePromptArgs(tokens) {
252
+ const out = {};
253
+ for (const tok of tokens) {
254
+ const eq = tok.indexOf('=');
255
+ if (eq > 0)
256
+ out[tok.slice(0, eq)] = tok.slice(eq + 1);
257
+ }
258
+ return out;
259
+ }
260
+ /** Pluralise "tool"/"tools" based on the count. */
261
+ export function pluralTools(n) {
262
+ return `tool${n === 1 ? '' : 's'}`;
263
+ }
264
+ /**
265
+ * Group a flat list of tools by `serverName`, preserving first-seen order.
266
+ * Used by `/mcp` (default), `/mcp reload`, and the install report.
267
+ */
268
+ export function groupToolsByServer(tools) {
269
+ const byServer = new Map();
270
+ for (const t of tools) {
271
+ if (!byServer.has(t.serverName))
272
+ byServer.set(t.serverName, []);
273
+ byServer.get(t.serverName).push(t);
274
+ }
275
+ return Array.from(byServer, ([serverName, serverTools]) => ({ serverName, serverTools }));
276
+ }
277
+ /** Format the `/mcp` default server/tool listing. */
278
+ export function formatMcpServerList(tools, errors) {
279
+ if (tools.length === 0 && errors.length === 0) {
280
+ return [
281
+ '_No MCP servers connected to this session._',
282
+ '',
283
+ 'Add one with `/mcp add <name> <command> [args...]` — it persists to `.codeep/mcp_servers.json`.',
284
+ 'Or browse the marketplace with `/mcp browse` and install with `/mcp install <id>`.',
285
+ ].join('\n');
286
+ }
287
+ const lines = ['## MCP servers', ''];
288
+ if (tools.length > 0) {
289
+ for (const { serverName, serverTools } of groupToolsByServer(tools)) {
290
+ lines.push(`**${serverName}** — ${serverTools.length} ${pluralTools(serverTools.length)}`);
291
+ for (const t of serverTools) {
292
+ const desc = t.description ? ` — ${t.description}` : '';
293
+ lines.push(`- \`${t.agentName}\`${desc}`);
294
+ }
295
+ lines.push('');
296
+ }
297
+ }
298
+ if (errors.length > 0) {
299
+ lines.push('### Failed servers');
300
+ for (const e of errors)
301
+ lines.push(`- **${e.server}** — \`${e.error}\``);
302
+ }
303
+ return lines.join('\n').trim();
304
+ }
305
+ /** Format the `/mcp reload` report. */
306
+ export function formatMcpReloadReport(toolCount, serverCount, errors) {
307
+ const lines = ['## MCP reloaded', '', `**${toolCount}** ${pluralTools(toolCount)} from **${serverCount}** server${serverCount === 1 ? '' : 's'}.`];
308
+ if (errors.length > 0) {
309
+ lines.push('', '### Failed servers');
310
+ for (const e of errors)
311
+ lines.push(`- **${e.server}** — \`${e.error}\``);
312
+ }
313
+ return lines.join('\n');
314
+ }
315
+ /** Format the `/mcp resources` listing. */
316
+ export function formatMcpResourcesList(groups) {
317
+ if (groups.length === 0)
318
+ return '_No MCP server in this session exposes resources._';
319
+ const lines = ['## MCP resources', ''];
320
+ for (const g of groups) {
321
+ lines.push(`**${g.serverName}** — ${g.resources.length} resource${g.resources.length === 1 ? '' : 's'}`);
322
+ for (const r of g.resources) {
323
+ const label = r.name ? `${r.name} — ` : '';
324
+ const mime = r.mimeType ? ` (${r.mimeType})` : '';
325
+ lines.push(`- ${label}\`${r.uri}\`${mime}${r.description ? ` — ${r.description}` : ''}`);
326
+ }
327
+ lines.push('');
328
+ }
329
+ lines.push('Read one with `/mcp read <uri>`.');
330
+ return lines.join('\n').trim();
331
+ }
332
+ /** Format the `/mcp read` output for a list of resource contents. */
333
+ export function formatMcpResourceRead(uri, contents) {
334
+ if (contents.length === 0)
335
+ return `_No content returned for \`${uri}\`._`;
336
+ const lines = [`## Resource: \`${uri}\``, ''];
337
+ for (const c of contents) {
338
+ if (c.text !== undefined) {
339
+ const fence = c.mimeType?.includes('json') ? 'json' : c.mimeType?.includes('markdown') ? 'markdown' : '';
340
+ lines.push('```' + fence);
341
+ lines.push(c.text);
342
+ lines.push('```');
343
+ }
344
+ else if (c.blob) {
345
+ lines.push(`_(${c.mimeType ?? 'binary'} blob, ${c.blob.length} base64 chars — not rendered)_`);
346
+ }
347
+ }
348
+ return lines.join('\n');
349
+ }
350
+ /** Format the `/mcp prompts` listing. */
351
+ export function formatMcpPromptsList(groups) {
352
+ if (groups.length === 0)
353
+ return '_No MCP server in this session exposes prompt templates._';
354
+ const lines = ['## MCP prompt templates', ''];
355
+ for (const g of groups) {
356
+ lines.push(`**${g.serverName}** — ${g.prompts.length} prompt${g.prompts.length === 1 ? '' : 's'}`);
357
+ for (const p of g.prompts) {
358
+ const argList = p.arguments?.length
359
+ ? ` (${p.arguments.map((a) => (a.required ? a.name : `[${a.name}]`)).join(', ')})`
360
+ : '';
361
+ lines.push(`- \`${p.name}\`${argList}${p.description ? ` — ${p.description}` : ''}`);
362
+ }
363
+ lines.push('');
364
+ }
365
+ lines.push('Materialise one with `/mcp prompt <server> <name> [key=value...]`.');
366
+ return lines.join('\n').trim();
367
+ }
368
+ /** Format the `/mcp prompt` materialised output. */
369
+ export function formatMcpPromptResult(serverName, name, description, messages) {
370
+ const lines = [`## Prompt \`${serverName}/${name}\``];
371
+ if (description)
372
+ lines.push(`_${description}_`);
373
+ lines.push('');
374
+ for (const m of messages) {
375
+ const text = typeof m.content?.text === 'string' ? m.content.text : JSON.stringify(m.content);
376
+ lines.push(`**${m.role}:** ${text}`, '');
377
+ }
378
+ return lines.join('\n').trim();
379
+ }
380
+ // ─── /insights --days parser ──────────────────────────────────────────────────
381
+ /**
382
+ * Parse the `--days N` / `--days=N` flag from `/insights` args. Returns the
383
+ * default (7) when absent or unparseable; clamps negatives to 0.
384
+ */
385
+ export function parseInsightsDays(args, fallback = 7) {
386
+ let days = fallback;
387
+ for (let i = 0; i < args.length; i++) {
388
+ const a = args[i];
389
+ if (a === '--days' && args[i + 1]) {
390
+ const n = parseInt(args[i + 1], 10);
391
+ if (Number.isFinite(n))
392
+ days = n;
393
+ }
394
+ else if (a.startsWith('--days=')) {
395
+ const n = parseInt(a.slice('--days='.length), 10);
396
+ if (Number.isFinite(n))
397
+ days = n;
398
+ }
399
+ }
400
+ return days;
401
+ }
402
+ // ─── /cloud session-row formatter ─────────────────────────────────────────────
403
+ /**
404
+ * Format a single cloud-session row for the `/cloud` picker, mirroring the
405
+ * inline template: `title · date · N msg · [project?]`.
406
+ */
407
+ export function formatCloudSessionLabel(s) {
408
+ const date = new Date(s.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
409
+ const title = s.sessionName || s.sessionId.slice(0, 8);
410
+ const projectTag = s.projectName ? ` · ${s.projectName}` : '';
411
+ return `${title} · ${date} · ${s.messageCount} msg${projectTag}`;
412
+ }
413
+ // ─── /me sync + learn + init formatters ───────────────────────────────────────
414
+ /** Format the `/me sync` result list. `pulled` is `1` on success, `0` or `null` otherwise. */
415
+ export function formatMeSyncReport(pushed, pulled) {
416
+ const lines = [];
417
+ if (pushed)
418
+ lines.push('✓ Profile pushed to the dashboard');
419
+ if (pulled === 1)
420
+ lines.push('✓ Profile pulled to this machine');
421
+ if (lines.length === 0)
422
+ lines.push('Nothing to sync yet — run `/me init` and fill in your profile first.');
423
+ return `## Profile sync\n\n${lines.join('\n')}`;
424
+ }
425
+ /**
426
+ * Format the `/me learn` result. `updated` distinguishes "new facts written"
427
+ * from "already covered"; `file` is the human-readable path.
428
+ */
429
+ export function formatMeLearnResult(scope, file, res) {
430
+ return res.updated
431
+ ? `Updated your ${scope} learned profile (\`${file}\`):\n\n${res.facts}\n\nClear it anytime with \`/me forget\`.`
432
+ : `No changes — your ${scope} learned profile already covers this:\n\n${res.facts}`;
433
+ }
434
+ /** Format the `/me init` result. */
435
+ export function formatMeInitResult(scope, res) {
436
+ return res.created
437
+ ? `Created ${scope} profile: \`${res.path}\`\n\nEdit it in your editor — Codeep uses it automatically. View anytime with \`/me\`.`
438
+ : `${scope === 'global' ? 'Global' : 'Project'} profile already exists: \`${res.path}\`\n\nEdit it directly, or view it with \`/me\`.`;
439
+ }
440
+ // ─── /skills show formatter ───────────────────────────────────────────────────
441
+ /** Format the `/skills show` detail view from a skill bundle. */
442
+ export function formatSkillsShow(bundle) {
443
+ return `# ${bundle.name}\n_${bundle.description}_\n\n**Source:** ${bundle.source}\n\n---\n\n${bundle.body}`;
444
+ }
445
+ // ─── /skills browse + publish formatters ──────────────────────────────────────
446
+ /** Format the `/skills browse` empty-state message. */
447
+ export function formatSkillsBrowseEmpty(query) {
448
+ return query ? `_No public skills matching "${query}"._` : '_No public skills published yet._';
449
+ }
450
+ /** Format the `/skills publish` success message. */
451
+ export function formatSkillsPublishResult(slug, isPublic, owner) {
452
+ return `Published \`${slug}\` (${isPublic ? 'public' : 'private'}) to codeep.dev. Install elsewhere with \`/skills install ${owner ?? '<you>'}/${slug}\`.`;
453
+ }
@@ -144,7 +144,7 @@ export const COMMANDS = [
144
144
  { name: 'copy', description: 'Copy code block to clipboard', category: 'code', usage: ['[n]'] },
145
145
  { name: 'paste', description: 'Paste from clipboard', category: 'code' },
146
146
  { name: 'apply', description: 'Apply file changes from AI', category: 'code' },
147
- { name: 'add', description: 'Add file to context', category: 'code', usage: ['<path>'] },
147
+ { name: 'add', description: 'Add file to context (or type @path inline)', category: 'code', usage: ['<path>'] },
148
148
  { name: 'drop', description: 'Remove file (or all) from context', category: 'code', usage: ['[path]'] },
149
149
  { name: 'multiline', description: 'Toggle multi-line input mode', category: 'code' },
150
150
  // ── skills (shortcuts) ─────────────────────────────────────────────────────
@@ -216,6 +216,7 @@ export const COMMANDS = [
216
216
  },
217
217
  { name: 'hooks', description: 'List installed lifecycle hooks (.codeep/hooks/<event>.sh)', category: 'extensions' },
218
218
  { name: 'commands', description: 'List custom slash commands in .codeep/commands/*.md', category: 'extensions' },
219
+ { name: 'web-cache', aliases: ['webcache'], description: 'Show @web fetch cache stats (alias: /web-cache clear)', category: 'extensions' },
219
220
  // ── cloud & account ────────────────────────────────────────────────────────
220
221
  { name: 'account', description: 'Link this machine to your codeep.dev account', category: 'cloud' },
221
222
  { name: 'tasks', description: 'List/add/done/delete codeep.dev tasks', category: 'cloud', usage: ['add <title> [--bug|--feature]'] },