obol-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -0
- package/bin/obol.js +64 -0
- package/docs/DEPLOY.md +277 -0
- package/docs/obol-banner.png +0 -0
- package/package.json +29 -0
- package/src/background.js +188 -0
- package/src/backup.js +66 -0
- package/src/claude.js +443 -0
- package/src/clean.js +168 -0
- package/src/cli/backup.js +20 -0
- package/src/cli/init.js +381 -0
- package/src/cli/logs.js +12 -0
- package/src/cli/start.js +47 -0
- package/src/cli/status.js +44 -0
- package/src/cli/stop.js +12 -0
- package/src/config.js +57 -0
- package/src/db/migrate.js +134 -0
- package/src/evolve.js +668 -0
- package/src/first-run.js +110 -0
- package/src/heartbeat.js +16 -0
- package/src/index.js +55 -0
- package/src/memory.js +164 -0
- package/src/messages.js +140 -0
- package/src/personality.js +27 -0
- package/src/post-setup.js +410 -0
- package/src/telegram.js +377 -0
- package/src/test-utils.js +111 -0
package/src/claude.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
const { OBOL_DIR } = require('./config');
|
|
6
|
+
|
|
7
|
+
function createClaude(anthropicConfig, { personality, memory }) {
|
|
8
|
+
const client = new Anthropic({ apiKey: anthropicConfig.apiKey });
|
|
9
|
+
|
|
10
|
+
// Build system prompt from personality files
|
|
11
|
+
const systemPrompt = buildSystemPrompt(personality);
|
|
12
|
+
|
|
13
|
+
// Conversation history per chat (in-memory, resets on restart)
|
|
14
|
+
const histories = new Map();
|
|
15
|
+
const MAX_HISTORY = 50;
|
|
16
|
+
|
|
17
|
+
// Define tools
|
|
18
|
+
const tools = buildTools(memory);
|
|
19
|
+
|
|
20
|
+
async function chat(userMessage, context = {}) {
|
|
21
|
+
const chatId = context.chatId || 'default';
|
|
22
|
+
|
|
23
|
+
// Get or create history
|
|
24
|
+
if (!histories.has(chatId)) histories.set(chatId, []);
|
|
25
|
+
const history = histories.get(chatId);
|
|
26
|
+
|
|
27
|
+
// Ask Haiku if we need memory for this message
|
|
28
|
+
let memoryContext = '';
|
|
29
|
+
if (memory) {
|
|
30
|
+
try {
|
|
31
|
+
const memoryDecision = await client.messages.create({
|
|
32
|
+
model: 'claude-haiku-4-20250514',
|
|
33
|
+
max_tokens: 100,
|
|
34
|
+
system: `You are a router. Analyze this user message and decide two things:
|
|
35
|
+
|
|
36
|
+
1. Does it need memory context? (past conversations, facts, preferences, people, events)
|
|
37
|
+
2. What model complexity does it need?
|
|
38
|
+
|
|
39
|
+
Reply with ONLY a JSON object:
|
|
40
|
+
{"need_memory": true/false, "search_query": "optimized search query", "model": "sonnet|opus"}
|
|
41
|
+
|
|
42
|
+
Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true with optimized search query.
|
|
43
|
+
|
|
44
|
+
Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single-step work). Use "opus" ONLY for: complex multi-step research, architecture/design decisions, long-form writing, deep analysis, debugging complex code, tasks requiring exceptional reasoning.`,
|
|
45
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const decisionText = memoryDecision.content[0]?.text || '';
|
|
49
|
+
const decision = JSON.parse(decisionText.match(/\{[\s\S]*\}/)?.[0] || '{}');
|
|
50
|
+
|
|
51
|
+
// Set model based on Haiku's decision
|
|
52
|
+
if (decision.model === 'opus') {
|
|
53
|
+
context._model = 'claude-opus-4-20250514';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (decision.need_memory) {
|
|
57
|
+
const query = decision.search_query || userMessage;
|
|
58
|
+
|
|
59
|
+
// Today's context + semantic search
|
|
60
|
+
const todayMemories = await memory.byDate('today', { limit: 3 });
|
|
61
|
+
const semanticMemories = await memory.search(query, { limit: 3, threshold: 0.5 });
|
|
62
|
+
|
|
63
|
+
// Dedupe by ID
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const combined = [];
|
|
66
|
+
for (const m of [...todayMemories, ...semanticMemories]) {
|
|
67
|
+
if (!seen.has(m.id)) {
|
|
68
|
+
seen.add(m.id);
|
|
69
|
+
combined.push(m);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (combined.length > 0) {
|
|
74
|
+
memoryContext = '\n\n[Relevant memories]\n' +
|
|
75
|
+
combined.map(m => `- [${m.category}] ${m.content}`).join('\n');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add user message with memory context
|
|
82
|
+
const enrichedMessage = memoryContext
|
|
83
|
+
? userMessage + memoryContext
|
|
84
|
+
: userMessage;
|
|
85
|
+
history.push({ role: 'user', content: enrichedMessage });
|
|
86
|
+
|
|
87
|
+
// Trim history if too long
|
|
88
|
+
while (history.length > MAX_HISTORY) history.shift();
|
|
89
|
+
|
|
90
|
+
// Call Claude — Haiku picks the model
|
|
91
|
+
const model = context._model || 'claude-sonnet-4-20250514';
|
|
92
|
+
let response = await client.messages.create({
|
|
93
|
+
model,
|
|
94
|
+
max_tokens: 4096,
|
|
95
|
+
system: systemPrompt,
|
|
96
|
+
messages: history,
|
|
97
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Handle tool use loop
|
|
101
|
+
while (response.stop_reason === 'tool_use') {
|
|
102
|
+
const assistantContent = response.content;
|
|
103
|
+
history.push({ role: 'assistant', content: assistantContent });
|
|
104
|
+
|
|
105
|
+
const toolResults = [];
|
|
106
|
+
for (const block of assistantContent) {
|
|
107
|
+
if (block.type === 'tool_use') {
|
|
108
|
+
const result = await executeToolCall(block, memory, context);
|
|
109
|
+
toolResults.push({
|
|
110
|
+
type: 'tool_result',
|
|
111
|
+
tool_use_id: block.id,
|
|
112
|
+
content: result,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
history.push({ role: 'user', content: toolResults });
|
|
118
|
+
|
|
119
|
+
response = await client.messages.create({
|
|
120
|
+
model,
|
|
121
|
+
max_tokens: 4096,
|
|
122
|
+
system: systemPrompt,
|
|
123
|
+
messages: history,
|
|
124
|
+
tools,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Extract text response
|
|
129
|
+
const textBlocks = response.content.filter(b => b.type === 'text');
|
|
130
|
+
const replyText = textBlocks.map(b => b.text).join('\n');
|
|
131
|
+
|
|
132
|
+
// Add assistant response to history
|
|
133
|
+
history.push({ role: 'assistant', content: response.content });
|
|
134
|
+
|
|
135
|
+
return replyText;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reloadPersonality() {
|
|
139
|
+
const newPersonality = require('./personality').loadPersonality();
|
|
140
|
+
Object.assign(personality, newPersonality);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clearHistory(chatId) {
|
|
144
|
+
if (chatId) {
|
|
145
|
+
histories.delete(chatId);
|
|
146
|
+
} else {
|
|
147
|
+
histories.clear();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { chat, client, reloadPersonality, clearHistory };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildSystemPrompt(personality) {
|
|
155
|
+
const parts = ['You are an AI assistant powered by OBOL.'];
|
|
156
|
+
|
|
157
|
+
if (personality.soul) parts.push(`\n## Personality\n${personality.soul}`);
|
|
158
|
+
if (personality.user) parts.push(`\n## About Your Owner\n${personality.user}`);
|
|
159
|
+
if (personality.agents) parts.push(`\n## Operating Instructions\n${personality.agents}`);
|
|
160
|
+
|
|
161
|
+
parts.push(`
|
|
162
|
+
## Workspace Discipline
|
|
163
|
+
|
|
164
|
+
The OBOL directory (~/.obol/) has a fixed structure:
|
|
165
|
+
|
|
166
|
+
\`\`\`
|
|
167
|
+
~/.obol/
|
|
168
|
+
├── config.json
|
|
169
|
+
├── personality/ (SOUL.md, USER.md, AGENTS.md, evolution/)
|
|
170
|
+
├── scripts/ (utility scripts)
|
|
171
|
+
├── tests/ (test suite)
|
|
172
|
+
├── commands/ (command definitions)
|
|
173
|
+
├── apps/ (web apps for Vercel)
|
|
174
|
+
└── logs/
|
|
175
|
+
\`\`\`
|
|
176
|
+
|
|
177
|
+
**Rules:**
|
|
178
|
+
- NEVER create new top-level directories unless the user explicitly asks for one.
|
|
179
|
+
- Place files in the correct existing directory. Scripts → scripts/, tests → tests/, etc.
|
|
180
|
+
- Temporary files go in /tmp, not in the OBOL directory.
|
|
181
|
+
- If unsure where something belongs, ask — don't guess.
|
|
182
|
+
- Run \`/clean\` to audit and fix misplaced files.
|
|
183
|
+
`);
|
|
184
|
+
|
|
185
|
+
parts.push(`\nCurrent time: ${new Date().toISOString()}`);
|
|
186
|
+
|
|
187
|
+
return parts.join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildTools(memory) {
|
|
191
|
+
const tools = [];
|
|
192
|
+
|
|
193
|
+
// Shell execution
|
|
194
|
+
tools.push({
|
|
195
|
+
name: 'exec',
|
|
196
|
+
description: 'Execute a shell command and return the output. Use for file operations, system tasks, running scripts.',
|
|
197
|
+
input_schema: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: {
|
|
200
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
201
|
+
timeout: { type: 'number', description: 'Timeout in seconds (default 30)' },
|
|
202
|
+
},
|
|
203
|
+
required: ['command'],
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Memory tools
|
|
208
|
+
if (memory) {
|
|
209
|
+
tools.push({
|
|
210
|
+
name: 'memory_search',
|
|
211
|
+
description: 'Search vector memory for relevant past context. Use before answering questions about prior conversations, decisions, or facts.',
|
|
212
|
+
input_schema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: {
|
|
215
|
+
query: { type: 'string', description: 'Search query' },
|
|
216
|
+
limit: { type: 'number', description: 'Max results (default 10)' },
|
|
217
|
+
category: { type: 'string', description: 'Filter by category' },
|
|
218
|
+
},
|
|
219
|
+
required: ['query'],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
tools.push({
|
|
224
|
+
name: 'memory_add',
|
|
225
|
+
description: 'Store a new memory. Use to remember facts, decisions, preferences, events.',
|
|
226
|
+
input_schema: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: {
|
|
229
|
+
content: { type: 'string', description: 'What to remember' },
|
|
230
|
+
category: { type: 'string', enum: ['fact', 'preference', 'decision', 'lesson', 'person', 'project', 'event', 'conversation', 'resource', 'pattern', 'context'], description: 'Memory category' },
|
|
231
|
+
importance: { type: 'number', description: 'Importance 0-1 (default 0.5)' },
|
|
232
|
+
source: { type: 'string', description: 'Where this came from' },
|
|
233
|
+
},
|
|
234
|
+
required: ['content'],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
tools.push({
|
|
239
|
+
name: 'memory_date',
|
|
240
|
+
description: 'Get memories from a specific date. Use for "what did we do today/yesterday" questions.',
|
|
241
|
+
input_schema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
date: { type: 'string', description: 'Date: "today", "yesterday", "2026-02-22", "7d"' },
|
|
245
|
+
category: { type: 'string', description: 'Filter by category' },
|
|
246
|
+
},
|
|
247
|
+
required: ['date'],
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Web fetch
|
|
253
|
+
tools.push({
|
|
254
|
+
name: 'web_fetch',
|
|
255
|
+
description: 'Fetch and extract readable content from a URL.',
|
|
256
|
+
input_schema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
url: { type: 'string', description: 'URL to fetch' },
|
|
260
|
+
},
|
|
261
|
+
required: ['url'],
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Vercel deploy
|
|
266
|
+
tools.push({
|
|
267
|
+
name: 'vercel_deploy',
|
|
268
|
+
description: 'Deploy a directory to Vercel. Use to ship websites, dashboards, and web apps for the user.',
|
|
269
|
+
input_schema: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
directory: { type: 'string', description: 'Path to the project directory to deploy' },
|
|
273
|
+
name: { type: 'string', description: 'Project name' },
|
|
274
|
+
production: { type: 'boolean', description: 'Deploy to production (default false = preview)' },
|
|
275
|
+
},
|
|
276
|
+
required: ['directory'],
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
tools.push({
|
|
281
|
+
name: 'vercel_list',
|
|
282
|
+
description: 'List Vercel deployments for a project.',
|
|
283
|
+
input_schema: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
properties: {
|
|
286
|
+
project: { type: 'string', description: 'Project name' },
|
|
287
|
+
},
|
|
288
|
+
required: ['project'],
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Background task
|
|
293
|
+
tools.push({
|
|
294
|
+
name: 'background_task',
|
|
295
|
+
description: 'Spawn a heavy task in the background. Use when a request will take multiple steps (research, building a site, complex analysis). The main conversation stays responsive. The user gets progress check-ins every 30s and the final result when done. Reply to the user with a brief acknowledgment like "On it 🪙" after spawning.',
|
|
296
|
+
input_schema: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
properties: {
|
|
299
|
+
task: { type: 'string', description: 'Detailed description of the task to complete' },
|
|
300
|
+
},
|
|
301
|
+
required: ['task'],
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Read/write files
|
|
306
|
+
tools.push({
|
|
307
|
+
name: 'read_file',
|
|
308
|
+
description: 'Read contents of a file.',
|
|
309
|
+
input_schema: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
path: { type: 'string', description: 'File path' },
|
|
313
|
+
},
|
|
314
|
+
required: ['path'],
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
tools.push({
|
|
319
|
+
name: 'write_file',
|
|
320
|
+
description: 'Write content to a file. Creates parent directories if needed.',
|
|
321
|
+
input_schema: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
properties: {
|
|
324
|
+
path: { type: 'string', description: 'File path' },
|
|
325
|
+
content: { type: 'string', description: 'File content' },
|
|
326
|
+
},
|
|
327
|
+
required: ['path', 'content'],
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return tools;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function executeToolCall(toolUse, memory, context = {}) {
|
|
335
|
+
const { name, input } = toolUse;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
switch (name) {
|
|
339
|
+
case 'exec': {
|
|
340
|
+
const timeout = (input.timeout || 30) * 1000;
|
|
341
|
+
const output = execSync(input.command, {
|
|
342
|
+
encoding: 'utf-8',
|
|
343
|
+
timeout,
|
|
344
|
+
maxBuffer: 1024 * 1024,
|
|
345
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
346
|
+
});
|
|
347
|
+
return output.substring(0, 10000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case 'memory_search': {
|
|
351
|
+
const results = await memory.search(input.query, {
|
|
352
|
+
limit: input.limit,
|
|
353
|
+
category: input.category,
|
|
354
|
+
});
|
|
355
|
+
return JSON.stringify(results.map(m => ({
|
|
356
|
+
content: m.content,
|
|
357
|
+
category: m.category,
|
|
358
|
+
importance: m.importance,
|
|
359
|
+
created: m.created_at,
|
|
360
|
+
source: m.source,
|
|
361
|
+
})));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case 'memory_add': {
|
|
365
|
+
const result = await memory.add(input.content, {
|
|
366
|
+
category: input.category || 'fact',
|
|
367
|
+
importance: input.importance || 0.5,
|
|
368
|
+
source: input.source,
|
|
369
|
+
});
|
|
370
|
+
return `Stored memory: ${result.id}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case 'memory_date': {
|
|
374
|
+
const results = await memory.byDate(input.date, { category: input.category });
|
|
375
|
+
return JSON.stringify(results.map(m => ({
|
|
376
|
+
content: m.content,
|
|
377
|
+
category: m.category,
|
|
378
|
+
created: m.created_at,
|
|
379
|
+
})));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case 'background_task': {
|
|
383
|
+
const { bg, ctx: telegramCtx } = context;
|
|
384
|
+
if (!bg || !telegramCtx) return 'Background tasks not available in this context.';
|
|
385
|
+
const claudeInstance = { chat, client, reloadPersonality };
|
|
386
|
+
const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory);
|
|
387
|
+
return `Background task #${taskId} spawned. It will send progress updates and the final result to the chat.`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case 'vercel_deploy': {
|
|
391
|
+
const { loadConfig } = require('./config');
|
|
392
|
+
const cfg = loadConfig();
|
|
393
|
+
const token = cfg?.vercel?.token;
|
|
394
|
+
if (!token) return 'Vercel not configured.';
|
|
395
|
+
const dir = input.directory;
|
|
396
|
+
const prod = input.production ? '--prod' : '';
|
|
397
|
+
const name = input.name ? `--name ${input.name}` : '';
|
|
398
|
+
const output = execSync(
|
|
399
|
+
`cd ${dir} && npx vercel ${prod} ${name} --token ${token} --yes 2>&1`,
|
|
400
|
+
{ encoding: 'utf-8', timeout: 120000 }
|
|
401
|
+
);
|
|
402
|
+
return output.substring(0, 5000);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
case 'vercel_list': {
|
|
406
|
+
const { loadConfig } = require('./config');
|
|
407
|
+
const cfg = loadConfig();
|
|
408
|
+
const token = cfg?.vercel?.token;
|
|
409
|
+
if (!token) return 'Vercel not configured.';
|
|
410
|
+
const output = execSync(
|
|
411
|
+
`npx vercel ls ${input.project} --token ${token} 2>&1`,
|
|
412
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
413
|
+
);
|
|
414
|
+
return output.substring(0, 5000);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case 'web_fetch': {
|
|
418
|
+
const res = await fetch(input.url);
|
|
419
|
+
const text = await res.text();
|
|
420
|
+
// Basic HTML stripping
|
|
421
|
+
const clean = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').substring(0, 10000);
|
|
422
|
+
return clean;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
case 'read_file': {
|
|
426
|
+
return fs.readFileSync(input.path, 'utf-8').substring(0, 50000);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
case 'write_file': {
|
|
430
|
+
fs.mkdirSync(path.dirname(input.path), { recursive: true });
|
|
431
|
+
fs.writeFileSync(input.path, input.content);
|
|
432
|
+
return `Written: ${input.path}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
default:
|
|
436
|
+
return `Unknown tool: ${name}`;
|
|
437
|
+
}
|
|
438
|
+
} catch (e) {
|
|
439
|
+
return `Error: ${e.message}`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = { createClaude };
|
package/src/clean.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { OBOL_DIR } = require('./config');
|
|
15
|
+
|
|
16
|
+
// Allowed top-level entries
|
|
17
|
+
const ALLOWED_DIRS = new Set(['personality', 'scripts', 'tests', 'commands', 'apps', 'logs']);
|
|
18
|
+
const ALLOWED_FILES = new Set([
|
|
19
|
+
'config.json',
|
|
20
|
+
'.evolution-state.json',
|
|
21
|
+
'.first-run-done',
|
|
22
|
+
'.post-setup-done',
|
|
23
|
+
]);
|
|
24
|
+
// Files that can appear at top level with any name
|
|
25
|
+
const ALLOWED_PATTERNS = [
|
|
26
|
+
/^\./, // Hidden files (dotfiles)
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Where file types belong
|
|
30
|
+
const FILE_RULES = {
|
|
31
|
+
'.js': 'scripts',
|
|
32
|
+
'.sh': 'scripts',
|
|
33
|
+
'.md': 'commands', // .md files outside personality/ are probably commands
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function cleanWorkspace() {
|
|
37
|
+
const issues = [];
|
|
38
|
+
const errors = [];
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(OBOL_DIR)) {
|
|
41
|
+
return { issues, errors: ['OBOL_DIR does not exist'] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entries = fs.readdirSync(OBOL_DIR, { withFileTypes: true });
|
|
45
|
+
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const fullPath = path.join(OBOL_DIR, entry.name);
|
|
48
|
+
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (!ALLOWED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
51
|
+
// Rogue directory — check if it has useful files first
|
|
52
|
+
const files = safeReaddir(fullPath);
|
|
53
|
+
if (files.length === 0) {
|
|
54
|
+
// Empty rogue dir — delete
|
|
55
|
+
try {
|
|
56
|
+
fs.rmdirSync(fullPath);
|
|
57
|
+
issues.push({ path: entry.name + '/', action: 'deleted (empty rogue dir)' });
|
|
58
|
+
} catch (e) {
|
|
59
|
+
errors.push(`Failed to remove ${entry.name}/: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Non-empty rogue dir — relocate files, then delete
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const src = path.join(fullPath, file);
|
|
65
|
+
const dest = guessDestination(file);
|
|
66
|
+
if (dest) {
|
|
67
|
+
try {
|
|
68
|
+
const destPath = path.join(OBOL_DIR, dest, file);
|
|
69
|
+
fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
|
|
70
|
+
fs.renameSync(src, destPath);
|
|
71
|
+
issues.push({ path: `${entry.name}/${file}`, action: `moved → ${dest}/${file}` });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
errors.push(`Failed to move ${entry.name}/${file}: ${e.message}`);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
try {
|
|
77
|
+
fs.unlinkSync(src);
|
|
78
|
+
issues.push({ path: `${entry.name}/${file}`, action: 'deleted (unknown type)' });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
errors.push(`Failed to delete ${entry.name}/${file}: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Try to remove the now-empty dir
|
|
85
|
+
try {
|
|
86
|
+
fs.rmdirSync(fullPath);
|
|
87
|
+
issues.push({ path: entry.name + '/', action: 'deleted (rogue dir cleared)' });
|
|
88
|
+
} catch {} // May not be empty if errors occurred
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else if (entry.isFile()) {
|
|
92
|
+
if (!ALLOWED_FILES.has(entry.name) && !ALLOWED_PATTERNS.some(p => p.test(entry.name))) {
|
|
93
|
+
// Misplaced file at top level
|
|
94
|
+
const dest = guessDestination(entry.name);
|
|
95
|
+
if (dest) {
|
|
96
|
+
try {
|
|
97
|
+
const destPath = path.join(OBOL_DIR, dest, entry.name);
|
|
98
|
+
fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
|
|
99
|
+
fs.renameSync(fullPath, destPath);
|
|
100
|
+
issues.push({ path: entry.name, action: `moved → ${dest}/${entry.name}` });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
errors.push(`Failed to move ${entry.name}: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(fullPath);
|
|
107
|
+
issues.push({ path: entry.name, action: 'deleted (unknown file at root)' });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
errors.push(`Failed to delete ${entry.name}: ${e.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for misplaced files within known directories
|
|
117
|
+
const dirFileRules = {
|
|
118
|
+
personality: ['.md'], // Only markdown
|
|
119
|
+
scripts: ['.js', '.sh'], // Only scripts
|
|
120
|
+
tests: ['.js', '.sh'], // Only tests
|
|
121
|
+
commands: ['.md'], // Only markdown
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const [dir, allowedExts] of Object.entries(dirFileRules)) {
|
|
125
|
+
const dirPath = path.join(OBOL_DIR, dir);
|
|
126
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
127
|
+
|
|
128
|
+
const files = safeReaddir(dirPath);
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const ext = path.extname(file);
|
|
131
|
+
if (ext && !allowedExts.includes(ext)) {
|
|
132
|
+
const dest = guessDestination(file);
|
|
133
|
+
if (dest && dest !== dir) {
|
|
134
|
+
try {
|
|
135
|
+
const src = path.join(dirPath, file);
|
|
136
|
+
const destPath = path.join(OBOL_DIR, dest, file);
|
|
137
|
+
fs.mkdirSync(path.join(OBOL_DIR, dest), { recursive: true });
|
|
138
|
+
fs.renameSync(src, destPath);
|
|
139
|
+
issues.push({ path: `${dir}/${file}`, action: `moved → ${dest}/${file}` });
|
|
140
|
+
} catch (e) {
|
|
141
|
+
errors.push(`Failed to move ${dir}/${file}: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { issues, errors };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function guessDestination(filename) {
|
|
152
|
+
const ext = path.extname(filename);
|
|
153
|
+
|
|
154
|
+
// Test files go to tests/
|
|
155
|
+
if (filename.startsWith('test-') || filename.startsWith('test_')) return 'tests';
|
|
156
|
+
|
|
157
|
+
return FILE_RULES[ext] || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function safeReaddir(dir) {
|
|
161
|
+
try {
|
|
162
|
+
return fs.readdirSync(dir).filter(f => {
|
|
163
|
+
try { return fs.statSync(path.join(dir, f)).isFile(); } catch { return false; }
|
|
164
|
+
});
|
|
165
|
+
} catch { return []; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { cleanWorkspace };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { loadConfig } = require('../config');
|
|
2
|
+
const { runBackup } = require('../backup');
|
|
3
|
+
|
|
4
|
+
async function backup() {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
if (!config?.github) {
|
|
7
|
+
console.log('🪙 GitHub backup not configured. Run: obol init');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log('🪙 Running backup...');
|
|
12
|
+
try {
|
|
13
|
+
await runBackup(config.github);
|
|
14
|
+
console.log('✅ Backup complete');
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(`❌ Backup failed: ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { backup };
|