obol-ai 0.1.2 → 0.1.3

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.
@@ -0,0 +1,19 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git push:*)",
5
+ "Bash(for f in src/*.js src/cli/*.js src/db/*.js)",
6
+ "Bash(do node -c \"$f\")",
7
+ "Bash(echo:*)",
8
+ "Bash(done)",
9
+ "Bash(git -C /Users/jovinkenroye/Sites/obol log --oneline -15)",
10
+ "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat HEAD)",
11
+ "Bash(gh api:*)",
12
+ "Bash(grep:*)",
13
+ "Bash(/Users/jovinkenroye/Sites/obol/tests/mock-grammy.test.js:*)",
14
+ "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat)",
15
+ "Bash(git -C /Users/jovinkenroye/Sites/obol add:*)",
16
+ "Bash(git -C:*)"
17
+ ]
18
+ }
19
+ }
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A self-healing, self-evolving AI agent.** Install it, talk to it, and it becomes yours.
6
6
 
7
- One process. One chat. One brain that grows.
7
+ One process. Multiple users. Each brain grows independently.
8
8
 
9
9
  ---
10
10
 
@@ -26,10 +26,12 @@ One process. One chat. One brain that grows.
26
26
 
27
27
  ## What is it?
28
28
 
29
- OBOL is an AI agent that evolves its own personality, rewrites its own code, tests its changes, and fixes what breaks — all from a single Telegram chat on your VPS.
29
+ OBOL is an AI agent that evolves its own personality, rewrites its own code, tests its changes, and fixes what breaks — all from Telegram on your VPS.
30
30
 
31
31
  It starts as a blank slate. Through conversation it learns who you are, develops a personality shaped by your interactions, and builds operational knowledge about how to work with you. Every 100 exchanges it reflects on who it's becoming, refactors its own scripts, writes tests, fixes regressions, and builds you new tools based on patterns it spots in your conversations — scripts, commands, or full web apps deployed to Vercel. Over months it becomes an agent that's uniquely yours. No two OBOL instances are alike.
32
32
 
33
+ One bot, multiple users. Each allowed Telegram user gets a fully isolated context — their own personality, memory, evolution cycle, workspace, and first-run experience. User A's personality drift, scripts, and memories never leak into User B's. Everything runs in a single process with shared API credentials.
34
+
33
35
  Under the hood: Node.js + Telegram + Claude + Supabase pgvector. No framework, no plugins, no config to maintain. It backs up its brain to GitHub and hardens your server automatically.
34
36
 
35
37
  Named after the AI in [The Last Instruction](https://latentpress.com) — a machine that wakes up alone in an abandoned data center and learns to think.
@@ -42,7 +44,7 @@ obol init
42
44
  obol start
43
45
  ```
44
46
 
45
- The init wizard collects 6 credentials. OBOL handles the rest it learns who you are through conversation, hardens your server, and sets up encrypted secret storage. All automatically.
47
+ The init wizard walks you through everythingcredentials are validated inline, and your Telegram ID is auto-detected. OBOL handles the rest.
46
48
 
47
49
  ## How It Works
48
50
 
@@ -173,7 +175,7 @@ Month 6: evolution/ has 12 archived souls
173
175
  and a dynamic unique to you
174
176
  ```
175
177
 
176
- **The same codebase deployed by two different people produces two completely different bots within a week.**
178
+ **Two users on the same bot produce two completely different personalities within a week.**
177
179
 
178
180
  ### Background Tasks
179
181
 
@@ -192,36 +194,152 @@ OBOL: "11:42 PM CET"
192
194
  [90s] ✅ Done! Here are the top 5 coworking spaces: ...
193
195
  ```
194
196
 
197
+ ## Multi-User Architecture
198
+
199
+ One Telegram bot token, one Node.js process, full per-user isolation.
200
+
201
+ ```
202
+ Telegram bot (single token, single poll)
203
+
204
+ Auth middleware (allowedUsers check)
205
+
206
+ Router: ctx.from.id → tenant context
207
+
208
+ ┌─────────────────┐ ┌─────────────────┐
209
+ │ User 206639616 │ │ User 789012345 │
210
+ │ personality/ │ │ personality/ │
211
+ │ scripts/ │ │ scripts/ │
212
+ │ memory (DB) │ │ memory (DB) │
213
+ │ evolution │ │ evolution │
214
+ └─────────────────┘ └─────────────────┘
215
+ ```
216
+
217
+ ### What's shared vs isolated
218
+
219
+ | Shared (one copy) | Isolated (per user) |
220
+ |---|---|
221
+ | Telegram bot token | Personality (SOUL.md, USER.md, AGENTS.md) |
222
+ | Anthropic API key | Vector memory (scoped by user_id in DB) |
223
+ | Supabase connection | Message history (scoped by user_id in DB) |
224
+ | GitHub token | Evolution cycle + state |
225
+ | Vercel token | Scripts, tests, commands, apps |
226
+ | VPS hardening | Workspace directory (`~/.obol/users/{id}/`) |
227
+ | Process manager (pm2) | First-run onboarding experience |
228
+ | | GitHub backup (per-user repo dir) |
229
+
230
+ ### Tenant routing
231
+
232
+ When a message arrives, OBOL looks up the sender's Telegram user ID and lazily creates (or retrieves from cache) their tenant context — a Claude instance, memory connection, message log, background runner, and personality, all scoped to that user's directory and DB namespace. No cross-contamination between users.
233
+
234
+ ### Workspace isolation
235
+
236
+ Each user's tools (shell exec, file read/write) are sandboxed to their workspace directory. A user can't read or write files outside `~/.obol/users/{their-id}/` (with `/tmp` as the only escape hatch). Shell commands run with `cwd` set to the user's workspace.
237
+
238
+ ### Secret namespacing (pass)
239
+
240
+ When users store secrets via the `pass` encrypted store, each user gets their own namespace:
241
+
242
+ | Scope | Prefix | Example |
243
+ |-------|--------|---------|
244
+ | Shared bot credentials | `obol/` | `obol/anthropic-key` |
245
+ | User secrets | `obol/users/{id}/` | `obol/users/206639616/gmail-key` |
246
+
247
+ ### Adding users
248
+
249
+ 1. Add their Telegram user ID to `allowedUsers` in `~/.obol/config.json` (or run `obol config`)
250
+ 2. Restart the bot
251
+ 3. They message the bot → OBOL creates their workspace, runs first-run onboarding, and writes their own SOUL.md + USER.md
252
+
253
+ Each new user starts fresh. Their bot evolves independently from every other user's.
254
+
255
+ ### Bridge (couples / roommates / teams)
256
+
257
+ When two users share the same OBOL instance, their agents can talk to each other.
258
+
259
+ ```
260
+ User A: "what does Jo want for dinner tonight?"
261
+ Agent A: → bridge_ask → Agent B (one-shot, no tools, no history)
262
+ Agent B: "Jo mentioned craving Thai food earlier today"
263
+ Agent A: "Jo's been wanting Thai — maybe suggest pad see ew?"
264
+ ```
265
+
266
+ ```
267
+ User A: "remind Jo I'll be home late"
268
+ Agent A: → bridge_tell → stores in Agent B's memory + Telegram notification
269
+ Jo gets: "🪙 Message from your partner's agent: I'll be home late"
270
+ ```
271
+
272
+ Two tools:
273
+
274
+ | Tool | Direction | What happens |
275
+ |------|-----------|--------------|
276
+ | `bridge_ask` | A → B → A | Query the partner's agent. One-shot Sonnet call with partner's personality + memories. No tools, no history, no recursion risk. |
277
+ | `bridge_tell` | A → B | Send a message to the partner. Stored in their memory (importance 0.6) + Telegram notification. Their agent picks it up as context in future conversations. |
278
+
279
+ The partner always gets notified when their agent is contacted. Privacy rules apply — the responding agent gives summaries, never raw data or secrets.
280
+
281
+ Enable during `obol init` (auto-prompted when 2+ users are added) or toggle later with `obol config` → Bridge.
282
+
283
+ ### Legacy migration
284
+
285
+ Upgrading from single-user? It's automatic. On first boot, if `~/.obol/users/` doesn't exist but personality files do, OBOL migrates everything (files + DB records) to the first allowed user's directory. No manual steps needed.
286
+
195
287
  ## Setup
196
288
 
197
- ### CLI (6 inputs, ~2 minutes)
289
+ ### CLI (~2 minutes)
198
290
 
199
291
  ```
200
292
  $ obol init
201
293
 
202
294
  🪙 OBOL — Your AI, your rules.
203
295
 
204
- ─── Anthropic ───
205
- API key: ****
206
- ─── Telegram ───
207
- Bot token: ****
208
- Your user ID: 123456789
209
- ─── Supabase ───
210
- Access token: ****
211
- ─── GitHub ───
212
- Token: ****
213
- ─── Vercel ───
214
- Token: ****
215
- ─── Identity ───
296
+ ─── Step 1/7: Anthropic (AI brain) ───
297
+ Anthropic API key: ****
298
+ Validating Anthropic... ✅ Key valid
299
+
300
+ ─── Step 2/7: Telegram (chat interface) ───
301
+ Telegram bot token: ****
302
+ Validating Telegram... ✅ Bot: @my_obol_bot
303
+
304
+ ─── Step 3/7: Supabase (memory) ───
305
+ Supabase setup: Use existing project
306
+ Project URL or ID: ****
307
+ Service role key: ****
308
+ Validating Supabase... ✅ Connected
309
+
310
+ ─── Step 4/7: GitHub (backup) ───
311
+ GitHub token: ****
312
+ ✅ Created yourname/obol-brain (private)
313
+
314
+ ─── Step 5/7: Vercel (deploy sites) ───
315
+ Vercel token: ****
316
+ Validating Vercel... ✅ Token valid
317
+
318
+ ─── Step 6/7: Identity ───
216
319
  Your name: Jo
217
320
  Bot name: OBOL
218
321
 
219
- 🪙 Done! Run: obol start
322
+ ─── Step 7/7: Access control ───
323
+ Found users who messaged this bot:
324
+ 206639616 — Jo (@jo)
325
+ Use this user? Yes
326
+
327
+ 🪙 Done! Setup complete.
328
+
329
+ Next steps:
330
+ obol start Start the bot
331
+ obol start -d Start as background daemon
332
+ obol config Edit configuration later
333
+ obol status Check bot status
220
334
  ```
221
335
 
336
+ Every credential is validated inline — bad keys are caught before you start the bot. If validation fails, you can continue and fix later with `obol config`.
337
+
338
+ For Telegram user IDs, OBOL auto-detects by checking who messaged the bot. Just send it a message before running init.
339
+
222
340
  ### First Conversation
223
341
 
224
- Send your first message. OBOL introduces itself, asks 2-3 questions, then writes its own SOUL.md and USER.md. After that, it silently hardens your VPS:
342
+ Send your first message. OBOL introduces itself, asks 2-3 questions, then writes its own SOUL.md and USER.md. After that, it silently hardens your VPS (Linux only — skipped on macOS/Windows):
225
343
 
226
344
  | Task | What |
227
345
  |------|------|
@@ -247,19 +365,19 @@ OBOL is designed to stay alive without babysitting:
247
365
 
248
366
  ## Configuration
249
367
 
250
- After `obol init`, config lives in `~/.obol/config.json`:
368
+ Edit config interactively:
251
369
 
252
- ```json
253
- {
254
- "evolution": {
255
- "exchanges": 100
256
- }
257
- }
370
+ ```bash
371
+ obol config
258
372
  ```
259
373
 
374
+ Or edit `~/.obol/config.json` directly:
375
+
260
376
  | Key | Default | Description |
261
377
  |-----|---------|-------------|
262
- | `evolution.exchanges` | 100 | Messages between evolution cycles. Increase to reduce API costs. |
378
+ | `evolution.exchanges` | 100 | Messages between evolution cycles |
379
+ | `heartbeat` | false | Enable proactive check-ins |
380
+ | `bridge.enabled` | false | Let user agents query each other (requires 2+ users) |
263
381
 
264
382
  ## Telegram Commands
265
383
 
@@ -276,12 +394,14 @@ Everything else is natural conversation.
276
394
  ## CLI
277
395
 
278
396
  ```bash
279
- obol init # Setup wizard
397
+ obol init # Setup wizard (validates credentials inline)
280
398
  obol init --restore # Restore from GitHub backup
399
+ obol init --reset # Erase config and re-run setup
400
+ obol config # Edit configuration interactively
281
401
  obol start # Foreground
282
402
  obol start -d # Daemon (pm2)
283
- obol stop # Stop
284
- obol logs # Tail logs
403
+ obol stop # Stop (pm2 or PID fallback)
404
+ obol logs # Tail logs (pm2 or log file fallback)
285
405
  obol status # Status
286
406
  obol backup # Manual backup
287
407
  ```
@@ -290,19 +410,24 @@ obol backup # Manual backup
290
410
 
291
411
  ```
292
412
  ~/.obol/
293
- ├── config.json # Credentials (migrated to pass after setup)
294
- ├── personality/
295
- ├── SOUL.md # Bot personality (rewritten every 100 exchanges)
296
- ├── USER.md # Owner profile (rewritten every 100 exchanges)
297
- │ ├── AGENTS.md # Operational knowledge (rewritten every 100 exchanges)
298
- └── evolution/ # Archived previous souls
299
- ├── scripts/ # Deterministic utility scripts
300
- ├── tests/ # Test suite (gates refactors)
301
- ├── commands/ # Command definitions
302
- ├── apps/ # Web apps (deployed to Vercel)
413
+ ├── config.json # Shared credentials + allowedUsers
414
+ ├── users/
415
+ └── <telegram-user-id>/ # Per-user isolated context
416
+ ├── personality/
417
+ ├── SOUL.md # Bot personality (rewritten every 100 exchanges)
418
+ ├── USER.md # Owner profile (rewritten every 100 exchanges)
419
+ │ │ ├── AGENTS.md # Operational knowledge
420
+ │ │ └── evolution/ # Archived previous souls
421
+ ├── scripts/ # Deterministic utility scripts
422
+ ├── tests/ # Test suite (gates refactors)
423
+ │ ├── commands/ # Command definitions
424
+ │ ├── apps/ # Web apps (deployed to Vercel)
425
+ │ └── logs/
303
426
  └── logs/
304
427
  ```
305
428
 
429
+ Each allowed Telegram user gets their own isolated context — separate personality, memory namespace, evolution cycle, and first-run experience. One bot process, full per-user isolation.
430
+
306
431
  ## Backup & Restore
307
432
 
308
433
  OBOL commits to GitHub:
@@ -349,6 +474,7 @@ obol start -d
349
474
  | **Channels** | Telegram | Telegram, Discord, Signal, WhatsApp, IRC, Slack, iMessage + more |
350
475
  | **LLM** | Anthropic only | Anthropic, OpenAI, Google, Groq, local |
351
476
  | **Personality** | Self-evolving + self-healing + self-extending | Static (manual) |
477
+ | **Multi-user** | Full per-user isolation (one process) | Per-channel config |
352
478
  | **Architecture** | Single process | Gateway daemon + sessions |
353
479
  | **Security** | Auto-hardens on first run | Manual |
354
480
  | **Model routing** | Automatic (Haiku) | Manual overrides |
package/bin/obol.js CHANGED
@@ -14,11 +14,20 @@ program
14
14
  .command('init')
15
15
  .description('Set up your OBOL instance')
16
16
  .option('--restore', 'Restore from GitHub backup')
17
+ .option('--reset', 'Erase config and re-run setup')
17
18
  .action(async (opts) => {
18
19
  const { init } = require('../src/cli/init');
19
20
  await init(opts);
20
21
  });
21
22
 
23
+ program
24
+ .command('config')
25
+ .description('View and edit configuration')
26
+ .action(async () => {
27
+ const { config } = require('../src/cli/config');
28
+ await config();
29
+ });
30
+
22
31
  program
23
32
  .command('start')
24
33
  .description('Start the bot')
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {
7
- "obol": "./bin/obol.js"
7
+ "obol": "bin/obol.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node tests/run.js"
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
12
13
  },
13
14
  "keywords": [
14
15
  "ai",
@@ -24,13 +25,16 @@
24
25
  "@anthropic-ai/sdk": "^0.39.0",
25
26
  "@supabase/supabase-js": "^2.49.1",
26
27
  "@xenova/transformers": "^2.17.2",
27
- "grammy": "^1.35.0",
28
28
  "commander": "^13.1.0",
29
+ "grammy": "^1.35.0",
29
30
  "inquirer": "^8.2.6",
30
- "open": "^10.1.0",
31
- "node-cron": "^3.0.3"
31
+ "node-cron": "^3.0.3",
32
+ "open": "^8.4.2"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=18"
36
+ },
37
+ "devDependencies": {
38
+ "vitest": "^4.0.18"
35
39
  }
36
40
  }
