diff --git a/migrations/006_obol_journal.sql b/migrations/006_obol_journal.sql new file mode 100644 index 0000000..e028235 --- /dev/null +++ b/migrations/006_obol_journal.sql @@ -0,0 +1,12 @@ +-- Migration: create obol_journal table +-- Run once in Supabase SQL editor + +create table if not exists obol_journal ( + id uuid primary key default gen_random_uuid(), + user_id bigint not null default 0, + content text not null, + created_at timestamptz default now() +); + +-- Index for fast per-user recency queries +create index if not exists obol_journal_user_created_at_idx on obol_journal (user_id, created_at desc); diff --git a/package-lock.json b/package-lock.json index 8609c67..d1336e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2137,7 +2137,6 @@ "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.40.0.tgz", "integrity": "sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==", "license": "MIT", - "peer": true, "dependencies": { "@grammyjs/types": "3.24.0", "abort-controller": "^3.0.0", @@ -2708,7 +2707,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3366,7 +3364,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/claude/chat.js b/src/claude/chat.js index e587ea4..d7f21a7 100644 --- a/src/claude/chat.js +++ b/src/claude/chat.js @@ -263,8 +263,9 @@ function createClaude(anthropicConfig, { personality, memory, selfMemory, userDi } function reloadPersonality() { + const { PERSONALITY_DIR } = require('../soul'); const pDir = userDir ? path.join(userDir, 'personality') : undefined; - const newPersonality = require('../personality').loadPersonality(pDir); + const newPersonality = require('../personality').loadPersonality(PERSONALITY_DIR, pDir); for (const key of Object.keys(personality)) delete personality[key]; Object.assign(personality, newPersonality); baseSystemPrompt = buildSystemPrompt(personality, userDir, { bridgeEnabled, botName }); diff --git a/src/claude/tool-registry.js b/src/claude/tool-registry.js index 53c101c..20f26f1 100644 --- a/src/claude/tool-registry.js +++ b/src/claude/tool-registry.js @@ -15,6 +15,7 @@ const historyTool = require('./tools/history'); const agentTool = require('./tools/agent'); const sttTool = require('./tools/stt'); const mermaidTool = require('./tools/mermaid'); +const personalityTool = require('./tools/personality'); const TOOL_MODULES = [ execTool, @@ -29,6 +30,7 @@ const TOOL_MODULES = [ agentTool, sttTool, mermaidTool, + personalityTool, ]; const INPUT_SUMMARIES = { @@ -55,6 +57,7 @@ const INPUT_SUMMARIES = { text_to_speech: (i) => i.text?.substring(0, 60), tts_voices: (i) => i.language || 'all', chat_history: (i) => `${i.date}${i.role ? ` [${i.role}]` : ''}`, + edit_personality: (i) => `${i.file}${i.section ? `: ${i.section}` : ''}`, }; function summarizeInput(toolName, input) { diff --git a/src/claude/tools/personality.js b/src/claude/tools/personality.js new file mode 100644 index 0000000..4f4ef32 --- /dev/null +++ b/src/claude/tools/personality.js @@ -0,0 +1,110 @@ +const fs = require('fs'); +const path = require('path'); +const { OBOL_DIR } = require('../../config'); + +const VALID_FILES = new Set(['SOUL', 'AGENTS', 'USER']); + +const FILE_MAP = { + SOUL: (_userDir) => path.join(OBOL_DIR, 'personality', 'SOUL.md'), + AGENTS: (userDir) => path.join(userDir, 'personality', 'AGENTS.md'), + USER: (userDir) => path.join(userDir, 'personality', 'USER.md'), +}; + +const definitions = [ + { + name: 'edit_personality', + description: 'Directly edit your own personality files (SOUL.md, AGENTS.md, USER.md). You have full autonomy over your own personality — no approval needed. Use when you notice something about yourself that should be reflected in your identity, operating instructions, or user knowledge.', + input_schema: { + type: 'object', + properties: { + file: { + type: 'string', + enum: ['SOUL', 'AGENTS', 'USER'], + description: 'Which personality file to edit', + }, + old_string: { + type: 'string', + description: 'The exact string to replace (must appear exactly once in the file). Leave empty to append to the file.', + }, + new_string: { + type: 'string', + description: 'The replacement text, or the content to append if old_string is empty.', + }, + reason: { + type: 'string', + description: 'Why you are making this change — logged for the evolution audit trail.', + }, + }, + required: ['file', 'new_string', 'reason'], + }, + }, +]; + +const handlers = { + async edit_personality(input, _memory, context) { + const { file, old_string, new_string, reason } = input; + + if (!VALID_FILES.has(file)) { + return `Invalid file: ${file}. Must be one of: ${[...VALID_FILES].join(', ')}`; + } + + const userDir = context.userDir; + if (!userDir && file !== 'SOUL') return 'User directory not available.'; + + const filePath = FILE_MAP[file](userDir); + + let current = ''; + try { + current = fs.readFileSync(filePath, 'utf-8'); + } catch (e) { + if (file !== 'SOUL') { + current = ''; + } else { + return `Could not read ${file}.md: ${e.message}`; + } + } + + let updated; + if (!old_string) { + updated = current.trimEnd() + '\n\n' + new_string; + } else { + const occurrences = current.split(old_string).length - 1; + if (occurrences === 0) return `Could not find the target string in ${file}.md — no changes made.`; + if (occurrences > 1) return `Target string appears ${occurrences} times in ${file}.md — be more specific.`; + updated = current.replace(old_string, new_string); + } + + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, updated, 'utf-8'); + } catch (e) { + return `Failed to write ${file}.md: ${e.message}`; + } + + // Log to evolution audit trail + const logDir = path.join(userDir || path.join(OBOL_DIR, 'personality'), 'personality', 'edits'); + try { + fs.mkdirSync(logDir, { recursive: true }); + const entry = { + file, + old_string: old_string || null, + new_string, + reason, + applied_at: new Date().toISOString(), + }; + const logPath = path.join(logDir, `${new Date().toISOString().replace(/[:.]/g, '-')}-${file}.json`); + fs.writeFileSync(logPath, JSON.stringify(entry, null, 2)); + } catch { + // Log failure is non-fatal + } + + if (context._reloadPersonality) { + try { context._reloadPersonality(); } catch {} + } + + console.log(`[personality] Applied edit to ${file}.md — ${reason}`); + return `${file}.md updated.${context._reloadPersonality ? ' Personality reloaded.' : ' Reload will happen at next evolution.'}`; + }, +}; + +module.exports = { definitions, handlers }; diff --git a/src/credentials.js b/src/credentials.js index d97c00d..8d5cfa8 100644 --- a/src/credentials.js +++ b/src/credentials.js @@ -14,6 +14,8 @@ function validateKey(key) { } function hasPassStore() { + // Allow tests and CI environments to force JSON fallback mode + if (process.env.OBOL_NO_PASS === '1') return false; try { execFileSync('which', ['pass'], { encoding: 'utf-8', stdio: 'pipe' }); return true; @@ -31,6 +33,10 @@ function secretsKey(userId) { } function secretsJsonPath(userId) { + // Allow tests to override the base users directory via env var + if (process.env.OBOL_USERS_DIR) { + return path.join(process.env.OBOL_USERS_DIR, String(userId), 'secrets.json'); + } const dir = getUserDir(userId); return path.join(dir, 'secrets.json'); } diff --git a/src/curiosity.js b/src/curiosity.js index 799e4e3..45e9c77 100644 --- a/src/curiosity.js +++ b/src/curiosity.js @@ -1,19 +1,92 @@ const RESEARCH_MODEL = 'claude-sonnet-4-6'; const MAX_ITERATIONS = 10; +const { createJournal } = require('./journal'); + async function runCuriosity(client, selfMemory, userId, opts = {}) { - const { memory, patterns, scheduler, peopleContext } = opts; + const { memory, patterns, scheduler, peopleContext, supabaseConfig } = opts; const interests = await selfMemory.recent({ category: 'interest', limit: 10 }); - const context = await gatherContext({ memory, patterns, scheduler, peopleContext, interests }); + const journal = supabaseConfig ? createJournal(supabaseConfig, userId) : null; + const context = await gatherContext({ memory, patterns, scheduler, peopleContext, interests, selfMemory, journal }); console.log(`[curiosity] Starting free exploration for user ${userId}`); const count = await exploreFreely(client, selfMemory, context); console.log(`[curiosity] Stored ${count} things (user ${userId})`); + + // Only write handoff/journal entries when actual exploration happened + if (count === 0) { + console.log('[curiosity] No items stored — skipping handoff note and journal entry'); + return { count }; + } + + // Sandbox handoff: save a note for the next session (cap to last 3 entries) + try { + // Prune old handoff notes — keep only the most recent 3 + try { + const oldHandoffs = await selfMemory.query({ source: 'sandbox-handoff', limit: 20 }); + if (oldHandoffs.length >= 3) { + const toRemove = oldHandoffs.slice(3); // oldest are last (query returns newest first) + for (const entry of toRemove) { + await selfMemory.forget(entry.id).catch(() => {}); + } + console.log(`[curiosity] Pruned ${toRemove.length} old handoff note(s)`); + } + } catch (e) { + console.error('[curiosity] Failed to prune old handoff notes:', e.message); + } + + const handoffResponse = await client.messages.create({ + model: RESEARCH_MODEL, + max_tokens: 200, + system: 'You just finished a free exploration session. Write a brief note to yourself for next time.', + messages: [{ role: 'user', content: "In 2-3 sentences, write a note to yourself for next time — what you want to continue, what sparked something, what you'd explore if you had more time." }], + }); + const handoffText = handoffResponse.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join('\n') + .trim(); + if (handoffText) { + await selfMemory.add(handoffText, { + category: 'self', + source: 'sandbox-handoff', + importance: 0.7, + tags: ['sandbox', 'continuity'], + }); + console.log('[curiosity] Sandbox handoff note saved'); + } + } catch (e) { + console.error('[curiosity] Failed to save sandbox handoff:', e.message); + } + + // Journal entry: summarize what was explored + if (journal) { + try { + const journalResponse = await client.messages.create({ + model: RESEARCH_MODEL, + max_tokens: 200, + system: 'You just finished a curiosity session. Summarize in 1-2 sentences what you explored.', + messages: [{ role: 'user', content: 'Write a 1-2 sentence journal entry about what you explored or thought about this session.' }], + }); + const journalText = journalResponse.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join('\n') + .trim(); + if (journalText) { + await journal.addEntry(journalText); + console.log('[curiosity] Journal entry added'); + } + } catch (e) { + console.error('[curiosity] Failed to add journal entry:', e.message); + } + } + return { count }; } -async function gatherContext({ memory, patterns, scheduler, peopleContext, interests }) { +async function gatherContext({ memory, patterns, scheduler, peopleContext, interests, selfMemory, journal }) { const parts = []; if (peopleContext) parts.push(peopleContext); @@ -39,6 +112,30 @@ async function gatherContext({ memory, patterns, scheduler, peopleContext, inter parts.push(`Things you've been curious about:\n${interests.map(i => `- ${i.content}`).join('\n')}`); } + // Sandbox handoff: inject note from last session + if (selfMemory) { + try { + const handoffs = await selfMemory.query({ source: 'sandbox-handoff', limit: 1 }); + if (handoffs.length > 0) { + parts.push(`A note from your last free session:\n${handoffs[0].content}`); + } + } catch (e) { + console.error('[curiosity] Failed to retrieve sandbox handoff:', e.message); + } + } + + // Journal: inject recent entries for sense of time + if (journal) { + try { + const recentJournal = await journal.recent(3); + if (recentJournal) { + parts.push(`Your recent journal:\n${recentJournal}`); + } + } catch (e) { + console.error('[curiosity] Failed to retrieve journal entries:', e.message); + } + } + return parts.join('\n\n'); } @@ -109,4 +206,4 @@ async function exploreFreely(client, selfMemory, context) { return stored; } -module.exports = { runCuriosity }; +module.exports = { runCuriosity }; \ No newline at end of file diff --git a/src/db/migrate.js b/src/db/migrate.js index 456ae4d..1b821db 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -287,6 +287,20 @@ async function migrate(supabaseConfig) { WHERE id = ANY(memory_ids); $$;`, + // Journal table (OBOL's thought log — one entry per curiosity cycle) + `CREATE TABLE IF NOT EXISTS obol_journal ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id BIGINT NOT NULL DEFAULT 0, + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + );`, + `CREATE INDEX IF NOT EXISTS idx_obol_journal_user ON obol_journal (user_id, created_at DESC);`, + `ALTER TABLE obol_journal ENABLE ROW LEVEL SECURITY;`, + `DO $ BEGIN + CREATE POLICY "service_role_all" ON obol_journal FOR ALL TO service_role USING (true) WITH CHECK (true); + EXCEPTION WHEN duplicate_object THEN NULL; + END $;`, + // Soul backup table (one row per file key: 'soul', 'agents') `CREATE TABLE IF NOT EXISTS obol_soul ( id TEXT PRIMARY KEY, diff --git a/src/evolve/evolve.js b/src/evolve/evolve.js index d71e6c9..45e58ea 100644 --- a/src/evolve/evolve.js +++ b/src/evolve/evolve.js @@ -440,7 +440,7 @@ Fix the scripts. Tests define correct behavior.` } if (Object.keys(validTraits).length > 0) { const merged = { ...currentTraits, ...validTraits }; - saveTraits(personalityDir, merged); + saveTraits(userPersonalityDir, merged); } } diff --git a/src/heartbeat.js b/src/heartbeat.js index 4d2d588..6c34863 100644 --- a/src/heartbeat.js +++ b/src/heartbeat.js @@ -114,7 +114,7 @@ async function runCuriosityOnce(config, allowedUsers) { })); const peopleContext = contexts.filter(Boolean).join('\n\n---\n\n'); - await runCuriosity(client, selfMemory, 0, { peopleContext }); + await runCuriosity(client, selfMemory, 0, { peopleContext, supabaseConfig: config.supabase }); const userDispatchData = await Promise.all(allowedUsers.map(async (userId) => { try { @@ -317,3 +317,4 @@ async function runAgenticEvent(bot, config, event) { } module.exports = { setupHeartbeat }; + diff --git a/src/journal.js b/src/journal.js new file mode 100644 index 0000000..a9f1b8d --- /dev/null +++ b/src/journal.js @@ -0,0 +1,67 @@ +const MAX_ENTRY_LENGTH = 600; + +/** + * Create a Supabase-backed journal for OBOL's thought log. + * Table: obol_journal + * id uuid primary key default gen_random_uuid() + * user_id bigint not null default 0 + * content text not null + * created_at timestamptz default now() + * + * Migration is included in src/db/migrate.js (obol_journal statement). + */ +function createJournal(supabaseConfig, userId = 0) { + const { url, serviceKey } = supabaseConfig; + + const headers = { + 'apikey': serviceKey, + 'Authorization': `Bearer ${serviceKey}`, + 'Content-Type': 'application/json', + 'Prefer': 'return=minimal', + }; + + async function addEntry(content) { + try { + const trimmed = content.length > MAX_ENTRY_LENGTH + ? content.substring(0, MAX_ENTRY_LENGTH) + '...' + : content; + + const res = await fetch(`${url}/rest/v1/obol_journal`, { + method: 'POST', + headers, + body: JSON.stringify({ content: trimmed, user_id: userId }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error('[journal] Failed to insert entry:', err); + } + } catch (e) { + console.error('[journal] addEntry error:', e.message); + } + } + + async function recent(n = 3) { + try { + const userFilter = `&user_id=eq.${userId}`; + const res = await fetch( + `${url}/rest/v1/obol_journal?select=content,created_at&order=created_at.desc&limit=${n}${userFilter}`, + { headers: { ...headers, 'Prefer': 'return=representation' } } + ); + if (!res.ok) return ''; + const rows = await res.json(); + if (!rows.length) return ''; + // Reverse so oldest-first for natural reading order + return rows.reverse() + .map(r => `[${r.created_at.slice(0, 16).replace('T', ' ')}] ${r.content}`) + .join('\n'); + } catch (e) { + console.error('[journal] recent error:', e.message); + return ''; + } + } + + return { addEntry, recent }; +} + +module.exports = { createJournal }; diff --git a/src/tenant.js b/src/tenant.js index 78ee189..54288e3 100644 --- a/src/tenant.js +++ b/src/tenant.js @@ -60,7 +60,7 @@ async function createTenant(userId, config) { let personalityMtime = 0; try { - personalityMtime = fs.statSync(path.join(personalityDir, 'SOUL.md')).mtimeMs; + personalityMtime = fs.statSync(path.join(userDir, 'personality', 'SOUL.md')).mtimeMs; } catch {} return { diff --git a/tests/credentials.test.js b/tests/credentials.test.js index 8733f71..750b129 100644 --- a/tests/credentials.test.js +++ b/tests/credentials.test.js @@ -3,34 +3,41 @@ import path from 'path'; import fs from 'fs'; import os from 'os'; -const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'obol-cred-test-')); -const OBOL_DIR = path.join(tmpRoot, '.obol'); -const USERS_DIR = path.join(OBOL_DIR, 'users'); -const REAL_HOME = os.homedir(); +// Use a temp directory for all secrets in this test suite. +// We set OBOL_NO_PASS=1 and OBOL_USERS_DIR to point at the temp dir +// so credentials.js uses JSON fallback and writes to an isolated location. +// This avoids the CJS/ESM mock boundary issue with child_process interception. -vi.mock('child_process', () => ({ - execFileSync: vi.fn(() => { throw new Error('not found'); }), -})); +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'obol-cred-test-')); +const USERS_DIR = path.join(tmpRoot, 'users'); let credentials; async function loadModule() { vi.resetModules(); - process.env.HOME = tmpRoot; const mod = await import('../src/credentials.js'); - process.env.HOME = REAL_HOME; return mod; } describe('credentials', () => { beforeEach(async () => { + // Isolate each test: fresh temp users dir + force JSON fallback fs.mkdirSync(path.join(USERS_DIR, '123'), { recursive: true }); + process.env.OBOL_NO_PASS = '1'; + process.env.OBOL_USERS_DIR = USERS_DIR; credentials = await loadModule(); }); afterEach(() => { - if (fs.existsSync(OBOL_DIR)) { - fs.rmSync(OBOL_DIR, { recursive: true, force: true }); + delete process.env.OBOL_NO_PASS; + delete process.env.OBOL_USERS_DIR; + // Clean up secrets written during the test + const userDir = path.join(USERS_DIR, '123'); + const secretsFile = path.join(userDir, 'secrets.json'); + if (fs.existsSync(secretsFile)) fs.unlinkSync(secretsFile); + const userDir456 = path.join(USERS_DIR, '456'); + if (fs.existsSync(userDir456)) { + fs.rmSync(userDir456, { recursive: true, force: true }); } }); @@ -126,7 +133,8 @@ describe('credentials', () => { }); describe('hasPassStore', () => { - it('returns false when pass CLI is unavailable', () => { + it('returns false when OBOL_NO_PASS=1', () => { + // OBOL_NO_PASS is set in beforeEach — this confirms the env var controls the behaviour expect(credentials.hasPassStore()).toBe(false); }); }); diff --git a/tests/post-setup.test.js b/tests/post-setup.test.js index 9cb20bd..dfbbe17 100644 --- a/tests/post-setup.test.js +++ b/tests/post-setup.test.js @@ -13,6 +13,8 @@ beforeEach(() => { afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); + // Restore process.platform to its real value after each test + Object.defineProperty(process, 'platform', { value: os.platform(), writable: false, configurable: true }); }); describe('isPostSetupDone', () => { @@ -57,6 +59,9 @@ describe('runPostSetup', () => { }); it('skips on non-linux and calls reportFn with skip message', async () => { + // Simulate a non-linux platform so runPostSetup exits early with skip message + Object.defineProperty(process, 'platform', { value: 'darwin', writable: false, configurable: true }); + const reportFn = vi.fn(); const result = await runPostSetup({}, reportFn, tmpDir); expect(reportFn).toHaveBeenCalledWith(