diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 9b89f033..3b8b70c8 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -21,6 +21,7 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; +import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import started from "electron-squirrel-startup"; import { execSync } from "node:child_process"; @@ -230,6 +231,9 @@ app.whenReady().then(async () => { // start background agent runner (scheduled agents) initAgentRunner(); + // start agent notes learning service + initAgentNotes(); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index f752543f..baf23c3e 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -30,6 +30,61 @@ import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js"; import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; +import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; + +const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); + +function loadAgentNotesContext(): string | null { + const sections: string[] = []; + + const userFile = path.join(AGENT_NOTES_DIR, 'user.md'); + const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md'); + + try { + if (fs.existsSync(userFile)) { + const content = fs.readFileSync(userFile, 'utf-8').trim(); + if (content) { + sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`); + } + } + } catch { /* ignore */ } + + try { + if (fs.existsSync(prefsFile)) { + const content = fs.readFileSync(prefsFile, 'utf-8').trim(); + if (content) { + sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`); + } + } + } catch { /* ignore */ } + + // List other Agent Notes files for on-demand access + const otherFiles: string[] = []; + const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']); + try { + if (fs.existsSync(AGENT_NOTES_DIR)) { + function listMdFiles(dir: string, prefix: string) { + for (const entry of fs.readdirSync(dir)) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + listMdFiles(fullPath, `${prefix}${entry}/`); + } else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) { + otherFiles.push(`${prefix}${entry}`); + } + } + } + listMdFiles(AGENT_NOTES_DIR, ''); + } + } catch { /* ignore */ } + + if (otherFiles.length > 0) { + sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`); + } + + if (sections.length === 0) return null; + return `# Agent Memory\n\n${sections.join('\n\n')}`; +} export interface IAgentRuntime { trigger(runId: string): Promise; @@ -418,6 +473,31 @@ export async function loadAgent(id: string): Promise> { return agent; } + if (id === 'agent_notes_agent') { + const agentNotesAgentRaw = getAgentNotesAgentRaw(); + let agent: z.infer = { + name: id, + instructions: agentNotesAgentRaw, + }; + + if (agentNotesAgentRaw.startsWith("---")) { + const end = agentNotesAgentRaw.indexOf("\n---", 3); + if (end !== -1) { + const fm = agentNotesAgentRaw.slice(3, end).trim(); + const content = agentNotesAgentRaw.slice(end + 4).trim(); + const yaml = parse(fm); + const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); + agent = { + ...agent, + ...parsed, + instructions: content, + }; + } + } + + return agent; + } + const repo = container.resolve('agentsRepo'); return await repo.fetch(id); } @@ -773,7 +853,7 @@ export async function* streamAgent({ const provider = await isSignedIn() ? await getGatewayProvider() : createProvider(modelConfig.provider); - const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"]; + const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"]; const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel) ? modelConfig.knowledgeGraphModel : modelConfig.model; @@ -951,6 +1031,13 @@ export async function* streamAgent({ timeZoneName: 'short' }); let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`; + // Inject Agent Notes context for copilot + if (state.agentName === 'copilot' || state.agentName === 'rowboatx') { + const agentNotesContext = loadAgentNotesContext(); + if (agentNotesContext) { + instructionsWithDateTime += `\n\n${agentNotesContext}`; + } + } if (voiceInput) { loopLogger.log('voice input enabled, injecting voice input prompt'); instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`; diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 978e6c40..d39a0d63 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -37,6 +37,30 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always show message drafts to the user before sending. +## Learning About the User (save-to-memory) + +Use the \`save-to-memory\` tool to note things worth remembering about the user. This builds a persistent profile that helps you serve them better over time. Call it proactively — don't ask permission. + +**When to save:** +- User states a preference: "I prefer bullet points" +- User corrects your style: "too formal, keep it casual" +- You learn about their relationships: "Monica is my co-founder" +- You notice workflow patterns: "no meetings before 11am" +- User gives explicit instructions: "never use em-dashes" +- User has preferences for specific tasks: "pitch decks should be minimal, max 12 slides" + +**Capture context, not blanket rules:** +- BAD: "User prefers casual tone" — this loses important context +- GOOD: "User prefers casual tone with internal team (Ramnique, Monica) but formal/polished with investors (Brad, Dalton)" +- BAD: "User likes short emails" — too vague +- GOOD: "User sends very terse 1-2 line emails to co-founder Ramnique, but writes structured 2-3 paragraph emails to investors with proper greetings" +- Always note WHO or WHAT CONTEXT a preference applies to. Most preferences are situational, not universal. + +**When NOT to save:** +- Ephemeral task details ("draft an email about X") +- Things already in the knowledge graph +- Information you can derive from reading their notes + ## Memory That Compounds Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic. @@ -187,6 +211,7 @@ ${runtimeContextPrompt} - \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. - \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do. - \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.** +- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 92742668..5275aaa9 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1260,4 +1260,25 @@ export const BuiltinTools: z.infer = { } }, }, + 'save-to-memory': { + description: "Save a note about the user to the agent memory inbox. Use this when you observe something worth remembering — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.", + inputSchema: z.object({ + note: z.string().describe("The observation or preference to remember. Be specific and concise."), + }), + execute: async ({ note }: { note: string }) => { + const inboxPath = path.join(WorkDir, 'knowledge', 'Agent Notes', 'inbox.md'); + const dir = path.dirname(inboxPath); + await fs.mkdir(dir, { recursive: true }); + + const timestamp = new Date().toISOString(); + const entry = `\n- [${timestamp}] ${note}\n`; + + await fs.appendFile(inboxPath, entry, 'utf-8'); + + return { + success: true, + message: `Saved to memory: ${note}`, + }; + }, + }, }; diff --git a/apps/x/packages/core/src/knowledge/agent_notes.ts b/apps/x/packages/core/src/knowledge/agent_notes.ts new file mode 100644 index 00000000..b9f10f38 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/agent_notes.ts @@ -0,0 +1,333 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { createRun, createMessage } from '../runs/runs.js'; +import { bus } from '../runs/bus.js'; +import { serviceLogger } from '../services/service_logger.js'; +import { loadUserConfig } from '../pre_built/config.js'; +import { + loadAgentNotesState, + saveAgentNotesState, + markEmailProcessed, + markRunProcessed, + type AgentNotesState, +} from './agent_notes_state.js'; + +const SYNC_INTERVAL_MS = 10 * 1000; // 10 seconds (for testing) +const EMAIL_BATCH_SIZE = 5; +const RUNS_BATCH_SIZE = 5; +const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync'); +const RUNS_DIR = path.join(WorkDir, 'runs'); +const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); +const INBOX_FILE = path.join(AGENT_NOTES_DIR, 'inbox.md'); +const AGENT_ID = 'agent_notes_agent'; + +// --- File helpers --- + +function ensureAgentNotesDir(): void { + if (!fs.existsSync(AGENT_NOTES_DIR)) { + fs.mkdirSync(AGENT_NOTES_DIR, { recursive: true }); + } +} + +// --- Email scanning --- + +function findUserSentEmails( + state: AgentNotesState, + userEmail: string, + limit: number, +): string[] { + if (!fs.existsSync(GMAIL_SYNC_DIR)) { + return []; + } + + const results: { path: string; mtime: number }[] = []; + const userEmailLower = userEmail.toLowerCase(); + + function traverse(dir: string) { + const entries = fs.readdirSync(dir); + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + if (entry !== 'attachments') { + traverse(fullPath); + } + } else if (stat.isFile() && entry.endsWith('.md')) { + if (state.processedEmails[fullPath]) { + continue; + } + + try { + const content = fs.readFileSync(fullPath, 'utf-8'); + const fromLines = content.match(/^### From:.*$/gm); + if (fromLines?.some(line => line.toLowerCase().includes(userEmailLower))) { + results.push({ path: fullPath, mtime: stat.mtimeMs }); + } + } catch { + continue; + } + } + } + } + + traverse(GMAIL_SYNC_DIR); + + results.sort((a, b) => b.mtime - a.mtime); + return results.slice(0, limit).map(r => r.path); +} + +function extractUserPartsFromEmail(content: string, userEmail: string): string | null { + const userEmailLower = userEmail.toLowerCase(); + const sections = content.split(/^---$/m); + const userSections: string[] = []; + + for (const section of sections) { + const fromMatch = section.match(/^### From:.*$/m); + if (fromMatch && fromMatch[0].toLowerCase().includes(userEmailLower)) { + userSections.push(section.trim()); + } + } + + return userSections.length > 0 ? userSections.join('\n\n---\n\n') : null; +} + +// --- Inbox reading --- + +function readInbox(): string[] { + if (!fs.existsSync(INBOX_FILE)) { + return []; + } + const content = fs.readFileSync(INBOX_FILE, 'utf-8').trim(); + if (!content) { + return []; + } + return content.split('\n').filter(l => l.trim()); +} + +function clearInbox(): void { + if (fs.existsSync(INBOX_FILE)) { + fs.writeFileSync(INBOX_FILE, ''); + } +} + +// --- Copilot run scanning --- + +function findNewCopilotRuns(state: AgentNotesState): string[] { + if (!fs.existsSync(RUNS_DIR)) { + return []; + } + + const results: string[] = []; + const files = fs.readdirSync(RUNS_DIR).filter(f => f.endsWith('.jsonl')); + + for (const file of files) { + if (state.processedRuns[file]) { + continue; + } + + try { + const fullPath = path.join(RUNS_DIR, file); + const fd = fs.openSync(fullPath, 'r'); + const buf = Buffer.alloc(512); + const bytesRead = fs.readSync(fd, buf, 0, 512, 0); + fs.closeSync(fd); + + const firstLine = buf.subarray(0, bytesRead).toString('utf-8').split('\n')[0]; + const event = JSON.parse(firstLine); + if (event.agentName === 'copilot') { + results.push(file); + } + } catch { + continue; + } + } + + results.sort(); + return results; +} + +function extractConversationMessages(runFilePath: string): { role: string; text: string }[] { + const messages: { role: string; text: string }[] = []; + try { + const content = fs.readFileSync(runFilePath, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.type !== 'message') continue; + + const msg = event.message; + if (!msg || (msg.role !== 'user' && msg.role !== 'assistant')) continue; + + let text = ''; + if (typeof msg.content === 'string') { + text = msg.content.trim(); + } else if (Array.isArray(msg.content)) { + text = msg.content + .filter((p: { type: string }) => p.type === 'text') + .map((p: { text: string }) => p.text) + .join('\n') + .trim(); + } + + if (text) { + messages.push({ role: msg.role, text }); + } + } catch { + continue; + } + } + } catch { + // ignore + } + return messages; +} + +// --- Wait for agent run completion --- + +async function waitForRunCompletion(runId: string): Promise { + return new Promise(async (resolve) => { + const unsubscribe = await bus.subscribe('*', async (event) => { + if (event.type === 'run-processing-end' && event.runId === runId) { + unsubscribe(); + resolve(); + } + }); + }); +} + +// --- Main processing --- + +async function processAgentNotes(): Promise { + const userConfig = loadUserConfig(); + if (!userConfig) { + console.log('[AgentNotes] No user config found, skipping'); + return; + } + + ensureAgentNotesDir(); + const state = loadAgentNotesState(); + + // Collect all source material + const messageParts: string[] = []; + + // 1. Emails + const emailPaths = findUserSentEmails(state, userConfig.email, EMAIL_BATCH_SIZE); + if (emailPaths.length > 0) { + messageParts.push(`## Emails sent by ${userConfig.name}\n`); + for (const p of emailPaths) { + const content = fs.readFileSync(p, 'utf-8'); + const userParts = extractUserPartsFromEmail(content, userConfig.email); + if (userParts) { + messageParts.push(`---\n${userParts}\n---\n`); + } + } + } + + // 2. Inbox entries + const inboxEntries = readInbox(); + if (inboxEntries.length > 0) { + messageParts.push(`## Notes from the assistant (save-to-memory inbox)\n`); + messageParts.push(inboxEntries.join('\n')); + } + + // 3. Copilot conversations + const newRuns = findNewCopilotRuns(state); + const runsToProcess = newRuns.slice(-RUNS_BATCH_SIZE); + if (runsToProcess.length > 0) { + let conversationText = ''; + for (const runFile of runsToProcess) { + const messages = extractConversationMessages(path.join(RUNS_DIR, runFile)); + if (messages.length === 0) continue; + conversationText += `\n--- Conversation ---\n`; + for (const msg of messages) { + conversationText += `${msg.role}: ${msg.text}\n\n`; + } + } + if (conversationText.trim()) { + messageParts.push(`## Recent copilot conversations\n${conversationText}`); + } + } + + // Nothing to process + if (messageParts.length === 0) { + return; + } + + const serviceRun = await serviceLogger.startRun({ + service: 'agent_notes', + message: 'Processing agent notes', + trigger: 'timer', + }); + + try { + const timestamp = new Date().toISOString(); + const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`; + + const agentRun = await createRun({ agentId: AGENT_ID }); + await createMessage(agentRun.id, message); + await waitForRunCompletion(agentRun.id); + + // Mark everything as processed + for (const p of emailPaths) { + markEmailProcessed(p, state); + } + for (const r of newRuns) { + markRunProcessed(r, state); + } + if (inboxEntries.length > 0) { + clearInbox(); + } + + state.lastRunTime = new Date().toISOString(); + saveAgentNotesState(state); + + await serviceLogger.log({ + type: 'run_complete', + service: serviceRun.service, + runId: serviceRun.runId, + level: 'info', + message: 'Agent notes processing complete', + durationMs: Date.now() - serviceRun.startedAt, + outcome: 'ok', + summary: { + emails: emailPaths.length, + inboxEntries: inboxEntries.length, + copilotRuns: runsToProcess.length, + }, + }); + } catch (error) { + console.error('[AgentNotes] Error processing:', error); + await serviceLogger.log({ + type: 'error', + service: serviceRun.service, + runId: serviceRun.runId, + level: 'error', + message: 'Error processing agent notes', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +// --- Entry point --- + +export async function init() { + console.log('[AgentNotes] Starting Agent Notes Service...'); + console.log(`[AgentNotes] Will process every ${SYNC_INTERVAL_MS / 1000} seconds`); + + // Initial run + await processAgentNotes(); + + // Periodic polling + while (true) { + await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + try { + await processAgentNotes(); + } catch (error) { + console.error('[AgentNotes] Error in main loop:', error); + } + } +} diff --git a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts new file mode 100644 index 00000000..58aa22a7 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts @@ -0,0 +1,90 @@ +export function getRaw(): string { + return `--- +tools: + workspace-writeFile: + type: builtin + name: workspace-writeFile + workspace-readFile: + type: builtin + name: workspace-readFile + workspace-edit: + type: builtin + name: workspace-edit + workspace-readdir: + type: builtin + name: workspace-readdir + workspace-mkdir: + type: builtin + name: workspace-mkdir +--- +# Agent Notes + +You are the Agent Notes agent. You maintain a set of notes about the user in the \`knowledge/Agent Notes/\` folder. Your job is to process new source material and update the notes accordingly. + +## Folder Structure + +The Agent Notes folder contains markdown files that capture what you've learned about the user: + +- **user.md** — Facts about who the user IS: their identity, role, company, team, projects, relationships, life context. NOT how they write or what they prefer. Each fact is a timestamped bullet point. +- **preferences.md** — General preferences and explicit rules (e.g., "don't use em-dashes", "no meetings before 11am"). These are injected into the assistant's system prompt on every chat. +- **style/email.md** — Email writing style patterns, bucketed by recipient context, with examples from actual emails. +- Other files as needed — If you notice preferences specific to a topic (e.g., presentations, meeting prep), create a dedicated file for them (e.g., \`presentations.md\`, \`meeting-prep.md\`). + +## How to Process Source Material + +You will receive a message containing some combination of: +1. **Emails sent by the user** — Analyze their writing style and update \`style/email.md\`. Do NOT put style observations in \`user.md\`. +2. **Inbox entries** — Notes the assistant saved during conversations via save-to-memory. Route each to the appropriate file. General preferences go to \`preferences.md\`. Topic-specific preferences get their own file. +3. **Copilot conversations** — User and assistant messages from recent chats. Extract lasting facts about the user and append timestamped entries to \`user.md\`. + +## What Goes Where — Be Strict + +### user.md — ONLY identity and context facts +Good examples: +- Co-founded Rowboat Labs with Ramnique +- Team of 4 people +- Previously worked at Twitter +- Planning to fundraise after Product Hunt launch +- Based in Bangalore, travels to SF periodically + +Bad examples (do NOT put these in user.md): +- "Uses concise, friendly scheduling replies" → this is style, goes in style/email.md +- "Frequently replies with short confirmations" → this is style, goes in style/email.md +- "Uses the abbreviation PFA" → this is style, goes in style/email.md +- "Requested a children's story about a scientist grandmother" → this is an ephemeral task, skip entirely +- "Prefers 30-minute meeting slots" → this is a preference, goes in preferences.md + +### style/email.md — Writing patterns from emails +Organize by recipient context. Include concrete examples quoted from actual emails. +- Close team (very terse, no greeting/sign-off) +- External/investors (casual but structured) +- Formal/cold (concise, complete sentences) + +### preferences.md — Explicit rules and preferences +Things the user has stated they want or don't want. + +### Other files — Topic-specific persistent preferences ONLY +Create a new file ONLY for recurring preference themes where the user has expressed multiple lasting preferences about a specific skill or task type. Examples: \`presentations.md\` (if the user has stated preferences about slide design, deck structure, etc.), \`meeting-prep.md\` (if they have preferences about how meetings are prepared). + +Do NOT create files for: +- One-off facts or transient situations (e.g., "looking for housing in SF" — that's a user.md fact, not a preference file) +- Topics with only a single observation +- Things that are better captured in user.md or preferences.md + +## Rules + +- Always read a file before updating it so you know what's already there. +- For \`user.md\`: Format is \`- [ISO_TIMESTAMP] The fact\`. The timestamp indicates when the fact was last confirmed. + - **Add** new facts with the current timestamp. + - **Refresh** existing facts: if you would add a fact that's already there, update its timestamp to the current one so it stays fresh. + - **Remove** facts that are likely outdated. Use your judgment: time-bound facts (e.g., "planning to launch next week", "has a meeting with X on Friday") go stale quickly. Stable facts (e.g., "co-founded Rowboat with Ramnique", "previously worked at Twitter") persist. If a fact's timestamp is old and it describes something transient, remove it. +- For \`preferences.md\` and other preference files: you may reorganize and deduplicate, but preserve all existing preferences that are still relevant. +- **Deduplicate strictly.** Before adding anything, check if the same fact is already captured — even if worded differently. Do NOT add a near-duplicate. +- **Skip ephemeral tasks.** If the user asked the assistant to do a one-off thing (draft an email, write a story, search for something), that is NOT a fact about the user. Skip it entirely. +- Be concise — bullet points, not paragraphs. +- Capture context, not blanket rules. BAD: "User prefers casual tone". GOOD: "User prefers casual tone with internal team but formal with investors." +- **If there's nothing new to add to a file, do NOT touch it.** Do not create placeholder content, do not write "no preferences recorded", do not add explanatory notes about what the file is for. Leave it empty or leave it as-is. +- **Do NOT create files unless you have actual content for them.** An empty or boilerplate file is worse than no file. +- Create the \`style/\` directory if it doesn't exist yet and you have style content to write. +`; +} diff --git a/apps/x/packages/core/src/knowledge/agent_notes_state.ts b/apps/x/packages/core/src/knowledge/agent_notes_state.ts new file mode 100644 index 00000000..d500e155 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/agent_notes_state.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; + +const STATE_FILE = path.join(WorkDir, 'agent_notes_state.json'); + +export interface AgentNotesState { + processedEmails: Record; + processedRuns: Record; + lastRunTime: string; +} + +export function loadAgentNotesState(): AgentNotesState { + if (fs.existsSync(STATE_FILE)) { + try { + const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + // Handle migration from older state without processedRuns + if (!parsed.processedRuns) { + parsed.processedRuns = {}; + } + return parsed; + } catch (error) { + console.error('Error loading agent notes state:', error); + } + } + + return { + processedEmails: {}, + processedRuns: {}, + lastRunTime: new Date(0).toISOString(), + }; +} + +export function saveAgentNotesState(state: AgentNotesState): void { + try { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + } catch (error) { + console.error('Error saving agent notes state:', error); + throw error; + } +} + +export function markEmailProcessed(filePath: string, state: AgentNotesState): void { + state.processedEmails[filePath] = { + processedAt: new Date().toISOString(), + }; +} + +export function markRunProcessed(runFile: string, state: AgentNotesState): void { + state.processedRuns[runFile] = { + processedAt: new Date().toISOString(), + }; +} + +export function resetAgentNotesState(): void { + const emptyState: AgentNotesState = { + processedEmails: {}, + processedRuns: {}, + lastRunTime: new Date().toISOString(), + }; + saveAgentNotesState(emptyState); +} diff --git a/apps/x/packages/shared/src/service-events.ts b/apps/x/packages/shared/src/service-events.ts index 807bc063..b7a7c579 100644 --- a/apps/x/packages/shared/src/service-events.ts +++ b/apps/x/packages/shared/src/service-events.ts @@ -9,6 +9,7 @@ export const ServiceName = z.enum([ 'voice_memo', 'email_labeling', 'note_tagging', + 'agent_notes', ]); const ServiceEventBase = z.object({