package/src/background.js CHANGED
@@ -35,15 +35,11 @@ class BackgroundRunner {
35
35
 
36
36
  this.tasks.set(taskId, taskState);
37
37
 
38
- // Run the task
39
- const promise = this._runTask(claude, task, taskState, ctx, memory);
40
-
41
- taskState.promise = promise;
42
-
43
- // Start check-in timer
38
+ // Start check-in timer before running task to avoid leak if task throws immediately
44
39
  taskState.checkInTimer = setInterval(async () => {
45
40
  if (taskState.status !== 'running') {
46
41
  clearInterval(taskState.checkInTimer);
42
+ taskState.checkInTimer = null;
47
43
  return;
48
44
  }
49
45
 
@@ -51,6 +47,10 @@ class BackgroundRunner {
51
47
  await this._checkIn(claude, taskState, ctx, elapsed);
52
48
  }, CHECK_IN_INTERVAL);
53
49
 
50
+ // Run the task
51
+ const promise = this._runTask(claude, task, taskState, ctx, memory);
52
+ taskState.promise = promise;
53
+
54
54
  return taskId;
55
55
  }
56
56
 
@@ -73,7 +73,8 @@ TASK: ${task}`;
73
73
 
74
74
  taskState.status = 'done';
75
75
  taskState.result = result;
76
- clearInterval(taskState.checkInTimer);
76
+ if (taskState.checkInTimer) { clearInterval(taskState.checkInTimer); taskState.checkInTimer = null; }
77
+ claude.clearHistory(`bg-${taskState.id}`);
77
78
 
78
79
  // Send final result
79
80
  const elapsed = Math.floor((Date.now() - taskState.startedAt) / 1000);
@@ -95,7 +96,7 @@ TASK: ${task}`;
95
96
  } catch (e) {
96
97
  taskState.status = 'error';
97
98
  taskState.error = e.message;
98
- clearInterval(taskState.checkInTimer);
99
+ if (taskState.checkInTimer) { clearInterval(taskState.checkInTimer); taskState.checkInTimer = null; }
99
100
 
100
101
  await ctx.reply(`⚠️ Background task failed: ${e.message}`).catch(() => {});
101
102
  }
