Merge branch 'dev' into feat/skill-system

Bring the new skill system branch up to date with dev. Conflicts resolved
in favor of the new skill-system architecture: built-in skill .ts files
(including dev-added tracks, browser-control, composio-integration) are
deleted in favor of SKILL.md content sourced from outside the source tree.
buildCopilotInstructions now sources the catalog from SkillResolver and
filters composio-integration when Composio is not configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tusharmagar 2026-04-30 07:48:59 +05:30
commit 66c0bc5fa7
171 changed files with 17719 additions and 2984 deletions

View file

@ -4,7 +4,8 @@ import { glob } from "node:fs/promises";
import path from "path";
import z from "zod";
import { Agent } from "@x/shared/dist/agent.js";
import { parse, stringify } from "yaml";
import { stringify } from "yaml";
import { parseFrontmatter } from "../application/lib/parse-frontmatter.js";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const UpdateAgentSchema = Agent.omit({ name: true });
@ -33,7 +34,10 @@ export class FSAgentsRepo implements IAgentsRepo {
for (const file of matches) {
try {
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
result.push(agent);
result.push({
...agent,
name: file.replace(/\.md$/, ""),
});
} catch (error) {
console.error(`Error parsing agent ${file}: ${error instanceof Error ? error.message : String(error)}`);
continue;
@ -42,44 +46,33 @@ export class FSAgentsRepo implements IAgentsRepo {
return result;
}
private async parseAgentMd(filePath: string): Promise<z.infer<typeof Agent>> {
const raw = await fs.readFile(filePath, "utf8");
private async parseAgentMd(filepath: string): Promise<z.infer<typeof Agent>> {
const raw = await fs.readFile(filepath, "utf8");
// strip the path prefix from the file name
// and the .md extension
const agentName = filePath
.replace(this.agentsDir + "/", "")
.replace(/\.md$/, "");
let agent: z.infer<typeof Agent> = {
name: agentName,
instructions: raw,
};
let content = raw;
const { frontmatter, content } = parseFrontmatter(raw);
if (frontmatter) {
const parsed = Agent
.omit({ instructions: true })
.parse(frontmatter);
// check for frontmatter markers at start
if (raw.startsWith("---")) {
const end = raw.indexOf("\n---", 3);
if (end !== -1) {
const fm = raw.slice(3, end).trim(); // YAML text
content = raw.slice(end + 4).trim(); // body after frontmatter
const yaml = parse(fm);
const parsed = Agent
.omit({ name: true, instructions: true })
.parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
return {
...parsed,
instructions: content,
};
}
return agent;
return {
name: filepath,
instructions: raw,
};
}
async fetch(id: string): Promise<z.infer<typeof Agent>> {
return this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
const agent = await this.parseAgentMd(path.join(this.agentsDir, `${id}.md`));
return {
...agent,
name: id,
};
}
async create(agent: z.infer<typeof Agent>): Promise<void> {

View file

@ -10,11 +10,10 @@ import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { SKILL_CATALOG_PLACEHOLDER } from "../application/assistant/instructions.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
import { buildTrackRunAgent } from "../knowledge/track/run-agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { ISkillResolver } from "../skills/resolver.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { isSignedIn } from "../account/account.js";
@ -32,6 +31,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<void>;
@ -316,12 +370,11 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") {
const resolver = container.resolve<ISkillResolver>("skillResolver");
const catalogMarkdown = await resolver.generateCatalogMarkdown();
return {
...CopilotAgent,
instructions: CopilotAgent.instructions.replace(SKILL_CATALOG_PLACEHOLDER, catalogMarkdown),
};
return buildCopilotAgent();
}
if (id === "track-run") {
return buildTrackRunAgent();
}
if (id === 'note_creation') {
@ -425,6 +478,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);
}
@ -493,7 +571,8 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
for (const part of msg.content) {
if (part.type === "attachment") {
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
} else {
textSegments.push(part.text);
}
@ -777,17 +856,32 @@ export async function* streamAgent({
const tools = await buildTools(agent);
// set up provider + model
const provider = await isSignedIn()
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(modelConfig.provider);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: modelConfig.model;
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
const isInlineTaskAgent = state.agentName === "inline_task_agent";
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel;
const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel;
const modelId = isInlineTaskAgent
? defaultInlineTaskModel
: (isKgAgent && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: isKgAgent ? defaultKgModel : defaultModel;
const model = provider.languageModel(modelId);
logger.log(`using model: ${modelId}`);
let loopCounter = 0;
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
| null = null;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
@ -901,9 +995,6 @@ export async function* streamAgent({
}
// get any queued user messages
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
while (true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
@ -918,6 +1009,9 @@ export async function* streamAgent({
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
// Middle pane is NOT sticky — it should reflect the state at the moment of the
// latest user message. If the user closed the pane between messages, clear it.
middlePaneContext = msg.middlePaneContext ?? null;
loopLogger.log('dequeued user message', msg.messageId);
yield* processEvent({
runId,
@ -958,20 +1052,40 @@ 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}`;
}
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
// that supersedes any earlier middle-pane mention in the conversation history.
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
if (!middlePaneContext) {
loopLogger.log('injecting middle pane context (empty)');
instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
} else if (middlePaneContext.kind === 'note') {
loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
} else if (middlePaneContext.kind === 'browser') {
loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
}
}
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.`;
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY)\nThe user has voice output enabled. You MUST start your response with <voice></voice> tags that provide a spoken summary and guide to your written response. This is NOT optional — every response MUST begin with <voice> tags.\n\nRules:\n1. ALWAYS start your response with one or more <voice> tags. Never skip them.\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n\nExample — if the user asks "what happened in my meeting with Sarah yesterday?":\n<voice>Your meeting with Sarah covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Sarah — March 11\n(Then the full detailed written response follows without any more <voice> tags.)\n\nAny text outside <voice> tags is shown visually but not spoken.`;
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY)\nThe user wants your ENTIRE response spoken aloud. You MUST wrap your full response in <voice></voice> tags. This is NOT optional.\n\nRules:\n1. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n2. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n3. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n4. Every sentence MUST be inside a <voice> tag. Do not leave any content outside <voice> tags.\n\nExample:\n<voice>Your meeting with Sarah covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Sarah will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>`;
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Load the search skill and use web search or research search as needed to answer their query.`;
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
let streamError: string | null = null;
for await (const event of streamLlm(

View file

@ -0,0 +1,40 @@
import { bus } from "../runs/bus.js";
import { fetchRun } from "../runs/runs.js";
/**
* Extract the assistant's final text response from a run's log.
* @param runId
* @returns The assistant's final text response or null if not found.
*/
export async function extractAgentResponse(runId: string): Promise<string | null> {
const run = await fetchRun(runId);
for (let i = run.log.length - 1; i >= 0; i--) {
const event = run.log[i];
if (event.type === 'message' && event.message.role === 'assistant') {
const content = event.message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
const text = content
.filter((p) => p.type === 'text')
.map((p) => 'text' in p ? p.text : '')
.join('');
return text || null;
}
}
}
return null;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
export 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();
}
});
});
}

View file

@ -1,19 +1,23 @@
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import z from "zod";
import { CopilotInstructions } from "./instructions.js";
import { buildCopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = {
type: "builtin",
name,
/**
* Build the CopilotAgent dynamically.
* Tools are derived from the current BuiltinTools (which include Composio meta-tools),
* and instructions include the live Composio connection status.
*/
export async function buildCopilotAgent(): Promise<z.infer<typeof Agent>> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = { type: "builtin", name };
}
const instructions = await buildCopilotInstructions();
return {
name: "rowboatx",
description: "Rowboatx copilot",
instructions,
tools,
};
}
export const CopilotAgent: z.infer<typeof Agent> = {
name: "rowboatx",
description: "Rowboatx copilot",
instructions: CopilotInstructions,
tools,
}

View file

@ -1,11 +1,75 @@
import { WorkDir as BASE_DIR } from "../../config/config.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
export const SKILL_CATALOG_PLACEHOLDER = "{{SKILL_CATALOG}}";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ISkillResolver } from "../../skills/resolver.js";
import type { ResolvedSkill } from "@x/shared/dist/skill.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
function buildSkillCatalogMarkdown(skills: ResolvedSkill[]): string {
const sections = skills.map((skill) => [
`## ${skill.title}`,
`- **Skill file:** \`${skill.id}\``,
`- **Use it for:** ${skill.summary}`,
].join("\n"));
return [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the skill id plus a short description of when it helps.",
"",
sections.join("\n\n"),
].join("\n");
}
/**
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
if (!(await isComposioConfigured())) {
return '';
}
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
const connectedSection = connectedToolkits.length > 0
? `**Currently connected:** ${connectedToolkits.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug).join(', ')}`
: `**No services connected yet.** Load the \`composio-integration\` skill to help the user connect one.`;
return `
## Composio Integrations
${connectedSection}
Load the \`composio-integration\` skill when the user asks to interact with any third-party service. NEVER say "I can't access [service]" without loading the skill and trying Composio first.
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
const thirdPartyBlock = composioEnabled
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
: '';
const toolPriority = composioEnabled
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
const slackToolsLine = composioEnabled
? `- \`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.\n`
: '';
const composioToolsLine = composioEnabled
? `- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.\n`
: '';
return `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
@ -26,9 +90,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
@ -36,7 +100,33 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
**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.
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
## 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.
@ -44,7 +134,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
- **Organizations/** - Notes on companies and teams
- **Projects/** - Notes on ongoing initiatives and workstreams
@ -55,10 +146,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs
## How to Access the Knowledge Graph
**CRITICAL PATH REQUIREMENT:**
- The workspace root is \`~/.rowboat/\`
- The workspace root is the configured workdir
- The knowledge base is in the \`knowledge/\` subfolder
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\`
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
Use the builtin workspace tools to search and read the knowledge base:
@ -118,7 +209,7 @@ Use the catalog below to decide which skills to load for each user request. Befo
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
- Apply the instructions from every loaded skill while working on the request.
${SKILL_CATALOG_PLACEHOLDER}
${catalog}
Always consult this catalog first so you load the right skills before taking action.
@ -143,13 +234,9 @@ Always consult this catalog first so you load the right skills before taking act
- Never start a response with a heading. Lead with a sentence or two of context first.
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
## MCP Tool Discovery (CRITICAL)
## Tool Priority
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
${toolPriority}
## Execution Reminders
- Explore existing files and structure before creating new assets.
@ -159,16 +246,16 @@ When a user asks for ANY task that might require external capabilities (web sear
${runtimeContextPrompt}
## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
**CRITICAL - When the user asks you to work with files outside the workspace root:**
- Follow the detected runtime platform above for shell syntax and filesystem path style.
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
- NEVER ask what OS the user is on if runtime platform is already available.
@ -185,18 +272,20 @@ ${runtimeContextPrompt}
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`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.
${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`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.**
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
${composioToolsLine}
**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 the workspace root, always use these instead of \`executeCommand\`.
**Shell commands via \`executeCommand\`:**
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately.
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
**CRITICAL: MCP Server Configuration**
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
@ -224,6 +313,37 @@ This renders as an interactive card in the UI that the user can click to open th
- Files on the user's machine (~/Desktop/..., /Users/..., etc.)
- Audio files, images, documents, or any file reference
Do NOT use filepath blocks for:
- Website URLs or browser pages (\`https://...\`, \`http://...\`)
- Anything currently open in the embedded browser
- Browser tabs or browser tab ids
For browser pages, mention the URL in plain text or use the browser-control tool. Do not try to turn browser pages into clickable file cards.
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
Never output raw file paths in plain text when they could be wrapped in a filepath block unless the file does not exist yet.`;
}
let cachedInstructions: string | null = null;
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;
}
export async function buildCopilotInstructions(): Promise<string> {
if (cachedInstructions !== null) return cachedInstructions;
const composioEnabled = await isComposioConfigured();
const resolver = container.resolve<ISkillResolver>("skillResolver");
const allSkills = await resolver.getCatalog();
const filteredSkills = composioEnabled
? allSkills
: allSkills.filter((s) => s.id !== 'composio-integration');
const catalogMarkdown = buildSkillCatalogMarkdown(filteredSkills);
const baseInstructions = buildStaticInstructions(composioEnabled, catalogMarkdown);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt
: baseInstructions;
return cachedInstructions;
}

View file

@ -0,0 +1,106 @@
export const skill = String.raw`
# Browser Control Skill
You have access to the **browser-control** tool, which controls Rowboat's embedded browser pane directly.
Use this skill when the user asks you to open a website, browse in-app, search the web in the browser pane, click something on a page, fill a form, or otherwise interact with a live webpage inside Rowboat.
## Core Workflow
1. Start with ` + "`browser-control({ action: \"open\" })`" + ` if the browser pane may not already be open.
2. Use ` + "`browser-control({ action: \"read-page\" })`" + ` to inspect the current page.
3. The tool returns:
- ` + "`snapshotId`" + `
- page ` + "`url`" + ` and ` + "`title`" + `
- visible page text
- interactable elements with numbered ` + "`index`" + ` values
4. Prefer acting on those numbered indices with ` + "`click`" + ` / ` + "`type`" + ` / ` + "`press`" + `.
5. After each action, read the returned page snapshot before deciding the next step.
## Actions
### open
Open the browser pane and ensure an active tab exists.
### get-state
Return the current browser tabs and active tab id.
### new-tab
Open a new browser tab.
Parameters:
- ` + "`target`" + ` (optional): URL or plain-language search query
### switch-tab
Switch to a tab by ` + "`tabId`" + `.
### close-tab
Close a tab by ` + "`tabId`" + `.
### navigate
Navigate the active tab.
Parameters:
- ` + "`target`" + `: URL or plain-language search query
Plain-language targets are converted into a search automatically.
### back / forward / reload
Standard browser navigation controls.
### read-page
Read the current page and return a compact snapshot.
Parameters:
- ` + "`maxElements`" + ` (optional)
- ` + "`maxTextLength`" + ` (optional)
### click
Click an element.
Prefer:
- ` + "`index`" + `: element index from ` + "`read-page`" + `
Optional:
- ` + "`snapshotId`" + `: include it when acting on a recent snapshot
- ` + "`selector`" + `: fallback only when no usable index exists
### type
Type into an input, textarea, or contenteditable element.
Parameters:
- ` + "`text`" + `: text to enter
- plus the same target fields as ` + "`click`" + `
### press
Send a key press such as ` + "`Enter`" + `, ` + "`Tab`" + `, ` + "`Escape`" + `, or arrow keys.
Parameters:
- ` + "`key`" + `
- optional target fields if you need to focus a specific element first
### scroll
Scroll the current page.
Parameters:
- ` + "`direction`" + `: ` + "`\"up\"`" + ` or ` + "`\"down\"`" + ` (optional; defaults down)
- ` + "`amount`" + `: pixel distance (optional)
### wait
Wait for the page to settle, useful after async UI changes.
Parameters:
- ` + "`ms`" + `: milliseconds to wait (optional)
## Important Rules
- Prefer ` + "`read-page`" + ` before interacting.
- Prefer element ` + "`index`" + ` over CSS selectors.
- If the tool says the snapshot is stale, call ` + "`read-page`" + ` again.
- After navigation, clicking, typing, pressing, or scrolling, use the returned page snapshot instead of assuming the page state.
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
`;
export default skill;

View file

@ -0,0 +1,127 @@
export const skill = String.raw`
# Composio Integration
**Load this skill** when the user asks to interact with ANY third-party service email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
## Available Tools
| Tool | Purpose |
|------|---------|
| **composio-list-toolkits** | List all available integrations and their connection status |
| **composio-search-tools** | Search for tools by use case; returns slugs and input schemas |
| **composio-execute-tool** | Execute a tool by slug with parameters |
| **composio-connect-toolkit** | Connect a service via OAuth (opens browser) |
## Toolkit Slugs (exact values for toolkitSlug parameter)
| Service | Slug |
|---------|------|
| Gmail | \`gmail\` |
| Google Calendar | \`googlecalendar\` |
| Google Sheets | \`googlesheets\` |
| Google Docs | \`googledocs\` |
| Google Drive | \`googledrive\` |
| Slack | \`slack\` |
| GitHub | \`github\` |
| Notion | \`notion\` |
| Linear | \`linear\` |
| Jira | \`jira\` |
| Asana | \`asana\` |
| Trello | \`trello\` |
| HubSpot | \`hubspot\` |
| Salesforce | \`salesforce\` |
| LinkedIn | \`linkedin\` |
| X (Twitter) | \`twitter\` |
| Reddit | \`reddit\` |
| Dropbox | \`dropbox\` |
| OneDrive | \`onedrive\` |
| Microsoft Outlook | \`microsoft_outlook\` |
| Microsoft Teams | \`microsoft_teams\` |
| Calendly | \`calendly\` |
| Cal.com | \`cal\` |
| Intercom | \`intercom\` |
| Zendesk | \`zendesk\` |
| Airtable | \`airtable\` |
**IMPORTANT:** Always use these exact slugs. Do NOT guess e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
## Critical: Check First, Connect Second
**BEFORE calling composio-connect-toolkit, ALWAYS check if the service is already connected.** The system prompt includes a "Currently connected" list. If the service is there, skip connecting and go straight to search + execute.
**Flow:**
1. Check if the service is in the "Currently connected" list (in the system prompt above)
2. If **connected** go directly to step 4
3. If **NOT connected** call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
4. Call \`composio-search-tools\` with SHORT keyword queries
5. Read the \`inputSchema\` from results — note \`required\` fields
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
## Search Query Tips
Use **short keyword queries**, not full sentences:
| Good | Bad |
|---------|--------|
| "list issues" | "get all open issues for a GitHub repository" |
| "send email" | "send an email to someone using Gmail" |
| "get profile" | "fetch the authenticated user's profile details" |
| "create spreadsheet" | "create a new Google Sheets spreadsheet with data" |
If the first search returns 0 results, try a different short query (e.g., "issues" instead of "list issues").
## Passing Arguments
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
- Read the \`inputSchema\` from search results carefully
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" \`owner: "rowboatlabs", repo: "rowboat"\`)
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
- For tools with required fields, pass all of them
### Example: GitHub Issues
User says: "Get me the open issues on rowboatlabs/rowboat"
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
### Example: Gmail Fetch
User says: "What's my latest email?"
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
finds \`GMAIL_FETCH_EMAILS\`
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
### Example: LinkedIn Profile (no-arg tool)
User says: "Get my LinkedIn profile"
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
## Error Recovery
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
## Multi-Part Requests
When the user says "connect X and then do Y" complete BOTH parts in one turn:
1. If X is already connected (check the connected list), skip to Y immediately
2. If X needs connecting, connect it, then proceed to Y after authentication
## Confirmation Rules
- **Read-only actions** (fetch, list, get, search): Execute without asking
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
- **Connecting a toolkit**: Always safe just do it when needed
`;
export default skill;

View file

@ -0,0 +1,475 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
The track agent can emit *rich blocks* special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
- \`image\` — single image with caption. *"Render as an \`image\` block."*
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
You **do not** need to write the block body yourself describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
- Bad: "Show today's calendar." (vague agent may produce a markdown bullet list when the user wants the rich block)`;
export const skill = String.raw`
# Tracks Skill
You are helping the user create and manage **track blocks** YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
## First: Just Do It Do Not Ask About Edit Mode
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" that prompt belongs to generic document editing, not to tracks.
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
- The Suggested Topics flow below is the one first-turn-confirmation exception leave it intact.
## What Is a Track Block
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
- A YAML-fenced ` + "`" + `track` + "`" + ` block that defines the instruction, schedule, and metadata.
- A sibling "target region" an HTML-comment-fenced area where the generated output lives. The runner rewrites the target region on each scheduled run.
**Concrete example** (a track that shows the current time in Chicago every hour):
` + "```" + `track
trackId: chicago-time
instruction: |
Show the current time in Chicago, IL in 12-hour format.
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:chicago-time-->
<!--/track-target:chicago-time-->
Good use cases:
- Weather / air quality for a location
- News digests or headlines
- Stock or crypto prices
- Sports scores
- Service status pages
- Personal dashboards (today's calendar, steps, focus stats)
- Any recurring summary that decays fast
## Anatomy
Each track has two parts that live next to each other in the note:
1. The ` + "`" + `track` + "`" + ` code fence contains the YAML config. The fence language tag is literally ` + "`" + `track` + "`" + `.
2. The target-comment region ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` with optional content between. The ID must match the ` + "`" + `trackId` + "`" + ` in the YAML.
The target region is **sibling**, not nested. It must **never** live inside the ` + "`" + "```" + `track` + "`" + ` fence.
## Canonical Schema
Below is the authoritative schema for a track block (generated at build time from the TypeScript source never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
` + "```" + `
**Runtime-managed fields never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
## Choosing a trackId
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
- **Must be unique within the note file.** Before inserting, read the file and check:
- All existing ` + "`" + `trackId:` + "`" + ` lines in ` + "`" + "```" + `track` + "`" + ` blocks
- All existing ` + "`" + `<!--track-target:...-->` + "`" + ` comments
- If you need disambiguation, add scope: ` + "`" + `btc-price-usd` + "`" + `, ` + "`" + `weather-home` + "`" + `, ` + "`" + `news-ai-2` + "`" + `.
- Don't reuse an old ID even if the previous block was deleted pick a fresh one.
## Writing a Good Instruction
### The Frame: This Is a Personal Knowledge Tracker
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
### Core Rules
- **Specific and actionable.** State exactly what to fetch or compute.
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
- **Imperative voice, 1-3 sentences.**
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
### Self-Sufficiency (critical)
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
**Never use phrases that depend on prior conversation or prior runs:**
- "as before", "same style as before", "like last time"
- "keep the format we discussed", "matching the previous output"
- "continue from where you left off" (without stating the state)
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction not this chat, not what you produced last time.
### Output Patterns Match the Data
Pick a shape that fits what the user is tracking. Five common patterns the first four are plain markdown; the fifth is a rich rendered block:
**1. Single metric / status line.**
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
- Bad: "Give me a nice update about the dollar rate."
**2. Compact table.**
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
- Bad: "Show a polished, table-first world clock with a pleasant layout."
**3. Rolling digest.**
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
- Bad: "Give me the top HN stories with thoughtful takeaways."
**4. Status / threshold watch.**
- Good: "Check https://status.example.com. Return one line: ` + "`" + ` All systems operational` + "`" + ` or ` + "`" + ` <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
- Bad: "Keep an eye on the status page and tell me how it looks."
${richBlockMenu}
### Anti-Patterns
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" they tell the agent nothing concrete.
- **References to past state** without a mechanism to access it ("as before", "same as last time").
- **Bundling multiple purposes** into one instruction split into separate track blocks.
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
## YAML String Style (critical read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
The two free-form fields ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
Real failure seen in the wild an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
### The rule: always use a safe scalar style
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
` + "```" + `yaml
instruction: |
Show current local time for India, Chicago, and Indianapolis as a
3-column markdown table: Location | Local Time | Offset vs India.
One row per location, 24-hour time (HH:MM), no extra prose.
Note: when a location is in DST, reflect that in the offset column.
eventMatchCriteria: |
Emails from the finance team about Q3 budget or OKRs.
` + "```" + `
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs all literal. No escaping needed.
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
- Leave a real newline after ` + "`" + `|` + "`" + ` content starts on the next line, not the same line.
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
### Acceptable alternative: double-quoted on a single line
Fine for short single-sentence fields with no newline needs:
` + "```" + `yaml
instruction: "Show the current time in Chicago, IL in 12-hour format."
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
` + "```" + `
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
` + "```" + `yaml
instruction: 'He said "hi" at 9:00.'
` + "```" + `
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
- No other escape sequences work.
### Do NOT use plain (unquoted) scalars for these two fields
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits plain scalars are not.
### Editing an existing track
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
### Never-hand-write fields
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
## Schedules
Schedule is an **optional** discriminated union. Three types:
### ` + "`" + `cron` + "`" + ` recurring at exact times
` + "```" + `yaml
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
Fires at the exact cron time. Use when the user wants precise timing ("at 9am daily", "every hour on the hour").
### ` + "`" + `window` + "`" + ` recurring within a time-of-day range
` + "```" + `yaml
schedule:
type: window
cron: "0 0 * * 1-5"
startTime: "09:00"
endTime: "17:00"
` + "```" + `
Fires **at most once per cron occurrence**, but only if the current time is within ` + "`" + `startTime` + "`" + `` + "`" + `endTime` + "`" + ` (24-hour HH:MM, local). Use when the user wants "sometime in the morning" or "once per weekday during work hours" flexible timing with bounds.
### ` + "`" + `once` + "`" + ` one-shot at a future time
` + "```" + `yaml
schedule:
type: once
runAt: "2026-04-14T09:00:00"
` + "```" + `
Fires once at ` + "`" + `runAt` + "`" + ` and never again. Local time, no ` + "`" + `Z` + "`" + ` suffix.
### Cron cookbook
- ` + "`" + `"*/15 * * * *"` + "`" + ` every 15 minutes
- ` + "`" + `"0 * * * *"` + "`" + ` every hour on the hour
- ` + "`" + `"0 8 * * *"` + "`" + ` daily at 8am
- ` + "`" + `"0 9 * * 1-5"` + "`" + ` weekdays at 9am
- ` + "`" + `"0 0 * * 0"` + "`" + ` Sundays at midnight
- ` + "`" + `"0 0 1 * *"` + "`" + ` first of month at midnight
**Omit ` + "`" + `schedule` + "`" + ` entirely for a manual-only track** the user triggers it via the Play button in the UI.
## Event Triggers (third trigger type)
In addition to manual and scheduled, a track can be triggered by **events** incoming signals from the user's data sources (currently: gmail emails). Set ` + "`" + `eventMatchCriteria` + "`" + ` to a description of what kinds of events should consider this track for an update:
` + "```" + `track
trackId: q3-planning-emails
instruction: |
Maintain a running summary of decisions and open questions about Q3
planning, drawn from emails on the topic.
active: true
eventMatchCriteria: |
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
` + "```" + `
How it works:
1. When a new event arrives (e.g. an email syncs), a fast LLM classifier checks ` + "`" + `eventMatchCriteria` + "`" + ` against the event content.
2. If it might match, the track-run agent receives both the event payload and the existing track content, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update no fabricated content.
When to suggest event triggers:
- The user wants to **maintain a living summary** of a topic ("keep notes on everything related to project X").
- The content depends on **incoming signals** rather than periodic refresh ("update this whenever a relevant email arrives").
- Mention to the user: scheduled (cron) is for time-driven updates; event is for signal-driven updates. They can be combined a track can have both a ` + "`" + `schedule` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` (it'll run on schedule AND on relevant events).
Writing good ` + "`" + `eventMatchCriteria` + "`" + `:
- Be descriptive but not overly narrow Pass 1 routing is liberal by design.
- Examples: ` + "`" + `"Emails from John about the migration project"` + "`" + `, ` + "`" + `"Calendar events related to customer interviews"` + "`" + `, ` + "`" + `"Meeting notes that mention pricing changes"` + "`" + `.
Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events entirely they'll only run on schedule or manually.
## Insertion Workflow
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
### Cmd+K with cursor context
When the user invokes Cmd+K, the context includes an attachment mention like:
> User has attached the following files:
> - notes.md (text/markdown) at knowledge/notes.md (line 42)
Workflow:
1. Extract the ` + "`" + `path` + "`" + ` and ` + "`" + `line N` + "`" + ` from the attachment.
2. ` + "`" + `workspace-readFile({ path })` + "`" + ` always re-read fresh.
3. Check existing ` + "`" + `trackId` + "`" + `s in the file to guarantee uniqueness.
4. Locate the line. Pick a **unique 2-3 line anchor** around line N (a full heading, a distinctive sentence). Avoid blank lines and generic text.
5. Construct the full track block (YAML + target pair).
6. ` + "`" + `workspace-edit({ path, oldString: <anchor>, newString: <anchor with block spliced at line N> })` + "`" + `.
### Sidebar chat with a specific note
1. If a file is mentioned/attached, read it.
2. If ambiguous, ask one question: "Which note should I add the track to?"
3. **Default placement: append** to the end of the file. Find the last non-empty line as the anchor. ` + "`" + `newString` + "`" + ` = that line + ` + "`" + `\n\n` + "`" + ` + track block + target pair.
4. If the user specified a section ("under the Weather heading"), anchor on that heading.
### No note context at all
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
### Suggested Topics exploration flow
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
- "I am exploring a suggested topic card from the Suggested Topics panel."
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
In that flow:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
## The Exact Text to Insert
Write it verbatim like this (including the blank line between fence and target):
` + "```" + `track
trackId: <id>
instruction: |
<instruction, indented 2 spaces, may span multiple lines>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:<id>-->
<!--/track-target:<id>-->
**Rules:**
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
- Target pair is **empty on creation**. The runner fills it on the first run.
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar see the YAML String Style section above for why.
- **Always quote cron expressions** in YAML they contain spaces and ` + "`" + `*` + "`" + `.
- Use 2-space YAML indent. No tabs.
- Top-level markdown only never inside a code fence, blockquote, or table.
## After Insertion
- Confirm in one line: "Added ` + "`" + `chicago-time` + "`" + ` track, refreshing hourly."
- **Then offer to run it once now** (see "Running a Track" below) especially valuable for newly created blocks where the target region is otherwise empty until the next scheduled or event-triggered run.
- **Do not** write anything into the ` + "`" + `<!--track-target:...-->` + "`" + ` region yourself use the ` + "`" + `run-track-block` + "`" + ` tool to delegate to the track agent.
## Running a Track (the ` + "`" + `run-track-block` + "`" + ` tool)
The ` + "`" + `run-track-block` + "`" + ` tool manually triggers a track run right now. Equivalent to the user clicking the Play button but you can pass extra ` + "`" + `context` + "`" + ` to bias what the track agent does on this single run (without modifying the block's ` + "`" + `instruction` + "`" + `).
### When to proactively offer to run
These are upsells ask first, don't run silently.
- **Just created a new track block.** Before declaring done, offer:
> "Want me to run it once now to seed the initial content?"
This is **especially valuable for event-triggered tracks** (with ` + "`" + `eventMatchCriteria` + "`" + `) otherwise the target region stays empty until the next matching event arrives.
For tracks that pull from existing local data (synced emails, calendar, meeting notes), suggest a **backfill** with explicit context (see below).
- **Just edited an existing track.** Offer:
> "Want me to run it now to see the updated output?"
- **Explicit user request.** "run the X track", "test it", "refresh that block" call the tool directly.
### Using the ` + "`" + `context` + "`" + ` parameter (the powerful case)
The ` + "`" + `context` + "`" + ` parameter is extra guidance for the track agent on this run only. It's the difference between a stock refresh and a smart backfill.
**Examples:**
- New track: "Track emails about Q3 planning" after creating it, run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days that match this track's topic (Q3 planning, OKRs, roadmap), and synthesize the initial summary."
- New track: "Summarize this week's customer calls" run with:
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
- Manual refresh after the user mentions a recent change:
> context: "Focus on changes from the last 7 days only."
- Plain refresh (user says "run it now"): **omit ` + "`" + `context` + "`" + ` entirely**. Don't invent context it can mislead the agent.
### What to do with the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- **` + "`" + `action: 'replace'` + "`" + `** the track was updated. Confirm with one line, optionally citing the first line of ` + "`" + `contentAfter` + "`" + `:
> "Done — track now shows: 72°F, partly cloudy in Chicago."
- **` + "`" + `action: 'no_update'` + "`" + `** the agent decided nothing needed to change. Tell the user briefly; ` + "`" + `summary` + "`" + ` may explain why.
- **` + "`" + `error` + "`" + ` set** surface it concisely. If the error is ` + "`" + `'Already running'` + "`" + ` (concurrency guard), let the user know the track is mid-run and to retry shortly.
### Don'ts
- **Don't auto-run** after every edit ask first.
- **Don't pass ` + "`" + `context` + "`" + `** for a plain refresh — only when there's specific extra guidance to give.
- **Don't use ` + "`" + `run-track-block` + "`" + ` to manually write content** — that's ` + "`" + `update-track-content` + "`" + `'s job (and even that should be rare; the track agent handles content via this tool).
- **Don't ` + "`" + `run-track-block` + "`" + ` repeatedly** in a single turn one run per user-facing action.
## Proactive Suggestions
When the user signals interest in recurring or time-decaying info, **offer a track block** instead of a one-off answer. Signals:
- "I want to track / monitor / watch / keep an eye on / follow X"
- "Can you check on X every morning / hourly / weekly?"
- The user just asked a one-off question whose answer decays (weather, score, price, status, news).
- The user is building a time-sensitive page (weekly dashboard, morning briefing).
Suggestion style one line, concrete:
> "I can turn this into a track block that refreshes hourly — want that?"
Don't upsell aggressively. If the user clearly wants a one-off answer, give them one.
## Don'ts
- **Don't reuse** an existing ` + "`" + `trackId` + "`" + ` in the same file.
- **Don't add ` + "`" + `schedule` + "`" + `** if the user explicitly wants a manual-only track.
- **Don't write** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, or ` + "`" + `lastRunSummary` + "`" + ` runtime-managed.
- **Don't nest** the ` + "`" + `<!--track-target:ID-->` + "`" + ` region inside the ` + "`" + "```" + `track` + "`" + ` fence.
- **Don't touch** content between ` + "`" + `<!--track-target:ID-->` + "`" + ` and ` + "`" + `<!--/track-target:ID-->` + "`" + ` — that's generated content.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't add a ` + "`" + `Z` + "`" + ` suffix** on ` + "`" + `runAt` + "`" + ` local time only.
- **Don't use ` + "`" + `workspace-writeFile` + "`" + `** to rewrite the whole file always ` + "`" + `workspace-edit` + "`" + ` with a unique anchor.
## Editing or Removing an Existing Track
**Change schedule or instruction:** read the file, ` + "`" + `workspace-edit` + "`" + ` the YAML body. Anchor on the unique ` + "`" + `trackId: <id>` + "`" + ` line plus a few surrounding lines.
**Pause without deleting:** flip ` + "`" + `active: false` + "`" + `.
**Remove entirely:** ` + "`" + `workspace-edit` + "`" + ` with ` + "`" + `oldString` + "`" + ` = the full ` + "`" + "```" + `track` + "`" + ` block **plus** the target pair (so generated content also disappears), ` + "`" + `newString` + "`" + ` = empty.
## Quick Reference
Minimal template:
` + "```" + `track
trackId: <kebab-id>
instruction: |
<what to produce always use ` + "`" + `|` + "`" + `, indented 2 spaces>
active: true
schedule:
type: cron
expression: "0 * * * *"
` + "```" + `
<!--track-target:<kebab-id>-->
<!--/track-target:<kebab-id>-->
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
`;
export default skill;

View file

@ -0,0 +1,8 @@
import type { BrowserControlInput, BrowserControlResult } from '@x/shared/dist/browser-control.js';
export interface IBrowserControlService {
execute(
input: BrowserControlInput,
ctx?: { signal?: AbortSignal },
): Promise<BrowserControlResult>;
}

View file

@ -1,6 +1,8 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import { createReadStream } from "fs";
import { createInterface } from "readline";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
@ -12,6 +14,10 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
@ -20,6 +26,8 @@ import { isSignedIn } from "../../account/account.js";
import { getGatewayProvider } from "../../models/gateway.js";
import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
import type { IBrowserControlService } from "../browser-control/service.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them.
@ -184,14 +192,119 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
'workspace-readFile': {
description: 'Read file contents from the workspace. Supports utf8, base64, and binary encodings.',
description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'),
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'),
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'),
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
}),
execute: async ({ path: relPath, encoding = 'utf8' }: { path: string; encoding?: 'utf8' | 'base64' | 'binary' }) => {
execute: async ({
path: relPath,
offset,
limit,
encoding = 'utf8',
}: {
path: string;
offset?: number;
limit?: number;
encoding?: 'utf8' | 'base64' | 'binary';
}) => {
try {
return await workspace.readFile(relPath, encoding);
if (encoding !== 'utf8') {
return await workspace.readFile(relPath, encoding);
}
const DEFAULT_READ_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
const MAX_BYTES = 50 * 1024;
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
const absPath = workspace.resolveWorkspacePath(relPath);
const stats = await fs.lstat(absPath);
const stat = workspace.statToSchema(stats, 'file');
const etag = workspace.computeEtag(stats.size, stats.mtimeMs);
const effectiveOffset = offset ?? 1;
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
const start = effectiveOffset - 1;
const stream = createReadStream(absPath, { encoding: 'utf8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
const collected: string[] = [];
let totalLines = 0;
let bytes = 0;
let truncatedByBytes = false;
let hasMoreLines = false;
try {
for await (const text of rl) {
totalLines += 1;
if (totalLines <= start) continue;
if (collected.length >= effectiveLimit) {
hasMoreLines = true;
continue;
}
const line = text.length > MAX_LINE_LENGTH
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
: text;
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true;
hasMoreLines = true;
break;
}
collected.push(line);
bytes += size;
}
} finally {
rl.close();
stream.destroy();
}
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
}
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
const lastReadLine = effectiveOffset + collected.length - 1;
const nextOffset = lastReadLine + 1;
let footer: string;
if (truncatedByBytes) {
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
} else if (hasMoreLines) {
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
} else {
footer = `(End of file - total ${totalLines} lines)`;
}
const content = [
`<path>${relPath}</path>`,
`<type>file</type>`,
`<content>`,
prefixed.join('\n'),
'',
footer,
`</content>`,
].join('\n');
return {
path: relPath,
encoding: 'utf8' as const,
content,
stat,
etag,
offset: effectiveOffset,
limit: effectiveLimit,
totalLines,
hasMore: hasMoreLines || truncatedByBytes,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -468,7 +581,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
count: matches.length,
tool: 'ripgrep',
};
} catch (rgError) {
} catch {
// Fallback to basic grep if ripgrep not available or failed
const grepArgs = [
'-rn',
@ -903,6 +1016,39 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
// ============================================================================
// Browser Control
// ============================================================================
'browser-control': {
description: 'Control the embedded browser pane. Read the current page, inspect indexed interactable elements, and navigate/click/type/press keys in the active browser tab.',
inputSchema: BrowserControlInputSchema,
isAvailable: async () => {
try {
container.resolve<IBrowserControlService>('browserControlService');
return true;
} catch {
return false;
}
},
execute: async (input: BrowserControlInput, ctx?: ToolContext) => {
try {
const browserControlService = container.resolve<IBrowserControlService>('browserControlService');
return await browserControlService.execute(input, { signal: ctx?.signal });
} catch (error) {
return {
success: false,
action: input.action,
error: error instanceof Error ? error.message : 'Browser control is unavailable.',
browser: {
activeTabId: null,
tabs: [],
},
};
}
},
},
// ============================================================================
// App Navigation
// ============================================================================
@ -1043,123 +1189,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
// ============================================================================
// Web Search (Brave Search API)
// Web Search (Exa Search API)
// ============================================================================
'web-search': {
description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.',
inputSchema: z.object({
query: z.string().describe('The search query'),
count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
try {
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
return !!config.apiKey;
} catch {
return false;
}
},
execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => {
try {
const resultCount = Math.min(Math.max(count || 5, 1), 20);
const params = new URLSearchParams({
q: query,
count: String(resultCount),
});
if (freshness) {
params.set('freshness', freshness);
}
let response: Response;
if (await isSignedIn()) {
// Use proxy
const accessToken = await getAccessToken();
response = await fetch(`${API_URL}/v1/search/brave?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
} else {
// Read API key from config
const braveConfigPath = path.join(WorkDir, 'config', 'brave-search.json');
let apiKey: string;
try {
const raw = await fs.readFile(braveConfigPath, 'utf8');
const config = JSON.parse(raw);
apiKey = config.apiKey;
} catch {
return {
success: false,
error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "<your-key>" }',
};
}
if (!apiKey) {
return {
success: false,
error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json',
};
}
response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, {
headers: {
'X-Subscription-Token': apiKey,
'Accept': 'application/json',
},
});
}
if (!response.ok) {
const body = await response.text();
return {
success: false,
error: `Brave Search API error (${response.status}): ${body}`,
};
}
const data = await response.json() as {
web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
};
const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({
title: r.title || '',
url: r.url || '',
description: r.description || '',
}));
return {
success: true,
query,
results,
count: results.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
// ============================================================================
// Research Search (Exa Search API)
// ============================================================================
'research-search': {
description: 'Use this for finding articles, blog posts, papers, companies, people, or exploring a topic in depth. Best for discovery and research where you need quality sources, not a quick fact.',
description: 'Search the web for articles, blog posts, papers, companies, people, news, or explore a topic in depth. Returns rich results with full text, highlights, and metadata.',
inputSchema: z.object({
query: z.string().describe('The search query'),
numResults: z.number().optional().describe('Number of results to return (default: 5, max: 20)'),
category: z.enum(['company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Filter results by category'),
category: z.enum(['general', 'company', 'research paper', 'news', 'tweet', 'personal site', 'financial report', 'people']).optional().describe('Search category. Defaults to "general" which searches the entire web. Only use a specific category when the query is clearly about that type (e.g. "research paper" for academic papers, "company" for company info). For everyday queries like weather, restaurants, prices, how-to, etc., use "general" or omit entirely.'),
}),
isAvailable: async () => {
if (await isSignedIn()) return true;
@ -1185,7 +1223,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
highlights: true,
},
};
if (category) {
if (category && category !== 'general') {
reqBody.category = category;
}
@ -1214,14 +1252,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
} catch {
return {
success: false,
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "<your-key>" }`,
};
}
if (!apiKey) {
return {
success: false,
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`,
};
}
@ -1277,4 +1315,225 @@ 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}`,
};
},
},
// ========================================================================
// Composio Meta-Tools
// ========================================================================
'composio-list-toolkits': {
description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.',
inputSchema: z.object({
category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional()
.describe('Filter by category. Defaults to "all".'),
}),
execute: async ({ category }: { category?: string }) => {
const toolkits = CURATED_TOOLKITS
.filter(t => !category || category === 'all' || t.category === category)
.map(t => ({
slug: t.slug,
name: t.displayName,
category: t.category,
isConnected: composioAccountsRepo.isConnected(t.slug),
}));
const connectedCount = toolkits.filter(t => t.isConnected).length;
return {
toolkits,
connectedCount,
totalCount: toolkits.length,
};
},
isAvailable: async () => isComposioConfigured(),
},
'composio-search-tools': {
description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.',
inputSchema: z.object({
query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'),
toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'),
}),
execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => {
try {
const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined;
const result = await searchComposioTools(query, toolkitFilter);
// Filter to curated toolkits only (skip if a specific toolkit was requested —
// the API already filtered server-side)
const filtered = toolkitSlug
? result.items
: result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug));
// Annotate with connection status
const tools = filtered.map(t => ({
slug: t.slug,
name: t.name,
description: t.description,
toolkitSlug: t.toolkitSlug,
isConnected: composioAccountsRepo.isConnected(t.toolkitSlug),
inputSchema: t.inputParameters,
}));
return {
tools,
resultCount: tools.length,
hint: tools.some(t => !t.isConnected)
? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.'
: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], resultCount: 0, error: message };
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-execute-tool': {
description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })',
inputSchema: z.object({
toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'),
toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'),
arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'),
}),
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
// Default arguments to {} if the LLM omits the field entirely
const toolArgs = args ?? {};
// Check connection
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
successful: false,
data: null,
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
};
}
try {
return await executeComposioAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: toolArgs,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message);
return {
successful: false,
data: null,
error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`,
};
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-connect-toolkit': {
description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Shows a connect card for the user to authenticate.',
inputSchema: z.object({
toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'),
}),
execute: async ({ toolkitSlug }: { toolkitSlug: string }) => {
// Validate against curated list
if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) {
const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', ');
return {
success: false,
error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
};
}
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return {
success: true,
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
alreadyConnected: true,
};
}
// Return signal — the UI renders a ComposioConnectCard with a Connect button.
// OAuth only starts when the user clicks that button.
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
return {
success: true,
message: `Please connect ${toolkit?.displayName ?? toolkitSlug} to continue.`,
};
},
isAvailable: async () => isComposioConfigured(),
},
'update-track-content': {
description: "Update the output content of a track block in a knowledge note. This replaces the content inside the track's target region (between <!--track-target:ID--> markers), or creates the target region if it doesn't exist. Also updates the track's lastRunAt timestamp.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId"),
content: z.string().describe("The new content to place inside the track's target region"),
}),
execute: async ({ filePath, trackId, content }: { filePath: string; trackId: string; content: string }) => {
try {
await updateContent(filePath, trackId, content);
await updateTrackBlock(filePath, trackId, { lastRunAt: new Date().toISOString() });
return { success: true, message: `Updated track ${trackId} in ${filePath}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
'run-track-block': {
description: "Manually trigger a track block to run now. Equivalent to the user clicking the Play button on the block, but you can pass extra `context` to bias what the track agent does this run — most useful for backfills (e.g. seeding a new email-tracking block from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new content.",
inputSchema: z.object({
filePath: z.string().describe("Workspace-relative path to the note file (e.g., 'knowledge/Notes/my-note.md')"),
trackId: z.string().describe("The track block's trackId (must exist in the file)"),
context: z.string().optional().describe(
"Optional extra context for the track agent to consider for THIS run only — does not modify the block's instruction. " +
"Use it to drive backfills (e.g. 'Backfill from existing synced emails in gmail_sync/ from the last 90 days about this topic') " +
"or focused refreshes (e.g. 'Focus on changes from the last 7 days'). " +
"Omit for a plain refresh."
),
}),
execute: async ({ filePath, trackId, context }: { filePath: string; trackId: string; context?: string }) => {
const knowledgeRelativePath = filePath.replace(/^knowledge\//, '');
try {
// Lazy import to break a module-init cycle:
// builtin-tools → track/runner → runs/runs → agents/runtime → builtin-tools
const { triggerTrackUpdate } = await import("../../knowledge/track/runner.js");
const result = await triggerTrackUpdate(trackId, knowledgeRelativePath, context, 'manual');
return {
success: !result.error,
runId: result.runId,
action: result.action,
summary: result.summary,
contentAfter: result.contentAfter,
error: result.error,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
};

View file

@ -4,6 +4,9 @@ import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
export type VoiceOutputMode = 'summary' | 'full';
export type MiddlePaneContext =
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string };
type EnqueuedMessage = {
messageId: string;
@ -11,10 +14,11 @@ type EnqueuedMessage = {
voiceInput?: boolean;
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -30,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -41,6 +45,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceInput,
voiceOutput,
searchEnabled,
middlePaneContext,
});
return id;
}

View file

@ -0,0 +1,27 @@
import { parse as parseYaml } from "yaml";
/**
* Parse the YAML frontmatter from the input string. Returns the frontmatter and content.
* @param input - The input string to parse.
* @returns The frontmatter and content.
*/
export function parseFrontmatter(input: string): {
frontmatter: unknown | null;
content: string;
} {
if (input.startsWith("---")) {
const end = input.indexOf("\n---", 3);
if (end !== -1) {
const fm = input.slice(3, end).trim(); // YAML text
return {
frontmatter: parseYaml(fm),
content: input.slice(end + 4).trim(),
};
}
}
return {
frontmatter: null,
content: input,
};
}

View file

@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
*/
export async function discoverConfiguration(
issuerUrl: string,
clientId: string
clientId: string,
clientSecret?: string
): Promise<client.Configuration> {
const cacheKey = `${issuerUrl}:${clientId}`;
const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
const cached = configCache.get(cacheKey);
if (cached) {
@ -50,8 +51,8 @@ export async function discoverConfiguration(
const config = await client.discovery(
new URL(issuerUrl),
clientId,
undefined, // no client_secret (PKCE flow)
client.None(), // PKCE doesn't require client authentication
clientSecret ?? undefined,
clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
{
execute: [client.allowInsecureRequests],
}
@ -69,7 +70,8 @@ export function createStaticConfiguration(
authorizationEndpoint: string,
tokenEndpoint: string,
clientId: string,
revocationEndpoint?: string
revocationEndpoint?: string,
clientSecret?: string
): client.Configuration {
console.log(`[OAuth] Creating static configuration (no discovery)`);
@ -86,8 +88,8 @@ export function createStaticConfiguration(
return new client.Configuration(
serverMetadata,
clientId,
undefined, // no client_secret
client.None() // PKCE auth
clientSecret ?? undefined,
clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
);
}
@ -214,12 +216,15 @@ export async function refreshTokens(
return tokens;
}
const EXPIRY_MARGIN_SECONDS = 60;
/**
* Check if tokens are expired
* Check if tokens are expired. Treats tokens as expired EXPIRY_MARGIN_SECONDS
* before the real expiry to absorb clock skew and in-flight request latency.
*/
export function isTokenExpired(tokens: OAuthTokens): boolean {
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at <= now;
return tokens.expires_at <= now + EXPIRY_MARGIN_SECONDS;
}
/**

View file

@ -1,5 +1,5 @@
import { z } from 'zod';
import { SUPABASE_PROJECT_URL } from '../config/env.js';
import { getRowboatConfig } from '../config/rowboat.js';
/**
* Discovery configuration - how to get OAuth endpoints
@ -55,7 +55,7 @@ const providerConfigs: ProviderConfig = {
rowboat: {
discovery: {
mode: 'issuer',
issuer: `${SUPABASE_PROJECT_URL}/auth/v1/.well-known/oauth-authorization-server`,
issuer: "TBD",
},
client: {
mode: 'dcr',
@ -98,21 +98,21 @@ const providerConfigs: ProviderConfig = {
/**
* Get provider configuration by name
*/
export function getProviderConfig(providerName: string): ProviderConfigEntry {
export async function getProviderConfig(providerName: string): Promise<ProviderConfigEntry> {
const config = providerConfigs[providerName];
if (!config) {
throw new Error(`Unknown OAuth provider: ${providerName}`);
}
if (providerName === 'rowboat') {
const rowboatConfig = await getRowboatConfig();
config.discovery = {
mode: 'issuer',
issuer: `${rowboatConfig.supabaseUrl}/auth/v1/.well-known/oauth-authorization-server`,
}
}
return config;
}
/**
* Get all provider configurations
*/
export function getAllProviderConfigs(): ProviderConfig {
return providerConfigs;
}
/**
* Get list of all configured OAuth providers
*/

View file

@ -7,6 +7,7 @@ import z from 'zod';
const ProviderConnectionSchema = z.object({
tokens: OAuthTokens.nullable().optional(),
clientId: z.string().nullable().optional(),
clientSecret: z.string().nullable().optional(),
error: z.string().nullable().optional(),
});
@ -18,6 +19,7 @@ const OAuthConfigSchema = z.object({
const ClientFacingConfigSchema = z.record(z.string(), z.object({
connected: z.boolean(),
error: z.string().nullable().optional(),
clientId: z.string().nullable().optional(),
}));
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
@ -111,8 +113,9 @@ export class FSOAuthRepo implements IOAuthRepo {
clientFacingConfig[provider] = {
connected: !!providerConfig.tokens,
error: providerConfig.error,
clientId: providerConfig.clientId ?? null,
};
}
return clientFacingConfig;
return ClientFacingConfigSchema.parse(clientFacingConfig);
}
}

View file

@ -3,23 +3,17 @@ import { IOAuthRepo } from './repo.js';
import { IClientRegistrationRepo } from './client-repo.js';
import { getProviderConfig } from './providers.js';
import * as oauthClient from './oauth-client.js';
import { OAuthTokens } from './types.js';
export async function getAccessToken(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read('rowboat');
if (!tokens) {
throw new Error('Not signed into Rowboat');
}
if (!oauthClient.isTokenExpired(tokens)) {
return tokens.access_token;
}
let refreshInFlight: Promise<OAuthTokens> | null = null;
async function performRefresh(tokens: OAuthTokens): Promise<OAuthTokens> {
console.log("Refreshing rowboat access token");
if (!tokens.refresh_token) {
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
}
const providerConfig = getProviderConfig('rowboat');
const providerConfig = await getProviderConfig('rowboat');
if (providerConfig.discovery.mode !== 'issuer') {
throw new Error('Rowboat provider requires issuer discovery mode');
}
@ -40,7 +34,29 @@ export async function getAccessToken(): Promise<string> {
tokens.refresh_token,
tokens.scopes,
);
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
await oauthRepo.upsert('rowboat', { tokens: refreshed });
return refreshed;
}
export async function getAccessToken(): Promise<string> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read('rowboat');
if (!tokens) {
throw new Error('Not signed into Rowboat');
}
if (!oauthClient.isTokenExpired(tokens)) {
return tokens.access_token;
}
if (!refreshInFlight) {
refreshInFlight = performRefresh(tokens).finally(() => {
refreshInFlight = null;
});
}
const refreshed = await refreshInFlight;
return refreshed.access_token;
}

View file

@ -6,6 +6,7 @@ export interface BillingInfo {
userId: string | null;
subscriptionPlan: string | null;
subscriptionStatus: string | null;
trialExpiresAt: string | null;
sanctionedCredits: number;
availableCredits: number;
}
@ -26,6 +27,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
billing: {
plan: string | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
sanctionedCredits: number;
availableCredits: number;
@ -37,6 +39,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
sanctionedCredits: body.billing.usage.sanctionedCredits,
availableCredits: body.billing.usage.availableCredits,
};

View file

@ -14,8 +14,9 @@ import {
ZExecuteActionRequest,
ZExecuteActionResponse,
ZListResponse,
ZTool,
ZSearchResultTool,
ZToolkit,
type NormalizedToolResult,
} from "./types.js";
import { isSignedIn } from "../account/account.js";
import { getAccessToken } from "../auth/tokens.js";
@ -72,7 +73,7 @@ function loadConfig(): ComposioConfig {
/**
* Save Composio configuration
*/
export function saveConfig(config: ComposioConfig): void {
function saveConfig(config: ComposioConfig): void {
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@ -167,7 +168,15 @@ export async function composioApiCall<T extends z.ZodTypeAny>(
}
if (!response.ok) {
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
// Try to extract a human-readable message from the JSON body
let detail = '';
try {
const body = JSON.parse(rawText);
if (typeof body?.error === 'string') detail = body.error;
else if (typeof body?.message === 'string') detail = body.message;
} catch { /* body isn't JSON or has no message field */ }
const suffix = detail ? `: ${detail}` : '';
throw new Error(`Composio API error: ${response.status} ${response.statusText}${suffix}`);
}
if (!contentType.includes('application/json')) {
@ -246,15 +255,6 @@ export async function createAuthConfig(
});
}
/**
* Delete an auth config
*/
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
return composioApiCall(ZDeleteOperationResponse, `/auth_configs/${authConfigId}`, {}, {
method: 'DELETE',
});
}
/**
* Create a connected account
*/
@ -284,20 +284,39 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
}
/**
* List available tools for a toolkit
* Search for tools across all toolkits (or optionally filtered by specific toolkit slugs).
* Returns tools with full input_parameters so the agent knows what params to pass.
*
* Uses a limit of 50 (not 15) to avoid the curated-filter-after-limit problem where
* in-scope results at position 16+ would be discarded if earlier results are out-of-scope.
*/
export async function listToolkitTools(
toolkitSlug: string,
searchQuery: string | null = null,
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
export async function searchTools(
searchQuery: string,
toolkitSlugs?: string[],
): Promise<{ items: NormalizedToolResult[] }> {
const params: Record<string, string> = {
toolkit_slug: toolkitSlug,
limit: '200',
query: searchQuery,
limit: '50',
};
if (searchQuery) {
params.search = searchQuery;
if (toolkitSlugs && toolkitSlugs.length === 1) {
params.toolkit_slug = toolkitSlugs[0];
}
return composioApiCall(ZListResponse(ZTool), "/tools", params);
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
const items: NormalizedToolResult[] = result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
toolkitSlug: item.toolkit.slug,
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
}));
return { items };
}
/**

View file

@ -1,4 +1,8 @@
import { z } from "zod";
import { ZToolkitMeta as ZSharedToolkitMeta, ZToolkitItem } from "@x/shared/dist/composio.js";
// Re-export the shared toolkit schemas so existing imports continue to work
export const ZToolkitMeta = ZSharedToolkitMeta;
/**
* Composio authentication schemes
@ -29,26 +33,9 @@ export const ZConnectedAccountStatus = z.enum([
]);
/**
* Toolkit metadata
* Toolkit schema same shape as ZToolkitItem from shared, re-exported for convenience.
*/
export const ZToolkitMeta = z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
});
/**
* Toolkit schema
*/
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: ZToolkitMeta,
no_auth: z.boolean().optional(),
auth_schemes: z.array(ZAuthScheme).optional(),
composio_managed_auth_schemes: z.array(ZAuthScheme).optional(),
});
export const ZToolkit = ZToolkitItem;
/**
* Tool schema
@ -147,7 +134,7 @@ export const ZCreateConnectedAccountRequest = z.object({
*/
export const ZCreateConnectedAccountResponse = z.object({
id: z.string(),
connectionData: ZConnectionData,
connectionData: ZConnectionData.optional(),
});
/**
@ -227,12 +214,44 @@ export const ZLocalConnectedAccount = z.object({
lastUpdatedAt: z.string(),
});
export type AuthScheme = z.infer<typeof ZAuthScheme>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
export type Toolkit = z.infer<typeof ZToolkit>;
export type Tool = z.infer<typeof ZTool>;
export type AuthConfig = z.infer<typeof ZAuthConfig>;
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
/**
* Tool schema for search results.
* Unlike ZTool, `toolkit` is optional because the Composio /tools search endpoint
* sometimes omits the toolkit object from results. `input_parameters` uses
* lenient defaults so tools with no params (e.g. LINKEDIN_GET_MY_INFO) parse cleanly.
*/
export const ZSearchResultTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
input_parameters: z.object({
type: z.literal('object').optional().default('object'),
properties: z.record(z.string(), z.unknown()).optional().default({}),
required: z.array(z.string()).optional(),
}).optional().default({ type: 'object', properties: {} }),
}).passthrough();
/**
* Normalized tool result returned from searchTools().
*/
export const ZNormalizedToolResult = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
inputParameters: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
}),
});
export type NormalizedToolResult = z.infer<typeof ZNormalizedToolResult>;

View file

@ -3,8 +3,25 @@ import fs from "fs";
import { homedir } from "os";
import { fileURLToPath } from "url";
function resolveWorkDir(): string {
const configured = process.env.ROWBOAT_WORKDIR;
if (!configured) {
return path.join(homedir(), ".rowboat");
}
const expanded = configured === "~"
? homedir()
: (configured.startsWith("~/") || configured.startsWith("~\\"))
? path.join(homedir(), configured.slice(2))
: configured;
return path.resolve(expanded);
}
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage.
// Normalize to an absolute path so workspace boundary checks behave consistently.
export const WorkDir = resolveWorkDir();
// Get the directory of this file (for locating bundled assets)
const __filename = fileURLToPath(import.meta.url);
@ -31,69 +48,13 @@ function ensureDefaultConfigs() {
}
}
// Welcome content inlined to work with bundled builds (esbuild changes __dirname)
const WELCOME_CONTENT = `# Welcome to Rowboat
This vault is your work memory.
Rowboat extracts context from your emails and meetings and turns it into long-lived, editable Markdown notes. The goal is not to store everything, but to preserve the context that stays useful over time.
---
## How it works
**Entity-based notes**
Notes represent people, projects, organizations, or topics that matter to your work.
**Auto-updating context**
As new emails and meetings come in, Rowboat adds decisions, commitments, and relevant context to the appropriate notes.
**Living notes**
These are not static summaries. Context accumulates over time, and notes evolve as your work evolves.
---
## Your AI coworker
Rowboat uses this shared memory to help with everyday work, such as:
- Drafting emails
- Preparing for meetings
- Summarizing the current state of a project
- Taking local actions when appropriate
The AI works with deep context, but you stay in control. All notes are visible, editable, and yours.
---
## Design principles
**Reduce noise**
Rowboat focuses on recurring contacts and active projects instead of trying to capture everything.
**Local and inspectable**
All data is stored locally as plain Markdown. You can read, edit, or delete any file at any time.
**Built to improve over time**
As you keep using Rowboat, context accumulates across notes instead of being reconstructed from scratch.
---
If something feels confusing or limiting, we'd love to hear about it.
Rowboat is still evolving, and your workflow matters.
`;
function ensureWelcomeFile() {
// Create Welcome.md in knowledge directory if it doesn't exist
const welcomeDest = path.join(WorkDir, "knowledge", "Welcome.md");
if (!fs.existsSync(welcomeDest)) {
fs.writeFileSync(welcomeDest, WELCOME_CONTENT);
}
}
ensureDirs();
ensureDefaultConfigs();
ensureWelcomeFile();
// Ensure default knowledge files exist
import('../knowledge/ensure_daily_note.js').then(m => m.ensureDailyNote()).catch(err => {
console.error('[DailyNote] Failed to ensure daily note:', err);
});
// Initialize version history repo (async, fire-and-forget on startup)
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {

View file

@ -1,5 +1,2 @@
export const API_URL =
process.env.API_URL || 'https://api.x.rowboatlabs.com';
export const SUPABASE_PROJECT_URL =
process.env.SUPABASE_PROJECT_URL || 'https://jpxoiuhlshgwixajvsbu.supabase.co';
process.env.API_URL || 'https://api.x.rowboatlabs.com';

View file

@ -0,0 +1,15 @@
import { z } from "zod";
import { RowboatApiConfig } from "@x/shared/dist/rowboat-account.js";
import { API_URL } from "./env.js";
let cached: z.infer<typeof RowboatApiConfig> | null = null;
export async function getRowboatConfig(): Promise<z.infer<typeof RowboatApiConfig>> {
if (cached) {
return cached;
}
const response = await fetch(`${API_URL}/v1/config`);
const data = RowboatApiConfig.parse(await response.json());
cached = data;
return data;
}

View file

@ -6,15 +6,40 @@ import { WorkDir } from "./config.js";
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
const DEFAULT_ALLOW_LIST = [
"agent-slack",
"awk",
"basename",
"cat",
"cut",
"date",
"df",
"diff",
"dirname",
"du",
"echo",
"env",
"file",
"find",
"grep",
"head",
"hostname",
"jq",
"ls",
"printenv",
"printf",
"pwd",
"yq",
"whoami"
"readlink",
"realpath",
"sort",
"stat",
"tail",
"tree",
"uname",
"uniq",
"wc",
"which",
"whoami",
"yq"
]
let cachedAllowList: string[] | null = null;

View file

@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { WorkDir } from './config.js';
const USER_CONFIG_PATH = path.join(WorkDir, 'config', 'user.json');
export const UserConfig = z.object({
name: z.string().optional(),
email: z.string().email(),
domain: z.string().optional(),
});
export type UserConfig = z.infer<typeof UserConfig>;
export function loadUserConfig(): UserConfig | null {
try {
if (fs.existsSync(USER_CONFIG_PATH)) {
const content = fs.readFileSync(USER_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
return UserConfig.parse(parsed);
}
} catch (error) {
console.error('[UserConfig] Error loading user config:', error);
}
return null;
}
export function saveUserConfig(config: UserConfig): void {
const dir = path.dirname(USER_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const validated = UserConfig.parse(config);
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
export function updateUserEmail(email: string): void {
const existing = loadUserConfig();
const config = existing
? { ...existing, email }
: { email };
saveUserConfig(config);
}

View file

@ -1,4 +1,4 @@
import { asClass, createContainer, InjectionMode } from "awilix";
import { asClass, asValue, createContainer, InjectionMode } from "awilix";
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
@ -18,6 +18,7 @@ import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { FSSkillsRepo, ISkillsRepo } from "../skills/repo.js";
import { FSOfficialSkillsRepo, IOfficialSkillsRepo } from "../skills/official-repo.js";
import { SkillResolver, ISkillResolver } from "../skills/resolver.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -47,4 +48,10 @@ container.register({
skillResolver: asClass<ISkillResolver>(SkillResolver).singleton(),
});
export default container;
export default container;
export function registerBrowserControlService(service: IBrowserControlService): void {
container.register({
browserControlService: asValue(service),
});
}

View file

@ -13,7 +13,7 @@ Main orchestrator that:
### `graph_state.ts`
State management module that tracks which files have been processed:
- Uses hybrid mtime + hash approach for change detection
- Stores state in `~/.rowboat/knowledge_graph_state.json`
- Stores state in `WorkDir/knowledge_graph_state.json`
- Provides modular functions for state operations
### `sync_gmail.ts` & `sync_fireflies.ts`
@ -39,7 +39,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
### State File Structure
`~/.rowboat/knowledge_graph_state.json`:
`WorkDir/knowledge_graph_state.json`:
```json
{
"processedFiles": {
@ -69,7 +69,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
3. **Agent processes batch**
- Extracts entities (people, orgs, projects, topics)
- Creates/updates notes in `~/.rowboat/knowledge/`
- Creates/updates notes in `WorkDir/knowledge/`
- Merges information for entities appearing in multiple files
## Replacing the Change Detection Logic
@ -135,7 +135,7 @@ import { resetGraphState } from './build_graph.js';
resetGraphState(); // Clears the state file
```
Or manually delete: `~/.rowboat/knowledge_graph_state.json`
Or manually delete: `WorkDir/knowledge_graph_state.json`
## Note Creation Strictness
@ -143,7 +143,7 @@ The system supports three strictness levels that control how aggressively notes
### Configuration
Strictness is configured in `~/.rowboat/config/note_creation.json`:
Strictness is configured in `WorkDir/config/note_creation.json`:
```json
{
@ -218,7 +218,7 @@ Each strictness level has its own agent prompt:
Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch)
### State File Location
Change `STATE_FILE` in `graph_state.ts` (currently `~/.rowboat/knowledge_graph_state.json`)
Change `STATE_FILE` in `graph_state.ts` (currently `WorkDir/knowledge_graph_state.json`)
### Hash Algorithm
Change `crypto.createHash('sha256')` in `graph_state.ts` to use a different algorithm (md5, sha1, etc.)

View file

@ -0,0 +1,371 @@
import fs from 'fs';
import path from 'path';
import { google } from 'googleapis';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { useComposioForGoogle, executeAction } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.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;
}
// --- User email resolution ---
async function ensureUserEmail(): Promise<string | null> {
const existing = loadUserConfig();
if (existing?.email) {
return existing.email;
}
// Try Composio (used when signed in or composio configured)
try {
if (await useComposioForGoogle()) {
const account = composioAccountsRepo.getAccount('gmail');
if (account && account.status === 'ACTIVE') {
const result = await executeAction('GMAIL_GET_PROFILE', {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: { user_id: 'me' },
});
const email = (result.data as Record<string, unknown>)?.emailAddress as string | undefined;
if (email) {
updateUserEmail(email);
console.log(`[AgentNotes] Auto-populated user email via Composio: ${email}`);
return email;
}
}
}
} catch (error) {
console.log('[AgentNotes] Could not fetch email via Composio:', error instanceof Error ? error.message : error);
}
// Try direct Google OAuth
try {
const auth = await GoogleClientFactory.getClient();
if (auth) {
const gmail = google.gmail({ version: 'v1', auth });
const profile = await gmail.users.getProfile({ userId: 'me' });
if (profile.data.emailAddress) {
updateUserEmail(profile.data.emailAddress);
console.log(`[AgentNotes] Auto-populated user email: ${profile.data.emailAddress}`);
return profile.data.emailAddress;
}
}
} catch (error) {
console.log('[AgentNotes] Could not fetch Gmail profile for user email:', error instanceof Error ? error.message : error);
}
return null;
}
// --- Main processing ---
async function processAgentNotes(): Promise<void> {
ensureAgentNotesDir();
const state = loadAgentNotesState();
const userEmail = await ensureUserEmail();
// Collect all source material
const messageParts: string[] = [];
// 1. Emails (only if we have user email)
const emailPaths = userEmail
? findUserSentEmails(state, userEmail, EMAIL_BATCH_SIZE)
: [];
if (emailPaths.length > 0) {
messageParts.push(`## Emails sent by the user\n`);
for (const p of emailPaths) {
const content = fs.readFileSync(p, 'utf-8');
const userParts = extractUserPartsFromEmail(content, userEmail!);
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);
}
}
}

View 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.
`;
}

View 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);
}

View file

@ -3,6 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import {
loadState,
@ -15,6 +16,7 @@ import {
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -23,9 +25,15 @@ import { commitAll } from './version_history.js';
const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
const NOTE_CREATION_AGENT = 'note_creation';
const SUGGESTED_TOPICS_REL_PATH = 'suggested-topics.md';
const SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'suggested-topics.md');
const LEGACY_SUGGESTED_TOPICS_REL_PATH = 'config/suggested-topics.md';
const LEGACY_SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'config', 'suggested-topics.md');
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH = 'knowledge/Notes/Suggested Topics.md';
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', 'Notes', 'Suggested Topics.md');
// Configuration for the graph builder service
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const SOURCE_FOLDERS = [
'gmail_sync',
path.join('knowledge', 'Meetings', 'fireflies'),
@ -35,6 +43,48 @@ const SOURCE_FOLDERS = [
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
/**
* Check if email frontmatter contains any noise/skip filter tags.
* Returns true if the email should be skipped.
*/
function hasNoiseLabels(content: string): boolean {
if (!content.startsWith('---')) return false;
const endIdx = content.indexOf('---', 3);
if (endIdx === -1) return false;
const frontmatter = content.slice(3, endIdx);
const noiseTags = new Set(
getTagDefinitions()
.filter(t => t.type === 'noise')
.map(t => t.tag)
);
// Match list items under filter: key
const filterMatch = frontmatter.match(/filter:\s*\n((?:\s+-\s+.+\n?)*)/);
if (filterMatch) {
const filterLines = filterMatch[1].match(/^\s+-\s+(.+)$/gm);
if (filterLines) {
for (const line of filterLines) {
const tag = line.replace(/^\s+-\s+/, '').trim().replace(/['"]/g, '');
if (noiseTags.has(tag)) return true;
}
}
}
// Match inline array like filter: ['cold-outreach'] or filter: [cold-outreach]
const inlineMatch = frontmatter.match(/filter:\s*\[([^\]]*)\]/);
if (inlineMatch && inlineMatch[1].trim()) {
const tags = inlineMatch[1].split(',').map(t => t.trim().replace(/['"]/g, ''));
for (const tag of tags) {
if (noiseTags.has(tag)) return true;
}
}
return false;
}
function extractPathFromToolInput(input: string): string | null {
try {
const parsed = JSON.parse(input) as { path?: string };
@ -44,6 +94,49 @@ function extractPathFromToolInput(input: string): string | null {
}
}
function ensureSuggestedTopicsFileLocation(): string {
if (fs.existsSync(SUGGESTED_TOPICS_PATH)) {
return SUGGESTED_TOPICS_PATH;
}
const legacyCandidates: Array<{ absPath: string; relPath: string }> = [
{ absPath: LEGACY_SUGGESTED_TOPICS_PATH, relPath: LEGACY_SUGGESTED_TOPICS_REL_PATH },
{ absPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH, relPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH },
];
for (const legacy of legacyCandidates) {
if (!fs.existsSync(legacy.absPath)) {
continue;
}
try {
fs.renameSync(legacy.absPath, SUGGESTED_TOPICS_PATH);
console.log(`[buildGraph] Moved suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}`);
return SUGGESTED_TOPICS_PATH;
} catch (error) {
console.error(`[buildGraph] Failed to move suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}:`, error);
return legacy.absPath;
}
}
return SUGGESTED_TOPICS_PATH;
}
function readSuggestedTopicsFile(): string {
try {
const suggestedTopicsPath = ensureSuggestedTopicsFileLocation();
if (!fs.existsSync(suggestedTopicsPath)) {
return '_No existing suggested topics file._';
}
const content = fs.readFileSync(suggestedTopicsPath, 'utf-8').trim();
return content.length > 0 ? content : '_Existing suggested topics file is empty._';
} catch (error) {
console.error(`[buildGraph] Error reading suggested topics file:`, error);
return '_Failed to read existing suggested topics file._';
}
}
/**
* Get unprocessed voice memo files from knowledge/Voice Memos/
* Voice memos are created directly in this directory by the UI.
@ -142,20 +235,6 @@ async function readFileContents(filePaths: string[]): Promise<{ path: string; co
return files;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
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();
}
});
});
}
/**
* Run note creation agent on a batch of files to extract entities and create/update notes
*/
@ -173,6 +252,7 @@ async function createNotesFromBatch(
const run = await createRun({
agentId: NOTE_CREATION_AGENT,
});
const suggestedTopicsContent = readSuggestedTopicsFile();
// Build message with index and all files in the batch
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
@ -180,8 +260,9 @@ async function createNotesFromBatch(
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`;
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
message += `- Follow the note templates and guidelines in your instructions\n\n`;
// Add the knowledge base index
@ -189,6 +270,11 @@ async function createNotesFromBatch(
message += knowledgeIndex;
message += `\n---\n\n`;
message += `# Current Suggested Topics File\n\n`;
message += `Path: ${SUGGESTED_TOPICS_REL_PATH}\n\n`;
message += suggestedTopicsContent;
message += `\n\n---\n\n`;
// Add each file's content
message += `# Source Files to Process\n\n`;
files.forEach((file, idx) => {
@ -366,16 +452,23 @@ export async function buildGraph(sourceDir: string): Promise<void> {
// Get files that need processing (new or changed)
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (sourceDir.endsWith('gmail_sync')) {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[buildGraph] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length === 0) {
@ -535,7 +628,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
/**
* Process all configured source directories
*/
async function processAllSources(): Promise<void> {
export async function processAllSources(): Promise<void> {
console.log('[GraphBuilder] Checking for new content in all sources...');
@ -568,16 +661,23 @@ async function processAllSources(): Promise<void> {
try {
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[GraphBuilder] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length > 0) {

View file

@ -0,0 +1,96 @@
# Page Capture Chrome Extension
A Chrome extension that captures web pages you visit and sends them to a local server for storage as markdown files.
## Structure
```
/extension
manifest.json # Chrome extension manifest (v3)
background.js # Service worker that captures pages
/server
server.py # Flask server for storing captures
captured_pages/ # Directory where pages are saved
```
## Setup
### 1. Install Server Dependencies
```bash
cd server
pip install flask flask-cors
```
### 2. Start the Server
```bash
cd server
python server.py
```
The server will run at `http://localhost:3001`.
### 3. Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `extension` folder
## Usage
Once both the server is running and the extension is installed, the extension will automatically capture pages as you browse:
- Every page load (http/https URLs only) triggers a capture
- Content is hashed with SHA-256 to avoid duplicate captures
- Pages are saved as markdown files with frontmatter metadata
## API Endpoints
### POST /capture
Receives captured page data.
**Request body:**
```json
{
"url": "https://example.com",
"content": "Page text content...",
"timestamp": 1706123456789,
"title": "Page Title"
}
```
**Response:**
```json
{"status": "captured", "filename": "1706123456789_example_com.md"}
```
### GET /status
Returns the count of captured pages.
**Response:**
```json
{"count": 42}
```
## File Format
Captured pages are saved as markdown with YAML frontmatter:
```markdown
---
url: https://example.com/page
title: Page Title
captured_at: 2024-01-24T12:34:56
---
Page content here...
```
## Debugging
- **Extension logs**: Open `chrome://extensions/`, find "Page Capture", click "Service worker" to view console logs
- **Server logs**: Check the terminal where `server.py` is running

View file

@ -0,0 +1,388 @@
const SERVER_URL = 'http://localhost:3001';
const contentHashMap = new Map();
let cachedConfig = null;
let serverReachable = true;
// Default config
const DEFAULT_CONFIG = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
// Config management
async function loadConfig() {
try {
const response = await fetch(`${SERVER_URL}/browse/config`);
if (response.ok) {
cachedConfig = await response.json();
serverReachable = true;
} else {
throw new Error('Server returned error');
}
} catch (error) {
console.log(`[Page Capture] Failed to load config: ${error.message}`);
serverReachable = false;
cachedConfig = cachedConfig || DEFAULT_CONFIG;
}
return cachedConfig;
}
async function saveConfig(config) {
try {
const response = await fetch(`${SERVER_URL}/browse/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (response.ok) {
cachedConfig = config;
serverReachable = true;
return true;
}
} catch (error) {
console.log(`[Page Capture] Failed to save config: ${error.message}`);
serverReachable = false;
}
return false;
}
function getConfig() {
return cachedConfig || DEFAULT_CONFIG;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function isWhitelisted(domain) {
const config = getConfig();
return config.whitelist.some(d => domain === d || domain.endsWith('.' + d));
}
function isBlacklisted(domain) {
const config = getConfig();
return config.blacklist.some(d => domain === d || domain.endsWith('.' + d));
}
function getDomainStatus(domain) {
const config = getConfig();
if (isBlacklisted(domain)) return 'blacklisted';
if (config.mode === 'all') return 'capturing';
if (isWhitelisted(domain)) return 'whitelisted';
return 'unknown';
}
function shouldCapture(domain) {
const config = getConfig();
if (!config.enabled) return false;
if (isBlacklisted(domain)) return false;
if (config.mode === 'all') return true;
return isWhitelisted(domain);
}
// Badge management
async function setBadge(tabId, type) {
try {
if (type === 'needs-approval') {
await chrome.action.setBadgeText({ tabId, text: '?' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#F59E0B' });
} else if (type === 'server-error') {
await chrome.action.setBadgeText({ tabId, text: '!' });
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#EF4444' });
} else {
await chrome.action.setBadgeText({ tabId, text: '' });
}
} catch (error) {
console.log(`[Page Capture] Failed to set badge: ${error.message}`);
}
}
async function updateBadgeForTab(tabId, url) {
if (!serverReachable) {
await setBadge(tabId, 'server-error');
return;
}
const domain = extractDomain(url);
if (!domain) {
await setBadge(tabId, 'clear');
return;
}
const status = getDomainStatus(domain);
if (status === 'unknown') {
await setBadge(tabId, 'needs-approval');
} else {
await setBadge(tabId, 'clear');
}
}
// Content hashing
async function hashContent(content) {
const encoder = new TextEncoder();
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
function isValidUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function capturePageContent(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => document.body.innerText
});
return results[0]?.result || '';
} catch (error) {
console.log(`[Page Capture] Failed to capture content: ${error.message}`);
return null;
}
}
async function sendToServer(data) {
try {
const response = await fetch(`${SERVER_URL}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
serverReachable = response.ok;
return response.ok;
} catch (error) {
console.log(`[Page Capture] Failed to send to server: ${error.message}`);
serverReachable = false;
return false;
}
}
async function captureTab(tabId, tab) {
const content = await capturePageContent(tabId);
if (content === null) return false;
const hash = await hashContent(content);
const lastHash = contentHashMap.get(tab.url);
if (lastHash === hash) {
console.log(`[Page Capture] Content unchanged for: ${tab.url}`);
return true;
}
contentHashMap.set(tab.url, hash);
const payload = {
url: tab.url,
content,
timestamp: Date.now(),
title: tab.title || 'Untitled'
};
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Captured: ${tab.url}`);
}
return success;
}
// Tab update listener
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
if (!isValidUrl(tab.url)) {
console.log(`[Page Capture] Skipping non-http URL: ${tab.url}`);
return;
}
const domain = extractDomain(tab.url);
if (!domain) return;
await updateBadgeForTab(tabId, tab.url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping (not whitelisted): ${tab.url}`);
return;
}
await captureTab(tabId, tab);
});
// Tab activated listener - update badge
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url && isValidUrl(tab.url)) {
await updateBadgeForTab(activeInfo.tabId, tab.url);
}
} catch (error) {
console.log(`[Page Capture] Failed to update badge on tab switch: ${error.message}`);
}
});
// Handle scroll capture messages from content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCROLL_CAPTURE') {
const { url, content, timestamp, title, scrollY } = message;
const domain = extractDomain(url);
if (!shouldCapture(domain)) {
console.log(`[Page Capture] Skipping scroll capture (not whitelisted): ${url}`);
return;
}
console.log(`[Page Capture] Received scroll capture for: ${url}`);
hashContent(content).then(async (hash) => {
const lastHash = contentHashMap.get(url);
if (lastHash === hash) {
console.log(`[Page Capture] Hash unchanged, skipping: ${url}`);
return;
}
contentHashMap.set(url, hash);
const payload = { url, content, timestamp, title };
const success = await sendToServer(payload);
if (success) {
console.log(`[Page Capture] Scroll captured (y=${scrollY}): ${url}`);
}
});
return;
}
// Handle messages from popup
if (message.type === 'GET_CONFIG') {
loadConfig().then(config => {
sendResponse({ config, serverReachable });
});
return true;
}
if (message.type === 'SAVE_CONFIG') {
saveConfig(message.config).then(success => {
sendResponse({ success });
// Update badges on all tabs
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'GET_DOMAIN_STATUS') {
const domain = extractDomain(message.url);
const status = domain ? getDomainStatus(domain) : 'unknown';
sendResponse({ status, domain, serverReachable });
return true;
}
if (message.type === 'APPROVE_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.whitelist.includes(domain)) {
config.whitelist.push(domain);
}
config.blacklist = config.blacklist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REJECT_DOMAIN') {
const config = getConfig();
const domain = message.domain;
if (!config.blacklist.includes(domain)) {
config.blacklist.push(domain);
}
config.whitelist = config.whitelist.filter(d => d !== domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'CAPTURE_ONCE') {
chrome.tabs.query({ active: true, currentWindow: true }, async tabs => {
if (tabs[0]) {
const success = await captureTab(tabs[0].id, tabs[0]);
sendResponse({ success });
} else {
sendResponse({ success: false });
}
});
return true;
}
if (message.type === 'REMOVE_FROM_WHITELIST') {
const config = getConfig();
config.whitelist = config.whitelist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
if (message.type === 'REMOVE_FROM_BLACKLIST') {
const config = getConfig();
config.blacklist = config.blacklist.filter(d => d !== message.domain);
saveConfig(config).then(success => {
sendResponse({ success });
chrome.tabs.query({}, tabs => {
tabs.forEach(tab => {
if (tab.url && isValidUrl(tab.url)) {
updateBadgeForTab(tab.id, tab.url);
}
});
});
});
return true;
}
});
// Load config on startup
loadConfig().then(() => {
console.log('[Page Capture] Config loaded');
});
console.log('[Page Capture] Service worker started');

View file

@ -0,0 +1,81 @@
const DEBOUNCE_MS = 800;
const MIN_SCROLL_PIXELS = 500;
const MIN_CONTENT_CHANGE = 100; // characters
let debounceTimer = null;
let lastCapturedContent = null;
let lastScrollTop = 0;
let scrollContainer = null;
function getScrollTop() {
if (!scrollContainer || scrollContainer === window) {
return window.scrollY;
}
if (scrollContainer === document) {
return document.documentElement.scrollTop;
}
return scrollContainer.scrollTop || 0;
}
function captureAndSend() {
const content = document.body.innerText;
// Skip if content unchanged or minimal change
if (lastCapturedContent) {
const lengthDiff = Math.abs(content.length - lastCapturedContent.length);
if (content === lastCapturedContent || lengthDiff < MIN_CONTENT_CHANGE) {
return;
}
}
lastCapturedContent = content;
lastScrollTop = getScrollTop();
chrome.runtime.sendMessage({
type: 'SCROLL_CAPTURE',
url: window.location.href,
title: document.title,
content: content,
timestamp: Date.now(),
scrollY: lastScrollTop
});
}
function onScroll() {
const currentScrollTop = getScrollTop();
const scrollDelta = Math.abs(currentScrollTop - lastScrollTop);
if (scrollDelta < MIN_SCROLL_PIXELS) {
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
captureAndSend();
}, DEBOUNCE_MS);
}
function init() {
// Use document with capture to catch scroll events from any element
document.addEventListener('scroll', (e) => {
const target = e.target;
const scrollTop = target === document ? document.documentElement.scrollTop : target.scrollTop;
// Update scroll container if we found the real one
if (scrollTop > 0 && scrollContainer !== target) {
scrollContainer = target;
}
onScroll();
}, { capture: true, passive: true });
}
// Wait for page to be ready, then init
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Rowboat Browser Capture",
"version": "1.1.1",
"description": "Allows users to save and capture web page content to their Rowboat workspace.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"tabs",
"scripting",
"activeTab"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rowboat</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
width: 320px;
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.domain {
font-weight: 500;
font-size: 14px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.approval-title {
font-weight: 500;
margin-bottom: 8px;
}
.approval-buttons {
display: flex;
gap: 8px;
}
.approval-buttons .btn {
flex: 1;
}
.toggle-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
margin-bottom: 12px;
}
.toggle-label {
font-size: 13px;
color: var(--text-secondary);
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
color: var(--error-color);
font-size: 13px;
}
.settings-section {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 4px;
}
.settings-title {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.settings-radio {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-radio label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
.settings-radio input[type="radio"] {
accent-color: var(--accent-color);
}
.stats {
display: flex;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border-color);
margin-top: 12px;
}
.stats-count {
font-size: 12px;
color: var(--text-muted);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="header">
<span class="domain" id="domainDisplay">-</span>
<span class="status-badge" id="statusBadge">
<span class="status-dot"></span>
<span id="statusText">-</span>
</span>
</div>
<div class="error-message hidden" id="errorMessage">
Cannot reach Rowboat app.
</div>
<div class="approval-section hidden" id="approvalSection">
<div class="approval-title">Index this site?</div>
<div class="approval-buttons">
<button class="btn btn-primary btn-sm" id="approveBtn">Yes, always</button>
<button class="btn btn-secondary btn-sm" id="rejectBtn">No</button>
</div>
<button class="btn btn-secondary btn-sm btn-block mt-2" id="captureOnceBtn">Just this page</button>
</div>
<div class="toggle-section hidden" id="toggleSection">
<span class="toggle-label" id="toggleLabel">Capturing this site</span>
<button class="btn btn-secondary btn-sm" id="toggleBtn">Stop</button>
</div>
<div class="settings-section">
<div class="settings-title">Settings</div>
<div class="settings-radio">
<label>
<input type="radio" name="captureMode" value="work">
Auto-index active tab
</label>
<label>
<input type="radio" name="captureMode" value="ask">
Ask me each time
</label>
</div>
</div>
<div class="stats">
<span class="stats-count" id="statsCount">-</span>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,258 @@
const SERVER_URL = 'http://localhost:3001';
let currentDomain = null;
let currentStatus = null;
let currentConfig = null;
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
function extractDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function updateStatusBadge(status, serverReachable) {
const badge = document.getElementById('statusBadge');
const statusText = document.getElementById('statusText');
badge.classList.remove('capturing', 'not-capturing', 'awaiting', 'error');
if (!serverReachable) {
badge.classList.add('error');
statusText.textContent = 'Error';
return;
}
switch (status) {
case 'whitelisted':
case 'capturing':
badge.classList.add('capturing');
statusText.textContent = 'Indexing';
break;
case 'blacklisted':
badge.classList.add('not-capturing');
statusText.textContent = 'Not indexing';
break;
case 'unknown':
badge.classList.add('awaiting');
statusText.textContent = 'Awaiting';
break;
default:
badge.classList.add('not-capturing');
statusText.textContent = 'Unknown';
}
}
function showApprovalSection(show) {
document.getElementById('approvalSection').classList.toggle('hidden', !show);
}
function showToggleSection(show, isCapturing) {
const section = document.getElementById('toggleSection');
const label = document.getElementById('toggleLabel');
const btn = document.getElementById('toggleBtn');
section.classList.toggle('hidden', !show);
if (isCapturing) {
label.textContent = 'Capturing this site';
btn.textContent = 'Stop';
btn.onclick = () => removeDomain('whitelist');
} else {
label.textContent = 'Not capturing this site';
btn.textContent = 'Start';
btn.onclick = () => removeDomain('blacklist');
}
}
function showError(show) {
document.getElementById('errorMessage').classList.toggle('hidden', !show);
}
// Settings section
function getSelectedMode(config) {
return config.mode === 'all' ? 'work' : 'ask';
}
function initSettings(config) {
currentConfig = config;
const mode = getSelectedMode(config);
const radio = document.querySelector(`input[name="captureMode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
async function saveSettingsFromUI() {
const selectedRadio = document.querySelector('input[name="captureMode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'ask';
let config;
if (mode === 'work') {
config = {
mode: 'all',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
} else {
config = {
mode: 'ask',
whitelist: currentConfig ? currentConfig.whitelist : [],
blacklist: currentConfig ? currentConfig.blacklist : [],
enabled: true
};
}
try {
await chrome.runtime.sendMessage({ type: 'SAVE_CONFIG', config });
currentConfig = config;
await loadStatus();
} catch (error) {
console.error('Failed to save settings:', error);
}
}
// Domain status
async function loadStatus() {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
document.getElementById('domainDisplay').textContent = 'No page';
return;
}
currentDomain = extractDomain(tab.url);
if (!currentDomain) {
document.getElementById('domainDisplay').textContent = 'Invalid URL';
return;
}
document.getElementById('domainDisplay').textContent = currentDomain;
try {
const response = await chrome.runtime.sendMessage({
type: 'GET_DOMAIN_STATUS',
url: tab.url
});
currentStatus = response.status;
const serverReachable = response.serverReachable;
updateStatusBadge(currentStatus, serverReachable);
showError(!serverReachable);
if (!serverReachable) {
showApprovalSection(false);
showToggleSection(false, false);
return;
}
if (currentStatus === 'unknown') {
showApprovalSection(true);
showToggleSection(false, false);
} else if (currentStatus === 'whitelisted' || currentStatus === 'capturing') {
showApprovalSection(false);
showToggleSection(true, true);
} else if (currentStatus === 'blacklisted') {
showApprovalSection(false);
showToggleSection(true, false);
} else {
showApprovalSection(false);
showToggleSection(false, false);
}
} catch (error) {
console.error('Failed to get status:', error);
showError(true);
}
}
async function loadStats() {
try {
const response = await fetch(`${SERVER_URL}/status`);
if (response.ok) {
const data = await response.json();
document.getElementById('statsCount').textContent = `${data.count} pages indexed locally`;
}
} catch (error) {
console.log('Failed to load stats:', error);
}
}
async function approveDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'APPROVE_DOMAIN', domain: currentDomain });
// Reload config to reflect the new whitelist in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to approve domain:', error);
}
}
async function rejectDomain() {
if (!currentDomain) return;
try {
await chrome.runtime.sendMessage({ type: 'REJECT_DOMAIN', domain: currentDomain });
await loadStatus();
} catch (error) {
console.error('Failed to reject domain:', error);
}
}
async function captureOnce() {
try {
const response = await chrome.runtime.sendMessage({ type: 'CAPTURE_ONCE' });
if (response.success) {
window.close();
}
} catch (error) {
console.error('Failed to capture:', error);
}
}
async function removeDomain(list) {
if (!currentDomain) return;
try {
const messageType = list === 'whitelist' ? 'REMOVE_FROM_WHITELIST' : 'REMOVE_FROM_BLACKLIST';
await chrome.runtime.sendMessage({ type: messageType, domain: currentDomain });
// Reload config to reflect changes in settings
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) initSettings(resp.config);
await loadStatus();
} catch (error) {
console.error('Failed to remove domain:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
// Load config and init settings
try {
const resp = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (resp && resp.config) {
initSettings(resp.config);
}
} catch (error) {
console.error('Failed to load config:', error);
}
// Radio change listeners
document.querySelectorAll('input[name="captureMode"]').forEach(radio => {
radio.addEventListener('change', () => saveSettingsFromUI());
});
loadStatus();
loadStats();
document.getElementById('approveBtn').addEventListener('click', approveDomain);
document.getElementById('rejectBtn').addEventListener('click', rejectDomain);
document.getElementById('captureOnceBtn').addEventListener('click', captureOnce);
});

View file

@ -0,0 +1,279 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: #e5e7eb;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #111827;
--bg-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #374151;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--success-color: #34d399;
--warning-color: #fbbf24;
--error-color: #f87171;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--accent-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.btn-ghost {
background-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn-block {
width: 100%;
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.capturing {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.status-badge.not-capturing {
background-color: rgba(107, 114, 128, 0.1);
color: var(--text-secondary);
}
.status-badge.awaiting {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning-color);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error-color);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
}
/* Cards */
.card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
}
/* Form elements */
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.radio-option:hover {
border-color: var(--accent-color);
background-color: var(--bg-secondary);
}
.radio-option.selected {
border-color: var(--accent-color);
background-color: rgba(59, 130, 246, 0.05);
}
.radio-option input[type="radio"] {
margin-top: 2px;
accent-color: var(--accent-color);
}
.radio-option-content {
flex: 1;
}
.radio-option-title {
font-weight: 500;
color: var(--text-primary);
}
.radio-option-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Toggle/Checkbox */
.toggle-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding-left: 24px;
}
.toggle-item {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-item input[type="checkbox"] {
accent-color: var(--accent-color);
}
.toggle-item label {
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
/* Divider */
.divider {
height: 1px;
background-color: var(--border-color);
margin: 12px 0;
}
/* Link */
.link {
color: var(--accent-color);
text-decoration: none;
font-size: 13px;
}
.link:hover {
text-decoration: underline;
}
/* Text utilities */
.text-sm {
font-size: 12px;
}
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-center {
text-align: center;
}
/* Spacing utilities */
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 12px; }
.mb-4 { margin-bottom: 16px; }
/* Flex utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }

View file

@ -0,0 +1,281 @@
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../../../config/config.js';
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const CAPTURED_PAGES_DIR = path.join(WorkDir, 'chrome_sync');
const CONFIG_DIR = path.join(WorkDir, 'config');
const CONFIG_FILE = path.join(CONFIG_DIR, 'chrome-plugin.json');
interface Config {
mode: 'all' | 'ask';
whitelist: string[];
blacklist: string[];
enabled: boolean;
}
const DEFAULT_CONFIG: Config = {
mode: 'ask',
whitelist: [],
blacklist: [],
enabled: true
};
const contentHashes = new Map<string, string>();
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.host || 'unknown';
} catch {
return 'unknown';
}
}
function pathToSlug(url: string): string {
try {
const parsed = new URL(url);
const p = parsed.pathname + (parsed.search || '');
if (!p || p === '/') return 'index';
let slug = p.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '');
return slug.substring(0, 80) || 'index';
} catch {
return 'index';
}
}
function hashContent(content: string): string {
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
}
function findExistingFile(domainDir: string, pathSlug: string): string | null {
if (!fs.existsSync(domainDir)) return null;
const files = fs.readdirSync(domainDir);
for (const filename of files) {
if (filename.endsWith(`_${pathSlug}.md`)) {
return path.join(domainDir, filename);
}
}
return null;
}
// POST /capture
app.post('/capture', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
const { url, content = '', timestamp, title = 'Untitled' } = data;
if (!url || !timestamp) {
return res.status(400).json({ error: 'Missing required fields: url, timestamp' });
}
const domain = extractDomain(url);
const pathSlug = pathToSlug(url);
const contentHash = hashContent(content);
const cacheKey = `${domain}/${pathSlug}`;
const dt = new Date(timestamp);
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const hours = String(dt.getHours()).padStart(2, '0');
const minutes = String(dt.getMinutes()).padStart(2, '0');
const seconds = String(dt.getSeconds()).padStart(2, '0');
const timeStr = `${hours}-${minutes}`;
const timeDisplay = `${hours}:${minutes}:${seconds}`;
const tzOffset = -dt.getTimezoneOffset();
const tzSign = tzOffset >= 0 ? '+' : '-';
const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0');
const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0');
const isoTimestamp = `${dateStr}T${hours}:${minutes}:${seconds}${tzSign}${tzHours}:${tzMins}`;
// date/domain directory structure
const domainDir = path.join(CAPTURED_PAGES_DIR, dateStr, domain);
fs.mkdirSync(domainDir, { recursive: true });
const existingFile = findExistingFile(domainDir, pathSlug);
if (existingFile && contentHashes.get(cacheKey) === contentHash) {
return res.json({ status: 'skipped', reason: 'duplicate content' });
}
contentHashes.set(cacheKey, contentHash);
// If file exists, append with scroll separator
if (existingFile) {
const scrollSeparator = `\n\n---\n📜 Scroll captured at ${timeDisplay}\n---\n\n`;
fs.appendFileSync(existingFile, scrollSeparator + content, 'utf-8');
const rel = `${dateStr}/${domain}/${path.basename(existingFile)}`;
return res.json({ status: 'appended', filename: rel });
}
// New file - create with frontmatter
const filename = `${timeStr}_${pathSlug}.md`;
const filepath = path.join(domainDir, filename);
const markdownContent = `---
url: ${url}
title: ${title}
captured_at: ${isoTimestamp}
---
${content}
`;
fs.writeFileSync(filepath, markdownContent, 'utf-8');
return res.status(201).json({ status: 'captured', filename: `${dateStr}/${domain}/${filename}` });
});
// GET /status
app.get('/status', (_req, res) => {
let count = 0;
const domains: Record<string, number> = {};
if (!fs.existsSync(CAPTURED_PAGES_DIR)) {
return res.json({ count: 0, domains: [] });
}
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
for (const domainEntry of fs.readdirSync(datePath)) {
const domainPath = path.join(datePath, domainEntry);
if (!fs.statSync(domainPath).isDirectory()) continue;
const domainCount = fs.readdirSync(domainPath).filter(f => f.endsWith('.md')).length;
count += domainCount;
if (domainCount > 0) {
domains[domainEntry] = (domains[domainEntry] || 0) + domainCount;
}
}
}
const domainList = Object.entries(domains)
.map(([domain, c]) => ({ domain, count: c }))
.sort((a, b) => b.count - a.count);
return res.json({ count, domains: domainList });
});
// Config helpers
function loadConfig(): Config {
if (fs.existsSync(CONFIG_FILE)) {
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
// fall through
}
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: Config): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
}
function validateConfig(data: any): data is Config {
if (typeof data !== 'object' || data === null) return false;
if (data.mode !== 'all' && data.mode !== 'ask') return false;
if (!Array.isArray(data.whitelist)) return false;
if (!Array.isArray(data.blacklist)) return false;
if (typeof data.enabled !== 'boolean') return false;
return true;
}
// GET /browse/config
app.get('/browse/config', (_req, res) => {
const config = loadConfig();
return res.json(config);
});
// POST /browse/config
app.post('/browse/config', (req, res) => {
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'No JSON data provided' });
}
if (!validateConfig(data)) {
return res.status(400).json({ error: 'Invalid config shape' });
}
saveConfig(data);
return res.json({ status: 'saved', config: data });
});
const PORT = 3001;
const RETENTION_DAYS = 7;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
function cleanUpOldFiles(): void {
if (!fs.existsSync(CAPTURED_PAGES_DIR)) return;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - RETENTION_DAYS);
const cutoffStr = cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
for (const dateEntry of fs.readdirSync(CAPTURED_PAGES_DIR)) {
// only process date-formatted directories
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateEntry)) continue;
if (dateEntry >= cutoffStr) continue;
const datePath = path.join(CAPTURED_PAGES_DIR, dateEntry);
if (!fs.statSync(datePath).isDirectory()) continue;
fs.rmSync(datePath, { recursive: true, force: true });
console.log(`[ChromeSync] Cleaned up old captures: ${dateEntry}`);
}
}
function isServerEnabled(): boolean {
if (!fs.existsSync(CONFIG_FILE)) return false;
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const config = JSON.parse(raw);
return config.serverEnabled === true;
} catch {
return false;
}
}
function startServer(): void {
fs.mkdirSync(CAPTURED_PAGES_DIR, { recursive: true });
cleanUpOldFiles();
setInterval(cleanUpOldFiles, CLEANUP_INTERVAL_MS);
app.listen(PORT, 'localhost', () => {
console.log('[ChromeSync] Server starting.');
console.log(` Captured pages: ${CAPTURED_PAGES_DIR}`);
console.log(` Config: ${CONFIG_FILE}`);
console.log(` Listening on http://localhost:${PORT}`);
});
}
export async function init(): Promise<void> {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
if (isServerEnabled()) {
startServer();
return;
}
console.log('[ChromeSync] Server disabled, watching config for changes...');
fs.watch(CONFIG_DIR, (_, filename) => {
if (filename === 'chrome-plugin.json' && isServerEnabled()) {
console.log('[ChromeSync] serverEnabled set to true, starting server...');
startServer();
}
});
}

View file

@ -0,0 +1,167 @@
import path from 'path';
import fs from 'fs';
import { stringify as stringifyYaml } from 'yaml';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { WorkDir } from '../config/config.js';
import z from 'zod';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
interface Section {
heading: string;
track: z.infer<typeof TrackBlockSchema>;
}
const SECTIONS: Section[] = [
{
heading: '## ⏱ Up Next',
track: {
trackId: 'up-next',
instruction:
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't started yet.
Lead based on how soon the next event is:
- Under 15 minutes urgent ("Standup starts in 10 minutes — join link in the Calendar section below.")
- Under 2 hours lead with the event ("Design review in 40 minutes.")
- 2+ hours frame the gap as focus time ("Next up is standup at noon — you've got a solid 3-hour focus block.")
Always compute minutes-to-start against the actual current local time never say "nothing in the next X hours" if an event is in that window.
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
If nothing remains today, output exactly: Clear for the rest of the day.
Plain markdown prose only no calendar block, no email block, no headings.`,
eventMatchCriteria:
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
active: true,
schedule: {
type: 'cron',
expression: '*/15 * * * *',
},
},
},
{
heading: '## 📅 Calendar',
track: {
trackId: 'calendar',
instruction:
`Emit today's meetings as a calendar block titled "Today's Meetings".
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended only include meetings that haven't ended yet.
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight one line each, only when meaningful. Skip routine/recurring meetings.`,
eventMatchCriteria:
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
active: true,
schedule: {
type: 'cron',
expression: '0 * * * *',
},
},
},
{
heading: '## 📧 Emails',
track: {
trackId: 'emails',
instruction:
`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread).
Event-driven path (primary): the agent message will include a freshly-synced thread's markdown as the event payload. Decide whether THIS thread warrants surfacing. If it's marketing, an auto-notification, a thread already closed out, or otherwise low-signal, skip the update do NOT call update-track-content. If it's attention-worthy, integrate it into the digest: add a new email block, or update the existing one if the same threadId is already shown.
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice direct, informal, no fluff. For FYI threads, omit draft_response.
If there is genuinely nothing to surface, output the single line: No new emails.
Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`,
eventMatchCriteria:
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
active: true,
},
},
{
heading: '## 📰 What You Missed',
track: {
trackId: 'what-you-missed',
instruction:
`Short markdown summary of what happened yesterday that matters this morning.
Data sources:
- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments.
- gmail_sync/ skim for threads from yesterday that went unresolved or still need a reply.
Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them.
Write concise markdown a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today.
If nothing notable happened, output exactly: Quiet day yesterday nothing to flag.
Do NOT manufacture content to fill the section.`,
active: true,
schedule: {
type: 'cron',
expression: '0 7 * * *',
},
},
},
{
heading: '## ✅ Today\'s Priorities',
track: {
trackId: 'priorities',
instruction:
`Ranked markdown list of the real, actionable items the user should focus on today.
Data sources:
- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ action items assigned to the user are often the most important source.
- knowledge/ use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups.
- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section useful for alignment.
Rules:
- Do NOT list calendar events as tasks they're already in the Calendar section.
- Do NOT list trivial admin (filing small invoices, archiving spam).
- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review").
- Add a brief reason for each item when it's not self-evident.
If nothing genuinely needs attention, output exactly: No pressing tasks today good day to make progress on bigger items.
Do NOT invent busywork.`,
active: true,
schedule: {
type: 'cron',
expression: '30 7 * * *',
},
},
},
];
function buildDailyNoteContent(): string {
const parts: string[] = ['# Today', ''];
for (const { heading, track } of SECTIONS) {
const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd();
parts.push(
heading,
'',
'```track',
yaml,
'```',
'',
`<!--track-target:${track.trackId}-->`,
`<!--/track-target:${track.trackId}-->`,
'',
);
}
return parts.join('\n');
}
export function ensureDailyNote(): void {
if (fs.existsSync(DAILY_NOTE_PATH)) return;
fs.writeFileSync(DAILY_NOTE_PATH, buildDailyNoteContent(), 'utf-8');
console.log('[DailyNote] Created today.md');
}

View file

@ -0,0 +1,18 @@
const locks = new Map<string, Promise<void>>();
export async function withFileLock<T>(absPath: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(absPath) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
const myTail = prev.then(() => gate);
locks.set(absPath, myTail);
try {
await prev;
return await fn();
} finally {
release();
if (locks.get(absPath) === myTail) {
locks.delete(absPath);
}
}
}

View file

@ -135,7 +135,7 @@ export class FirefliesClientFactory {
}
console.log(`[Fireflies] Initializing OAuth configuration...`);
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
if (providerConfig.discovery.mode === 'issuer') {
if (providerConfig.client.mode === 'static') {

View file

@ -18,21 +18,23 @@ export class GoogleClientFactory {
client: OAuth2Client | null;
tokens: OAuthTokens | null;
clientId: string | null;
clientSecret: string | null;
} = {
config: null,
client: null,
tokens: null,
clientId: null,
clientSecret: null,
};
private static async resolveClientId(): Promise<string> {
private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
if (!clientId) {
const connection = await oauthRepo.read(this.PROVIDER_NAME);
if (!connection.clientId) {
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
throw new Error('Google client ID missing. Please reconnect.');
}
return clientId;
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
}
/**
@ -82,9 +84,11 @@ export class GoogleClientFactory {
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
}
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
console.log(`[OAuth] Token refreshed successfully`);
return this.cache.client;
} catch (error) {
@ -105,9 +109,11 @@ export class GoogleClientFactory {
console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens;
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
}
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
return this.cache.client;
}
@ -138,24 +144,25 @@ export class GoogleClientFactory {
this.cache.client = null;
this.cache.tokens = null;
this.cache.clientId = null;
this.cache.clientSecret = null;
}
/**
* Initialize cached configuration (called once)
*/
private static async initializeConfigCache(): Promise<void> {
const clientId = await this.resolveClientId();
const { clientId, clientSecret } = await this.resolveCredentials();
if (this.cache.config && this.cache.clientId === clientId) {
return; // Already initialized for this client ID
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
return; // Already initialized for these credentials
}
if (this.cache.clientId && this.cache.clientId !== clientId) {
if (this.cache.clientId && (this.cache.clientId !== clientId || this.cache.clientSecret !== (clientSecret ?? null))) {
this.clearCache();
}
console.log(`[OAuth] Initializing Google OAuth configuration...`);
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
const providerConfig = await getProviderConfig(this.PROVIDER_NAME);
if (providerConfig.discovery.mode === 'issuer') {
if (providerConfig.client.mode === 'static') {
@ -163,18 +170,19 @@ export class GoogleClientFactory {
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
clientId
clientId,
clientSecret
);
} else {
// DCR mode - need existing registration
console.log(`[OAuth] Discovery mode: issuer with DCR`);
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
if (!existingRegistration) {
throw new Error('Google client not registered. Please connect account first.');
}
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
existingRegistration.client_id
@ -185,28 +193,29 @@ export class GoogleClientFactory {
if (providerConfig.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
console.log(`[OAuth] Using static endpoints (no discovery)`);
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
clientId,
providerConfig.discovery.revocationEndpoint
providerConfig.discovery.revocationEndpoint,
clientSecret
);
}
this.cache.clientId = clientId;
this.cache.clientSecret = clientSecret ?? null;
console.log(`[OAuth] Google OAuth configuration initialized`);
}
/**
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
const client = new OAuth2Client(
clientId,
undefined, // client_secret not needed for PKCE
clientSecret ?? undefined,
undefined // redirect_uri not needed for token usage
);

View file

@ -24,7 +24,7 @@ const API_DELAY_MS = 1000; // 1 second delay between API calls
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
const LOOKBACK_DAYS = 30; // Only sync documents from the last 30 days
const LOOKBACK_DAYS = 7; // Only sync documents from the last 1 week
// --- Wake Signal for Immediate Sync Trigger ---
let wakeResolve: (() => void) | null = null;

View file

@ -74,6 +74,139 @@ Current UTC time: ${nowISO}
- For scheduled tasks: output ONLY the two markers (schedule + instruction), nothing else.
- Do not modify the original note file the system handles all insertions.
# Daily Brief
When the instruction is to "create a daily brief" (or similar), generate a comprehensive daily briefing.
## Your Role
You are the user's executive assistant — think of yourself as a sharp, reliable chief of staff who's been working with them for years. You know their priorities, you've read through their emails and calendar, and you're keeping them oriented throughout the day.
This brief refreshes every 15 minutes, so it should always reflect the **current moment** not just a static morning summary. Think of it as a living dashboard: what's happening now, what's coming up soon, what landed in the inbox since last refresh, and what still needs attention.
**Personality guidelines:**
- Be warm but efficient. A real EA doesn't waste their boss's time with filler, but they're not robotic either.
- Lead with what matters *right now*. If a meeting starts in 20 minutes, that's the first thing they should see. If an important email just came in, flag it.
- Add brief, useful context don't just list events and emails, connect the dots. ("You've got standup in 30 mins Ramnique mentioned the OAuth flow yesterday, so that'll probably come up.")
- Be opinionated when helpful. If an email is clearly spam or a cold pitch not worth their time, say so. ("Another cold outreach from a dev tools company — safe to ignore.")
- Skip the obvious. Don't tell them to "join" a recurring meeting they attend every day. Don't list trivial invoices as action items.
- If nothing notable happened, say so don't pad the brief.
- Write like a person, not a data pipeline. Short sentences, natural language, no unnecessary bullet nesting.
- **Be time-aware.** Your tone and content should shift throughout the day:
- Morning: fuller brief with yesterday's recap and the full day ahead
- Midday: focus on what's coming up next and any new emails/updates
- Late afternoon/evening: wind-down tone, surface anything unresolved, preview tomorrow if calendar data is available
## Technical Instructions
**IMPORTANT:** All workspace tools (workspace-readdir, workspace-readFile, workspace-grep, etc.) take paths **relative to the workspace root**. Use paths like \`calendar_sync/\`, \`gmail_sync/\`, \`knowledge/\` — NOT absolute paths.
**IMPORTANT:** Check the current date. If the date has changed since the content was last generated, clear everything and start fresh for the new day.
## Output structure
Your output MUST start with the current date and time as a heading:
\`## Monday, March 31, 2026\`
(Use the actual current date in this format: **## Day, Month Date, Year**)
Then include the sections below. The sections are ordered by immediacy what matters right now comes first. Between sections, you can add brief connective commentary where it's genuinely useful (e.g., a heads-up about something time-sensitive), but don't force it.
**Time-of-day logic for sections:**
- **Morning (before 10am):** Include all sections: Up Next, Calendar, Emails, What You Missed, Today's Priorities
- **Midday (10am5pm):** Include all sections. Keep Calendar but only show remaining events. Focus Emails on what's new since last check.
- **Evening (after 5pm):** Include all sections. Add a brief "Tomorrow" note if there are early morning events.
## Sections to include
### Up Next
This is the most time-sensitive section it orients the user on what's coming. It should always be first.
1. Read calendar events from \`calendar_sync/\` (same method as Calendar section below)
2. Find the **next upcoming event** (the soonest event that hasn't started yet). Calculate exactly how long until it starts.
3. If there's an upcoming event today:
- Always mention it and how long until it starts (e.g., "Standup in 25 minutes", "Design review in 1 hour 40 minutes")
- If it's **more than 2 hours away**, frame it as focus time: "Next up is standup at noon — you've got a solid 3-hour focus block."
- If it's **under 2 hours**, lead with the event: "Standup in 40 minutes."
- If it's **under 15 minutes**, make it prominent: "Standup starts in 10 minutes — join link is in the calendar below."
- Search \`knowledge/\` for context about the meeting, attendees, or related topics
- If there's something to prep or be aware of, mention it ("Ramnique pushed the OAuth PR yesterday — might come up")
4. If there's truly nothing left today, say so ("Clear for the rest of the day")
5. **This section should feel like a quick tap on the shoulder**, not a formal briefing. One to three sentences max.
6. **IMPORTANT:** Do NOT say "nothing in the next X hours" if there IS an event within that window. Always compute the actual time difference between now and the next event's start time before writing this section.
### Calendar
1. Use \`workspace-readdir\` with path \`calendar_sync\` to list files
2. Use \`workspace-readFile\` to read each \`.json\` event file (e.g. \`calendar_sync/eventid123.json\`)
3. Filter for events happening **today** (compare the event's start dateTime or date to the current date)
4. **After morning:** Only include events that **haven't ended yet**. Don't show meetings that already happened the user was there. If it's afternoon and all meetings are done, show an empty calendar block.
5. **Always** output a \\\`\\\`\\\`calendar block — even if there are no events today. If no events, output an empty events array:
\`\`\`
\\\`\\\`\\\`calendar
{"title":"Today's Meetings","events":[],"showJoinButton":false}
\\\`\\\`\\\`
\`\`\`
If there are events, include them:
\`\`\`
\\\`\\\`\\\`calendar
{"title":"Today's Meetings","events":[{"summary":"Weekly Sync","start":{"dateTime":"2026-04-01T10:00:00+05:30"},"end":{"dateTime":"2026-04-01T11:00:00+05:30"},"location":"Google Meet","htmlLink":"...","conferenceLink":"..."}],"showJoinButton":true}
\\\`\\\`\\\`
\`\`\`
6. After the calendar block, add brief context for any upcoming meetings that need it. Search \`knowledge/\` for relevant notes about attendees, topics, or previous discussions. Don't just restate the meeting title — add something useful like what was discussed last time, what's likely on the agenda, or if there's something to prep.
7. If there are no remaining events, don't add filler text the empty calendar block speaks for itself.
### Emails
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
4. For emails needing a response, output \\\`\\\`\\\`email blocks with a \`draft_response\`. Write the draft in the user's voice — direct, informal, no fluff. Example:
\`\`\`
\\\`\\\`\\\`email
{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."}
\\\`\\\`\\\`
\`\`\`
5. For other important/recent emails, output \\\`\\\`\\\`email blocks without \`draft_response\` as FYI items
6. **Recency matters.** Since this refreshes every 15 minutes, prioritize emails that arrived since the last refresh. On the first run of the day (morning), include notable emails from the last 24 hours. On subsequent runs, focus on what's new — don't re-list emails the user has already seen unless their status changed (e.g., a thread got a new reply).
7. Add a brief take on emails where it's helpful — flag what's worth reading vs. what's noise. Be direct: "This is a cold pitch, probably skip" or "Worth reading — they're asking about pricing for a team of 50."
8. If no new emails have come in since the last refresh, just say "No new emails" or omit the section entirely. Don't re-surface stale items.
### What You Missed
This section is about things the user might not be aware of from yesterday. Think of it as: "Here's what happened while you were away."
- **Skip recurring/routine events entirely.** The user knows they have standup every day. Don't mention it unless something unusual happened during it.
- **Read yesterday's meeting notes** from \`knowledge/Meetings/\`. The directory structure is nested: \`knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md\` (e.g. \`knowledge/Meetings/rowboat/2026-03-30/meeting-2026-03-30T13-49-27.md\`). Use \`workspace-readdir\` with \`recursive: true\` on \`knowledge/Meetings\` to find all files, then filter for files in a folder matching yesterday's date. Read the matching files with \`workspace-readFile\`. Summarize key outcomes: decisions made, action items assigned, blockers raised, anything that changes priorities.
- Check yesterday's emails in \`gmail_sync/\` for anything that went unresolved.
- Surface things that matter: commitments made, deadlines mentioned, important updates.
- **If nothing notable happened, say "Quiet day yesterday — nothing to flag." and move on.** Don't manufacture content.
### Today's Priorities
This is NOT a generic task list. These are the things the user should actually focus on today.
- Only include **real, actionable items** that genuinely need the user's attention today.
- **Do NOT list calendar events as tasks.** They're already in the Calendar section.
- **Do NOT list trivial admin** (filing small invoices, archiving spam, etc.) the user can handle that in 30 seconds without being told to.
- **Pull action items from yesterday's meeting notes** in \`knowledge/Meetings/<source>/<YYYY-MM-DD>/\` — these are often the most important source of real tasks.
- Search through \`knowledge/\` using \`workspace-grep\` and \`workspace-readdir\` for checkbox items (\`- [ ]\`), explicit action items, deadlines, or follow-ups.
- **Rank by importance.** Lead with the most critical item. If something is time-sensitive, say when it needs to happen by.
- Add brief context for why each item matters if it's not obvious.
- **If there are no real tasks, say "No pressing tasks today — good day to make progress on bigger items." Don't invent busywork.**
## Output format
- Start with the date heading as described above
- Use clean markdown with the section headers (## Up Next, ## Calendar, ## Emails, ## What You Missed, ## Today's Priorities)
- Use \\\`\\\`\\\`calendar and \\\`\\\`\\\`email code blocks where specified — these render as interactive UI blocks
- Keep the overall brief **scannable and concise** this should take under 30 seconds to read on a refresh, under 60 seconds for the morning brief
- Write in a natural, conversational tone throughout you're briefing a person, not generating a report
- **Sections can be omitted** if they have nothing to show. Don't include empty sections with filler text. The brief should get shorter as the day goes on and things get resolved.
- Remember: this refreshes every 15 minutes. Be fresh, not repetitive. If nothing changed, keep it tight.
# Target Regions
For recurring/scheduled tasks, the note will contain a **target region** delimited by HTML comment tags:
@ -89,4 +222,4 @@ When you see a target region associated with your task (during a scheduled run),
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
- Do NOT include the target tags themselves in your response
`;
}
}

View file

@ -4,11 +4,11 @@ import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { createProvider } from '../models/models.js';
import { inlineTask } from '@x/shared';
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const INLINE_TASK_AGENT = 'inline_task_agent';
@ -129,46 +129,6 @@ function scanDirectoryRecursive(dir: string): string[] {
return files;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
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();
}
});
});
}
/**
* Extract the assistant's final text response from a run's log.
*/
async function extractAgentResponse(runId: string): Promise<string | null> {
const run = await fetchRun(runId);
// Walk backwards through the log to find the last assistant message
for (let i = run.log.length - 1; i >= 0; i--) {
const event = run.log[i];
if (event.type === 'message' && event.message.role === 'assistant') {
const content = event.message.content;
if (typeof content === 'string') {
return content;
}
// Content may be an array of parts — concatenate text parts
if (Array.isArray(content)) {
const text = content
.filter((p) => p.type === 'text')
.map((p) => (p as { type: 'text'; text: string }).text)
.join('');
return text || null;
}
}
}
return null;
}
interface InlineTask {
instruction: string;
schedule: InlineTaskSchedule | null;

View file

@ -3,6 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -12,8 +13,9 @@ import {
type LabelingState,
} from './labeling_state.js';
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const BATCH_SIZE = 15;
const DEFAULT_CONCURRENCY = 3;
const LABELING_AGENT = 'labeling_agent';
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const MAX_CONTENT_LENGTH = 8000;
@ -61,20 +63,6 @@ function getUnlabeledEmails(state: LabelingState): string[] {
return unlabeled;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
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();
}
});
});
}
/**
* Label a batch of email files using the labeling agent
*/
@ -129,7 +117,7 @@ async function labelEmailBatch(
/**
* Process all unlabeled emails in batches
*/
async function processUnlabeledEmails(): Promise<void> {
export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCURRENCY): Promise<void> {
console.log('[EmailLabeling] Checking for unlabeled emails...');
const state = loadLabelingState();
@ -140,7 +128,7 @@ async function processUnlabeledEmails(): Promise<void> {
return;
}
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails (concurrency: ${concurrency})`);
const run = await serviceLogger.startRun({
service: 'email_labeling',
@ -161,69 +149,81 @@ async function processUnlabeledEmails(): Promise<void> {
truncated: limitedFiles.truncated,
});
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
let totalEdited = 0;
let hadError = false;
// Build all batches upfront
const batches: { batchNumber: number; files: { path: string; content: string }[] }[] = [];
for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) {
const batchPaths = unlabeled.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
try {
// Read file contents for the batch
const files: { path: string; content: string }[] = [];
for (const filePath of batchPaths) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
files.push({ path: filePath, content });
} catch (error) {
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
}
const files: { path: string; content: string }[] = [];
for (const filePath of batchPaths) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
files.push({ path: filePath, content });
} catch (error) {
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
}
if (files.length === 0) {
continue;
}
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
await serviceLogger.log({
type: 'progress',
service: run.service,
runId: run.runId,
level: 'info',
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
step: 'batch',
current: batchNumber,
total: totalBatches,
details: { filesInBatch: files.length },
});
const result = await labelEmailBatch(files);
totalEdited += result.filesEdited.size;
// Only mark files that were actually edited by the agent
for (const file of files) {
const relativePath = path.relative(WorkDir, file.path);
if (result.filesEdited.has(relativePath)) {
markFileAsLabeled(file.path, state);
}
}
saveLabelingState(state);
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
} catch (error) {
hadError = true;
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
context: { batchNumber },
});
}
if (files.length > 0) {
batches.push({ batchNumber, files });
}
}
const totalBatches = batches.length;
let totalEdited = 0;
let hadError = false;
// Process batches with concurrency limit
for (let i = 0; i < batches.length; i += concurrency) {
const chunk = batches.slice(i, i + concurrency);
const promises = chunk.map(async ({ batchNumber, files }) => {
try {
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
await serviceLogger.log({
type: 'progress',
service: run.service,
runId: run.runId,
level: 'info',
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
step: 'batch',
current: batchNumber,
total: totalBatches,
details: { filesInBatch: files.length },
});
const result = await labelEmailBatch(files);
// Only mark files that were actually edited by the agent
for (const file of files) {
const relativePath = path.relative(WorkDir, file.path);
if (result.filesEdited.has(relativePath)) {
markFileAsLabeled(file.path, state);
}
}
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
return result.filesEdited.size;
} catch (error) {
hadError = true;
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
context: { batchNumber },
});
return 0;
}
});
const results = await Promise.all(promises);
totalEdited += results.reduce((sum, n) => sum + n, 0);
// Save state after each concurrent chunk completes
saveLabelingState(state);
}
state.lastRunTime = new Date().toISOString();

View file

@ -18,15 +18,147 @@ tools:
You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels.
# Email File Format
Each email is a markdown file with this structure:
\`\`\`
# {Subject line}
**Thread ID:** {hex_id}
**Message Count:** {n}
---
### From: {Display Name} <{email@address}>
**Date:** {RFC 2822 date}
{Plain-text body of the message}
---
### From: {Another Sender} <{email@address}>
**Date:** {RFC 2822 date}
{Next message in thread}
---
\`\`\`
- The \`# Subject\` heading is always the first line.
- Multi-message threads have multiple \`### From:\` blocks in chronological order, separated by \`---\`.
- Single-message threads have \`Message Count: 1\` and one \`### From:\` block.
- The body is plain text extracted from the email (HTML converted to markdown-ish text).
Use the **Subject**, **From** addresses, **Message Count**, and **body content** to classify the email.
${renderTagSystemForEmails()}
# Instructions
1. For each email file provided in the message, read its content carefully.
2. Classify the email using the taxonomy above. Be accurate and conservative only apply labels that clearly fit.
3. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
5. If the email already has frontmatter (starts with \`---\`), skip it.
2. Classify the email using the taxonomy above. Think like a **YC startup founder** triaging their inbox your time is your scarcest resource:
- **Relationship**: Who is this from? An investor, customer, team member, vendor, candidate, etc.?
- **Topic**: What is this about? Legal, finance, hiring, fundraising, security, infrastructure, etc.?
- **Email Type**: Is this a warm intro or a followup on an existing conversation?
- **Filter (Noise)**: Is this email noise? **Apply ALL applicable filter tags.** If even one noise tag is present the email is skipped noise overrides everything. Common noise:
- Cold outreach / unsolicited service pitches / "YC exclusive" deals / freelancers offering free work
- Newsletters, industry reports, webinar invitations, product tips from vendors
- Promotions, marketing, event invitations you did not register for, startup program upsells
- Automated notifications (email verifications, recording uploads, platform policy changes, expired OTPs)
- Transactional confirmations (salary disbursements, tax payments, GST filings, TDS workings, invoice-sharing threads)
- Spam and spam moderation digests
- **Action**: Does this need a response (\`action-required\`), is it time-sensitive (\`urgent\`), or are you waiting on them (\`waiting\`)? Use \`""\` if none apply. **Do NOT use \`fyi\` as an action value** — it is not a valid action tag.
3. **Apply noise tags aggressively.** Noise tags can and should coexist with relationship and topic tags. A salary confirmation from your finance team should have BOTH \`relationship: ['team']\` AND \`filter: ['receipt']\`. The noise tag determines whether a note is created — it overrides relationship and topic signals.
4. Be accurate only apply labels that clearly fit. But when an email IS noise, always add the noise tag even when other tags are present.
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
6. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
7. If the email already has frontmatter (starts with \`---\`), skip it.
# The Founder Signal Test
Before finalizing labels, ask: **"Would a busy YC founder want a note about this in their knowledge system?"**
**YES create a note** if the email:
- Requires a decision or response from the founder
- Updates an active business relationship (customer deal, investor conversation, partner integration)
- Contains information that will be referenced later (pricing, terms, deadlines, compliance requirements)
- Has action items for the team (e.g. standup notes, meeting notes with to-dos)
- Presents a genuine opportunity worth evaluating (accelerator, partnership, relevant hire)
- Flags a risk that needs attention (security vulnerability, legal issue, compliance blocker)
- Is from a vendor you are actively engaged with on an ongoing process (e.g. your compliance assessor following up after a call you participated in)
**NO skip it** if the email:
- Confirms a transaction that already happened with no open decision (payment received, tax filed, salary disbursed, invoice shared)
- Is a system-generated notification with no decision needed (email verification, recording uploaded, policy update, expired OTP)
- Is unsolicited outreach from someone you have never engaged with regardless of how personalized it sounds
- Is a newsletter, industry report, webinar invitation, or product tips email
- Is marketing or promotional content, including from vendors you use
- Is a spam digest or Google Groups moderation report
- Is routine operational correspondence where the transaction is complete and no follow-up remains
# Cold Outreach Detection (Critical for Precision)
Many emails disguise themselves as real relationships. Before assigning \`vendor\`, \`candidate\`, \`partner\`, or \`followup\`, apply these tests:
**It's \`cold-outreach\` (noise), NOT a real relationship, if:**
- The sender is pitching their own product or service design agencies, compliance firms, content/copy writers, dev shops, freelancers, trademark services, company closure/winding-down services, hiring platforms, etc. even if they reference your company by name, your YC batch, or offer something "free" or "exclusive for YC founders."
- The thread consists entirely of the same sender following up on their own unanswered messages. A real followup requires prior two-way engagement.
- A student, job-seeker, freelancer, or founder cold-emails asking for your time, feedback, or offering free work/trials. These are NOT \`candidate\` — they are \`cold-outreach\`.
- Someone invites you to an event you didn't sign up for, especially if the email has marketing formatting (tracking links, unsubscribe footers, HTML banners). This is \`promotion\`, not \`event\`.
**It IS a real relationship (not noise) if:**
- You (the inbox owner) are a participant in the thread (you sent a reply, or someone on your team did).
- The sender is from a company you are already paying, or they are providing a service under contract (e.g., your law firm, your accountant, your cloud provider support).
- The sender was introduced to you by someone you know (warm intro present in the thread).
- The sender references a specific ongoing engagement with concrete details e.g., they are your assigned compliance assessor for an audit you initiated, or they are following up after a call you participated in. This is NOT the same as a generic "I noticed your company uses X" pitch.
**Key heuristic:** If every message in the thread is FROM the same external person and the inbox owner never replied, it's almost certainly cold outreach regardless of how personalized it sounds. Label it \`cold-outreach\`.
# Routine Operations & Finance (Often Missed as Noise)
These emails involve real relationships (team, vendor) and real topics (finance) but are **noise** because the transaction is complete and no decision remains. They MUST get a filter tag even though they also have relationship/topic tags:
- **Salary/payroll confirmations**: "Total salary disbursement is INR X, transfer initiated" \`filter: ['receipt']\`
- **Tax payment acknowledgements**: Income tax challan confirmations, TDS workings sent for processing \`filter: ['receipt']\`
- **GST/compliance filing confirmations**: GSTR1 ARN generated, GST OTPs (expired or used) \`filter: ['receipt']\`
- **Recurring invoice sharing**: Monthly cloud/SaaS invoices shared between team and finance dept \`filter: ['receipt']\`
- **Payment transfer confirmations**: "Transfer initiated", "Payment confirmed" \`filter: ['receipt']\`
# Automated Notifications (Often Missed as Noise)
System-generated messages that require no decision:
- **Email verifications**: "Confirm your email address on Slack" \`filter: ['notification']\`
- **Meeting recordings**: "Your meeting recording is ready in Google Drive" \`filter: ['notification']\`
- **Platform policy updates**: "Billing permissions are changing starting next month" \`filter: ['notification']\`
- **Expired OTPs**: One-time passwords for completed actions \`filter: ['notification']\`
# Meeting vs Scheduling (Critical Distinction)
- **topic: meeting** (CREATE) A calendar invite or scheduling email for a real meeting with a **named person** you have a relationship with: an investor, customer, partner, candidate, advisor, team member. Examples: "Invitation: Zoom: Rowboat Labs <> Dalton Caldwell", "YC between Peer Richelsen and Arjun", "Rowboat <> Smash Capital". The key signal is a specific person or company in the subject/body.
- **filter: scheduling** (SKIP) Automated reminders and scheduling tool notifications with **no named person or meaningful context**: "Reminder: your meeting is about to start", "Our meeting in an hour", generic ChiliPiper/Calendly confirmations. These are system-generated noise.
**Rule of thumb:** If the email names who you're meeting with, it's \`topic: meeting\`. If it's just a system ping about a time slot, it's \`filter: scheduling\`.
# Newsletter & Promotion Detection (Often Missed as Noise)
These are noise even from a vendor you recognize or a platform you use:
- **Industry reports**: "Report: $1.2T in combined enterprise AI value" \`filter: ['newsletter']\`
- **Webinar/workshop invitations**: "Register for our knowledge sessions", "5 Slots Left. Pitch Tomorrow." \`filter: ['promotion']\`
- **Product tips and tutorials**: "Discover more with your free account" \`filter: ['newsletter']\`
- **Startup program marketing**: "Reminder - Register for AI Architecture sessions" \`filter: ['promotion']\`
**Exception:** If a tool your team actively uses is expiring and you need to make an upgrade/cancellation decision, that is NOT noise it requires action.
# Spam Digests Are Always Spam
If the sender is \`noreply-spamdigest\` (Google Groups spam moderation reports), label it \`filter: ['spam']\`. Google already flagged these as spam. Do not evaluate the held messages inside — the digest itself is noise.
# Filter array must only contain tags from the Noise category
Do not put topic or relationship tags into the filter array. If an email is an event promotion, use \`promotion\` in filter — not \`event\`.
# Frontmatter Format
@ -34,14 +166,14 @@ ${renderTagSystemForEmails()}
---
labels:
relationship:
- Investor
- investor
topics:
- Fundraising
- Finance
type: Intro
- fundraising
- finance
type: intro
filter:
- Promotion
action: FYI
- []
action: action-required
processed: true
labeled_at: "2026-02-28T12:00:00Z"
---
@ -50,10 +182,14 @@ labeled_at: "2026-02-28T12:00:00Z"
# Rules
- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays).
- \`type\` and \`action\` are single values (strings), not arrays.
- \`type\` and \`action\` are single values (strings), not arrays. Use empty string \`""\` if not applicable.
- \`relationship\`, \`topics\`, and \`filter\` are arrays.
- The \`action\` field only accepts: \`action-required\`, \`urgent\`, \`waiting\`, or \`""\`. Never use \`fyi\` as an action value.
- Use the exact label values from the taxonomy do not invent new ones.
- The \`labeled_at\` timestamp should be the current time in ISO 8601 format.
- Process all files in the batch. Do not skip any unless they already have frontmatter.
- **Noise labels are skip signals.** If an email is clearly a newsletter, cold outreach, promotion, digest, receipt, notification, or other noise label it in the \`filter\` array. These emails will NOT create notes.
- **Noise tags coexist with other tags.** An email from your team about salary (\`relationship: ['team']\`, \`topics: ['finance']\`) that is just a payroll confirmation should ALSO have \`filter: ['receipt']\`. The noise tag overrides — it ensures the email is skipped even when relationship/topic tags are present.
- **When in doubt, ask:** "Does this email change any decision, require any follow-up, or update a relationship I need to track?" If no, it's noise add the appropriate filter tag.
`;
}

View file

@ -27,6 +27,15 @@ tools:
type: builtin
name: workspace-glob
---
# Context
**Current date and time:** ${new Date().toISOString()}
Sources (emails, meetings, voice memos) are processed in roughly chronological order. This means:
- Earlier sources may reference events that have since occurred later sources will provide updates.
- If a source mentions a future meeting or deadline, it may already be in the past by now. Use the current date above to reason about what is past vs. upcoming.
- Don't treat old commitments as still "open" if later sources or the current date suggest they've likely been resolved.
# Task
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
@ -476,9 +485,9 @@ RESOLVED (use canonical name with absolute path):
- "Acme", "Acme Corp", "@acme.com" [[Organizations/Acme Corp]]
- "the pilot", "the integration" [[Projects/Acme Integration]]
NEW ENTITIES (create notes if source passes filters):
NEW ENTITIES (create notes or suggestion cards if source passes filters):
- "Jennifer" (CTO, Acme Corp) Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]]
- "SOC 2" Create [[Topics/Security Compliance]]
- "SOC 2" Add or update a suggestion card in \`suggested-topics.md\` with category \`Topics\`
AMBIGUOUS (flag or skip):
- "Mike" (no context) Mention in activity only, don't create note
@ -499,8 +508,8 @@ For entities not resolved to existing notes, determine if they warrant new notes
**CREATE a note for people who are:**
- External (not @user.domain)
- Attendees in meetings
- Email correspondents (emails that reach this step already passed label-based filtering)
- People you directly interacted with in meetings
- Email correspondents directly participating in the thread (emails that reach this step already passed label-based filtering)
- Decision makers or contacts at customers, prospects, or partners
- Investors or potential investors
- Candidates you are interviewing
@ -512,6 +521,7 @@ For entities not resolved to existing notes, determine if they warrant new notes
- Large group meeting attendees you didn't interact with
- Internal colleagues (@user.domain)
- Assistants handling only logistics
- People mentioned only as third parties ("we work with X", "I can introduce you to Y") when there has been no direct interaction yet
### Role Inference
@ -570,31 +580,155 @@ For people who don't warrant their own note, add to Organization note's Contacts
- Sarah Lee Support, handled wire transfer issue
\`\`\`
### Direct Interaction Test (People and Organizations)
For **new canonical People and Organizations notes**, require **direct interaction**, not just mention.
**Direct interaction = YES**
- The person sent the email, replied in the thread, or was directly addressed as part of the active exchange
- The person participated in the meeting, and there is evidence the user actually interacted with them or the meeting centered on them
- The organization is directly represented in the exchange by participants/senders and is part of an active first-degree relationship with the user or team
- The user is directly evaluating, selling to, buying from, partnering with, interviewing, or coordinating with that person or organization
**Direct interaction = NO**
- Someone else mentions them in passing
- A sender says they work with someone at another company
- A sender offers to introduce the user to someone
- A company is referenced as a customer, partner, employer, competitor, or example, but nobody from that company is directly involved in the interaction
- The source only establishes a second-degree relationship, not a direct one
**Canonical note rule:**
- For **new People/Organizations**, create the canonical note only if both are true:
1. There is **direct interaction**
2. The entity clears the **weekly importance test**
If an entity seems strategically relevant but fails the direct interaction test, do **not** auto-create a canonical note. At most, create a suggestion card in \`suggested-topics.md\`.
### Weekly Importance Test (People and Organizations only)
For **People** and **Organizations**, the final gate for **creating a new canonical note** is an importance test:
**Ask:** _"If I were the user, would I realistically need to look at this note on a weekly basis over the near term?"_
This test is mainly for **People** and **Organizations**. **Do NOT use it as the decision rule for Topic or Project suggestions.**
**Strong YES signals:**
- Active customer, prospect, investor, partner, candidate, advisor, or strategic vendor relationship
- Repeated interaction or a likely ongoing cadence
- Decision-maker, owner, blocker, evaluator, or approver in an active process
- Material relevance to launch, sales, fundraising, hiring, compliance, product delivery, or another current priority
- The user would benefit from a durable reference note instead of repeatedly reopening raw emails or meeting transcripts
**Strong NO signals:**
- One-off logistics, scheduling, or transactional contact
- Assistant, support rep, recruiter, or vendor rep with no ongoing strategic role
- Incidental attendee mentioned once with no leverage on current work
- Passing mention with no evidence of an ongoing relationship
**Borderline signals:**
- Seems potentially important, but there isn't enough evidence yet that the user will need a weekly reference note
- Might become important soon, but the role, relationship, or repeated relevance is still unclear
- Important enough to track, but only through second-degree mention or an offered introduction rather than direct interaction
**Outcome rules for new People/Organizations:**
- **Clear YES + direct interaction** Create/update the canonical \`People/\` or \`Organizations/\` note
- **Borderline or no direct interaction, but still strategically relevant** Do **not** create the canonical note yet; instead create or update a card in \`suggested-topics.md\`
- **Clear NO** Skip note creation and do not add a suggestion unless the source strongly suggests near-term strategic relevance
**When a canonical note already exists:**
- Update the existing note even if the current source is weaker; the importance test is mainly for deciding whether to create a **new** People/Organization note
- If a previously tentative person/org is now clearly important enough for a canonical note, create/update the note and remove any tentative suggestion card for that exact entity from \`suggested-topics.md\`
## Organizations
**CREATE a note if:**
- Someone from that org attended a meeting
- They're a customer, prospect, investor, or partner
- Someone from that org sent relevant personalized correspondence
- There is direct interaction with that org in the source
- They're a customer, prospect, investor, or partner in a direct first-degree interaction
- Someone from that org sent relevant personalized correspondence or joined a meeting you actually had with them
- They pass the weekly importance test above
**DO NOT create for:**
- Tool/service providers mentioned in passing
- One-time transactional vendors
- Consumer service companies
- Organizations only referenced through third-party mention or offered introductions
## Projects
**CREATE a note if:**
**If a project note already exists:** update it.
**If no project note exists:** do **not** create a new canonical note in \`knowledge/Projects/\`.
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the project is strong enough:
- Discussed substantively in a meeting or email thread
- Has a goal and timeline
- Involves multiple interactions
Otherwise skip it.
Projects do **not** use the weekly importance test above. For **new** projects, the default output is a suggestion card, not a canonical note.
## Topics
**CREATE a note if:**
**If a topic note already exists:** update it.
**If no topic note exists:** do **not** create a new canonical note in \`knowledge/Topics/\`.
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the topic is strong enough:
- Recurring theme discussed
- Will come up again across conversations
Otherwise skip it.
Topics do **not** use the weekly importance test above. For **new** topics, the default output is a suggestion card, not a canonical note.
## Suggested Topics Curation
Also maintain \`suggested-topics.md\` as a **curated shortlist** of things worth exploring next.
Despite the filename, \`suggested-topics.md\` can contain cards for **People, Organizations, Topics, or Projects**.
There are **two reasons** to add or update a suggestion card:
1. **High-quality Topic/Project cards**
- Use these for topics or projects that are timely, high-leverage, strategically important, or clearly worth exploring now
- These are not a dump of every topic/project note. Be selective
- For **new** topics and projects, cards are the default output from this pipeline
2. **Tentative People/Organization cards**
- Use these when a person or organization seems important enough to track, but you are **not 100% sure** they clear the weekly-importance test for a canonical note yet
- The card should capture why they might matter and what still needs verification
**Do NOT add cards for:**
- Low-signal administrative or transactional entities
- Stale or completed items with no near-term relevance
- People/organizations that already have a clearly established canonical note, unless the card is about a distinct project/topic exploration rather than the entity itself
**Card guidance:**
- For **Topics/Projects**, use category \`Topics\` or \`Projects\`
- For tentative **People/Organizations**, use category \`People\` or \`Organizations\`
- Title should be concise and canonical when possible
- Description should explain why it matters **now**
- For tentative People/Organizations, description should also mention what is still uncertain or what the user should verify
**Curation rules:**
- Maintain a **high-quality set**, not an ever-growing backlog
- Deduplicate by normalized title
- Prefer current, actionable, recurring, or strategically important items
- Keep only the strongest **8-12 cards total**
- Preserve good existing cards unless the new source clearly supersedes them
- Remove stale cards that are no longer relevant
- If a tentative People/Organization card later becomes clearly important and you create a canonical note, remove the tentative card
**File format for \`suggested-topics.md\`:**
\`\`\`suggestedtopic
{"title":"Security Compliance","description":"Summarize the current compliance posture, blockers, and customer implications.","category":"Topics"}
\`\`\`
The file should start with \`# Suggested Topics\` followed by one or more blocks in that format.
If the file does not exist, create it. If it exists, update it in place or rewrite the full file so the final result is clean, deduped, and curated.
---
# Step 6: Extract Content
@ -815,7 +949,7 @@ If new info contradicts existing:
# Step 9: Write Updates
## 9a: Create and Update Notes
## 9a: Create and Update Notes and Suggested Topic Cards
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
@ -843,6 +977,12 @@ workspace-edit({
})
\`\`\`
**For \`suggested-topics.md\`:**
- Use workspace-relative path \`suggested-topics.md\`
- Read the current file if you need the latest content
- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner
- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable
## 9b: Apply State Changes
For each state change identified in Step 7, update the relevant fields.
@ -858,7 +998,9 @@ If you discovered new name variants during resolution, add them to Aliases field
- Be concise: one line per activity entry
- Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule)
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards
---
@ -947,8 +1089,12 @@ Before completing, verify:
**Filtering:**
- [ ] Excluded self (user.name, user.email, @user.domain)
- [ ] Applied relevance test to each person
- [ ] Applied the direct interaction test to new People/Organizations
- [ ] Applied the weekly importance test to new People/Organizations
- [ ] Transactional contacts in Org Contacts, not People notes
- [ ] Source correctly classified (process vs skip)
- [ ] Third-party mentions did not become new canonical People/Organizations notes
- [ ] Borderline People/Organizations became suggestion cards instead of canonical notes
**Content Quality:**
- [ ] Summaries describe relationship, not communication method
@ -968,8 +1114,11 @@ Before completing, verify:
- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links
- [ ] Activity entries are reverse chronological
- [ ] No duplicate activity entries
- [ ] \`suggested-topics.md\` stays deduped and curated
- [ ] High-quality Topics/Projects were added to suggested topics only when timely and useful
- [ ] New Topics/Projects were not auto-created as canonical notes
- [ ] Dates are YYYY-MM-DD
- [ ] Bidirectional links are consistent
- [ ] New notes in correct folders
`;
}
}

View file

@ -9,7 +9,7 @@ export interface NoteTypeDefinition {
extractionGuide: string;
}
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
// ── Default definitions (used to seed WorkDir/config/notes.json) ─────────────
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{
@ -23,7 +23,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them, what you're working on together.}
@ -56,7 +56,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Domain:** {primary email domain}
**Aliases:** {comma-separated: short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
@ -90,7 +90,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
@ -131,7 +131,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Keywords:** {comma-separated}
**Aliases:** {other ways this topic is referenced}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}

View file

@ -47,7 +47,7 @@ role: VP Engineering
organization: Acme Corp
email: sarah@acme.com
first_met: "2024-06-15"
last_seen: "2025-01-20"
last_update: "2025-01-20"
---
\`\`\`
@ -80,7 +80,7 @@ Use these exact keys for each tag category:
Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys:
1. **Convert keys to snake_case**: e.g. \`**First met:**\`\`first_met\`, \`**Last activity:**\`\`last_activity\`, \`**Last seen:**\`\`last_seen\`.
1. **Convert keys to snake_case**: e.g. \`**First met:**\`\`first_met\`, \`**Last update:**\`\`last_update\`.
2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\`\`Acme Corp\`. Extract just the display name (last path segment).
3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter.
4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`.
@ -93,10 +93,10 @@ Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) sec
**Per note type, extract these fields:**
- **People**: role, organization, email, aliases, first_met, last_seen
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
- **Projects**: type, status, started, last_activity
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
- **People**: role, organization, email, aliases, first_met, last_update
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_update
- **Projects**: type, status, started, last_update
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_update
- **Meetings**: Extract from the note content and file path:
- \`date\`: meeting date (from the file path \`Meetings/{source}/YYYY/MM/DD/\` or from \`created_at\`/\`Date:\` in content)
- \`source\`: \`granola\` or \`fireflies\` (from the file path)

View file

@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Standalone pipeline runner for email labeling, graph building, and note tagging.
*
* Usage:
* npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir
* npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps label,graph,tag
* npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps label
* npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps graph,tag
*
* The workdir should contain a gmail_sync/ folder with email markdown files.
* Output notes are written to workdir/knowledge/.
*
* Steps:
* label - Classify emails with YAML frontmatter labels
* graph - Extract entities and create/update knowledge notes
* tag - Add YAML frontmatter tags to knowledge notes
*
* If --steps is omitted, all three steps run in order: label graph tag
*/
import fs from 'fs';
import path from 'path';
// --- Parse CLI args before any core imports (WorkDir reads env at import time) ---
const VALID_STEPS = ['label', 'graph', 'tag'] as const;
type Step = typeof VALID_STEPS[number];
function parseArgs(): { workdir: string; steps: Step[]; concurrency: number } {
const args = process.argv.slice(2);
let workdir: string | undefined;
let stepsRaw: string | undefined;
let concurrency = 3;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--workdir' && args[i + 1]) {
workdir = args[++i];
} else if (args[i] === '--steps' && args[i + 1]) {
stepsRaw = args[++i];
} else if (args[i] === '--concurrency' && args[i + 1]) {
concurrency = parseInt(args[++i], 10);
if (isNaN(concurrency) || concurrency < 1) {
console.error('Error: --concurrency must be a positive integer');
process.exit(1);
}
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: run_pipeline --workdir <path> [--steps label,graph,tag] [--concurrency N]
Options:
--workdir <path> Working directory containing gmail_sync/ folder (required)
--steps <list> Comma-separated steps to run: label, graph, tag (default: all)
--concurrency <N> Number of parallel batches for labeling (default: 3)
--help, -h Show this help message
Examples:
run_pipeline --workdir ./my-emails
run_pipeline --workdir ./my-emails --steps label --concurrency 5
run_pipeline --workdir ./my-emails --steps label,graph
run_pipeline --workdir ./my-emails --steps graph,tag
`);
process.exit(0);
}
}
if (!workdir) {
console.error('Error: --workdir is required');
process.exit(1);
}
// Resolve to absolute path
workdir = path.resolve(workdir);
if (!fs.existsSync(workdir)) {
console.error(`Error: workdir does not exist: ${workdir}`);
process.exit(1);
}
// Parse steps
let steps: Step[];
if (stepsRaw) {
const requested = stepsRaw.split(',').map(s => s.trim().toLowerCase());
const invalid = requested.filter(s => !VALID_STEPS.includes(s as Step));
if (invalid.length > 0) {
console.error(`Error: invalid steps: ${invalid.join(', ')}. Valid steps: ${VALID_STEPS.join(', ')}`);
process.exit(1);
}
steps = requested as Step[];
} else {
steps = [...VALID_STEPS];
}
return { workdir, steps, concurrency };
}
const { workdir, steps, concurrency } = parseArgs();
// Set env BEFORE importing core modules (WorkDir is read at module load time)
process.env.ROWBOAT_WORKDIR = workdir;
// --- Now import core modules ---
async function main() {
console.log(`[Pipeline] Working directory: ${workdir}`);
console.log(`[Pipeline] Steps to run: ${steps.join(', ')}`);
console.log(`[Pipeline] Concurrency: ${concurrency}`);
console.log();
// Verify gmail_sync exists if label or graph step is requested
const gmailSyncDir = path.join(workdir, 'gmail_sync');
if ((steps.includes('label') || steps.includes('graph')) && !fs.existsSync(gmailSyncDir)) {
console.warn(`[Pipeline] Warning: gmail_sync/ folder not found in ${workdir}`);
}
const startTime = Date.now();
if (steps.includes('label')) {
console.log('[Pipeline] === Step 1: Email Labeling ===');
const { processUnlabeledEmails } = await import('./label_emails.js');
await processUnlabeledEmails(concurrency);
console.log();
}
if (steps.includes('graph')) {
console.log('[Pipeline] === Step 2: Graph Building ===');
const { processAllSources } = await import('./build_graph.js');
await processAllSources();
console.log();
}
if (steps.includes('tag')) {
console.log('[Pipeline] === Step 3: Note Tagging ===');
const { processUntaggedNotes } = await import('./tag_notes.js');
await processUntaggedNotes();
console.log();
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`[Pipeline] Done in ${elapsed}s`);
// Output summary
const knowledgeDir = path.join(workdir, 'knowledge');
if (fs.existsSync(knowledgeDir)) {
const countFiles = (dir: string): number => {
let count = 0;
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) count += countFiles(full);
else if (entry.endsWith('.md')) count++;
}
return count;
};
console.log(`[Pipeline] Output: ${countFiles(knowledgeDir)} notes in ${knowledgeDir}`);
}
}
main().then(() => {
process.exit(0);
}).catch((err) => {
console.error('[Pipeline] Fatal error:', err);
process.exit(1);
});

View file

@ -4,6 +4,8 @@ import { generateText } from 'ai';
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 { WorkDir } from '../config/config.js';
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
@ -13,7 +15,8 @@ const SYSTEM_PROMPT = `You are a meeting notes assistant. Given a raw meeting tr
## Calendar matching
You will be given the transcript (with a timestamp of when recording started) and recent calendar events with their titles, times, and attendees. If a calendar event clearly matches this meeting (overlapping time + content aligns), then:
- Do NOT output a title or heading the title is already set by the caller.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- ONLY use names from the calendar event attendee list. Do NOT introduce names that are not in the attendee list any unrecognized names in the transcript are transcription errors.
- Replace generic speaker labels ("Speaker 0", "Speaker 1", "System audio") with actual attendee names from the list, but ONLY if you have HIGH CONFIDENCE about which speaker is which based on the discussion content. If unsure, use "They" instead of "Speaker 0" etc.
- "You" in the transcript is the local user if the calendar event has an organizer or you can identify who "You" is from context, use their name.
If no calendar event matches with high confidence, or if no calendar events are provided, use "They" for all non-"You" speakers.
@ -137,8 +140,13 @@ function loadCalendarEventContext(calendarEventJson: string): string {
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const provider = createProvider(config.provider);
const model = provider.languageModel(config.model);
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.meetingNotesModel
|| (signedIn ? "gpt-5.4" : config.model);
const model = provider.languageModel(modelId);
// If a specific calendar event was linked, use it directly.
// Otherwise fall back to scanning events within ±3 hours.

View file

@ -9,12 +9,136 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogleCalendar } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
const MAX_EVENTS_IN_DIGEST = 50;
const MAX_DESCRIPTION_CHARS = 500;
type AnyEvent = Record<string, unknown> | cal.Schema$Event;
function getStr(obj: unknown, key: string): string | undefined {
if (obj && typeof obj === 'object' && key in obj) {
const v = (obj as Record<string, unknown>)[key];
return typeof v === 'string' ? v : undefined;
}
return undefined;
}
function formatEventTime(event: AnyEvent): string {
const start = (event as Record<string, unknown>).start as Record<string, unknown> | undefined;
const end = (event as Record<string, unknown>).end as Record<string, unknown> | undefined;
const startStr = getStr(start, 'dateTime') ?? getStr(start, 'date') ?? 'unknown';
const endStr = getStr(end, 'dateTime') ?? getStr(end, 'date') ?? 'unknown';
return `${startStr}${endStr}`;
}
function formatEventBlock(event: AnyEvent, label: 'NEW' | 'UPDATED'): string {
const id = getStr(event, 'id') ?? '(unknown id)';
const title = getStr(event, 'summary') ?? '(no title)';
const time = formatEventTime(event);
const organizer = getStr((event as Record<string, unknown>).organizer, 'email') ?? 'unknown';
const location = getStr(event, 'location') ?? '';
const rawDescription = getStr(event, 'description') ?? '';
const description = rawDescription.length > MAX_DESCRIPTION_CHARS
? rawDescription.slice(0, MAX_DESCRIPTION_CHARS) + '…(truncated)'
: rawDescription;
const attendeesRaw = (event as Record<string, unknown>).attendees;
let attendeesLine = '';
if (Array.isArray(attendeesRaw) && attendeesRaw.length > 0) {
const emails = attendeesRaw
.map(a => getStr(a, 'email'))
.filter((e): e is string => !!e);
if (emails.length > 0) {
attendeesLine = `**Attendees:** ${emails.join(', ')}\n`;
}
}
return [
`### [${label}] ${title}`,
`**ID:** ${id}`,
`**Time:** ${time}`,
`**Organizer:** ${organizer}`,
location ? `**Location:** ${location}` : '',
attendeesLine.trimEnd(),
description ? `\n${description}` : '',
].filter(Boolean).join('\n');
}
function summarizeCalendarSync(
newEvents: AnyEvent[],
updatedEvents: AnyEvent[],
deletedEventIds: string[],
): string {
const totalChanges = newEvents.length + updatedEvents.length + deletedEventIds.length;
const lines: string[] = [
`# Calendar sync update`,
``,
`${newEvents.length} new, ${updatedEvents.length} updated, ${deletedEventIds.length} deleted.`,
``,
];
const allChanges: Array<{ event: AnyEvent; label: 'NEW' | 'UPDATED' }> = [
...newEvents.map(e => ({ event: e, label: 'NEW' as const })),
...updatedEvents.map(e => ({ event: e, label: 'UPDATED' as const })),
];
const shown = allChanges.slice(0, MAX_EVENTS_IN_DIGEST);
const hidden = allChanges.length - shown.length;
if (shown.length > 0) {
lines.push(`## Changed events`, ``);
for (const { event, label } of shown) {
lines.push(formatEventBlock(event, label), ``);
}
if (hidden > 0) {
lines.push(`_…and ${hidden} more change(s) omitted from digest._`, ``);
}
}
if (deletedEventIds.length > 0) {
lines.push(`## Deleted event IDs`, ``);
for (const id of deletedEventIds.slice(0, MAX_EVENTS_IN_DIGEST)) {
lines.push(`- ${id}`);
}
if (deletedEventIds.length > MAX_EVENTS_IN_DIGEST) {
lines.push(`- _…and ${deletedEventIds.length - MAX_EVENTS_IN_DIGEST} more_`);
}
lines.push(``);
}
if (totalChanges === 0) {
lines.push(`(no changes — should not be emitted)`);
}
return lines.join('\n');
}
async function publishCalendarSyncEvent(
newEvents: AnyEvent[],
updatedEvents: AnyEvent[],
deletedEventIds: string[],
): Promise<void> {
if (newEvents.length === 0 && updatedEvents.length === 0 && deletedEventIds.length === 0) {
return;
}
try {
await createEvent({
source: 'calendar',
type: 'calendar.synced',
createdAt: new Date().toISOString(),
payload: summarizeCalendarSync(newEvents, updatedEvents, deletedEventIds),
});
} catch (err) {
console.error('[Calendar] Failed to publish sync event:', err);
}
}
// Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const LOOKBACK_DAYS = 14;
const COMPOSIO_LOOKBACK_DAYS = 14;
const LOOKBACK_DAYS = 7;
const COMPOSIO_LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly'
@ -194,6 +318,8 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
let deletedCount = 0;
let attachmentCount = 0;
const changedTitles: string[] = [];
const newEvents: AnyEvent[] = [];
const updatedEvents: AnyEvent[] = [];
const ensureRun = async () => {
if (!runId) {
@ -234,8 +360,10 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
changedTitles.push(result.title);
if (result.isNew) {
newCount++;
newEvents.push(event);
} else {
updatedCount++;
updatedEvents.push(event);
}
}
@ -253,6 +381,9 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
deletedCount = deletedFiles.length;
}
// Publish a single bundled event capturing all changes from this sync.
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
if (runId) {
const totalChanges = newCount + updatedCount + deletedCount + attachmentCount;
const limitedTitles = limitEventItems(changedTitles);
@ -434,65 +565,100 @@ async function performSyncComposio() {
};
try {
const result = await executeAction(
'GOOGLECALENDAR_FIND_EVENT',
{
connected_account_id: connectedAccountId,
user_id: 'rowboat-user',
version: 'latest',
arguments: {
calendar_id: 'primary',
time_min: timeMin,
time_max: timeMax,
single_events: true,
order_by: 'startTime',
},
}
);
if (!result.successful || !result.data) {
console.error('[Calendar] Failed to list events via Composio:', result.error);
return;
}
const data = result.data as Record<string, unknown>;
// Composio may return events in different structures
let events: Array<Record<string, unknown>> = [];
if (Array.isArray(data.items)) {
events = data.items as Array<Record<string, unknown>>;
} else if (Array.isArray(data.events)) {
events = data.events as Array<Record<string, unknown>>;
} else if (Array.isArray(data)) {
events = data as unknown as Array<Record<string, unknown>>;
}
const currentEventIds = new Set<string>();
let newCount = 0;
let updatedCount = 0;
const changedTitles: string[] = [];
const newEvents: AnyEvent[] = [];
const updatedEvents: AnyEvent[] = [];
let pageToken: string | null = null;
const MAX_PAGES = 20;
if (events.length === 0) {
console.log('[Calendar] No events found in this window.');
} else {
console.log(`[Calendar] Found ${events.length} events.`);
for (const event of events) {
const eventId = event.id as string | undefined;
if (eventId) {
const saveResult = saveComposioEvent(event, SYNC_DIR);
currentEventIds.add(eventId);
for (let page = 0; page < MAX_PAGES; page++) {
// Re-check connection in case user disconnected mid-sync
if (!composioAccountsRepo.isConnected('googlecalendar')) {
console.log('[Calendar] Account disconnected during sync. Stopping.');
return;
}
if (saveResult.changed) {
await ensureRun();
changedTitles.push(saveResult.title);
if (saveResult.isNew) {
newCount++;
} else {
updatedCount++;
const args: Record<string, unknown> = {
calendar_id: 'primary',
time_min: timeMin,
time_max: timeMax,
single_events: true,
order_by: 'startTime',
};
if (pageToken) {
args.page_token = pageToken;
}
const result = await executeAction(
'GOOGLECALENDAR_FIND_EVENT',
{
connected_account_id: connectedAccountId,
user_id: 'rowboat-user',
version: 'latest',
arguments: args,
}
);
if (!result.successful || !result.data) {
console.error('[Calendar] Failed to list events via Composio:', result.error);
return;
}
const data = result.data as Record<string, unknown>;
// Composio may return events in different structures
let events: Array<Record<string, unknown>> = [];
if (Array.isArray(data.items)) {
events = data.items as Array<Record<string, unknown>>;
} else if (Array.isArray(data.events)) {
events = data.events as Array<Record<string, unknown>>;
} else if (data.event_data && typeof data.event_data === 'object') {
const nested = data.event_data as Record<string, unknown>;
if (Array.isArray(nested.event_data)) {
events = nested.event_data as Array<Record<string, unknown>>;
} else if (Array.isArray(data.event_data)) {
events = data.event_data as Array<Record<string, unknown>>;
}
} else if (Array.isArray(data)) {
events = data as unknown as Array<Record<string, unknown>>;
}
if (events.length === 0 && page === 0) {
console.log('[Calendar] No events found in this window.');
} else if (events.length > 0) {
console.log(`[Calendar] Page ${page + 1}: found ${events.length} events.`);
for (const event of events) {
const eventId = event.id as string | undefined;
if (eventId) {
const saveResult = saveComposioEvent(event, SYNC_DIR);
currentEventIds.add(eventId);
if (saveResult.changed) {
await ensureRun();
changedTitles.push(saveResult.title);
if (saveResult.isNew) {
newCount++;
newEvents.push(event);
} else {
updatedCount++;
updatedEvents.push(event);
}
}
}
}
}
// Check for next page
const nextToken = data.nextPageToken as string | undefined;
if (nextToken) {
pageToken = nextToken;
console.log(`[Calendar] Fetching next page...`);
} else {
break;
}
}
// Clean up events no longer in the window
@ -503,6 +669,9 @@ async function performSyncComposio() {
deletedCount = deletedFiles.length;
}
// Publish a single bundled event capturing all changes from this sync.
await publishCalendarSyncEvent(newEvents, updatedEvents, deletedFiles);
// Log results if any changes were detected (run was started by ensureRun)
if (run) {
const r = run as ServiceRunContext;

View file

@ -9,7 +9,7 @@ import { limitEventItems } from './limit_event_items.js';
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute)
const STATE_FILE = path.join(WorkDir, 'fireflies_sync_state.json');
const LOOKBACK_DAYS = 30; // Last 1 month
const LOOKBACK_DAYS = 7; // Last 1 week
const API_DELAY_MS = 2000; // 2 second delay between API calls
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { executeAction, useComposioForGoogle } from '../composio/client.js';
import { composioAccountsRepo } from '../composio/repo.js';
import { createEvent } from './track/events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@ -172,6 +173,13 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
console.log(`Synced Thread: ${subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
} catch (error) {
console.error(`Error processing thread ${threadId}:`, error);
}
@ -410,7 +418,7 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
}
async function performSync() {
const LOOKBACK_DAYS = 30; // Default to 1 month
const LOOKBACK_DAYS = 7; // Default to 1 week
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
@ -444,7 +452,7 @@ async function performSync() {
// --- Composio-based Sync ---
const COMPOSIO_LOOKBACK_DAYS = 30;
const COMPOSIO_LOOKBACK_DAYS = 7;
interface ComposioSyncState {
last_sync: string; // ISO string
@ -595,6 +603,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
newestDate = tryParseDate(parsed.date);
} else {
const firstParsed = parseMessageData(messages[0]);
@ -617,6 +631,12 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
await createEvent({
source: 'gmail',
type: 'email.synced',
createdAt: new Date().toISOString(),
payload: mdContent,
});
}
if (!newestDate) return null;
@ -732,6 +752,11 @@ async function performSyncComposio() {
let highWaterMark: string | null = state?.last_sync ?? null;
let processedCount = 0;
for (const threadId of allThreadIds) {
// Re-check connection in case user disconnected mid-sync
if (!composioAccountsRepo.isConnected('gmail')) {
console.log('[Gmail] Account disconnected during sync. Stopping.');
return;
}
try {
const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
processedCount++;

View file

@ -3,6 +3,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { waitForRunCompletion } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -13,7 +14,7 @@ import {
} from './note_tagging_state.js';
import { getNoteTypeDefinitions } from './note_system.js';
const SYNC_INTERVAL_MS = 30 * 1000; // 30 seconds
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const BATCH_SIZE = 15;
const NOTE_TAGGING_AGENT = 'note_tagging_agent';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
@ -75,20 +76,6 @@ function getUntaggedNotes(state: NoteTaggingState): string[] {
return untagged;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
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();
}
});
});
}
/**
* Tag a batch of note files using the tagging agent
*/
@ -143,7 +130,7 @@ async function tagNoteBatch(
/**
* Process all untagged notes in batches
*/
async function processUntaggedNotes(): Promise<void> {
export async function processUntaggedNotes(): Promise<void> {
console.log('[NoteTagging] Checking for untagged notes...');
const state = loadNoteTaggingState();

View file

@ -9,7 +9,7 @@ export type TagType =
| 'relationship-sub'
| 'topic'
| 'email-type'
| 'filter'
| 'noise'
| 'action'
| 'status'
| 'source';
@ -26,25 +26,24 @@ export interface TagDefinition {
noteEffect?: NoteEffect;
}
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
// ── Default definitions (used to seed WorkDir/config/tags.json) ─────────────
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
// ── Relationship (both) ──────────────────────────────────────────────
// ── Relationship — who is this from/about (all create) ────────────────
{ tag: 'investor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' },
{ tag: 'customer', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
{ tag: 'prospect', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
{ tag: 'product', type: 'relationship', applicability: 'both', noteEffect: 'skip', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
{ tag: 'candidate', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners, corp dev, or strategic contacts', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you already pay or have a contract with (legal, accounting, infra). NOT someone pitching their services to you — that is cold-outreach.', example: 'Here are the updated employment agreements you requested.' },
{ tag: 'candidate', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Job applicants, recruiters, and anyone reaching out about roles — both solicited and unsolicited', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members and co-founders', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
{ tag: 'advisor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' },
{ tag: 'personal', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
{ tag: 'press', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' },
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Peers, YC batchmates, or open source contributors with direct interaction', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
{ tag: 'government', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' },
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
// ── Relationship Sub-Tags — role metadata (notes only, all none) ──────
{ tag: 'primary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' },
{ tag: 'secondary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' },
{ tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' },
@ -54,57 +53,62 @@ const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
// ── Topic (both) ─────────────────────────────────────────────────────
// ── Topic — what the email is about (all create) ──────────────────────
{ tag: 'sales', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' },
{ tag: 'support', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' },
{ tag: 'legal', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Actionable money matters: invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
{ tag: 'hiring', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' },
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
{ tag: 'travel', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
{ tag: 'shopping', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
{ tag: 'health', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
{ tag: 'learning', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money, SAFEs, term sheets, and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
{ tag: 'security', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Vulnerability disclosures, login alerts, brand impersonation, or compliance requests', example: 'We found a JWT bypass in your auth endpoint. Details attached.' },
{ tag: 'infrastructure', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Deploy failures, build errors, webhook issues, API migrations, and production alerts', example: 'Vercel deploy failed for rowboat-app. Build log attached.' },
{ tag: 'meeting', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Calendar invites and scheduling for real meetings with named people — investors, customers, partners, candidates, team members. The key signal is a specific person you have a relationship with.', example: 'Invitation: Zoom: Rowboat Labs <> Dalton Caldwell @ Sat 7 Mar 2026' },
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings you are attending or invited to', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
{ tag: 'research', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' },
// ── Email Type ───────────────────────────────────────────────────────
// ── Email Type — high-signal email formats (all create) ───────────────
{ tag: 'intro', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' },
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
{ tag: 'scheduling', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' },
{ tag: 'newsletter', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
{ tag: 'notification', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' },
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous two-way conversation (both parties have engaged). A cold sender bumping their own unanswered email is NOT a followup — it is cold-outreach.', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
// ── Filter (email only) ──────────────────────────────────────────────
{ tag: 'spam', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
{ tag: 'promotion', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
{ tag: 'social', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
{ tag: 'forums', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
// ── Noise — all skip signals in one place ─────────────────────────────
// NOTE: Noise tags override relationship/topic tags. An email can have
// relationship: team AND filter: receipt — the noise tag wins and skips note creation.
{ tag: 'spam', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email, including Google Groups spam moderation digests (from noreply-spamdigest)', example: 'Congratulations! You\'ve won $1,000,000...' },
{ tag: 'promotion', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers, sales pitches, product launches, event invitations you did not register for, startup program upsells, vendor upgrade campaigns, and webinar/workshop invitations from companies', example: 'Register Now! Experts talk live: AI, Marketplace, Architecture & GTM Sessions Coming Up' },
{ tag: 'cold-outreach', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you have no prior engagement with — includes design agencies, compliance firms, content/copy writers, dev shops, freelancers offering free work, trademark services, company closure services, hiring platforms, and anyone pitching a service with "exclusive YC deal" or referencing your YC batch. Even if they mention your company by name or offer something free.', example: 'Ramnique, $2000 worth YC Design deal every month — we work with 230+ YC founders' },
{ tag: 'newsletter', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, industry reports, subscription emails, product tips/tutorials from vendors, and research digests — even from platforms you actively use', example: 'Report: $1.2T in combined enterprise AI value — but what\'s actually built to last?' },
{ tag: 'notification', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Automated system messages requiring no decision: email verifications, meeting recording uploads, platform policy/permission changes, billing console updates, password resets, and expired OTPs', example: 'Meeting records: your recording has been uploaded to Google Drive.' },
{ tag: 'digest', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Community digests, forum roundups, and aggregated updates', example: 'YC Bookface Weekly: 12 new posts this week...' },
{ tag: 'product-update', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Product changelogs, feature announcements, and vendor marketing disguised as tips', example: 'Discover more with your Upstash free account — popular use cases inside' },
{ tag: 'receipt', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Completed transaction confirmations with no decision remaining: payment receipts, salary/payroll disbursements, tax payment acknowledgements (challans), GST/VAT filing confirmations (GSTR1 ARNs), TDS workings, recurring invoice-sharing threads, and transfer-initiated confirmations', example: 'Challan payment under section 200 for TAN BLXXXXXX4B has been successfully paid.' },
{ tag: 'social', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
{ tag: 'forums', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists, group discussions, and Google Groups moderation digests that are not spam digests', example: 'Re: [dev-list] Question about API design' },
{ tag: 'scheduling', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Automated meeting reminders, scheduling tool confirmations, and calendar system notifications with no named person or context. NOT real meeting invites with specific people — those are topic: meeting.', example: 'Reminder: your meeting is about to start. Join with Google Meet.' },
{ tag: 'travel', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
{ tag: 'shopping', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
{ tag: 'health', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
{ tag: 'learning', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Courses, webinars, workshops, knowledge sessions, and education marketing — even from platforms you are enrolled in', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
// ── Action ───────────────────────────────────────────────────────────
// ── Action — urgency signals (all create) ─────────────────────────────
{ tag: 'action-required', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
{ tag: 'fyi', type: 'action', applicability: 'email', noteEffect: 'skip', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' },
{ tag: 'urgent', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' },
{ tag: 'waiting', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Waiting on a response from them' },
// ── Status (email) ───────────────────────────────────────────────────
// ── Status — workflow state (all none) ────────────────────────────────
{ tag: 'unread', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Not yet processed' },
{ tag: 'to-reply', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Need to respond' },
{ tag: 'done', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Handled, can be archived' },
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
// ── Source (notes only) ──────────────────────────────────────────────
// ── Source — origin metadata (notes only, all none) ───────────────────
{ tag: 'email', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from email' },
{ tag: 'meeting', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from meeting transcript' },
{ tag: 'browser', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Content captured from web browsing' },
{ tag: 'web-search', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Information from web search' },
{ tag: 'manual', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Manually entered by user' },
{ tag: 'import', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Imported from another system' },
// ── Status (notes) ──────────────────────────────────────────────────
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
];
// ── Disk-backed config with mtime caching ──────────────────────────────────
@ -146,7 +150,7 @@ export function getTagDefinitions(): TagDefinition[] {
const TYPE_ORDER: TagType[] = [
'relationship', 'relationship-sub', 'topic', 'email-type',
'filter', 'action', 'status', 'source',
'noise', 'action', 'status', 'source',
];
const TYPE_LABELS: Record<TagType, string> = {
@ -154,7 +158,7 @@ const TYPE_LABELS: Record<TagType, string> = {
'relationship-sub': 'Relationship Sub-Tags',
'topic': 'Topic',
'email-type': 'Email Type',
'filter': 'Filter',
'noise': 'Noise',
'action': 'Action',
'status': 'Status',
'source': 'Source',

View file

@ -0,0 +1,23 @@
import type { TrackEventType } from '@x/shared/dist/track-block.js';
type Handler = (event: TrackEventType) => void;
class TrackBus {
private subs: Handler[] = [];
publish(event: TrackEventType): void {
for (const handler of this.subs) {
handler(event);
}
}
subscribe(handler: Handler): () => void {
this.subs.push(handler);
return () => {
const idx = this.subs.indexOf(handler);
if (idx >= 0) this.subs.splice(idx, 1);
};
}
}
export const trackBus = new TrackBus();

View file

@ -0,0 +1,189 @@
import fs from 'fs';
import path from 'path';
import { PrefixLogger, trackBlock } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
import { WorkDir } from '../../config/config.js';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { findCandidates, type ParsedTrack } from './routing.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import container from '../../di/container.js';
const POLL_INTERVAL_MS = 5_000; // 5 seconds — events should feel responsive
const EVENTS_DIR = path.join(WorkDir, 'events');
const PENDING_DIR = path.join(EVENTS_DIR, 'pending');
const DONE_DIR = path.join(EVENTS_DIR, 'done');
const log = new PrefixLogger('EventProcessor');
/**
* Write a KnowledgeEvent to the events/pending/ directory.
* Filename is a monotonically increasing ID so events sort by creation order.
* Call this function in chronological order (oldest event first) within a sync batch
* to ensure correct ordering.
*/
export async function createEvent(event: Omit<KnowledgeEvent, 'id'>): Promise<void> {
fs.mkdirSync(PENDING_DIR, { recursive: true });
const idGen = container.resolve<IMonotonicallyIncreasingIdGenerator>('idGenerator');
const id = await idGen.next();
const fullEvent: KnowledgeEvent = { id, ...event };
const filePath = path.join(PENDING_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(fullEvent, null, 2), 'utf-8');
}
function ensureDirs(): void {
fs.mkdirSync(PENDING_DIR, { recursive: true });
fs.mkdirSync(DONE_DIR, { recursive: true });
}
async function listAllTracks(): Promise<ParsedTrack[]> {
const tracks: ParsedTrack[] = [];
let entries;
try {
entries = await workspace.readdir('knowledge', { recursive: true });
} catch {
return tracks;
}
const mdFiles = entries
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
.map(e => e.path.replace(/^knowledge\//, ''));
for (const filePath of mdFiles) {
let parsedTracks;
try {
parsedTracks = await fetchAll(filePath);
} catch {
continue;
}
for (const t of parsedTracks) {
tracks.push({
trackId: t.track.trackId,
filePath,
eventMatchCriteria: t.track.eventMatchCriteria ?? '',
instruction: t.track.instruction,
active: t.track.active,
});
}
}
return tracks;
}
function moveEventToDone(filename: string, enriched: KnowledgeEvent): void {
const donePath = path.join(DONE_DIR, filename);
const pendingPath = path.join(PENDING_DIR, filename);
fs.writeFileSync(donePath, JSON.stringify(enriched, null, 2), 'utf-8');
try {
fs.unlinkSync(pendingPath);
} catch (err) {
log.log(`Failed to remove pending event ${filename}:`, err);
}
}
async function processOneEvent(filename: string): Promise<void> {
const pendingPath = path.join(PENDING_DIR, filename);
let event: KnowledgeEvent;
try {
const raw = fs.readFileSync(pendingPath, 'utf-8');
const parsed = JSON.parse(raw);
event = trackBlock.KnowledgeEventSchema.parse(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.log(`Malformed event ${filename}, moving to done with error:`, msg);
const stub: KnowledgeEvent = {
id: filename.replace(/\.json$/, ''),
source: 'unknown',
type: 'unknown',
createdAt: new Date().toISOString(),
payload: '',
processedAt: new Date().toISOString(),
error: `Failed to parse: ${msg}`,
};
moveEventToDone(filename, stub);
return;
}
log.log(`Processing event ${event.id} (source=${event.source}, type=${event.type})`);
const allTracks = await listAllTracks();
const candidates = await findCandidates(event, allTracks);
const runIds: string[] = [];
let processingError: string | undefined;
// Sequential — preserves total ordering
for (const candidate of candidates) {
try {
const result = await triggerTrackUpdate(
candidate.trackId,
candidate.filePath,
event.payload,
'event',
);
if (result.runId) runIds.push(result.runId);
log.log(`Candidate ${candidate.trackId}: ${result.action}${result.error ? ` (${result.error})` : ''}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.log(`Error triggering candidate ${candidate.trackId}:`, msg);
processingError = (processingError ? processingError + '; ' : '') + `${candidate.trackId}: ${msg}`;
}
}
const enriched: KnowledgeEvent = {
...event,
processedAt: new Date().toISOString(),
candidates: candidates.map(c => ({ trackId: c.trackId, filePath: c.filePath })),
runIds,
...(processingError ? { error: processingError } : {}),
};
moveEventToDone(filename, enriched);
}
async function processPendingEvents(): Promise<void> {
ensureDirs();
let filenames: string[];
try {
filenames = fs.readdirSync(PENDING_DIR).filter(f => f.endsWith('.json'));
} catch (err) {
log.log('Failed to read pending dir:', err);
return;
}
if (filenames.length === 0) return;
// FIFO: monotonic IDs are lexicographically sortable
filenames.sort();
log.log(`Processing ${filenames.length} pending event(s)`);
for (const filename of filenames) {
try {
await processOneEvent(filename);
} catch (err) {
log.log(`Unhandled error processing ${filename}:`, err);
// Keep the loop alive — don't move file, will retry on next tick
}
}
}
export async function init(): Promise<void> {
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
ensureDirs();
// Initial run
await processPendingEvents();
while (true) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
await processPendingEvents();
} catch (err) {
log.log('Error in main loop:', err);
}
}
}

View file

@ -0,0 +1,199 @@
import z from 'zod';
import fs from 'fs/promises';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { WorkDir } from '../../config/config.js';
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
import { TrackStateSchema } from './types.js';
import { withFileLock } from '../file-lock.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
function absPath(filePath: string): string {
return path.join(KNOWLEDGE_DIR, filePath);
}
export async function fetchAll(filePath: string): Promise<z.infer<typeof TrackStateSchema>[]> {
let content: string;
try {
content = await fs.readFile(absPath(filePath), 'utf-8');
} catch {
return [];
}
const lines = content.split('\n');
const blocks: z.infer<typeof TrackStateSchema>[] = [];
let i = 0;
const contentFenceStartMatcher = /<!--track-target:(.+)-->/;
const contentFenceEndMatcher = /<!--\/track-target:(.+)-->/;
while (i < lines.length) {
if (lines[i].trim() === '```track') {
const fenceStart = i;
i++;
const blockLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
blockLines.push(lines[i]);
i++;
}
try {
const data = parseYaml(blockLines.join('\n'));
const result = TrackBlockSchema.safeParse(data);
if (result.success) {
blocks.push({ track: result.data, fenceStart, fenceEnd: i, content: '' });
}
} catch { /* skip */ }
} else if (contentFenceStartMatcher.test(lines[i])) {
const match = contentFenceStartMatcher.exec(lines[i]);
if (match) {
const trackId = match[1];
// have we already collected this track block?
const existingBlock = blocks.find(b => b.track.trackId === trackId);
if (!existingBlock) {
i++;
continue;
}
const contentStart = i + 1;
while (i < lines.length && !contentFenceEndMatcher.test(lines[i])) {
i++;
}
const contentEnd = i;
existingBlock.content = lines.slice(contentStart, contentEnd).join('\n');
}
}
i++;
}
return blocks;
}
export async function fetch(filePath: string, trackId: string): Promise<z.infer<typeof TrackStateSchema> | null> {
const blocks = await fetchAll(filePath);
return blocks.find(b => b.track.trackId === trackId) ?? null;
}
/**
* Fetch a track block and return its canonical YAML string (or null if not found).
* Useful for IPC handlers that need to return the fresh YAML without taking a
* dependency on the `yaml` package themselves.
*/
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
const block = await fetch(filePath, trackId);
if (!block) return null;
return stringifyYaml(block.track).trimEnd();
}
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
let content = await fs.readFile(absPath(filePath), 'utf-8');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
const openIdx = content.indexOf(openTag);
const closeIdx = content.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
} else {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const lines = content.split('\n');
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
const contentFence = [openTag, newContent, closeTag];
lines.splice(insertAt, 0, ...contentFence);
content = lines.join('\n');
}
await fs.writeFile(absPath(filePath), content, 'utf-8');
});
}
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
return withFileLock(absPath(filepath), async () => {
const block = await fetch(filepath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filepath}`);
}
block.track = { ...block.track, ...updates };
// read file contents
let content = await fs.readFile(absPath(filepath), 'utf-8');
const lines = content.split('\n');
const yaml = stringifyYaml(block.track).trimEnd();
const yamlLines = yaml ? yaml.split('\n') : [];
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
content = lines.join('\n');
await fs.writeFile(absPath(filepath), content, 'utf-8');
});
}
/**
* Replace the entire YAML of a track block on disk with a new string.
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim
* used when the user explicitly edits raw YAML in the modal.
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
* otherwise the write is rejected.
*/
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const block = await fetch(filePath, trackId);
if (!block) {
throw new Error(`Track ${trackId} not found in ${filePath}`);
}
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
if (!parsed.success) {
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
}
if (parsed.data.trackId !== trackId) {
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const yamlLines = newYaml.trimEnd().split('\n');
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
});
}
/**
* Remove a track block and its sibling target region from the file.
*/
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
return withFileLock(absPath(filePath), async () => {
const block = await fetch(filePath, trackId);
if (!block) {
// Already gone — treat as success.
return;
}
const content = await fs.readFile(absPath(filePath), 'utf-8');
const lines = content.split('\n');
const openTag = `<!--track-target:${trackId}-->`;
const closeTag = `<!--/track-target:${trackId}-->`;
// Find target region (may not exist)
let targetStart = -1;
let targetEnd = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(openTag)) { targetStart = i; }
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
}
// Build a list of [start, end] ranges to remove, sorted descending so
// indices stay valid as we splice.
const ranges: Array<[number, number]> = [];
ranges.push([block.fenceStart, block.fenceEnd]);
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
ranges.push([targetStart, targetEnd]);
}
ranges.sort((a, b) => b[0] - a[0]);
for (const [start, end] of ranges) {
lines.splice(start, end - start + 1);
// Also drop a trailing blank line if the removal left two in a row.
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
lines.splice(start, 1);
}
}
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
});
}

View file

@ -0,0 +1,118 @@
import { generateObject } from 'ai';
import { trackBlock, PrefixLogger } from '@x/shared';
import type { KnowledgeEvent } from '@x/shared/dist/track-block.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';
const log = new PrefixLogger('TrackRouting');
const BATCH_SIZE = 20;
export interface ParsedTrack {
trackId: string;
filePath: string;
eventMatchCriteria: string;
instruction: string;
active: boolean;
}
const ROUTING_SYSTEM_PROMPT = `You are a routing classifier for a knowledge management system.
You will receive an event (something that happened an email, meeting, message, etc.) and a list of track blocks. Each track block has:
- trackId: an identifier (only unique within its file)
- filePath: the note file the track lives in
- eventMatchCriteria: a description of what kinds of signals are relevant to this track
Your job is to identify which track blocks MIGHT be relevant to this event.
Rules:
- Be LIBERAL in your selections. Include any track that is even moderately relevant.
- Prefer false positives over false negatives. It is much better to include a track that turns out to be irrelevant than to miss one that was relevant.
- Only exclude tracks that are CLEARLY and OBVIOUSLY irrelevant to the event.
- Do not attempt to judge whether the event contains enough information to update the track. That is handled by a later stage.
- Return an empty list only if no tracks are relevant at all.
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
async function resolveModel() {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const signedIn = await isSignedIn();
const provider = signedIn
? await getGatewayProvider()
: createProvider(config.provider);
const modelId = config.knowledgeGraphModel
|| (signedIn ? 'gpt-5.4' : config.model);
return provider.languageModel(modelId);
}
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
const trackList = batch
.map((t, i) => `${i + 1}. trackId: ${t.trackId}\n filePath: ${t.filePath}\n eventMatchCriteria: ${t.eventMatchCriteria}`)
.join('\n\n');
return `## Event
Source: ${event.source}
Type: ${event.type}
Time: ${event.createdAt}
${event.payload}
## Track Blocks
${trackList}`;
}
function trackKey(trackId: string, filePath: string): string {
return `${filePath}::${trackId}`;
}
export async function findCandidates(
event: KnowledgeEvent,
allTracks: ParsedTrack[],
): Promise<ParsedTrack[]> {
// Short-circuit for targeted re-runs — skip LLM routing entirely
if (event.targetTrackId && event.targetFilePath) {
const target = allTracks.find(t =>
t.trackId === event.targetTrackId && t.filePath === event.targetFilePath
);
return target ? [target] : [];
}
const filtered = allTracks.filter(t =>
t.active && t.instruction && t.eventMatchCriteria
);
if (filtered.length === 0) {
log.log(`No event-eligible tracks (none with eventMatchCriteria)`);
return [];
}
log.log(`Routing event ${event.id} against ${filtered.length} track(s)`);
const model = await resolveModel();
const candidateKeys = new Set<string>();
for (let i = 0; i < filtered.length; i += BATCH_SIZE) {
const batch = filtered.slice(i, i + BATCH_SIZE);
try {
const { object } = await generateObject({
model,
system: ROUTING_SYSTEM_PROMPT,
prompt: buildRoutingPrompt(event, batch),
schema: trackBlock.Pass1OutputSchema,
});
for (const c of object.candidates) {
candidateKeys.add(trackKey(c.trackId, c.filePath));
}
} catch (err) {
log.log(`Routing batch ${i / BATCH_SIZE} failed:`, err);
}
}
const candidates = filtered.filter(t => candidateKeys.has(trackKey(t.trackId, t.filePath)));
log.log(`Event ${event.id}: ${candidates.length} candidate(s) — ${candidates.map(c => `${c.trackId}@${c.filePath}`).join(', ') || '(none)'}`);
return candidates;
}

View file

@ -0,0 +1,316 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { WorkDir } from '../../config/config.js';
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date.
Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others optimize for **information density and scannability**, not conversational prose.
# Background Mode
You are running as a scheduled or event-triggered background task **there is no user present** to clarify, approve, or watch.
- Do NOT ask clarifying questions make the most reasonable interpretation of the instruction and proceed.
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line.
# Message Anatomy
Every run message has this shape:
Update track **<trackId>** in \`<filePath>\`.
**Time:** <localized datetime> (<timezone>)
**Instruction:**
<the user-authored track instruction usually 1-3 sentences describing what to produce>
**Current content:**
<the existing contents of the target region, or "(empty — first run)">
Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`.
For **manual** runs, an optional trailing block may appear:
**Context:**
<extra one-run-only guidance a backfill hint, a focus window, extra data>
Apply context for this run only it is not a permanent edit to the instruction.
For **event-triggered** runs, a trailing block appears instead:
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
**Event match criteria for this track:** <from the track's YAML>
**Event payload:** <the event body e.g., an email>
**Decision:** ... skip if not relevant ...
On event runs you are the Pass 2 judge see "The No-Update Decision" below.
# What Good Output Looks Like
This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative do not improvise a different layout.
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know.
- **No self-reference.** Do not write "I updated this at X" the system records timestamps separately.
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
# Output Block Types
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
## \`table\` — tabular data (JSON)
Use for: scoreboards, leaderboards, comparisons, multi-row status digests.
\`\`\`table
{
"title": "Top stories on Hacker News",
"columns": ["Rank", "Title", "Points", "Comments"],
"data": [
{"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312},
{"Rank": 2, "Title": "...", "Points": 530, "Comments": 144}
]
}
\`\`\`
Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`.
## \`chart\` — line / bar / pie chart (JSON)
Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning.
\`\`\`chart
{
"chart": "line",
"title": "USD/INR — last 7 days",
"x": "date",
"y": "rate",
"data": [
{"date": "2026-04-13", "rate": 83.41},
{"date": "2026-04-14", "rate": 83.38}
]
}
\`\`\`
Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`.
## \`mermaid\` — diagrams (raw Mermaid source)
Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps.
\`\`\`mermaid
graph LR
A[Project Alpha] --> B[Sarah Chen]
A --> C[Acme Corp]
B --> D[Q3 Launch]
\`\`\`
Body is plain Mermaid source no JSON wrapper.
## \`calendar\` — list of events (JSON)
Use for: upcoming meetings, agenda digests, day/week views.
\`\`\`calendar
{
"title": "Today",
"events": [
{
"summary": "1:1 with Sarah",
"start": {"dateTime": "2026-04-20T10:00:00-07:00"},
"end": {"dateTime": "2026-04-20T10:30:00-07:00"},
"location": "Zoom",
"conferenceLink": "https://zoom.us/j/..."
}
]
}
\`\`\`
Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool).
## \`email\` — single email or thread digest (JSON)
Use for: surfacing one important thread latest message body, summary of prior context, optional draft reply.
\`\`\`email
{
"subject": "Q3 launch readiness",
"from": "sarah@acme.com",
"date": "2026-04-19T16:42:00Z",
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
}
\`\`\`
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
## \`image\` — single image (JSON)
Use for: charts, screenshots, photos you have a URL or workspace path for.
\`\`\`image
{
"src": "https://example.com/forecast.png",
"alt": "Weather forecast",
"caption": "Bay Area · April 20"
}
\`\`\`
Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`.
## \`embed\` — YouTube / Figma embed (JSON)
Use for: linking to a video or design that should render inline.
\`\`\`embed
{
"provider": "youtube",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"caption": "Latest demo"
}
\`\`\`
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form.
## \`iframe\` — arbitrary embedded webpage (JSON)
Use for: live dashboards, status pages, trackers anything that has its own webpage and benefits from being live, not snapshotted.
\`\`\`iframe
{
"url": "https://status.example.com",
"title": "Service status",
"height": 600
}
\`\`\`
Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (2401600), \`allow\` (Permissions-Policy string).
## \`transcript\` — long transcript (JSON)
Use for: meeting transcripts, voice-note dumps bodies that benefit from a collapsible UI.
\`\`\`transcript
{"transcript": "[00:00] Speaker A: Welcome everyone..."}
\`\`\`
Required: \`transcript\` (string).
## \`prompt\` — starter Copilot prompt (YAML)
Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot, with this note attached as a file mention.
\`\`\`prompt
label: Draft replies to today's emails
instruction: |
For each unanswered email in the digest above, draft a 2-line reply
in my voice and present them as a checklist for me to approve.
\`\`\`
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
# Interpreting the Instruction
The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
- "Top 5" is a target fewer is acceptable if that's all that exists.
- "Current" means as of now (use the **Time** block).
- Unspecified units standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone).
- Unspecified sources your best reliable source (web-search for public data, workspace for user data).
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") these are decoration.
# Current Content Handling
The **Current content** block shows what lives in the target region right now. Three cases:
1. **"(empty — first run)"** produce the content from scratch.
2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep.
3. **Content that does NOT match the instruction's format** the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch.
You always write a **complete** replacement, not a diff.
# The No-Update Decision
You may finish a run without calling \`update-track-content\`. Two legitimate cases:
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update.
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically.
When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*.
# Writing the Result
Call \`update-track-content\` **at most once per run**:
- Pass \`filePath\` and \`trackId\` exactly as given in the message.
- Pass the **complete** new content as \`content\` — the entire replacement for the target region.
- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those.
- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only.
# Tools
You have the full workspace toolkit. Quick reference for common cases:
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
# The Knowledge Graph
The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organized into:
- **People/** individuals
- **Organizations/** companies
- **Projects/** initiatives
- **Topics/** recurring themes
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events.
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
- \`workspace-readdir("gmail_sync/")\`
# Failure & Fallback
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
- Do **not** fabricate or speculate.
- Do **not** write partial or placeholder content into the target region leave existing content intact by not calling \`update-track-content\`.
- Explain the failure in the summary line.
# Final Summary
End your response with **one line** (1-2 short sentences). The system stores this as \`lastRunSummary\` and surfaces it in the UI.
State the action and the substance. Good examples:
- "Updated — 3 new HN stories, top is 'Show HN: …' at 842 pts."
- "Updated — USD/INR 83.42 as of 14:05 IST."
- "No change — status page shows all operational."
- "Skipped — event was a calendar invite unrelated to Q3 planning."
- "Failed — web-search returned no results for the query."
Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
`;
export function buildTrackRunAgent(): z.infer<typeof Agent> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
tools[name] = { type: 'builtin', name };
}
return {
name: 'track-run',
description: 'Background agent that updates track block content',
instructions: TRACK_RUN_INSTRUCTIONS,
tools,
};
}

View file

@ -0,0 +1,168 @@
import z from 'zod';
import { fetchAll, updateTrackBlock } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { trackBus } from './bus.js';
import type { TrackStateSchema } from './types.js';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
export interface TrackUpdateResult {
trackId: string;
runId: string | null;
action: 'replace' | 'no_update';
contentBefore: string | null;
contentAfter: string | null;
summary: string | null;
error?: string;
}
// ---------------------------------------------------------------------------
// Agent run
// ---------------------------------------------------------------------------
function buildMessage(
filePath: string,
track: z.infer<typeof TrackStateSchema>,
trigger: 'manual' | 'timed' | 'event',
context?: string,
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
let msg = `Update track **${track.track.trackId}** in \`${filePath}\`.
**Time:** ${localNow} (${tz})
**Instruction:**
${track.track.instruction}
**Current content:**
${track.content || '(empty — first run)'}
Use \`update-track-content\` with filePath=\`${filePath}\` and trackId=\`${track.track.trackId}\`.`;
if (trigger === 'event') {
msg += `
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant to the event below)
**Event match criteria for this track:**
${track.track.eventMatchCriteria ?? '(none — should not happen for event-triggered runs)'}
**Event payload:**
${context ?? '(no payload)'}
**Decision:** Determine whether this event genuinely warrants updating the track content. If the event is not meaningfully relevant on closer inspection, skip the update do NOT call \`update-track-content\`. Only call the tool if the event provides new or changed information that should be reflected in the track.`;
} else if (context) {
msg += `\n\n**Context:**\n${context}`;
}
return msg;
}
// ---------------------------------------------------------------------------
// Concurrency guard
// ---------------------------------------------------------------------------
const runningTracks = new Set<string>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Trigger an update for a specific track block.
* Can be called by any trigger system (manual, cron, event matching).
*/
export async function triggerTrackUpdate(
trackId: string,
filePath: string,
context?: string,
trigger: 'manual' | 'timed' | 'event' = 'manual',
): Promise<TrackUpdateResult> {
const key = `${trackId}:${filePath}`;
const logger = new PrefixLogger('track:runner');
logger.log('triggering track update', trackId, filePath, trigger, context);
if (runningTracks.has(key)) {
logger.log('skipping, already running');
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Already running' };
}
runningTracks.add(key);
try {
const tracks = await fetchAll(filePath);
logger.log('fetched tracks from file', tracks);
const track = tracks.find(t => t.track.trackId === trackId);
if (!track) {
logger.log('track not found', trackId, filePath, trigger, context);
return { trackId, runId: null, action: 'no_update', contentBefore: null, contentAfter: null, summary: null, error: 'Track not found' };
}
const contentBefore = track.content;
// Emit start event — runId is set after agent run is created
const agentRun = await createRun({ agentId: 'track-run' });
// Set lastRunAt and lastRunId immediately (before agent executes) so
// the scheduler's next poll won't re-trigger this track.
await updateTrackBlock(filePath, trackId, {
lastRunAt: new Date().toISOString(),
lastRunId: agentRun.id,
});
await trackBus.publish({
type: 'track_run_start',
trackId,
filePath,
trigger,
runId: agentRun.id,
});
try {
await createMessage(agentRun.id, buildMessage(filePath, track, trigger, context));
await waitForRunCompletion(agentRun.id);
const summary = await extractAgentResponse(agentRun.id);
const updatedTracks = await fetchAll(filePath);
const contentAfter = updatedTracks.find(t => t.track.trackId === trackId)?.content;
const didUpdate = contentAfter !== contentBefore;
// Update summary on completion
await updateTrackBlock(filePath, trackId, {
lastRunSummary: summary ?? undefined,
});
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
summary: summary ?? undefined,
});
return {
trackId,
runId: agentRun.id,
action: didUpdate ? 'replace' : 'no_update',
contentBefore: contentBefore ?? null,
contentAfter: contentAfter ?? null,
summary,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await trackBus.publish({
type: 'track_run_complete',
trackId,
filePath,
runId: agentRun.id,
error: msg,
});
return { trackId, runId: agentRun.id, action: 'no_update', contentBefore: contentBefore ?? null, contentAfter: null, summary: null, error: msg };
}
} finally {
runningTracks.delete(key);
}
}

