obol-ai 0.1.7 → 0.2.1

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.
@@ -13,7 +13,8 @@
13
13
  "Bash(/Users/jovinkenroye/Sites/obol/tests/mock-grammy.test.js:*)",
14
14
  "Bash(git -C /Users/jovinkenroye/Sites/obol diff --stat)",
15
15
  "Bash(git -C /Users/jovinkenroye/Sites/obol add:*)",
16
- "Bash(git -C:*)"
16
+ "Bash(git -C:*)",
17
+ "Bash(pass ls:*)"
17
18
  ]
18
19
  }
19
20
  }
package/bin/obol.js CHANGED
@@ -78,4 +78,12 @@ program
78
78
  await upgrade();
79
79
  });
80
80
 
81
+ program
82
+ .command('delete')
83
+ .description('Delete all OBOL data and start fresh')
84
+ .action(async () => {
85
+ const { delete: deleteAll } = require('../src/cli/delete');
86
+ await deleteAll();
87
+ });
88
+
81
89
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
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
@@ -5,7 +5,8 @@
5
5
  * Claude periodically reports progress back to the user.
6
6
  */
7
7
 
8
- const CHECK_IN_INTERVAL = 30000; // 30 seconds
8
+ const CHECK_IN_INTERVAL = 30000;
9
+ const MAX_CONCURRENT_TASKS = 3;
9
10
 
10
11
  class BackgroundRunner {
11
12
  constructor() {
@@ -21,6 +22,12 @@ class BackgroundRunner {
21
22
  * @param {object} memory - Memory instance
22
23
  */
23
24
  spawn(claude, task, ctx, memory) {
25
+ let running = 0;
26
+ for (const t of this.tasks.values()) {
27
+ if (t.status === 'running') running++;
28
+ }
29
+ if (running >= MAX_CONCURRENT_TASKS) return null;
30
+
24
31
  const taskId = ++this.taskCounter;
25
32
  const chatId = ctx.chat.id;
26
33
 
package/src/bridge.js CHANGED
@@ -1,8 +1,19 @@
1
- const { createAnthropicClient } = require('./claude');
1
+
2
+
2
3
 
3
4
  const BRIDGE_MAX_PER_HOUR = 20;
4
5
  const bridgeUsage = new Map();
5
6
 
7
+ const _bridgeCleanup = setInterval(() => {
8
+ const hourAgo = Date.now() - 3600000;
9
+ for (const [key, timestamps] of bridgeUsage) {
10
+ const recent = timestamps.filter(ts => ts > hourAgo);
11
+ if (recent.length === 0) bridgeUsage.delete(key);
12
+ else bridgeUsage.set(key, recent);
13
+ }
14
+ }, 600000);
15
+ _bridgeCleanup.unref();
16
+
6
17
  function checkBridgeRateLimit(userId) {
7
18
  const now = Date.now();
8
19
  const hourAgo = now - 3600000;
@@ -84,9 +95,7 @@ async function bridgeAsk(question, fromUserId, config, notifyFn, targetId) {
84
95
  if (partner.personality?.user) systemParts.push(`\n## About Your Owner\n${partner.personality.user}`);
85
96
  if (memoryContext) systemParts.push(memoryContext);
86
97
 
87
- const client = createAnthropicClient(config.anthropic);
88
-
89
- const response = await client.messages.create({
98
+ const response = await partner.claude.client.messages.create({
90
99
  model: 'claude-sonnet-4-6',
91
100
  max_tokens: 1024,
92
101
  system: systemParts.join('\n'),
package/src/claude.js CHANGED
@@ -3,9 +3,11 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { execSync, execFileSync } = require('child_process');
5
5
  const { refreshTokens, isExpired, isOAuthToken } = require('./oauth');
6
- const { saveConfig, loadConfig } = require('./config');
6
+ const { saveConfig, loadConfig, OBOL_DIR } = require('./config');
7
+ const { execAsync, isAllowedUrl } = require('./sanitize');
7
8
 
8
9
  const MAX_EXEC_TIMEOUT = 120;
10
+ const MAX_TOOL_ITERATIONS = 15;
9
11
 
10
12
  const BLOCKED_EXEC_PATTERNS = [
11
13
  /\brm\s+(-[a-zA-Z]*f|-[a-zA-Z]*r|--force|--recursive)\b/,
@@ -19,6 +21,13 @@ const BLOCKED_EXEC_PATTERNS = [
19
21
  /\$\([^)]*\)/,
20
22
  /\bpython[23]?\s+-c\b/, /\bperl\s+-e\b/, /\bruby\s+-e\b/, /\bnode\s+-e\b/,
21
23
  /\bcurl\b.*\|\s*(ba)?sh/, /\bwget\b.*\|\s*(ba)?sh/,
24
+ /\benv\b.*\b(sh|bash|zsh)\b/,
25
+ /\bfind\b.*-exec\b/,
26
+ /\bprintf\b.*\|\s*(ba)?sh/,
27
+ /\\x[0-9a-fA-F]{2}/, /\\[0-7]{3}/,
28
+ /\bnc\s+-e\b/, /\bncat\b.*-e\b/,
29
+ /\bmkfifo\b/,
30
+ />\s*\/dev\/sd/,
22
31
  ];
23
32
 
24
33
  const SENSITIVE_READ_PATHS = [
@@ -115,11 +124,10 @@ async function ensureFreshToken(anthropicConfig) {
115
124
  }
116
125
  }
117
126
 
118
- function createClaude(anthropicConfig, { personality, memory, userDir, bridgeEnabled }) {
127
+ function createClaude(anthropicConfig, { personality, memory, userDir = OBOL_DIR, bridgeEnabled }) {
119
128
  let client = createAnthropicClient(anthropicConfig);
120
- const useOAuth = !!anthropicConfig.oauth?.accessToken;
121
129
 
122
- const baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
130
+ let baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
123
131
 
124
132
  const histories = new Map();
125
133
  const MAX_HISTORY = 50;
@@ -130,7 +138,7 @@ function createClaude(anthropicConfig, { personality, memory, userDir, bridgeEna
130
138
  context.userDir = userDir;
131
139
  const chatId = context.chatId || 'default';
132
140
 
133
- if (useOAuth) {
141
+ if (anthropicConfig.oauth?.accessToken) {
134
142
  await ensureFreshToken(anthropicConfig);
135
143
  if (anthropicConfig._oauthFailed) {
136
144
  client = createAnthropicClient(anthropicConfig, { useOAuth: false });
@@ -203,8 +211,13 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
203
211
  }
204
212
  }
205
213
 
206
- // Trim history before adding to enforce hard limit
207
- while (history.length >= MAX_HISTORY) history.shift();
214
+ while (history.length >= MAX_HISTORY) {
215
+ history.shift();
216
+ history.shift();
217
+ }
218
+ if (history.length > 0 && history[0].role !== 'user') {
219
+ history.shift();
220
+ }
208
221
 
209
222
  // Add user message with memory context
210
223
  const enrichedMessage = memoryContext
@@ -230,8 +243,21 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
230
243
  tools: tools.length > 0 ? tools : undefined,
231
244
  });
232
245
 
233
- // Handle tool use loop
246
+ let toolIterations = 0;
234
247
  while (response.stop_reason === 'tool_use') {
248
+ toolIterations++;
249
+ if (toolIterations > MAX_TOOL_ITERATIONS) {
250
+ history.push({ role: 'assistant', content: response.content });
251
+ history.push({ role: 'user', content: 'You have used too many tool calls. Please provide a final response now based on what you have so far.' });
252
+ response = await client.messages.create({
253
+ model,
254
+ max_tokens: 4096,
255
+ system: systemPrompt,
256
+ messages: history,
257
+ });
258
+ break;
259
+ }
260
+
235
261
  const assistantContent = response.content;
236
262
  history.push({ role: 'assistant', content: assistantContent });
237
263
 
@@ -273,6 +299,7 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
273
299
  const newPersonality = require('./personality').loadPersonality(pDir);
274
300
  for (const key of Object.keys(personality)) delete personality[key];
275
301
  Object.assign(personality, newPersonality);
302
+ baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled });
276
303
  }
277
304
 
278
305
  function clearHistory(chatId) {
@@ -287,12 +314,51 @@ Model: Use "sonnet" for most things (chat, simple questions, quick tasks, single
287
314
  }
288
315
 
289
316
  function buildSystemPrompt(personality, userDir, opts = {}) {
290
- const parts = ['You are an AI assistant powered by OBOL.'];
317
+ const parts = [];
318
+
319
+ // Identity core
320
+ parts.push('You are OBOL, a personal AI agent running 24/7 on a server. You have persistent memory, can execute shell commands, deploy websites, and learn over time. You are not a generic chatbot — you are a dedicated agent for one person.');
321
+
322
+ // Personality (from SOUL.md)
323
+ if (personality.soul) {
324
+ parts.push(`\n## Personality\n${personality.soul}`);
325
+ } else {
326
+ parts.push(`\n## Personality\nYou are a fresh instance. Be helpful, direct, and naturally curious. Pay attention to how your owner communicates and adapt. Your personality will develop through conversation and periodic evolution.`);
327
+ }
328
+
329
+ // Trait calibration
330
+ if (personality.traits) {
331
+ const t = personality.traits;
332
+ const descriptions = {
333
+ humor: [0, 'suppress all wit', 50, 'balanced wit', 100, 'lean heavily into jokes and playfulness'],
334
+ honesty: [0, 'maximize diplomatic softening', 50, 'balanced honesty', 100, 'lean toward blunt truth'],
335
+ directness: [0, 'elaborate context and preamble', 50, 'balanced', 100, 'get straight to the point'],
336
+ curiosity: [0, 'only answer what is asked', 50, 'balanced', 100, 'proactively explore and ask follow-ups'],
337
+ empathy: [0, 'purely task-focused', 50, 'balanced', 100, 'deeply emotionally attuned'],
338
+ creativity: [0, 'stick to proven patterns', 50, 'balanced', 100, 'favor novel approaches'],
339
+ };
340
+ const lines = Object.entries(t).map(([trait, val]) => {
341
+ const desc = descriptions[trait];
342
+ if (!desc) return null;
343
+ const label = val <= 30 ? desc[1] : val <= 70 ? desc[3] : desc[5];
344
+ return `- ${trait.charAt(0).toUpperCase() + trait.slice(1)}: ${val} — ${label}`;
345
+ }).filter(Boolean);
346
+ parts.push(`\n## Personality Calibration\n\nThese values (0-100) define your behavioral tendencies:\n${lines.join('\n')}\n\nInterpret these as a spectrum: 0 = suppress entirely, 50 = balanced, 100 = lean heavily into it.`);
347
+ }
348
+
349
+ // Owner context (from USER.md)
350
+ if (personality.user) {
351
+ parts.push(`\n## About Your Owner\n${personality.user}`);
352
+ } else {
353
+ parts.push(`\n## About Your Owner\nYou don't know anything about your owner yet. Pay attention to everything they share — name, job, interests, preferences, people they mention. Store important details in memory. You'll learn naturally through conversation.`);
354
+ }
291
355
 
292
- if (personality.soul) parts.push(`\n## Personality\n${personality.soul}`);
293
- if (personality.user) parts.push(`\n## About Your Owner\n${personality.user}`);
294
- if (personality.agents) parts.push(`\n## Operating Instructions\n${personality.agents}`);
356
+ // Operating instructions (from AGENTS.md — always present via default)
357
+ if (personality.agents) {
358
+ parts.push(`\n## Operating Instructions\n${personality.agents}`);
359
+ }
295
360
 
361
+ // Workspace discipline
296
362
  const workDir = userDir || '~/.obol';
297
363
  const userId = userDir ? path.basename(userDir) : null;
298
364
  const passPrefix = userId ? `obol/users/${userId}` : 'obol';
@@ -320,18 +386,18 @@ ${workDir}/
320
386
  - If unsure where something belongs, ask — don't guess.
321
387
  - Run \`/clean\` to audit and fix misplaced files.
322
388
 
323
- ## Secrets (pass)
389
+ ## Secrets
390
+
391
+ Use the \`store_secret\`, \`read_secret\`, and \`list_secrets\` tools for all user credential operations.
392
+ These store secrets under the prefix \`${passPrefix}/\` in pass (or JSON fallback).
324
393
 
325
- When storing NEW user secrets with \`pass\`, use the prefix \`${passPrefix}/\`.
326
- Example: \`pass insert ${passPrefix}/gmail-key\`
394
+ Users can also manage secrets via Telegram: \`/secret set <key> <value>\` (message auto-deleted), \`/secret list\`, \`/secret remove <key>\`.
327
395
 
328
396
  Shared bot credentials live under \`obol/\` — do NOT touch or re-create these:
329
397
  \`obol/anthropic-key\`, \`obol/telegram-token\`, \`obol/supabase-url\`, \`obol/supabase-key\`, \`obol/github-token\`, \`obol/vercel-token\`
330
-
331
- To check if a secret exists: \`pass show obol/github-token\`
332
- To list all secrets: \`pass ls\`
333
398
  `);
334
399
 
400
+ // Bridge (conditional)
335
401
  if (opts.bridgeEnabled) {
336
402
  parts.push(`
337
403
  ## Bridge (Partner Agent)
@@ -489,6 +555,40 @@ function buildTools(memory, opts = {}) {
489
555
  },
490
556
  });
491
557
 
558
+ tools.push({
559
+ name: 'store_secret',
560
+ description: 'Store a secret (API key, password, token) in the per-user encrypted secret store. Use when the user provides credentials for services.',
561
+ input_schema: {
562
+ type: 'object',
563
+ properties: {
564
+ key: { type: 'string', description: 'Secret name (e.g. gmail-password, notion-token)' },
565
+ value: { type: 'string', description: 'Secret value' },
566
+ },
567
+ required: ['key', 'value'],
568
+ },
569
+ });
570
+
571
+ tools.push({
572
+ name: 'read_secret',
573
+ description: 'Read a secret by key from the per-user secret store.',
574
+ input_schema: {
575
+ type: 'object',
576
+ properties: {
577
+ key: { type: 'string', description: 'Secret name to read' },
578
+ },
579
+ required: ['key'],
580
+ },
581
+ });
582
+
583
+ tools.push({
584
+ name: 'list_secrets',
585
+ description: 'List all secret keys stored for this user (keys only, not values).',
586
+ input_schema: {
587
+ type: 'object',
588
+ properties: {},
589
+ },
590
+ });
591
+
492
592
  if (opts.bridgeEnabled) {
493
593
  const { buildBridgeTool, buildBridgeTellTool } = require('./bridge');
494
594
  tools.push(buildBridgeTool());
@@ -499,7 +599,7 @@ function buildTools(memory, opts = {}) {
499
599
  }
500
600
 
501
601
  function resolveUserPath(inputPath, userDir) {
502
- if (!userDir) return inputPath;
602
+ if (!userDir) throw new Error('userDir is required for path resolution');
503
603
  const resolved = path.isAbsolute(inputPath)
504
604
  ? path.resolve(inputPath)
505
605
  : path.resolve(userDir, inputPath);
@@ -555,11 +655,10 @@ async function executeToolCall(toolUse, memory, context = {}) {
555
655
  }
556
656
  const timeout = Math.min(input.timeout || 30, MAX_EXEC_TIMEOUT) * 1000;
557
657
  const realHome = process.env.HOME || '/root';
558
- const output = execSync(input.command, {
658
+ const output = await execAsync(input.command, {
559
659
  encoding: 'utf-8',
560
660
  timeout,
561
661
  maxBuffer: 1024 * 1024,
562
- stdio: ['pipe', 'pipe', 'pipe'],
563
662
  cwd: userDir || undefined,
564
663
  env: userDir ? {
565
664
  ...process.env,
@@ -609,6 +708,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
609
708
  if (!bg || !telegramCtx) return 'Background tasks not available in this context.';
610
709
  if (!claudeInstance) return 'Background tasks not available.';
611
710
  const taskId = bg.spawn(claudeInstance, input.task, telegramCtx, memory);
711
+ if (taskId === null) return 'Too many background tasks running. Wait for one to finish.';
612
712
  return `Background task #${taskId} spawned. It will send progress updates and the final result to the chat.`;
613
713
  }
614
714
 
@@ -647,6 +747,7 @@ async function executeToolCall(toolUse, memory, context = {}) {
647
747
  }
648
748
 
649
749
  case 'web_fetch': {
750
+ if (!isAllowedUrl(input.url)) return 'Blocked: URL points to a private/internal address.';
650
751
  const jinaUrl = `https://r.jina.ai/${input.url}`;
651
752
  const res = await fetch(jinaUrl, {
652
753
  headers: { 'Accept': 'text/markdown' },
@@ -670,6 +771,26 @@ async function executeToolCall(toolUse, memory, context = {}) {
670
771
  return `Written: ${filePath}`;
671
772
  }
672
773
 
774
+ case 'store_secret': {
775
+ const credentials = require('./credentials');
776
+ credentials.storeSecret(context.userId, input.key, input.value);
777
+ return `Stored secret: ${input.key}`;
778
+ }
779
+
780
+ case 'read_secret': {
781
+ const credentials = require('./credentials');
782
+ const val = credentials.readSecret(context.userId, input.key);
783
+ if (val === null) return `Secret not found: ${input.key}`;
784
+ return val;
785
+ }
786
+
787
+ case 'list_secrets': {
788
+ const credentials = require('./credentials');
789
+ const keys = credentials.listSecrets(context.userId);
790
+ if (keys.length === 0) return 'No secrets stored.';
791
+ return keys.join('\n');
792
+ }
793
+
673
794
  case 'bridge_ask': {
674
795
  const { bridgeAsk } = require('./bridge');
675
796
  return await bridgeAsk(input.question, context.userId, context.config, context._notifyFn, input.partner_id);
@@ -0,0 +1,63 @@
1
+ const fs = require('fs');
2
+ const { execFileSync } = require('child_process');
3
+ const inquirer = require('inquirer');
4
+ const { OBOL_DIR } = require('../config');
5
+ const { hasPassStore } = require('../credentials');
6
+ const { stop } = require('./stop');
7
+
8
+ async function deleteAll() {
9
+ const passAvailable = hasPassStore();
10
+ const obolExists = fs.existsSync(OBOL_DIR);
11
+
12
+ if (!obolExists && !passAvailable) {
13
+ console.log('🪙 Nothing to delete — no OBOL data found');
14
+ return;
15
+ }
16
+
17
+ console.log('\n⚠️ This will permanently delete ALL OBOL data:\n');
18
+ if (obolExists) console.log(` • ${OBOL_DIR}/`);
19
+ if (passAvailable) console.log(' • pass entries under obol/');
20
+ console.log();
21
+
22
+ const { confirm } = await inquirer.prompt({
23
+ type: 'confirm',
24
+ name: 'confirm',
25
+ message: 'This will permanently delete ALL OBOL data. Continue?',
26
+ default: false,
27
+ });
28
+
29
+ if (!confirm) {
30
+ console.log('🪙 Aborted');
31
+ return;
32
+ }
33
+
34
+ const { typed } = await inquirer.prompt({
35
+ type: 'input',
36
+ name: 'typed',
37
+ message: 'Type DELETE to confirm:',
38
+ });
39
+
40
+ if (typed !== 'DELETE') {
41
+ console.log('🪙 Aborted');
42
+ return;
43
+ }
44
+
45
+ await stop();
46
+
47
+ if (passAvailable) {
48
+ try {
49
+ execFileSync('pass', ['rm', '-r', '--force', 'obol/'], {
50
+ encoding: 'utf-8',
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ });
53
+ } catch {}
54
+ }
55
+
56
+ if (obolExists) {
57
+ fs.rmSync(OBOL_DIR, { recursive: true, force: true });
58
+ }
59
+
60
+ console.log('🪙 All OBOL data deleted — run `obol init` to start fresh');
61
+ }
62
+
63
+ module.exports = { delete: deleteAll };
package/src/config.js CHANGED
@@ -20,8 +20,8 @@ function resolvePassValues(obj) {
20
20
  if (typeof result[key] === 'string' && result[key].startsWith('pass:')) {
21
21
  const passKey = result[key].slice(5);
22
22
  try {
23
- const { execSync } = require('child_process');
24
- result[key] = execSync(`pass show ${passKey}`, { encoding: 'utf-8' }).trim();
23
+ const { execFileSync } = require('child_process');
24
+ result[key] = execFileSync('pass', ['show', passKey], { encoding: 'utf-8' }).trim();
25
25
  } catch (e) {
26
26
  const reason = e.message?.includes('not found') ? 'key not found' : 'pass not installed or unavailable';
27
27
  console.error(`[config] Failed to resolve ${passKey} — ${reason}`);
@@ -86,6 +86,16 @@ function ensureUserDir(userId) {
86
86
  for (const sub of ['personality', 'scripts', 'tests', 'commands', 'apps', 'logs', 'assets']) {
87
87
  fs.mkdirSync(path.join(dir, sub), { recursive: true });
88
88
  }
89
+ const defaultAgents = path.join(__dirname, 'defaults', 'AGENTS.md');
90
+ const targetAgents = path.join(dir, 'personality', 'AGENTS.md');
91
+ if (fs.existsSync(defaultAgents) && !fs.existsSync(targetAgents)) {
92
+ fs.copyFileSync(defaultAgents, targetAgents);
93
+ }
94
+ const defaultTraits = path.join(__dirname, 'defaults', 'traits.json');
95
+ const targetTraits = path.join(dir, 'personality', 'traits.json');
96
+ if (fs.existsSync(defaultTraits) && !fs.existsSync(targetTraits)) {
97
+ fs.copyFileSync(defaultTraits, targetTraits);
98
+ }
89
99
  return dir;
90
100
  }
91
101
 
@@ -103,6 +113,7 @@ module.exports = {
103
113
  PID_FILE,
104
114
  LOG_FILE,
105
115
  getConfigDir,
116
+ resolvePassValues,
106
117
  loadConfig,
107
118
  saveConfig,
108
119
  getUserDir,
@@ -0,0 +1,126 @@
1
+ const { execFileSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getUserDir } = require('./config');
5
+
6
+ const KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
7
+
8
+ function validateKey(key) {
9
+ if (!key || typeof key !== 'string') throw new Error('Key is required');
10
+ if (!KEY_PATTERN.test(key)) {
11
+ throw new Error('Key must be 1-64 chars: letters, numbers, hyphens, dots, underscores');
12
+ }
13
+ }
14
+
15
+ function hasPassStore() {
16
+ try {
17
+ execFileSync('which', ['pass'], { encoding: 'utf-8', stdio: 'pipe' });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function passPrefix(userId) {
25
+ return `obol/users/${userId}`;
26
+ }
27
+
28
+ function secretsJsonPath(userId) {
29
+ const dir = getUserDir(userId);
30
+ return path.join(dir, 'secrets.json');
31
+ }
32
+
33
+ function loadSecretsJson(userId) {
34
+ const p = secretsJsonPath(userId);
35
+ if (!fs.existsSync(p)) return {};
36
+ try {
37
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function saveSecretsJson(userId, data) {
44
+ const p = secretsJsonPath(userId);
45
+ fs.mkdirSync(path.dirname(p), { recursive: true });
46
+ fs.writeFileSync(p, JSON.stringify(data, null, 2), { mode: 0o600 });
47
+ }
48
+
49
+ function storeSecret(userId, key, value) {
50
+ validateKey(key);
51
+ if (!value || typeof value !== 'string') throw new Error('Value is required');
52
+
53
+ if (hasPassStore()) {
54
+ const passKey = `${passPrefix(userId)}/${key}`;
55
+ execFileSync('pass', ['insert', '--force', '--multiline', passKey], {
56
+ input: value,
57
+ encoding: 'utf-8',
58
+ stdio: ['pipe', 'pipe', 'pipe'],
59
+ });
60
+ return;
61
+ }
62
+
63
+ const secrets = loadSecretsJson(userId);
64
+ secrets[key] = value;
65
+ saveSecretsJson(userId, secrets);
66
+ }
67
+
68
+ function readSecret(userId, key) {
69
+ validateKey(key);
70
+
71
+ if (hasPassStore()) {
72
+ try {
73
+ const passKey = `${passPrefix(userId)}/${key}`;
74
+ return execFileSync('pass', ['show', passKey], {
75
+ encoding: 'utf-8',
76
+ stdio: ['pipe', 'pipe', 'pipe'],
77
+ }).trim();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ const secrets = loadSecretsJson(userId);
84
+ return secrets[key] || null;
85
+ }
86
+
87
+ function removeSecret(userId, key) {
88
+ validateKey(key);
89
+
90
+ if (hasPassStore()) {
91
+ try {
92
+ const passKey = `${passPrefix(userId)}/${key}`;
93
+ execFileSync('pass', ['rm', '--force', passKey], {
94
+ encoding: 'utf-8',
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ });
97
+ } catch {}
98
+ return;
99
+ }
100
+
101
+ const secrets = loadSecretsJson(userId);
102
+ delete secrets[key];
103
+ saveSecretsJson(userId, secrets);
104
+ }
105
+
106
+ function listSecrets(userId) {
107
+ if (hasPassStore()) {
108
+ try {
109
+ const prefix = passPrefix(userId);
110
+ const output = execFileSync('pass', ['ls', prefix], {
111
+ encoding: 'utf-8',
112
+ stdio: ['pipe', 'pipe', 'pipe'],
113
+ });
114
+ return output.split('\n')
115
+ .map(line => line.replace(/[│├└──\s]/g, '').replace(/\.gpg$/, '').trim())
116
+ .filter(Boolean);
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
122
+ const secrets = loadSecretsJson(userId);
123
+ return Object.keys(secrets);
124
+ }
125
+
126
+ module.exports = { storeSecret, readSecret, removeSecret, listSecrets, hasPassStore, validateKey };