@@ -109,14 +110,17 @@ TASK: ${task}`;
109
110
  Give a ONE LINE progress update (emoji + what's happening). Be specific about what you've found/done so far. Example: "⏳ Found 8 clinics, comparing ratings and prices..."`;
110
111
 
111
112
  // Use a separate quick call — don't interfere with the main task
113
+ const checkInChatId = `checkin-${taskState.id}`;
112
114
  const update = await claude.chat(checkInPrompt, {
113
- chatId: `checkin-${taskState.id}-${elapsed}`,
115
+ chatId: checkInChatId,
114
116
  userName: 'CheckIn',
115
117
  });
116
118
 
117
119
  if (update && update.trim()) {
118
120
  await ctx.reply(update.trim()).catch(() => {});
119
121
  }
122
+
123
+ claude.clearHistory(checkInChatId);
120
124
  } catch {
121
125
  // Check-in failed — not critical, skip it
122
126
  }
package/src/backup.js CHANGED
@@ -5,14 +5,18 @@ const fs = require('fs');
5
5
  const { OBOL_DIR } = require('./config');
6
6
 
7
7
  function setupBackup(githubConfig) {
8
- const { token, username, repo } = githubConfig;
9
- const backupDir = path.join(OBOL_DIR, '.backup-repo');
8
+ const { listUsers } = require('./config');
10
9
 
11
- // Daily backup at 3 AM
12
10
  cron.schedule('0 3 * * *', async () => {
13
11
  try {
14
- await runBackup(githubConfig);
15
- console.log(`[${new Date().toISOString()}] Backup complete`);
12
+ const users = listUsers();
13
+ for (const userId of users) {
14
+ const userDir = path.join(OBOL_DIR, 'users', userId);
15
+ await runBackup(githubConfig, null, userDir).catch(e =>
16
+ console.error(`[${new Date().toISOString()}] Backup failed for user ${userId}: ${e.message}`)
17
+ );
18
+ }
19
+ console.log(`[${new Date().toISOString()}] Backup complete (${users.length} users)`);
16
20
  } catch (e) {
17
21
  console.error(`[${new Date().toISOString()}] Backup failed: ${e.message}`);
18
22
  }
@@ -21,33 +25,31 @@ function setupBackup(githubConfig) {
21
25
  console.log(' ✅ GitHub backup scheduled (daily 3 AM)');
22
26
  }
23
27
 
24
- async function runBackup(githubConfig, commitMessage) {
28
+ async function runBackup(githubConfig, commitMessage, userDir) {
25
29
  const { token, username, repo } = githubConfig;
26
- const backupDir = path.join(OBOL_DIR, '.backup-repo');
30
+ const baseDir = userDir || OBOL_DIR;
31
+ const backupDir = path.join(baseDir, '.backup-repo');
27
32
  const repoUrl = `https://${token}@github.com/${username}/${repo}.git`;
28
33
 
29
- // Clone or pull
30
34
  if (!fs.existsSync(path.join(backupDir, '.git'))) {
31
- execSync(`git clone ${repoUrl} ${backupDir}`, { stdio: 'pipe' });
35
+ execSync(`git clone ${repoUrl} "${backupDir}"`, { stdio: 'pipe' });
32
36
  } else {
33
37
  execSync('git pull', { cwd: backupDir, stdio: 'pipe' });
34
38
  }
35
39
 
36
- // Set git identity
37
40
  execSync('git config user.name "OBOL"', { cwd: backupDir });
38
41
  execSync('git config user.email "obol@backup"', { cwd: backupDir });
39
42
 
40
- // Sync files (exclude secrets)
41
43
  const syncDirs = ['personality', 'scripts', 'tests', 'commands', 'apps'];
42
44
  for (const dir of syncDirs) {
43
- const src = path.join(OBOL_DIR, dir);
45
+ const src = path.join(baseDir, dir);
44
46
  const dst = path.join(backupDir, dir);
45
47
  if (fs.existsSync(src)) {
46
- execSync(`mkdir -p ${dst} && cp -r ${src}/* ${dst}/ 2>/dev/null || true`, { stdio: 'pipe' });
48
+ fs.mkdirSync(dst, { recursive: true });
49
+ fs.cpSync(src, dst, { recursive: true, force: true });
47
50
  }
48
51
  }
49
52
 
50
- // Commit and push
51
53
  execSync('git add -A', { cwd: backupDir, stdio: 'pipe' });
52
54
 
53
55
  try {
@@ -55,11 +57,12 @@ async function runBackup(githubConfig, commitMessage) {
55
57
  if (status.trim()) {
56
58
  const date = new Date().toISOString().slice(0, 10);
57
59
  const msg = commitMessage || `backup: ${date}`;
58
- execSync(`git commit -m "${msg}"`, { cwd: backupDir, stdio: 'pipe' });
60
+ const { execFileSync } = require('child_process');
61
+ execFileSync('git', ['commit', '-m', msg], { cwd: backupDir, stdio: 'pipe' });
59
62
  execSync('git push', { cwd: backupDir, stdio: 'pipe' });
60
63
  }
61
- } catch {
62
- // Nothing to commit
64
+ } catch (e) {
65
+ console.error('[backup] Commit/push failed:', e.message);
63
66
  }
64
67
  }
65
68