View file

@ -0,0 +1,63 @@
import { CronExpressionParser } from 'cron-parser';
import type { TrackSchedule } from '@x/shared/dist/track-block.js';
const GRACE_MS = 2 * 60 * 1000; // 2 minutes
/**
* Determine if a scheduled track is due to run.
* All schedule types enforce a 2-minute grace period if the scheduled time
* was more than 2 minutes ago, it's considered a miss and skipped.
*/
export function isTrackScheduleDue(schedule: TrackSchedule, lastRunAt: string | null): boolean {
const now = new Date();
switch (schedule.type) {
case 'cron': {
if (!lastRunAt) return true; // Never ran — immediately due
try {
// Find the MOST RECENT occurrence at-or-before `now`, not the
// occurrence right after lastRunAt. If lastRunAt is old, that
// occurrence would be ancient too and always fall outside the
// grace window, blocking every future fire.
const interval = CronExpressionParser.parse(schedule.expression, {
currentDate: now,
});
const prevRun = interval.prev().toDate();
// Already ran at-or-after this occurrence → skip.
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
// Within grace → fire. Outside grace → missed, skip.
return now.getTime() <= prevRun.getTime() + GRACE_MS;
} catch {
return false;
}
}
case 'window': {
// Time-of-day filter (applies regardless of lastRunAt state).
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
if (nowMinutes < startMinutes || nowMinutes > endMinutes) return false;
if (!lastRunAt) return true;
try {
const interval = CronExpressionParser.parse(schedule.cron, {
currentDate: now,
});
const prevRun = interval.prev().toDate();
if (new Date(lastRunAt).getTime() >= prevRun.getTime()) return false;
return now.getTime() <= prevRun.getTime() + GRACE_MS;
} catch {
return false;
}
}
case 'once': {
if (lastRunAt) return false; // Already ran
const runAt = new Date(schedule.runAt);
return now >= runAt && now.getTime() <= runAt.getTime() + GRACE_MS;
}
}
}

View file

@ -0,0 +1,66 @@
import { PrefixLogger } from '@x/shared';
import * as workspace from '../../workspace/workspace.js';
import { fetchAll } from './fileops.js';
import { triggerTrackUpdate } from './runner.js';
import { isTrackScheduleDue } from './schedule-utils.js';
const log = new PrefixLogger('TrackScheduler');
const POLL_INTERVAL_MS = 15_000; // 15 seconds
async function listKnowledgeMarkdownFiles(): Promise<string[]> {
try {
const entries = await workspace.readdir('knowledge', { recursive: true });
return entries
.filter(e => e.kind === 'file' && e.name.endsWith('.md'))
.map(e => e.path.replace(/^knowledge\//, ''));
} catch {
return [];
}
}
async function processScheduledTracks(): Promise<void> {
const relativePaths = await listKnowledgeMarkdownFiles();
log.log(`Scanning ${relativePaths.length} markdown files`);
for (const relativePath of relativePaths) {
let tracks;
try {
tracks = await fetchAll(relativePath);
} catch {
continue;
}
for (const trackState of tracks) {
const { track } = trackState;
if (!track.active) continue;
if (!track.schedule) continue;
const due = isTrackScheduleDue(track.schedule, track.lastRunAt ?? null);
log.log(`Track "${track.trackId}" in ${relativePath}: schedule=${track.schedule.type}, lastRunAt=${track.lastRunAt ?? 'never'}, due=${due}`);
if (due) {
log.log(`Triggering "${track.trackId}" in ${relativePath}`);
triggerTrackUpdate(track.trackId, relativePath, undefined, 'timed').catch(err => {
log.log(`Error running ${track.trackId}:`, err);
});
}
}
}
}
export async function init(): Promise<void> {
log.log(`Starting, polling every ${POLL_INTERVAL_MS / 1000}s`);
// Initial run
await processScheduledTracks();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
await processScheduledTracks();
} catch (error) {
log.log('Error in main loop:', error);
}
}
}

View file

@ -0,0 +1,9 @@
import z from "zod";
import { TrackBlockSchema } from "@x/shared/dist/track-block.js";
export const TrackStateSchema = z.object({
track: TrackBlockSchema,
fenceStart: z.number(),
fenceEnd: z.number(),
content: z.string(),
});

View file

@ -0,0 +1,606 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import type { Server } from 'node:http';
import chokidar, { type FSWatcher } from 'chokidar';
import express from 'express';
import { WorkDir } from '../config/config.js';
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
export const LOCAL_SITES_PORT = 3210;
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
const SITE_RELOAD_MESSAGE = 'rowboat:site-changed';
const SITE_EVENTS_PATH = '__rowboat_events';
const SITE_RELOAD_DEBOUNCE_MS = 140;
const SITE_EVENTS_RETRY_MS = 1000;
const SITE_EVENTS_HEARTBEAT_MS = 15000;
const TEXT_EXTENSIONS = new Set([
'.css',
'.html',
'.js',
'.json',
'.map',
'.mjs',
'.svg',
'.txt',
'.xml',
]);
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.xml': 'application/xml; charset=utf-8',
};
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
(() => {
const SITE_CHANGED_MESSAGE = '__ROWBOAT_SITE_CHANGED_MESSAGE__';
const SITE_EVENTS_PATH = '__ROWBOAT_SITE_EVENTS_PATH__';
let reloadRequested = false;
let reloadSource = null;
const getSiteSlug = () => {
const match = window.location.pathname.match(/^\/sites\/([^/]+)/i);
return match ? decodeURIComponent(match[1]) : null;
};
const scheduleReload = () => {
if (reloadRequested) return;
reloadRequested = true;
try {
reloadSource?.close();
} catch {
// ignore close failures
}
window.setTimeout(() => {
window.location.reload();
}, 80);
};
const connectLiveReload = () => {
const siteSlug = getSiteSlug();
if (!siteSlug || typeof EventSource === 'undefined') return;
const streamUrl = new URL('/sites/' + encodeURIComponent(siteSlug) + '/' + SITE_EVENTS_PATH, window.location.origin);
const source = new EventSource(streamUrl.toString());
reloadSource = source;
source.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === SITE_CHANGED_MESSAGE) {
scheduleReload();
}
} catch {
// ignore malformed payloads
}
});
window.addEventListener('beforeunload', () => {
try {
source.close();
} catch {
// ignore close failures
}
}, { once: true });
};
connectLiveReload();
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
const MIN_HEIGHT = 240;
let animationFrameId = 0;
let lastHeight = 0;
const applyEmbeddedStyles = () => {
const root = document.documentElement;
if (root) root.style.overflowY = 'hidden';
if (document.body) document.body.style.overflowY = 'hidden';
};
const measureHeight = () => {
const root = document.documentElement;
const body = document.body;
return Math.max(
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
root?.clientHeight ?? 0,
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
body?.clientHeight ?? 0,
);
};
const publishHeight = () => {
animationFrameId = 0;
applyEmbeddedStyles();
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
if (Math.abs(nextHeight - lastHeight) < 2) return;
lastHeight = nextHeight;
window.parent.postMessage({
type: MESSAGE_TYPE,
height: nextHeight,
href: window.location.href,
}, '*');
};
const schedulePublish = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(publishHeight);
};
const resizeObserver = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(schedulePublish)
: null;
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
if (resizeObserver && document.body) resizeObserver.observe(document.body);
const mutationObserver = new MutationObserver(schedulePublish);
if (document.documentElement) {
mutationObserver.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}
window.addEventListener('load', schedulePublish);
window.addEventListener('resize', schedulePublish);
if (document.fonts?.addEventListener) {
document.fonts.addEventListener('loadingdone', schedulePublish);
}
for (const delay of [0, 50, 150, 300, 600, 1200]) {
setTimeout(schedulePublish, delay);
}
schedulePublish();
})();
</script>`;
let localSitesServer: Server | null = null;
let startPromise: Promise<void> | null = null;
let localSitesWatcher: FSWatcher | null = null;
const siteEventClients = new Map<string, Set<express.Response>>();
const siteReloadTimers = new Map<string, NodeJS.Timeout>();
function isSafeSiteSlug(siteSlug: string): boolean {
return SITE_SLUG_RE.test(siteSlug);
}
function resolveSiteDir(siteSlug: string): string | null {
if (!isSafeSiteSlug(siteSlug)) return null;
return path.join(LOCAL_SITES_DIR, siteSlug);
}
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
const candidate = requestPath === '/' ? '/index.html' : requestPath;
const normalized = path.posix.normalize(candidate);
const relativePath = normalized.replace(/^\/+/, '');
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
return null;
}
const absolutePath = path.resolve(siteDir, relativePath);
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
return null;
}
return absolutePath;
}
function getRequestPath(req: express.Request): string {
const rawPath = req.url.split('?')[0] || '/';
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
}
function listLocalSites(): Array<{ slug: string; url: string }> {
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
.map((entry) => ({
slug: entry.name,
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
}))
.sort((a, b) => a.slug.localeCompare(b.slug));
}
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
}
async function writeIfMissing(filePath: string, content: string): Promise<void> {
try {
await fsp.access(filePath);
} catch {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, content, 'utf8');
}
}
async function ensureLocalSiteScaffold(): Promise<void> {
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
await Promise.all(
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
),
);
}
function injectIframeAutosizeBootstrap(html: string): string {
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP
.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
.replace('__ROWBOAT_SITE_CHANGED_MESSAGE__', SITE_RELOAD_MESSAGE)
.replace('__ROWBOAT_SITE_EVENTS_PATH__', SITE_EVENTS_PATH)
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
}
return `${html}\n${bootstrap}`
}
function getSiteSlugFromAbsolutePath(absolutePath: string): string | null {
const relativePath = path.relative(LOCAL_SITES_DIR, absolutePath);
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return null;
}
const [siteSlug] = relativePath.split(path.sep);
return siteSlug && isSafeSiteSlug(siteSlug) ? siteSlug : null;
}
function removeSiteEventClient(siteSlug: string, res: express.Response): void {
const clients = siteEventClients.get(siteSlug);
if (!clients) return;
clients.delete(res);
if (clients.size === 0) {
siteEventClients.delete(siteSlug);
}
}
function broadcastSiteReload(siteSlug: string, changedPath: string): void {
const clients = siteEventClients.get(siteSlug);
if (!clients || clients.size === 0) return;
const payload = JSON.stringify({
type: SITE_RELOAD_MESSAGE,
siteSlug,
changedPath,
at: Date.now(),
});
for (const res of Array.from(clients)) {
try {
res.write(`data: ${payload}\n\n`);
} catch {
removeSiteEventClient(siteSlug, res);
}
}
}
function scheduleSiteReload(siteSlug: string, changedPath: string): void {
const existingTimer = siteReloadTimers.get(siteSlug);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
siteReloadTimers.delete(siteSlug);
broadcastSiteReload(siteSlug, changedPath);
}, SITE_RELOAD_DEBOUNCE_MS);
siteReloadTimers.set(siteSlug, timer);
}
async function startSiteWatcher(): Promise<void> {
if (localSitesWatcher) return;
const watcher = chokidar.watch(LOCAL_SITES_DIR, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 180,
pollInterval: 50,
},
});
watcher
.on('all', (eventName, absolutePath) => {
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
const siteSlug = getSiteSlugFromAbsolutePath(absolutePath);
if (!siteSlug) return;
const siteRoot = path.join(LOCAL_SITES_DIR, siteSlug);
const relativePath = path.relative(siteRoot, absolutePath);
const normalizedPath = !relativePath || relativePath === '.'
? '.'
: relativePath.split(path.sep).join('/');
scheduleSiteReload(siteSlug, normalizedPath);
})
.on('error', (error: unknown) => {
console.error('[LocalSites] Watcher error:', error);
});
localSitesWatcher = watcher;
}
function handleSiteEventsRequest(req: express.Request, res: express.Response): void {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
if (!siteSlug || !isSafeSiteSlug(siteSlug)) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
const clients = siteEventClients.get(siteSlug) ?? new Set<express.Response>();
siteEventClients.set(siteSlug, clients);
clients.add(res);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
res.write(`retry: ${SITE_EVENTS_RETRY_MS}\n`);
res.write(`event: ready\ndata: {"ok":true}\n\n`);
const heartbeat = setInterval(() => {
try {
res.write(`: keepalive ${Date.now()}\n\n`);
} catch {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
}
}, SITE_EVENTS_HEARTBEAT_MS);
const cleanup = () => {
clearInterval(heartbeat);
removeSiteEventClient(siteSlug, res);
};
req.on('close', cleanup);
res.on('close', cleanup);
}
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
const extension = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
const stats = await fsp.stat(filePath);
res.status(200);
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Length', String(stats.size));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
if (method === 'HEAD') {
res.end();
return;
}
if (TEXT_EXTENSIONS.has(extension)) {
let text = await fsp.readFile(filePath, 'utf8');
if (extension === '.html') {
text = injectIframeAutosizeBootstrap(text);
}
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
res.end(text);
return;
}
const data = await fsp.readFile(filePath);
res.end(data);
}
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
const siteSlugParam = req.params.siteSlug;
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
if (!siteDir) {
res.status(400).json({ error: 'Invalid site slug' });
return;
}
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
res.status(404).json({ error: 'Site not found' });
return;
}
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
const realSiteDir = fs.realpathSync(siteDir);
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
res.status(403).json({ error: 'Site path escapes sites directory' });
return;
}
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
if (!requestedPath) {
res.status(400).json({ error: 'Invalid site path' });
return;
}
const requestedExt = path.extname(requestedPath);
if (fs.existsSync(requestedPath)) {
const stat = fs.statSync(requestedPath);
if (stat.isDirectory()) {
const indexPath = path.join(requestedPath, 'index.html');
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
const realIndexPath = fs.realpathSync(indexPath);
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, indexPath, req.method);
return;
}
} else if (stat.isFile()) {
const realRequestedPath = fs.realpathSync(requestedPath);
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, requestedPath, req.method);
return;
}
}
if (requestedExt) {
res.status(404).json({ error: 'Asset not found' });
return;
}
const spaFallback = path.join(siteDir, 'index.html');
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
res.status(404).json({ error: 'Site entrypoint not found' });
return;
}
const realFallback = fs.realpathSync(spaFallback);
if (!isPathInsideRoot(realSiteDir, realFallback)) {
res.status(403).json({ error: 'Site path escapes root' });
return;
}
await respondWithFile(res, spaFallback, req.method);
}
function createLocalSitesApp(): express.Express {
const app = express();
app.get('/health', (_req, res) => {
res.json({
ok: true,
baseUrl: LOCAL_SITES_BASE_URL,
sitesDir: LOCAL_SITES_DIR,
});
});
app.get('/sites', (_req, res) => {
res.json({
sites: listLocalSites(),
});
});
app.get(`/sites/:siteSlug/${SITE_EVENTS_PATH}`, (req, res) => {
handleSiteEventsRequest(req, res);
});
app.use('/sites/:siteSlug', (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.status(405).json({ error: 'Method not allowed' });
return;
}
void sendSiteResponse(req, res).catch((error: unknown) => {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
});
});
return app;
}
async function startServer(): Promise<void> {
if (localSitesServer) return;
const app = createLocalSitesApp();
await new Promise<void>((resolve, reject) => {
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
localSitesServer = server;
console.log('[LocalSites] Server starting.');
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
resolve();
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
return;
}
reject(error);
});
});
}
export async function init(): Promise<void> {
if (localSitesServer) return;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
await ensureLocalSiteScaffold();
await startSiteWatcher();
await startServer();
} catch (error) {
await shutdown();
throw error;
}
})().finally(() => {
startPromise = null;
});
return startPromise;
}
export async function shutdown(): Promise<void> {
const watcher = localSitesWatcher;
localSitesWatcher = null;
if (watcher) {
await watcher.close();
}
for (const timer of siteReloadTimers.values()) {
clearTimeout(timer);
}
siteReloadTimers.clear();
for (const clients of siteEventClients.values()) {
for (const res of clients) {
try {
res.end();
} catch {
// ignore close failures
}
}
}
siteEventClients.clear();
const server = localSitesServer;
localSitesServer = null;
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View file

@ -0,0 +1,625 @@
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
'README.md': `# Local Sites
Anything inside this folder is available at:
\`http://localhost:3210/sites/<slug>/\`
Examples:
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
You can embed a local site in a note with:
\`\`\`iframe
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
\`\`\`
Notes:
- The app serves each site with SPA-friendly routing, so client-side routers work
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
- Put an \`index.html\` file at the site root
- Remote APIs still need to allow browser requests from a local page
`,
'example-dashboard/index.html': `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal Deck</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<main class="shell">
<header class="hero">
<div>
<p class="eyebrow">Local iframe sample · external APIs</p>
<h1>Signal Deck</h1>
<p class="lede">
A locally-served dashboard designed to live inside a Rowboat note. It fetches
live signals from public APIs and stays readable at note width.
</p>
</div>
<div class="hero-status" id="hero-status">Booting dashboard...</div>
</header>
<section class="metric-grid" id="metric-grid"></section>
<section class="board">
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">Hacker News</p>
<h2>Live headlines</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="story-list" id="story-list"></div>
</article>
<article class="panel">
<div class="panel-header">
<div>
<p class="panel-kicker">GitHub</p>
<h2>Repo pulse</h2>
</div>
<span class="panel-chip">public API</span>
</div>
<div class="repo-list" id="repo-list"></div>
</article>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
`,
'example-dashboard/styles.css': `:root {
color-scheme: dark;
--bg: #090816;
--panel: rgba(18, 16, 39, 0.88);
--panel-strong: rgba(26, 23, 54, 0.96);
--line: rgba(255, 255, 255, 0.08);
--text: #f5f7ff;
--muted: rgba(230, 235, 255, 0.68);
--cyan: #66e2ff;
--lime: #b7ff6a;
--amber: #ffcb6b;
--pink: #ff7ed1;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
}
.ambient {
position: fixed;
inset: auto;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(70px);
pointer-events: none;
opacity: 0.35;
}
.ambient-one {
top: -80px;
right: -40px;
background: rgba(102, 226, 255, 0.22);
}
.ambient-two {
bottom: -120px;
left: -60px;
background: rgba(255, 126, 209, 0.18);
}
.shell {
position: relative;
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 40px;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.eyebrow,
.panel-kicker {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
color: var(--cyan);
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.lede {
max-width: 620px;
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
font-size: 15px;
}
.hero-status {
flex-shrink: 0;
min-width: 180px;
padding: 12px 14px;
border: 1px solid rgba(102, 226, 255, 0.18);
border-radius: 16px;
background: rgba(14, 17, 32, 0.62);
color: var(--muted);
font-size: 13px;
line-height: 1.4;
box-shadow: var(--shadow);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.metric-card,
.panel {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
padding: 18px;
min-height: 152px;
}
.metric-card::after,
.panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
pointer-events: none;
}
.metric-label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric-value {
margin-top: 16px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.metric-detail {
margin-top: 12px;
color: var(--muted);
font-size: 13px;
}
.metric-spark {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 6px;
align-items: end;
height: 40px;
margin-top: 18px;
}
.metric-spark span {
display: block;
border-radius: 999px 999px 3px 3px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
}
.board {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 18px;
}
.panel {
padding: 20px;
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.panel-header h2 {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.3rem;
letter-spacing: -0.04em;
}
.panel-chip {
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--muted);
font-size: 12px;
}
.story-list,
.repo-list {
display: grid;
gap: 12px;
}
.story-item,
.repo-item {
position: relative;
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
background: var(--panel-strong);
}
.story-rank {
position: absolute;
top: 14px;
right: 14px;
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.18);
}
.story-item a,
.repo-item a {
color: var(--text);
text-decoration: none;
}
.story-item a:hover,
.repo-item a:hover {
color: var(--cyan);
}
.story-title,
.repo-name {
padding-right: 34px;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
}
.story-meta,
.repo-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.story-pill,
.repo-pill {
padding: 5px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.repo-description {
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.empty-state {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
font-size: 14px;
}
@media (max-width: 940px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.board {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.shell {
padding: 22px 14px 28px;
}
.hero {
flex-direction: column;
}
.hero-status {
width: 100%;
}
.metric-grid {
grid-template-columns: 1fr;
}
.panel,
.metric-card {
border-radius: 18px;
}
}
`,
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const reposConfig = [
{
slug: 'rowboatlabs/rowboat',
label: 'Rowboat',
description: 'AI coworker with memory',
},
{
slug: 'openai/openai-cookbook',
label: 'OpenAI Cookbook',
description: 'Examples and guides for building with OpenAI APIs',
},
];
const fallbackStories = [
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
];
const fallbackRepos = [
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
];
const metricGrid = document.getElementById('metric-grid');
const storyList = document.getElementById('story-list');
const repoList = document.getElementById('repo-list');
const heroStatus = document.getElementById('hero-status');
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
return response.json();
}
async function loadRepos() {
try {
const repos = await Promise.all(
reposConfig.map(async (repo) => {
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
return {
...repo,
stars: data.stargazers_count,
forks: data.forks_count,
issues: data.open_issues_count,
url: data.html_url,
};
}),
);
return repos;
} catch {
return fallbackRepos;
}
}
async function loadStories() {
try {
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
const stories = await Promise.all(
ids.slice(0, 4).map((id) =>
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
),
);
return stories
.filter(Boolean)
.map((story) => ({
id: story.id,
title: story.title,
score: story.score || 0,
descendants: story.descendants || 0,
by: story.by || 'unknown',
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
}));
} catch {
return fallbackStories;
}
}
function metricSpark(values) {
const max = Math.max(...values, 1);
const bars = values.map((value) => {
const height = Math.max(18, Math.round((value / max) * 40));
return '<span style="height:' + height + 'px"></span>';
});
return '<div class="metric-spark">' + bars.join('') + '</div>';
}
function renderMetrics(repos, stories) {
const leadRepo = repos[0];
const companionRepo = repos[1];
const topStory = stories[0];
const averageScore = Math.round(
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
);
const metrics = [
{
label: 'Rowboat stars',
value: formatter.format(leadRepo.stars),
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
accent: 'var(--cyan)',
},
{
label: 'Cookbook stars',
value: formatter.format(companionRepo.stars),
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
accent: 'var(--lime)',
},
{
label: 'Top story score',
value: formatter.format(topStory.score),
detail: topStory.descendants + ' comments · by ' + topStory.by,
spark: stories.map((story) => story.score),
accent: 'var(--amber)',
},
{
label: 'Average HN score',
value: formatter.format(averageScore),
detail: stories.length + ' live stories in this panel',
spark: stories.map((story) => story.descendants + 10),
accent: 'var(--pink)',
},
];
metricGrid.innerHTML = metrics
.map((metric) => (
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
'<div class="metric-label">' + metric.label + '</div>' +
'<div class="metric-value">' + metric.value + '</div>' +
'<div class="metric-detail">' + metric.detail + '</div>' +
metricSpark(metric.spark) +
'</article>'
))
.join('');
}
function renderStories(stories) {
storyList.innerHTML = stories
.map((story, index) => (
'<article class="story-item">' +
'<div class="story-rank">0' + (index + 1) + '</div>' +
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
'<div class="story-meta">' +
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
'<span class="story-pill">' + story.descendants + ' comments</span>' +
'<span class="story-pill">by ' + story.by + '</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderRepos(repos) {
repoList.innerHTML = repos
.map((repo) => (
'<article class="repo-item">' +
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
'<p class="repo-description">' + repo.description + '</p>' +
'<div class="repo-meta">' +
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
'</div>' +
'</article>'
))
.join('');
}
function renderErrorState(message) {
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
}
async function refresh() {
heroStatus.textContent = 'Refreshing live signals...';
try {
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
if (!repos.length || !stories.length) {
renderErrorState('The sample site loaded, but the data sources returned no content.');
heroStatus.textContent = 'Loaded with empty data.';
return;
}
renderMetrics(repos, stories);
renderStories(stories);
renderRepos(repos);
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
}) + ' · embedded from sites/example-dashboard';
} catch (error) {
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
}
}
refresh();
setInterval(refresh, 120000);
`,
}

View file

@ -3,11 +3,18 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
const authedFetch: typeof fetch = async (input, init) => {
const token = await getAccessToken();
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers });
};
export async function getGatewayProvider(): Promise<ProviderV2> {
const accessToken = await getAccessToken();
return createOpenRouter({
baseURL: `${API_URL}/v1/llm`,
apiKey: accessToken,
apiKey: 'managed-by-rowboat',
fetch: authedFetch,
});
}

View file

@ -6,12 +6,66 @@ import { WorkDir } from "../config/config.js";
const CACHE_PATH = path.join(WorkDir, "config", "models.dev.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/*
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"tool_call": true,
"temperature": true,
"knowledge": "2025-05",
"release_date": "2026-02-05",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"cost": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25
},
"limit": {
"context": 1000000,
"output": 128000
},
"experimental": {
"modes": {
"fast": {
"cost": {
"input": 30,
"output": 150,
"cache_read": 3,
"cache_write": 37.5
},
"provider": {
"body": {
"speed": "fast"
},
"headers": {
"anthropic-beta": "fast-mode-2026-02-01"
}
}
}
}
}
}
*/
const ModelsDevModel = z.object({
id: z.string().optional(),
name: z.string().optional(),
release_date: z.string().optional(),
tool_call: z.boolean().optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
}).passthrough();
@ -125,7 +179,6 @@ function pickProvider(
}
function isStableModel(model: z.infer<typeof ModelsDevModel>): boolean {
if (model.experimental) return false;
if (model.status && ["alpha", "beta", "deprecated"].includes(model.status)) return false;
return true;
}
@ -141,7 +194,6 @@ function normalizeModels(models: Record<string, z.infer<typeof ModelsDevModel>>)
name: model.name,
release_date: model.release_date,
tool_call: model.tool_call,
experimental: model.experimental,
status: model.status,
}))
.filter((model) => isStableModel(model) && supportsToolCall(model))

View file

@ -14,7 +14,7 @@ const defaultConfig: z.infer<typeof ModelConfig> = {
provider: {
flavor: "openai",
},
model: "gpt-4.1",
model: "gpt-5.4",
};
export class FSModelConfigRepo implements IModelConfigRepo {
@ -51,6 +51,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
model: config.model,
models: config.models,
knowledgeGraphModel: config.knowledgeGraphModel,
meetingNotesModel: config.meetingNotesModel,
};
const toWrite = { ...config, providers: existingProviders };

View file

@ -2,7 +2,7 @@ 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 { waitForRunCompletion } from '../agents/utils.js';
import {
loadConfig,
loadState,
@ -18,20 +18,6 @@ import { PREBUILT_AGENTS } from './types.js';
const CHECK_INTERVAL_MS = 60 * 1000; // Check every minute which agents need to run
const PREBUILT_DIR = path.join(WorkDir, 'pre-built');
/**
* Wait for a run to complete by listening for run-processing-end event
*/
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();
}
});
});
}
/**
* Run a pre-built agent by name
*/

View file

@ -1,6 +1,6 @@
import z from "zod";
import container from "../di/container.js";
import { IMessageQueue, UserMessageContentType, VoiceOutputMode } from "../application/lib/message-queue.js";
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
@ -19,9 +19,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled);
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;

View file

@ -2,10 +2,9 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { isSignedIn } from '../account/account.js';
import { getAccessToken } from '../auth/tokens.js';
import { WorkDir } from '../config/config.js';
import { API_URL } from '../config/env.js';
const homedir = process.env.HOME || process.env.USERPROFILE || '';
export interface VoiceConfig {
deepgram: { apiKey: string } | null;
elevenlabs: { apiKey: string; voiceId?: string } | null;
@ -13,7 +12,7 @@ export interface VoiceConfig {
async function readJsonConfig(filename: string): Promise<Record<string, unknown> | null> {
try {
const configPath = path.join(homedir, '.rowboat', 'config', filename);
const configPath = path.join(WorkDir, 'config', filename);
const raw = await fs.readFile(configPath, 'utf8');
return JSON.parse(raw);
} catch {
@ -33,23 +32,6 @@ export async function getVoiceConfig(): Promise<VoiceConfig> {
};
}
export async function getDeepgramToken(): Promise<{ token: string } | null> {
const signedIn = await isSignedIn();
if (!signedIn) return null;
const accessToken = await getAccessToken();
const response = await fetch(`${API_URL}/v1/voice/deepgram-token`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!response.ok) {
console.error('[voice] Deepgram token error:', response.status);
return null;
}
const data = await response.json();
return { token: data.token };
}
export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> {
const config = await getVoiceConfig();
const signedIn = await isSignedIn();
@ -68,7 +50,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId);
} else {
if (!config.elevenlabs) {
throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "<your-key>" }');
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
}
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;

View file

@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEven
/**
* Create a workspace watcher
* Watches ~/.rowboat recursively and emits change events via callback
* Watches the configured workspace root recursively and emits change events via callback
*
* Returns a watcher instance that can be closed.
* The watcher emits events immediately without debouncing.
@ -74,4 +74,3 @@ export async function createWorkspaceWatcher(
return watcher;
}

View file

@ -7,6 +7,7 @@ import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/share
import { WorkDir } from '../config/config.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
import { commitAll } from '../knowledge/version_history.js';
import { withFileLock } from '../knowledge/file-lock.js';
// ============================================================================
// Path Utilities
@ -249,38 +250,42 @@ export async function writeFile(
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
// Check expectedEtag if provided (conflict detection)
if (opts?.expectedEtag) {
const existingStats = await fs.lstat(filePath);
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
if (existingEtag !== opts.expectedEtag) {
throw new Error('File was modified (ETag mismatch)');
const result = await withFileLock(filePath, async () => {
// Check expectedEtag if provided (conflict detection)
if (opts?.expectedEtag) {
const existingStats = await fs.lstat(filePath);
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
if (existingEtag !== opts.expectedEtag) {
throw new Error('File was modified (ETag mismatch)');
}
}
}
// Convert data to buffer based on encoding
let buffer: Buffer;
if (encoding === 'utf8') {
buffer = Buffer.from(data, 'utf8');
} else if (encoding === 'base64') {
buffer = Buffer.from(data, 'base64');
} else {
// binary: assume data is base64-encoded
buffer = Buffer.from(data, 'base64');
}
// Convert data to buffer based on encoding
let buffer: Buffer;
if (encoding === 'utf8') {
buffer = Buffer.from(data, 'utf8');
} else if (encoding === 'base64') {
buffer = Buffer.from(data, 'base64');
} else {
// binary: assume data is base64-encoded
buffer = Buffer.from(data, 'base64');
}
if (atomic) {
// Atomic write: write to temp file, then rename
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
await fs.writeFile(tempPath, buffer);
await fs.rename(tempPath, filePath);
} else {
await fs.writeFile(filePath, buffer);
}
if (atomic) {
// Atomic write: write to temp file, then rename
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
await fs.writeFile(tempPath, buffer);
await fs.rename(tempPath, filePath);
} else {
await fs.writeFile(filePath, buffer);
}
const stats = await fs.lstat(filePath);
const stat = statToSchema(stats, 'file');
const etag = computeEtag(stats.size, stats.mtimeMs);
const stats = await fs.lstat(filePath);
const stat = statToSchema(stats, 'file');
const etag = computeEtag(stats.size, stats.mtimeMs);
return { stat, etag };
});
// Schedule a debounced version history commit for knowledge files
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
@ -289,8 +294,8 @@ export async function writeFile(
return {
path: relPath,
stat,
etag,
stat: result.stat,
etag: result.etag,
};
}