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 +5 -0
- package/package.json +1 -1
- package/src/background.js +4 -3
- package/src/claude/constants.js +5 -5
- package/src/claude/prompt.js +7 -1
- package/src/claude/tool-registry.js +2 -0
- package/src/claude/tools/exec.js +18 -3
- package/src/claude/tools/mermaid.js +68 -0
- package/src/clean.js +89 -196
- package/src/config.js +1 -1
- package/src/telegram/bot.js +13 -4
- package/src/telegram/commands/admin.js +81 -9
- package/src/telegram/handlers/callbacks.js +1 -3
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.
|
|
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;
|
package/src/claude/constants.js
CHANGED
|
@@ -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 = [
|
package/src/claude/prompt.js
CHANGED
|
@@ -56,7 +56,7 @@ ${workDir}/
|
|
|
56
56
|
├── scripts/ (utility scripts)
|
|
57
57
|
├── tests/ (test suite)
|
|
58
58
|
├── commands/ (command definitions)
|
|
59
|
-
├── apps/ (web apps
|
|
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 = {
|
package/src/claude/tools/exec.js
CHANGED
|
@@ -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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
51
|
-
if (!fs.existsSync(userDir)) return
|
|
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
|
-
|
|
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 (!
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
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
|
|
84
|
+
return issues;
|
|
173
85
|
}
|
|
174
86
|
|
|
175
87
|
/**
|
|
176
|
-
* @param {string}
|
|
177
|
-
* @param {Array}
|
|
88
|
+
* @param {string} baseDir
|
|
89
|
+
* @param {Array} issues
|
|
178
90
|
* @returns {{issues: Array, errors: Array}}
|
|
179
91
|
*/
|
|
180
|
-
function
|
|
181
|
-
const
|
|
92
|
+
function applyIssues(baseDir, issues) {
|
|
93
|
+
const applied = [];
|
|
182
94
|
const errors = [];
|
|
183
95
|
|
|
184
|
-
for (const item of
|
|
96
|
+
for (const item of issues) {
|
|
185
97
|
if (item.type === 'dir') {
|
|
186
|
-
const
|
|
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(
|
|
192
|
-
|
|
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
|
|
104
|
+
errors.push(`Failed to delete ${item.name}/: ${e.message}`);
|
|
195
105
|
}
|
|
196
106
|
} else {
|
|
197
|
-
|
|
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.
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
117
|
+
const src = path.join(baseDir, item.name);
|
|
118
|
+
if (item.dest) {
|
|
119
|
+
const destDir = path.join(baseDir, item.dest);
|
|
226
120
|
try {
|
|
227
|
-
|
|
228
|
-
fs.
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
244
|
-
if (dest && dest !== item.currentDir) {
|
|
245
|
-
const
|
|
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
|
-
|
|
248
|
-
fs.
|
|
249
|
-
|
|
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
|
-
* @
|
|
264
|
-
* @returns {Promise<{issues: Array, errors: Array}>}
|
|
152
|
+
* @returns {Promise<{baseDir: string, issues: Array}>}
|
|
265
153
|
*/
|
|
266
|
-
async function
|
|
154
|
+
async function planClean(userDir) {
|
|
267
155
|
const baseDir = userDir || OBOL_DIR;
|
|
268
|
-
if (!fs.existsSync(baseDir)) {
|
|
269
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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');
|
package/src/telegram/bot.js
CHANGED
|
@@ -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 =
|
|
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 }).
|
|
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
|
-
|
|
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 {
|
|
30
|
+
const { planClean } = require('../../clean');
|
|
29
31
|
await ctx.replyWithChatAction('typing');
|
|
30
32
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
}
|