@thispointon/kondi-chat 0.1.2

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,122 @@
1
+ export interface MCPServer {
2
+ id: string;
3
+ name: string;
4
+ url: string;
5
+ transport: 'http' | 'sse' | 'stdio';
6
+ status: 'disconnected' | 'connecting' | 'connected' | 'error';
7
+ icon?: string;
8
+ tools?: MCPTool[];
9
+ error?: string;
10
+ accessToken?: string;
11
+ clientId?: string;
12
+ clientSecret?: string;
13
+ sessionId?: string;
14
+ authHint?: 'none' | 'oauth' | 'token';
15
+ messageEndpoint?: string; // For SSE transport - the endpoint to POST JSON-RPC commands to
16
+ type?: 'remote' | 'github_mcp_local';
17
+ metadata?: Record<string, any>;
18
+ autoConnect?: boolean; // If true, attempt to connect automatically on app startup
19
+ }
20
+
21
+ export interface MCPTool {
22
+ name: string;
23
+ description: string;
24
+ inputSchema: {
25
+ type: string;
26
+ properties: Record<string, any>;
27
+ required?: string[];
28
+ };
29
+ }
30
+
31
+ export interface ToolCall {
32
+ id: string;
33
+ serverId: string;
34
+ toolName: string;
35
+ arguments: Record<string, any>;
36
+ status: 'pending' | 'running' | 'completed' | 'failed';
37
+ result?: any;
38
+ error?: string;
39
+ }
40
+
41
+ export interface MessageAttachment {
42
+ name: string;
43
+ type: string;
44
+ size: number;
45
+ }
46
+
47
+ export interface MessageUsage {
48
+ inputTokens: number;
49
+ outputTokens: number;
50
+ cacheRead?: number;
51
+ cacheCreation?: number;
52
+ /** Estimated payload size in chars (JSON-serialized request body) */
53
+ payloadChars?: number;
54
+ /** Number of API calls (tool loop turns) for this response */
55
+ apiTurns?: number;
56
+ }
57
+
58
+ export interface Message {
59
+ id: string;
60
+ role: 'user' | 'assistant' | 'system';
61
+ content: string;
62
+ toolCalls?: ToolCall[];
63
+ timestamp: Date;
64
+ provider?: string;
65
+ attachments?: MessageAttachment[];
66
+ /** Token usage from the API response (assistant messages only) */
67
+ usage?: MessageUsage;
68
+ }
69
+
70
+ export type CollaborationMode = 'single' | 'collaborate' | 'debate';
71
+
72
+ export interface OAuthDiscovery {
73
+ requiresAuth: boolean;
74
+ authorizationEndpoint?: string;
75
+ tokenEndpoint?: string;
76
+ registrationEndpoint?: string;
77
+ supportsDynamicRegistration: boolean;
78
+ dynamicClientId?: string;
79
+ dynamicClientSecret?: string;
80
+ error?: string;
81
+ }
82
+
83
+ export interface GithubInstallResult {
84
+ serverPath: string;
85
+ entrypoint: string;
86
+ success: boolean;
87
+ error?: string;
88
+ }
89
+
90
+ export interface CommandOutput {
91
+ stdout: string;
92
+ stderr: string;
93
+ exitCode: number;
94
+ success: boolean;
95
+ }
96
+
97
+ export interface McpManifest {
98
+ name?: string;
99
+ version?: string;
100
+ description?: string;
101
+ entrypoint?: string;
102
+ runtime?: 'node' | 'python' | 'binary';
103
+ package?: {
104
+ name?: string;
105
+ version?: string;
106
+ manager?: 'npm' | 'pip' | 'uv' | 'none';
107
+ module_probe?: string;
108
+ };
109
+ // Run configuration - allows full customization
110
+ run?: {
111
+ command?: string; // The command to execute (e.g., "python", "node", "npx")
112
+ args?: string[]; // Arguments to pass
113
+ env?: Record<string, string>; // Environment variables
114
+ workingDir?: string; // Working directory relative to server path
115
+ shell?: boolean; // Run via shell (for complex commands)
116
+ };
117
+ // Install configuration
118
+ install?: {
119
+ command?: string; // Custom install command (overrides package.manager)
120
+ args?: string[]; // Arguments for install command
121
+ };
122
+ }
@@ -0,0 +1,73 @@
1
+ import type { MCPTool } from '../types/mcp';
2
+ import { LOCAL_SERVER_ID } from '../services/localTools';
3
+
4
+ /** Server IDs for built-in/local services that are always available regardless of restrictions. */
5
+ export const BUILTIN_SERVER_IDS = [LOCAL_SERVER_ID, 'kondi-search'];
6
+
7
+ /**
8
+ * Filter MCP tools map based on allowed server IDs.
9
+ * undefined → all servers (unrestricted)
10
+ * [] → no servers at all (fully restricted)
11
+ * ['a','b'] → built-in servers + listed servers
12
+ */
13
+ export function filterToolsByServerIds(
14
+ tools: Map<string, { serverId: string; tools: MCPTool[] }>,
15
+ allowedServerIds?: string[]
16
+ ): Map<string, { serverId: string; tools: MCPTool[] }> {
17
+ if (allowedServerIds === undefined) return tools;
18
+ const filtered = new Map<string, { serverId: string; tools: MCPTool[] }>();
19
+ const includeBuiltins = allowedServerIds.length > 0;
20
+ for (const [key, value] of tools) {
21
+ if ((includeBuiltins && BUILTIN_SERVER_IDS.includes(key)) || allowedServerIds.includes(key)) {
22
+ filtered.set(key, value);
23
+ }
24
+ }
25
+ return filtered;
26
+ }
27
+
28
+ /** Count total tools across all servers */
29
+ export function countTools(tools: Map<string, { serverId: string; tools: MCPTool[] }>): number {
30
+ let count = 0;
31
+ for (const [, v] of tools) count += v.tools.length;
32
+ return count;
33
+ }
34
+
35
+ /**
36
+ * Pre-flight check: scan prompt text for MCP tool references and warn if
37
+ * any referenced tools aren't available. Prevents wasting a full council
38
+ * run only to fail mid-way because a tool server isn't connected.
39
+ *
40
+ * Looks for patterns like `mcp__serverId__toolName` in the prompt text
41
+ * and verifies the server is in the available tools map.
42
+ */
43
+ export function verifyRequiredTools(
44
+ availableTools: Map<string, { serverId: string; tools: MCPTool[] }>,
45
+ promptText: string,
46
+ contextLabel: string
47
+ ): void {
48
+ // Match mcp__<server>__<tool> patterns commonly used in prompts
49
+ const mcpPattern = /mcp__([a-zA-Z0-9_-]+)__([a-zA-Z0-9_-]+)/g;
50
+ const referencedServers = new Set<string>();
51
+ let match;
52
+ while ((match = mcpPattern.exec(promptText)) !== null) {
53
+ referencedServers.add(match[1]);
54
+ }
55
+
56
+ if (referencedServers.size === 0) return;
57
+
58
+ const availableServerIds = new Set<string>();
59
+ for (const [key] of availableTools) {
60
+ availableServerIds.add(key);
61
+ }
62
+
63
+ const missing = [...referencedServers].filter(
64
+ (s) => !availableServerIds.has(s) && !BUILTIN_SERVER_IDS.includes(s)
65
+ );
66
+
67
+ if (missing.length > 0) {
68
+ console.warn(
69
+ `[verifyRequiredTools] "${contextLabel}" references MCP servers not currently connected: ${missing.join(', ')}. ` +
70
+ `Tools from these servers will not be available during execution.`
71
+ );
72
+ }
73
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Apply — parses model output and writes changes to disk.
3
+ *
4
+ * Supports two output modes:
5
+ * - file_replacements: model returns full file contents with path labels
6
+ * - diff: model returns unified diffs
7
+ *
8
+ * All writes are backed up before overwriting. Backup files go to
9
+ * .kondi-chat/backups/<task-id>/ so they can be restored.
10
+ */
11
+
12
+ import {
13
+ readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync,
14
+ } from 'node:fs';
15
+ import { join, resolve, dirname, relative } from 'node:path';
16
+
17
+ function isPathSafe(base: string, fullPath: string): boolean {
18
+ const rel = relative(base, fullPath);
19
+ return !rel.startsWith('..') && !resolve(fullPath).includes('\0');
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface FileChange {
27
+ path: string;
28
+ content: string;
29
+ isNew: boolean;
30
+ }
31
+
32
+ export interface ApplyResult {
33
+ applied: FileChange[];
34
+ skipped: string[];
35
+ backupDir?: string;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Parse model output
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Parse file replacements from model output.
44
+ *
45
+ * Expects patterns like:
46
+ * #### path/to/file.ts
47
+ * ```
48
+ * file content
49
+ * ```
50
+ *
51
+ * Or:
52
+ * **File: path/to/file.ts**
53
+ * ```typescript
54
+ * file content
55
+ * ```
56
+ *
57
+ * Or:
58
+ * // path/to/file.ts
59
+ * ```
60
+ * file content
61
+ * ```
62
+ */
63
+ export function parseFileReplacements(output: string): FileChange[] {
64
+ const changes: FileChange[] = [];
65
+
66
+ // Pattern 1: #### path/to/file
67
+ // Pattern 2: **File: path/to/file**
68
+ // Pattern 3: ## path/to/file
69
+ // Pattern 4: `path/to/file`:
70
+ const headerPatterns = [
71
+ /^#{1,4}\s+([^\n]+?)$/gm,
72
+ /^\*\*(?:File:\s*)?([^\n*]+?)\*\*$/gm,
73
+ /^`([^`\n]+?)`\s*:?\s*$/gm,
74
+ /^\/\/\s+([^\n]+?)$/gm,
75
+ ];
76
+
77
+ // Find all code blocks
78
+ const codeBlockRegex = /```[a-z]*\n([\s\S]*?)```/g;
79
+ const blocks: { start: number; end: number; content: string }[] = [];
80
+ let match;
81
+ while ((match = codeBlockRegex.exec(output)) !== null) {
82
+ blocks.push({
83
+ start: match.index,
84
+ end: match.index + match[0].length,
85
+ content: match[1],
86
+ });
87
+ }
88
+
89
+ if (blocks.length === 0) return [];
90
+
91
+ // For each code block, look backwards for a path header
92
+ for (const block of blocks) {
93
+ const textBefore = output.slice(Math.max(0, block.start - 300), block.start);
94
+ let filePath: string | null = null;
95
+
96
+ for (const pattern of headerPatterns) {
97
+ pattern.lastIndex = 0;
98
+ let headerMatch;
99
+ let lastMatch: RegExpExecArray | null = null;
100
+ while ((headerMatch = pattern.exec(textBefore)) !== null) {
101
+ lastMatch = headerMatch;
102
+ }
103
+ if (lastMatch) {
104
+ const candidate = lastMatch[1].trim()
105
+ .replace(/^`|`$/g, '')
106
+ .replace(/^\*\*|\*\*$/g, '')
107
+ .replace(/^File:\s*/i, '')
108
+ .trim();
109
+ // Validate it looks like a file path
110
+ if (candidate.includes('/') || candidate.includes('.')) {
111
+ filePath = candidate;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+
117
+ if (filePath) {
118
+ // Clean up the content — remove trailing newline
119
+ let content = block.content;
120
+ if (content.endsWith('\n')) {
121
+ content = content.slice(0, -1);
122
+ }
123
+ changes.push({
124
+ path: filePath,
125
+ content,
126
+ isNew: false, // Will be set during apply
127
+ });
128
+ }
129
+ }
130
+
131
+ return changes;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Apply changes to disk
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Apply file changes to the working directory.
140
+ *
141
+ * @param workingDir Root directory for the project
142
+ * @param changes Parsed file changes
143
+ * @param backupDir Where to store backups (optional)
144
+ */
145
+ export function applyChanges(
146
+ workingDir: string,
147
+ changes: FileChange[],
148
+ backupDir?: string,
149
+ ): ApplyResult {
150
+ const base = resolve(workingDir);
151
+ const applied: FileChange[] = [];
152
+ const skipped: string[] = [];
153
+
154
+ // Create backup directory
155
+ if (backupDir) {
156
+ mkdirSync(backupDir, { recursive: true });
157
+ }
158
+
159
+ for (const change of changes) {
160
+ const fullPath = resolve(join(workingDir, change.path));
161
+
162
+ // Path traversal check
163
+ if (!isPathSafe(base, fullPath)) {
164
+ skipped.push(`${change.path} (path traversal blocked)`);
165
+ continue;
166
+ }
167
+
168
+ // Backup existing file
169
+ if (existsSync(fullPath) && backupDir) {
170
+ const backupPath = join(backupDir, change.path);
171
+ mkdirSync(dirname(backupPath), { recursive: true });
172
+ copyFileSync(fullPath, backupPath);
173
+ }
174
+
175
+ change.isNew = !existsSync(fullPath);
176
+
177
+ // Create parent directories
178
+ mkdirSync(dirname(fullPath), { recursive: true });
179
+
180
+ // Write the file
181
+ writeFileSync(fullPath, change.content + '\n');
182
+ applied.push(change);
183
+ }
184
+
185
+ return { applied, skipped, backupDir };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Restore from backup
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Restore files from a backup directory.
194
+ */
195
+ export function restoreBackup(workingDir: string, backupDir: string, files: string[]): string[] {
196
+ const restored: string[] = [];
197
+
198
+ for (const relPath of files) {
199
+ const backupPath = join(backupDir, relPath);
200
+ const targetPath = join(workingDir, relPath);
201
+
202
+ if (existsSync(backupPath)) {
203
+ mkdirSync(dirname(targetPath), { recursive: true });
204
+ copyFileSync(backupPath, targetPath);
205
+ restored.push(relPath);
206
+ }
207
+ }
208
+
209
+ return restored;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Format for display
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export function formatApplyResult(result: ApplyResult): string {
217
+ const lines: string[] = [];
218
+
219
+ if (result.applied.length > 0) {
220
+ lines.push(`Applied ${result.applied.length} file(s):`);
221
+ for (const f of result.applied) {
222
+ lines.push(` ${f.isNew ? '+' : '~'} ${f.path}`);
223
+ }
224
+ }
225
+
226
+ if (result.skipped.length > 0) {
227
+ lines.push(`Skipped ${result.skipped.length}:`);
228
+ for (const s of result.skipped) {
229
+ lines.push(` ✗ ${s}`);
230
+ }
231
+ }
232
+
233
+ if (result.backupDir) {
234
+ lines.push(`Backups: ${result.backupDir}`);
235
+ }
236
+
237
+ return lines.join('\n');
238
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Checkpoints — automatic restore points taken before the first mutating
3
+ * tool call in a turn. Git mode uses `git stash create`; file mode copies
4
+ * mutated files into `.kondi-chat/checkpoints/<session-id>/<cp-id>/files/`.
5
+ */
6
+
7
+ import { execSync } from 'node:child_process';
8
+ import {
9
+ existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, renameSync,
10
+ } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ export type CheckpointMode = 'git' | 'file';
14
+
15
+ export interface Checkpoint {
16
+ id: string;
17
+ turnNumber: number;
18
+ timestamp: string;
19
+ mode: CheckpointMode;
20
+ stashRef?: string; // git sha of the stash commit
21
+ preHead?: string; // HEAD at checkpoint time (git mode)
22
+ filesChanged: string[];
23
+ summary: string;
24
+ costUsd: number;
25
+ userMessage: string;
26
+ }
27
+
28
+ const MAX_CHECKPOINTS = 20;
29
+ const NON_MUTATING_COMMAND_PREFIXES = [
30
+ 'ls', 'cat', 'grep', 'find', 'echo', 'pwd', 'which', 'file', 'head', 'tail', 'wc',
31
+ 'git status', 'git log', 'git diff', 'git show', 'git blame', 'git branch',
32
+ 'npm test', 'npm run test', 'npx vitest', 'npx tsc',
33
+ 'cargo check', 'cargo test', 'cargo fmt --check', 'cargo clippy',
34
+ 'tsc --noEmit', 'python -c', 'node -v', 'node --version',
35
+ ];
36
+
37
+ const MUTATING_TOOLS = new Set([
38
+ 'write_file', 'edit_file', 'create_task', 'update_memory',
39
+ 'git_commit', 'git_branch', 'git_create_pr',
40
+ ]);
41
+
42
+ /** Predict which files this tool call will touch (for file-mode pre-snapshots). */
43
+ export function predictedMutations(name: string, args: Record<string, unknown>): string[] {
44
+ if (name === 'write_file' || name === 'edit_file') {
45
+ const p = args.path;
46
+ return typeof p === 'string' ? [p] : [];
47
+ }
48
+ return [];
49
+ }
50
+
51
+ /** Return true if this tool call should cause a checkpoint snapshot before running. */
52
+ export function isMutatingToolCall(name: string, args: Record<string, unknown>): boolean {
53
+ if (MUTATING_TOOLS.has(name)) return true;
54
+ if (name === 'run_command') {
55
+ const cmd = String(args.command || '').trim();
56
+ if (!cmd) return false;
57
+ for (const prefix of NON_MUTATING_COMMAND_PREFIXES) {
58
+ if (cmd === prefix || cmd.startsWith(prefix + ' ')) return false;
59
+ }
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function tryGit(cmd: string, cwd: string): string {
66
+ try {
67
+ return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 15_000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
68
+ } catch { return ''; }
69
+ }
70
+
71
+ function atomicWrite(path: string, data: string) {
72
+ const tmp = path + '.tmp';
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(tmp, data);
75
+ try { renameSync(tmp, path); } catch { writeFileSync(path, data); }
76
+ }
77
+
78
+ export class CheckpointManager {
79
+ private workingDir: string;
80
+ private storageDir: string;
81
+ private indexPath: string;
82
+ private checkpoints: Checkpoint[] = [];
83
+ private isGitRepo: boolean;
84
+
85
+ constructor(workingDir: string, sessionId: string, storageRoot: string) {
86
+ this.workingDir = resolve(workingDir);
87
+ this.storageDir = join(storageRoot, 'checkpoints', sessionId);
88
+ this.indexPath = join(this.storageDir, 'index.json');
89
+ mkdirSync(this.storageDir, { recursive: true });
90
+ this.isGitRepo = tryGit('git rev-parse --is-inside-work-tree', this.workingDir) === 'true';
91
+ this.loadIndex();
92
+ }
93
+
94
+ list(): Checkpoint[] {
95
+ return [...this.checkpoints].reverse();
96
+ }
97
+
98
+ get(id: string): Checkpoint | undefined {
99
+ return this.checkpoints.find(c => c.id === id);
100
+ }
101
+
102
+ /**
103
+ * Create a checkpoint just before the first mutation in a turn.
104
+ * `mutatedFiles` is used in file mode; `summary` is shown in /checkpoints.
105
+ */
106
+ create(summary: string, userMessage: string, turnNumber: number, costUsd: number, mutatedFiles: Set<string>): Checkpoint {
107
+ const id = `cp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
108
+ const timestamp = new Date().toISOString();
109
+
110
+ let cp: Checkpoint;
111
+ if (this.isGitRepo) {
112
+ const stashRef = tryGit('git stash create', this.workingDir);
113
+ if (stashRef) {
114
+ const preHead = tryGit('git rev-parse HEAD', this.workingDir);
115
+ cp = {
116
+ id, turnNumber, timestamp, mode: 'git',
117
+ stashRef, preHead, filesChanged: [...mutatedFiles], summary, costUsd, userMessage,
118
+ };
119
+ } else {
120
+ cp = this.createFileCheckpoint(id, timestamp, turnNumber, summary, userMessage, costUsd, mutatedFiles);
121
+ }
122
+ } else {
123
+ cp = this.createFileCheckpoint(id, timestamp, turnNumber, summary, userMessage, costUsd, mutatedFiles);
124
+ }
125
+
126
+ this.checkpoints.push(cp);
127
+ this.saveIndex();
128
+ this.prune();
129
+ return cp;
130
+ }
131
+
132
+ private createFileCheckpoint(
133
+ id: string, timestamp: string, turnNumber: number,
134
+ summary: string, userMessage: string, costUsd: number,
135
+ mutatedFiles: Set<string>,
136
+ ): Checkpoint {
137
+ const dir = join(this.storageDir, id, 'files');
138
+ mkdirSync(dir, { recursive: true });
139
+ const files: string[] = [];
140
+ for (const rel of mutatedFiles) {
141
+ const source = join(this.workingDir, rel);
142
+ if (!existsSync(source)) { files.push(rel); continue; }
143
+ const dest = join(dir, rel);
144
+ mkdirSync(dirname(dest), { recursive: true });
145
+ try { copyFileSync(source, dest); files.push(rel); } catch { /* skip */ }
146
+ }
147
+ return { id, turnNumber, timestamp, mode: 'file', filesChanged: files, summary, costUsd, userMessage };
148
+ }
149
+
150
+ /**
151
+ * Restore to the given checkpoint. If `target` is a negative number, restore
152
+ * to the Nth checkpoint from the tail (so -1 == latest).
153
+ */
154
+ restore(target: string | number): { restored: Checkpoint; filesRestored: string[]; errors: string[] } {
155
+ let cp: Checkpoint | undefined;
156
+ if (typeof target === 'number') {
157
+ const idx = this.checkpoints.length + target; // e.g. -1 -> last index
158
+ cp = this.checkpoints[idx];
159
+ } else {
160
+ cp = this.get(target);
161
+ }
162
+ if (!cp) throw new Error(`Checkpoint not found: ${target}`);
163
+
164
+ const errors: string[] = [];
165
+ const filesRestored: string[] = [];
166
+
167
+ if (cp.mode === 'git' && cp.stashRef) {
168
+ // Stash current state first so user can recover it manually if desired.
169
+ tryGit('git stash push -u -m "kondi-chat pre-undo"', this.workingDir);
170
+ const out = tryGit(`git stash apply ${cp.stashRef}`, this.workingDir);
171
+ if (!out && tryGit('git status --porcelain', this.workingDir) === '') {
172
+ errors.push('Apply returned no output — stash may be empty');
173
+ }
174
+ filesRestored.push(...cp.filesChanged);
175
+ } else {
176
+ const dir = join(this.storageDir, cp.id, 'files');
177
+ for (const rel of cp.filesChanged) {
178
+ const source = join(dir, rel);
179
+ const dest = join(this.workingDir, rel);
180
+ try {
181
+ if (existsSync(source)) {
182
+ mkdirSync(dirname(dest), { recursive: true });
183
+ copyFileSync(source, dest);
184
+ filesRestored.push(rel);
185
+ } else if (existsSync(dest)) {
186
+ // File was created in that turn; delete it
187
+ rmSync(dest, { force: true });
188
+ filesRestored.push(rel);
189
+ }
190
+ } catch (e) {
191
+ errors.push(`${rel}: ${(e as Error).message}`);
192
+ }
193
+ }
194
+ }
195
+
196
+ return { restored: cp, filesRestored, errors };
197
+ }
198
+
199
+ /** Remove the oldest checkpoints beyond MAX_CHECKPOINTS. */
200
+ prune(): number {
201
+ if (this.checkpoints.length <= MAX_CHECKPOINTS) return 0;
202
+ const removeCount = this.checkpoints.length - MAX_CHECKPOINTS;
203
+ const removed = this.checkpoints.splice(0, removeCount);
204
+ for (const cp of removed) {
205
+ if (cp.mode === 'file') {
206
+ try { rmSync(join(this.storageDir, cp.id), { recursive: true, force: true }); } catch { /* ignore */ }
207
+ }
208
+ }
209
+ this.saveIndex();
210
+ return removeCount;
211
+ }
212
+
213
+ format(): string {
214
+ if (this.checkpoints.length === 0) return 'No checkpoints yet.';
215
+ const lines = ['Checkpoints (newest first):'];
216
+ for (const cp of this.list()) {
217
+ lines.push(
218
+ ` ${cp.id} turn ${cp.turnNumber} ${cp.mode} ${cp.filesChanged.length} files $${cp.costUsd.toFixed(4)}`
219
+ + `\n "${cp.summary}"`,
220
+ );
221
+ }
222
+ return lines.join('\n');
223
+ }
224
+
225
+ private loadIndex(): void {
226
+ if (!existsSync(this.indexPath)) { this.checkpoints = []; return; }
227
+ try {
228
+ this.checkpoints = JSON.parse(readFileSync(this.indexPath, 'utf-8'));
229
+ } catch {
230
+ this.checkpoints = [];
231
+ }
232
+ }
233
+
234
+ private saveIndex(): void {
235
+ atomicWrite(this.indexPath, JSON.stringify(this.checkpoints, null, 2));
236
+ }
237
+ }