obol-ai 0.2.22 → 0.2.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -99,6 +99,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR
99
99
  context._reloadPersonality = reloadPersonality;
100
100
  context._abortSignal = abortController.signal;
101
101
  context._forceSignal = forceController.signal;
102
+ context.claude = { chat, clearHistory, client };
102
103
  const runnableTools = buildRunnableTools(tools, memory, context, vlog);
103
104
  let activeModel = model;
104
105
 
@@ -12,6 +12,7 @@ const schedulerTool = require('./tools/scheduler');
12
12
  const ttsTool = require('./tools/tts');
13
13
  const bridgeTool = require('./tools/bridge');
14
14
  const historyTool = require('./tools/history');
15
+ const agentTool = require('./tools/agent');
15
16
 
16
17
  const TOOL_MODULES = [
17
18
  execTool,
@@ -24,17 +25,22 @@ const TOOL_MODULES = [
24
25
  schedulerTool,
25
26
  ttsTool,
26
27
  historyTool,
28
+ agentTool,
27
29
  ];
28
30
 
29
31
  const INPUT_SUMMARIES = {
30
32
  exec: (i) => i.command,
31
33
  write_file: (i) => i.path,
32
- read_file: (i) => i.path,
34
+ read_file: (i) => i.offset ? `${i.path}:${i.offset}` : i.path,
35
+ edit_file: (i) => i.path,
36
+ glob: (i) => i.pattern,
37
+ grep: (i) => `${i.pattern}${i.path ? ` in ${i.path}` : ''}`,
33
38
  memory_search: (i) => i.query,
34
39
  memory_add: (i) => `[${i.category || 'fact'}]`,
35
40
  memory_remove: (i) => i.ids?.join(', '),
36
41
  memory_query: (i) => `${i.date || ''}${i.tags ? ' #' + i.tags.join(' #') : ''}${i.category ? ' [' + i.category + ']' : ''}`.trim() || 'all',
37
42
  web_search: (i) => i.query,
43
+ agent: (i) => i.task?.substring(0, 60),
38
44
  background_task: (i) => i.task?.substring(0, 60),
39
45
  schedule_event: (i) => `${i.title} @ ${i.due_at}${i.cron_expr ? ` [${i.cron_expr}]` : ''}`,
40
46
  cancel_event: (i) => i.event_id,
@@ -0,0 +1,40 @@
1
+ const definitions = [{
2
+ name: 'agent',
3
+ description: 'Spawn a focused sub-agent to handle a specific task and return the result. Use for research, file analysis, or any multi-step work that would clutter the main conversation. Defaults to haiku — use sonnet for tasks requiring deeper reasoning.',
4
+ input_schema: {
5
+ type: 'object',
6
+ properties: {
7
+ task: { type: 'string', description: 'Detailed description of what the sub-agent should do' },
8
+ model: { type: 'string', enum: ['haiku', 'sonnet'], description: 'Model to use (default: haiku)' },
9
+ },
10
+ required: ['task'],
11
+ },
12
+ }];
13
+
14
+ const handlers = {
15
+ async agent(input, memory, context) {
16
+ const { claude } = context;
17
+ if (!claude) return 'Agent tool not available in this context.';
18
+
19
+ const depth = context._agentDepth || 0;
20
+ if (depth >= 2) return 'Error: max agent nesting depth reached.';
21
+
22
+ const model = input.model === 'sonnet' ? 'claude-sonnet-4-6' : 'claude-haiku-4-5-20251001';
23
+ const subChatId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
24
+
25
+ const { text } = await claude.chat(input.task, {
26
+ chatId: subChatId,
27
+ _agentDepth: depth + 1,
28
+ _model: model,
29
+ userDir: context.userDir,
30
+ toolPrefs: context.toolPrefs,
31
+ verbose: context.verbose,
32
+ _verboseNotify: context._verboseNotify,
33
+ });
34
+
35
+ claude.clearHistory(subChatId);
36
+ return text || '(agent completed with no output)';
37
+ },
38
+ };
39
+
40
+ module.exports = { definitions, handlers };
@@ -1,6 +1,31 @@
1
- const { MAX_EXEC_TIMEOUT, BLOCKED_EXEC_PATTERNS, SENSITIVE_READ_PATHS } = require('../constants');
1
+ const { MAX_EXEC_TIMEOUT, BLOCKED_EXEC_PATTERNS } = require('../constants');
2
2
  const { execAsync } = require('../../sanitize');
3
3
 
4
+ const ALLOWED_PATH_PREFIXES = [
5
+ '/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/lib32/',
6
+ '/opt/',
7
+ '/tmp/',
8
+ '/dev/null', '/dev/stdout', '/dev/stderr', '/dev/stdin',
9
+ '/proc/self/',
10
+ ];
11
+
12
+ /** Extract absolute path tokens from a shell command */
13
+ function extractAbsolutePaths(command) {
14
+ const re = /(?:^|[\s=|&;<>('"])(\/[\w.\-/]*)/g;
15
+ const paths = new Set();
16
+ let m;
17
+ while ((m = re.exec(command)) !== null) {
18
+ paths.add(m[1]);
19
+ }
20
+ return [...paths];
21
+ }
22
+
23
+ /** Returns true if path is within userDir or a safe system prefix */
24
+ function isAllowedPath(p, userDir) {
25
+ if (p === userDir || p.startsWith(userDir + '/')) return true;
26
+ return ALLOWED_PATH_PREFIXES.some(prefix => p.startsWith(prefix));
27
+ }
28
+
4
29
  const definitions = [{
5
30
  name: 'exec',
6
31
  description: 'Execute a shell command and return the output. Use for file operations, system tasks, running scripts.',
@@ -23,10 +48,9 @@ const handlers = {
23
48
  }
24
49
  }
25
50
  if (userDir) {
26
- for (const pattern of SENSITIVE_READ_PATHS) {
27
- if (pattern.test(input.command)) {
28
- return `Blocked: command accesses a sensitive path. Ask the user for confirmation first.`;
29
- }
51
+ const blockedPaths = extractAbsolutePaths(input.command).filter(p => !isAllowedPath(p, userDir));
52
+ if (blockedPaths.length > 0) {
53
+ return `Blocked: command accesses path(s) outside your workspace: ${blockedPaths.join(', ')}`;
30
54
  }
31
55
  }
32
56
  const timeout = Math.min(input.timeout || 30, MAX_EXEC_TIMEOUT) * 1000;
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const fsPromises = require('fs/promises');
2
3
  const path = require('path');
3
4
  const { execFileSync } = require('child_process');
4
5
  const { resolveUserPath } = require('../utils');
@@ -6,11 +7,13 @@ const { resolveUserPath } = require('../utils');
6
7
  const definitions = [
7
8
  {
8
9
  name: 'read_file',
9
- description: 'Read contents of a file. Supports text files and PDFs (extracts text from PDF automatically).',
10
+ description: 'Read contents of a file. Supports text files and PDFs. Use offset and limit for large files.',
10
11
  input_schema: {
11
12
  type: 'object',
12
13
  properties: {
13
14
  path: { type: 'string', description: 'File path' },
15
+ offset: { type: 'number', description: 'Line number to start reading from (1-based)' },
16
+ limit: { type: 'number', description: 'Number of lines to read' },
14
17
  },
15
18
  required: ['path'],
16
19
  },
@@ -27,6 +30,43 @@ const definitions = [
27
30
  required: ['path', 'content'],
28
31
  },
29
32
  },
33
+ {
34
+ name: 'edit_file',
35
+ description: 'Replace an exact string in a file. old_string must appear exactly once. Prefer this over write_file for surgical edits.',
36
+ input_schema: {
37
+ type: 'object',
38
+ properties: {
39
+ path: { type: 'string', description: 'File path' },
40
+ old_string: { type: 'string', description: 'Text to replace — must be unique in the file' },
41
+ new_string: { type: 'string', description: 'Replacement text' },
42
+ },
43
+ required: ['path', 'old_string', 'new_string'],
44
+ },
45
+ },
46
+ {
47
+ name: 'glob',
48
+ description: 'Find files matching a glob pattern within your workspace. E.g. **/*.js, scripts/*.sh, test-*.js',
49
+ input_schema: {
50
+ type: 'object',
51
+ properties: {
52
+ pattern: { type: 'string', description: 'Glob pattern' },
53
+ },
54
+ required: ['pattern'],
55
+ },
56
+ },
57
+ {
58
+ name: 'grep',
59
+ description: 'Search file contents for a pattern within your workspace. Returns matching lines with file and line number.',
60
+ input_schema: {
61
+ type: 'object',
62
+ properties: {
63
+ pattern: { type: 'string', description: 'Search pattern (regex)' },
64
+ path: { type: 'string', description: 'File or directory to search (default: workspace root)' },
65
+ glob: { type: 'string', description: 'Limit search to files matching this glob pattern, e.g. *.js' },
66
+ },
67
+ required: ['pattern'],
68
+ },
69
+ },
30
70
  {
31
71
  name: 'send_file',
32
72
  description: 'Send a file to the user via Telegram (PDF, image, document, etc). Use after generating files the user requested.',
@@ -59,14 +99,22 @@ const handlers = {
59
99
  const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
60
100
  if (filePath.toLowerCase().endsWith('.pdf')) {
61
101
  const pdfParse = require('pdf-parse');
62
- const pdfBuffer = fs.readFileSync(filePath);
63
- const { text } = await pdfParse(pdfBuffer);
64
- const truncatedPdf = text.substring(0, 50000);
65
- return text.length > 50000 ? truncatedPdf + '\n...(truncated)' : truncatedPdf;
102
+ const { text } = await pdfParse(fs.readFileSync(filePath));
103
+ const truncated = text.substring(0, 50000);
104
+ return text.length > 50000 ? truncated + '\n...(truncated)' : truncated;
105
+ }
106
+ const raw = fs.readFileSync(filePath, 'utf-8');
107
+ if (input.offset || input.limit) {
108
+ const lines = raw.split('\n');
109
+ const start = Math.max(0, (input.offset || 1) - 1);
110
+ const slice = input.limit ? lines.slice(start, start + input.limit) : lines.slice(start);
111
+ const numbered = slice.map((l, i) => `${start + i + 1}\t${l}`).join('\n');
112
+ const totalLines = lines.length;
113
+ const end = start + slice.length;
114
+ return `Lines ${start + 1}-${end} of ${totalLines}:\n${numbered}`;
66
115
  }
67
- const fileContent = fs.readFileSync(filePath, 'utf-8');
68
- const truncatedFile = fileContent.substring(0, 50000);
69
- return fileContent.length > 50000 ? truncatedFile + '\n...(truncated)' : truncatedFile;
116
+ const truncated = raw.substring(0, 50000);
117
+ return raw.length > 50000 ? truncated + '\n...(truncated)' : truncated;
70
118
  },
71
119
 
72
120
  async write_file(input, memory, context) {
@@ -80,6 +128,50 @@ const handlers = {
80
128
  return `Written: ${filePath}`;
81
129
  },
82
130
 
131
+ async edit_file(input, memory, context) {
132
+ const { userDir } = context;
133
+ const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
134
+ const content = fs.readFileSync(filePath, 'utf-8');
135
+ const count = content.split(input.old_string).length - 1;
136
+ if (count === 0) return `Error: old_string not found in ${input.path}`;
137
+ if (count > 1) return `Error: old_string matches ${count} times — add more context to make it unique`;
138
+ fs.writeFileSync(filePath, content.replace(input.old_string, input.new_string));
139
+ if (path.basename(filePath) === 'traits.json' || filePath.includes('personality/')) {
140
+ context._reloadPersonality?.();
141
+ }
142
+ return `Edited: ${filePath}`;
143
+ },
144
+
145
+ async glob(input, memory, context) {
146
+ const { userDir } = context;
147
+ const cwd = userDir || '/tmp';
148
+ const files = [];
149
+ for await (const f of fsPromises.glob(input.pattern, { cwd })) {
150
+ files.push(f);
151
+ }
152
+ if (files.length === 0) return 'No files found matching pattern.';
153
+ return files.sort().join('\n');
154
+ },
155
+
156
+ async grep(input, memory, context) {
157
+ const { userDir } = context;
158
+ const searchRoot = input.path
159
+ ? resolveUserPath(input.path, userDir)
160
+ : (userDir || '/tmp');
161
+ const args = ['-r', '-n', '--include', input.glob || '*', input.pattern, searchRoot];
162
+ try {
163
+ const output = execFileSync('grep', args, { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
164
+ const lines = output.trim().split('\n');
165
+ // Make paths relative to userDir for cleaner output
166
+ const relative = lines.map(l => l.replace(searchRoot + '/', ''));
167
+ const truncated = relative.slice(0, 200);
168
+ return truncated.join('\n') + (lines.length > 200 ? `\n...(${lines.length - 200} more lines)` : '');
169
+ } catch (e) {
170
+ if (e.status === 1) return 'No matches found.';
171
+ throw e;
172
+ }
173
+ },
174
+
83
175
  async send_file(input, memory, context) {
84
176
  const { userDir } = context;
85
177
  const filePath = userDir ? resolveUserPath(input.path, userDir) : input.path;
package/src/clean.js CHANGED
@@ -1,19 +1,7 @@
1
- /**
2
- * Workspace cleaner — audits ~/.obol/ for misplaced files and rogue directories.
3
- *
4
- * Known structure:
5
- * config.json, .evolution-state.json, .first-run-done, .post-setup-done
6
- * personality/, scripts/, tests/, commands/, apps/, logs/
7
- *
8
- * Everything else is flagged. Rogue directories and unknown files are removed.
9
- * Misplaced files (e.g. a .js in personality/) are moved to the correct location.
10
- */
11
-
12
1
  const fs = require('fs');
13
2
  const path = require('path');
14
3
  const { OBOL_DIR } = require('./config');
15
4
 
16
- // Allowed top-level entries
17
5
  const ALLOWED_DIRS = new Set(['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets']);
18
6
  const ALLOWED_FILES = new Set([
19
7
  'config.json',
@@ -21,121 +9,248 @@ const ALLOWED_FILES = new Set([
21
9
  '.first-run-done',
22
10
  '.post-setup-done',
23
11
  ]);
24
- // Files that can appear at top level with any name
25
- const ALLOWED_PATTERNS = [
26
- /^\./, // Hidden files (dotfiles)
27
- ];
12
+ const ALLOWED_PATTERNS = [/^\./];
28
13
 
29
- // Where file types belong
30
14
  const FILE_RULES = {
31
15
  '.js': 'scripts',
32
16
  '.sh': 'scripts',
33
- '.md': 'commands', // .md files outside personality/ are probably commands
17
+ '.md': 'commands',
34
18
  };
35
19
 
36
- async function cleanWorkspace(userDir) {
37
- const baseDir = userDir || OBOL_DIR;
38
- const issues = [];
39
- const errors = [];
20
+ const DIR_FILE_RULES = {
21
+ personality: ['.md'],
22
+ scripts: ['.js', '.sh'],
23
+ tests: ['.js', '.sh'],
24
+ commands: ['.md'],
25
+ };
40
26
 
41
- if (!fs.existsSync(baseDir)) {
42
- return { issues, errors: ['Directory does not exist'] };
43
- }
27
+ function safeReaddir(dir) {
28
+ try {
29
+ return fs.readdirSync(dir).filter(f => {
30
+ try { return fs.statSync(path.join(dir, f)).isFile(); } catch { return false; }
31
+ });
32
+ } catch { return []; }
33
+ }
44
34
 
45
- const entries = fs.readdirSync(baseDir, { withFileTypes: true });
35
+ function safeReaddirAll(dir) {
36
+ try { return fs.readdirSync(dir); } catch { return []; }
37
+ }
46
38
 
47
- for (const entry of entries) {
48
- const fullPath = path.join(baseDir, entry.name);
39
+ function guessDestination(filename) {
40
+ const ext = path.extname(filename);
41
+ if (filename.startsWith('test-') || filename.startsWith('test_')) return 'tests';
42
+ return FILE_RULES[ext] || null;
43
+ }
44
+
45
+ /**
46
+ * @param {string} userDir
47
+ * @returns {Array<{type: string, name: string, children?: string[], currentDir?: string}>}
48
+ */
49
+ function scanWorkspace(userDir) {
50
+ const rogueItems = [];
51
+ if (!fs.existsSync(userDir)) return rogueItems;
49
52
 
53
+ const entries = fs.readdirSync(userDir, { withFileTypes: true });
54
+
55
+ for (const entry of entries) {
50
56
  if (entry.isDirectory()) {
51
57
  if (!ALLOWED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
52
- const files = safeReaddir(fullPath);
53
- if (files.length === 0) {
54
- try {
55
- fs.rmdirSync(fullPath);
56
- issues.push({ path: entry.name + '/', action: 'deleted (empty rogue dir)' });
57
- } catch (e) {
58
- errors.push(`Failed to remove ${entry.name}/: ${e.message}`);
59
- }
60
- } else {
61
- for (const file of files) {
62
- const src = path.join(fullPath, file);
63
- const dest = guessDestination(file);
64
- if (dest) {
65
- try {
66
- const destPath = path.join(baseDir, dest, file);
67
- fs.mkdirSync(path.join(baseDir, dest), { recursive: true });
68
- fs.renameSync(src, destPath);
69
- issues.push({ path: `${entry.name}/${file}`, action: `moved → ${dest}/${file}` });
70
- } catch (e) {
71
- errors.push(`Failed to move ${entry.name}/${file}: ${e.message}`);
72
- }
73
- } else {
74
- try {
75
- fs.unlinkSync(src);
76
- issues.push({ path: `${entry.name}/${file}`, action: 'deleted (unknown type)' });
77
- } catch (e) {
78
- errors.push(`Failed to delete ${entry.name}/${file}: ${e.message}`);
79
- }
80
- }
81
- }
82
- try {
83
- fs.rmdirSync(fullPath);
84
- issues.push({ path: entry.name + '/', action: 'deleted (rogue dir cleared)' });
85
- } catch {}
86
- }
58
+ rogueItems.push({ type: 'dir', name: entry.name, children: safeReaddirAll(path.join(userDir, entry.name)) });
87
59
  }
88
60
  } else if (entry.isFile()) {
89
61
  if (!ALLOWED_FILES.has(entry.name) && !ALLOWED_PATTERNS.some(p => p.test(entry.name))) {
90
- const dest = guessDestination(entry.name);
91
- if (dest) {
92
- try {
93
- const destPath = path.join(baseDir, dest, entry.name);
94
- fs.mkdirSync(path.join(baseDir, dest), { recursive: true });
95
- fs.renameSync(fullPath, destPath);
96
- issues.push({ path: entry.name, action: `moved → ${dest}/${entry.name}` });
97
- } catch (e) {
98
- errors.push(`Failed to move ${entry.name}: ${e.message}`);
99
- }
100
- } else {
101
- try {
102
- fs.unlinkSync(fullPath);
103
- issues.push({ path: entry.name, action: 'deleted (unknown file at root)' });
104
- } catch (e) {
105
- errors.push(`Failed to delete ${entry.name}: ${e.message}`);
106
- }
107
- }
62
+ rogueItems.push({ type: 'file', name: entry.name });
108
63
  }
109
64
  }
110
65
  }
111
66
 
112
- const dirFileRules = {
113
- personality: ['.md'],
114
- scripts: ['.js', '.sh'],
115
- tests: ['.js', '.sh'],
116
- commands: ['.md'],
117
- };
118
-
119
- for (const [dir, allowedExts] of Object.entries(dirFileRules)) {
120
- const dirPath = path.join(baseDir, dir);
67
+ for (const [dir, allowedExts] of Object.entries(DIR_FILE_RULES)) {
68
+ const dirPath = path.join(userDir, dir);
121
69
  if (!fs.existsSync(dirPath)) continue;
122
-
123
- const files = safeReaddir(dirPath);
124
- for (const file of files) {
70
+ for (const file of safeReaddir(dirPath)) {
125
71
  const ext = path.extname(file);
126
72
  if (ext && !allowedExts.includes(ext)) {
127
- const dest = guessDestination(file);
128
- if (dest && dest !== dir) {
129
- try {
130
- const src = path.join(dirPath, file);
131
- const destPath = path.join(baseDir, dest, file);
132
- fs.mkdirSync(path.join(baseDir, dest), { recursive: true });
133
- fs.renameSync(src, destPath);
134
- issues.push({ path: `${dir}/${file}`, action: `moved → ${dest}/${file}` });
135
- } catch (e) {
136
- errors.push(`Failed to move ${dir}/${file}: ${e.message}`);
73
+ rogueItems.push({ type: 'misplaced', name: file, currentDir: dir });
74
+ }
75
+ }
76
+ }
77
+
78
+ return rogueItems;
79
+ }
80
+
81
+ /**
82
+ * @param {Array} rogueItems
83
+ * @param {object} claudeClient - Anthropic client instance
84
+ * @returns {Promise<Array<{path: string, action: string, dest?: string}>|null>}
85
+ */
86
+ async function resolveWithLlm(rogueItems, claudeClient) {
87
+ const itemList = rogueItems.map(item => {
88
+ if (item.type === 'dir') {
89
+ return `- Directory "${item.name}/" containing: ${item.children.length ? item.children.join(', ') : '(empty)'}`;
90
+ }
91
+ if (item.type === 'misplaced') {
92
+ return `- File "${item.currentDir}/${item.name}" (wrong location for its type)`;
93
+ }
94
+ return `- File "${item.name}" at root level`;
95
+ }).join('\n');
96
+
97
+ const prompt = `You are organizing a workspace directory. The valid structure is:
98
+ - personality/ — .md files (soul, personality config)
99
+ - scripts/ — .js and .sh scripts
100
+ - tests/ — test files (test-*.js, test_*.js, *.test.js)
101
+ - commands/ — .md command definitions
102
+ - apps/ — application subdirectories
103
+ - logs/ — log files
104
+ - assets/ — media and binary assets
105
+
106
+ These items don't belong in their current location:
107
+ ${itemList}
108
+
109
+ For each item, decide: "move" to a valid directory, or "delete" if truly rogue/irrelevant.
110
+ Respond ONLY with a JSON array, no explanation:
111
+ [{"path":"item-name","action":"move|delete","dest":"destination-dir"}]
112
+ For directories use "dirname/", for misplaced files use "currentDir/filename".`;
113
+
114
+ const response = await claudeClient.messages.create({
115
+ model: 'claude-haiku-4-5-20251001',
116
+ max_tokens: 1024,
117
+ messages: [{ role: 'user', content: prompt }],
118
+ });
119
+
120
+ const text = response.content[0]?.text || '[]';
121
+ const match = text.match(/\[[\s\S]*\]/);
122
+ if (!match) return null;
123
+ try {
124
+ return JSON.parse(match[0]);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * @param {string} userDir
132
+ * @param {Array} rogueItems
133
+ * @param {Array} decisions
134
+ * @returns {{issues: Array, errors: Array}}
135
+ */
136
+ function applyDecisions(userDir, rogueItems, decisions) {
137
+ const issues = [];
138
+ const errors = [];
139
+
140
+ for (const decision of decisions) {
141
+ const item = rogueItems.find(r => {
142
+ if (r.type === 'dir') return decision.path === r.name + '/';
143
+ if (r.type === 'misplaced') return decision.path === `${r.currentDir}/${r.name}`;
144
+ return decision.path === r.name;
145
+ });
146
+ if (!item) continue;
147
+
148
+ const srcPath = item.type === 'misplaced'
149
+ ? path.join(userDir, item.currentDir, item.name)
150
+ : path.join(userDir, item.name);
151
+
152
+ if (decision.action === 'delete') {
153
+ try {
154
+ fs.rmSync(srcPath, { recursive: true, force: true });
155
+ issues.push({ path: decision.path, action: 'deleted' });
156
+ } catch (e) {
157
+ errors.push(`Failed to delete ${decision.path}: ${e.message}`);
158
+ }
159
+ } else if (decision.action === 'move' && decision.dest) {
160
+ const destDir = path.join(userDir, decision.dest);
161
+ const destPath = path.join(destDir, item.name);
162
+ try {
163
+ fs.mkdirSync(destDir, { recursive: true });
164
+ fs.renameSync(srcPath, destPath);
165
+ issues.push({ path: decision.path, action: `moved → ${decision.dest}/${item.name}` });
166
+ } catch (e) {
167
+ errors.push(`Failed to move ${decision.path}: ${e.message}`);
168
+ }
169
+ }
170
+ }
171
+
172
+ return { issues, errors };
173
+ }
174
+
175
+ /**
176
+ * @param {string} userDir
177
+ * @param {Array} rogueItems
178
+ * @returns {{issues: Array, errors: Array}}
179
+ */
180
+ function applyHeuristics(userDir, rogueItems) {
181
+ const issues = [];
182
+ const errors = [];
183
+
184
+ for (const item of rogueItems) {
185
+ if (item.type === 'dir') {
186
+ const fullPath = path.join(userDir, item.name);
187
+ const files = safeReaddir(fullPath);
188
+
189
+ if (item.children.length === 0) {
190
+ try {
191
+ fs.rmSync(fullPath, { recursive: true, force: true });
192
+ issues.push({ path: item.name + '/', action: 'deleted (empty rogue dir)' });
193
+ } catch (e) {
194
+ errors.push(`Failed to remove ${item.name}/: ${e.message}`);
195
+ }
196
+ } else {
197
+ for (const file of files) {
198
+ const dest = guessDestination(file);
199
+ if (dest) {
200
+ try {
201
+ const destPath = path.join(userDir, dest, file);
202
+ fs.mkdirSync(path.join(userDir, dest), { recursive: true });
203
+ fs.renameSync(path.join(fullPath, file), destPath);
204
+ issues.push({ path: `${item.name}/${file}`, action: `moved → ${dest}/${file}` });
205
+ } catch (e) {
206
+ errors.push(`Failed to move ${item.name}/${file}: ${e.message}`);
207
+ }
208
+ } else {
209
+ try {
210
+ fs.unlinkSync(path.join(fullPath, file));
211
+ issues.push({ path: `${item.name}/${file}`, action: 'deleted (unknown type)' });
212
+ } catch (e) {
213
+ errors.push(`Failed to delete ${item.name}/${file}: ${e.message}`);
214
+ }
137
215
  }
138
216
  }
217
+ try {
218
+ fs.rmSync(fullPath, { recursive: true, force: true });
219
+ issues.push({ path: item.name + '/', action: 'deleted (rogue dir cleared)' });
220
+ } catch {}
221
+ }
222
+ } else if (item.type === 'file') {
223
+ const dest = guessDestination(item.name);
224
+ const fullPath = path.join(userDir, item.name);
225
+ if (dest) {
226
+ try {
227
+ const destPath = path.join(userDir, dest, item.name);
228
+ fs.mkdirSync(path.join(userDir, dest), { recursive: true });
229
+ fs.renameSync(fullPath, destPath);
230
+ issues.push({ path: item.name, action: `moved → ${dest}/${item.name}` });
231
+ } catch (e) {
232
+ errors.push(`Failed to move ${item.name}: ${e.message}`);
233
+ }
234
+ } else {
235
+ try {
236
+ fs.unlinkSync(fullPath);
237
+ issues.push({ path: item.name, action: 'deleted (unknown file at root)' });
238
+ } catch (e) {
239
+ errors.push(`Failed to delete ${item.name}: ${e.message}`);
240
+ }
241
+ }
242
+ } else if (item.type === 'misplaced') {
243
+ const dest = guessDestination(item.name);
244
+ if (dest && dest !== item.currentDir) {
245
+ const src = path.join(userDir, item.currentDir, item.name);
246
+ try {
247
+ const destPath = path.join(userDir, dest, item.name);
248
+ fs.mkdirSync(path.join(userDir, dest), { recursive: true });
249
+ fs.renameSync(src, destPath);
250
+ issues.push({ path: `${item.currentDir}/${item.name}`, action: `moved → ${dest}/${item.name}` });
251
+ } catch (e) {
252
+ errors.push(`Failed to move ${item.currentDir}/${item.name}: ${e.message}`);
253
+ }
139
254
  }
140
255
  }
141
256
  }
@@ -143,21 +258,30 @@ async function cleanWorkspace(userDir) {
143
258
  return { issues, errors };
144
259
  }
145
260
 
146
- function guessDestination(filename) {
147
- const ext = path.extname(filename);
261
+ /**
262
+ * @param {string} userDir
263
+ * @param {object|null} claudeClient - optional Anthropic client for LLM-based resolution
264
+ * @returns {Promise<{issues: Array, errors: Array}>}
265
+ */
266
+ async function cleanWorkspace(userDir, claudeClient = null) {
267
+ const baseDir = userDir || OBOL_DIR;
268
+ if (!fs.existsSync(baseDir)) {
269
+ return { issues: [], errors: ['Directory does not exist'] };
270
+ }
148
271
 
149
- // Test files go to tests/
150
- if (filename.startsWith('test-') || filename.startsWith('test_')) return 'tests';
272
+ const rogueItems = scanWorkspace(baseDir);
273
+ if (rogueItems.length === 0) return { issues: [], errors: [] };
151
274
 
152
- return FILE_RULES[ext] || null;
153
- }
275
+ if (claudeClient) {
276
+ try {
277
+ const decisions = await resolveWithLlm(rogueItems, claudeClient);
278
+ if (decisions) return applyDecisions(baseDir, rogueItems, decisions);
279
+ } catch (e) {
280
+ console.error('[clean] LLM resolution failed, falling back to heuristics:', e.message);
281
+ }
282
+ }
154
283
 
155
- function safeReaddir(dir) {
156
- try {
157
- return fs.readdirSync(dir).filter(f => {
158
- try { return fs.statSync(path.join(dir, f)).isFile(); } catch { return false; }
159
- });
160
- } catch { return []; }
284
+ return applyHeuristics(baseDir, rogueItems);
161
285
  }
162
286
 
163
287
  module.exports = { cleanWorkspace };
@@ -28,7 +28,7 @@ function register(bot, config) {
28
28
  const { cleanWorkspace } = require('../../clean');
29
29
  await ctx.replyWithChatAction('typing');
30
30
  try {
31
- const result = await cleanWorkspace(tenant.userDir);
31
+ const result = await cleanWorkspace(tenant.userDir, tenant.claude.client);
32
32
  if (result.issues.length === 0) {
33
33
  await ctx.reply('✨ Workspace is clean. Nothing out of place.');
34
34
  } else {
@@ -13,6 +13,7 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
13
13
  const tenant = await getTenant(userId, config);
14
14
  const stopped = tenant?.claude?.stopChat(chatId);
15
15
  await answer({ text: stopped ? 'Stopping...' : 'Nothing to stop' });
16
+ ctx.editMessageReplyMarkup({ inline_keyboard: [] }).catch(() => {});
16
17
  return;
17
18
  }
18
19
 
@@ -22,6 +23,7 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
22
23
  const tenant = await getTenant(userId, config);
23
24
  const stopped = tenant?.claude?.forceStopChat(chatId);
24
25
  await answer({ text: stopped ? 'Force stopped' : 'Nothing to stop' });
26
+ ctx.editMessageReplyMarkup({ inline_keyboard: [] }).catch(() => {});
25
27
  return;
26
28
  }
27
29
 
@@ -107,7 +109,7 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
107
109
  clearTimeout(pending.timer);
108
110
  pendingAsks.delete(askId);
109
111
  const confirmHtml = markdownToTelegramHtml(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`);
110
- ctx.editMessageText(confirmHtml, { parse_mode: 'HTML' }).catch(() => {});
112
+ ctx.editMessageText(confirmHtml, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } }).catch(() => {});
111
113
  pending.resolve(selected);
112
114
  });
113
115
  }