mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
Memory2 (#444)
Agent Notes: background memory system that learns from emails, chats, and explicit saves - Background service (agent_notes.ts) runs periodically, collecting user-sent emails, copilot conversation history, and save-to-memory inbox entries - Agent (agent_notes_agent.ts) processes all sources with workspace tools, deciding what to update: user.md (timestamped facts), preferences.md (general rules), style/email.md (writing patterns), and topic-specific files as needed - save-to-memory builtin tool lets the copilot proactively note preferences during conversations - user.md and preferences.md injected into copilot system prompt on every turn; other files listed for on-demand access - Agent manages timestamp freshness on user.md: refreshes confirmed facts, removes stale transient ones
This commit is contained in:
parent
c41586b85d
commit
d191c00e4d
8 changed files with 624 additions and 1 deletions
|
|
@ -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 initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.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 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 { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
@ -230,6 +231,9 @@ app.whenReady().then(async () => {
|
||||||
// start background agent runner (scheduled agents)
|
// start background agent runner (scheduled agents)
|
||||||
initAgentRunner();
|
initAgentRunner();
|
||||||
|
|
||||||
|
// start agent notes learning service
|
||||||
|
initAgentNotes();
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
|
||||||
|
|
@ -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 getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_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 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 {
|
export interface IAgentRuntime {
|
||||||
trigger(runId: string): Promise<void>;
|
trigger(runId: string): Promise<void>;
|
||||||
|
|
@ -418,6 +473,31 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === 'agent_notes_agent') {
|
||||||
|
const agentNotesAgentRaw = getAgentNotesAgentRaw();
|
||||||
|
let agent: z.infer<typeof Agent> = {
|
||||||
|
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<IAgentsRepo>('agentsRepo');
|
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||||
return await repo.fetch(id);
|
return await repo.fetch(id);
|
||||||
}
|
}
|
||||||
|
|
@ -773,7 +853,7 @@ export async function* streamAgent({
|
||||||
const provider = await isSignedIn()
|
const provider = await isSignedIn()
|
||||||
? await getGatewayProvider()
|
? await getGatewayProvider()
|
||||||
: createProvider(modelConfig.provider);
|
: 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)
|
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||||
? modelConfig.knowledgeGraphModel
|
? modelConfig.knowledgeGraphModel
|
||||||
: modelConfig.model;
|
: modelConfig.model;
|
||||||
|
|
@ -951,6 +1031,13 @@ export async function* streamAgent({
|
||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
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) {
|
if (voiceInput) {
|
||||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
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.`;
|
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.`;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**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
|
## 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.
|
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.
|
- \`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.
|
- \`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.**
|
- \`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\`.
|
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1260,4 +1260,25 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'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}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
333
apps/x/packages/core/src/knowledge/agent_notes.ts
Normal file
333
apps/x/packages/core/src/knowledge/agent_notes.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
90
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
|
|
@ -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.
|
||||||
|
`;
|
||||||
|
}
|
||||||
62
apps/x/packages/core/src/knowledge/agent_notes_state.ts
Normal file
62
apps/x/packages/core/src/knowledge/agent_notes_state.ts
Normal file
|
|
@ -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<string, { processedAt: string }>;
|
||||||
|
processedRuns: Record<string, { processedAt: string }>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ export const ServiceName = z.enum([
|
||||||
'voice_memo',
|
'voice_memo',
|
||||||
'email_labeling',
|
'email_labeling',
|
||||||
'note_tagging',
|
'note_tagging',
|
||||||
|
'agent_notes',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ServiceEventBase = z.object({
|
const ServiceEventBase = z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue