mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-06 20:42:15 +02:00
Merge remote-tracking branch 'origin/dev' into drive
This commit is contained in:
commit
8e6978b6c1
40 changed files with 2181 additions and 375 deletions
|
|
@ -3,7 +3,7 @@ import fs from "fs";
|
|||
import path from "path";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
|
||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||
|
|
@ -23,7 +23,7 @@ import { resolveProviderConfig } from "../models/defaults.js";
|
|||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
|
|
@ -235,6 +235,96 @@ function loadAgentNotesContext(): string | null {
|
|||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
||||
}
|
||||
|
||||
function isCopilotLikeAgent(agentName: string | null | undefined): boolean {
|
||||
return agentName === 'copilot' || agentName === 'rowboatx';
|
||||
}
|
||||
|
||||
function formatCurrentDateTime(now: Date): string {
|
||||
return now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function toUserMessageContextMiddlePane(middlePaneContext: MiddlePaneContext | null): z.infer<typeof UserMessageContext>['middlePane'] {
|
||||
if (!middlePaneContext) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
if (middlePaneContext.kind === 'note') {
|
||||
return {
|
||||
kind: 'note',
|
||||
path: middlePaneContext.path,
|
||||
content: middlePaneContext.content,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'browser',
|
||||
url: middlePaneContext.url,
|
||||
title: middlePaneContext.title,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessageContext({
|
||||
agentName,
|
||||
middlePaneContext,
|
||||
}: {
|
||||
agentName: string | null | undefined;
|
||||
middlePaneContext: MiddlePaneContext | null;
|
||||
}): z.infer<typeof UserMessageContext> {
|
||||
return {
|
||||
currentDateTime: formatCurrentDateTime(new Date()),
|
||||
...(isCopilotLikeAgent(agentName)
|
||||
? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatUserMessageContextForLlm(userMessageContext: z.infer<typeof UserMessageContext>): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (userMessageContext.currentDateTime) {
|
||||
sections.push(`Current date and time: ${userMessageContext.currentDateTime}`);
|
||||
}
|
||||
|
||||
if (userMessageContext.middlePane) {
|
||||
if (userMessageContext.middlePane.kind === 'empty') {
|
||||
sections.push(`Middle pane:\nState: empty`);
|
||||
} else if (userMessageContext.middlePane.kind === 'note') {
|
||||
sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``);
|
||||
} else {
|
||||
sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `# User Context
|
||||
${sections.join('\n\n')}
|
||||
|
||||
# User Message
|
||||
`;
|
||||
}
|
||||
|
||||
const USER_CONTEXT_SYSTEM_INSTRUCTIONS = `# Hidden User Context
|
||||
User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message".
|
||||
|
||||
Use "Current date and time" for temporal reasoning.
|
||||
|
||||
If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current.
|
||||
|
||||
If Middle pane state is empty, the user was not looking at any relevant note or web page at that point. Answer the user's message on its own merits.
|
||||
|
||||
If Middle pane state is note, the supplied path and content are available so you can reference the note when relevant. The user may or may not be talking about this note. Do NOT assume every message is about it. Only reference or act on this note when the user's message clearly relates to it, such as "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly the note's content. For unrelated questions, ignore this note entirely and answer normally. Do not mention that you can see this note unless it is relevant to the answer.
|
||||
|
||||
If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`;
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -392,9 +482,10 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T
|
|||
case "builtin": {
|
||||
if (t.name === "ask-human") {
|
||||
return tool({
|
||||
description: "Ask a human before proceeding",
|
||||
description: "Ask a human before proceeding. Optionally pass `options` (an array of short button labels) to render the question as a one-click choice; the user's response will be the chosen label verbatim.",
|
||||
inputSchema: z.object({
|
||||
question: z.string().describe("The question to ask the human"),
|
||||
options: z.array(z.string()).optional().describe("Optional short button labels (2-4 recommended). If provided, the user picks one with a single click instead of typing. The response you receive will be the chosen label."),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -721,17 +812,18 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
providerOptions,
|
||||
});
|
||||
break;
|
||||
case "user":
|
||||
case "user": {
|
||||
const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
content: `${userMessageContextPrefix}${msg.content}`,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse to text for LLM
|
||||
const textSegments: string[] = [];
|
||||
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
|
||||
const attachmentLines: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
|
|
@ -745,7 +837,11 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
if (userMessageContextPrefix) {
|
||||
textSegments.push("User has attached the following files:", ...attachmentLines, "");
|
||||
} else {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
|
|
@ -755,6 +851,7 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
|
|
@ -1065,6 +1162,7 @@ export async function* streamAgent({
|
|||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
let codeMode: 'claude' | 'codex' | null = null;
|
||||
let middlePaneContext:
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
|
|
@ -1213,6 +1311,9 @@ export async function* streamAgent({
|
|||
if (msg.searchEnabled) {
|
||||
searchEnabled = true;
|
||||
}
|
||||
// Code mode is per-message: latest message decides whether the assistant
|
||||
// should route coding work through the code-with-agents skill / chosen agent.
|
||||
codeMode = msg.codeMode ?? null;
|
||||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
|
|
@ -1220,6 +1321,10 @@ export async function* streamAgent({
|
|||
// latest user message. If the user closed the pane between messages, clear it.
|
||||
middlePaneContext = msg.middlePaneContext ?? null;
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
const userMessageContext = buildUserMessageContext({
|
||||
agentName: state.agentName,
|
||||
middlePaneContext,
|
||||
});
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "message",
|
||||
|
|
@ -1227,6 +1332,7 @@ export async function* streamAgent({
|
|||
message: {
|
||||
role: "user",
|
||||
content: msg.message,
|
||||
userMessageContext,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
|
|
@ -1248,17 +1354,7 @@ export async function* streamAgent({
|
|||
loopLogger.log('running llm turn');
|
||||
// stream agent response and build message
|
||||
const messageBuilder = new StreamStepMessageBuilder();
|
||||
const now = new Date();
|
||||
const currentDateTime = now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
let instructionsWithDateTime = `Current date and time: ${currentDateTime}\n\n${agent.instructions}`;
|
||||
let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
|
||||
// Inject Agent Notes context for copilot
|
||||
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
||||
const agentNotesContext = loadAgentNotesContext();
|
||||
|
|
@ -1287,19 +1383,6 @@ Use absolute paths rooted at this directory with the \`file-*\` tools. For examp
|
|||
|
||||
Do not announce the work directory unless it's relevant. Just use it.`;
|
||||
}
|
||||
// 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');
|
||||
|
|
@ -1316,6 +1399,50 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
|||
loopLogger.log('search enabled, injecting search prompt');
|
||||
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
|
||||
}
|
||||
if (codeMode) {
|
||||
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
|
||||
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
|
||||
const otherAgent = codeMode === 'claude' ? 'codex' : 'claude';
|
||||
const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code';
|
||||
// Deterministic, per-chat session name so the coding agent keeps
|
||||
// context across the user's requests within this chat. Reusing the
|
||||
// same -s <name> resumes the session; the first call creates it.
|
||||
const sessionName = `rowboat-${runId}`;
|
||||
instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay}
|
||||
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn.
|
||||
|
||||
**The user can override the agent at any time, two ways:**
|
||||
1. By toggling the chip in the composer (preferred).
|
||||
2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that — use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness.
|
||||
|
||||
**Persistent session for this chat — session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create).
|
||||
|
||||
**1. First coding action in this chat — ensure the session exists:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName}
|
||||
\`\`\`
|
||||
|
||||
(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.)
|
||||
|
||||
**2. Then run the prompt:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch.
|
||||
|
||||
Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets).
|
||||
|
||||
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
|
||||
}
|
||||
let streamError: string | null = null;
|
||||
for await (const event of streamLlm(
|
||||
model,
|
||||
|
|
@ -1371,11 +1498,16 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
|||
const underlyingTool = agent.tools![part.toolName];
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
|
||||
const rawOptions = (part.arguments as { options?: unknown }).options;
|
||||
const options = Array.isArray(rawOptions)
|
||||
? rawOptions.filter((o): o is string => typeof o === 'string' && o.trim().length > 0)
|
||||
: undefined;
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "ask-human-request",
|
||||
toolCallId: part.toolCallId,
|
||||
query: part.arguments.question,
|
||||
...(options && options.length > 0 ? { options } : {}),
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { API_URL } from '../config/env.js';
|
|||
// In dev/tsc, fall back to process.env so local runs work too.
|
||||
const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? '';
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com';
|
||||
const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim();
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initAttempted = false;
|
||||
|
|
@ -29,7 +30,7 @@ function getClient(): PostHog | null {
|
|||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||
client.identify({
|
||||
distinctId: getInstallationId(),
|
||||
properties: { api_url: API_URL },
|
||||
properties: { api_url: API_URL, ...appVersionProperties() },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to init PostHog:', err);
|
||||
|
|
@ -42,6 +43,10 @@ function activeDistinctId(): string {
|
|||
return identifiedUserId ?? getInstallationId();
|
||||
}
|
||||
|
||||
function appVersionProperties(): Record<string, string> {
|
||||
return APP_VERSION ? { app_version: APP_VERSION } : {};
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
const ph = getClient();
|
||||
if (!ph) return;
|
||||
|
|
@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
|
|||
ph.capture({
|
||||
distinctId: activeDistinctId(),
|
||||
event,
|
||||
properties,
|
||||
properties: {
|
||||
...properties,
|
||||
...appVersionProperties(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Analytics] capture failed:', err);
|
||||
|
|
@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record<string, unknown>):
|
|||
properties: {
|
||||
...properties,
|
||||
api_url: API_URL,
|
||||
...appVersionProperties(),
|
||||
},
|
||||
});
|
||||
identifiedUserId = userId;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
|
|||
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 { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
|
|
@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
|||
`;
|
||||
}
|
||||
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): 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.`
|
||||
|
|
@ -80,7 +82,9 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
|||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
|
||||
|
||||
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
|
||||
${codeModeEnabled
|
||||
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
|
||||
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
|||
/** Keep backward-compatible export for any external consumers */
|
||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||
|
||||
/**
|
||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
let cachedInstructions: string | null = null;
|
||||
|
||||
/**
|
||||
* Invalidate the cached instructions so the next buildCopilotInstructions() call
|
||||
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
|
||||
*/
|
||||
export function invalidateCopilotInstructionsCache(): void {
|
||||
cachedInstructions = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full copilot instructions with dynamic Composio tools section.
|
||||
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
|
||||
*/
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
const catalog = composioEnabled
|
||||
? skillCatalog
|
||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
||||
let codeModeEnabled = false;
|
||||
try {
|
||||
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||
codeModeEnabled = (await repo.getConfig()).enabled;
|
||||
} catch {
|
||||
// repo unavailable — default to disabled
|
||||
}
|
||||
const excludeIds: string[] = [];
|
||||
if (!composioEnabled) excludeIds.push('composio-integration');
|
||||
if (!codeModeEnabled) excludeIds.push('code-with-agents');
|
||||
const catalog = excludeIds.length > 0
|
||||
? buildSkillCatalog({ excludeIds })
|
||||
: skillCatalog;
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
|
|
|
|||
|
|
@ -1,90 +1,140 @@
|
|||
export const skill = String.raw`
|
||||
# Code with Agents Skill
|
||||
|
||||
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
|
||||
Use this skill whenever the user asks you to write code, build a project, create scripts, fix bugs, read/explain code, or do any software development task — even simple file creations like "make a .c file".
|
||||
|
||||
## Important: delegate ALL coding work
|
||||
Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself.
|
||||
|
||||
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
|
||||
- Writing, editing, or refactoring code
|
||||
- Reading, summarizing, or explaining code
|
||||
- Debugging and fixing bugs
|
||||
- Running tests or build commands
|
||||
- Exploring project structure
|
||||
- Any other task that involves interacting with a codebase
|
||||
---
|
||||
|
||||
Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
|
||||
## STEP 1 — MANDATORY FIRST ACTION
|
||||
|
||||
## Prerequisites
|
||||
Look in your **system context** for a section titled **"# Code Mode (Active)"**.
|
||||
|
||||
The user must have one of the following installed on their machine:
|
||||
- **Claude Code** — https://claude.ai/code
|
||||
- **Codex** — https://codex.openai.com
|
||||
### Case A — "# Code Mode (Active)" IS present
|
||||
|
||||
These are external tools that you cannot install for the user.
|
||||
Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human.
|
||||
|
||||
## Workflow
|
||||
### Case B — "# Code Mode (Active)" is NOT present
|
||||
|
||||
### Step 1: Gather requirements
|
||||
Your **very next tool call MUST be \`ask-human\`** with options. Do not write any explanation text first. Do not describe a plan. Do not check the workspace boundary. Just call:
|
||||
|
||||
Before running anything, confirm the following with the user:
|
||||
\`\`\`
|
||||
ask-human({
|
||||
question: "How should I handle this coding request?",
|
||||
options: [
|
||||
"Use code mode (Claude Code)",
|
||||
"Use code mode (Codex)",
|
||||
"Continue with default Rowboat"
|
||||
]
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
|
||||
2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
|
||||
This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here.
|
||||
|
||||
### Step 2: Confirm execution plan
|
||||
**Branch on the response:**
|
||||
- "Use code mode (Claude Code)" → proceed to Step 2 with agent = \`claude\`.
|
||||
- "Use code mode (Codex)" → proceed to Step 2 with agent = \`codex\`.
|
||||
- "Continue with default Rowboat" → ABANDON this skill. Handle the request yourself using your own tools (workspace file tools, \`executeCommand\` shell, etc.). The rest of this skill does not apply for this turn.
|
||||
|
||||
Once you know the folder and agent, tell the user:
|
||||
---
|
||||
|
||||
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
|
||||
## STEP 2 — Resolve workdir, confirm, execute
|
||||
|
||||
### Step 3: Execute with acpx
|
||||
**Resolve the workdir** (in this priority order):
|
||||
1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`).
|
||||
2. The path from a "# User Work Directory" block in your context.
|
||||
3. Ask once in plain text: "Which folder should I work in?"
|
||||
|
||||
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
|
||||
**State your intent in one line, then execute immediately — do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
|
||||
|
||||
**For Claude Code:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
|
||||
` + "`" + `
|
||||
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
|
||||
|
||||
**For Codex:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
|
||||
` + "`" + `
|
||||
…and then immediately make the \`executeCommand\` call in the same turn.
|
||||
|
||||
**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context.
|
||||
|
||||
Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order:
|
||||
- An explicit in-chat override from the user this turn ("use codex", "switch to claude") — honor it.
|
||||
- The agent chosen in Step 1 / the "# Code Mode (Active)" block.
|
||||
|
||||
Pick \`<session-name>\` — **stable for this whole chat**:
|
||||
- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name.
|
||||
- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** — never a new name each time.
|
||||
|
||||
**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt:
|
||||
|
||||
**1. First coding action in this chat — ensure the session exists:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>
|
||||
\`\`\`
|
||||
|
||||
(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.)
|
||||
|
||||
**2. Then run the prompt:**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**3. Every follow-up coding request in this chat — reuse the same session (do NOT create again):**
|
||||
|
||||
\`\`\`
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
|
||||
\`\`\`
|
||||
|
||||
**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins.
|
||||
|
||||
Do NOT use \`exec\` — it is one-shot and forgets everything.
|
||||
|
||||
Concrete example:
|
||||
|
||||
\`\`\`
|
||||
# First coding message in the chat — ensure the session, then prompt:
|
||||
npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space."
|
||||
|
||||
# Follow-up in the same chat — reuse the session, no create:
|
||||
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings."
|
||||
\`\`\`
|
||||
|
||||
### Critical: flag order
|
||||
|
||||
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
|
||||
\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name:
|
||||
|
||||
` + "`" + `
|
||||
npx acpx@latest [global flags] <agent> exec "<prompt>"
|
||||
` + "`" + `
|
||||
- ✓ Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\`
|
||||
- ✗ Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail)
|
||||
|
||||
**Correct:**
|
||||
` + "`" + `
|
||||
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
|
||||
` + "`" + `
|
||||
### Writing good prompts for the agent
|
||||
|
||||
**Wrong (will fail):**
|
||||
` + "`" + `
|
||||
npx acpx@latest claude --approve-all exec "fix the bug"
|
||||
` + "`" + `
|
||||
- Be specific: file names, function signatures, expected behavior.
|
||||
- Mention constraints (language, framework, style).
|
||||
- Expand short user requests into clear, actionable prompts.
|
||||
|
||||
### Writing good prompts
|
||||
---
|
||||
|
||||
When constructing the prompt for the coding agent:
|
||||
- Be specific and detailed about what to build or fix
|
||||
- Include file names, function signatures, and expected behavior
|
||||
- Mention any constraints (language, framework, style)
|
||||
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
|
||||
## STEP 3 — Report results
|
||||
|
||||
### Step 4: Report results
|
||||
After the command finishes:
|
||||
- Pass through the coding agent's summary as-is. Do not rewrite.
|
||||
- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.)
|
||||
- Only add your own explanation if the command failed (non-zero exit):
|
||||
- Exit code 5 — permissions were denied (shouldn't happen with \`--approve-all\`; flag it).
|
||||
- Exit code 4 / "No acpx session found" — the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.)
|
||||
- "command not found" / agent not installed, or an auth/sign-in error — the agent isn't set up. Tell the user to install or sign in to the agent via **Settings → Code Mode**, where Rowboat shows the install and sign-in status.
|
||||
|
||||
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
|
||||
---
|
||||
|
||||
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
|
||||
## Once delegating: delegate fully
|
||||
|
||||
- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
|
||||
After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent — writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
|
||||
|
||||
## Prerequisites (informational)
|
||||
|
||||
The user must have one of these installed locally — these are external tools you cannot install:
|
||||
- Claude Code — https://claude.ai/code
|
||||
- Codex — https://codex.openai.com
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -95,21 +95,47 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
|||
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
|
||||
// from the npm-shim layout and inject it via env so the bridge can spawn it.
|
||||
function resolveClaudeExeOnWindows(): string | undefined {
|
||||
const pathDirs = (process.env.PATH ?? '').split(';');
|
||||
for (const dir of pathDirs) {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) continue;
|
||||
const cmdPath = path.join(trimmed, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
|
||||
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
|
||||
// includes them, which would otherwise leave us unable to find claude.exe and
|
||||
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
|
||||
const home = process.env.USERPROFILE ?? '';
|
||||
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
|
||||
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
const knownDirs = [
|
||||
appData && path.join(appData, 'npm'),
|
||||
localAppData && path.join(localAppData, 'npm'),
|
||||
appData && path.join(appData, 'pnpm'),
|
||||
localAppData && path.join(localAppData, 'pnpm'),
|
||||
home && path.join(home, '.volta', 'bin'),
|
||||
path.join(programFiles, 'nodejs'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
|
||||
const key = d.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const dir of candidates) {
|
||||
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
|
||||
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
||||
if (existsSync(exeFromLayout)) return exeFromLayout;
|
||||
|
||||
// Otherwise parse the claude.cmd shim for the real exe path.
|
||||
const cmdPath = path.join(dir, 'claude.cmd');
|
||||
if (!existsSync(cmdPath)) continue;
|
||||
try {
|
||||
const content = readFileSync(cmdPath, 'utf-8');
|
||||
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
|
||||
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
|
||||
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
|
||||
if (relMatch) {
|
||||
const resolved = path.join(trimmed, relMatch[1]);
|
||||
const resolved = path.join(dir, relMatch[1]);
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -825,7 +851,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
|
||||
// Fallback to original for backward compatibility
|
||||
const result = await executeCommand(command, { cwd: workingDir });
|
||||
const result = await executeCommand(command, { cwd: workingDir, env: envOverride });
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export async function executeCommand(
|
|||
cwd?: string;
|
||||
timeout?: number; // timeout in milliseconds
|
||||
maxBuffer?: number; // max buffer size in bytes
|
||||
env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx)
|
||||
}
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
|
|
@ -89,6 +90,7 @@ export async function executeCommand(
|
|||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell,
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,17 +8,20 @@ export type MiddlePaneContext =
|
|||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string };
|
||||
|
||||
export type CodeMode = 'claude' | 'codex';
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: UserMessageContentType;
|
||||
voiceInput?: boolean;
|
||||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
codeMode?: CodeMode;
|
||||
middlePaneContext?: MiddlePaneContext;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +37,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
voiceInput,
|
||||
voiceOutput,
|
||||
searchEnabled,
|
||||
codeMode,
|
||||
middlePaneContext,
|
||||
});
|
||||
return id;
|
||||
|
|
|
|||
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
|
||||
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
|
||||
export { checkCodeModeAgentStatus } from './status.js';
|
||||
47
apps/x/packages/core/src/code-mode/repo.ts
Normal file
47
apps/x/packages/core/src/code-mode/repo.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { CodeModeConfig } from './types.js';
|
||||
import { checkCodeModeAgentStatus } from './status.js';
|
||||
|
||||
export interface ICodeModeConfigRepo {
|
||||
getConfig(): Promise<CodeModeConfig>;
|
||||
setConfig(config: CodeModeConfig): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSCodeModeConfigRepo implements ICodeModeConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json');
|
||||
private agentReadyPromise: Promise<boolean> | null = null;
|
||||
|
||||
// Reuse the existing agent check (Claude Code / Codex installed + signed in),
|
||||
// cached for the process lifetime so we probe (shell + keychain) at most once
|
||||
// per session rather than on every getConfig call.
|
||||
private agentReady(): Promise<boolean> {
|
||||
if (!this.agentReadyPromise) {
|
||||
this.agentReadyPromise = checkCodeModeAgentStatus()
|
||||
.then((s) =>
|
||||
(s.claude.installed && s.claude.signedIn)
|
||||
|| (s.codex.installed && s.codex.signedIn))
|
||||
.catch(() => false);
|
||||
}
|
||||
return this.agentReadyPromise;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<CodeModeConfig> {
|
||||
try {
|
||||
// The file only exists once the user has explicitly toggled code mode
|
||||
// in settings — always honor that choice.
|
||||
const content = await fs.readFile(this.configPath, 'utf8');
|
||||
return CodeModeConfig.parse(JSON.parse(content));
|
||||
} catch {
|
||||
// No explicit choice yet: enable automatically when a coding agent is ready.
|
||||
return { enabled: await this.agentReady() };
|
||||
}
|
||||
}
|
||||
|
||||
async setConfig(config: CodeModeConfig): Promise<void> {
|
||||
const validated = CodeModeConfig.parse(config);
|
||||
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
||||
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
}
|
||||
199
apps/x/packages/core/src/code-mode/status.ts
Normal file
199
apps/x/packages/core/src/code-mode/status.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { CodeModeAgentStatus } from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn.
|
||||
// We scan these directly because Electron's spawned shell sometimes doesn't
|
||||
// inherit the user's full PATH (especially on macOS GUI launches, and even on
|
||||
// Windows when global npm prefix isn't propagated to system PATH).
|
||||
function commonInstallPaths(binary: string): string[] {
|
||||
const home = os.homedir();
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
||||
return [
|
||||
path.join(appData, 'npm', `${binary}.cmd`),
|
||||
path.join(appData, 'npm', `${binary}.exe`),
|
||||
path.join(localAppData, 'npm', `${binary}.cmd`),
|
||||
path.join(localAppData, 'pnpm', `${binary}.cmd`),
|
||||
path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`),
|
||||
path.join(programFiles, 'nodejs', `${binary}.cmd`),
|
||||
path.join(home, '.volta', 'bin', `${binary}.cmd`),
|
||||
];
|
||||
}
|
||||
return [
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin', // Apple Silicon Homebrew
|
||||
'/usr/bin',
|
||||
path.join(home, '.npm-global', 'bin'),
|
||||
path.join(home, '.local', 'bin'),
|
||||
path.join(home, '.volta', 'bin'),
|
||||
path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
|
||||
path.join(home, 'bin'),
|
||||
].map(dir => path.join(dir, binary));
|
||||
}
|
||||
|
||||
async function probeShell(binary: string): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
|
||||
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
|
||||
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isInstalled(binary: string): Promise<boolean> {
|
||||
if (await probeShell(binary)) return true;
|
||||
// Fallback: scan well-known install locations directly.
|
||||
for (const candidate of commonInstallPaths(binary)) {
|
||||
if (existsSync(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
|
||||
const parsed = JSON.parse(json);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Given the raw credentials JSON (from a file or the macOS Keychain), decide
|
||||
// whether it represents a usable signed-in state: a valid API key, an unexpired
|
||||
// access token, or a refresh token (which can mint a new access token).
|
||||
function isClaudeCredentialSignedIn(raw: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (oauth) {
|
||||
const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
|
||||
const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : '';
|
||||
if (refresh.length > 0) return true;
|
||||
if (access.length > 0) {
|
||||
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true;
|
||||
if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true;
|
||||
} catch {
|
||||
// malformed JSON
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reads Claude Code's credentials from the macOS login Keychain, where the
|
||||
// CLI stores them on macOS (service "Claude Code-credentials"). On Linux/Windows
|
||||
// it uses the ~/.claude/.credentials.json file instead, so this is a no-op there.
|
||||
//
|
||||
// Caveats:
|
||||
// - The first read by this app (a different binary than the `claude` CLI that
|
||||
// created the item) triggers a one-time macOS authorization dialog; the user
|
||||
// must "Always Allow". Headless/SSH sessions can't show it and will fail.
|
||||
// - If CLAUDE_CONFIG_DIR is set, Claude appends a SHA-256 suffix to the service
|
||||
// name, which this lookup won't match — such setups usually keep the file too.
|
||||
async function readClaudeKeychainCredential(): Promise<string | null> {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`security find-generic-password -s "Claude Code-credentials" -w`,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const out = stdout.trim();
|
||||
return out.length > 0 ? out : null;
|
||||
} catch {
|
||||
// not present in keychain
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validates Claude Code auth. On macOS the credentials live in the login
|
||||
// Keychain; on Linux/Windows in ~/.claude/.credentials.json (or ~/.config
|
||||
// fallback). We check both so detection works across platforms.
|
||||
async function checkClaudeSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const candidates = [
|
||||
path.join(home, '.claude', '.credentials.json'),
|
||||
path.join(home, '.config', 'claude', '.credentials.json'),
|
||||
];
|
||||
for (const full of candidates) {
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
if (isClaudeCredentialSignedIn(raw)) return true;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: credentials are stored in the Keychain rather than on disk.
|
||||
const keychainRaw = await readClaudeKeychainCredential();
|
||||
if (keychainRaw && isClaudeCredentialSignedIn(keychainRaw)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validates Codex auth at ~/.codex/auth.json on all platforms.
|
||||
// Considered signed in if API key set, or a refresh_token / access_token
|
||||
// exists. id_token expiry is intentionally NOT used as a rejection signal —
|
||||
// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks.
|
||||
async function checkCodexSignedIn(): Promise<boolean> {
|
||||
const home = os.homedir();
|
||||
const full = path.join(home, '.codex', 'auth.json');
|
||||
try {
|
||||
const raw = await fs.readFile(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
|
||||
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
if (tokens) {
|
||||
const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '';
|
||||
const access = typeof tokens.access_token === 'string' ? tokens.access_token : '';
|
||||
const id = typeof tokens.id_token === 'string' ? tokens.id_token : '';
|
||||
if (refresh.length > 0 || access.length > 0 || id.length > 0) return true;
|
||||
}
|
||||
} catch {
|
||||
// file missing or unreadable
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exported for diagnostics — silenced unused-var warning by re-export only.
|
||||
export { decodeJwtPayload };
|
||||
|
||||
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
|
||||
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
|
||||
isInstalled('claude'),
|
||||
isInstalled('codex'),
|
||||
checkClaudeSignedIn(),
|
||||
checkCodexSignedIn(),
|
||||
]);
|
||||
return {
|
||||
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
|
||||
codex: { installed: codexInstalled, signedIn: codexSignedIn },
|
||||
};
|
||||
}
|
||||
18
apps/x/packages/core/src/code-mode/types.ts
Normal file
18
apps/x/packages/core/src/code-mode/types.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import z from "zod";
|
||||
|
||||
export const CodeModeConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;
|
||||
|
||||
export const AgentStatus = z.object({
|
||||
installed: z.boolean(),
|
||||
signedIn: z.boolean(),
|
||||
});
|
||||
export type AgentStatus = z.infer<typeof AgentStatus>;
|
||||
|
||||
export const CodeModeAgentStatus = z.object({
|
||||
claude: AgentStatus,
|
||||
codex: AgentStatus,
|
||||
});
|
||||
export type CodeModeAgentStatus = z.infer<typeof CodeModeAgentStatus>;
|
||||
|
|
@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
|||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
|
||||
import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||
|
|
@ -38,6 +39,7 @@ container.register({
|
|||
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
||||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
|
||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||
|
|
|
|||
42
apps/x/packages/core/src/knowledge/sync_gmail.test.ts
Normal file
42
apps/x/packages/core/src/knowledge/sync_gmail.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sanitizeReplyBodyForGmailReply,
|
||||
stripGmailQuotedReplyHtml,
|
||||
stripGmailQuotedReplyText,
|
||||
} from './sync_gmail.js';
|
||||
|
||||
describe('Gmail reply body sanitization', () => {
|
||||
it('strips Gmail quote attribution and older quoted text from plain text replies', () => {
|
||||
const body = [
|
||||
'Sounds good, thanks. I will send it over today.',
|
||||
'',
|
||||
'On Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:',
|
||||
'> Can you share the final file?',
|
||||
'> Thanks',
|
||||
].join('\n');
|
||||
|
||||
expect(stripGmailQuotedReplyText(body)).toBe('Sounds good, thanks. I will send it over today.');
|
||||
});
|
||||
|
||||
it('strips Gmail quote blocks from html replies', () => {
|
||||
const html = [
|
||||
'<p>Sounds good, thanks.</p>',
|
||||
'<div class="gmail_quote">',
|
||||
'<div dir="ltr" class="gmail_attr">On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:<br></div>',
|
||||
'<blockquote>Older thread text</blockquote>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
expect(stripGmailQuotedReplyHtml(html)).toBe('<p>Sounds good, thanks.</p>');
|
||||
});
|
||||
|
||||
it('regenerates html from clean text if only the text boundary is detected', () => {
|
||||
const result = sanitizeReplyBodyForGmailReply(
|
||||
'<p>Sounds good, thanks.</p><p>Older thread text</p>',
|
||||
'Sounds good, thanks.\n\nOn Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:\nOlder thread text',
|
||||
);
|
||||
|
||||
expect(result.bodyText).toBe('Sounds good, thanks.');
|
||||
expect(result.bodyHtml).toBe('<p>Sounds good, thanks.</p>');
|
||||
});
|
||||
});
|
||||
|
|
@ -35,7 +35,7 @@ const nhm = new NodeHtmlMarkdown();
|
|||
// previously cached snapshots (e.g. attachment / recipient parsing fixes). The
|
||||
// short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches,
|
||||
// so stale entries are transparently rebuilt on the next sync.
|
||||
const SNAPSHOT_PARSER_VERSION = 2;
|
||||
const SNAPSHOT_PARSER_VERSION = 3;
|
||||
|
||||
interface SnapshotCacheEntry {
|
||||
historyId: string;
|
||||
|
|
@ -405,6 +405,112 @@ function normalizeBody(body: string): string {
|
|||
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function isGmailQuoteAttribution(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return /^On\b.+\bwrote:\s*$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function isOriginalMessageBoundary(line: string): boolean {
|
||||
return /^-{2,}\s*Original Message\s*-{2,}$/i.test(line.trim());
|
||||
}
|
||||
|
||||
function isForwardedMessageBoundary(line: string): boolean {
|
||||
return /^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim());
|
||||
}
|
||||
|
||||
function isOutlookHeaderBoundary(lines: string[], index: number): boolean {
|
||||
if (!/^From:\s+\S/i.test(lines[index]?.trim() || '')) return false;
|
||||
const next = lines.slice(index + 1, index + 6).map((line) => line.trim());
|
||||
return next.some((line) => /^(Sent|Date):\s+\S/i.test(line))
|
||||
&& next.some((line) => /^To:\s+\S/i.test(line))
|
||||
&& next.some((line) => /^Subject:\s+\S/i.test(line));
|
||||
}
|
||||
|
||||
function findQuotedReplyBoundary(lines: string[]): number {
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] || '';
|
||||
if (
|
||||
isGmailQuoteAttribution(line)
|
||||
|| isOriginalMessageBoundary(line)
|
||||
|| isForwardedMessageBoundary(line)
|
||||
|| isOutlookHeaderBoundary(lines, i)
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
|
||||
// Gmail plain text drafts often carry older messages as a quoted block.
|
||||
// Treat a trailing blockquote as history, but avoid stripping an inline
|
||||
// quote the user is actively writing at the top of the reply.
|
||||
if (i > 0 && line.trim().startsWith('>') && (lines[i - 1]?.trim() === '' || lines[i - 1]?.trim().startsWith('>'))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function stripGmailQuotedReplyText(text: string): string {
|
||||
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
const boundary = findQuotedReplyBoundary(lines);
|
||||
const visible = boundary >= 0 ? lines.slice(0, boundary) : lines;
|
||||
return visible
|
||||
.join('\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function htmlQuoteBoundaryIndex(html: string): number {
|
||||
const candidates: number[] = [];
|
||||
const patterns = [
|
||||
/<[^>]+\bclass\s*=\s*["'][^"']*\bgmail_(?:quote|attr)\b[^"']*["'][^>]*>/i,
|
||||
/<blockquote\b[^>]*(?:type\s*=\s*["']cite["']|class\s*=\s*["'][^"']*\bgmail_quote\b[^"']*["'])[^>]*>/i,
|
||||
/<(p|div|li)\b[^>]*>\s*(?:<(?:span|b|strong|i|em)\b[^>]*>\s*)*On\b[\s\S]{0,800}?\bwrote:\s*(?:<br\s*\/?>\s*)?(?:<\/(?:span|b|strong|i|em)>\s*)*<\/\1>/i,
|
||||
/<(p|div|li)\b[^>]*>\s*-{2,}\s*(?:Original Message|Forwarded message)\s*-{2,}\s*<\/\1>/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(html);
|
||||
if (match?.index !== undefined) candidates.push(match.index);
|
||||
}
|
||||
|
||||
return candidates.length > 0 ? Math.min(...candidates) : -1;
|
||||
}
|
||||
|
||||
export function stripGmailQuotedReplyHtml(html: string): string {
|
||||
const boundary = htmlQuoteBoundaryIndex(html);
|
||||
const visible = boundary >= 0 ? html.slice(0, boundary) : html;
|
||||
return visible.trim();
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function sanitizeReplyBodyForGmailReply(bodyHtml: string, bodyText: string): { bodyHtml: string; bodyText: string } {
|
||||
const cleanText = stripGmailQuotedReplyText(bodyText);
|
||||
const cleanHtml = stripGmailQuotedReplyHtml(bodyHtml);
|
||||
const textWasStripped = cleanText !== bodyText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||
const htmlWasStripped = cleanHtml !== bodyHtml.trim();
|
||||
|
||||
return {
|
||||
bodyText: cleanText,
|
||||
bodyHtml: textWasStripped && !htmlWasStripped ? textToHtml(cleanText) : cleanHtml,
|
||||
};
|
||||
}
|
||||
|
||||
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
|
||||
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
|
||||
}
|
||||
|
|
@ -636,9 +742,13 @@ async function buildAndCacheSnapshot(
|
|||
|
||||
const sentMessages = parsed.filter((m) => !m.isDraft);
|
||||
const draftMessages = parsed.filter((m) => m.isDraft);
|
||||
const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest);
|
||||
const visibleMessages = sentMessages.map((msg) => {
|
||||
const rest: Partial<typeof msg> = { ...msg };
|
||||
delete rest.isDraft;
|
||||
return rest as Omit<typeof msg, 'isDraft'>;
|
||||
});
|
||||
const latestDraftBody = draftMessages.length > 0
|
||||
? draftMessages[draftMessages.length - 1]!.body.trim()
|
||||
? stripGmailQuotedReplyText(draftMessages[draftMessages.length - 1]!.body)
|
||||
: '';
|
||||
|
||||
if (visibleMessages.length === 0) return null;
|
||||
|
|
@ -674,7 +784,10 @@ async function buildAndCacheSnapshot(
|
|||
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
|
||||
snapshot.importance = classification.importance;
|
||||
if (classification.summary) snapshot.summary = classification.summary;
|
||||
if (classification.draftResponse) snapshot.draft_response = classification.draftResponse;
|
||||
if (classification.draftResponse) {
|
||||
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
|
||||
if (draftResponse) snapshot.draft_response = draftResponse;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
|
||||
}
|
||||
|
|
@ -947,16 +1060,20 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
|||
// If the state file holds a last_sync timestamp (e.g. left over from a
|
||||
// prior Composio sync, or from a previous successful native sync that
|
||||
// we're falling back to after a history.list 404), use that as the
|
||||
// floor instead of the default lookback. Carries forward Composio's
|
||||
// last_sync on first migration so we don't refetch the last 7 days.
|
||||
// floor — but never reach back further than lookbackDays. This caps the
|
||||
// window at "1 week at most": if last_sync is within the lookback window
|
||||
// we resume from it (a smaller window), otherwise we clamp to lookbackDays
|
||||
// ago. Mail older than the cap that arrived during a long offline gap is
|
||||
// intentionally skipped rather than backfilled.
|
||||
const state = loadState(stateFile);
|
||||
const lookbackFloor = new Date();
|
||||
lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays);
|
||||
let pastDate: Date;
|
||||
if (state.last_sync) {
|
||||
if (state.last_sync && new Date(state.last_sync) > lookbackFloor) {
|
||||
pastDate = new Date(state.last_sync);
|
||||
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
|
||||
} else {
|
||||
pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
||||
pastDate = lookbackFloor;
|
||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
||||
}
|
||||
|
||||
|
|
@ -1222,12 +1339,22 @@ async function performSync() {
|
|||
// this runs once, the cache directory is populated and we fall back to
|
||||
// partial-sync on subsequent calls.
|
||||
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
|
||||
// partialSync replays *every* messageAdded since the stored historyId,
|
||||
// regardless of date — so after a long offline gap a still-valid
|
||||
// historyId would pull the entire gap (e.g. 3 weeks). To honor the
|
||||
// "1 week at most" cap, bypass it when last_sync is older than the
|
||||
// lookback window and run a (date-clamped) fullSync instead.
|
||||
const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0;
|
||||
const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (!state.historyId) {
|
||||
console.log("No history ID found, starting full sync...");
|
||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
} else if (cacheMissing) {
|
||||
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
|
||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
} else if (gapTooLarge) {
|
||||
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`);
|
||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
} else {
|
||||
console.log("History ID found, starting partial sync...");
|
||||
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||
|
|
@ -1330,6 +1457,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
|||
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
|
||||
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
|
||||
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
|
||||
const replyBody = opts.threadId
|
||||
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
|
||||
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
|
||||
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
|
||||
|
||||
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const headers: string[] = [];
|
||||
|
|
@ -1348,13 +1479,13 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
|||
parts.push('Content-Type: text/plain; charset="UTF-8"');
|
||||
parts.push('Content-Transfer-Encoding: base64');
|
||||
parts.push('');
|
||||
parts.push(encodeMimeBase64(opts.bodyText));
|
||||
parts.push(encodeMimeBase64(replyBody.bodyText));
|
||||
parts.push('');
|
||||
parts.push(`--${boundary}`);
|
||||
parts.push('Content-Type: text/html; charset="UTF-8"');
|
||||
parts.push('Content-Transfer-Encoding: base64');
|
||||
parts.push('');
|
||||
parts.push(encodeMimeBase64(opts.bodyHtml));
|
||||
parts.push(encodeMimeBase64(replyBody.bodyHtml));
|
||||
parts.push('');
|
||||
parts.push(`--${boundary}--`);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,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, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
return id;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue