@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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/bin/kondi-chat +56 -0
- package/bin/kondi-chat.js +72 -0
- package/package.json +55 -0
- package/scripts/demo.tape +49 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/audit/analytics.ts +261 -0
- package/src/audit/ledger.ts +253 -0
- package/src/audit/telemetry.ts +165 -0
- package/src/cli/backend.ts +675 -0
- package/src/cli/commands.ts +419 -0
- package/src/cli/help.ts +182 -0
- package/src/cli/submit-helpers.ts +159 -0
- package/src/cli/submit.ts +539 -0
- package/src/cli/wizard.ts +121 -0
- package/src/context/bootstrap.ts +138 -0
- package/src/context/budget.ts +100 -0
- package/src/context/manager.ts +666 -0
- package/src/context/memory.ts +160 -0
- package/src/context/preflight.ts +176 -0
- package/src/context/project-brain.ts +101 -0
- package/src/context/receipts.ts +108 -0
- package/src/context/skills.ts +154 -0
- package/src/context/symbol-index.ts +240 -0
- package/src/council/profiles.ts +137 -0
- package/src/council/tool.ts +138 -0
- package/src/council-engine/cli/council-artifacts.ts +230 -0
- package/src/council-engine/cli/council-config.ts +178 -0
- package/src/council-engine/cli/council-session-export.ts +116 -0
- package/src/council-engine/cli/kondi.ts +98 -0
- package/src/council-engine/cli/llm-caller.ts +229 -0
- package/src/council-engine/cli/localStorage-shim.ts +119 -0
- package/src/council-engine/cli/node-platform.ts +68 -0
- package/src/council-engine/cli/run-council.ts +481 -0
- package/src/council-engine/cli/run-pipeline.ts +772 -0
- package/src/council-engine/cli/session-export.ts +153 -0
- package/src/council-engine/configs/councils/analysis.json +101 -0
- package/src/council-engine/configs/councils/code-planning.json +86 -0
- package/src/council-engine/configs/councils/coding.json +89 -0
- package/src/council-engine/configs/councils/debate.json +97 -0
- package/src/council-engine/configs/councils/solo-claude.json +34 -0
- package/src/council-engine/configs/councils/solo-gpt.json +34 -0
- package/src/council-engine/council/coding-orchestrator.ts +1205 -0
- package/src/council-engine/council/context-bootstrap.ts +147 -0
- package/src/council-engine/council/context-inspection.ts +42 -0
- package/src/council-engine/council/context-store.ts +763 -0
- package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
- package/src/council-engine/council/factory.ts +164 -0
- package/src/council-engine/council/index.ts +201 -0
- package/src/council-engine/council/ledger-store.ts +438 -0
- package/src/council-engine/council/prompts.ts +1689 -0
- package/src/council-engine/council/storage-cleanup.ts +164 -0
- package/src/council-engine/council/store.ts +1110 -0
- package/src/council-engine/council/synthesis.ts +291 -0
- package/src/council-engine/council/types.ts +845 -0
- package/src/council-engine/council/validation.ts +613 -0
- package/src/council-engine/pipeline/build-detect.ts +73 -0
- package/src/council-engine/pipeline/executor.ts +1048 -0
- package/src/council-engine/pipeline/index.ts +9 -0
- package/src/council-engine/pipeline/install-detect.ts +84 -0
- package/src/council-engine/pipeline/memory-store.ts +182 -0
- package/src/council-engine/pipeline/output-parsers.ts +146 -0
- package/src/council-engine/pipeline/run-output.ts +149 -0
- package/src/council-engine/pipeline/session-import.ts +177 -0
- package/src/council-engine/pipeline/store.ts +753 -0
- package/src/council-engine/pipeline/test-detect.ts +82 -0
- package/src/council-engine/pipeline/types.ts +401 -0
- package/src/council-engine/services/deliberationSummary.ts +114 -0
- package/src/council-engine/tsconfig.json +16 -0
- package/src/council-engine/types/mcp.ts +122 -0
- package/src/council-engine/utils/filterTools.ts +73 -0
- package/src/engine/apply.ts +238 -0
- package/src/engine/checkpoints.ts +237 -0
- package/src/engine/consultants.ts +347 -0
- package/src/engine/diff.ts +171 -0
- package/src/engine/errors.ts +102 -0
- package/src/engine/git-tools.ts +246 -0
- package/src/engine/hooks.ts +181 -0
- package/src/engine/loop-guard.ts +155 -0
- package/src/engine/permissions.ts +293 -0
- package/src/engine/pipeline.ts +376 -0
- package/src/engine/sub-agents.ts +133 -0
- package/src/engine/task-card.ts +185 -0
- package/src/engine/task-router.ts +256 -0
- package/src/engine/task-store.ts +86 -0
- package/src/engine/tools.ts +783 -0
- package/src/engine/verify.ts +111 -0
- package/src/mcp/client.ts +225 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/tool-manager.ts +192 -0
- package/src/mcp/types.ts +61 -0
- package/src/providers/llm-caller.ts +943 -0
- package/src/providers/rate-limiter.ts +238 -0
- package/src/router/NOTES.md +28 -0
- package/src/router/collector.ts +474 -0
- package/src/router/embeddings.ts +286 -0
- package/src/router/index.ts +299 -0
- package/src/router/intent-router.ts +225 -0
- package/src/router/nn-router.ts +205 -0
- package/src/router/profiles.ts +309 -0
- package/src/router/registry.ts +565 -0
- package/src/router/rules.ts +274 -0
- package/src/router/train.py +408 -0
- package/src/session/store.ts +211 -0
- package/src/test-utils/mock-llm.ts +39 -0
- package/src/types.ts +322 -0
- 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
|
+
}
|