mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-01 03:16:29 +02:00
moved to a full agent
This commit is contained in:
parent
8e55eaa613
commit
6028fda2db
5 changed files with 218 additions and 406 deletions
|
|
@ -30,6 +30,7 @@ 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');
|
||||
|
||||
|
|
@ -57,6 +58,30 @@ function loadAgentNotesContext(): string | null {
|
|||
}
|
||||
} 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')}`;
|
||||
}
|
||||
|
|
@ -448,6 +473,31 @@ export async function loadAgent(id: string): Promise<z.infer<typeof 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');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
|
@ -803,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;
|
||||
|
|
|
|||
|
|
@ -42,11 +42,12 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
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" → save as preference
|
||||
- User corrects your style: "too formal, keep it casual" → save as style
|
||||
- You learn about their relationships: "Monica is my co-founder" → save as people
|
||||
- You notice workflow patterns: "no meetings before 11am" → save as routine
|
||||
- User gives explicit instructions: "never use em-dashes" → save as preference
|
||||
- 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
|
||||
|
|
@ -60,12 +61,6 @@ Use the \`save-to-memory\` tool to note things worth remembering about the user.
|
|||
- Things already in the knowledge graph
|
||||
- Information you can derive from reading their notes
|
||||
|
||||
**Categories:**
|
||||
- \`preference\` — rules about how they want things done
|
||||
- \`style\` — writing and communication patterns (always note the context: who, what type of communication)
|
||||
- \`people\` — relationship context and per-person tone
|
||||
- \`routine\` — scheduling, workflow, recurring patterns
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1261,24 +1261,23 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
'save-to-memory': {
|
||||
description: "Save a note about user preferences, style, people, or routines to the agent memory inbox. Use this when you observe something worth remembering about the user — their preferences, communication patterns, relationship context, scheduling habits, or explicit instructions about how they want things done.",
|
||||
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."),
|
||||
category: z.enum(['preference', 'style', 'people', 'routine']).describe("Category: 'preference' for rules/preferences, 'style' for writing/communication patterns, 'people' for relationship context, 'routine' for scheduling/workflow patterns"),
|
||||
}),
|
||||
execute: async ({ note, category }: { note: string; category: string }) => {
|
||||
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}] [${category}] ${note}\n`;
|
||||
const entry = `\n- [${timestamp}] ${note}\n`;
|
||||
|
||||
await fs.appendFile(inboxPath, entry, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Saved to memory inbox: [${category}] ${note}`,
|
||||
message: `Saved to memory: ${note}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { getGatewayProvider } from '../models/gateway.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 {
|
||||
|
|
@ -23,65 +19,17 @@ 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 STYLE_DIR = path.join(AGENT_NOTES_DIR, 'style');
|
||||
const INBOX_FILE = path.join(AGENT_NOTES_DIR, 'inbox.md');
|
||||
|
||||
const NOTE_FILES = {
|
||||
preferences: path.join(AGENT_NOTES_DIR, 'preferences.md'),
|
||||
writingStyle: path.join(STYLE_DIR, 'writing.md'),
|
||||
emailStyle: path.join(STYLE_DIR, 'email.md'),
|
||||
slackStyle: path.join(STYLE_DIR, 'slack.md'),
|
||||
documentsStyle: path.join(STYLE_DIR, 'documents.md'),
|
||||
people: path.join(AGENT_NOTES_DIR, 'people.md'),
|
||||
routines: path.join(AGENT_NOTES_DIR, 'routines.md'),
|
||||
user: path.join(AGENT_NOTES_DIR, 'user.md'),
|
||||
};
|
||||
|
||||
const CATEGORY_TO_FILE: Record<string, string[]> = {
|
||||
preference: [NOTE_FILES.preferences],
|
||||
style: [NOTE_FILES.writingStyle],
|
||||
people: [NOTE_FILES.people],
|
||||
routine: [NOTE_FILES.routines],
|
||||
};
|
||||
|
||||
// --- LLM helpers ---
|
||||
|
||||
async function getModel() {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const provider = await isSignedIn()
|
||||
? await getGatewayProvider()
|
||||
: createProvider(config.provider);
|
||||
const modelId = config.knowledgeGraphModel || config.model;
|
||||
return provider.languageModel(modelId);
|
||||
}
|
||||
|
||||
function stripCodeFences(text: string): string {
|
||||
return text
|
||||
.replace(/^```(?:markdown|md)?\s*\n?/, '')
|
||||
.replace(/\n?```\s*$/, '')
|
||||
.trim();
|
||||
}
|
||||
const AGENT_ID = 'agent_notes_agent';
|
||||
|
||||
// --- File helpers ---
|
||||
|
||||
function ensureAgentNotesDir(): void {
|
||||
for (const dir of [AGENT_NOTES_DIR, STYLE_DIR]) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(AGENT_NOTES_DIR)) {
|
||||
fs.mkdirSync(AGENT_NOTES_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readNoteFile(filePath: string): string {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return '';
|
||||
}
|
||||
|
||||
// --- Email scanning ---
|
||||
|
||||
function findUserSentEmails(
|
||||
|
|
@ -145,35 +93,17 @@ function extractUserPartsFromEmail(content: string, userEmail: string): string |
|
|||
return userSections.length > 0 ? userSections.join('\n\n---\n\n') : null;
|
||||
}
|
||||
|
||||
// --- Inbox processing ---
|
||||
// --- Inbox reading ---
|
||||
|
||||
interface InboxEntry {
|
||||
timestamp: string;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
function readInbox(): InboxEntry[] {
|
||||
const content = readNoteFile(INBOX_FILE);
|
||||
if (!content.trim()) {
|
||||
function readInbox(): string[] {
|
||||
if (!fs.existsSync(INBOX_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: InboxEntry[] = [];
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^- \[([^\]]+)\] \[([^\]]+)\] (.+)$/);
|
||||
if (match) {
|
||||
entries.push({
|
||||
timestamp: match[1],
|
||||
category: match[2],
|
||||
note: match[3],
|
||||
});
|
||||
}
|
||||
const content = fs.readFileSync(INBOX_FILE, 'utf-8').trim();
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries;
|
||||
return content.split('\n').filter(l => l.trim());
|
||||
}
|
||||
|
||||
function clearInbox(): void {
|
||||
|
|
@ -182,112 +112,6 @@ function clearInbox(): void {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Note file updates (single LLM call per file) ---
|
||||
|
||||
async function updateNoteFile(
|
||||
filePath: string,
|
||||
noteDescription: string,
|
||||
sourceContent: string,
|
||||
): Promise<void> {
|
||||
const model = await getModel();
|
||||
const existing = readNoteFile(filePath);
|
||||
|
||||
const system = `You maintain a personal knowledge file about a user. Your job is to update this file by integrating new source material.
|
||||
|
||||
Rules:
|
||||
- Preserve all existing content that is still relevant
|
||||
- Add new insights from the source material
|
||||
- Deduplicate: if an insight is already captured, do not add it again
|
||||
- Refine existing observations when new evidence supports a more nuanced version
|
||||
- Keep the file well-organized with clear markdown headings and bullet points
|
||||
- Be concise — prefer bullet points over paragraphs
|
||||
- If the file is empty, create initial structure appropriate for: ${noteDescription}
|
||||
- Output ONLY the complete updated file content, no commentary or explanation`;
|
||||
|
||||
const prompt = `## Current file content:
|
||||
${existing || '(empty — this is a new file)'}
|
||||
|
||||
## New source material to integrate:
|
||||
${sourceContent}
|
||||
|
||||
Return the complete updated file:`;
|
||||
|
||||
const result = await generateText({ model, system, prompt });
|
||||
const text = stripCodeFences(result.text);
|
||||
fs.writeFileSync(filePath, text);
|
||||
}
|
||||
|
||||
// --- Email style processing ---
|
||||
|
||||
async function updateEmailStyle(
|
||||
emailFiles: { path: string; content: string }[],
|
||||
userName: string,
|
||||
userEmail: string,
|
||||
): Promise<void> {
|
||||
let sourceContent = `Emails written by ${userName}:\n\n`;
|
||||
for (const file of emailFiles) {
|
||||
const userParts = extractUserPartsFromEmail(file.content, userEmail);
|
||||
if (userParts) {
|
||||
sourceContent += `---\n${userParts}\n---\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
await updateNoteFile(
|
||||
NOTE_FILES.emailStyle,
|
||||
'Email writing style patterns — voice, tone, formatting, sign-offs, bucketed by recipient context. Include concrete examples.',
|
||||
sourceContent,
|
||||
);
|
||||
|
||||
await updateNoteFile(
|
||||
NOTE_FILES.writingStyle,
|
||||
'General voice and tone patterns across all writing',
|
||||
sourceContent,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inbox processing ---
|
||||
|
||||
async function processInbox(entries: InboxEntry[]): Promise<number> {
|
||||
if (entries.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Group entries by category
|
||||
const grouped = new Map<string, InboxEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const category = entry.category;
|
||||
if (!grouped.has(category)) {
|
||||
grouped.set(category, []);
|
||||
}
|
||||
grouped.get(category)!.push(entry);
|
||||
}
|
||||
|
||||
// Update each relevant note file
|
||||
for (const [category, categoryEntries] of grouped) {
|
||||
const targetFiles = CATEGORY_TO_FILE[category];
|
||||
if (!targetFiles) {
|
||||
console.log(`[AgentNotes] Unknown category: ${category}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceContent = `Observations from conversations:\n\n${categoryEntries.map(e => `- ${e.note}`).join('\n')}`;
|
||||
|
||||
for (const targetFile of targetFiles) {
|
||||
const description = targetFile === NOTE_FILES.preferences
|
||||
? 'Hard rules and explicit preferences — always loaded for context'
|
||||
: targetFile === NOTE_FILES.writingStyle
|
||||
? 'General voice and tone patterns across all writing'
|
||||
: targetFile === NOTE_FILES.people
|
||||
? 'Per-person relationship context, tone preferences, and interaction notes'
|
||||
: 'Scheduling patterns, workflow habits, recurring tasks';
|
||||
|
||||
await updateNoteFile(targetFile, description, sourceContent);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
// --- Copilot run scanning ---
|
||||
|
||||
function findNewCopilotRuns(state: AgentNotesState): string[] {
|
||||
|
|
@ -320,15 +144,10 @@ function findNewCopilotRuns(state: AgentNotesState): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort chronologically (filenames are timestamps), newest last
|
||||
results.sort();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only user and assistant text messages from a run file.
|
||||
* Skips tool calls, tool results, system messages, and any non-text content.
|
||||
*/
|
||||
function extractConversationMessages(runFilePath: string): { role: string; text: string }[] {
|
||||
const messages: { role: string; text: string }[] = [];
|
||||
try {
|
||||
|
|
@ -347,7 +166,6 @@ function extractConversationMessages(runFilePath: string): { role: string; text:
|
|||
if (typeof msg.content === 'string') {
|
||||
text = msg.content.trim();
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Only extract text parts, skip tool-call parts
|
||||
text = msg.content
|
||||
.filter((p: { type: string }) => p.type === 'text')
|
||||
.map((p: { text: string }) => p.text)
|
||||
|
|
@ -368,85 +186,17 @@ function extractConversationMessages(runFilePath: string): { role: string; text:
|
|||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process copilot runs and append new facts to user.md.
|
||||
* Each fact is a timestamped line. The LLM decides what's new vs already known.
|
||||
*/
|
||||
async function updateUserNotes(runFiles: string[]): Promise<number> {
|
||||
if (runFiles.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
// --- Wait for agent run completion ---
|
||||
|
||||
// Collect conversations from runs (limit to RUNS_BATCH_SIZE)
|
||||
const runsToProcess = runFiles.slice(-RUNS_BATCH_SIZE);
|
||||
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()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const model = await getModel();
|
||||
const existing = readNoteFile(NOTE_FILES.user);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const system = `You analyze conversations between a user and their AI assistant to learn facts about the user.
|
||||
|
||||
Your job: extract any new, non-trivial facts about the user that are worth remembering long-term.
|
||||
|
||||
Examples of good facts:
|
||||
- Working on Project X, an AI assistant product
|
||||
- Team is 4 people, co-founder is Ramnique
|
||||
- Preparing for Series A fundraise
|
||||
- Based in Bangalore, India
|
||||
- Prefers to work late evenings
|
||||
- Has a meeting with Brad from Smash Capital next week
|
||||
|
||||
Examples of things NOT to extract:
|
||||
- Ephemeral task details ("user asked to draft an email")
|
||||
- Facts the assistant already knows from tools/knowledge graph
|
||||
- Obvious or trivial observations ("user uses a computer")
|
||||
|
||||
Output format: Return ONLY new facts as a bullet list, one per line. Each line should be:
|
||||
- [${timestamp}] The fact
|
||||
|
||||
If there are no new facts worth noting, return exactly: NO_NEW_FACTS
|
||||
|
||||
IMPORTANT: Check the existing user notes below. Do NOT repeat facts that are already captured there (even if worded differently).`;
|
||||
|
||||
const prompt = `## Existing user notes:
|
||||
${existing || '(none yet)'}
|
||||
|
||||
## Recent conversations to analyze:
|
||||
${conversationText}
|
||||
|
||||
Extract new facts (or return NO_NEW_FACTS):`;
|
||||
|
||||
const result = await generateText({ model, system, prompt });
|
||||
const text = stripCodeFences(result.text).trim();
|
||||
|
||||
if (text === 'NO_NEW_FACTS' || !text) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Append new facts to user.md
|
||||
const header = existing ? '' : '# User\n\n';
|
||||
const newContent = existing
|
||||
? existing.trimEnd() + '\n' + text + '\n'
|
||||
: header + text + '\n';
|
||||
fs.writeFileSync(NOTE_FILES.user, newContent);
|
||||
|
||||
// Count lines added
|
||||
return text.split('\n').filter(l => l.trim().startsWith('-')).length;
|
||||
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 ---
|
||||
|
|
@ -461,147 +211,112 @@ async function processAgentNotes(): Promise<void> {
|
|||
ensureAgentNotesDir();
|
||||
const state = loadAgentNotesState();
|
||||
|
||||
const run = await serviceLogger.startRun({
|
||||
// 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',
|
||||
});
|
||||
|
||||
let hadError = false;
|
||||
let emailsProcessed = 0;
|
||||
let inboxProcessed = 0;
|
||||
let userFactsAdded = 0;
|
||||
|
||||
// --- Email Style Learning ---
|
||||
try {
|
||||
const emailPaths = findUserSentEmails(state, userConfig.email, EMAIL_BATCH_SIZE);
|
||||
if (emailPaths.length > 0) {
|
||||
console.log(`[AgentNotes] Found ${emailPaths.length} new emails with user content`);
|
||||
await serviceLogger.log({
|
||||
type: 'progress',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Analyzing ${emailPaths.length} emails for style`,
|
||||
step: 'email_style',
|
||||
current: 1,
|
||||
total: 3,
|
||||
});
|
||||
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 emailFiles = emailPaths.map(p => ({
|
||||
path: p,
|
||||
content: fs.readFileSync(p, 'utf-8'),
|
||||
}));
|
||||
const agentRun = await createRun({ agentId: AGENT_ID });
|
||||
await createMessage(agentRun.id, message);
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
|
||||
await updateEmailStyle(emailFiles, userConfig.name, userConfig.email);
|
||||
|
||||
for (const p of emailPaths) {
|
||||
markEmailProcessed(p, state);
|
||||
}
|
||||
saveAgentNotesState(state);
|
||||
emailsProcessed = emailPaths.length;
|
||||
// Mark everything as processed
|
||||
for (const p of emailPaths) {
|
||||
markEmailProcessed(p, state);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error('[AgentNotes] Error processing emails:', error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: 'Error processing email style',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Inbox Processing ---
|
||||
try {
|
||||
const entries = readInbox();
|
||||
if (entries.length > 0) {
|
||||
console.log(`[AgentNotes] Found ${entries.length} inbox entries`);
|
||||
await serviceLogger.log({
|
||||
type: 'progress',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Processing ${entries.length} inbox entries`,
|
||||
step: 'inbox',
|
||||
current: 2,
|
||||
total: 3,
|
||||
});
|
||||
|
||||
inboxProcessed = await processInbox(entries);
|
||||
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) {
|
||||
hadError = true;
|
||||
console.error('[AgentNotes] Error processing inbox:', error);
|
||||
console.error('[AgentNotes] Error processing:', error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
service: serviceRun.service,
|
||||
runId: serviceRun.runId,
|
||||
level: 'error',
|
||||
message: 'Error processing inbox',
|
||||
message: 'Error processing agent notes',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Copilot Run Learning (user.md) ---
|
||||
try {
|
||||
const newRuns = findNewCopilotRuns(state);
|
||||
if (newRuns.length > 0) {
|
||||
console.log(`[AgentNotes] Found ${newRuns.length} new copilot runs`);
|
||||
await serviceLogger.log({
|
||||
type: 'progress',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'info',
|
||||
message: `Analyzing ${newRuns.length} copilot runs for user facts`,
|
||||
step: 'copilot_runs',
|
||||
current: 3,
|
||||
total: 3,
|
||||
});
|
||||
|
||||
userFactsAdded = await updateUserNotes(newRuns);
|
||||
|
||||
for (const r of newRuns) {
|
||||
markRunProcessed(r, state);
|
||||
}
|
||||
saveAgentNotesState(state);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error('[AgentNotes] Error processing copilot runs:', error);
|
||||
await serviceLogger.log({
|
||||
type: 'error',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: 'error',
|
||||
message: 'Error processing copilot runs',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
state.lastRunTime = new Date().toISOString();
|
||||
saveAgentNotesState(state);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run.service,
|
||||
runId: run.runId,
|
||||
level: hadError ? 'error' : 'info',
|
||||
message: 'Agent notes processing complete',
|
||||
durationMs: Date.now() - run.startedAt,
|
||||
outcome: hadError ? 'error' : 'ok',
|
||||
summary: { emailsProcessed, inboxProcessed, userFactsAdded },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Entry point ---
|
||||
|
||||
export async function init() {
|
||||
console.log('[AgentNotes] Starting Agent Notes Service...');
|
||||
console.log(`[AgentNotes] Will process every ${SYNC_INTERVAL_MS / 60000} minutes`);
|
||||
console.log(`[AgentNotes] Will process every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
await processAgentNotes();
|
||||
|
|
|
|||
53
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
53
apps/x/packages/core/src/knowledge/agent_notes_agent.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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 the user: who they are, what they're working on, their team, their context. Each fact is a timestamped bullet point.
|
||||
- **preferences.md** — General preferences and 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.
|
||||
- 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\`
|
||||
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 facts about the user and append timestamped entries to \`user.md\`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Always read a file before updating it so you know what's already there.
|
||||
- For \`user.md\`: append new timestamped facts. Do NOT rewrite or remove existing entries. Format: \`- [ISO_TIMESTAMP] The fact\`
|
||||
- For \`preferences.md\` and other preference files: you may reorganize and deduplicate, but preserve all existing preferences that are still relevant.
|
||||
- For \`style/email.md\`: organize by recipient context (close team, investors/external, formal/cold). Include concrete examples from the emails.
|
||||
- Do NOT add facts that are already captured (even if worded differently).
|
||||
- Do NOT extract ephemeral task details ("user asked to draft an email").
|
||||
- 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, don't modify files unnecessarily.
|
||||
- Create the \`style/\` directory if it doesn't exist yet.
|
||||
`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue