camo-cli 2.0.1
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/README.md +184 -0
- package/dist/agent.js +977 -0
- package/dist/art.js +33 -0
- package/dist/components/App.js +71 -0
- package/dist/components/Chat.js +509 -0
- package/dist/components/HITLConfirmation.js +89 -0
- package/dist/components/ModelSelector.js +100 -0
- package/dist/components/SetupScreen.js +43 -0
- package/dist/config/constants.js +58 -0
- package/dist/config/prompts.js +98 -0
- package/dist/config/store.js +5 -0
- package/dist/core/AgentLoop.js +159 -0
- package/dist/hooks/useAutocomplete.js +52 -0
- package/dist/hooks/useKeyboard.js +73 -0
- package/dist/index.js +31 -0
- package/dist/mcp.js +95 -0
- package/dist/memory/MemoryManager.js +228 -0
- package/dist/providers/index.js +85 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.js +5 -0
- package/dist/theme.js +45 -0
- package/dist/tools/FileTools.js +88 -0
- package/dist/tools/MemoryTools.js +53 -0
- package/dist/tools/SearchTools.js +45 -0
- package/dist/tools/ShellTools.js +40 -0
- package/dist/tools/TaskTools.js +52 -0
- package/dist/tools/ToolDefinitions.js +102 -0
- package/dist/tools/ToolRegistry.js +30 -0
- package/dist/types/Agent.js +6 -0
- package/dist/types/ink.js +1 -0
- package/dist/types/message.js +1 -0
- package/dist/types/ui.js +1 -0
- package/dist/utils/CriticAgent.js +88 -0
- package/dist/utils/DecisionLogger.js +156 -0
- package/dist/utils/MessageHistory.js +55 -0
- package/dist/utils/PermissionManager.js +253 -0
- package/dist/utils/SessionManager.js +180 -0
- package/dist/utils/TaskState.js +108 -0
- package/dist/utils/debug.js +35 -0
- package/dist/utils/execAsync.js +3 -0
- package/dist/utils/retry.js +50 -0
- package/dist/utils/tokenCounter.js +24 -0
- package/dist/utils/uiFormatter.js +106 -0
- package/package.json +92 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message History - Persistent conversation state for n0 loop
|
|
3
|
+
*/
|
|
4
|
+
class MessageHistoryManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.messages = [];
|
|
7
|
+
}
|
|
8
|
+
static getInstance() {
|
|
9
|
+
if (!MessageHistoryManager.instance) {
|
|
10
|
+
MessageHistoryManager.instance = new MessageHistoryManager();
|
|
11
|
+
}
|
|
12
|
+
return MessageHistoryManager.instance;
|
|
13
|
+
}
|
|
14
|
+
addUser(content) {
|
|
15
|
+
this.messages.push({
|
|
16
|
+
role: 'user',
|
|
17
|
+
content,
|
|
18
|
+
timestamp: Date.now()
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
addAssistant(content) {
|
|
22
|
+
this.messages.push({
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content,
|
|
25
|
+
timestamp: Date.now()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
addToolResult(toolName, toolCallId, result) {
|
|
29
|
+
this.messages.push({
|
|
30
|
+
role: 'tool',
|
|
31
|
+
content: result,
|
|
32
|
+
toolName,
|
|
33
|
+
toolCallId,
|
|
34
|
+
timestamp: Date.now()
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
getAll() {
|
|
38
|
+
return [...this.messages];
|
|
39
|
+
}
|
|
40
|
+
getForLLM() {
|
|
41
|
+
return this.messages.map(m => ({
|
|
42
|
+
role: m.role === 'tool' ? 'user' : m.role,
|
|
43
|
+
content: m.role === 'tool'
|
|
44
|
+
? `[TOOL_RESULT:${m.toolName}] ${m.content}`
|
|
45
|
+
: m.content
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
clear() {
|
|
49
|
+
this.messages = [];
|
|
50
|
+
}
|
|
51
|
+
get length() {
|
|
52
|
+
return this.messages.length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const MessageHistory = MessageHistoryManager.getInstance();
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export class PermissionManager {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.sessionAllowedResources = new Set();
|
|
6
|
+
this.sessionAllowedPaths = new Set();
|
|
7
|
+
// Safe-List: Tools that auto-execute without HITL
|
|
8
|
+
this.SAFE_TOOLS = new Set([
|
|
9
|
+
'readFile',
|
|
10
|
+
'listFiles',
|
|
11
|
+
'listSymbols',
|
|
12
|
+
'grep',
|
|
13
|
+
'internal_thought',
|
|
14
|
+
'ToDoWrite'
|
|
15
|
+
]);
|
|
16
|
+
// Safe shell command patterns
|
|
17
|
+
this.SAFE_SHELL_PATTERNS = [
|
|
18
|
+
// Git read operations
|
|
19
|
+
/^git\s+(status|log|diff|show|branch|remote|config\s+--get)/,
|
|
20
|
+
// File listing
|
|
21
|
+
/^(ls|dir|find|tree|pwd|whoami)\s/,
|
|
22
|
+
/^(ls|dir|pwd|whoami)$/,
|
|
23
|
+
// File reading
|
|
24
|
+
/^(cat|head|tail|less|more|grep|rg|ag)\s/,
|
|
25
|
+
// Package managers (read-only)
|
|
26
|
+
/^(npm|yarn|pnpm)\s+(list|ls|view|info|outdated|audit)\s/,
|
|
27
|
+
/^(npm|yarn|pnpm)\s+-v$/,
|
|
28
|
+
/^node\s+-v$/,
|
|
29
|
+
// Testing (read-only execution, no modifications)
|
|
30
|
+
/^(npm|yarn|pnpm)\s+(test|t)\s/,
|
|
31
|
+
/^(jest|vitest|mocha|ava)\s/,
|
|
32
|
+
// Build tools (read-only check)
|
|
33
|
+
/^(tsc|eslint|prettier)\s+--noEmit/,
|
|
34
|
+
// Other safe commands
|
|
35
|
+
/^(date|echo|printf|which|type|command\s+-v)/
|
|
36
|
+
];
|
|
37
|
+
// Critical shell command patterns (require HITL)
|
|
38
|
+
this.CRITICAL_SHELL_PATTERNS = [
|
|
39
|
+
/^(rm|mv|cp)\s/,
|
|
40
|
+
/^git\s+(push|commit|add|reset|rebase|merge|cherry-pick|pull)/,
|
|
41
|
+
/^(npm|yarn|pnpm)\s+(install|i|add|remove|uninstall|publish|run\s+(?!test))/,
|
|
42
|
+
/^(curl|wget|fetch)\s/,
|
|
43
|
+
/^chmod\s/,
|
|
44
|
+
/^sudo\s/,
|
|
45
|
+
/>/, // Output redirection
|
|
46
|
+
/>>/ // Append redirection
|
|
47
|
+
];
|
|
48
|
+
// BLOCKED commands - never allowed, even with HITL
|
|
49
|
+
this.BLOCKED_COMMANDS = [
|
|
50
|
+
/^sudo\s+rm\s+-rf\s+\/$/,
|
|
51
|
+
/^rm\s+-rf\s+\/$/,
|
|
52
|
+
/^rm\s+-rf\s+~$/,
|
|
53
|
+
/^chmod\s+777\s+\/$/,
|
|
54
|
+
/^:(){ :\|:& };:/, // Fork bomb
|
|
55
|
+
/^dd\s+if=.*of=\/dev\//,
|
|
56
|
+
/^mkfs\./,
|
|
57
|
+
/^format\s/
|
|
58
|
+
];
|
|
59
|
+
this.mode = 'manual';
|
|
60
|
+
this.logPath = path.join(process.cwd(), '.camo', 'permissions.log');
|
|
61
|
+
this.ensureLogDir();
|
|
62
|
+
}
|
|
63
|
+
static getInstance() {
|
|
64
|
+
if (!PermissionManager.instance) {
|
|
65
|
+
PermissionManager.instance = new PermissionManager();
|
|
66
|
+
}
|
|
67
|
+
return PermissionManager.instance;
|
|
68
|
+
}
|
|
69
|
+
async ensureLogDir() {
|
|
70
|
+
try {
|
|
71
|
+
await fs.mkdir(path.dirname(this.logPath), { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
catch (e) { /* ignore */ }
|
|
74
|
+
}
|
|
75
|
+
async logDecision(type, resource, decision, reason, scope) {
|
|
76
|
+
// Redact secrets from log
|
|
77
|
+
const sanitizedResource = this.redactSecrets(resource);
|
|
78
|
+
const entry = `[${new Date().toISOString()}] [${type}] [${decision}] [${scope}] Resource: "${sanitizedResource}" | Reason: ${reason}\n`;
|
|
79
|
+
try {
|
|
80
|
+
await fs.appendFile(this.logPath, entry, 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
catch (e) { /* ignore logging errors */ }
|
|
83
|
+
}
|
|
84
|
+
redactSecrets(text) {
|
|
85
|
+
// Redact common secret patterns
|
|
86
|
+
return text
|
|
87
|
+
.replace(/AIza[A-Za-z0-9_-]{35}/g, '[REDACTED_API_KEY]')
|
|
88
|
+
.replace(/sk-[A-Za-z0-9]{48}/g, '[REDACTED_API_KEY]')
|
|
89
|
+
.replace(/ghp_[A-Za-z0-9]{36}/g, '[REDACTED_TOKEN]')
|
|
90
|
+
.replace(/password[=:]\S+/gi, 'password=[REDACTED]')
|
|
91
|
+
.replace(/token[=:]\S+/gi, 'token=[REDACTED]');
|
|
92
|
+
}
|
|
93
|
+
isPathSafe(targetPath) {
|
|
94
|
+
const resolved = path.resolve(process.cwd(), targetPath);
|
|
95
|
+
const projectRoot = process.cwd();
|
|
96
|
+
return resolved.startsWith(projectRoot);
|
|
97
|
+
}
|
|
98
|
+
isCommandBlocked(command) {
|
|
99
|
+
return this.BLOCKED_COMMANDS.some(pattern => pattern.test(command.trim()));
|
|
100
|
+
}
|
|
101
|
+
setMode(mode) {
|
|
102
|
+
this.mode = mode;
|
|
103
|
+
}
|
|
104
|
+
getMode() {
|
|
105
|
+
return this.mode;
|
|
106
|
+
}
|
|
107
|
+
async validate(type, context, callbacks) {
|
|
108
|
+
const resource = this.getResourceId(type, context);
|
|
109
|
+
// 0. BLOCKED commands - never allowed
|
|
110
|
+
if (type === 'SHELL' && context.command && this.isCommandBlocked(context.command)) {
|
|
111
|
+
await this.logDecision(type, resource, 'DENIED', 'Blocked dangerous command', 'AUTO');
|
|
112
|
+
callbacks.onChunk('\n[SECURITY] Command blocked: dangerous operation not allowed.\n');
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// 0b. Path sandboxing - reject writes outside project
|
|
116
|
+
if (type === 'FILE_WRITE' && context.path && !this.isPathSafe(context.path)) {
|
|
117
|
+
await this.logDecision(type, resource, 'DENIED', 'Path outside project root', 'AUTO');
|
|
118
|
+
callbacks.onChunk('\n[SECURITY] Write blocked: path outside project root.\n');
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// 1. Safe-List Check for Tools (Auto-Execute)
|
|
122
|
+
if (context.toolName && this.SAFE_TOOLS.has(context.toolName)) {
|
|
123
|
+
await this.logDecision(type, resource, 'APPROVED', 'Safe-List Tool', 'AUTO');
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
// 2. Session Allow-List Check
|
|
127
|
+
if (this.sessionAllowedResources.has(resource)) {
|
|
128
|
+
await this.logDecision(type, resource, 'APPROVED', 'Session Allowed', 'SESSION');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
// 3. Session Path Trust Check (for FILE_WRITE)
|
|
132
|
+
if (type === 'FILE_WRITE' && context.path) {
|
|
133
|
+
const normalizedPath = path.resolve(process.cwd(), context.path);
|
|
134
|
+
if (this.sessionAllowedPaths.has(normalizedPath)) {
|
|
135
|
+
await this.logDecision(type, resource, 'APPROVED', 'Session Trusted Path', 'SESSION');
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// 4. Auto-Allow Safety Checks (Only in AUTO mode or for explicitly safe types)
|
|
140
|
+
const { isSafe, reason } = this.checkSafety(type, context);
|
|
141
|
+
if (isSafe) {
|
|
142
|
+
await this.logDecision(type, resource, 'APPROVED', reason, 'AUTO');
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// 5. HITL Trigger for Critical Operations
|
|
146
|
+
const hitlResult = await callbacks.onHITL({
|
|
147
|
+
id: Date.now().toString(),
|
|
148
|
+
type: type,
|
|
149
|
+
command: context.command,
|
|
150
|
+
path: context.path,
|
|
151
|
+
diff: context.diff,
|
|
152
|
+
reason: reason,
|
|
153
|
+
mode: this.mode
|
|
154
|
+
});
|
|
155
|
+
if (hitlResult.approved) {
|
|
156
|
+
if (hitlResult.scope === 'session') {
|
|
157
|
+
this.sessionAllowedResources.add(resource);
|
|
158
|
+
// For FILE_WRITE, also trust the path
|
|
159
|
+
if (type === 'FILE_WRITE' && context.path) {
|
|
160
|
+
const normalizedPath = path.resolve(process.cwd(), context.path);
|
|
161
|
+
this.sessionAllowedPaths.add(normalizedPath);
|
|
162
|
+
}
|
|
163
|
+
await this.logDecision(type, resource, 'APPROVED', 'User Approved (Session)', 'SESSION');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await this.logDecision(type, resource, 'APPROVED', 'User Approved (Once)', 'ONCE');
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
await this.logDecision(type, resource, 'DENIED', 'User Denied', 'ONCE');
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
getResourceId(type, context) {
|
|
176
|
+
if (type === 'SHELL')
|
|
177
|
+
return `SHELL:${context.command}`;
|
|
178
|
+
if (type === 'FILE_WRITE')
|
|
179
|
+
return `WRITE:${context.path}`;
|
|
180
|
+
if (type === 'FILE_READ')
|
|
181
|
+
return `READ:${context.path}`;
|
|
182
|
+
if (type === 'WEB_ACCESS')
|
|
183
|
+
return `WEB:${context.url}`;
|
|
184
|
+
return 'UNKNOWN';
|
|
185
|
+
}
|
|
186
|
+
checkSafety(type, context) {
|
|
187
|
+
// FILE_READ is always safe (auto-execute)
|
|
188
|
+
if (type === 'FILE_READ') {
|
|
189
|
+
return { isSafe: true, reason: 'Safe read operation' };
|
|
190
|
+
}
|
|
191
|
+
// SHELL command analysis
|
|
192
|
+
if (type === 'SHELL' && context.command) {
|
|
193
|
+
// MANUAL MODE: Require approval for ALL shell commands
|
|
194
|
+
if (this.mode === 'manual') {
|
|
195
|
+
return { isSafe: false, reason: 'Manual Mode: Shell commands require approval' };
|
|
196
|
+
}
|
|
197
|
+
const cmd = context.command.trim();
|
|
198
|
+
// Check if command matches safe patterns
|
|
199
|
+
const isSafeCommand = this.SAFE_SHELL_PATTERNS.some(pattern => pattern.test(cmd));
|
|
200
|
+
if (isSafeCommand) {
|
|
201
|
+
return { isSafe: true, reason: 'Safe read-only command' };
|
|
202
|
+
}
|
|
203
|
+
// Check if command matches critical patterns
|
|
204
|
+
const isCritical = this.CRITICAL_SHELL_PATTERNS.some(pattern => pattern.test(cmd));
|
|
205
|
+
if (isCritical) {
|
|
206
|
+
return { isSafe: false, reason: 'Critical operation requires approval' };
|
|
207
|
+
}
|
|
208
|
+
// Additional path check for unlisted commands
|
|
209
|
+
const paths = this.extractPaths(cmd);
|
|
210
|
+
const isInternal = paths.length === 0 || paths.every(p => p.startsWith(process.cwd()));
|
|
211
|
+
if (!isInternal) {
|
|
212
|
+
return { isSafe: false, reason: 'Accessing files outside project' };
|
|
213
|
+
}
|
|
214
|
+
// Default to requiring approval for unknown commands
|
|
215
|
+
return { isSafe: false, reason: 'Unknown command, requires approval' };
|
|
216
|
+
}
|
|
217
|
+
// WEB_ACCESS whitelist check
|
|
218
|
+
if (type === 'WEB_ACCESS' && context.url) {
|
|
219
|
+
const url = context.url;
|
|
220
|
+
const inUserMsg = context.userMessage?.includes(url);
|
|
221
|
+
const inProject = context.projectContext?.includes(url);
|
|
222
|
+
if (inUserMsg || inProject) {
|
|
223
|
+
return { isSafe: true, reason: 'URL mentioned in user request' };
|
|
224
|
+
}
|
|
225
|
+
return { isSafe: false, reason: 'External URL not explicitly requested' };
|
|
226
|
+
}
|
|
227
|
+
// FILE_WRITE always requires approval (unless session trusted)
|
|
228
|
+
if (type === 'FILE_WRITE') {
|
|
229
|
+
return { isSafe: false, reason: 'File modification requires approval' };
|
|
230
|
+
}
|
|
231
|
+
return { isSafe: false, reason: 'Unknown operation' };
|
|
232
|
+
}
|
|
233
|
+
extractPaths(cmd) {
|
|
234
|
+
const parts = cmd.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
235
|
+
const commandWords = ['ls', 'cat', 'grep', 'find', 'git', 'npm', 'yarn', 'pnpm', 'node', 'echo', 'pwd', 'jest', 'vitest', 'mocha', 'tsc', 'eslint'];
|
|
236
|
+
const potentialPaths = parts.filter(p => !p.startsWith('-') &&
|
|
237
|
+
!commandWords.includes(p.replace(/['"]/g, '')));
|
|
238
|
+
return potentialPaths.map(p => {
|
|
239
|
+
const clean = p.replace(/['"]/g, '');
|
|
240
|
+
try {
|
|
241
|
+
return path.resolve(process.cwd(), clean);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return clean;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Clear session trust (for testing or explicit reset)
|
|
249
|
+
clearSessionTrust() {
|
|
250
|
+
this.sessionAllowedResources.clear();
|
|
251
|
+
this.sessionAllowedPaths.clear();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export class SessionManager {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.currentSession = null;
|
|
6
|
+
this.autoSaveInterval = null;
|
|
7
|
+
this.sessionDir = path.join(process.cwd(), '.camo', 'sessions');
|
|
8
|
+
this.ensureSessionDir();
|
|
9
|
+
}
|
|
10
|
+
static getInstance() {
|
|
11
|
+
if (!SessionManager.instance) {
|
|
12
|
+
SessionManager.instance = new SessionManager();
|
|
13
|
+
}
|
|
14
|
+
return SessionManager.instance;
|
|
15
|
+
}
|
|
16
|
+
async ensureSessionDir() {
|
|
17
|
+
try {
|
|
18
|
+
await fs.mkdir(this.sessionDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
catch (e) { /* ignore */ }
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a new session
|
|
24
|
+
*/
|
|
25
|
+
async createSession() {
|
|
26
|
+
const sessionId = `session-${Date.now()}`;
|
|
27
|
+
this.currentSession = {
|
|
28
|
+
sessionId,
|
|
29
|
+
createdAt: Date.now(),
|
|
30
|
+
lastUpdatedAt: Date.now(),
|
|
31
|
+
messages: [],
|
|
32
|
+
metadata: {
|
|
33
|
+
totalTokens: 0,
|
|
34
|
+
totalCost: 0,
|
|
35
|
+
toolsUsed: []
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
await this.saveSession();
|
|
39
|
+
this.startAutoSave();
|
|
40
|
+
return sessionId;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resume an existing session
|
|
44
|
+
*/
|
|
45
|
+
async resumeSession(sessionId) {
|
|
46
|
+
try {
|
|
47
|
+
const sessionPath = path.join(this.sessionDir, `${sessionId}.json`);
|
|
48
|
+
const data = await fs.readFile(sessionPath, 'utf-8');
|
|
49
|
+
this.currentSession = JSON.parse(data);
|
|
50
|
+
this.startAutoSave();
|
|
51
|
+
return this.currentSession;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the latest session (for auto-resume)
|
|
59
|
+
*/
|
|
60
|
+
async getLatestSession() {
|
|
61
|
+
try {
|
|
62
|
+
const files = await fs.readdir(this.sessionDir);
|
|
63
|
+
const sessionFiles = files.filter(f => f.startsWith('session-') && f.endsWith('.json'));
|
|
64
|
+
if (sessionFiles.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
// Sort by timestamp (newest first)
|
|
67
|
+
sessionFiles.sort().reverse();
|
|
68
|
+
const latestFile = sessionFiles[0];
|
|
69
|
+
const sessionPath = path.join(this.sessionDir, latestFile);
|
|
70
|
+
const data = await fs.readFile(sessionPath, 'utf-8');
|
|
71
|
+
return JSON.parse(data);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Add a message to the current session
|
|
79
|
+
*/
|
|
80
|
+
addMessage(message) {
|
|
81
|
+
if (!this.currentSession)
|
|
82
|
+
return;
|
|
83
|
+
this.currentSession.messages.push(message);
|
|
84
|
+
this.currentSession.lastUpdatedAt = Date.now();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Update session metadata
|
|
88
|
+
*/
|
|
89
|
+
updateMetadata(updates) {
|
|
90
|
+
if (!this.currentSession)
|
|
91
|
+
return;
|
|
92
|
+
this.currentSession.metadata = {
|
|
93
|
+
...this.currentSession.metadata,
|
|
94
|
+
...updates
|
|
95
|
+
};
|
|
96
|
+
this.currentSession.lastUpdatedAt = Date.now();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Save current session to disk
|
|
100
|
+
*/
|
|
101
|
+
async saveSession() {
|
|
102
|
+
if (!this.currentSession)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
const sessionPath = path.join(this.sessionDir, `${this.currentSession.sessionId}.json`);
|
|
106
|
+
await fs.writeFile(sessionPath, JSON.stringify(this.currentSession, null, 2), 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
console.error('Failed to save session:', e);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Auto-save every 30 seconds
|
|
114
|
+
*/
|
|
115
|
+
startAutoSave() {
|
|
116
|
+
if (this.autoSaveInterval) {
|
|
117
|
+
clearInterval(this.autoSaveInterval);
|
|
118
|
+
}
|
|
119
|
+
this.autoSaveInterval = setInterval(() => {
|
|
120
|
+
this.saveSession();
|
|
121
|
+
}, 30000); // 30 seconds
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Stop auto-save
|
|
125
|
+
*/
|
|
126
|
+
stopAutoSave() {
|
|
127
|
+
if (this.autoSaveInterval) {
|
|
128
|
+
clearInterval(this.autoSaveInterval);
|
|
129
|
+
this.autoSaveInterval = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get current session
|
|
134
|
+
*/
|
|
135
|
+
getCurrentSession() {
|
|
136
|
+
return this.currentSession;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* List all sessions
|
|
140
|
+
*/
|
|
141
|
+
async listSessions() {
|
|
142
|
+
try {
|
|
143
|
+
const files = await fs.readdir(this.sessionDir);
|
|
144
|
+
const sessionFiles = files.filter(f => f.startsWith('session-') && f.endsWith('.json'));
|
|
145
|
+
const sessions = await Promise.all(sessionFiles.map(async (file) => {
|
|
146
|
+
const sessionPath = path.join(this.sessionDir, file);
|
|
147
|
+
const data = await fs.readFile(sessionPath, 'utf-8');
|
|
148
|
+
const session = JSON.parse(data);
|
|
149
|
+
return {
|
|
150
|
+
id: session.sessionId,
|
|
151
|
+
createdAt: session.createdAt,
|
|
152
|
+
messageCount: session.messages.length
|
|
153
|
+
};
|
|
154
|
+
}));
|
|
155
|
+
return sessions.sort((a, b) => b.createdAt - a.createdAt);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Delete a session
|
|
163
|
+
*/
|
|
164
|
+
async deleteSession(sessionId) {
|
|
165
|
+
try {
|
|
166
|
+
const sessionPath = path.join(this.sessionDir, `${sessionId}.json`);
|
|
167
|
+
await fs.unlink(sessionPath);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
console.error('Failed to delete session:', e);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Cleanup: Save and stop auto-save
|
|
175
|
+
*/
|
|
176
|
+
async cleanup() {
|
|
177
|
+
await this.saveSession();
|
|
178
|
+
this.stopAutoSave();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskState - Persistent state for two-phase agent execution
|
|
3
|
+
*/
|
|
4
|
+
class TaskStateManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.state = {
|
|
7
|
+
phase: 'idle',
|
|
8
|
+
originalTask: '',
|
|
9
|
+
plan: [],
|
|
10
|
+
currentTaskIndex: 0,
|
|
11
|
+
errorCount: 0,
|
|
12
|
+
lastError: null
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
static getInstance() {
|
|
16
|
+
if (!TaskStateManager.instance) {
|
|
17
|
+
TaskStateManager.instance = new TaskStateManager();
|
|
18
|
+
}
|
|
19
|
+
return TaskStateManager.instance;
|
|
20
|
+
}
|
|
21
|
+
// Start new task - enters planning phase
|
|
22
|
+
startTask(userInput) {
|
|
23
|
+
this.state = {
|
|
24
|
+
phase: 'planning',
|
|
25
|
+
originalTask: userInput,
|
|
26
|
+
plan: [],
|
|
27
|
+
currentTaskIndex: 0,
|
|
28
|
+
errorCount: 0,
|
|
29
|
+
lastError: null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Set plan from manageTasks
|
|
33
|
+
setPlan(tasks) {
|
|
34
|
+
this.state.plan = tasks;
|
|
35
|
+
this.state.phase = 'awaiting_approval';
|
|
36
|
+
}
|
|
37
|
+
// User approved - start execution
|
|
38
|
+
approve() {
|
|
39
|
+
this.state.phase = 'executing';
|
|
40
|
+
this.state.currentTaskIndex = 0;
|
|
41
|
+
}
|
|
42
|
+
// Mark current task in progress
|
|
43
|
+
startCurrentTask() {
|
|
44
|
+
if (this.state.plan[this.state.currentTaskIndex]) {
|
|
45
|
+
this.state.plan[this.state.currentTaskIndex].status = 'in_progress';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Complete current task and advance
|
|
49
|
+
completeCurrentTask() {
|
|
50
|
+
if (this.state.plan[this.state.currentTaskIndex]) {
|
|
51
|
+
this.state.plan[this.state.currentTaskIndex].status = 'completed';
|
|
52
|
+
this.state.currentTaskIndex++;
|
|
53
|
+
}
|
|
54
|
+
// Check if all done
|
|
55
|
+
if (this.state.currentTaskIndex >= this.state.plan.length) {
|
|
56
|
+
this.state.phase = 'complete';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Record error for self-correction
|
|
60
|
+
recordError(error) {
|
|
61
|
+
this.state.errorCount++;
|
|
62
|
+
this.state.lastError = error;
|
|
63
|
+
}
|
|
64
|
+
// Reset state
|
|
65
|
+
reset() {
|
|
66
|
+
this.state = {
|
|
67
|
+
phase: 'idle',
|
|
68
|
+
originalTask: '',
|
|
69
|
+
plan: [],
|
|
70
|
+
currentTaskIndex: 0,
|
|
71
|
+
errorCount: 0,
|
|
72
|
+
lastError: null
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Getters
|
|
76
|
+
get phase() { return this.state.phase; }
|
|
77
|
+
get originalTask() { return this.state.originalTask; }
|
|
78
|
+
get plan() { return this.state.plan; }
|
|
79
|
+
get currentTaskIndex() { return this.state.currentTaskIndex; }
|
|
80
|
+
get totalTasks() { return this.state.plan.length; }
|
|
81
|
+
get progress() {
|
|
82
|
+
return `${this.state.currentTaskIndex + 1}/${this.state.plan.length}`;
|
|
83
|
+
}
|
|
84
|
+
get isComplete() { return this.state.phase === 'complete'; }
|
|
85
|
+
get hasError() { return this.state.lastError !== null; }
|
|
86
|
+
get lastError() { return this.state.lastError; }
|
|
87
|
+
// Get state for context injection
|
|
88
|
+
getContextString() {
|
|
89
|
+
if (this.state.phase === 'idle')
|
|
90
|
+
return '';
|
|
91
|
+
let ctx = `\n**TASK STATE**\n`;
|
|
92
|
+
ctx += `Original: ${this.state.originalTask}\n`;
|
|
93
|
+
ctx += `Phase: ${this.state.phase}\n`;
|
|
94
|
+
if (this.state.plan.length > 0) {
|
|
95
|
+
ctx += `Plan:\n`;
|
|
96
|
+
this.state.plan.forEach((t, i) => {
|
|
97
|
+
const marker = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '→' : ' ';
|
|
98
|
+
ctx += ` [${marker}] ${i + 1}. ${t.title}\n`;
|
|
99
|
+
});
|
|
100
|
+
ctx += `Progress: ${this.progress}\n`;
|
|
101
|
+
}
|
|
102
|
+
if (this.state.lastError) {
|
|
103
|
+
ctx += `Last Error: ${this.state.lastError}\n`;
|
|
104
|
+
}
|
|
105
|
+
return ctx;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export const TaskState = TaskStateManager.getInstance();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug utilities
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from 'fs';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
const DEBUG_LOG_FILE = path.join(homedir(), '.camo-debug.log');
|
|
8
|
+
export async function debugLog(message, data) {
|
|
9
|
+
try {
|
|
10
|
+
const timestamp = new Date().toISOString();
|
|
11
|
+
let logLine = `[${timestamp}] ${message}`;
|
|
12
|
+
if (data) {
|
|
13
|
+
// Hide sensitive data
|
|
14
|
+
const sanitized = JSON.stringify(data)
|
|
15
|
+
.replace(/"apiKey":"[^"]*"/g, '"apiKey":"***"')
|
|
16
|
+
.replace(/"api_key":"[^"]*"/g, '"api_key":"***"');
|
|
17
|
+
logLine += ` ${sanitized}`;
|
|
18
|
+
}
|
|
19
|
+
await fs.appendFile(DEBUG_LOG_FILE, logLine + '\n', 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
// Silent fail
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function isDebugEnabled() {
|
|
26
|
+
return process.env.CAMO_DEBUG === '1' || process.env.CAMO_DEBUG === 'true';
|
|
27
|
+
}
|
|
28
|
+
export async function getDebugLog() {
|
|
29
|
+
try {
|
|
30
|
+
return await fs.readFile(DEBUG_LOG_FILE, 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'No debug log found';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry utility with exponential backoff
|
|
3
|
+
*/
|
|
4
|
+
const defaultOptions = {
|
|
5
|
+
maxAttempts: 3,
|
|
6
|
+
baseDelayMs: 1000,
|
|
7
|
+
maxDelayMs: 10000,
|
|
8
|
+
};
|
|
9
|
+
export async function withRetry(fn, options = {}) {
|
|
10
|
+
const opts = { ...defaultOptions, ...options };
|
|
11
|
+
let lastError = null;
|
|
12
|
+
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
lastError = error;
|
|
18
|
+
if (attempt === opts.maxAttempts) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
// Exponential backoff: 1s, 2s, 4s...
|
|
22
|
+
const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt - 1), opts.maxDelayMs);
|
|
23
|
+
opts.onRetry?.(attempt, error);
|
|
24
|
+
await sleep(delay);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw lastError;
|
|
28
|
+
}
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Execute with timeout
|
|
34
|
+
*/
|
|
35
|
+
export function withTimeout(promise, timeoutMs, errorMessage = 'Operation timed out') {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
reject(new Error(errorMessage));
|
|
39
|
+
}, timeoutMs);
|
|
40
|
+
promise
|
|
41
|
+
.then(result => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve(result);
|
|
44
|
+
})
|
|
45
|
+
.catch(error => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
reject(error);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|