obol-ai 0.2.25 → 0.2.26

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.2.26
2
+ - show cleaning status instead of processing during /clean
3
+ - fix ask deadlock, clean writes tests and audits secrets
4
+ - clean confirmation gate, exec sandbox fix, mermaid tool, scheduler always-on
5
+
1
6
  ## 0.2.25
2
7
  - changelog
3
8
  - add npmignore to exclude local files from package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
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": {
package/src/background.js CHANGED
@@ -8,7 +8,7 @@ class BackgroundRunner {
8
8
  this.taskCounter = 0;
9
9
  }
10
10
 
11
- spawn(claude, task, ctx, memory, parentContext) {
11
+ spawn(claude, task, ctx, memory, parentContext, opts = {}) {
12
12
  let running = 0;
13
13
  for (const t of this.tasks.values()) {
14
14
  if (t.status === 'running') running++;
@@ -30,13 +30,13 @@ class BackgroundRunner {
30
30
  const verbose = parentContext?.verbose || false;
31
31
  const verboseNotify = parentContext?._verboseNotify;
32
32
 
33
- const promise = this._runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify);
33
+ const promise = this._runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify, opts.model);
34
34
  taskState.promise = promise;
35
35
 
36
36
  return taskId;
37
37
  }
38
38
 
39
- async _runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify) {
39
+ async _runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify, model) {
40
40
  let statusMsgId = null;
41
41
  let statusTimer = null;
42
42
  let statusStart = Date.now();
@@ -76,6 +76,7 @@ TASK: ${task}`;
76
76
  chatId: `bg-${taskState.id}`,
77
77
  userName: 'BackgroundTask',
78
78
  verbose,
79
+ ...(model ? { _model: model } : {}),
79
80
  _verboseNotify: bgNotify,
80
81
  _onRouteDecision: (info) => {
81
82
  routeInfo = info;
@@ -24,16 +24,16 @@ const OPTIONAL_TOOLS = {
24
24
  tools: ['vercel_deploy', 'vercel_list'],
25
25
  config: {},
26
26
  },
27
- scheduler: {
28
- label: 'Scheduler',
29
- tools: ['schedule_event', 'list_events', 'cancel_event'],
30
- config: {},
31
- },
32
27
  background: {
33
28
  label: 'Background Tasks',
34
29
  tools: ['background_task'],
35
30
  config: {},
36
31
  },
32
+ mermaid: {
33
+ label: 'Flowchart',
34
+ tools: ['mermaid_chart'],
35
+ config: {},
36
+ },
37
37
  };
38
38
 
39
39
  const BLOCKED_EXEC_PATTERNS = [
@@ -56,7 +56,7 @@ ${workDir}/
56
56
  ├── scripts/ (utility scripts)
57
57
  ├── tests/ (test suite)
58
58
  ├── commands/ (command definitions)
59
- ├── apps/ (web apps for Vercel)
59
+ ├── apps/ (git repos and web apps any structure)
60
60
  ├── assets/ (uploaded files, images, media)
61
61
  └── logs/
62
62
  \`\`\`
@@ -177,6 +177,12 @@ Convert text to voice messages. Use when the user wants something read aloud.
177
177
  - \`text_to_speech\` — synthesize text and send as voice message. Voice defaults to user preference.
178
178
  - \`tts_voices\` — list available voices, filterable by language and gender
179
179
 
180
+ ### Flowchart / Diagram (\`mermaid_chart\`)
181
+ Generate diagrams and send them as images. Supports flowcharts, sequence diagrams, ER diagrams, Gantt charts, pie charts, etc.
182
+ - \`definition\` — Mermaid syntax (e.g. \`graph TD; A-->B\`)
183
+ - \`theme\` — default / dark / forest / neutral
184
+ - \`caption\` — optional caption on the image
185
+
180
186
  ### Bridge (\`bridge_ask\`, \`bridge_tell\`)
181
187
  Only available if bridge is enabled. Communicate with partner's AI agent.
182
188
  `);
@@ -14,6 +14,7 @@ const bridgeTool = require('./tools/bridge');
14
14
  const historyTool = require('./tools/history');
15
15
  const agentTool = require('./tools/agent');
16
16
  const sttTool = require('./tools/stt');
17
+ const mermaidTool = require('./tools/mermaid');
17
18
 
18
19
  const TOOL_MODULES = [
19
20
  execTool,
@@ -28,6 +29,7 @@ const TOOL_MODULES = [
28
29
  historyTool,
29
30
  agentTool,
30
31
  sttTool,
32
+ mermaidTool,
31
33
  ];
32
34
 
33
35
  const INPUT_SUMMARIES = {
@@ -1,3 +1,4 @@
1
+ const path = require('path');
1
2
  const { MAX_EXEC_TIMEOUT, BLOCKED_EXEC_PATTERNS } = require('../constants');
2
3
  const { execAsync } = require('../../sanitize');
3
4
 
@@ -20,6 +21,17 @@ function extractAbsolutePaths(command) {
20
21
  return [...paths];
21
22
  }
22
23
 
24
+ /** Extract and resolve relative traversal paths (e.g. ../../etc) against userDir */
25
+ function extractTraversalPaths(command, userDir) {
26
+ const re = /(?:^|[\s=|&;<>('"])(\.\.[\w.\-/]*)/g;
27
+ const paths = [];
28
+ let m;
29
+ while ((m = re.exec(command)) !== null) {
30
+ paths.push(path.resolve(userDir, m[1]));
31
+ }
32
+ return paths;
33
+ }
34
+
23
35
  /** Returns true if path is within userDir or a safe system prefix */
24
36
  function isAllowedPath(p, userDir) {
25
37
  if (p === userDir || p.startsWith(userDir + '/')) return true;
@@ -48,9 +60,12 @@ const handlers = {
48
60
  }
49
61
  }
50
62
  if (userDir) {
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(', ')}`;
63
+ const blocked = [
64
+ ...extractAbsolutePaths(input.command).filter(p => !isAllowedPath(p, userDir)),
65
+ ...extractTraversalPaths(input.command, userDir).filter(p => !isAllowedPath(p, userDir)),
66
+ ];
67
+ if (blocked.length > 0) {
68
+ return `Blocked: command accesses path(s) outside your workspace: ${blocked.join(', ')}. Your workspace is ${userDir} — all file operations must stay within it.`;
54
69
  }
55
70
  }
56
71
  const timeout = Math.min(input.timeout || 30, MAX_EXEC_TIMEOUT) * 1000;
@@ -0,0 +1,68 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const https = require('https');
4
+
5
+ const definitions = [
6
+ {
7
+ name: 'mermaid_chart',
8
+ description: 'Generate a diagram from a Mermaid definition and send it as an image to the chat. Supports flowcharts, sequence diagrams, ER diagrams, Gantt charts, etc.',
9
+ input_schema: {
10
+ type: 'object',
11
+ properties: {
12
+ definition: { type: 'string', description: 'Mermaid diagram definition (e.g. "graph TD; A-->B")' },
13
+ caption: { type: 'string', description: 'Optional caption for the image' },
14
+ theme: { type: 'string', enum: ['default', 'dark', 'forest', 'neutral'], description: 'Chart theme (default: default)' },
15
+ },
16
+ required: ['definition'],
17
+ },
18
+ },
19
+ ];
20
+
21
+ /** @param {string} url @returns {Promise<Buffer>} */
22
+ function fetchBuffer(url) {
23
+ return new Promise((resolve, reject) => {
24
+ https.get(url, (res) => {
25
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
26
+ const chunks = [];
27
+ res.on('data', c => chunks.push(c));
28
+ res.on('end', () => resolve(Buffer.concat(chunks)));
29
+ res.on('error', reject);
30
+ }).on('error', reject);
31
+ });
32
+ }
33
+
34
+ const handlers = {
35
+ async mermaid_chart(input, memory, context) {
36
+ const telegramCtx = context.ctx;
37
+ if (!telegramCtx) return 'Cannot send charts in this context.';
38
+
39
+ const theme = input.theme || 'default';
40
+ const payload = JSON.stringify({ code: input.definition, mermaid: { theme } });
41
+ const encoded = Buffer.from(payload).toString('base64url');
42
+ const url = `https://mermaid.ink/img/${encoded}`;
43
+
44
+ let imgBuffer;
45
+ try {
46
+ imgBuffer = await fetchBuffer(url);
47
+ } catch (e) {
48
+ return `Failed to render chart: ${e.message}`;
49
+ }
50
+
51
+ const tmpPath = path.join('/tmp', `mermaid-${Date.now()}.png`);
52
+ fs.writeFileSync(tmpPath, imgBuffer);
53
+
54
+ try {
55
+ const { InputFile } = require('grammy');
56
+ await telegramCtx.replyWithPhoto(new InputFile(tmpPath), {
57
+ caption: input.caption || undefined,
58
+ });
59
+ return 'Chart sent.';
60
+ } catch (e) {
61
+ return `Failed to send chart: ${e.message}`;
62
+ } finally {
63
+ fs.unlink(tmpPath, () => {});
64
+ }
65
+ },
66
+ };
67
+
68
+ module.exports = { definitions, handlers };
package/src/clean.js CHANGED
@@ -2,27 +2,23 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { OBOL_DIR } = require('./config');
4
4
 
5
- const ALLOWED_DIRS = new Set(['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets']);
6
- const ALLOWED_FILES = new Set([
5
+ const ALLOWED_DIRS = new Set(['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets', 'library']);
6
+ const ALLOWED_ROOT_FILES = new Set([
7
7
  'config.json',
8
+ 'secrets.json',
8
9
  '.evolution-state.json',
9
10
  '.first-run-done',
10
11
  '.post-setup-done',
11
12
  ]);
12
- const ALLOWED_PATTERNS = [/^\./];
13
13
 
14
- const FILE_RULES = {
15
- '.js': 'scripts',
16
- '.sh': 'scripts',
17
- '.md': 'commands',
18
- };
14
+ // Extensions that belong in scripts/
15
+ const SCRIPT_EXTS = new Set(['.js', '.ts', '.sh', '.py', '.rb', '.php', '.go', '.rs', '.pl', '.lua']);
16
+ // Extensions that belong in assets/
17
+ const ASSET_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.mp4', '.mp3', '.wav', '.pdf', '.zip']);
19
18
 
20
- const DIR_FILE_RULES = {
21
- personality: ['.md'],
22
- scripts: ['.js', '.sh'],
23
- tests: ['.js', '.sh'],
24
- commands: ['.md'],
25
- };
19
+ // Dirs where only .md files are allowed (with per-dir exceptions)
20
+ const MD_ONLY_DIRS = new Set(['personality', 'commands']);
21
+ const MD_DIR_EXCEPTIONS = { personality: new Set(['traits.json']) };
26
22
 
27
23
  function safeReaddir(dir) {
28
24
  try {
@@ -36,252 +32,149 @@ function safeReaddirAll(dir) {
36
32
  try { return fs.readdirSync(dir); } catch { return []; }
37
33
  }
38
34
 
35
+ /** @param {string} filename @returns {string|null} */
39
36
  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;
37
+ const ext = path.extname(filename).toLowerCase();
38
+ const base = path.basename(filename, ext).toLowerCase();
39
+ if (base.startsWith('test-') || base.startsWith('test_') || base.endsWith('.test') || base.endsWith('.spec')) return 'tests';
40
+ if (SCRIPT_EXTS.has(ext)) return 'scripts';
41
+ if (ASSET_EXTS.has(ext)) return 'assets';
42
+ if (ext === '.md') return 'commands';
43
+ if (ext === '.log') return 'logs';
44
+ return null;
43
45
  }
44
46
 
45
47
  /**
46
48
  * @param {string} userDir
47
- * @returns {Array<{type: string, name: string, children?: string[], currentDir?: string}>}
49
+ * @returns {Array<{type: string, name: string, dest?: string, children?: string[], currentDir?: string}>}
48
50
  */
49
51
  function scanWorkspace(userDir) {
50
- const rogueItems = [];
51
- if (!fs.existsSync(userDir)) return rogueItems;
52
+ const issues = [];
53
+ if (!fs.existsSync(userDir)) return issues;
52
54
 
53
55
  const entries = fs.readdirSync(userDir, { withFileTypes: true });
54
56
 
55
57
  for (const entry of entries) {
56
58
  if (entry.isDirectory()) {
57
59
  if (!ALLOWED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
58
- rogueItems.push({ type: 'dir', name: entry.name, children: safeReaddirAll(path.join(userDir, entry.name)) });
60
+ // Unknown root dir move to apps/ (likely a project/repo)
61
+ issues.push({ type: 'dir', name: entry.name, dest: 'apps', children: safeReaddirAll(path.join(userDir, entry.name)) });
59
62
  }
60
63
  } else if (entry.isFile()) {
61
- if (!ALLOWED_FILES.has(entry.name) && !ALLOWED_PATTERNS.some(p => p.test(entry.name))) {
62
- rogueItems.push({ type: 'file', name: entry.name });
64
+ if (!ALLOWED_ROOT_FILES.has(entry.name) && !entry.name.startsWith('.')) {
65
+ const dest = guessDestination(entry.name);
66
+ issues.push({ type: 'file', name: entry.name, dest });
63
67
  }
64
68
  }
65
69
  }
66
70
 
67
- for (const [dir, allowedExts] of Object.entries(DIR_FILE_RULES)) {
71
+ // Check md-only dirs for non-.md files
72
+ for (const dir of MD_ONLY_DIRS) {
68
73
  const dirPath = path.join(userDir, dir);
69
74
  if (!fs.existsSync(dirPath)) continue;
70
75
  for (const file of safeReaddir(dirPath)) {
71
- const ext = path.extname(file);
72
- if (ext && !allowedExts.includes(ext)) {
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}`);
76
+ if (file.startsWith('.')) continue;
77
+ if (path.extname(file).toLowerCase() !== '.md' && !MD_DIR_EXCEPTIONS[dir]?.has(file)) {
78
+ const dest = guessDestination(file);
79
+ issues.push({ type: 'misplaced', name: file, currentDir: dir, dest });
168
80
  }
169
81
  }
170
82
  }
171
83
 
172
- return { issues, errors };
84
+ return issues;
173
85
  }
174
86
 
175
87
  /**
176
- * @param {string} userDir
177
- * @param {Array} rogueItems
88
+ * @param {string} baseDir
89
+ * @param {Array} issues
178
90
  * @returns {{issues: Array, errors: Array}}
179
91
  */
180
- function applyHeuristics(userDir, rogueItems) {
181
- const issues = [];
92
+ function applyIssues(baseDir, issues) {
93
+ const applied = [];
182
94
  const errors = [];
183
95
 
184
- for (const item of rogueItems) {
96
+ for (const item of issues) {
185
97
  if (item.type === 'dir') {
186
- const fullPath = path.join(userDir, item.name);
187
- const files = safeReaddir(fullPath);
188
-
98
+ const src = path.join(baseDir, item.name);
189
99
  if (item.children.length === 0) {
190
100
  try {
191
- fs.rmSync(fullPath, { recursive: true, force: true });
192
- issues.push({ path: item.name + '/', action: 'deleted (empty rogue dir)' });
101
+ fs.rmSync(src, { recursive: true, force: true });
102
+ applied.push({ path: item.name + '/', action: 'deleted (empty dir)' });
193
103
  } catch (e) {
194
- errors.push(`Failed to remove ${item.name}/: ${e.message}`);
104
+ errors.push(`Failed to delete ${item.name}/: ${e.message}`);
195
105
  }
196
106
  } 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
- }
215
- }
216
- }
107
+ const dest = path.join(baseDir, 'apps', item.name);
217
108
  try {
218
- fs.rmSync(fullPath, { recursive: true, force: true });
219
- issues.push({ path: item.name + '/', action: 'deleted (rogue dir cleared)' });
220
- } catch {}
109
+ fs.mkdirSync(path.join(baseDir, 'apps'), { recursive: true });
110
+ fs.renameSync(src, dest);
111
+ applied.push({ path: item.name + '/', action: `moved → apps/${item.name}/` });
112
+ } catch (e) {
113
+ errors.push(`Failed to move ${item.name}/: ${e.message}`);
114
+ }
221
115
  }
222
116
  } else if (item.type === 'file') {
223
- const dest = guessDestination(item.name);
224
- const fullPath = path.join(userDir, item.name);
225
- if (dest) {
117
+ const src = path.join(baseDir, item.name);
118
+ if (item.dest) {
119
+ const destDir = path.join(baseDir, item.dest);
226
120
  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}` });
121
+ fs.mkdirSync(destDir, { recursive: true });
122
+ fs.renameSync(src, path.join(destDir, item.name));
123
+ applied.push({ path: item.name, action: `moved → ${item.dest}/${item.name}` });
231
124
  } catch (e) {
232
125
  errors.push(`Failed to move ${item.name}: ${e.message}`);
233
126
  }
234
127
  } 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
- }
128
+ errors.push(`Don't know where to put ${item.name} — move it manually`);
241
129
  }
242
130
  } 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);
131
+ const src = path.join(baseDir, item.currentDir, item.name);
132
+ if (item.dest && item.dest !== item.currentDir) {
133
+ const destDir = path.join(baseDir, item.dest);
246
134
  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}` });
135
+ fs.mkdirSync(destDir, { recursive: true });
136
+ fs.renameSync(src, path.join(destDir, item.name));
137
+ applied.push({ path: `${item.currentDir}/${item.name}`, action: `moved → ${item.dest}/${item.name}` });
251
138
  } catch (e) {
252
139
  errors.push(`Failed to move ${item.currentDir}/${item.name}: ${e.message}`);
253
140
  }
141
+ } else {
142
+ errors.push(`Don't know where to put ${item.currentDir}/${item.name} — move it manually`);
254
143
  }
255
144
  }
256
145
  }
257
146
 
258
- return { issues, errors };
147
+ return { issues: applied, errors };
259
148
  }
260
149
 
261
150
  /**
262
151
  * @param {string} userDir
263
- * @param {object|null} claudeClient - optional Anthropic client for LLM-based resolution
264
- * @returns {Promise<{issues: Array, errors: Array}>}
152
+ * @returns {Promise<{baseDir: string, issues: Array}>}
265
153
  */
266
- async function cleanWorkspace(userDir, claudeClient = null) {
154
+ async function planClean(userDir) {
267
155
  const baseDir = userDir || OBOL_DIR;
268
- if (!fs.existsSync(baseDir)) {
269
- return { issues: [], errors: ['Directory does not exist'] };
270
- }
271
-
272
- const rogueItems = scanWorkspace(baseDir);
273
- if (rogueItems.length === 0) return { issues: [], errors: [] };
156
+ if (!fs.existsSync(baseDir)) return { baseDir, issues: [] };
157
+ return { baseDir, issues: scanWorkspace(baseDir) };
158
+ }
274
159
 
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
- }
160
+ /**
161
+ * @param {string} baseDir
162
+ * @param {Array} issues
163
+ * @returns {{issues: Array, errors: Array}}
164
+ */
165
+ function applyPlan(baseDir, issues) {
166
+ return applyIssues(baseDir, issues);
167
+ }
283
168
 
284
- return applyHeuristics(baseDir, rogueItems);
169
+ /**
170
+ * Convenience wrapper: plan + apply in one call.
171
+ * @param {string} userDir
172
+ * @returns {Promise<{issues: Array, errors: Array}>}
173
+ */
174
+ async function cleanWorkspace(userDir) {
175
+ const plan = await planClean(userDir);
176
+ if (plan.issues.length === 0) return { issues: [], errors: [] };
177
+ return applyPlan(plan.baseDir, plan.issues);
285
178
  }
286
179
 
287
- module.exports = { cleanWorkspace };
180
+ module.exports = { planClean, applyPlan, cleanWorkspace };
package/src/config.js CHANGED
@@ -150,7 +150,7 @@ function getUserDir(userId) {
150
150
 
151
151
  function ensureUserDir(userId) {
152
152
  const dir = getUserDir(userId);
153
- for (const sub of ['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets']) {
153
+ for (const sub of ['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets', 'library']) {
154
154
  fs.mkdirSync(path.join(dir, sub), { recursive: true });
155
155
  }
156
156
  const defaultAgents = path.join(__dirname, 'defaults', 'AGENTS.md');
@@ -25,7 +25,7 @@ function createBot(telegramConfig, config) {
25
25
  const processedUpdates = new Map();
26
26
  let askIdCounter = 0;
27
27
 
28
- function createAsk(ctx, message, options, timeoutSecs = 60) {
28
+ function createAsk(ctx, message, options, timeoutSecs = 120) {
29
29
  return new Promise((resolve) => {
30
30
  const askId = ++askIdCounter;
31
31
  const keyboard = new InlineKeyboard();
@@ -33,14 +33,22 @@ function createBot(telegramConfig, config) {
33
33
  keyboard.text(opt, `ask:${askId}:${i}`);
34
34
  if ((i + 1) % 3 === 0 && i < options.length - 1) keyboard.row();
35
35
  });
36
+ let sentMsgId = null;
36
37
  const timer = setTimeout(() => {
37
38
  if (pendingAsks.has(askId)) {
38
39
  pendingAsks.delete(askId);
40
+ if (sentMsgId) {
41
+ ctx.api.editMessageReplyMarkup(ctx.chat.id, sentMsgId, {
42
+ reply_markup: { inline_keyboard: [] },
43
+ }).catch(() => {});
44
+ }
39
45
  resolve('timeout');
40
46
  }
41
47
  }, timeoutSecs * 1000);
42
48
  pendingAsks.set(askId, { resolve, options, timer });
43
- sendHtml(ctx, message, { reply_markup: keyboard }).catch(() => {
49
+ sendHtml(ctx, message, { reply_markup: keyboard }).then((msg) => {
50
+ sentMsgId = msg.message_id;
51
+ }).catch(() => {
44
52
  clearTimeout(timer);
45
53
  pendingAsks.delete(askId);
46
54
  resolve('error');
@@ -49,7 +57,8 @@ function createBot(telegramConfig, config) {
49
57
  }
50
58
 
51
59
  bot.use(sequentialize((ctx) => {
52
- if (ctx.callbackQuery?.data?.startsWith('stop:') || ctx.callbackQuery?.data?.startsWith('force:')) return undefined;
60
+ const cbData = ctx.callbackQuery?.data;
61
+ if (cbData?.startsWith('stop:') || cbData?.startsWith('force:') || cbData?.startsWith('ask:')) return undefined;
53
62
  return ctx.chat?.id.toString();
54
63
  }));
55
64
 
@@ -100,7 +109,7 @@ function createBot(telegramConfig, config) {
100
109
  conversationCommands.register(bot, config);
101
110
  memoryCommands.register(bot, config);
102
111
  statusCommands.register(bot, config);
103
- adminCommands.register(bot, config);
112
+ adminCommands.register(bot, config, createAsk);
104
113
  traitsCommands.register(bot, config);
105
114
  secretsCommands.register(bot, config);
106
115
  toolsCommands.register(bot, config);
@@ -4,9 +4,11 @@ const { execSync } = require('child_process');
4
4
  const { getTenant } = require('../../tenant');
5
5
  const { loadConfig } = require('../../config');
6
6
  const { getMaxToolIterations, setMaxToolIterations } = require('../../claude');
7
+ const { createChatContext, createStatusTracker } = require('../handlers/text');
8
+ const { sendHtml, splitMessage, startTyping } = require('../utils');
7
9
  const pkg = require('../../../package.json');
8
10
 
9
- function register(bot, config) {
11
+ function register(bot, config, createAsk) {
10
12
  bot.command('backup', async (ctx) => {
11
13
  if (!ctx.from) return;
12
14
  try {
@@ -25,17 +27,87 @@ function register(bot, config) {
25
27
  bot.command('clean', async (ctx) => {
26
28
  if (!ctx.from) return;
27
29
  const tenant = await getTenant(ctx.from.id, config);
28
- const { cleanWorkspace } = require('../../clean');
30
+ const { planClean } = require('../../clean');
29
31
  await ctx.replyWithChatAction('typing');
30
32
  try {
31
- const result = await cleanWorkspace(tenant.userDir, tenant.claude.client);
32
- if (result.issues.length === 0) {
33
+ const plan = await planClean(tenant.userDir);
34
+ const testsDir = path.join(plan.baseDir, 'tests');
35
+ const scriptsDir = path.join(plan.baseDir, 'scripts');
36
+ const hasTests = fs.existsSync(testsDir) && fs.readdirSync(testsDir).filter(f => !f.startsWith('.')).length > 0;
37
+ const hasScripts = fs.existsSync(scriptsDir) && fs.readdirSync(scriptsDir).filter(f => !f.startsWith('.')).length > 0;
38
+
39
+ if (plan.issues.length === 0 && (hasTests || !hasScripts)) {
33
40
  await ctx.reply('✨ Workspace is clean. Nothing out of place.');
34
- } else {
35
- const text = `🧹 Found ${result.issues.length} issue(s):\n\n` +
36
- result.issues.map(i => `${i.action === 'deleted' ? '🗑️' : '📦'} ${i.path} → ${i.action}`).join('\n') +
37
- (result.errors.length > 0 ? `\n\n⚠️ ${result.errors.length} error(s):\n${result.errors.join('\n')}` : '');
38
- await ctx.reply(text);
41
+ return;
42
+ }
43
+
44
+ const promptParts = [];
45
+
46
+ if (plan.issues.length > 0) {
47
+ const issueLines = plan.issues.map(i => {
48
+ const src = i.type === 'misplaced' ? `${i.currentDir}/${i.name}` : (i.type === 'dir' ? i.name + '/' : i.name);
49
+ return i.dest ? `- ${src} → ${i.dest}/` : `- ${src} (unknown type)`;
50
+ }).join('\n');
51
+
52
+ promptParts.push(`Clean up the obol workspace located at: ${plan.baseDir}
53
+
54
+ ## Workspace Structure
55
+ Allowed root directories: personality/, scripts/, tests/, commands/, apps/, logs/, assets/
56
+ Allowed root files: config.json, secrets.json, .evolution-state.json, .first-run-done, .post-setup-done
57
+ - personality/ and commands/ only contain .md files (except personality/traits.json which must stay)
58
+ - Unknown directories at the root should be moved into apps/
59
+ - Script files (.js, .ts, .sh, etc.) go into scripts/
60
+ - Asset files (images, audio, pdf, etc.) go into assets/
61
+ - .DS_Store and other dotfiles should be deleted
62
+ - secrets.json and personality/traits.json must NOT be moved
63
+
64
+ ## Issues Found
65
+ ${issueLines}
66
+
67
+ Resolve all of these issues. Use the exec tool to run shell commands (mv, rm, mkdir) to move or delete files as appropriate.`);
68
+ }
69
+
70
+ promptParts.push(`## Secret Hygiene
71
+ Read every script in ${plan.baseDir}/scripts/. If any script has hardcoded API keys, passwords, tokens, or credentials (e.g. API_KEY = "sk-...", PASSWORD = "..."), refactor it:
72
+ 1. Use \`store_secret\` to save each hardcoded value under a descriptive key (e.g. "deepseek-api-key")
73
+ 2. Rewrite the script to accept a JSON secrets object as its first argument:
74
+ \`\`\`python
75
+ import json, sys
76
+ secrets = json.loads(sys.argv[1])
77
+ api_key = secrets.get('deepseek-api-key', '').strip()
78
+ \`\`\`
79
+ 3. Never leave plaintext secrets in script files
80
+
81
+ ## Tests
82
+ Check the tests/ folder at ${plan.baseDir}/tests/. If it has test files, run them — fix any failures and re-run until all pass. If tests/ is empty or missing, read the scripts in ${plan.baseDir}/scripts/ and write a test file for each script, then run them all.
83
+ Summarize what was cleaned, secrets migrated, and final test results.`);
84
+
85
+ const taskPrompt = promptParts.join('\n\n');
86
+
87
+ const stopTyping = startTyping(ctx);
88
+ const status = createStatusTracker(ctx);
89
+ const chatContext = createChatContext(ctx, tenant, config, { allowedUsers: new Set(), bot, createAsk });
90
+ chatContext._model = 'claude-sonnet-4-6';
91
+ chatContext._onRouteDecision = (info) => { status.setRouteInfo(info); status.start(); };
92
+ chatContext._onToolStart = (toolName, inputSummary) => {
93
+ status.setStatusText('Cleaning');
94
+ status.start();
95
+ };
96
+
97
+ try {
98
+ const { text: response } = await tenant.claude.chat(taskPrompt, chatContext);
99
+ status.stopTimer();
100
+ status.updateFormatting();
101
+ stopTyping();
102
+ status.deleteMsg();
103
+ if (response?.trim()) {
104
+ const chunks = splitMessage(response, 4096);
105
+ for (const chunk of chunks) await sendHtml(ctx, chunk).catch(() => {});
106
+ }
107
+ } catch (e) {
108
+ status.clear();
109
+ stopTyping();
110
+ await ctx.reply(`⚠️ Clean failed: ${e.message}`).catch(() => {});
39
111
  }
40
112
  } catch (e) {
41
113
  await ctx.reply(`⚠️ Clean failed: ${e.message}`);
@@ -1,4 +1,3 @@
1
- const { markdownToTelegramHtml } = require('../utils');
2
1
  const { handleToolCallback } = require('../commands/tools');
3
2
  const { handleVoiceCallback } = require('../voice');
4
3
 
@@ -108,8 +107,7 @@ function registerCallbackHandler(bot, { config, pendingAsks, getTenant }) {
108
107
  await answer({ text: selected });
109
108
  clearTimeout(pending.timer);
110
109
  pendingAsks.delete(askId);
111
- const confirmHtml = markdownToTelegramHtml(`${ctx.callbackQuery.message.text}\n\n✓ _${selected}_`);
112
- ctx.editMessageText(confirmHtml, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } }).catch(() => {});
110
+ ctx.deleteMessage().catch(() => {});
113
111
  pending.resolve(selected);
114
112
  });
115
113
  }