Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions migrations/006_obol_journal.sql
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 0 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/claude/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions src/claude/tool-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@ const TOOL_MODULES = [
agentTool,
sttTool,
mermaidTool,
personalityTool,
];

const INPUT_SUMMARIES = {
Expand All @@ -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) {
Expand Down
110 changes: 110 additions & 0 deletions src/claude/tools/personality.js
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 6 additions & 0 deletions src/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
}
Expand Down
105 changes: 101 additions & 4 deletions src/curiosity.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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');
}

Expand Down Expand Up @@ -109,4 +206,4 @@ async function exploreFreely(client, selfMemory, context) {
return stored;
}

module.exports = { runCuriosity };
module.exports = { runCuriosity };
14 changes: 14 additions & 0 deletions src/db/migrate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/evolve/evolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/heartbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -317,3 +317,4 @@ async function runAgenticEvent(bot, config, event) {
}

module.exports = { setupHeartbeat };

Loading