codeep 1.3.41 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +208 -0
  2. package/dist/acp/commands.js +770 -7
  3. package/dist/acp/protocol.d.ts +11 -2
  4. package/dist/acp/server.js +179 -11
  5. package/dist/acp/session.d.ts +3 -0
  6. package/dist/acp/session.js +5 -0
  7. package/dist/api/index.js +39 -6
  8. package/dist/config/index.d.ts +13 -0
  9. package/dist/config/index.js +46 -1
  10. package/dist/config/providers.js +76 -1
  11. package/dist/renderer/App.d.ts +12 -0
  12. package/dist/renderer/App.js +96 -4
  13. package/dist/renderer/agentExecution.js +5 -0
  14. package/dist/renderer/commands.js +348 -2
  15. package/dist/renderer/components/Login.d.ts +1 -0
  16. package/dist/renderer/components/Login.js +24 -9
  17. package/dist/renderer/handlers.d.ts +11 -1
  18. package/dist/renderer/handlers.js +30 -0
  19. package/dist/renderer/main.js +73 -0
  20. package/dist/utils/agent.d.ts +17 -0
  21. package/dist/utils/agent.js +91 -7
  22. package/dist/utils/agentChat.d.ts +10 -2
  23. package/dist/utils/agentChat.js +48 -9
  24. package/dist/utils/agentStream.js +6 -2
  25. package/dist/utils/checkpoints.d.ts +93 -0
  26. package/dist/utils/checkpoints.js +205 -0
  27. package/dist/utils/context.d.ts +24 -0
  28. package/dist/utils/context.js +57 -0
  29. package/dist/utils/customCommands.d.ts +62 -0
  30. package/dist/utils/customCommands.js +201 -0
  31. package/dist/utils/hooks.d.ts +97 -0
  32. package/dist/utils/hooks.js +223 -0
  33. package/dist/utils/mcpClient.d.ts +229 -0
  34. package/dist/utils/mcpClient.js +497 -0
  35. package/dist/utils/mcpConfig.d.ts +55 -0
  36. package/dist/utils/mcpConfig.js +177 -0
  37. package/dist/utils/mcpMarketplace.d.ts +49 -0
  38. package/dist/utils/mcpMarketplace.js +175 -0
  39. package/dist/utils/mcpRegistry.d.ts +129 -0
  40. package/dist/utils/mcpRegistry.js +427 -0
  41. package/dist/utils/mcpSamplingBridge.d.ts +32 -0
  42. package/dist/utils/mcpSamplingBridge.js +88 -0
  43. package/dist/utils/mcpStreamableHttp.d.ts +65 -0
  44. package/dist/utils/mcpStreamableHttp.js +207 -0
  45. package/dist/utils/openrouterPrefs.d.ts +36 -0
  46. package/dist/utils/openrouterPrefs.js +83 -0
  47. package/dist/utils/skillBundles.d.ts +84 -0
  48. package/dist/utils/skillBundles.js +257 -0
  49. package/dist/utils/skillBundlesCloud.d.ts +66 -0
  50. package/dist/utils/skillBundlesCloud.js +196 -0
  51. package/dist/utils/tokenTracker.d.ts +14 -2
  52. package/dist/utils/tokenTracker.js +59 -45
  53. package/dist/utils/toolExecution.d.ts +17 -1
  54. package/dist/utils/toolExecution.js +184 -6
  55. package/dist/utils/tools.d.ts +22 -6
  56. package/dist/utils/tools.js +83 -8
  57. package/package.json +3 -2
  58. package/bin/codeep-macos-arm64 +0 -0
  59. package/bin/codeep-macos-x64 +0 -0
@@ -165,10 +165,66 @@ export async function runAgent(prompt, projectContext, options = {}) {
165
165
  const protocol = config.get('protocol');
166
166
  const providerId = config.get('provider');
167
167
  const useNativeTools = supportsNativeTools(providerId, protocol);
168
+ // Fetch the MCP tool catalog once per agent run. The session id keys into
169
+ // mcpRegistry; if no MCP servers are registered (or mcpSessionId is unset,
170
+ // e.g. TUI mode) we get back an empty array and the agent behaves as
171
+ // before. We do this before building the system prompt so the fallback
172
+ // text path can include MCP tools in its catalog too.
173
+ //
174
+ // We also append per-server "virtual" tools that wrap resource_list /
175
+ // resource_read / prompt_list / prompt_get so the agent can discover and
176
+ // pull MCP resources & prompts without the user having to type `/mcp
177
+ // read <uri>` manually. Servers that don't expose resources or prompts
178
+ // get no virtual tools — the wrappers are only emitted where useful.
179
+ let mcpToolDefs = [];
180
+ if (opts.mcpSessionId) {
181
+ try {
182
+ const { getSessionTools, getSessionVirtualTools } = await import('./mcpRegistry.js');
183
+ const [registered, virtuals] = await Promise.all([
184
+ getSessionTools(opts.mcpSessionId),
185
+ getSessionVirtualTools(opts.mcpSessionId),
186
+ ]);
187
+ mcpToolDefs = [...registered, ...virtuals].map(t => ({
188
+ name: t.agentName,
189
+ description: t.description,
190
+ inputSchema: t.inputSchema,
191
+ }));
192
+ }
193
+ catch {
194
+ // Don't let a registry blip kill the whole agent run.
195
+ }
196
+ }
197
+ // Skill bundles — structured `.codeep/skills/<name>/SKILL.md` directories
198
+ // the agent can discover and invoke via the `invoke_skill` tool. We just
199
+ // add the tool def here; the catalog block is appended to systemPrompt
200
+ // below alongside project rules / progress / etc. so we don't clobber
201
+ // those.
202
+ let skillCatalogBlock = '';
203
+ try {
204
+ const { loadSkillBundles, formatBundlesForSysprompt } = await import('./skillBundles.js');
205
+ const bundles = loadSkillBundles(projectContext.root);
206
+ if (bundles.length > 0) {
207
+ mcpToolDefs.push({
208
+ name: 'invoke_skill',
209
+ description: 'Invoke a Codeep skill bundle (curated workflow). Returns the SKILL.md body — follow its instructions step by step. Use when the user\'s request matches a skill\'s purpose.',
210
+ inputSchema: {
211
+ type: 'object',
212
+ properties: {
213
+ name: { type: 'string', description: 'Skill name from the catalog (e.g. "deploy").' },
214
+ },
215
+ required: ['name'],
216
+ },
217
+ });
218
+ skillCatalogBlock = formatBundlesForSysprompt(bundles);
219
+ }
220
+ }
221
+ catch {
222
+ // Skill loading failure shouldn't fail the whole agent run.
223
+ }
168
224
  // Build system prompt - use fallback format if native tools not supported
169
225
  let systemPrompt = useNativeTools
170
226
  ? getAgentSystemPrompt(projectContext)
171
- : getFallbackSystemPrompt(projectContext);
227
+ : getFallbackSystemPrompt(projectContext, mcpToolDefs);
172
228
  // Inject project rules (from .codeep/rules.md or CODEEP.md)
173
229
  const projectRules = loadProjectRules(projectContext.root);
174
230
  if (projectRules) {
@@ -191,6 +247,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
191
247
  if (chatHistoryStr) {
192
248
  systemPrompt += chatHistoryStr;
193
249
  }
250
+ // Skill bundles catalog goes last — closest to the user prompt so the
251
+ // model is most likely to remember the available skills when matching
252
+ // intent. Empty string when there are none.
253
+ if (skillCatalogBlock) {
254
+ systemPrompt += '\n\n' + skillCatalogBlock;
255
+ }
194
256
  // Initial user message with optional task plan
195
257
  let initialPrompt = prompt;
196
258
  if (taskPlan) {
@@ -282,13 +344,35 @@ export async function runAgent(prompt, projectContext, options = {}) {
282
344
  // Calculate dynamic timeout based on task complexity
283
345
  const dynamicTimeout = calculateDynamicTimeout(iteration, baseTimeout);
284
346
  debug(`Using timeout: ${dynamicTimeout}ms (base: ${baseTimeout}ms)`);
347
+ // Refresh MCP tool list if a server flagged its catalog as changed
348
+ // (e.g. via `tools/list_changed` notification, or after an
349
+ // auto-restart). This keeps the agent in sync mid-run instead of
350
+ // requiring a session restart to see new tools.
351
+ if (opts.mcpSessionId) {
352
+ try {
353
+ const { consumeSessionCatalogChanges, getSessionTools } = await import('./mcpRegistry.js');
354
+ const dirty = consumeSessionCatalogChanges(opts.mcpSessionId);
355
+ if (dirty.has('tools')) {
356
+ const refreshed = await getSessionTools(opts.mcpSessionId);
357
+ mcpToolDefs = refreshed.map(t => ({
358
+ name: t.agentName,
359
+ description: t.description,
360
+ inputSchema: t.inputSchema,
361
+ }));
362
+ debug(`MCP tool catalog refreshed mid-run: ${mcpToolDefs.length} tool(s)`);
363
+ }
364
+ }
365
+ catch {
366
+ // Don't let a refresh hiccup break the iteration.
367
+ }
368
+ }
285
369
  // Get AI response with retry logic for timeouts
286
370
  let chatResponse = null;
287
371
  let retryCount = 0;
288
372
  while (true) {
289
373
  try {
290
- chatResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, dynamicTimeout * (1 + retryCount * 0.5) // Increase timeout on retry
291
- );
374
+ chatResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, dynamicTimeout * (1 + retryCount * 0.5), // Increase timeout on retry
375
+ mcpToolDefs);
292
376
  consecutiveTimeouts = 0; // Reset consecutive count on success
293
377
  consecutiveRateLimits = 0;
294
378
  break;
@@ -559,12 +643,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
559
643
  catch (err) {
560
644
  debug('onExecuteCommand callback threw, falling back to local execution:', err);
561
645
  // Fallback to local execution if callback throws
562
- toolResult = await executeTool(toolCall, cwd);
646
+ toolResult = await executeTool(toolCall, cwd, opts.fs, opts.mcpSessionId);
563
647
  }
564
648
  }
565
649
  }
566
650
  else {
567
- toolResult = await executeTool(toolCall, projectContext.root || process.cwd());
651
+ toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
568
652
  }
569
653
  opts.onToolResult?.(toolResult, toolCall);
570
654
  // Log action
@@ -737,7 +821,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
737
821
  }
738
822
  // Get AI response to fix errors
739
823
  try {
740
- const fixResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal);
824
+ const fixResponse = await agentChat(messages, systemPrompt, opts.onChunk, opts.abortSignal, undefined, mcpToolDefs);
741
825
  const { content: fixContent, toolCalls: fixToolCalls } = fixResponse;
742
826
  if (fixToolCalls.length === 0) {
743
827
  // Agent gave up or thinks it's fixed
@@ -749,7 +833,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
749
833
  const fixResults = [];
750
834
  for (const toolCall of fixToolCalls) {
751
835
  opts.onToolCall?.(toolCall);
752
- const toolResult = await executeTool(toolCall, projectContext.root || process.cwd());
836
+ const toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
753
837
  opts.onToolResult?.(toolResult, toolCall);
754
838
  const actionLog = createActionLog(toolCall, toolResult);
755
839
  actions.push(actionLog);
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { ProjectContext } from './project';
15
15
  import { Message } from '../config/index';
16
+ import { AdditionalToolDef } from './tools';
16
17
  import type { AgentChatResponse } from './agentStream';
17
18
  export type { AgentChatResponse };
18
19
  /**
@@ -52,12 +53,19 @@ export declare function formatChatHistoryForAgent(history?: Array<{
52
53
  content: string;
53
54
  }>, maxChars?: number): string;
54
55
  export declare function getAgentSystemPrompt(projectContext: ProjectContext): string;
55
- export declare function getFallbackSystemPrompt(projectContext: ProjectContext): string;
56
+ export declare function getFallbackSystemPrompt(projectContext: ProjectContext, additionalTools?: AdditionalToolDef[]): string;
56
57
  /**
57
58
  * Make a chat API call for agent mode with native tool support.
58
59
  * Falls back to agentChatFallback() if provider doesn't support tools.
59
60
  */
60
- export declare function agentChat(messages: Message[], systemPrompt: string, onChunk?: (chunk: string) => void, abortSignal?: AbortSignal, dynamicTimeout?: number): Promise<AgentChatResponse>;
61
+ export declare function agentChat(messages: Message[], systemPrompt: string, onChunk?: (chunk: string) => void, abortSignal?: AbortSignal, dynamicTimeout?: number,
62
+ /**
63
+ * Extra tool definitions appended to the catalog (currently used to
64
+ * surface MCP-registered tools as first-class entries the model can
65
+ * invoke). Optional — built-in tools work the same whether this is
66
+ * omitted or an empty array.
67
+ */
68
+ additionalTools?: AdditionalToolDef[]): Promise<AgentChatResponse>;
61
69
  /**
62
70
  * Fallback chat without native tools (text-based tool format)
63
71
  */
@@ -20,6 +20,7 @@ import { getProviderBaseUrl, getProviderAuthHeader, supportsNativeTools, getEffe
20
20
  import { recordTokenUsage, extractOpenAIUsage, extractAnthropicUsage } from './tokenTracker.js';
21
21
  import { parseOpenAIToolCalls, parseAnthropicToolCalls, parseToolCalls } from './toolParsing.js';
22
22
  import { formatToolDefinitions, getOpenAITools, getAnthropicTools } from './tools.js';
23
+ import { readOpenRouterPreferences } from './openrouterPrefs.js';
23
24
  import { handleStream, handleOpenAIAgentStream, handleAnthropicAgentStream } from './agentStream.js';
24
25
  import { logger } from './logger.js';
25
26
  const debug = (...args) => {
@@ -212,14 +213,21 @@ ${projectContext.structure ? `\n## Project Structure\n${projectContext.structure
212
213
  return intelligence ? `\n\n${generateContextFromIntelligence(intelligence)}` : '';
213
214
  })()}`;
214
215
  }
215
- export function getFallbackSystemPrompt(projectContext) {
216
- return getAgentSystemPrompt(projectContext) + '\n\n' + formatToolDefinitions();
216
+ export function getFallbackSystemPrompt(projectContext, additionalTools) {
217
+ return getAgentSystemPrompt(projectContext) + '\n\n' + formatToolDefinitions(additionalTools);
217
218
  }
218
219
  /**
219
220
  * Make a chat API call for agent mode with native tool support.
220
221
  * Falls back to agentChatFallback() if provider doesn't support tools.
221
222
  */
222
- export async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTimeout) {
223
+ export async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTimeout,
224
+ /**
225
+ * Extra tool definitions appended to the catalog (currently used to
226
+ * surface MCP-registered tools as first-class entries the model can
227
+ * invoke). Optional — built-in tools work the same whether this is
228
+ * omitted or an empty array.
229
+ */
230
+ additionalTools) {
223
231
  const protocol = config.get('protocol');
224
232
  const model = config.get('model');
225
233
  const providerId = config.get('provider');
@@ -255,6 +263,14 @@ export async function agentChat(messages, systemPrompt, onChunk, abortSignal, dy
255
263
  }
256
264
  if (protocol === 'anthropic')
257
265
  headers['anthropic-version'] = '2023-06-01';
266
+ // OpenRouter branding — surfaces "Codeep" in the OpenRouter dashboard
267
+ // attribution so users (and OpenRouter itself, for any partnership
268
+ // tracking) see which app generated the traffic. Spec is informal; both
269
+ // headers are documented at openrouter.ai/docs#headers.
270
+ if (providerId === 'openrouter') {
271
+ headers['HTTP-Referer'] = 'https://codeep.dev';
272
+ headers['X-Title'] = 'Codeep';
273
+ }
258
274
  try {
259
275
  let endpoint;
260
276
  let body;
@@ -264,18 +280,30 @@ export async function agentChat(messages, systemPrompt, onChunk, abortSignal, dy
264
280
  const maxTok = getEffectiveMaxTokens(providerId, Math.max(config.get('maxTokens'), 16384));
265
281
  const tokParam = usesMaxCompletionTokens(providerId) ? { max_completion_tokens: maxTok } : { max_tokens: maxTok };
266
282
  endpoint = `${baseUrl}/chat/completions`;
283
+ // OpenRouter-specific extras: request `usage` block in the response
284
+ // body so we get per-call cost (skips our local pricing lookup), and
285
+ // optionally a provider-routing preferences object the user set via
286
+ // `/openrouter prefer …`.
287
+ const openRouterExtras = {};
288
+ if (providerId === 'openrouter') {
289
+ openRouterExtras.usage = { include: true };
290
+ const prefs = readOpenRouterPreferences();
291
+ if (prefs)
292
+ openRouterExtras.provider = prefs;
293
+ }
267
294
  body = {
268
295
  model, messages: [{ role: 'system', content: systemPrompt }, ...messages],
269
- tools: getOpenAITools(), tool_choice: 'auto', stream: useStreaming,
296
+ tools: getOpenAITools(additionalTools), tool_choice: 'auto', stream: useStreaming,
270
297
  ...tempParam, ...tokParam,
271
298
  ...(useStreaming && providerId === 'openai' ? { stream_options: { include_usage: true } } : {}),
299
+ ...openRouterExtras,
272
300
  };
273
301
  }
274
302
  else {
275
303
  endpoint = `${baseUrl}/v1/messages`;
276
304
  body = {
277
305
  model, system: systemPrompt, messages,
278
- tools: getAnthropicTools(), stream: useStreaming,
306
+ tools: getAnthropicTools(additionalTools), stream: useStreaming,
279
307
  ...tempParam, max_tokens: getEffectiveMaxTokens(providerId, Math.max(config.get('maxTokens'), 16384)),
280
308
  };
281
309
  }
@@ -298,8 +326,15 @@ export async function agentChat(messages, systemPrompt, onChunk, abortSignal, dy
298
326
  const data = await response.json();
299
327
  const usageExtractor = protocol === 'openai' ? extractOpenAIUsage : extractAnthropicUsage;
300
328
  const usage = usageExtractor(data);
301
- if (usage)
302
- recordTokenUsage(usage, model, providerId);
329
+ if (usage) {
330
+ // OpenRouter returns the authoritative per-call cost in
331
+ // `usage.cost` (USD). Use it instead of our local pricing table
332
+ // since the catalog has 100+ models we don't track ourselves.
333
+ const reportedCost = providerId === 'openrouter' && typeof data?.usage?.cost === 'number'
334
+ ? data.usage.cost
335
+ : undefined;
336
+ recordTokenUsage(usage, model, providerId, reportedCost);
337
+ }
303
338
  if (protocol === 'openai') {
304
339
  const message = data.choices?.[0]?.message;
305
340
  const content = message?.content || '';
@@ -426,8 +461,12 @@ export async function agentChatFallback(messages, systemPrompt, onChunk, abortSi
426
461
  const data = await response.json();
427
462
  const fallbackUsageExtractor = protocol === 'openai' ? extractOpenAIUsage : extractAnthropicUsage;
428
463
  const fallbackUsage = fallbackUsageExtractor(data);
429
- if (fallbackUsage)
430
- recordTokenUsage(fallbackUsage, model, providerId);
464
+ if (fallbackUsage) {
465
+ const reportedCost = providerId === 'openrouter' && typeof data?.usage?.cost === 'number'
466
+ ? data.usage.cost
467
+ : undefined;
468
+ recordTokenUsage(fallbackUsage, model, providerId, reportedCost);
469
+ }
431
470
  content = protocol === 'openai' ? (data.choices?.[0]?.message?.content || '') : (data.content?.[0]?.text || '');
432
471
  }
433
472
  const toolCalls = parseToolCalls(content);
@@ -120,8 +120,12 @@ export async function handleOpenAIAgentStream(body, onChunk, model, providerId)
120
120
  }
121
121
  if (usageData) {
122
122
  const usage = extractOpenAIUsage(usageData);
123
- if (usage)
124
- recordTokenUsage(usage, model, providerId);
123
+ if (usage) {
124
+ const reportedCost = providerId === 'openrouter' && typeof usageData?.usage?.cost === 'number'
125
+ ? usageData.usage.cost
126
+ : undefined;
127
+ recordTokenUsage(usage, model, providerId, reportedCost);
128
+ }
125
129
  }
126
130
  const rawToolCalls = Array.from(toolCallMap.values()).map(tc => ({
127
131
  id: tc.id,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session checkpoints — `/checkpoint` and `/rewind`.
3
+ *
4
+ * A checkpoint is a snapshot of the agent conversation at a point in time:
5
+ * session history, provider/model state, and the list of files the agent has
6
+ * touched in this session. It does NOT snapshot file content — that's git's
7
+ * job, and trying to do it ourselves means either a 100KB-per-file ceiling
8
+ * with surprise truncation, or a real-time disk hog. We surface a hint at
9
+ * rewind time telling the user how to restore files via git.
10
+ *
11
+ * Use cases this is for:
12
+ * - User suspects the agent is going off the rails after N steps and
13
+ * wants to roll the conversation back to before that turn.
14
+ * - User is about to start a risky multi-step refactor and wants a
15
+ * named bookmark to return to if it gets messy.
16
+ *
17
+ * Use cases this is NOT for:
18
+ * - Replacing git. If you only need file rollback, `git restore` /
19
+ * `git stash` is faster and reliable.
20
+ * - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
21
+ * folder — checkpoints are per-project, not global.
22
+ *
23
+ * On-disk shape (`.codeep/checkpoints/<id>.json`):
24
+ * {
25
+ * "id": "ck-2026-05-18-abc123",
26
+ * "name": "before big refactor",
27
+ * "createdAt": "...",
28
+ * "sessionId": "session-2026-05-18-...",
29
+ * "provider": "z.ai",
30
+ * "model": "glm-5.1",
31
+ * "messages": [ ... ],
32
+ * "filesTouched": ["src/a.ts", "src/b.ts"],
33
+ * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
34
+ * }
35
+ */
36
+ import type { Message } from '../config/index.js';
37
+ export interface Checkpoint {
38
+ id: string;
39
+ name?: string;
40
+ createdAt: string;
41
+ sessionId: string;
42
+ provider: string;
43
+ model: string;
44
+ messages: Message[];
45
+ filesTouched: string[];
46
+ gitHead?: string;
47
+ }
48
+ /** Lightweight metadata for `/checkpoints` list — avoids loading full message arrays. */
49
+ export interface CheckpointMeta {
50
+ id: string;
51
+ name?: string;
52
+ createdAt: string;
53
+ sessionId: string;
54
+ messageCount: number;
55
+ filesTouchedCount: number;
56
+ gitHead?: string;
57
+ }
58
+ /**
59
+ * Create a new checkpoint snapshot of the current session state.
60
+ * Returns the persisted checkpoint object.
61
+ */
62
+ export declare function createCheckpoint(opts: {
63
+ workspaceRoot: string;
64
+ sessionId: string;
65
+ provider: string;
66
+ model: string;
67
+ messages: Message[];
68
+ filesTouched: string[];
69
+ name?: string;
70
+ }): Checkpoint;
71
+ /**
72
+ * Load a single checkpoint by id. Returns null if not found or unreadable.
73
+ */
74
+ export declare function loadCheckpoint(workspaceRoot: string, id: string): Checkpoint | null;
75
+ /**
76
+ * List checkpoints in the workspace, newest first. Returns metadata only —
77
+ * the full `messages` array is not loaded so this is cheap for `/checkpoints`.
78
+ */
79
+ export declare function listCheckpoints(workspaceRoot: string): CheckpointMeta[];
80
+ /**
81
+ * Delete a checkpoint by id. Returns true if a file was removed.
82
+ */
83
+ export declare function deleteCheckpoint(workspaceRoot: string, id: string): boolean;
84
+ /**
85
+ * Format a checkpoint list as a Markdown block for `/checkpoints` output.
86
+ */
87
+ export declare function formatCheckpointList(metas: CheckpointMeta[]): string;
88
+ /**
89
+ * Build the user-facing hint shown after a successful /rewind. Tells the
90
+ * user how to bring their files back to checkpoint state — checkpoints
91
+ * don't snapshot file content, so this is the only restore path.
92
+ */
93
+ export declare function buildRewindGitHint(cp: Checkpoint): string;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Session checkpoints — `/checkpoint` and `/rewind`.
3
+ *
4
+ * A checkpoint is a snapshot of the agent conversation at a point in time:
5
+ * session history, provider/model state, and the list of files the agent has
6
+ * touched in this session. It does NOT snapshot file content — that's git's
7
+ * job, and trying to do it ourselves means either a 100KB-per-file ceiling
8
+ * with surprise truncation, or a real-time disk hog. We surface a hint at
9
+ * rewind time telling the user how to restore files via git.
10
+ *
11
+ * Use cases this is for:
12
+ * - User suspects the agent is going off the rails after N steps and
13
+ * wants to roll the conversation back to before that turn.
14
+ * - User is about to start a risky multi-step refactor and wants a
15
+ * named bookmark to return to if it gets messy.
16
+ *
17
+ * Use cases this is NOT for:
18
+ * - Replacing git. If you only need file rollback, `git restore` /
19
+ * `git stash` is faster and reliable.
20
+ * - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
21
+ * folder — checkpoints are per-project, not global.
22
+ *
23
+ * On-disk shape (`.codeep/checkpoints/<id>.json`):
24
+ * {
25
+ * "id": "ck-2026-05-18-abc123",
26
+ * "name": "before big refactor",
27
+ * "createdAt": "...",
28
+ * "sessionId": "session-2026-05-18-...",
29
+ * "provider": "z.ai",
30
+ * "model": "glm-5.1",
31
+ * "messages": [ ... ],
32
+ * "filesTouched": ["src/a.ts", "src/b.ts"],
33
+ * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
34
+ * }
35
+ */
36
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
37
+ import { join } from 'path';
38
+ import { randomUUID } from 'crypto';
39
+ import { execSync } from 'child_process';
40
+ function getCheckpointsDir(workspaceRoot) {
41
+ const dir = join(workspaceRoot, '.codeep', 'checkpoints');
42
+ if (!existsSync(dir))
43
+ mkdirSync(dir, { recursive: true });
44
+ return dir;
45
+ }
46
+ function readGitHead(workspaceRoot) {
47
+ try {
48
+ // Short SHA is enough for human display; full SHA available via git if needed.
49
+ const out = execSync('git rev-parse --short HEAD', {
50
+ cwd: workspaceRoot,
51
+ encoding: 'utf-8',
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ timeout: 2000,
54
+ }).trim();
55
+ return out || undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * Generate a unique, human-recognizable checkpoint id.
63
+ * Format: `ck-YYYY-MM-DD-<8-char-uuid>`
64
+ */
65
+ function generateCheckpointId() {
66
+ const date = new Date().toISOString().slice(0, 10);
67
+ return `ck-${date}-${randomUUID().slice(0, 8)}`;
68
+ }
69
+ /**
70
+ * Create a new checkpoint snapshot of the current session state.
71
+ * Returns the persisted checkpoint object.
72
+ */
73
+ export function createCheckpoint(opts) {
74
+ const checkpoint = {
75
+ id: generateCheckpointId(),
76
+ name: opts.name,
77
+ createdAt: new Date().toISOString(),
78
+ sessionId: opts.sessionId,
79
+ provider: opts.provider,
80
+ model: opts.model,
81
+ messages: opts.messages,
82
+ filesTouched: opts.filesTouched,
83
+ gitHead: readGitHead(opts.workspaceRoot),
84
+ };
85
+ const dir = getCheckpointsDir(opts.workspaceRoot);
86
+ writeFileSync(join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
87
+ return checkpoint;
88
+ }
89
+ /**
90
+ * Load a single checkpoint by id. Returns null if not found or unreadable.
91
+ */
92
+ export function loadCheckpoint(workspaceRoot, id) {
93
+ // Defensive: reject ids that look like path traversal attempts. Real ids are
94
+ // `ck-YYYY-MM-DD-<hex>` so the regex is tight enough to catch typos too.
95
+ if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
96
+ return null;
97
+ const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
98
+ if (!existsSync(file))
99
+ return null;
100
+ try {
101
+ return JSON.parse(readFileSync(file, 'utf-8'));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * List checkpoints in the workspace, newest first. Returns metadata only —
109
+ * the full `messages` array is not loaded so this is cheap for `/checkpoints`.
110
+ */
111
+ export function listCheckpoints(workspaceRoot) {
112
+ const dir = getCheckpointsDir(workspaceRoot);
113
+ let files;
114
+ try {
115
+ files = readdirSync(dir).filter(f => f.endsWith('.json'));
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ const metas = [];
121
+ for (const file of files) {
122
+ try {
123
+ const full = join(dir, file);
124
+ const stat = statSync(full);
125
+ const cp = JSON.parse(readFileSync(full, 'utf-8'));
126
+ metas.push({
127
+ id: cp.id,
128
+ name: cp.name,
129
+ createdAt: cp.createdAt || stat.mtime.toISOString(),
130
+ sessionId: cp.sessionId,
131
+ messageCount: cp.messages?.length ?? 0,
132
+ filesTouchedCount: cp.filesTouched?.length ?? 0,
133
+ gitHead: cp.gitHead,
134
+ });
135
+ }
136
+ catch {
137
+ // Skip corrupt entries — don't let one bad checkpoint block the list.
138
+ }
139
+ }
140
+ // Newest first by createdAt.
141
+ metas.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
142
+ return metas;
143
+ }
144
+ /**
145
+ * Delete a checkpoint by id. Returns true if a file was removed.
146
+ */
147
+ export function deleteCheckpoint(workspaceRoot, id) {
148
+ if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
149
+ return false;
150
+ const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
151
+ if (!existsSync(file))
152
+ return false;
153
+ try {
154
+ unlinkSync(file);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ /**
162
+ * Format a checkpoint list as a Markdown block for `/checkpoints` output.
163
+ */
164
+ export function formatCheckpointList(metas) {
165
+ if (metas.length === 0) {
166
+ return [
167
+ '_No checkpoints yet._',
168
+ '',
169
+ 'Create one with `/checkpoint [name]` before risky refactors so you can `/rewind` if things go sideways.',
170
+ ].join('\n');
171
+ }
172
+ const lines = ['## Checkpoints', ''];
173
+ for (const m of metas) {
174
+ const label = m.name ? `**${m.name}** (\`${m.id}\`)` : `\`${m.id}\``;
175
+ const gitFragment = m.gitHead ? ` · git \`${m.gitHead}\`` : '';
176
+ const date = m.createdAt.slice(0, 19).replace('T', ' ');
177
+ lines.push(`- ${label}`, ` ${date} · ${m.messageCount} message${m.messageCount === 1 ? '' : 's'} · ${m.filesTouchedCount} file${m.filesTouchedCount === 1 ? '' : 's'} touched${gitFragment}`);
178
+ }
179
+ lines.push('', 'Use `/rewind <id>` to restore a checkpoint, or `/checkpoint delete <id>` to drop one.');
180
+ return lines.join('\n');
181
+ }
182
+ /**
183
+ * Build the user-facing hint shown after a successful /rewind. Tells the
184
+ * user how to bring their files back to checkpoint state — checkpoints
185
+ * don't snapshot file content, so this is the only restore path.
186
+ */
187
+ export function buildRewindGitHint(cp) {
188
+ if (cp.gitHead) {
189
+ return [
190
+ `**Files were NOT restored** — only the conversation. To bring files back to the state at checkpoint time:`,
191
+ '',
192
+ '```bash',
193
+ `git stash # save current uncommitted changes if you want to keep them`,
194
+ `git checkout ${cp.gitHead} -- ${cp.filesTouched.length > 0 ? cp.filesTouched.map(f => `'${f}'`).join(' ') : '.'}`,
195
+ '```',
196
+ ].join('\n');
197
+ }
198
+ return [
199
+ '**Files were NOT restored** — only the conversation.',
200
+ cp.filesTouched.length > 0
201
+ ? `Files the agent touched between checkpoint and now: ${cp.filesTouched.map(f => `\`${f}\``).join(', ')}.`
202
+ : '',
203
+ 'Use `git` (or your editor\'s undo) to revert any file changes you don\'t want.',
204
+ ].filter(Boolean).join('\n');
205
+ }
@@ -27,6 +27,30 @@ export declare function clearContext(projectPath: string): boolean;
27
27
  * Get all saved contexts
28
28
  */
29
29
  export declare function getAllContexts(): ConversationContext[];
30
+ /**
31
+ * AI-powered compaction of a conversation history.
32
+ *
33
+ * Used by the `/compact` slash command. Sends the older portion of the
34
+ * conversation to the active provider with a summarization prompt, then
35
+ * replaces those messages with a single system message containing the
36
+ * summary. Keeps the last `keepRecent` messages verbatim so the
37
+ * conversation can continue without losing the most recent context.
38
+ *
39
+ * Returns the same `history` (untouched) if there isn't enough to
40
+ * meaningfully compact.
41
+ */
42
+ export declare function compactHistory(history: Message[], options?: {
43
+ keepRecent?: number;
44
+ projectContext?: import('./project').ProjectContext | null;
45
+ /** Cap on how long the summarization API call can take, in ms. Defaults to 60s. */
46
+ timeoutMs?: number;
47
+ /** External abort signal (e.g. user pressed /stop). Combined with the timeout. */
48
+ abortSignal?: AbortSignal;
49
+ }): Promise<{
50
+ compacted: Message[];
51
+ replaced: number;
52
+ summary: string;
53
+ }>;
30
54
  /**
31
55
  * Summarize messages for context persistence
32
56
  * Keeps recent messages and summarizes older ones