gencode-ai 0.1.2 → 0.2.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 (180) hide show
  1. package/README.md +15 -17
  2. package/dist/agent/agent.d.ts +43 -0
  3. package/dist/agent/agent.d.ts.map +1 -1
  4. package/dist/agent/agent.js +107 -4
  5. package/dist/agent/agent.js.map +1 -1
  6. package/dist/agent/index.d.ts +1 -0
  7. package/dist/agent/index.d.ts.map +1 -1
  8. package/dist/agent/types.d.ts +20 -1
  9. package/dist/agent/types.d.ts.map +1 -1
  10. package/dist/checkpointing/checkpoint-manager.d.ts +87 -0
  11. package/dist/checkpointing/checkpoint-manager.d.ts.map +1 -0
  12. package/dist/checkpointing/checkpoint-manager.js +281 -0
  13. package/dist/checkpointing/checkpoint-manager.js.map +1 -0
  14. package/dist/checkpointing/index.d.ts +29 -0
  15. package/dist/checkpointing/index.d.ts.map +1 -0
  16. package/dist/checkpointing/index.js +29 -0
  17. package/dist/checkpointing/index.js.map +1 -0
  18. package/dist/checkpointing/types.d.ts +98 -0
  19. package/dist/checkpointing/types.d.ts.map +1 -0
  20. package/dist/checkpointing/types.js +7 -0
  21. package/dist/checkpointing/types.js.map +1 -0
  22. package/dist/cli/components/App.d.ts.map +1 -1
  23. package/dist/cli/components/App.js +193 -7
  24. package/dist/cli/components/App.js.map +1 -1
  25. package/dist/cli/components/CommandSuggestions.d.ts.map +1 -1
  26. package/dist/cli/components/CommandSuggestions.js +5 -0
  27. package/dist/cli/components/CommandSuggestions.js.map +1 -1
  28. package/dist/cli/components/Messages.d.ts +7 -1
  29. package/dist/cli/components/Messages.d.ts.map +1 -1
  30. package/dist/cli/components/Messages.js +28 -2
  31. package/dist/cli/components/Messages.js.map +1 -1
  32. package/dist/cli/components/ModeIndicator.d.ts +42 -0
  33. package/dist/cli/components/ModeIndicator.d.ts.map +1 -0
  34. package/dist/cli/components/ModeIndicator.js +52 -0
  35. package/dist/cli/components/ModeIndicator.js.map +1 -0
  36. package/dist/cli/components/PlanApproval.d.ts +36 -0
  37. package/dist/cli/components/PlanApproval.d.ts.map +1 -0
  38. package/dist/cli/components/PlanApproval.js +154 -0
  39. package/dist/cli/components/PlanApproval.js.map +1 -0
  40. package/dist/cli/components/QuestionPrompt.d.ts +23 -0
  41. package/dist/cli/components/QuestionPrompt.d.ts.map +1 -0
  42. package/dist/cli/components/QuestionPrompt.js +231 -0
  43. package/dist/cli/components/QuestionPrompt.js.map +1 -0
  44. package/dist/cli/components/index.d.ts +1 -0
  45. package/dist/cli/components/index.d.ts.map +1 -1
  46. package/dist/cli/components/index.js +1 -0
  47. package/dist/cli/components/index.js.map +1 -1
  48. package/dist/cli/components/theme.d.ts +9 -0
  49. package/dist/cli/components/theme.d.ts.map +1 -1
  50. package/dist/cli/components/theme.js +14 -1
  51. package/dist/cli/components/theme.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/permissions/types.d.ts.map +1 -1
  57. package/dist/permissions/types.js +2 -0
  58. package/dist/permissions/types.js.map +1 -1
  59. package/dist/planning/index.d.ts +13 -0
  60. package/dist/planning/index.d.ts.map +1 -0
  61. package/dist/planning/index.js +15 -0
  62. package/dist/planning/index.js.map +1 -0
  63. package/dist/planning/plan-file.d.ts +59 -0
  64. package/dist/planning/plan-file.d.ts.map +1 -0
  65. package/dist/planning/plan-file.js +278 -0
  66. package/dist/planning/plan-file.js.map +1 -0
  67. package/dist/planning/state.d.ts +127 -0
  68. package/dist/planning/state.d.ts.map +1 -0
  69. package/dist/planning/state.js +261 -0
  70. package/dist/planning/state.js.map +1 -0
  71. package/dist/planning/tools/enter-plan-mode.d.ts +25 -0
  72. package/dist/planning/tools/enter-plan-mode.d.ts.map +1 -0
  73. package/dist/planning/tools/enter-plan-mode.js +98 -0
  74. package/dist/planning/tools/enter-plan-mode.js.map +1 -0
  75. package/dist/planning/tools/exit-plan-mode.d.ts +24 -0
  76. package/dist/planning/tools/exit-plan-mode.d.ts.map +1 -0
  77. package/dist/planning/tools/exit-plan-mode.js +149 -0
  78. package/dist/planning/tools/exit-plan-mode.js.map +1 -0
  79. package/dist/planning/types.d.ts +100 -0
  80. package/dist/planning/types.d.ts.map +1 -0
  81. package/dist/planning/types.js +28 -0
  82. package/dist/planning/types.js.map +1 -0
  83. package/dist/pricing/calculator.d.ts +21 -0
  84. package/dist/pricing/calculator.d.ts.map +1 -0
  85. package/dist/pricing/calculator.js +59 -0
  86. package/dist/pricing/calculator.js.map +1 -0
  87. package/dist/pricing/index.d.ts +7 -0
  88. package/dist/pricing/index.d.ts.map +1 -0
  89. package/dist/pricing/index.js +7 -0
  90. package/dist/pricing/index.js.map +1 -0
  91. package/dist/pricing/models.d.ts +20 -0
  92. package/dist/pricing/models.d.ts.map +1 -0
  93. package/dist/pricing/models.js +322 -0
  94. package/dist/pricing/models.js.map +1 -0
  95. package/dist/pricing/types.d.ts +30 -0
  96. package/dist/pricing/types.d.ts.map +1 -0
  97. package/dist/pricing/types.js +5 -0
  98. package/dist/pricing/types.js.map +1 -0
  99. package/dist/providers/anthropic.d.ts.map +1 -1
  100. package/dist/providers/anthropic.js +17 -10
  101. package/dist/providers/anthropic.js.map +1 -1
  102. package/dist/providers/gemini.d.ts.map +1 -1
  103. package/dist/providers/gemini.js +21 -14
  104. package/dist/providers/gemini.js.map +1 -1
  105. package/dist/providers/openai.d.ts.map +1 -1
  106. package/dist/providers/openai.js +12 -8
  107. package/dist/providers/openai.js.map +1 -1
  108. package/dist/providers/types.d.ts +2 -0
  109. package/dist/providers/types.d.ts.map +1 -1
  110. package/dist/providers/vertex-ai.d.ts.map +1 -1
  111. package/dist/providers/vertex-ai.js +17 -10
  112. package/dist/providers/vertex-ai.js.map +1 -1
  113. package/dist/session/manager.d.ts +4 -0
  114. package/dist/session/manager.d.ts.map +1 -1
  115. package/dist/session/manager.js +8 -0
  116. package/dist/session/manager.js.map +1 -1
  117. package/dist/tools/builtin/ask-user.d.ts +64 -0
  118. package/dist/tools/builtin/ask-user.d.ts.map +1 -0
  119. package/dist/tools/builtin/ask-user.js +148 -0
  120. package/dist/tools/builtin/ask-user.js.map +1 -0
  121. package/dist/tools/index.d.ts +19 -1
  122. package/dist/tools/index.d.ts.map +1 -1
  123. package/dist/tools/index.js +11 -0
  124. package/dist/tools/index.js.map +1 -1
  125. package/dist/tools/registry.d.ts +13 -0
  126. package/dist/tools/registry.d.ts.map +1 -1
  127. package/dist/tools/registry.js +79 -2
  128. package/dist/tools/registry.js.map +1 -1
  129. package/dist/tools/types.d.ts +17 -0
  130. package/dist/tools/types.d.ts.map +1 -1
  131. package/dist/tools/types.js.map +1 -1
  132. package/docs/cost-tracking-comparison.md +904 -0
  133. package/docs/operating-modes.md +96 -0
  134. package/docs/proposals/0012-ask-user-question.md +66 -1
  135. package/docs/proposals/0025-cost-tracking.md +60 -2
  136. package/docs/proposals/README.md +2 -2
  137. package/examples/test-ask-user.ts +167 -0
  138. package/examples/test-checkpointing.ts +121 -0
  139. package/examples/test-cost-tracking.ts +77 -0
  140. package/examples/test-interrupt-cleanup.ts +94 -0
  141. package/package.json +1 -1
  142. package/src/agent/agent.ts +130 -4
  143. package/src/agent/index.ts +1 -0
  144. package/src/agent/types.ts +19 -1
  145. package/src/checkpointing/checkpoint-manager.ts +327 -0
  146. package/src/checkpointing/index.ts +45 -0
  147. package/src/checkpointing/types.ts +104 -0
  148. package/src/cli/components/App.tsx +259 -8
  149. package/src/cli/components/CommandSuggestions.tsx +5 -0
  150. package/src/cli/components/Messages.tsx +66 -4
  151. package/src/cli/components/ModeIndicator.tsx +174 -0
  152. package/src/cli/components/PlanApproval.tsx +327 -0
  153. package/src/cli/components/QuestionPrompt.tsx +462 -0
  154. package/src/cli/components/index.ts +1 -0
  155. package/src/cli/components/theme.ts +14 -1
  156. package/src/index.ts +15 -0
  157. package/src/permissions/types.ts +2 -0
  158. package/src/planning/index.ts +53 -0
  159. package/src/planning/plan-file.ts +326 -0
  160. package/src/planning/state.ts +305 -0
  161. package/src/planning/tools/enter-plan-mode.ts +111 -0
  162. package/src/planning/tools/exit-plan-mode.ts +170 -0
  163. package/src/planning/types.ts +150 -0
  164. package/src/pricing/calculator.ts +71 -0
  165. package/src/pricing/index.ts +7 -0
  166. package/src/pricing/models.ts +334 -0
  167. package/src/pricing/types.ts +32 -0
  168. package/src/prompts/system/base.txt +42 -0
  169. package/src/prompts/tools/ask-user.txt +110 -0
  170. package/src/providers/anthropic.ts +21 -10
  171. package/src/providers/gemini.ts +25 -14
  172. package/src/providers/openai.ts +17 -8
  173. package/src/providers/types.ts +3 -0
  174. package/src/providers/vertex-ai.ts +21 -10
  175. package/src/session/manager.ts +9 -0
  176. package/src/tools/builtin/ask-user.ts +185 -0
  177. package/src/tools/index.ts +23 -0
  178. package/src/tools/registry.ts +95 -2
  179. package/src/tools/types.ts +18 -0
  180. package/.gencode/settings.local.json +0 -7
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Test Interrupt Cleanup
3
+ *
4
+ * Simulates the interrupt scenario and verifies cleanup works correctly
5
+ */
6
+
7
+ import { Agent } from '../src/agent/index.js';
8
+ import type { Message } from '../src/providers/types.js';
9
+
10
+ async function testInterruptCleanup() {
11
+ console.log('🧪 Testing Interrupt Cleanup\n');
12
+
13
+ const agent = new Agent({
14
+ provider: 'anthropic',
15
+ model: 'claude-3-5-sonnet-20241022',
16
+ cwd: process.cwd(),
17
+ });
18
+
19
+ try {
20
+ // Simulate incomplete tool_use message (what happens during interrupt)
21
+ console.log('1️⃣ Simulating incomplete tool_use message...');
22
+
23
+ // Access private messages array via getHistory and direct manipulation
24
+ const history = agent.getHistory();
25
+ console.log(` Initial history: ${history.length} messages`);
26
+
27
+ // Add a user message
28
+ (agent as any).messages.push({
29
+ role: 'user',
30
+ content: 'Create a file',
31
+ });
32
+ console.log(` After user message: ${agent.getHistory().length} messages`);
33
+
34
+ // Add an assistant message with tool_use (simulating interrupt point)
35
+ (agent as any).messages.push({
36
+ role: 'assistant',
37
+ content: [
38
+ { type: 'text', text: 'I will create the file.' },
39
+ {
40
+ type: 'tool_use',
41
+ id: 'toolu_test_123',
42
+ name: 'Write',
43
+ input: { file_path: 'test.txt', content: 'hello' },
44
+ },
45
+ ],
46
+ });
47
+ console.log(` After assistant+tool_use: ${agent.getHistory().length} messages`);
48
+ console.log(` Last message role: ${agent.getHistory()[agent.getHistory().length - 1].role}`);
49
+
50
+ // Verify incomplete state
51
+ const lastMessage = agent.getHistory()[agent.getHistory().length - 1];
52
+ const hasToolUse = Array.isArray(lastMessage.content) &&
53
+ lastMessage.content.some((c: any) => c.type === 'tool_use');
54
+ console.log(` Has incomplete tool_use: ${hasToolUse}\n`);
55
+
56
+ console.log('2️⃣ Calling cleanupIncompleteMessages()...');
57
+ agent.cleanupIncompleteMessages();
58
+
59
+ const afterCleanup = agent.getHistory();
60
+ console.log(` After cleanup: ${afterCleanup.length} messages`);
61
+
62
+ if (afterCleanup.length === 1 && afterCleanup[0].role === 'user') {
63
+ console.log(' ✓ Incomplete assistant message removed!\n');
64
+ } else {
65
+ console.log(' ✗ Cleanup failed!\n');
66
+ console.log(' Remaining messages:', JSON.stringify(afterCleanup, null, 2));
67
+ }
68
+
69
+ console.log('3️⃣ Testing with complete messages (no tool_use)...');
70
+
71
+ // Add complete assistant message
72
+ (agent as any).messages.push({
73
+ role: 'assistant',
74
+ content: [{ type: 'text', text: 'Here is your answer.' }],
75
+ });
76
+ console.log(` Before cleanup: ${agent.getHistory().length} messages`);
77
+
78
+ agent.cleanupIncompleteMessages();
79
+ console.log(` After cleanup: ${agent.getHistory().length} messages`);
80
+
81
+ if (agent.getHistory().length === 2) {
82
+ console.log(' ✓ Complete messages preserved!\n');
83
+ } else {
84
+ console.log(' ✗ Complete message was incorrectly removed!\n');
85
+ }
86
+
87
+ console.log('✅ All tests passed!\n');
88
+ } catch (error) {
89
+ console.error('❌ Test failed:', error);
90
+ }
91
+ }
92
+
93
+ // Run the test
94
+ testInterruptCleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gencode-ai",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "An open-source AI assistant for your terminal",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,6 +16,16 @@ import { SessionManager } from '../session/index.js';
16
16
  import { MemoryManager, type LoadedMemory } from '../memory/index.js';
17
17
  import type { AgentConfig, AgentEvent } from './types.js';
18
18
  import { buildSystemPromptForModel, debugPromptLoading } from '../prompts/index.js';
19
+ import type { Question, QuestionAnswer } from '../tools/types.js';
20
+ import {
21
+ getPlanModeManager,
22
+ type PlanModeManager,
23
+ type ModeType,
24
+ type AllowedPrompt,
25
+ } from '../planning/index.js';
26
+
27
+ // Type for askUser callback
28
+ export type AskUserCallback = (questions: Question[]) => Promise<QuestionAnswer[]>;
19
29
 
20
30
  export class Agent {
21
31
  private provider: LLMProvider;
@@ -23,10 +33,12 @@ export class Agent {
23
33
  private permissions: PermissionManager;
24
34
  private sessionManager: SessionManager;
25
35
  private memoryManager: MemoryManager;
36
+ private planModeManager: PlanModeManager;
26
37
  private config: AgentConfig;
27
38
  private messages: Message[] = [];
28
39
  private sessionId: string | null = null;
29
40
  private loadedMemory: LoadedMemory | null = null;
41
+ private askUserCallback: AskUserCallback | null = null;
30
42
 
31
43
  constructor(config: AgentConfig) {
32
44
  this.config = {
@@ -43,6 +55,7 @@ export class Agent {
43
55
  });
44
56
  this.sessionManager = new SessionManager();
45
57
  this.memoryManager = new MemoryManager();
58
+ this.planModeManager = getPlanModeManager();
46
59
  }
47
60
 
48
61
  /**
@@ -101,6 +114,14 @@ export class Agent {
101
114
  return this.permissions;
102
115
  }
103
116
 
117
+ /**
118
+ * Set callback for AskUserQuestion tool
119
+ * This allows the CLI to handle user questioning
120
+ */
121
+ setAskUserCallback(callback: AskUserCallback): void {
122
+ this.askUserCallback = callback;
123
+ }
124
+
104
125
  /**
105
126
  * Get memory manager for direct access
106
127
  */
@@ -124,6 +145,78 @@ export class Agent {
124
145
  return this.loadedMemory;
125
146
  }
126
147
 
148
+ // ============================================================================
149
+ // Plan Mode
150
+ // ============================================================================
151
+
152
+ /**
153
+ * Get plan mode manager for external access
154
+ */
155
+ getPlanModeManager(): PlanModeManager {
156
+ return this.planModeManager;
157
+ }
158
+
159
+ /**
160
+ * Check if plan mode is active
161
+ */
162
+ isPlanModeActive(): boolean {
163
+ return this.planModeManager.isActive();
164
+ }
165
+
166
+ /**
167
+ * Get current mode (build or plan)
168
+ */
169
+ getCurrentMode(): ModeType {
170
+ return this.planModeManager.getCurrentMode();
171
+ }
172
+
173
+ /**
174
+ * Enter plan mode programmatically
175
+ */
176
+ async enterPlanMode(taskDescription?: string): Promise<string> {
177
+ const { createPlanFile } = await import('../planning/index.js');
178
+ const cwd = this.config.cwd ?? process.cwd();
179
+ const planFile = await createPlanFile(cwd, taskDescription);
180
+ this.planModeManager.enter(planFile.path, taskDescription);
181
+ return planFile.path;
182
+ }
183
+
184
+ /**
185
+ * Exit plan mode
186
+ */
187
+ exitPlanMode(approved: boolean = false): void {
188
+ if (approved) {
189
+ // Add allowed prompts from plan mode to permissions
190
+ const allowedPrompts = this.planModeManager.getRequestedPermissions();
191
+ if (allowedPrompts.length > 0) {
192
+ this.permissions.addAllowedPrompts(allowedPrompts);
193
+ }
194
+ }
195
+ this.planModeManager.exit(approved);
196
+ }
197
+
198
+ /**
199
+ * Toggle plan mode
200
+ */
201
+ async togglePlanMode(): Promise<void> {
202
+ if (this.planModeManager.isActive()) {
203
+ this.planModeManager.exit(false);
204
+ } else {
205
+ await this.enterPlanMode();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get plan mode requested permissions
211
+ */
212
+ getPlanModePermissions(): AllowedPrompt[] {
213
+ return this.planModeManager.getRequestedPermissions();
214
+ }
215
+
216
+ // ============================================================================
217
+ // Session Management
218
+ // ============================================================================
219
+
127
220
  /**
128
221
  * Get current session ID
129
222
  */
@@ -277,8 +370,8 @@ export class Agent {
277
370
  while (turns < maxTurns) {
278
371
  turns++;
279
372
 
280
- // Get tool definitions
281
- const toolDefs = this.registry.getDefinitions(this.config.tools);
373
+ // Get tool definitions (filtered by plan mode if active)
374
+ const toolDefs = this.registry.getFilteredDefinitions(this.config.tools);
282
375
 
283
376
  // Call LLM
284
377
  let response;
@@ -328,7 +421,7 @@ export class Agent {
328
421
  await this.sessionManager.addMessage({ role: 'assistant', content: response.content });
329
422
 
330
423
  if (response.stopReason !== 'tool_use' || toolCalls.length === 0) {
331
- yield { type: 'done', text: textContent };
424
+ yield { type: 'done', text: textContent, usage: response.usage, cost: response.cost };
332
425
  return;
333
426
  }
334
427
 
@@ -336,12 +429,18 @@ export class Agent {
336
429
  const toolResults: ToolResultContent[] = [];
337
430
  const cwd = this.config.cwd ?? process.cwd();
338
431
 
432
+ // Build tool context with askUser callback
433
+ const toolContext = {
434
+ cwd,
435
+ askUser: this.askUserCallback ?? undefined,
436
+ };
437
+
339
438
  for (const call of toolCalls) {
340
439
  yield { type: 'tool_start', id: call.id, name: call.name, input: call.input };
341
440
 
342
441
  const allowed = await this.permissions.requestPermission(call.name, call.input);
343
442
  const result = allowed
344
- ? await this.registry.execute(call.name, call.input, { cwd })
443
+ ? await this.registry.execute(call.name, call.input, toolContext)
345
444
  : { success: false, output: '', error: 'Permission denied by user' };
346
445
 
347
446
  yield { type: 'tool_result', id: call.id, name: call.name, result };
@@ -369,6 +468,33 @@ export class Agent {
369
468
  this.sessionManager.clearMessages();
370
469
  }
371
470
 
471
+ /**
472
+ * Clean up incomplete tool use messages (after interruption)
473
+ * Removes the last assistant message if it contains tool_use without corresponding tool_result
474
+ */
475
+ cleanupIncompleteMessages(): void {
476
+ if (this.messages.length === 0) return;
477
+
478
+ const lastMessage = this.messages[this.messages.length - 1];
479
+
480
+ // Check if last message is an assistant message with tool_use
481
+ if (lastMessage.role === 'assistant' && Array.isArray(lastMessage.content)) {
482
+ const hasToolUse = lastMessage.content.some((c) => c.type === 'tool_use');
483
+
484
+ if (hasToolUse) {
485
+ // Remove the incomplete assistant message
486
+ this.messages.pop();
487
+
488
+ // Also remove from session manager
489
+ // Note: SessionManager should have corresponding cleanup method
490
+ const messages = this.sessionManager.getMessages();
491
+ if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
492
+ this.sessionManager.removeLastMessage();
493
+ }
494
+ }
495
+ }
496
+ }
497
+
372
498
  /**
373
499
  * Get conversation history
374
500
  */
@@ -4,3 +4,4 @@
4
4
 
5
5
  export * from './types.js';
6
6
  export { Agent } from './agent.js';
7
+ export type { AskUserCallback } from './agent.js';
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { PermissionConfig } from '../permissions/types.js';
6
+ import type { CostEstimate } from '../pricing/types.js';
6
7
 
7
8
  export interface AgentConfig {
8
9
  provider: 'openai' | 'anthropic' | 'gemini' | 'vertex-ai';
@@ -59,6 +60,22 @@ export interface AgentEventError {
59
60
  export interface AgentEventDone {
60
61
  type: 'done';
61
62
  text: string;
63
+ usage?: {
64
+ inputTokens: number;
65
+ outputTokens: number;
66
+ };
67
+ cost?: CostEstimate;
68
+ }
69
+
70
+ export interface AgentEventAskUser {
71
+ type: 'ask_user';
72
+ id: string;
73
+ questions: Array<{
74
+ question: string;
75
+ header: string;
76
+ options: Array<{ label: string; description: string }>;
77
+ multiSelect: boolean;
78
+ }>;
62
79
  }
63
80
 
64
81
  export type AgentEvent =
@@ -67,4 +84,5 @@ export type AgentEvent =
67
84
  | AgentEventToolResult
68
85
  | AgentEventThinking
69
86
  | AgentEventError
70
- | AgentEventDone;
87
+ | AgentEventDone
88
+ | AgentEventAskUser;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Checkpoint Manager - Core checkpointing logic
3
+ *
4
+ * Tracks file changes and provides rewind capabilities.
5
+ */
6
+
7
+ import * as fs from 'fs/promises';
8
+ import * as path from 'path';
9
+ import type {
10
+ FileCheckpoint,
11
+ CheckpointSession,
12
+ RewindOptions,
13
+ RewindResult,
14
+ CheckpointSummary,
15
+ RecordChangeInput,
16
+ } from './types.js';
17
+
18
+ /**
19
+ * Generates a unique ID for checkpoints
20
+ */
21
+ function generateId(): string {
22
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
23
+ }
24
+
25
+ /**
26
+ * CheckpointManager manages file change tracking and rewind operations.
27
+ *
28
+ * Usage:
29
+ * const manager = new CheckpointManager('session-123');
30
+ * manager.recordChange({ path: '/path/to/file', changeType: 'modify', ... });
31
+ * await manager.rewind({ all: true });
32
+ */
33
+ export class CheckpointManager {
34
+ private session: CheckpointSession;
35
+
36
+ constructor(sessionId: string = 'default') {
37
+ this.session = {
38
+ sessionId,
39
+ checkpoints: [],
40
+ createdAt: new Date(),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Get the session ID
46
+ */
47
+ getSessionId(): string {
48
+ return this.session.sessionId;
49
+ }
50
+
51
+ /**
52
+ * Record a file change as a checkpoint
53
+ */
54
+ recordChange(input: RecordChangeInput): FileCheckpoint {
55
+ const checkpoint: FileCheckpoint = {
56
+ id: generateId(),
57
+ path: input.path,
58
+ changeType: input.changeType,
59
+ timestamp: new Date(),
60
+ previousContent: input.previousContent,
61
+ newContent: input.newContent,
62
+ toolName: input.toolName,
63
+ };
64
+
65
+ this.session.checkpoints.push(checkpoint);
66
+ return checkpoint;
67
+ }
68
+
69
+ /**
70
+ * Get all checkpoints
71
+ */
72
+ getCheckpoints(): FileCheckpoint[] {
73
+ return [...this.session.checkpoints];
74
+ }
75
+
76
+ /**
77
+ * Get checkpoints for a specific file
78
+ */
79
+ getFileHistory(filePath: string): FileCheckpoint[] {
80
+ return this.session.checkpoints.filter((cp) => cp.path === filePath);
81
+ }
82
+
83
+ /**
84
+ * Get a summary of all changes
85
+ */
86
+ getSummary(): CheckpointSummary {
87
+ const summary: CheckpointSummary = {
88
+ created: 0,
89
+ modified: 0,
90
+ deleted: 0,
91
+ total: this.session.checkpoints.length,
92
+ };
93
+
94
+ for (const cp of this.session.checkpoints) {
95
+ switch (cp.changeType) {
96
+ case 'create':
97
+ summary.created++;
98
+ break;
99
+ case 'modify':
100
+ summary.modified++;
101
+ break;
102
+ case 'delete':
103
+ summary.deleted++;
104
+ break;
105
+ }
106
+ }
107
+
108
+ return summary;
109
+ }
110
+
111
+ /**
112
+ * Check if there are any checkpoints
113
+ */
114
+ hasCheckpoints(): boolean {
115
+ return this.session.checkpoints.length > 0;
116
+ }
117
+
118
+ /**
119
+ * Get the number of checkpoints
120
+ */
121
+ getCheckpointCount(): number {
122
+ return this.session.checkpoints.length;
123
+ }
124
+
125
+ /**
126
+ * Rewind changes based on options
127
+ */
128
+ async rewind(options: RewindOptions): Promise<RewindResult> {
129
+ const result: RewindResult = {
130
+ success: true,
131
+ revertedFiles: [],
132
+ errors: [],
133
+ };
134
+
135
+ // Determine which checkpoints to rewind
136
+ let checkpointsToRewind: FileCheckpoint[] = [];
137
+
138
+ if (options.checkpointId) {
139
+ // Rewind specific checkpoint
140
+ const cp = this.session.checkpoints.find((c) => c.id === options.checkpointId);
141
+ if (cp) {
142
+ checkpointsToRewind = [cp];
143
+ }
144
+ } else if (options.path) {
145
+ // Rewind all changes to a specific file (in reverse order)
146
+ checkpointsToRewind = this.session.checkpoints
147
+ .filter((c) => c.path === options.path)
148
+ .reverse();
149
+ } else if (options.count) {
150
+ // Rewind last N changes (in reverse order)
151
+ checkpointsToRewind = this.session.checkpoints.slice(-options.count).reverse();
152
+ } else if (options.all) {
153
+ // Rewind all changes (in reverse order)
154
+ checkpointsToRewind = [...this.session.checkpoints].reverse();
155
+ }
156
+
157
+ // Apply reverts
158
+ for (const checkpoint of checkpointsToRewind) {
159
+ try {
160
+ await this.revertCheckpoint(checkpoint);
161
+ result.revertedFiles.push({
162
+ path: checkpoint.path,
163
+ action: this.getRevertAction(checkpoint),
164
+ });
165
+
166
+ // Remove the checkpoint from session
167
+ const index = this.session.checkpoints.findIndex((c) => c.id === checkpoint.id);
168
+ if (index !== -1) {
169
+ this.session.checkpoints.splice(index, 1);
170
+ }
171
+ } catch (error) {
172
+ result.success = false;
173
+ result.errors.push({
174
+ path: checkpoint.path,
175
+ error: error instanceof Error ? error.message : String(error),
176
+ });
177
+ }
178
+ }
179
+
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * Revert a single checkpoint
185
+ */
186
+ private async revertCheckpoint(checkpoint: FileCheckpoint): Promise<void> {
187
+ switch (checkpoint.changeType) {
188
+ case 'create':
189
+ // File was created, delete it to revert
190
+ await fs.unlink(checkpoint.path);
191
+ break;
192
+
193
+ case 'modify':
194
+ // File was modified, restore previous content
195
+ if (checkpoint.previousContent !== null) {
196
+ await fs.writeFile(checkpoint.path, checkpoint.previousContent, 'utf-8');
197
+ }
198
+ break;
199
+
200
+ case 'delete':
201
+ // File was deleted, recreate it with previous content
202
+ if (checkpoint.previousContent !== null) {
203
+ await fs.mkdir(path.dirname(checkpoint.path), { recursive: true });
204
+ await fs.writeFile(checkpoint.path, checkpoint.previousContent, 'utf-8');
205
+ }
206
+ break;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Get the action that will be taken to revert a checkpoint
212
+ */
213
+ private getRevertAction(checkpoint: FileCheckpoint): 'restored' | 'deleted' | 'recreated' {
214
+ switch (checkpoint.changeType) {
215
+ case 'create':
216
+ return 'deleted';
217
+ case 'modify':
218
+ return 'restored';
219
+ case 'delete':
220
+ return 'recreated';
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Clear all checkpoints
226
+ */
227
+ clearCheckpoints(): void {
228
+ this.session.checkpoints = [];
229
+ }
230
+
231
+ /**
232
+ * Format checkpoints for display
233
+ */
234
+ formatCheckpointList(includeUsage: boolean = false): string {
235
+ if (this.session.checkpoints.length === 0) {
236
+ return 'No file changes in this session.';
237
+ }
238
+
239
+ const lines: string[] = [];
240
+
241
+ this.session.checkpoints.forEach((cp, index) => {
242
+ const timeAgo = this.formatTimeAgo(cp.timestamp);
243
+ const action = this.formatChangeType(cp.changeType);
244
+ const fileName = cp.path.split('/').pop() || cp.path;
245
+ lines.push(` [${index + 1}] ${timeAgo.padEnd(8)} ${fileName.padEnd(30)} (${action})`);
246
+ });
247
+
248
+ const summary = this.getSummary();
249
+ lines.push('');
250
+ lines.push(
251
+ `Total: ${summary.created} created, ${summary.modified} modified, ${summary.deleted} deleted`
252
+ );
253
+
254
+ if (includeUsage) {
255
+ lines.push('');
256
+ lines.push('Usage: /rewind [n] to revert change #n, /rewind all to revert all');
257
+ }
258
+
259
+ return lines.join('\n');
260
+ }
261
+
262
+ /**
263
+ * Format a change type for display
264
+ */
265
+ private formatChangeType(changeType: string): string {
266
+ switch (changeType) {
267
+ case 'create':
268
+ return 'created';
269
+ case 'modify':
270
+ return 'modified';
271
+ case 'delete':
272
+ return 'deleted';
273
+ default:
274
+ return changeType;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Format a timestamp as relative time
280
+ */
281
+ private formatTimeAgo(date: Date): string {
282
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
283
+
284
+ if (seconds < 60) {
285
+ return `${seconds}s ago`;
286
+ }
287
+
288
+ const minutes = Math.floor(seconds / 60);
289
+ if (minutes < 60) {
290
+ return `${minutes}m ago`;
291
+ }
292
+
293
+ const hours = Math.floor(minutes / 60);
294
+ return `${hours}h ago`;
295
+ }
296
+ }
297
+
298
+ // ============================================================================
299
+ // Singleton Instance
300
+ // ============================================================================
301
+
302
+ let globalCheckpointManager: CheckpointManager | null = null;
303
+
304
+ /**
305
+ * Get the global checkpoint manager instance
306
+ */
307
+ export function getCheckpointManager(): CheckpointManager {
308
+ if (!globalCheckpointManager) {
309
+ globalCheckpointManager = new CheckpointManager();
310
+ }
311
+ return globalCheckpointManager;
312
+ }
313
+
314
+ /**
315
+ * Initialize checkpoint manager with a session ID
316
+ */
317
+ export function initCheckpointManager(sessionId: string): CheckpointManager {
318
+ globalCheckpointManager = new CheckpointManager(sessionId);
319
+ return globalCheckpointManager;
320
+ }
321
+
322
+ /**
323
+ * Reset the global checkpoint manager (for testing)
324
+ */
325
+ export function resetCheckpointManager(): void {
326
+ globalCheckpointManager = null;
327
+ }