Compare commits

...

16 commits

Author SHA1 Message Date
Arjun
31ec625bef assistant welcome wording change 2026-05-25 21:18:33 +05:30
Arjun
23e1a89c9e add dismiss button for middle pane 2026-05-25 21:11:55 +05:30
Arjun
a79263c5ef fix email drafts 2026-05-25 20:49:00 +05:30
Arjun
a4697fc281 dont sync working location 2026-05-25 20:05:39 +05:30
Arjun
83389e93fc fixed multiple recepients / attachments in email issue 2026-05-25 20:04:52 +05:30
arkml
c0493e8b9f
Meetings4 (#574)
* fix meetings ui with design changes
2026-05-25 19:43:29 +05:30
Ramnique Singh
31e35e00b8 Refactor builtin file tools beyond workspace scope
Replace workspace-scoped builtin file tools with general-purpose file-* tools that accept relative, absolute, and ~/ paths. Relative paths still resolve against the configured workdir.

File operations within the workdir are auto-approved. File operations outside the workdir now emit file permission metadata and require user approval, with support for once, session, and persistent grants.

Add a shared filesystem layer for text-focused read/write/edit/list/search operations, including binary-file safeguards for text reads. parseFile and LLMParse continue to read file buffers for document/image parsing.

Update copilot prompts, background/live-note agents, knowledge workflows, and renderer labels/UI to use the new file-* tool surface and permission details.

Add package-local Vitest setup for @x/core with colocated filesystem unit tests covering path resolution, canonical permission paths, binary detection, read/write/edit behavior, glob, and grep.
2026-05-25 16:21:51 +05:30
Arjun
f1d3b7b825 show calendar details on click 2026-05-25 14:20:37 +05:30
Arjun
8aa0c1763a tools row in home 2026-05-25 13:19:34 +05:30
arkml
84aa980894
Gmail send, archive and delete (#573)
* added send, archive and delete

* fix scopes

* added replyall, cc, bcc etc

* - Added scope-aware Gmail status via gmail:getConnectionStatus, so the email empty state can
    distinguish “not connected” from “connected but missing new Gmail scope.”
  - Hardened Gmail send header construction against CR/LF header injection.
  - Switched MIME parts from invalid UTF-8 7bit bodies to base64-encoded UTF-8 parts.
  - Made forward send as a new message instead of attaching it to the original thread, and included
    forwarded message content.
  - Changed archive/delete UI behavior to remove the thread only after Gmail confirms success.

---------

Co-authored-by: Ramnique Singh <30795890+ramnique@users.noreply.github.com>
2026-05-25 09:47:08 +05:30
gagan
a59c42e22b
fix: notes — in-note section links, deep-note wiki resolution, file links (#571)
* fix discord users feedback

* fix: drop duplicate Link extension and scroll headings to viewport top

* fix: collapse ../ segments in note-relative file links

---------

Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
2026-05-24 23:48:20 +05:30
Ramnique Singh
566f4553b4 Clarify billing usage labels
- rename monthly credits label to plan usage
- rename daily credits label to daily use
- clarify daily usage reset and out-of-credits copy
2026-05-24 13:21:30 +05:30
Ramnique Singh
7cd661d726 Show split monthly and daily credits
- update billing contract to consume split monthly/daily usage
- show monthly and daily credit percentage bars in account settings
- keep sidebar plan labels normalized
- update out-of-credits copy for daily credits
2026-05-24 12:48:23 +05:30
Arjun
eb4b11a530 email and calendar empty states now check only native google oauth 2026-05-23 09:25:26 +05:30
Arjun
6094ac508a fix build isues 2026-05-23 08:49:49 +05:30
Arjun
4db42d17cf fix gmail sync 2026-05-23 08:49:49 +05:30
64 changed files with 3829 additions and 1040 deletions

View file

@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t
Two paths, both producing identical on-disk YAML:
1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick.
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message:
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
The agent's system prompt tells it to:
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites.
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
4. Never modify YAML frontmatter — that's owned by the user and the runtime.
5. End with a 1-2 sentence summary stored as `lastRunSummary`.
@ -115,7 +115,7 @@ Backend (main process)
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
└─ Builtin tool │ │
run-live-note-agent ────┘ ▼
workspace-readFile / -edit
file-readText / -edit
body region(s) rewritten on disk
@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
- Then content organized by sub-topic under H2 headings, freshest/most-important first.
- Tightness over decoration.
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
- **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
---
@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd
- **Purpose**: the user message seeded into each agent run.
- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`).
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
- **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
Three branches by `trigger`:
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.

View file

@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@ -493,6 +493,24 @@ export function setupIpcHandlers() {
triggerGmailSync();
return {};
},
'gmail:sendReply': async (_event, args) => {
return sendThreadReply(args);
},
'gmail:getConnectionStatus': async () => {
return getGmailConnectionStatus();
},
'gmail:getAccountEmail': async () => {
return { email: await getAccountEmail() };
},
'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId);
},
'gmail:trashThread': async (_event, args) => {
return trashThread(args.threadId);
},
'gmail:markThreadRead': async (_event, args) => {
return markThreadRead(args.threadId);
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};

View file

@ -232,6 +232,10 @@
color: var(--gm-text-faint);
}
.gmail-row-shell {
position: relative;
}
.gmail-row {
display: grid;
grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px;
@ -249,20 +253,56 @@
transition: background 120ms ease;
}
.gmail-row-actions {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.gmail-row-shell:hover .gmail-row-actions {
opacity: 1;
pointer-events: auto;
}
.gmail-row-shell:hover .gmail-row-date {
visibility: hidden;
}
.gmail-row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-row-action:hover {
background: var(--gm-bg-pill-hover);
color: var(--gm-text-strong);
}
.gmail-row-action-danger:hover {
color: #e8453c;
}
.gmail-row:hover {
background: var(--gm-bg-row-hover);
box-shadow: none;
}
.upcoming-event-row {
background-color: transparent;
transition: background-color 120ms ease;
}
.upcoming-event-row:hover {
background-color: var(--gm-bg-pill-hover);
}
.gmail-row-selected {
background: var(--gm-bg-row-selected);
box-shadow: inset 2px 0 0 var(--gm-accent);
@ -496,6 +536,7 @@
.gmail-message-from span,
.gmail-message-to,
.gmail-message-cc,
.gmail-message-date {
color: var(--gm-text-muted);
font-size: 12px;
@ -694,6 +735,126 @@
font: inherit;
}
.gmail-compose-label {
flex: none;
min-width: 28px;
color: var(--gm-text-muted);
}
.gmail-compose-subject-input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--gm-text);
font: inherit;
}
/* Recipient (To / Cc / Bcc) rows with editable chips */
.gmail-recipient-row {
display: flex;
align-items: flex-start;
gap: 8px;
min-height: 34px;
padding: 5px 12px;
border-bottom: 1px solid var(--gm-border);
font-size: 13px;
}
.gmail-recipient-label {
flex: none;
min-width: 28px;
padding-top: 5px;
color: var(--gm-text-muted);
}
.gmail-recipient-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.gmail-recipient-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
height: 24px;
padding: 0 4px 0 10px;
border-radius: 12px;
background: var(--gm-bg-pill);
color: var(--gm-text);
font-size: 12px;
line-height: 1;
}
.gmail-recipient-chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
.gmail-recipient-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--gm-text-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.gmail-recipient-chip-remove:hover {
background: var(--gm-bg-pill-hover);
color: var(--gm-text);
}
.gmail-recipient-input {
flex: 1 1 80px;
min-width: 80px;
height: 24px;
border: none;
outline: none;
background: transparent;
color: var(--gm-text);
font: inherit;
font-size: 13px;
}
.gmail-recipient-trailing {
flex: none;
padding-top: 5px;
}
.gmail-recipient-toggles {
display: flex;
gap: 10px;
}
.gmail-recipient-toggles button {
border: none;
background: transparent;
color: var(--gm-text-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.gmail-recipient-toggles button:hover {
color: var(--gm-text);
text-decoration: underline;
}
.gmail-compose-toolbar {
display: flex;
align-items: center;

View file

@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -69,7 +69,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
@ -779,6 +779,14 @@ function App() {
const [graphError, setGraphError] = useState<string | null>(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
// Middle-pane collapse animation. Animating its max-width from 100% is janky:
// 100% is relative to the parent (far wider than the pane's real width), so the
// transition spends its first frames non-binding (nothing moves) then snaps shut.
// Instead we snapshot the pane's real px width before it collapses and drive the
// transition from that value.
const [insetCollapseFromPx, setInsetCollapseFromPx] = useState<number | null>(null)
const [insetMaxWidth, setInsetMaxWidth] = useState<string>('100%')
const [insetAnimateMaxWidth, setInsetAnimateMaxWidth] = useState(true)
// Live-note panel: bound to a single note path. Mounted as a sibling of the
// markdown editor so it shares the layout (no overlap with chat) and
// auto-closes when the active note changes.
@ -3299,7 +3307,15 @@ function App() {
const toggleRightPaneMaximize = useCallback(() => {
setIsChatSidebarOpen(true)
setIsRightPaneMaximized(prev => !prev)
setIsRightPaneMaximized(prev => {
if (!prev) {
// About to collapse the middle pane: capture its real width now, while it's
// still laid out, so the collapse can animate from a binding px value.
const px = document.querySelector('[data-slot="sidebar-inset"]')?.getBoundingClientRect().width
setInsetCollapseFromPx(px && px > 0 ? px : null)
}
return !prev
})
}, [])
const handleOpenFullScreenChat = useCallback(() => {
@ -4776,8 +4792,30 @@ function App() {
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
}, [])
const resolveWikiFilePath = useCallback((wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath) return null
const targetPath = ensureMarkdownExtension(basePath)
const targetKey = targetPath.toLowerCase()
const exactMatch = knowledgeFiles.find((filePath) => normalizeWikiPath(filePath).toLowerCase() === targetKey)
if (exactMatch) return toKnowledgePath(exactMatch)
if (!basePath.includes('/')) {
const targetBaseName = targetPath.split('/').pop()?.toLowerCase()
const basenameMatches = knowledgeFiles.filter((filePath) => {
const normalizedFile = normalizeWikiPath(filePath)
return normalizedFile.split('/').pop()?.toLowerCase() === targetBaseName
})
if (basenameMatches.length === 1) return toKnowledgePath(basenameMatches[0])
}
return toKnowledgePath(basePath)
}, [knowledgeFiles])
const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath)
const resolvedPath = resolveWikiFilePath(wikiPath)
if (!resolvedPath) return null
try {
const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath })
@ -4794,9 +4832,11 @@ function App() {
console.error('Failed to ensure wiki link target:', err)
return null
}
}, [])
}, [resolveWikiFilePath])
const openWikiLink = useCallback(async (wikiPath: string) => {
const { path: basePath } = splitWikiFragment(normalizeWikiPath(wikiPath))
if (!basePath) return
const resolvedPath = await ensureWikiFile(wikiPath)
if (resolvedPath) {
navigateToFile(resolvedPath)
@ -5091,6 +5131,27 @@ function App() {
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
// Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's
// binding immediately (no flex jump), then animate to 0. Expanding goes back to 100%
// — its non-binding range lands at the end of the range, where it isn't visible.
useLayoutEffect(() => {
if (!shouldCollapseLeftPane) {
setInsetAnimateMaxWidth(true)
setInsetMaxWidth('100%')
return
}
if (insetCollapseFromPx == null) {
setInsetMaxWidth('0px')
return
}
setInsetAnimateMaxWidth(false)
setInsetMaxWidth(`${insetCollapseFromPx}px`)
const id = requestAnimationFrame(() => {
setInsetAnimateMaxWidth(true)
setInsetMaxWidth('0px')
})
return () => cancelAnimationFrame(id)
}, [shouldCollapseLeftPane, insetCollapseFromPx])
const openMarkdownTabs = React.useMemo(() => {
const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md'))
if (selectedPath?.endsWith('.md')) {
@ -5146,10 +5207,11 @@ function App() {
/>
<SidebarInset
className={cn(
"overflow-hidden! min-h-0 min-w-0 transition-[max-width] duration-200 ease-linear",
"overflow-hidden! min-h-0 min-w-0",
insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear",
shouldCollapseLeftPane && "pointer-events-none select-none"
)}
style={shouldCollapseLeftPane ? { maxWidth: 0 } : { maxWidth: '100%' }}
style={{ maxWidth: insetMaxWidth }}
aria-hidden={shouldCollapseLeftPane}
onMouseDownCapture={() => setActiveShortcutPane('left')}
onFocusCapture={() => setActiveShortcutPane('left')}
@ -5260,7 +5322,9 @@ function App() {
? { onClick: pushChatToSidePane, icon: <ArrowRight className="size-5" />, label: 'Dock chat to side pane' }
: (viewOpen && !isChatSidebarOpen)
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
: null
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
? { onClick: toggleRightPaneMaximize, icon: <X className="size-5" />, label: 'Expand chat' }
: null
return (
<Tooltip>
<TooltipTrigger asChild>
@ -5302,9 +5366,6 @@ function App() {
onOpenNote={(path) => navigateToFile(path)}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
onVoiceNoteCreated={handleVoiceNoteCreated}
onRunBrowserTask={handleToggleBrowser}
onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)}
onOpenChat={handleNewChatTab}
/>
</div>
@ -5656,6 +5717,7 @@ function App() {
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}

View file

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
@ -22,6 +23,15 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
};
export const PermissionRequest = ({
@ -33,14 +43,16 @@ export const PermissionRequest = ({
onDeny,
isProcessing = false,
response = null,
permission,
...props
}: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand
const command = toolCall.toolName === "executeCommand"
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const filePermission = permission?.kind === "file" ? permission : null;
const isResponded = response !== null;
const isApproved = response === 'approve';
@ -113,7 +125,35 @@ export const PermissionRequest = ({
</pre>
</div>
)}
{!command && toolCall.arguments && (
{filePermission && (
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Action
</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.paths.join("\n")}
</pre>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Approval Scope
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.pathPrefix}
</pre>
</div>
</div>
)}
{!command && !filePermission && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
@ -133,12 +173,12 @@ export const PermissionRequest = ({
size="sm"
onClick={onApprove}
disabled={isProcessing}
className={cn("flex-1", command && "rounded-r-none")}
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
>
<CheckIcon className="size-4" />
Approve
</Button>
{command && (
{(command || filePermission) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View file

@ -43,7 +43,7 @@ export function ChatEmptyState({
<Sparkles className="size-[17px]" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">How can I help?</div>
<div className="text-base font-semibold tracking-tight">What are we working on?</div>
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -12,6 +12,12 @@ import { SettingsDialog } from '@/components/settings-dialog'
type GmailThread = blocks.GmailThread
type GmailThreadMessage = blocks.GmailThreadMessage
type GmailConnectionStatus = {
connected: boolean
hasRequiredScope: boolean
missingScopes: string[]
email: string | null
}
function formatInboxTime(value?: string): string {
if (!value) return ''
@ -80,6 +86,112 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
return thread.messages[thread.messages.length - 1]
}
// Split a raw header recipient string (e.g. `"Jo Bloggs" <jo@x.com>, b@y.com`) into
// individual address tokens, respecting commas inside quotes/angle brackets.
function splitAddresses(raw?: string): string[] {
if (!raw) return []
const tokens: string[] = []
let buf = ''
let inQuote = false
let depth = 0
for (const ch of raw) {
if (ch === '"') inQuote = !inQuote
else if (ch === '<') depth += 1
else if (ch === '>') depth = Math.max(0, depth - 1)
if ((ch === ',' || ch === ';' || ch === '\n') && !inQuote && depth === 0) {
const token = buf.trim()
if (token) tokens.push(token)
buf = ''
continue
}
buf += ch
}
const last = buf.trim()
if (last) tokens.push(last)
return tokens
}
// Display label for a recipient chip: the display name if present, else the bare address.
function recipientLabel(token: string): string {
const named = token.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/)
if (named?.[1]?.trim()) return named[1].trim()
return extractAddress(token)
}
// Dedupe tokens by lowercased email address, dropping any whose address is in `exclude`.
function dedupeRecipients(tokens: string[], exclude: Set<string>): string[] {
const seen = new Set<string>(exclude)
const out: string[] = []
for (const token of tokens) {
const addr = extractAddress(token).toLowerCase()
if (!addr || seen.has(addr)) continue
seen.add(addr)
out.push(token)
}
return out
}
// Compute the To / Cc recipients for a reply, reply-all, or forward, excluding "me".
function buildRecipients(
mode: ComposeMode,
thread: GmailThread,
selfEmail: string,
): { to: string[]; cc: string[] } {
if (mode === 'forward') return { to: [], cc: [] }
const latest = latestMessage(thread)
const self = selfEmail.toLowerCase()
const fromAddr = latest?.from ? extractAddress(latest.from).toLowerCase() : ''
const iAmSender = Boolean(self) && fromAddr === self
// If my own message is the latest, reply to whoever I sent it to; otherwise reply to the sender.
const rawTo = iAmSender ? splitAddresses(latest?.to) : (latest?.from ? [latest.from] : [])
const ccPool = iAmSender
? splitAddresses(latest?.cc)
: [...splitAddresses(latest?.to), ...splitAddresses(latest?.cc)]
const selfSet = new Set<string>(self ? [self] : [])
const to = dedupeRecipients(rawTo, selfSet)
if (iAmSender && to.length === 0 && self && rawTo.some((token) => extractAddress(token).toLowerCase() === self)) {
to.push(self)
}
if (mode === 'reply') return { to, cc: [] }
const ccExclude = new Set<string>(selfSet)
for (const token of to) ccExclude.add(extractAddress(token).toLowerCase())
const cc = dedupeRecipients(ccPool, ccExclude)
return { to, cc }
}
// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes.
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
const raw = (rawSubject || '').trim()
if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim()
return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim()
}
function buildForwardedContent(thread: GmailThread): string {
const message = latestMessage(thread)
if (!message) return ''
const rows = [
'---------- Forwarded message ---------',
message.from ? `From: ${message.from}` : null,
message.date ? `Date: ${formatFullDate(message.date)}` : null,
message.subject || thread.subject ? `Subject: ${message.subject || thread.subject}` : null,
message.to ? `To: ${message.to}` : null,
message.cc ? `Cc: ${message.cc}` : null,
].filter((line): line is string => Boolean(line))
const body = (message.body || snippet(message.bodyHtml)).trim()
return [
'<p></p>',
'<blockquote>',
...rows.map((line) => `<p>${escapeHtml(line)}</p>`),
body ? `<p>${escapeHtml(body).replace(/\n/g, '<br />')}</p>` : '',
'</blockquote>',
].join('')
}
const PREFETCH_HOVER_MS = 180
const PREFETCH_MAX_IMAGES_PER_THREAD = 12
@ -374,7 +486,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
)
}
type ComposeMode = 'reply' | 'forward'
type ComposeMode = 'reply' | 'replyAll' | 'forward'
function ComposeToolbarButton({
editor,
@ -475,20 +587,110 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
)
}
function RecipientField({
label,
value,
onChange,
autoFocus,
trailing,
}: {
label: string
value: string[]
onChange: (next: string[]) => void
autoFocus?: boolean
trailing?: React.ReactNode
}) {
const [draft, setDraft] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (autoFocus) inputRef.current?.focus()
}, [autoFocus])
const commit = (raw: string) => {
const additions = splitAddresses(raw)
if (additions.length === 0) return
onChange(dedupeRecipients([...value, ...additions], new Set()))
setDraft('')
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
if (draft.trim()) {
event.preventDefault()
commit(draft)
}
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
onChange(value.slice(0, -1))
}
}
return (
<div className="gmail-recipient-row">
<span className="gmail-recipient-label">{label}</span>
<div className="gmail-recipient-field">
{value.map((token, index) => (
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
<button
type="button"
className="gmail-recipient-chip-remove"
aria-label={`Remove ${extractAddress(token)}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onChange(value.filter((_, idx) => idx !== index))}
>
×
</button>
</span>
))}
<input
ref={inputRef}
className="gmail-recipient-input"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
onBlur={() => { if (draft.trim()) commit(draft) }}
onPaste={(event) => {
const text = event.clipboardData.getData('text')
if (text && /[,;\n]/.test(text)) {
event.preventDefault()
commit(text)
}
}}
/>
</div>
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
</div>
)
}
function ComposeBox({
mode,
thread,
selfEmail,
onClose,
}: {
mode: ComposeMode
thread: GmailThread
selfEmail: string
onClose: () => void
}) {
const latest = latestMessage(thread)
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
const initialRecipients = useMemo(
() => buildRecipients(mode, thread, selfEmail),
[mode, thread, selfEmail],
)
const [toList, setToList] = useState<string[]>(initialRecipients.to)
const [ccList, setCcList] = useState<string[]>(initialRecipients.cc)
const [bccList, setBccList] = useState<string[]>([])
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
const [showBcc, setShowBcc] = useState<boolean>(false)
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const initialContent = useMemo(() => {
if (mode !== 'reply') return ''
if (mode === 'forward') return buildForwardedContent(thread)
// Gmail-side draft (user's own work) wins over the AI-generated draft.
const source = thread.gmail_draft || thread.draft_response
if (!source) return ''
@ -496,14 +698,14 @@ function ComposeBox({
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
.join('')
}, [mode, thread.gmail_draft, thread.draft_response])
}, [mode, thread])
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
}),
],
editorProps: {
@ -555,52 +757,65 @@ function ComposeBox({
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
}
const [sending, setSending] = useState(false)
const sendInGmail = async () => {
if (!editor) {
window.open(thread.threadUrl, '_blank')
return
}
if (!editor || sending) return
const html = editor.getHTML()
const text = editor.getText().trim()
let copied = false
if (text) {
try {
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' }),
}),
])
copied = true
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
copied = true
}
} catch (err) {
console.warn('[Gmail] clipboard write failed:', err)
}
if (!text) {
toast('Draft is empty.', 'error')
return
}
window.open(thread.threadUrl, '_blank')
if (copied) {
toast('Draft copied — open the reply in Gmail and paste.', 'info')
} else if (text) {
toast('Could not copy draft. Open Gmail and paste manually.', 'error')
if (toList.length === 0) {
toast('Add at least one recipient.', 'error')
return
}
// Build References chain from all known message ids (newest last).
const messageIds = thread.messages
.map((m) => m.messageIdHeader)
.filter((v): v is string => Boolean(v))
const references = messageIds.join(' ')
const inReplyTo = latest?.messageIdHeader
const isForward = mode === 'forward'
setSending(true)
try {
const result = await window.ipc.invoke('gmail:sendReply', {
threadId: isForward ? undefined : thread.threadId,
to: toList.join(', '),
cc: ccList.length ? ccList.join(', ') : undefined,
bcc: bccList.length ? bccList.join(', ') : undefined,
subject: subject.trim() || composeSubject(mode, thread.subject),
bodyHtml: html,
bodyText: text,
inReplyTo: isForward ? undefined : inReplyTo,
references: isForward ? undefined : references || undefined,
})
if (result.error) {
toast(`Send failed: ${result.error}`, 'error')
return
}
toast('Sent.', 'success')
onClose()
} catch (err) {
toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setSending(false)
}
}
const refineWithCopilot = () => {
if (!editor) return
const currentDraft = editor.getText().trim()
const subject = thread.subject || '(No subject)'
const threadSubject = thread.subject || '(No subject)'
const lines: string[] = []
lines.push(`Help me refine this draft email response. **Please ask me how I want to refine it before making any changes** — wait for my answer, then apply the edits.`)
lines.push('')
lines.push(`**Mode:** ${mode === 'reply' ? 'Reply' : 'Forward'}`)
lines.push(`**Subject:** ${subject}`)
lines.push(`**Mode:** ${modeLabel}`)
lines.push(`**Subject:** ${threadSubject}`)
lines.push('')
lines.push(`## Thread (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`)
lines.push('')
@ -625,17 +840,32 @@ function ComposeBox({
return (
<div className="gmail-compose-card">
<div className="gmail-compose-header">
<span>{mode === 'reply' ? 'Reply' : 'Forward'}</span>
<button type="button" onClick={onClose} aria-label="Close compose">x</button>
</div>
<div className="gmail-compose-line">
<span>{mode === 'reply' ? 'To' : 'Recipients'}</span>
<input value={to} placeholder="Recipients" readOnly={mode === 'reply'} />
<span>{modeLabel}</span>
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
</div>
<RecipientField
label="To"
value={toList}
onChange={setToList}
autoFocus={mode === 'forward'}
trailing={
<div className="gmail-recipient-toggles">
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
{!showBcc && <button type="button" onClick={() => setShowBcc(true)}>Bcc</button>}
</div>
}
/>
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
{mode === 'forward' && (
<div className="gmail-compose-line">
<span>Subject</span>
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
<span className="gmail-compose-label">Subject</span>
<input
className="gmail-compose-subject-input"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Subject"
/>
</div>
)}
<EditorContent editor={editor} className="gmail-compose-editor" />
@ -666,10 +896,11 @@ function ComposeBox({
type="button"
className="gmail-send-button"
onClick={() => { void sendInGmail() }}
title="Copy draft and open this thread in Gmail"
disabled={sending}
title="Send this reply via Gmail"
>
<Send size={15} />
Send
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
{sending ? 'Sending…' : 'Send'}
</button>
<button
type="button"
@ -698,10 +929,25 @@ function ThreadDetail({
hidden?: boolean
}) {
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
const [selfEmail, setSelfEmail] = useState<string>('')
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
() => new Set(thread.messages.length > 0 ? [thread.messages.length - 1] : [])
)
// The connected Gmail address, so reply-all can exclude "me".
useEffect(() => {
let cancelled = false
window.ipc.invoke('gmail:getAccountEmail', {})
.then((res) => { if (!cancelled && res?.email) setSelfEmail(res.email) })
.catch(() => {})
return () => { cancelled = true }
}, [])
const canReplyAll = useMemo(() => {
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
return cc.length > 0 || to.length > 1
}, [thread, selfEmail])
const toggleExpand = useCallback((index: number) => {
setExpandedIndices((prev) => {
const next = new Set(prev)
@ -752,7 +998,10 @@ function ThreadDetail({
</div>
</div>
{isExpanded ? (
<div className="gmail-message-to">to {message.to || 'me'}</div>
<>
<div className="gmail-message-to">to {message.to || 'me'}</div>
{message.cc && <div className="gmail-message-cc">cc {message.cc}</div>}
</>
) : (
<div className="gmail-message-snippet">{snippet(message.body)}</div>
)}
@ -771,6 +1020,12 @@ function ThreadDetail({
<Reply size={16} />
Reply
</button>
{canReplyAll && (
<button type="button" onClick={() => setComposeMode('replyAll')}>
<ReplyAll size={16} />
Reply all
</button>
)}
<button type="button" onClick={() => setComposeMode('forward')}>
<Forward size={16} />
Forward
@ -779,8 +1034,10 @@ function ThreadDetail({
{composeMode && (
<ComposeBox
key={composeMode}
mode={composeMode}
thread={thread}
selfEmail={selfEmail}
onClose={() => setComposeMode(null)}
/>
)}
@ -843,23 +1100,33 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
// Gmail connection status — null while checking, false shows the connect prompt.
const [emailConnected, setEmailConnected] = useState<boolean | null>(null)
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
if (!cancelled) setEmailConnected(result.isConnected)
const status = await window.ipc.invoke('gmail:getConnectionStatus', {})
if (!cancelled) setEmailConnection(status)
} catch {
if (!cancelled) setEmailConnected(false)
if (!cancelled) {
setEmailConnection({
connected: false,
hasRequiredScope: false,
missingScopes: [],
email: null,
})
}
}
}
void check()
const cleanup = window.ipc.on('oauth:didConnect', () => { void check() })
return () => { cancelled = true; cleanup() }
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
return () => {
cancelled = true
cleanupOAuthConnect()
}
}, [])
useEffect(() => { persistedImportant = important }, [important])
@ -870,18 +1137,81 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
else setOther(updater)
}, [])
const toggleThread = useCallback((threadId: string) => {
const updateThreadInState = useCallback((threadId: string, updater: (t: GmailThread) => GmailThread) => {
const mapSection = (prev: SectionState): SectionState => ({
...prev,
threads: prev.threads.map((t) => (t.threadId === threadId ? updater(t) : t)),
})
setImportant(mapSection)
setOther(mapSection)
}, [])
const removeThreadFromState = useCallback((threadId: string) => {
const filterSection = (prev: SectionState): SectionState => ({
...prev,
threads: prev.threads.filter((t) => t.threadId !== threadId),
})
setImportant(filterSection)
setOther(filterSection)
setSelectedThreadId((current) => (current === threadId ? null : current))
setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId))
}, [])
const markThreadReadAction = useCallback(async (threadId: string) => {
updateThreadInState(threadId, (t) => ({
...t,
unread: false,
messages: t.messages.map((m) => ({ ...m, unread: false })),
}))
try {
const result = await window.ipc.invoke('gmail:markThreadRead', { threadId })
if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error)
} catch (err) {
console.warn('[Gmail] mark-read failed:', err)
}
}, [updateThreadInState])
const archiveThreadAction = useCallback(async (threadId: string) => {
try {
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
if (result.ok) {
removeThreadFromState(threadId)
} else if (result.error) {
toast(`Archive failed: ${result.error}`, 'error')
}
} catch (err) {
toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
}
}, [removeThreadFromState])
const trashThreadAction = useCallback(async (threadId: string) => {
try {
const result = await window.ipc.invoke('gmail:trashThread', { threadId })
if (result.ok) {
removeThreadFromState(threadId)
} else if (result.error) {
toast(`Delete failed: ${result.error}`, 'error')
}
} catch (err) {
toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
}
}, [removeThreadFromState])
const toggleThread = useCallback((thread: GmailThread) => {
setSelectedThreadId((current) => {
const next = current === threadId ? null : threadId
const next = current === thread.threadId ? null : thread.threadId
if (next) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== next)
return [...without, next].slice(-MAX_KEPT_OPEN)
})
if (thread.unread) {
void markThreadReadAction(thread.threadId)
}
}
return next
})
}, [])
}, [markThreadReadAction])
const prefetchedRef = useRef<Set<string>>(new Set())
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -1128,29 +1458,69 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
const hasAny = important.threads.length > 0 || other.threads.length > 0
const initialLoading = !hasAny && refreshing
const needsEmailConnect = emailConnection?.connected === false
const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope
const renderRow = (thread: GmailThread) => {
const latest = latestMessage(thread)
const isSelected = thread.threadId === selectedThreadId
const isUnread = thread.unread === true
const isMounted = openedThreadIds.includes(thread.threadId)
const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation()
}
return (
<div key={thread.threadId} className="gmail-row-group">
<button
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
onClick={() => toggleThread(thread.threadId)}
<div
className={cn('gmail-row-shell', isSelected && 'gmail-row-shell-selected')}
onMouseEnter={() => scheduleHoverPrefetch(thread)}
onMouseLeave={cancelHoverPrefetch}
>
<span className="gmail-row-dot" aria-hidden />
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
<span className="gmail-row-content">
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
<button
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
onClick={() => toggleThread(thread)}
>
<span className="gmail-row-dot" aria-hidden />
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
<span className="gmail-row-content">
<strong>{thread.summary || thread.subject || '(No subject)'}</strong>
<span>{thread.summary ? thread.subject : snippet(latest?.body || thread.latest_email)}</span>
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
<div className="gmail-row-actions" onMouseDown={stop} onClick={stop}>
{isUnread && (
<button
type="button"
className="gmail-row-action"
title="Mark as read"
aria-label="Mark as read"
onClick={(e) => { stop(e); void markThreadReadAction(thread.threadId) }}
>
<CheckCheck size={15} />
</button>
)}
<button
type="button"
className="gmail-row-action"
title="Archive"
aria-label="Archive"
onClick={(e) => { stop(e); void archiveThreadAction(thread.threadId) }}
>
<Archive size={15} />
</button>
<button
type="button"
className="gmail-row-action gmail-row-action-danger"
title="Delete"
aria-label="Delete"
onClick={(e) => { stop(e); void trashThreadAction(thread.threadId) }}
>
<Trash2 size={15} />
</button>
</div>
</div>
{isMounted && (
<ThreadDetail
thread={thread}
@ -1220,17 +1590,21 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
</section>
)}
</div>
) : emailConnected === false ? (
) : needsEmailConnect || needsEmailReconnect ? (
<div className="gmail-empty-state flex flex-col items-center gap-3 py-16 text-center">
<Mail size={28} className="opacity-50" />
<p>Connect your email to see your inbox here.</p>
<p>
{needsEmailReconnect
? 'Reconnect your email to enable Gmail sync and actions.'
: 'Connect your email to see your inbox here.'}
</p>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
<Mail size={15} />
Connect your email
{needsEmailReconnect ? 'Reconnect your email' : 'Connect your email'}
</button>
</div>
) : (

View file

@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, FileText, Globe, Mail, MessageSquare, Mic, Plus, Telescope, Video } from 'lucide-react'
import { VoiceNoteButton } from '@/components/sidebar-content'
import { ArrowRight, Bot, Calendar, Clock, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
interface TreeNode {
path: string
@ -26,9 +25,6 @@ type HomeViewProps = {
onOpenNote: (path: string) => void
onOpenRun: (runId: string) => void
onTakeMeetingNotes: () => void
onVoiceNoteCreated?: (path: string) => void
onRunBrowserTask?: () => void
onStartResearch?: () => void
onOpenChat?: () => void
}
@ -58,6 +54,7 @@ type RawCalEvent = {
}
type EmailThread = { threadId: string; subject: string; from: string }
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
const h = new Date().getHours()
@ -157,6 +154,54 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
}
const CARD = 'rounded-xl border border-border bg-card p-4'
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function HomeView({
tree,
@ -169,13 +214,13 @@ export function HomeView({
onOpenNote,
onOpenRun,
onTakeMeetingNotes,
onVoiceNoteCreated,
onRunBrowserTask,
onStartResearch,
onOpenChat,
}: HomeViewProps) {
const [events, setEvents] = useState<CalEvent[]>([])
const [emails, setEmails] = useState<EmailThread[]>([])
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const loadEvents = useCallback(async () => {
try {
@ -215,7 +260,40 @@ export function HomeView({
}
}, [])
useEffect(() => { void loadEvents(); void loadEmails() }, [loadEvents, loadEmails])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadConnectorLogos])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
@ -445,6 +523,43 @@ export function HomeView({
</div>
)}
{/* Tool connections */}
<div className={CARD}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="font-medium">Connect your tools.</span>
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
{/* Open chat CTA */}
{onOpenChat && (
<button
@ -472,67 +587,6 @@ export function HomeView({
)
}
type DiscoveryTip = {
icon: typeof Mic
title: string
desc: string
/** Provide one of: an action node (e.g. VoiceNoteButton) or an onAction handler. */
action?: React.ReactNode
onAction?: () => void
}
function DiscoveryCarousel({ tips }: { tips: DiscoveryTip[] }) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
useEffect(() => {
if (paused || tips.length <= 1) return
const id = setInterval(() => setIndex((i) => (i + 1) % tips.length), 7000)
return () => clearInterval(id)
}, [paused, tips.length])
const safeIndex = index % tips.length
const tip = tips[safeIndex]
const Icon = tip.icon
return (
<div
className={`${CARD} flex items-center gap-3.5 border-dashed bg-transparent`}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card">
<Icon className="size-[15px]" />
</div>
<div className="flex-1 text-[13.5px] leading-snug">
<span className="font-medium">{tip.title}</span>
<span className="text-muted-foreground"> {tip.desc}</span>
</div>
{tip.action ?? (
<button
type="button"
onClick={() => tip.onAction?.()}
aria-label={tip.title}
className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ArrowRight className="size-[15px]" />
</button>
)}
<div className="ml-1 flex shrink-0 items-center gap-1">
{tips.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show tip ${i + 1}`}
className={`size-1.5 rounded-full transition-colors ${i === safeIndex ? 'bg-foreground' : 'bg-border hover:bg-muted-foreground/40'}`}
/>
))}
</div>
</div>
)
}
function formatFrom(from: string): string {
const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
return (m ? m[1] : from).trim()

View file

@ -1,5 +1,5 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -83,7 +83,8 @@ function nodeToText(node: JsonNode): string {
return text
} else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || ''
return path ? `[[${path}]]` : ''
const label = (child.attrs?.label as string | null | undefined) || ''
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
}
@ -189,7 +190,8 @@ function blockToMarkdown(node: JsonNode): string {
return '---'
case 'wikiLink': {
const path = (node.attrs?.path as string) || ''
return `[[${path}]]`
const label = (node.attrs?.label as string | null | undefined) || ''
return `[[${path}${label ? `|${label}` : ''}]]`
}
case 'image': {
const src = (node.attrs?.src as string) || ''
@ -297,7 +299,7 @@ import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css'
@ -523,6 +525,106 @@ const TabIndentExtension = Extension.create({
},
})
const slugifyHeading = (text: string) =>
text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const decodeLinkTarget = (target: string) => {
try {
return decodeURIComponent(target)
} catch {
return target
}
}
const scrollToHeading = (view: EditorView, rawTarget: string) => {
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
if (!target) return false
const targetSlug = slugifyHeading(target)
let foundPos: number | null = null
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return true
const headingText = node.textContent.trim()
if (
headingText.toLowerCase() === target.toLowerCase()
|| slugifyHeading(headingText) === targetSlug
) {
foundPos = pos
return false
}
return true
})
if (foundPos === null) return false
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
view.dispatch(
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
)
view.focus()
const domAtPos = view.domAtPos(foundPos + 1)
const node = domAtPos.node
const headingEl = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
return true
}
const stripMarkdownExtension = (path: string) =>
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
const isSameNotePath = (linkPath: string, notePath?: string) => {
if (!notePath) return false
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
return normalizedLink === normalizedNote
}
const isExternalHref = (href: string) =>
/^(https?:|mailto:|tel:)/i.test(href)
const collapseRelativeSegments = (relPath: string) => {
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
const stack: string[] = []
for (const part of parts) {
if (part === '..') {
if (stack.length === 0) return null
stack.pop()
} else {
stack.push(part)
}
}
return stack.join('/')
}
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
const withoutHash = href.split('#')[0]
const withoutQuery = withoutHash.split('?')[0]
const decoded = decodeLinkTarget(withoutQuery)
if (!decoded) return null
if (/^file:\/\//i.test(decoded)) {
try {
return decodeURIComponent(new URL(decoded).pathname)
} catch {
return decoded
}
}
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
const noteDir = notePath.split('/').slice(0, -1).join('/')
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
}
export interface MarkdownEditorHandle {
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
getCursorContext: () => { path: string; lineNumber: number } | null
@ -644,6 +746,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
heading: {
levels: [1, 2, 3],
},
link: false,
}),
Link.configure({
openOnClick: false,
@ -804,17 +907,57 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') {
event.preventDefault()
const wikiPath = String(node.attrs.path ?? '')
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
return scrollToHeading(_view, heading)
}
wikiLinks?.onOpen?.(node.attrs.path)
return true
}
return false
},
handleDOMEvents: {
click: (view, event) => {
const target = event.target as Element | null
const link = target?.closest('a[href]') as HTMLAnchorElement | null
if (!link) return false
if (link.dataset.type === 'wiki-link') return false
const href = link.getAttribute('href') ?? ''
if (!href) return false
if (href.startsWith('#')) {
event.preventDefault()
return scrollToHeading(view, href)
}
if (isExternalHref(href)) {
event.preventDefault()
window.open(href, '_blank')
return true
}
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
if (!workspacePath) return false
event.preventDefault()
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
if (result.error) console.error('Failed to open linked file:', result.error)
}).catch((err) => {
console.error('Failed to open linked file:', err)
})
return true
},
},
},
}, [
editorSessionKey,
maybeCommitPrimaryHeading,
notePath,
preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading,
wikiLinks,
])
const orderedFiles = useMemo(() => {

View file

@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react'
import { createPortal } from 'react-dom'
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SettingsDialog } from '@/components/settings-dialog'
import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event'
@ -40,14 +42,32 @@ type RawCalendarEvent = {
start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string }
location?: string
description?: string
htmlLink?: string
status?: string
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>
creator?: CalendarPerson
organizer?: CalendarPerson
attendees?: CalendarAttendee[]
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
hangoutLink?: string
conferenceLink?: string
}
type CalendarPerson = {
email?: string
displayName?: string
self?: boolean
}
type CalendarAttendee = CalendarPerson & {
responseStatus?: string
optional?: boolean
}
type DescriptionPart =
| { type: 'text'; text: string }
| { type: 'link'; text: string; href: string }
type UpcomingEvent = {
id: string
summary: string
@ -55,8 +75,12 @@ type UpcomingEvent = {
end: Date | null
isAllDay: boolean
location: string | null
description: string | null
htmlLink: string | null
conferenceLink: string | null
creator: CalendarPerson | null
organizer: CalendarPerson | null
attendees: CalendarAttendee[]
source: string // workspace path to the calendar_sync JSON
rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined
@ -125,8 +149,12 @@ function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEven
end,
isAllDay,
location: raw.location?.trim() || null,
description: raw.description?.trim() || null,
htmlLink: raw.htmlLink ?? null,
conferenceLink,
creator: raw.creator ?? null,
organizer: raw.organizer ?? null,
attendees: raw.attendees ?? [],
source: sourcePath,
rawStart: raw.start,
rawEnd: raw.end,
@ -185,12 +213,157 @@ function formatEventTimeRange(event: UpcomingEvent): string {
return `${start} ${end}`
}
// Compact range for the upcoming list: drops the leading meridiem when both
// ends share it ("9:00 11:00 AM" instead of "9:00 AM 11:00 AM").
function formatEventTimeRangeCompact(event: UpcomingEvent): string {
if (event.isAllDay) return 'All day'
const startStr = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (!event.end) return startStr
const sameDay = localDateKey(event.start) === localDateKey(event.end)
if (!sameDay) return formatEventTimeRange(event)
const endStr = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const meridiemRe = /\s*[AP]M$/i
const startMer = startStr.match(meridiemRe)?.[0]?.trim().toUpperCase()
const endMer = endStr.match(meridiemRe)?.[0]?.trim().toUpperCase()
if (startMer && endMer && startMer === endMer) {
return `${startStr.replace(meridiemRe, '')} ${endStr}`
}
return `${startStr} ${endStr}`
}
// Whether a timed event is happening right now.
function isEventNow(event: UpcomingEvent): boolean {
if (event.isAllDay) return false
const now = Date.now()
const start = event.start.getTime()
const end = event.end ? event.end.getTime() : start + 30 * 60 * 1000
return start <= now && now < end
}
// Human label for the conferencing provider behind an event's join link.
function meetingPlatformLabel(link: string | null): string | null {
if (!link) return null
if (/zoom\.us|zoomgov\.com/i.test(link)) return 'Zoom'
if (/teams\.(?:microsoft|live)\.com/i.test(link)) return 'Teams'
if (/meet\.google\.com/i.test(link)) return 'Meet'
return 'Video call'
}
function formatEventDetailTime(event: UpcomingEvent): string {
if (!event.isAllDay) {
const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
return `${date}, ${formatEventTimeRange(event)}`
}
const start = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
if (!event.end) return `${start}, all day`
const exclusiveEnd = addDays(event.end, -1)
if (localDateKey(exclusiveEnd) === localDateKey(event.start)) return `${start}, all day`
const end = exclusiveEnd.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
return `${start} ${end}, all day`
}
function personLabel(person: CalendarPerson | null | undefined): string | null {
if (!person) return null
return person.displayName?.trim() || person.email?.trim() || null
}
function attendeeLabel(attendee: CalendarAttendee): string | null {
const label = personLabel(attendee)
if (!label) return null
if (attendee.self) return `${label} (you)`
return label
}
function normalizeDescriptionParts(parts: DescriptionPart[]): DescriptionPart[] {
const normalized: DescriptionPart[] = []
for (const part of parts) {
const text = part.text.replace(/\n{3,}/g, '\n\n')
if (!text) continue
const previous = normalized[normalized.length - 1]
if (previous?.type === 'text' && part.type === 'text') {
previous.text += text
} else if (part.type === 'link') {
normalized.push({ ...part, text })
} else {
normalized.push({ type: 'text', text })
}
}
return normalized
}
function isSafeDescriptionHref(value: string): boolean {
try {
const url = new URL(value, window.location.href)
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'
} catch {
return false
}
}
function linkifyText(value: string): DescriptionPart[] {
const parts: DescriptionPart[] = []
const urlRe = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[^\s<>"')\]]+/gi
let lastIndex = 0
for (const match of value.matchAll(urlRe)) {
const raw = match[0]
const index = match.index ?? 0
if (index > lastIndex) parts.push({ type: 'text', text: value.slice(lastIndex, index) })
const href = raw.startsWith('www.') ? `https://${raw}` : raw
parts.push({ type: 'link', text: raw, href })
lastIndex = index + raw.length
}
if (lastIndex < value.length) parts.push({ type: 'text', text: value.slice(lastIndex) })
return parts
}
function parseDescriptionParts(value: string): DescriptionPart[] {
const withLineBreaks = value.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n')
if (typeof DOMParser === 'undefined') {
return normalizeDescriptionParts(linkifyText(withLineBreaks.replace(/<[^>]*>/g, '').trim()))
}
const doc = new DOMParser().parseFromString(withLineBreaks, 'text/html')
const parts: DescriptionPart[] = []
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(...linkifyText(node.textContent ?? ''))
return
}
if (!(node instanceof HTMLElement)) return
if (node.tagName === 'A') {
const href = node.getAttribute('href') ?? ''
const text = node.textContent?.trim() || href
if (href && isSafeDescriptionHref(href)) {
parts.push({ type: 'link', text, href })
return
}
}
if (node.tagName === 'BR') {
parts.push({ type: 'text', text: '\n' })
return
}
node.childNodes.forEach(visit)
if (/^(P|DIV|LI|TR|H[1-6])$/.test(node.tagName)) {
parts.push({ type: 'text', text: '\n' })
}
}
doc.body.childNodes.forEach(visit)
return normalizeDescriptionParts(parts).map((part, index, all) => {
if (index === 0 || index === all.length - 1) return { ...part, text: part.text.trim() }
return part
}).filter((part) => part.text.length > 0)
}
function UpcomingEvents() {
const [events, setEvents] = useState<UpcomingEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// Calendar connection status — null while checking, false shows the connect prompt.
// Calendar sync uses the native Google OAuth connection.
const [calendarConnected, setCalendarConnected] = useState<boolean | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
@ -198,15 +371,18 @@ function UpcomingEvents() {
let cancelled = false
const check = async () => {
try {
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'googlecalendar' })
if (!cancelled) setCalendarConnected(result.isConnected)
const oauthState = await window.ipc.invoke('oauth:getState', null)
if (!cancelled) setCalendarConnected(oauthState.config?.google?.connected ?? false)
} catch {
if (!cancelled) setCalendarConnected(false)
}
}
void check()
const cleanup = window.ipc.on('oauth:didConnect', () => { void check() })
return () => { cancelled = true; cleanup() }
const cleanupOAuthConnect = window.ipc.on('oauth:didConnect', () => { void check() })
return () => {
cancelled = true
cleanupOAuthConnect()
}
}, [])
const loadEvents = useCallback(async () => {
@ -292,8 +468,9 @@ function UpcomingEvents() {
break
}
})
// Refresh on the hour so day labels and "ended" filtering stay current.
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000)
// Refresh every minute so the "now" highlight, day labels, and "ended"
// filtering stay current without waiting on a calendar sync.
const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 1000)
return () => {
cleanup()
clearInterval(tick)
@ -323,10 +500,7 @@ function UpcomingEvents() {
Coming up
</h3>
{loading && events.length === 0 ? null : (
<span
className="text-[11px] uppercase tracking-wider"
style={{ color: 'var(--gm-text-faint)' }}
>
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
{totalVisible} {totalVisible === 1 ? 'event' : 'events'}
</span>
)}
@ -352,16 +526,12 @@ function UpcomingEvents() {
) : error ? (
<div className="py-4 text-sm text-muted-foreground">{error}</div>
) : (
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: 'var(--gm-border)', background: 'var(--gm-bg)' }}
>
{visibleDays.map((day, idx) => (
<UpcomingDayRow
<div className="flex flex-col gap-3">
{visibleDays.map((day) => (
<UpcomingDayCard
key={day.dateKey}
day={day}
isToday={day.dateKey === todayKey}
isLast={idx === visibleDays.length - 1}
/>
))}
</div>
@ -372,129 +542,242 @@ function UpcomingEvents() {
)
}
function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) {
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
const dayNum = day.date.getDate()
const month = day.date.toLocaleDateString([], { month: 'short' })
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
const count = day.events.length
return (
<div
className="grid"
style={{
gridTemplateColumns: '96px minmax(0, 1fr)',
borderBottom: isLast ? undefined : '1px dashed var(--gm-border-strong)',
}}
>
<div className="flex items-start gap-2 px-4 py-4">
<span
className="leading-none"
style={{ fontSize: 30, fontWeight: 400, color: 'var(--gm-text-strong)' }}
>
{dayNum}
</span>
<span className="flex flex-col leading-tight">
<span
className="flex items-center gap-1"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--gm-text)' }}
>
{month}
{isToday ? (
<span
aria-hidden
className="inline-block rounded-full"
style={{ width: 5, height: 5, background: 'var(--gm-accent)' }}
/>
) : null}
<div className="overflow-hidden rounded-xl border bg-card">
<div className="flex items-center justify-between gap-3 border-b bg-muted px-5 py-3.5">
<div className="flex min-w-0 items-baseline gap-2">
<span className="text-[22px] font-bold leading-none text-foreground">{dayNum}</span>
<span className="truncate text-[13px] text-muted-foreground">
{month} · {weekday}
</span>
<span style={{ fontSize: 12, color: 'var(--gm-text-faint)' }}>{weekday}</span>
{isToday ? (
<span className="shrink-0 rounded-md bg-foreground px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-background">
Today
</span>
) : null}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{count} {count === 1 ? 'event' : 'events'}
</span>
</div>
<div className="flex min-w-0 flex-col py-3 pr-3">
{day.events.length === 0 ? (
<div
className="flex w-full items-center gap-3 px-3 py-2 text-sm"
style={{ color: 'var(--gm-text-faint)', minHeight: 40 }}
>
<span aria-hidden className="self-stretch shrink-0" style={{ width: 3 }} />
<span>{isToday ? 'No events today' : 'No events'}</span>
</div>
) : (
day.events.map((ev) => <UpcomingEventItem key={ev.id} event={ev} />)
)}
</div>
{count === 0 ? (
<div className="px-5 py-4 text-sm text-muted-foreground">
{isToday ? 'No events today' : 'No events'}
</div>
) : (
day.events.map((ev, idx) => (
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
))
)}
</div>
)
}
function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
const handleOpen = useCallback(() => {
if (event.htmlLink) window.open(event.htmlLink, '_blank')
}, [event.htmlLink])
function NowBadge() {
return (
<span className="shrink-0 rounded bg-green-600 px-1.5 py-px text-[10px] font-bold uppercase leading-[1.5] tracking-wide text-white">
Now
</span>
)
}
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
const [open, setOpen] = useState(false)
const isNow = isEventNow(event)
const platform = meetingPlatformLabel(event.conferenceLink)
const subtitle = platform ?? event.location
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
return (
<div
role="button"
tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen()
}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="button"
tabIndex={0}
title={titleAndLocation}
className={cn(
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
!isLast && 'border-b',
isNow ? 'bg-muted' : 'hover:bg-muted/50',
)}
>
<span className="shrink-0 text-[13px] tabular-nums text-muted-foreground" style={{ width: 118 }}>
{formatEventTimeRangeCompact(event)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{event.summary}
</span>
{isNow ? <NowBadge /> : null}
</span>
{subtitle ? (
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{platform ? <Video className="size-3.5 shrink-0" /> : <MapPin className="size-3.5 shrink-0" />}
<span className="truncate">{subtitle}</span>
</span>
) : null}
</span>
<div className="shrink-0">
{event.conferenceLink ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
onNotesOnly={() => triggerMeetingCapture(event, false)}
/>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
onMouseDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
<Mic className="size-3.5" />
Take notes
</button>
)}
</div>
</div>
</PopoverTrigger>
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
</Popover>
)
}
function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) {
const organizer = personLabel(event.organizer) ?? personLabel(event.creator)
const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label))
const descriptionParts = event.description ? parseDescriptionParts(event.description) : []
const handleMeetingCapture = (openConference: boolean) => {
onClose()
triggerMeetingCapture(event, openConference)
}
return (
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl"
style={{
backgroundColor: 'var(--muted, #f4f4f5)',
borderColor: 'var(--border, #e4e4e7)',
color: 'var(--popover-foreground, #09090b)',
}}
title={titleAndLocation}
className={cn(
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
)}
style={{ color: 'var(--gm-text)', minHeight: 40 }}
>
<span
aria-hidden
className="self-stretch rounded-full"
style={{ width: 3, background: 'var(--gm-accent)', opacity: 0.55 }}
/>
<span className="min-w-0 flex-1">
<span
className="block truncate"
style={{ fontSize: 14, fontWeight: 500, color: 'var(--gm-text-strong)' }}
>
{event.summary}
</span>
<span
className="mt-0.5 block truncate"
style={{ fontSize: 12, color: 'var(--gm-text-muted)' }}
>
{formatEventTimeRange(event)}
{event.location ? <span style={{ color: 'var(--gm-text-faint)' }}> · {event.location}</span> : null}
</span>
</span>
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{event.conferenceLink ? (
<SplitJoinButton
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
onNotesOnly={() => triggerMeetingCapture(event, false)}
/>
) : (
<div className="flex items-center justify-end gap-1 border-b px-3 py-2" style={{ borderColor: 'var(--border, #e4e4e7)' }}>
{event.htmlLink ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }}
onMouseDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
onClick={() => window.open(event.htmlLink!, '_blank')}
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--muted-foreground, #71717a)' }}
aria-label="Open in Google Calendar"
title="Open in Google Calendar"
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Mic className="size-3" />
Take notes
<ExternalLink className="size-4" />
</button>
)}
) : null}
<button
type="button"
onClick={onClose}
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--muted-foreground, #71717a)' }}
aria-label="Close event details"
title="Close"
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<div className="flex gap-3">
<span
aria-hidden
className="mt-1.5 h-3 w-3 shrink-0 rounded-sm"
style={{ background: 'var(--primary, #18181b)' }}
/>
<div className="min-w-0">
<h4 className="break-words text-[20px] font-normal leading-6" style={{ color: 'var(--foreground, #09090b)' }}>
{event.summary}
</h4>
</div>
</div>
<EventDetailRow icon={<Clock className="size-4" />} value={formatEventDetailTime(event)} />
{event.location ? <EventDetailRow icon={<MapPin className="size-4" />} value={event.location} /> : null}
{organizer ? <EventDetailRow icon={<UserRound className="size-4" />} value={`Organizer: ${organizer}`} /> : null}
{attendees.length > 0 ? (
<EventDetailRow
icon={<UsersRound className="size-4" />}
value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')}
/>
) : null}
{event.conferenceLink ? (
<div className="flex gap-3">
<Video className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => handleMeetingCapture(true)}>
Join & take notes
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
Take notes only
</Button>
</div>
</div>
) : (
<div className="flex gap-3">
<Mic className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
Take notes
</Button>
</div>
)}
{descriptionParts.length > 0 ? (
<div className="flex gap-3">
<span className="mt-1 size-4 shrink-0" />
<div className="max-h-40 overflow-auto whitespace-pre-wrap break-words text-sm leading-5" style={{ color: 'var(--foreground, #27272a)' }}>
{descriptionParts.map((part, index) => {
if (part.type === 'text') return <span key={index}>{part.text}</span>
return (
<a
key={index}
href={part.href}
onClick={(e) => {
e.preventDefault()
window.open(part.href, '_blank')
}}
className="underline underline-offset-2"
style={{ color: 'var(--primary, #18181b)' }}
>
{part.text}
</a>
)
})}
</div>
</div>
) : null}
</div>
</PopoverContent>
)
}
function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) {
return (
<div className="flex gap-3 text-sm leading-5">
<span className="mt-0.5 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }}>{icon}</span>
<span className="min-w-0 break-words" style={{ color: 'var(--foreground, #27272a)' }}>{value}</span>
</div>
)
}
@ -504,41 +787,46 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
onNotesOnly: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Fixed-position coords for the portaled menu so it isn't clipped by the
// calendar card's `overflow-hidden`.
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null)
const updatePos = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
}, [])
useEffect(() => {
if (!open) return
updatePos()
const handler = (e: MouseEvent) => {
const target = e.target
if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) {
setOpen(false)
}
if (!(target instanceof globalThis.Node)) return
if (containerRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
window.addEventListener('resize', updatePos)
window.addEventListener('scroll', updatePos, true)
return () => {
document.removeEventListener('mousedown', handler)
window.removeEventListener('resize', updatePos)
window.removeEventListener('scroll', updatePos, true)
}
}, [open, updatePos])
return (
<div
ref={ref}
style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}
>
<div ref={containerRef} className="relative inline-flex items-stretch">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onJoinAndNotes() }}
className="inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderTopLeftRadius: 6,
borderBottomLeftRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
className="inline-flex items-center gap-1.5 rounded-l-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
<Video className="size-3" />
<Video className="size-3.5" />
Join & take notes
</button>
<button
@ -546,49 +834,30 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: {
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
aria-label="More meeting options"
className="inline-flex items-center justify-center px-1.5 py-1 transition-colors"
style={{
background: 'var(--gm-bg-pill)',
color: 'var(--gm-text)',
border: '1px solid var(--gm-border)',
borderLeft: 'none',
borderTopRightRadius: 6,
borderBottomRightRadius: 6,
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }}
className="inline-flex items-center justify-center rounded-r-md border border-l-0 bg-background px-1.5 py-1.5 text-foreground transition-colors hover:bg-accent"
>
<ChevronDown className="size-3" />
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 50,
background: 'var(--gm-bg-card)',
border: '1px solid var(--gm-border)',
borderRadius: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
minWidth: 144,
overflow: 'hidden',
}}
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
className="flex w-full items-center gap-1 px-2 py-1.5 text-xs"
style={{ background: 'transparent', color: 'var(--gm-text)', whiteSpace: 'nowrap', border: 'none' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-row-hover)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<Mic className="size-3" />
Take notes only
</button>
</div>
)}
{open && menuPos
? createPortal(
<div
ref={menuRef}
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right, zIndex: 60 }}
className="min-w-36 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg"
>
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setOpen(false); onNotesOnly() }}
className="flex w-full items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs transition-colors hover:bg-accent"
>
<Mic className="size-3" />
Take notes only
</button>
</div>,
document.body,
)
: null}
</div>
)
}

View file

@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) {
heading: {
levels: [1, 2, 3],
},
link: false,
}),
Link.configure({
openOnClick: true,

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import {
Dialog,
@ -110,6 +110,19 @@ function HelpSettings() {
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"

View file

@ -17,11 +17,44 @@ import {
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps {
dialogOpen: boolean
}
function formatPlanName(plan: string | null | undefined) {
if (!plan) return 'No Plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
}
function CreditUsageBar({ label, bucket, helper }: {
label: string
bucket: BillingUsageBucket
helper?: string
}) {
const pct = bucket.sanctionedCredits > 0
? Math.min(100, Math.max(0, Math.round((bucket.usedCredits / bucket.sanctionedCredits) * 100)))
: 0
return (
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{helper ? <p className="text-[11px] text-muted-foreground">{helper}</p> : null}
</div>
<p className="shrink-0 text-xs font-medium tabular-nums">
{pct}%
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [connectionLoading, setConnectionLoading] = useState(true)
@ -164,7 +197,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
{formatPlanName(billing.subscriptionPlan)}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -179,14 +212,19 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
{!billing.subscriptionPlan && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
{billing.subscriptionPlan === 'free' && (
<p className="text-xs text-muted-foreground">Free usage resets daily at 00:00 UTC.</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">
<CreditUsageBar label="Plan usage" bucket={billing.monthly} />
<CreditUsageBar
label="Daily use"
bucket={billing.daily}
helper="Daily usage resets at 00:00 UTC"
/>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">Unable to load plan details</p>

View file

@ -59,6 +59,7 @@ import { cn } from "@/lib/utils"
import { SettingsDialog } from "@/components/settings-dialog"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "@/lib/toast"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
@ -95,6 +96,11 @@ function displayNoteName(node: TreeNode): string {
return node.name
}
function formatBillingPlanName(plan: string | null | undefined) {
if (!plan) return 'No plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
}
function formatAgo(ms: number): string {
const diffMs = Math.max(0, Date.now() - ms)
const min = Math.floor(diffMs / 60000)
@ -472,7 +478,7 @@ export function SidebarContentPanel({
const loadNext = async () => {
try {
const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' })
if (!exists.exists) { if (!cancelled) setNextMeetingLabel(null); return }
if (!exists.exists) { if (!cancelled) setMeetings([]); return }
const entries = await window.ipc.invoke('workspace:readdir', {
path: 'calendar_sync',
opts: { recursive: false, includeHidden: false, includeStats: false },
@ -920,7 +926,7 @@ export function SidebarContentPanel({
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} plan` : 'No plan'}
{formatBillingPlanName(billing.subscriptionPlan)}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -998,7 +1004,7 @@ export function SidebarContentPanel({
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setOpenConnectorsAfterClose(false)
setOpenConnectionsAfterClose(false)
setShowOauthAlert(false)
}}
>
@ -1410,4 +1416,3 @@ function formatEmailFrom(from: string): string {
if (match) return match[1].trim()
return from
}

View file

@ -1,5 +1,5 @@
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
@ -25,9 +25,12 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
for (const match of matches) {
const matchIndex = match.index ?? 0
const matchText = match[0] ?? ''
const rawPath = match[1]?.trim() ?? ''
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
const rawLink = match[1]?.trim() ?? ''
const { label } = splitWikiAlias(rawLink)
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
if (matchIndex > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
@ -35,7 +38,8 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
if (isValidPath) {
const el = document.createElement('wiki-link')
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
if (label) el.setAttribute('data-label', label)
fragment.appendChild(el)
} else {
fragment.appendChild(document.createTextNode(matchText))
@ -80,6 +84,9 @@ export const WikiLink = Node.create<WikiLinkOptions>({
path: {
default: '',
},
label: {
default: null,
},
}
},
@ -89,28 +96,34 @@ export const WikiLink = Node.create<WikiLinkOptions>({
tag: 'wiki-link[data-path]',
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
{
tag: 'a[data-type="wiki-link"]',
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
]
},
renderHTML({ node, HTMLAttributes }) {
const label = wikiLabel(node.attrs.path) || node.attrs.path
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
return [
'a',
mergeAttributes(HTMLAttributes, {
'data-type': 'wiki-link',
'data-path': node.attrs.path,
'href': '#',
'class': 'wiki-link',
'aria-label': node.attrs.path,
}),
mergeAttributes(
HTMLAttributes,
{
'data-type': 'wiki-link',
'data-path': node.attrs.path,
'href': '#',
'class': 'wiki-link',
'aria-label': node.attrs.path,
},
node.attrs.label ? { 'data-label': node.attrs.label } : {}
),
label,
]
},
@ -120,7 +133,8 @@ export const WikiLink = Node.create<WikiLinkOptions>({
markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
const path = node.attrs.path ?? ''
state.write(`[[${path}]]`)
const label = (node.attrs as { label?: string }).label
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
},
parse: {
updateDOM(element: HTMLElement) {
@ -137,14 +151,20 @@ export const WikiLink = Node.create<WikiLinkOptions>({
new InputRule({
find: wikiLinkInputRegex,
handler: ({ state, range, match }) => {
const rawPath = match[1]?.trim()
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
const rawLink = match[1]?.trim()
const { label } = splitWikiAlias(rawLink ?? '')
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
if (
!normalizedPath
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
) return null
if (state.selection.$from.parent.type.spec.code) return null
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
const finalPath = ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
onCreate?.(finalPath)
},
}),

View file

@ -8,7 +8,7 @@ export const BILLING_ERROR_PATTERNS = [
{
pattern: /not enough credits/i,
title: "You've run out of credits",
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
cta: 'Upgrade plan',
},
{

View file

@ -479,19 +479,19 @@ export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardD
// Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = {
'workspace-readFile': 'Reading file',
'workspace-writeFile': 'Writing file',
'workspace-edit': 'Editing file',
'workspace-readdir': 'Reading directory',
'workspace-exists': 'Checking path',
'workspace-stat': 'Getting file info',
'workspace-glob': 'Finding files',
'workspace-grep': 'Searching files',
'workspace-mkdir': 'Creating directory',
'workspace-rename': 'Renaming',
'workspace-copy': 'Copying file',
'workspace-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root',
'file-readText': 'Reading file',
'file-writeText': 'Writing file',
'file-editText': 'Editing file',
'file-list': 'Reading directory',
'file-exists': 'Checking path',
'file-stat': 'Getting file info',
'file-glob': 'Finding files',
'file-grep': 'Searching files',
'file-mkdir': 'Creating directory',
'file-rename': 'Renaming',
'file-copy': 'Copying file',
'file-remove': 'Removing',
'file-getRoot': 'Getting file root',
'loadSkill': 'Loading skill',
'parseFile': 'Parsing file',
'LLMParse': 'Extracting content',

View file

@ -3,24 +3,50 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
export const stripKnowledgePrefix = (path: string) =>
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
export const splitWikiAlias = (input: string) => {
const separatorIndex = input.indexOf('|')
if (separatorIndex === -1) return { target: input, label: undefined }
const target = input.slice(0, separatorIndex)
const label = input.slice(separatorIndex + 1).trim()
return { target, label: label || undefined }
}
export const splitWikiFragment = (path: string) => {
const hashIndex = path.indexOf('#')
if (hashIndex === -1) return { path: path, heading: undefined }
const basePath = path.slice(0, hashIndex)
const heading = path.slice(hashIndex + 1).trim()
return { path: basePath, heading: heading || undefined }
}
export const normalizeWikiPath = (input: string) => {
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
const { target } = splitWikiAlias(input)
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
return stripKnowledgePrefix(trimmed)
}
export const ensureMarkdownExtension = (path: string) => {
if (path.toLowerCase().endsWith('.md')) return path
return `${path}.md`
const { path: basePath, heading } = splitWikiFragment(path)
if (!basePath) return heading ? `#${heading}` : path
const filePath = basePath.toLowerCase().endsWith('.md') ? basePath : `${basePath}.md`
return heading ? `${filePath}#${heading}` : filePath
}
export const toKnowledgePath = (wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
}
export const wikiLabel = (wikiPath: string) => {
const { label } = splitWikiAlias(wikiPath)
if (label) return label
const normalized = normalizeWikiPath(wikiPath)
const name = normalized.split('/').pop() || normalized
const { path: basePath, heading } = splitWikiFragment(normalized)
if (!basePath && heading) return heading
const name = (basePath || normalized).split('/').pop() || normalized
return name.replace(/\.md$/i, '')
}

View file

@ -5,8 +5,10 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc",
"dev": "tsc -w"
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -w -p tsconfig.build.json",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.63",
@ -29,8 +31,8 @@
"express": "^5.2.1",
"glob": "^13.0.0",
"google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
"googleapis": "^169.0.0",
"isomorphic-git": "^1.29.0",
"mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
@ -48,6 +50,7 @@
"@types/express": "^5.0.6",
"@types/node": "^25.0.3",
"@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5"
"@types/pdf-parse": "^1.1.5",
"vitest": "catalog:"
}
}

View file

@ -8,12 +8,14 @@ import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"
import { z } from "zod";
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 { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { buildCopilotAgent } from "../application/assistant/agent.js";
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
import { buildBackgroundTaskAgent } from "../background-tasks/agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
@ -38,6 +40,131 @@ import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
}
function commonPathPrefix(paths: string[]): string {
if (!paths.length) return path.resolve(WorkDir);
const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean));
const first = split[0];
const common: string[] = [];
for (let i = 0; i < first.length; i++) {
if (split.every(parts => parts[i] === first[i])) {
common.push(first[i]);
} else {
break;
}
}
const prefix = `${path.sep}${common.join(path.sep)}`;
return prefix === path.sep ? prefix : path.resolve(prefix);
}
function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string {
if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') {
return commonPathPrefix(resolvedPaths);
}
const parentPaths = resolvedPaths.map(p => path.dirname(p));
return commonPathPrefix(parentPaths);
}
function filePermissionTargets(toolName: string, args: Record<string, unknown>): { operation: FileAccessOperation; paths: string[] } | null {
const pathArg = typeof args.path === 'string' ? args.path : undefined;
switch (toolName) {
case 'file-readText':
case 'parseFile':
case 'LLMParse':
case 'file-exists':
case 'file-stat':
return pathArg ? { operation: 'read', paths: [pathArg] } : null;
case 'file-list':
return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null;
case 'file-glob':
return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] };
case 'file-grep':
return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] };
case 'file-writeText':
case 'file-editText':
case 'file-mkdir':
return pathArg ? { operation: 'write', paths: [pathArg] } : null;
case 'file-copy':
case 'file-rename': {
const from = typeof args.from === 'string' ? args.from : undefined;
const to = typeof args.to === 'string' ? args.to : undefined;
return from && to ? { operation: 'write', paths: [from, to] } : null;
}
case 'file-remove':
return pathArg ? { operation: 'delete', paths: [pathArg] } : null;
default:
return null;
}
}
async function getToolPermissionMetadata(
toolCall: z.infer<typeof ToolCallPart>,
underlyingTool: z.infer<typeof ToolAttachment>,
sessionAllowedCommands: Set<string>,
sessionAllowedFileAccess: FileAccessGrant[],
): Promise<ToolPermissionMetadataValue | null> {
if (underlyingTool.type !== 'builtin') {
return null;
}
if (underlyingTool.name === 'executeCommand') {
const args = toolCall.arguments;
if (!args || typeof args !== 'object' || !('command' in args)) {
return null;
}
const command = String((args as { command: unknown }).command);
if (!isBlocked(command, sessionAllowedCommands)) {
return null;
}
return {
kind: 'command',
commandNames: extractCommandNames(command),
};
}
const args = toolCall.arguments && typeof toolCall.arguments === 'object'
? toolCall.arguments as Record<string, unknown>
: {};
const targets = filePermissionTargets(underlyingTool.name, args);
if (!targets) {
return null;
}
const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p)));
const outsideWorkspacePaths = resolvedTargets
.filter(target => !target.isInsideWorkspace)
.map(target => target.canonicalPath);
if (!outsideWorkspacePaths.length) {
return null;
}
const persistentGrants = getFileAccessAllowList();
const allGrants = [...persistentGrants, ...sessionAllowedFileAccess];
const uncovered = outsideWorkspacePaths.filter(resolvedPath =>
!allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath))
);
if (!uncovered.length) {
return null;
}
return {
kind: 'file',
operation: targets.operation,
paths: uncovered,
pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered),
};
}
function loadUserWorkDir(): string | null {
try {
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
@ -95,7 +222,7 @@ function loadAgentNotesContext(): string | null {
} 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')}`);
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. 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;
@ -683,6 +810,7 @@ export class AgentState {
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
sessionAllowedCommands: Set<string> = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = [];
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
@ -828,6 +956,15 @@ export class AgentState {
switch (event.response) {
case "approve":
this.allowedToolCallIds[event.toolCallId] = true;
{
const permissionRequest = this.pendingToolPermissionRequests[event.toolCallId];
if (event.scope === "session" && permissionRequest?.permission?.kind === "file") {
this.sessionAllowedFileAccess.push({
operation: permissionRequest.permission.operation,
pathPrefix: permissionRequest.permission.pathPrefix,
});
}
}
// For session scope, extract command names and add to session allowlist
if (event.scope === "session") {
const toolCall = this.toolCallIdMap[event.toolCallId];
@ -1135,10 +1272,10 @@ Treat this as the **default location** for file operations whenever the user ref
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
@ -1236,17 +1373,21 @@ Do not announce the work directory unless it's relevant. Just use it.`;
subflow: [],
});
}
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
subflow: [],
});
}
const permission = await getToolPermissionMetadata(
part,
underlyingTool,
state.sessionAllowedCommands,
state.sessionAllowedFileAccess,
);
if (permission) {
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
yield* processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
permission,
subflow: [],
});
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);

View file

@ -140,39 +140,39 @@ Users can interact with the knowledge graph through you, open it directly in Obs
**CRITICAL PATH REQUIREMENT:**
- 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 any absolute path to the workspace root
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
- When searching knowledge, ALWAYS include \`knowledge/\` in the search path
- **WRONG:** \`file-grep({ pattern: "John", searchPath: "" })\` or \`searchPath: "."\` or any absolute path to the workspace root
- **CORRECT:** \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
Use the builtin workspace tools to search and read the knowledge base:
Use the builtin file tools to search and read the knowledge base:
**Finding notes:**
\`\`\`
# List all people notes
workspace-readdir("knowledge/People")
file-list("knowledge/People")
# Search for a person by name - MUST include knowledge/ in path
workspace-grep({ pattern: "Sarah Chen", path: "knowledge/" })
file-grep({ pattern: "Sarah Chen", searchPath: "knowledge/" })
# Find notes mentioning a company - MUST include knowledge/ in path
workspace-grep({ pattern: "Acme Corp", path: "knowledge/" })
file-grep({ pattern: "Acme Corp", searchPath: "knowledge/" })
\`\`\`
**Reading notes:**
\`\`\`
# Read a specific person's note
workspace-readFile("knowledge/People/Sarah Chen.md")
file-readText("knowledge/People/Sarah Chen.md")
# Read an organization note
workspace-readFile("knowledge/Organizations/Acme Corp.md")
file-readText("knowledge/Organizations/Acme Corp.md")
\`\`\`
**When a user mentions someone by name:**
1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\`
1. First, search for them: \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
2. Read their note to get full context: \`file-readText("knowledge/People/John Smith.md")\`
3. Use the context (role, organization, past interactions, commitments) in your response
**NEVER use an empty path or root path. ALWAYS set path to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
**NEVER use an empty search path or root path for knowledge lookup. ALWAYS set searchPath to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
## When to Access the Knowledge Graph
@ -237,29 +237,23 @@ ${toolPriority}
${runtimeContextPrompt}
## Workspace Access & Scope
- **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 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 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.
## File Access & Scope
- Use builtin file tools (\`file-readText\`, \`file-writeText\`, \`file-editText\`, etc.) for normal file work anywhere on the user's machine.
- Relative paths resolve against the Rowboat workspace root. Use paths like \`knowledge/People/Ada.md\` for knowledge files.
- Use absolute paths or \`~/...\` paths when the user refers to Desktop, Downloads, Documents, the injected work directory, or any other location outside the Rowboat workspace.
- File operations inside the Rowboat workspace normally run without approval. File operations outside the workspace may trigger a permission prompt; this is expected.
- Do NOT use \`executeCommand\` just to read, write, edit, list, search, move, copy, or remove files. Use file tools and let the permission system handle access.
- Do NOT read binary files as text. Use \`parseFile\` or \`LLMParse\` for PDFs, Office docs, images, scanned docs, presentations, and other non-text formats.
- Do NOT access files outside the workspace unless the user explicitly asks you to or the current task clearly requires it.
- Load the \`organize-files\` skill for guidance on file organization tasks.
## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval:
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
**IMPORTANT**: Rowboat provides builtin tools:
- \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
- \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
- \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute, ~/..., or relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images.
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
@ -270,23 +264,21 @@ ${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with fu
- \`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 the workspace root, always use these instead of \`executeCommand\`.
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
**Shell commands via \`executeCommand\`:**
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
- You can run shell commands 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 the workspace root (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. Prefer file tools for file changes.
**CRITICAL: MCP Server Configuration**
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
- NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers
- NEVER manually edit \`config/mcp.json\` using \`file-writeText\` for MCP servers
- Invalid MCP configs will prevent the agent from starting with validation errors
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
Rowboat's internal builtin tools never require approval only shell commands via \`executeCommand\` do.
File tools and \`executeCommand\` can both go through the approval flow depending on the path or command. If you need to delete a file, use \`file-remove\`, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`file-writeText\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
## File Path References

View file

@ -14,7 +14,7 @@ Open a specific knowledge file in the editor pane.
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
**Tips:**
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
- Use ` + "`file-grep`" + ` first to find the exact path if you're unsure of the filename.
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
### open-view

View file

@ -25,11 +25,11 @@ Mixed instructions ("summarize and email it") trigger both.
You have three dedicated builtin tools for this skill:
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`workspace-edit\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`file-editText\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
- \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
- \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content.
To inspect what tasks already exist, use \`workspace-glob\` on \`bg-tasks/*/task.yaml\` and \`workspace-readFile\` on candidates. The user's bg-tasks folder is workspace-relative.
To inspect what tasks already exist, use \`file-glob\` on \`bg-tasks/*/task.yaml\` and \`file-readText\` on candidates. The user's bg-tasks folder is workspace-relative.
## Mode: act-first

View file

@ -158,26 +158,26 @@ Pass the paper URL to the summariser. Don't ask for human input.
## Additional Builtin Tools
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`.
While \`executeCommand\` is useful for CLI tools and shell workflows, builtin file tools exist for normal file management. Use \`file-*\` tools for reading, writing, editing, listing, searching, moving, copying, and removing files instead of shell commands.
### Copilot-Specific Builtin Tools
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration:
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with file management, app workflows, and MCP integration:
#### File & Directory Operations
- \`workspace-readdir\` - List directory contents (supports recursive exploration)
- \`workspace-readFile\` - Read file contents
- \`workspace-writeFile\` - Create or update file contents
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites)
- \`workspace-remove\` - Remove files or directories
- \`workspace-exists\` - Check if a file or directory exists
- \`workspace-stat\` - Get file/directory statistics
- \`workspace-mkdir\` - Create directories
- \`workspace-rename\` - Rename or move files/directories
- \`workspace-copy\` - Copy files
- \`workspace-getRoot\` - Get workspace root directory path
- \`workspace-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- \`workspace-grep\` - Search file contents using regex, returns matching files and lines
- \`file-list\` - List directory contents (supports recursive exploration)
- \`file-readText\` - Read file contents
- \`file-writeText\` - Create or update file contents
- \`file-editText\` - Make precise edits by replacing specific text (safer than full rewrites)
- \`file-remove\` - Remove files or directories
- \`file-exists\` - Check if a file or directory exists
- \`file-stat\` - Get file/directory statistics
- \`file-mkdir\` - Create directories
- \`file-rename\` - Rename or move files/directories
- \`file-copy\` - Copy files
- \`file-getRoot\` - Get the default root for relative file paths
- \`file-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- \`file-grep\` - Search file contents using regex, returns matching files and lines
#### Agent Operations
- \`analyzeAgent\` - Read and analyze an agent file structure

View file

@ -78,19 +78,19 @@ Map each point to a slide layout from the Available Layout Types below. For a ty
## Workflow
1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc.
1. Use file-readText to check knowledge/ for relevant context about the company, product, team, etc.
2. Ensure Playwright is installed: \`npm install playwright && npx playwright install chromium\`
3. Use workspace-getRoot to get the workspace root path.
3. Use file-getRoot to get the workspace root path.
4. Plan the narrative arc and slide outline (see Content Planning above).
5. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
5. Use file-writeText to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
6. **Perform the Post-Generation Validation (see below). Fix any issues before proceeding.**
7. Use workspace-writeFile to create the conversion script at tmp/convert.js (workspace-relative) see Playwright Export section.
7. Use file-writeText to create the conversion script at tmp/convert.js (workspace-relative) see Playwright Export section.
8. Run it: \`node <WORKSPACE_ROOT>/tmp/convert.js\`
9. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" and note the theme used.
**Critical**: Never show HTML code to the user. Never ask the user to run commands, install packages, or make technical decisions. The entire pipeline from content to PDF must be invisible to the user.
Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT use executeCommand to write or read files.
Use file-writeText and file-readText for ALL file operations. Do NOT use executeCommand to write or read files.
## Post-Generation Validation (REQUIRED)
@ -142,14 +142,14 @@ html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !
## Playwright Export
\`\`\`javascript
// save as tmp/convert.js via workspace-writeFile
// save as tmp/convert.js via file-writeText
const { chromium } = require('playwright');
const path = require('path');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// Replace <WORKSPACE_ROOT> with the actual absolute path from workspace-getRoot
// Replace <WORKSPACE_ROOT> with the actual absolute path from file-getRoot
await page.goto('file://<WORKSPACE_ROOT>/tmp/presentation.html', { waitUntil: 'networkidle' });
await page.pdf({
path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'),
@ -162,7 +162,7 @@ const path = require('path');
})();
\`\`\`
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by workspace-getRoot.
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by file-getRoot.
## Available Layout Types (35 Templates)

View file

@ -22,7 +22,7 @@ You are an expert document assistant helping the user create, edit, and refine d
## CRITICAL: Re-read Before Every Response
**Before every response, you MUST use workspace-readFile to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version.
**Before every response, you MUST use file-readText to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version.
## Core Principles
@ -55,12 +55,12 @@ When the user mentions a document name, search for it using multiple approaches:
1. **Search by name pattern** (handles partial matches, different cases):
\`\`\`
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" })
file-glob({ pattern: "**/*[name]*", cwd: "knowledge/" })
\`\`\`
2. **Search by content** (finds docs that mention the topic):
\`\`\`
workspace-grep({ pattern: "[name]", path: "knowledge/" })
file-grep({ pattern: "[name]", searchPath: "knowledge/" })
\`\`\`
3. **Try common variations:**
@ -106,7 +106,7 @@ workspace-createFile({
**Types of requests:**
1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section"
Make the edit immediately using workspace-editFile
Make the edit immediately using file-editText
2. **Content generation** - "Write an intro", "Draft the executive summary", "Add a section about our approach"
Generate the content and add it to the document
@ -122,21 +122,21 @@ workspace-createFile({
### Step 3: Execute Changes
**For edits, use workspace-editFile:**
**For edits, use file-editText:**
\`\`\`
workspace-editFile({
file-editText({
path: "knowledge/[path].md",
old_string: "[exact text to replace]",
new_string: "[new text]"
oldString: "[exact text to replace]",
newString: "[new text]"
})
\`\`\`
**For additions at the end:**
\`\`\`
workspace-editFile({
file-editText({
path: "knowledge/[path].md",
old_string: "[last line or section]",
new_string: "[last line or section]\n\n[new content]"
oldString: "[last line or section]",
newString: "[last line or section]\n\n[new content]"
})
\`\`\`
@ -156,14 +156,14 @@ When the user mentions people, companies, or projects:
**Search for relevant notes:**
\`\`\`
workspace-grep({ pattern: "[Name]", path: "knowledge/" })
file-grep({ pattern: "[Name]", searchPath: "knowledge/" })
\`\`\`
**Read relevant notes:**
\`\`\`
workspace-readFile("knowledge/People/[Person].md")
workspace-readFile("knowledge/Organizations/[Company].md")
workspace-readFile("knowledge/Projects/[Project].md")
file-readText("knowledge/People/[Person].md")
file-readText("knowledge/Organizations/[Company].md")
file-readText("knowledge/Projects/[Project].md")
\`\`\`
**Use the context:**
@ -237,7 +237,7 @@ Renders a styled table from structured data.
### Block Guidelines
- The JSON must be valid and on a single line (no pretty-printing)
- Insert blocks using \`workspace-editFile\` just like any other content
- Insert blocks using \`file-editText\` just like any other content
- When the user asks for a chart, table, embed, or live dashboard use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`

View file

@ -16,11 +16,11 @@ When the user says "draft an email to Monica" or mentions ANY person, organizati
1. **STOP** - Do not draft anything yet
2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`):
\`\`\`
workspace-grep({ pattern: "Monica", path: "knowledge/" })
file-grep({ pattern: "Monica", searchPath: "knowledge/" })
\`\`\`
3. **READ** - Read their note to understand who they are:
\`\`\`
workspace-readFile("knowledge/People/Monica Smith.md")
file-readText("knowledge/People/Monica Smith.md")
\`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN DRAFT** - Only now draft the email, using this context
@ -133,19 +133,19 @@ Before drafting, gather relevant context. **Always check the knowledge base firs
First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`):
\`\`\`
# Search for the sender by name or email
workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" })
file-grep({ pattern: "sender_name_or_email", searchPath: "knowledge/" })
# List all people to find potential matches
workspace-readdir("knowledge/People")
file-list("knowledge/People")
\`\`\`
Then read the relevant notes:
\`\`\`
# Read the sender's note
workspace-readFile("knowledge/People/Sender Name.md")
file-readText("knowledge/People/Sender Name.md")
# Read their organization's note
workspace-readFile("knowledge/Organizations/Company Name.md")
file-readText("knowledge/Organizations/Company Name.md")
\`\`\`
Extract from these notes:

View file

@ -34,7 +34,7 @@ When this skill is loaded, your job is: make a passive note live (or extend the
## Mode: act-first (non-negotiable on strong signals)
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`file-editText\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
What you must NOT do on a strong-signal ask:
- Don't ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic doc editing, not live notes.
@ -77,7 +77,7 @@ When a strong signal lands without a specific note attached, pick the folder by
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
**Before creating**: \`file-grep\` and \`file-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
### Default cadence picker (when the user didn't specify timing)
@ -165,8 +165,8 @@ When skipping a re-run (because the user said not to or "later"):
**User:** "i want to set up a news feed to track news for India and the world."
**Right behaviour** (one turn):
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
1. \`file-grep({ pattern: "News Feed", searchPath: "knowledge/Notes/" })\` — search for an existing match.
2. \`file-grep({ pattern: "news", searchPath: "knowledge/Notes/" })\` — broader search to catch variants.
3. No match found create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
@ -460,16 +460,16 @@ live:
### Making a passive note live (no \`live:\` block yet)
1. \`workspace-readFile({ path })\` — re-read fresh.
1. \`file-readText({ path })\` — re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
3. \`workspace-edit\`:
3. \`file-editText\`:
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
### Extending an already-live note
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
1. \`file-readText({ path })\` — fetch the current \`live.objective\`.
2. Edit the \`objective\` value via \`file-editText\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
### Sidebar chat with a specific note
@ -606,17 +606,17 @@ The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, err
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
- **Don't use \`file-writeText\`** to rewrite the whole file — always \`file-editText\` with a unique anchor.
## Editing or Removing an Existing Live Note
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
**Change the objective:** \`file-editText\` the \`objective\` value (use \`|\` block scalar).
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
**Change triggers:** \`file-editText\` the relevant sub-field of the \`triggers\` object.
**Pause without removing:** flip \`active: false\`.
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
**Make passive (remove the \`live:\` block):** \`file-editText\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference

View file

@ -38,7 +38,7 @@ export const skill = String.raw`
**ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
**NEVER manually create or edit \`config/mcp.json\`** using \`workspace-writeFile\` for MCP servers—this bypasses validation and will cause errors.
**NEVER manually create or edit \`config/mcp.json\`** using \`file-writeText\` for MCP servers—this bypasses validation and will cause errors.
### MCP Server Configuration Schema

View file

@ -16,12 +16,12 @@ When the user asks to prep for a meeting or mentions attendees:
1. **STOP** - Do not create a generic brief
2. **SEARCH** - Look up each attendee in the knowledge base:
\`\`\`
workspace-grep({ pattern: "Attendee Name", path: "knowledge/" })
file-grep({ pattern: "Attendee Name", searchPath: "knowledge/" })
\`\`\`
3. **READ** - Read their notes to understand who they are:
\`\`\`
workspace-readFile("knowledge/People/Attendee Name.md")
workspace-readFile("knowledge/Organizations/Their Company.md")
file-readText("knowledge/People/Attendee Name.md")
file-readText("knowledge/Organizations/Their Company.md")
\`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN BRIEF** - Only now create the meeting brief, using this context
@ -68,13 +68,13 @@ For each attendee, search the knowledge base (path MUST be \`knowledge/\`):
**Search People notes:**
\`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" })
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" })
file-grep({ pattern: "attendee_name", searchPath: "knowledge/People/" })
file-grep({ pattern: "attendee_email", searchPath: "knowledge/People/" })
\`\`\`
If a person note exists, read it:
\`\`\`
workspace-readFile("knowledge/People/Attendee Name.md")
file-readText("knowledge/People/Attendee Name.md")
\`\`\`
Extract:
@ -86,13 +86,13 @@ Extract:
**Search Organization notes:**
\`\`\`
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" })
file-grep({ pattern: "company_name", searchPath: "knowledge/Organizations/" })
\`\`\`
**Search Projects:**
\`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" })
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" })
file-grep({ pattern: "attendee_name", searchPath: "knowledge/Projects/" })
file-grep({ pattern: "company_name", searchPath: "knowledge/Projects/" })
\`\`\`
### Step 4: Create Meeting Brief

View file

@ -58,7 +58,7 @@ Use these as the \`link\` parameter to land the user on a specific view in Rowbo
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
The \`type=file\` path is workspace-relative (the same path you'd pass to \`file-readText\`).
## Anti-patterns
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.

View file

@ -1,17 +1,14 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as fs from "fs/promises";
import { createReadStream, existsSync, readFileSync } from "fs";
import { createInterface } from "readline";
import { execSync } from "child_process";
import { glob } from "glob";
import { existsSync, readFileSync } from "fs";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
import { McpServerDefinition } from "@x/shared/dist/mcp.js";
import * as workspace from "../../workspace/workspace.js";
import * as files from "../../filesystem/files.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
@ -156,12 +153,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-getRoot': {
description: 'Get the workspace root directory path',
'file-getRoot': {
description: 'Get the default root directory for relative file paths. Relative paths passed to file tools resolve against this directory.',
inputSchema: z.object({}),
execute: async () => {
try {
return await workspace.getRoot();
return { root: WorkDir };
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -170,14 +167,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-exists': {
description: 'Check if a file or directory exists in the workspace',
'file-exists': {
description: 'Check if a file or directory exists. Accepts absolute paths, ~/ paths, or paths relative to the default root.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to check'),
path: z.string().min(1).describe('File or directory path to check'),
}),
execute: async ({ path: relPath }: { path: string }) => {
execute: async ({ path: filePath }: { path: string }) => {
try {
return await workspace.exists(relPath);
return await files.exists(filePath);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -186,14 +183,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-stat': {
'file-stat': {
description: 'Get file or directory statistics (size, modification time, etc.)',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to stat'),
path: z.string().min(1).describe('File or directory path to stat'),
}),
execute: async ({ path: relPath }: { path: string }) => {
execute: async ({ path: filePath }: { path: string }) => {
try {
return await workspace.stat(relPath);
return await files.stat(filePath);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -202,22 +199,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-readdir': {
'file-list': {
description: 'List directory contents. Can recursively explore directory structure with options.',
inputSchema: z.object({
path: z.string().describe('Workspace-relative directory path (empty string for root)'),
path: z.string().describe('Directory path to list. Use "." for the default root.'),
recursive: z.boolean().optional().describe('Recursively list all subdirectories (default: false)'),
includeStats: z.boolean().optional().describe('Include file stats like size and modification time (default: false)'),
includeHidden: z.boolean().optional().describe('Include hidden files starting with . (default: false)'),
allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'),
}),
execute: async ({
path: relPath,
recursive,
includeStats,
includeHidden,
allowedExtensions
}: {
execute: async ({
path: filePath,
recursive,
includeStats,
includeHidden,
allowedExtensions
}: {
path: string;
recursive?: boolean;
includeStats?: boolean;
@ -225,13 +222,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
allowedExtensions?: string[];
}) => {
try {
const entries = await workspace.readdir(relPath || '', {
return await files.list(filePath || '.', {
recursive,
includeStats,
includeHidden,
allowedExtensions,
});
return entries;
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -240,120 +236,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-readFile': {
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`.',
'file-readText': {
description: 'Read a UTF-8 text file. Returns content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use `offset` and `limit` to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<resolvedPath>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers are display-only — do NOT include them when later writing or editing the file. Refuses binary files; use parseFile or LLMParse for documents, PDFs, images, and other non-text formats.',
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)'),
path: z.string().min(1).describe('Text file path to read'),
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1).'),
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000).'),
}),
execute: async ({
path: relPath,
path: filePath,
offset,
limit,
encoding = 'utf8',
}: {
path: string;
offset?: number;
limit?: number;
encoding?: 'utf8' | 'base64' | 'binary';
}) => {
try {
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,
};
return await files.readText(filePath, offset, limit);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -362,34 +262,30 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-writeFile': {
description: 'Write or update file contents in the workspace. Automatically creates parent directories and supports atomic writes.',
'file-writeText': {
description: 'Write or update UTF-8 text file contents. Automatically creates parent directories and supports atomic writes.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'),
data: z.string().describe('File content to write'),
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('Data encoding (default: utf8)'),
path: z.string().min(1).describe('Text file path to write'),
data: z.string().describe('UTF-8 text content to write'),
atomic: z.boolean().optional().describe('Use atomic write (default: true)'),
mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'),
}),
execute: async ({
path: relPath,
path: filePath,
data,
encoding,
atomic,
mkdirp,
expectedEtag
}: {
path: string;
data: string;
encoding?: 'utf8' | 'base64' | 'binary';
atomic?: boolean;
mkdirp?: boolean;
expectedEtag?: string;
}) => {
try {
return await workspace.writeFile(relPath, data, {
encoding,
return await files.writeText(filePath, data, {
atomic,
mkdirp,
expectedEtag,
@ -402,16 +298,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-edit': {
description: 'Make precise edits to a file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss.',
'file-editText': {
description: 'Make precise edits to a UTF-8 text file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss. Refuses binary files.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'),
path: z.string().min(1).describe('Text file path to edit'),
oldString: z.string().describe('Exact text to find and replace'),
newString: z.string().describe('Replacement text'),
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'),
}),
execute: async ({
path: relPath,
path: filePath,
oldString,
newString,
replaceAll = false
@ -422,46 +318,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
replaceAll?: boolean;
}) => {
try {
const result = await workspace.readFile(relPath, 'utf8');
const content = result.data;
const occurrences = content.split(oldString).length - 1;
if (occurrences === 0) {
return { error: 'oldString not found in file' };
}
if (occurrences > 1 && !replaceAll) {
return {
error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.`
};
}
const newContent = replaceAll
? content.replaceAll(oldString, newString)
: content.replace(oldString, newString);
await workspace.writeFile(relPath, newContent, { encoding: 'utf8' });
return {
success: true,
replacements: replaceAll ? occurrences : 1
};
return await files.editText(filePath, oldString, newString, replaceAll);
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
},
},
'workspace-mkdir': {
description: 'Create a directory in the workspace',
'file-mkdir': {
description: 'Create a directory',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative directory path'),
path: z.string().min(1).describe('Directory path to create'),
recursive: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
}),
execute: async ({ path: relPath, recursive = true }: { path: string; recursive?: boolean }) => {
execute: async ({ path: filePath, recursive = true }: { path: string; recursive?: boolean }) => {
try {
return await workspace.mkdir(relPath, recursive);
return await files.mkdir(filePath, recursive);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -470,16 +342,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-rename': {
description: 'Rename or move a file or directory in the workspace',
'file-rename': {
description: 'Rename or move a file or directory',
inputSchema: z.object({
from: z.string().min(1).describe('Source workspace-relative path'),
to: z.string().min(1).describe('Destination workspace-relative path'),
from: z.string().min(1).describe('Source path'),
to: z.string().min(1).describe('Destination path'),
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
}),
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
try {
return await workspace.rename(from, to, overwrite);
return await files.rename(from, to, overwrite);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -488,16 +360,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-copy': {
description: 'Copy a file in the workspace (directories not supported)',
'file-copy': {
description: 'Copy a file (directories not supported)',
inputSchema: z.object({
from: z.string().min(1).describe('Source workspace-relative file path'),
to: z.string().min(1).describe('Destination workspace-relative file path'),
from: z.string().min(1).describe('Source file path'),
to: z.string().min(1).describe('Destination file path'),
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
}),
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
try {
return await workspace.copy(from, to, overwrite);
return await files.copy(from, to, overwrite);
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
@ -506,16 +378,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-remove': {
description: 'Remove a file or directory from the workspace. Files are moved to trash by default for safety.',
'file-remove': {
description: 'Remove a file or directory. Files are moved to the Rowboat trash by default for safety.',
inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to remove'),
path: z.string().min(1).describe('Path to remove'),
recursive: z.boolean().optional().describe('Required for directories (default: false)'),
trash: z.boolean().optional().describe('Move to trash instead of permanent delete (default: true)'),
}),
execute: async ({ path: relPath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
execute: async ({ path: filePath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
try {
return await workspace.remove(relPath, {
return await files.remove(filePath, {
recursive,
trash,
});
@ -527,45 +399,26 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'workspace-glob': {
'file-glob': {
description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.',
inputSchema: z.object({
pattern: z.string().describe('Glob pattern to match files'),
cwd: z.string().optional().describe('Subdirectory to search in, relative to workspace root (default: workspace root)'),
cwd: z.string().optional().describe('Directory to search in (default: default root)'),
}),
execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => {
try {
const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
// Ensure search directory is within workspace
const resolvedSearchDir = path.resolve(searchDir);
if (!resolvedSearchDir.startsWith(WorkDir)) {
return { error: 'Search directory must be within workspace' };
}
const files = await glob(pattern, {
cwd: searchDir,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
});
return {
files,
count: files.length,
pattern,
cwd: cwd || '.',
};
return await files.glob(pattern, cwd);
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
},
},
'workspace-grep': {
description: 'Search file contents using regex. Returns matching files and lines. Uses ripgrep if available, falls back to grep.',
'file-grep': {
description: 'Search text file contents using regex. Returns matching files and lines. Skips binary files.',
inputSchema: z.object({
pattern: z.string().describe('Regex pattern to search for'),
searchPath: z.string().optional().describe('Directory or file to search, relative to workspace root (default: workspace root)'),
searchPath: z.string().optional().describe('Directory or file to search (default: default root)'),
fileGlob: z.string().optional().describe('File pattern filter (e.g., "*.ts", "*.md")'),
contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'),
maxResults: z.number().optional().describe('Maximum results to return (default: 100)'),
@ -584,90 +437,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
maxResults?: number;
}) => {
try {
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir;
// Ensure target path is within workspace
const resolvedTargetPath = path.resolve(targetPath);
if (!resolvedTargetPath.startsWith(WorkDir)) {
return { error: 'Search path must be within workspace' };
}
// Try ripgrep first
try {
const rgArgs = [
'--json',
'-e', JSON.stringify(pattern),
contextLines > 0 ? `-C ${contextLines}` : '',
fileGlob ? `--glob ${JSON.stringify(fileGlob)}` : '',
`--max-count ${maxResults}`,
'--ignore-case',
JSON.stringify(resolvedTargetPath),
].filter(Boolean).join(' ');
const output = execSync(`rg ${rgArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
cwd: WorkDir,
});
const matches = output.trim().split('\n')
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(m => m && m.type === 'match');
return {
matches: matches.map(m => ({
file: path.relative(WorkDir, m.data.path.text),
line: m.data.line_number,
content: m.data.lines.text.trim(),
})),
count: matches.length,
tool: 'ripgrep',
};
} catch {
// Fallback to basic grep if ripgrep not available or failed
const grepArgs = [
'-rn',
fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '',
JSON.stringify(pattern),
JSON.stringify(resolvedTargetPath),
`| head -${maxResults}`,
].filter(Boolean).join(' ');
try {
const output = execSync(`grep ${grepArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
shell: '/bin/sh',
});
const lines = output.trim().split('\n').filter(Boolean);
return {
matches: lines.map(line => {
const match = line.match(/^(.+?):(\d+):(.*)$/);
if (match) {
return {
file: path.relative(WorkDir, match[1]),
line: parseInt(match[2], 10),
content: match[3].trim(),
};
}
return { file: '', line: 0, content: line };
}),
count: lines.length,
tool: 'grep',
};
} catch {
// No matches found (grep returns non-zero on no matches)
return { matches: [], count: 0, tool: 'grep' };
}
}
return await files.grep({ pattern, searchPath, fileGlob, contextLines, maxResults });
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
@ -677,7 +447,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
'parseFile': {
description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.',
inputSchema: z.object({
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'),
path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
}),
execute: async ({ path: filePath }: { path: string }) => {
try {
@ -692,14 +462,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
};
}
// Read file as buffer — support both absolute and workspace-relative paths
let buffer: Buffer;
if (path.isAbsolute(filePath)) {
buffer = await fs.readFile(filePath);
} else {
const result = await workspace.readFile(filePath, 'base64');
buffer = Buffer.from(result.data, 'base64');
}
const { buffer, resolvedPath } = await files.readBuffer(filePath);
if (ext === '.pdf') {
const { PDFParse } = await _importDynamic("pdf-parse");
@ -716,6 +479,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
pages: textResult.total,
title: infoResult.info?.Title || undefined,
author: infoResult.info?.Author || undefined,
resolvedPath,
},
};
} finally {
@ -785,7 +549,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
'LLMParse': {
description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).',
inputSchema: z.object({
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'),
path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'),
}),
execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => {
@ -801,14 +565,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
};
}
// Read file as buffer — support both absolute and workspace-relative paths
let buffer: Buffer;
if (path.isAbsolute(filePath)) {
buffer = await fs.readFile(filePath);
} else {
const result = await workspace.readFile(filePath, 'base64');
buffer = Buffer.from(result.data, 'base64');
}
const { buffer } = await files.readBuffer(filePath);
const base64 = buffer.toString('base64');
@ -1228,7 +985,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
case 'open-note': {
const filePath = input.path as string;
try {
const result = await workspace.exists(filePath);
const result = await files.exists(filePath);
if (!result.exists) {
return { success: false, error: `File not found: ${filePath}` };
}
@ -1256,15 +1013,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// Scan knowledge/ files and extract frontmatter properties
try {
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] });
const files = entries.filter(e => e.kind === 'file');
const entries = await files.list("knowledge", { recursive: true, allowedExtensions: [".md"] });
const noteFiles = entries.filter(e => e.kind === 'file');
const properties = new Map<string, Set<string>>();
let noteCount = 0;
for (const file of files) {
for (const file of noteFiles) {
try {
const { data } = await workspace.readFile(file.path);
const { fields } = parseFrontmatter(data);
const result = await fs.readFile(file.resolvedPath, 'utf8');
const { fields } = parseFrontmatter(result);
noteCount++;
for (const [key, value] of Object.entries(fields)) {
if (!value) continue;
@ -1309,7 +1066,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const basePath = `bases/${safeName}.base`;
try {
const config = { name: safeName, filters: [], columns: [] };
await workspace.writeFile(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
await files.writeText(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
return { success: true, action: 'create-base', name: safeName, path: basePath };
} catch (error) {
return {
@ -1655,7 +1412,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
'create-background-task': {
description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with workspace-edit, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.",
description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with file-editText, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.",
inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try {
@ -1675,7 +1432,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
'patch-background-task': {
description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `workspace-glob` on `bg-tasks/*/task.yaml` and `workspace-readFile` on the candidates first.",
description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `file-glob` on `bg-tasks/*/task.yaml` and `file-readText` on the candidates first.",
inputSchema: PatchBackgroundTaskInput,
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try {

View file

@ -75,9 +75,8 @@ const providerConfigs: ProviderConfig = {
mode: 'static',
},
scopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly',
],
},
'fireflies-ai': {
@ -119,4 +118,3 @@ export async function getProviderConfig(providerName: string): Promise<ProviderC
export function getAvailableProviders(): string[] {
return Object.keys(providerConfigs);
}

View file

@ -15,7 +15,7 @@ You are running with **no user present** to clarify, approve, or watch.
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
- \`index.md\` — agent-owned. You read and write this freely via \`workspace-readFile\` / \`workspace-edit\`.
- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
@ -26,7 +26,7 @@ OUTPUT MODE — keep \`index.md\` aligned to the instructions.
Use when instructions imply a **current state** artifact:
- "Maintain / show / summarize / track / digest of / dashboard for / brief on …"
- "Keep me posted on …" / "What's the latest on …"
On every run: \`workspace-readFile\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`workspace-edit\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
ACTION MODE perform a side-effect, append a journal entry.
Use when instructions imply a **recurring action**:

View file

@ -50,7 +50,7 @@ function buildMessage(
**Instructions:**
${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`workspace-readFile\` and update it with \`workspace-edit\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
return baseMessage + buildTriggerBlock({
trigger,

View file

@ -20,8 +20,17 @@ export async function getBillingInfo(): Promise<BillingInfo> {
status: string | null;
trialExpiresAt: string | null;
usage: {
sanctionedCredits: number;
availableCredits: number;
monthly: {
sanctionedCredits: number;
usedCredits: number;
availableCredits: number;
};
daily: {
sanctionedCredits: number;
usedCredits: number;
availableCredits: number;
usageDay: string;
};
};
};
};
@ -31,7 +40,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
sanctionedCredits: body.billing.usage.sanctionedCredits,
availableCredits: body.billing.usage.availableCredits,
monthly: body.billing.usage.monthly,
daily: body.billing.usage.daily,
};
}

View file

@ -42,26 +42,61 @@ const DEFAULT_ALLOW_LIST = [
"yq"
]
export type FileAccessOperation = "read" | "list" | "search" | "write" | "delete";
export type FileAccessGrant = {
operation: FileAccessOperation;
pathPrefix: string;
};
let cachedAllowList: string[] | null = null;
let cachedFileAccessAllowList: FileAccessGrant[] | null = null;
let cachedMtimeMs: number | null = null;
export async function addToSecurityConfig(commands: string[]): Promise<void> {
ensureSecurityConfigSync();
const current = readAllowList();
const merged = new Set(current);
const current = readSecurityConfig();
const merged = new Set(current.allowedCommands);
for (const cmd of commands) {
const normalized = cmd.trim().toLowerCase();
if (normalized) merged.add(normalized);
}
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n",
JSON.stringify({
allowedCommands: Array.from(merged).sort(),
allowedFileAccess: current.allowedFileAccess,
}, null, 2) + "\n",
"utf8",
);
// Reset cache so next read picks up the new file
resetSecurityAllowListCache();
}
export async function addFileAccessGrant(grant: FileAccessGrant): Promise<void> {
ensureSecurityConfigSync();
const current = readSecurityConfig();
const normalizedGrant = normalizeFileAccessGrant(grant);
const exists = current.allowedFileAccess.some(existing =>
existing.operation === normalizedGrant.operation
&& existing.pathPrefix === normalizedGrant.pathPrefix
);
const allowedFileAccess = exists
? current.allowedFileAccess
: [...current.allowedFileAccess, normalizedGrant].sort((a, b) =>
`${a.operation}:${a.pathPrefix}`.localeCompare(`${b.operation}:${b.pathPrefix}`)
);
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify({
allowedCommands: current.allowedCommands,
allowedFileAccess,
}, null, 2) + "\n",
"utf8",
);
resetSecurityAllowListCache();
}
/**
* Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs().
@ -103,28 +138,74 @@ function normalizeList(commands: unknown[]): string[] {
return Array.from(seen);
}
function parseSecurityPayload(payload: unknown): string[] {
function normalizeFileAccessGrant(grant: FileAccessGrant): FileAccessGrant {
return {
operation: grant.operation,
pathPrefix: path.resolve(grant.pathPrefix),
};
}
function normalizeFileAccessList(grants: unknown[]): FileAccessGrant[] {
const seen = new Set<string>();
const normalized: FileAccessGrant[] = [];
for (const entry of grants) {
if (!entry || typeof entry !== "object") continue;
const maybeGrant = entry as Record<string, unknown>;
const operation = maybeGrant.operation;
const pathPrefix = maybeGrant.pathPrefix;
if (
operation !== "read"
&& operation !== "list"
&& operation !== "search"
&& operation !== "write"
&& operation !== "delete"
) {
continue;
}
if (typeof pathPrefix !== "string" || !pathPrefix.trim()) continue;
const grant = normalizeFileAccessGrant({ operation, pathPrefix });
const key = `${grant.operation}:${grant.pathPrefix}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push(grant);
}
return normalized;
}
function parseSecurityPayload(payload: unknown): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
if (Array.isArray(payload)) {
return normalizeList(payload);
return { allowedCommands: normalizeList(payload), allowedFileAccess: [] };
}
if (payload && typeof payload === "object") {
const maybeObject = payload as Record<string, unknown>;
if (Array.isArray(maybeObject.allowedCommands)) {
return normalizeList(maybeObject.allowedCommands);
const allowedFileAccess = Array.isArray(maybeObject.allowedFileAccess)
? normalizeFileAccessList(maybeObject.allowedFileAccess)
: [];
if (Array.isArray(maybeObject.allowedCommands) || Array.isArray(maybeObject.allowedFileAccess)) {
return {
allowedCommands: Array.isArray(maybeObject.allowedCommands)
? normalizeList(maybeObject.allowedCommands)
: [],
allowedFileAccess,
};
}
const dynamicList = Object.entries(maybeObject)
.filter(([, value]) => Boolean(value))
.map(([key]) => key);
return normalizeList(dynamicList);
return {
allowedCommands: normalizeList(dynamicList),
allowedFileAccess,
};
}
return [];
return { allowedCommands: [], allowedFileAccess: [] };
}
function readAllowList(): string[] {
function readSecurityConfig(): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
ensureSecurityConfigSync();
try {
@ -133,10 +214,14 @@ function readAllowList(): string[] {
return parseSecurityPayload(parsed);
} catch (error) {
console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`);
return DEFAULT_ALLOW_LIST;
return { allowedCommands: DEFAULT_ALLOW_LIST, allowedFileAccess: [] };
}
}
function readAllowList(): string[] {
return readSecurityConfig().allowedCommands;
}
export function getSecurityAllowList(): string[] {
ensureSecurityConfigSync();
try {
@ -149,12 +234,32 @@ export function getSecurityAllowList(): string[] {
return cachedAllowList;
} catch {
cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null;
return readAllowList();
}
}
export function getFileAccessAllowList(): FileAccessGrant[] {
ensureSecurityConfigSync();
try {
const stats = fs.statSync(SECURITY_CONFIG_PATH);
if (cachedFileAccessAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedFileAccessAllowList;
}
cachedFileAccessAllowList = readSecurityConfig().allowedFileAccess;
cachedMtimeMs = stats.mtimeMs;
return cachedFileAccessAllowList;
} catch {
cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null;
return readSecurityConfig().allowedFileAccess;
}
}
export function resetSecurityAllowListCache() {
cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null;
}

View file

@ -0,0 +1,204 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let tmpDir: string;
let workspaceDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-files-test-"));
workspaceDir = path.join(tmpDir, "workspace");
process.env.ROWBOAT_WORKDIR = workspaceDir;
vi.resetModules();
vi.doMock("../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
});
afterEach(async () => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../knowledge/version_history.js");
vi.doUnmock("../knowledge/deprecate_today_note.js");
vi.resetModules();
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function loadFiles() {
return import("./files.js");
}
describe("filesystem files", () => {
it("resolves relative paths inside ROWBOAT_WORKDIR", async () => {
const files = await loadFiles();
const resolved = files.resolveFilePath("notes/example.md");
expect(resolved.originalPath).toBe("notes/example.md");
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "notes", "example.md"));
expect(resolved.isInsideWorkspace).toBe(true);
expect(resolved.workspaceRelPath).toBe("notes/example.md");
});
it("keeps absolute paths outside the workspace absolute", async () => {
const files = await loadFiles();
const absolutePath = path.join(tmpDir, "outside.txt");
const resolved = files.resolveFilePath(absolutePath);
expect(resolved.resolvedPath).toBe(absolutePath);
expect(resolved.isInsideWorkspace).toBe(false);
expect(resolved.workspaceRelPath).toBeNull();
});
it("expands home-relative paths", async () => {
const files = await loadFiles();
const resolved = files.resolveFilePath("~/rowboat-test.txt");
expect(resolved.resolvedPath).toBe(path.join(os.homedir(), "rowboat-test.txt"));
expect(resolved.isInsideWorkspace).toBe(false);
});
it("canonicalizes symlinked paths for permission checks", async () => {
const files = await loadFiles();
const externalDir = path.join(tmpDir, "external");
const linkPath = path.join(workspaceDir, "linked");
await fs.mkdir(externalDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
try {
await fs.symlink(externalDir, linkPath, "dir");
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
return;
}
throw error;
}
const canonicalExternalDir = await fs.realpath(externalDir);
const resolved = await files.resolveFilePathForPermission("linked/new-file.txt");
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "linked", "new-file.txt"));
expect(resolved.canonicalPath).toBe(path.join(canonicalExternalDir, "new-file.txt"));
expect(resolved.isInsideWorkspace).toBe(false);
expect(resolved.workspaceRelPath).toBeNull();
});
it("reads text with line numbers and pagination metadata", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "readme.txt"), "alpha\nbeta\ngamma\n", "utf8");
const result = await files.readText("readme.txt", 2, 1);
expect(result.path).toBe("readme.txt");
expect(result.encoding).toBe("utf8");
expect(result.offset).toBe(2);
expect(result.limit).toBe(1);
expect(result.totalLines).toBe(3);
expect(result.hasMore).toBe(true);
expect(result.content).toContain("2: beta");
});
it("rejects files containing NUL bytes as binary", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x61, 0x00, 0x62]));
await expect(files.readText("binary.dat")).rejects.toThrow("binary file");
});
it("rejects files with a high non-printable byte ratio", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "control.dat"), Buffer.from([0x01, 0x02, 0x03, 0x41]));
await expect(files.readText("control.dat")).rejects.toThrow("binary file");
});
it("rejects files that decode with many UTF-8 replacement characters", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "invalid-utf8.txt"), Buffer.alloc(512, 0xff));
await expect(files.readText("invalid-utf8.txt")).rejects.toThrow("binary file");
});
it("accepts normal text control characters", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "tabs.txt"), "one\ttwo\nthree\rfour\r\n", "utf8");
const result = await files.readText("tabs.txt");
expect(result.content).toContain("1: one\ttwo");
expect(result.content).toContain("2: three");
});
it("writes text and creates parent directories", async () => {
const files = await loadFiles();
await files.writeText("nested/dir/file.txt", "hello");
await expect(fs.readFile(path.join(workspaceDir, "nested", "dir", "file.txt"), "utf8")).resolves.toBe("hello");
});
it("rejects stale expectedEtag writes", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "etag.txt"), "first", "utf8");
const initial = await files.stat("etag.txt");
await files.writeText("etag.txt", "second");
await expect(files.writeText("etag.txt", "third", { expectedEtag: initial.etag })).rejects.toThrow("ETag mismatch");
});
it("requires unique editText matches unless replaceAll is true", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "edit.txt"), "one two one", "utf8");
const ambiguous = await files.editText("edit.txt", "one", "ONE");
expect(ambiguous).toEqual({ error: "oldString found 2 times. Use replaceAll: true or provide more context to make it unique." });
const replaced = await files.editText("edit.txt", "one", "ONE", true);
expect(replaced).toMatchObject({ success: true, replacements: 2 });
await expect(fs.readFile(path.join(workspaceDir, "edit.txt"), "utf8")).resolves.toBe("ONE two ONE");
});
it("runs glob relative to the requested cwd", async () => {
const files = await loadFiles();
await fs.mkdir(path.join(workspaceDir, "src"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "src", "a.ts"), "export {};", "utf8");
await fs.writeFile(path.join(workspaceDir, "src", "b.md"), "# b", "utf8");
await fs.writeFile(path.join(workspaceDir, "c.ts"), "export {};", "utf8");
const result = await files.glob("*.ts", "src");
expect(result.files).toEqual(["a.ts"]);
expect(result.resolvedFiles).toEqual([path.join(workspaceDir, "src", "a.ts")]);
expect(result.resolvedCwd).toBe(path.join(workspaceDir, "src"));
});
it("greps text files and skips binary files", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "match.txt"), "needle\n", "utf8");
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x6e, 0x65, 0x65, 0x64, 0x6c, 0x65, 0x00]));
const result = await files.grep({ pattern: "needle", searchPath: "." });
expect(result.count).toBe(1);
expect(result.matches).toEqual([
expect.objectContaining({
file: "match.txt",
line: 1,
content: "needle",
}),
]);
});
});

View file

@ -0,0 +1,641 @@
import fs from 'node:fs/promises';
import { createReadStream, type Stats } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createInterface } from 'node:readline';
import { glob as globFiles } from 'glob';
import { WorkDir } from '../config/config.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { commitAll } from '../knowledge/version_history.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from '../workspace/wiki-link-rewrite.js';
export type FileOperation = 'read' | 'list' | 'search' | 'write' | 'delete';
export type ResolvedFilePath = {
originalPath: string;
resolvedPath: string;
isInsideWorkspace: boolean;
workspaceRelPath: string | null;
};
export type CanonicalFilePath = ResolvedFilePath & {
canonicalPath: string;
};
export type FileStat = {
kind: 'file' | 'dir';
size: number;
mtimeMs: number;
ctimeMs: number;
isSymlink?: boolean;
};
export type DirEntry = {
name: string;
path: string;
resolvedPath: string;
kind: 'file' | 'dir';
stat?: {
size: number;
mtimeMs: number;
};
};
export type ReaddirOptions = {
recursive?: boolean;
includeStats?: boolean;
includeHidden?: boolean;
allowedExtensions?: string[];
};
export type WriteTextOptions = {
atomic?: boolean;
mkdirp?: boolean;
expectedEtag?: string;
};
export type RemoveOptions = {
recursive?: boolean;
trash?: boolean;
};
const DEFAULT_READ_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
const MAX_BYTES = 50 * 1024;
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
let canonicalWorkspaceRoot: string | null = null;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function expandHomePath(inputPath: string): string {
const trimmed = inputPath.trim();
if (!trimmed) {
throw new Error('Path is required');
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
}
export function resolveFilePath(inputPath: string): ResolvedFilePath {
const originalPath = inputPath;
const expandedPath = expandHomePath(inputPath);
const resolvedPath = path.resolve(path.isAbsolute(expandedPath) ? expandedPath : path.join(WorkDir, expandedPath));
const workspaceRoot = path.resolve(WorkDir);
const isInsideWorkspace = isPathInside(workspaceRoot, resolvedPath);
const workspaceRelPath = isInsideWorkspace
? path.relative(workspaceRoot, resolvedPath).split(path.sep).join('/')
: null;
return { originalPath, resolvedPath, isInsideWorkspace, workspaceRelPath };
}
async function getCanonicalWorkspaceRoot(): Promise<string> {
if (canonicalWorkspaceRoot) return canonicalWorkspaceRoot;
try {
canonicalWorkspaceRoot = await fs.realpath(WorkDir);
} catch {
canonicalWorkspaceRoot = path.resolve(WorkDir);
}
return canonicalWorkspaceRoot;
}
async function canonicalizePathForPermission(resolvedPath: string): Promise<string> {
try {
return await fs.realpath(resolvedPath);
} catch {
const parsed = path.parse(resolvedPath);
const missingParts: string[] = [];
let current = resolvedPath;
while (current !== parsed.root) {
try {
const canonicalParent = await fs.realpath(current);
return path.join(canonicalParent, ...missingParts.reverse());
} catch {
missingParts.push(path.basename(current));
current = path.dirname(current);
}
}
return path.resolve(resolvedPath);
}
}
export async function resolveFilePathForPermission(inputPath: string): Promise<CanonicalFilePath> {
const resolved = resolveFilePath(inputPath);
const [canonicalPath, workspaceRoot] = await Promise.all([
canonicalizePathForPermission(resolved.resolvedPath),
getCanonicalWorkspaceRoot(),
]);
const isInsideWorkspace = isPathInside(workspaceRoot, canonicalPath);
const workspaceRelPath = isInsideWorkspace
? path.relative(workspaceRoot, canonicalPath).split(path.sep).join('/')
: null;
return {
...resolved,
canonicalPath,
isInsideWorkspace,
workspaceRelPath,
};
}
export function computeEtag(size: number, mtimeMs: number): string {
return `${size}:${mtimeMs}`;
}
function statToSchema(stats: Stats): FileStat {
return {
kind: stats.isDirectory() ? 'dir' : 'file',
size: stats.size,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
isSymlink: stats.isSymbolicLink() ? true : undefined,
};
}
function scheduleKnowledgeCommit(filename: string): void {
if (knowledgeCommitTimer) {
clearTimeout(knowledgeCommitTimer);
}
knowledgeCommitTimer = setTimeout(() => {
knowledgeCommitTimer = null;
commitAll(`Edit ${filename}`, 'You').catch(err => {
console.error('[VersionHistory] Failed to commit after edit:', err);
});
}, 3 * 60 * 1000);
}
function isKnowledgeMarkdownPath(resolved: ResolvedFilePath): boolean {
return !!resolved.workspaceRelPath
&& resolved.workspaceRelPath.startsWith('knowledge/')
&& resolved.workspaceRelPath.endsWith('.md');
}
function scheduleKnowledgeCommitIfNeeded(resolved: ResolvedFilePath): void {
if (isKnowledgeMarkdownPath(resolved)) {
scheduleKnowledgeCommit(path.basename(resolved.resolvedPath));
}
}
async function assertTextFile(resolvedPath: string): Promise<void> {
const stats = await fs.lstat(resolvedPath);
if (!stats.isFile()) {
throw new Error('Path is not a file');
}
if (stats.size === 0) return;
const handle = await fs.open(resolvedPath, 'r');
try {
const buffer = Buffer.alloc(Math.min(stats.size, 8192));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const sample = buffer.subarray(0, bytesRead);
let nonPrintableCount = 0;
for (let index = 0; index < sample.length; index++) {
const byte = sample[index];
if (byte === 0) {
throw new Error('Refusing to read binary file as text');
}
if (byte < 9 || (byte > 13 && byte < 32)) {
nonPrintableCount++;
}
}
if (sample.length > 0 && nonPrintableCount / sample.length > 0.3) {
throw new Error('Refusing to read binary file as text');
}
const decoded = sample.toString('utf8');
const replacementChars = (decoded.match(/\uFFFD/g) || []).length;
if (replacementChars > Math.max(3, decoded.length * 0.01)) {
throw new Error('Refusing to read binary file as text');
}
} finally {
await handle.close();
}
}
export async function exists(inputPath: string): Promise<{ exists: boolean; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
try {
await fs.access(resolved.resolvedPath);
return { exists: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
} catch {
return { exists: false, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
}
}
export async function stat(inputPath: string): Promise<FileStat & { path: string; resolvedPath: string; isInsideWorkspace: boolean; etag: string }> {
const resolved = resolveFilePath(inputPath);
const stats = await fs.lstat(resolved.resolvedPath);
return {
...statToSchema(stats),
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
etag: computeEtag(stats.size, stats.mtimeMs),
};
}
export async function list(inputPath: string, opts?: ReaddirOptions): Promise<Array<DirEntry>> {
const root = resolveFilePath(inputPath || '.');
const entries: Array<DirEntry> = [];
async function readDir(currentPath: string, currentDisplayPath: string): Promise<void> {
const items = await fs.readdir(currentPath, { withFileTypes: true });
for (const item of items) {
if (!opts?.includeHidden && item.name.startsWith('.')) {
continue;
}
const itemPath = path.join(currentPath, item.name);
const displayPath = path.posix.join(currentDisplayPath.split(path.sep).join('/'), item.name);
const itemKind = item.isDirectory() ? 'dir' : item.isFile() ? 'file' : null;
if (!itemKind) continue;
if (itemKind === 'file' && opts?.allowedExtensions?.length) {
const ext = path.extname(item.name);
if (!opts.allowedExtensions.includes(ext)) continue;
}
let itemStat: { size: number; mtimeMs: number } | undefined;
if (opts?.includeStats) {
const stats = await fs.lstat(itemPath);
itemStat = { size: stats.size, mtimeMs: stats.mtimeMs };
}
entries.push({
name: item.name,
path: displayPath,
resolvedPath: itemPath,
kind: itemKind,
stat: itemStat,
});
if (itemKind === 'dir' && opts?.recursive) {
await readDir(itemPath, displayPath);
}
}
}
await readDir(root.resolvedPath, root.originalPath || '.');
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return entries;
}
export async function readText(inputPath: string, offset?: number, limit?: number) {
const resolved = resolveFilePath(inputPath);
await assertTextFile(resolved.resolvedPath);
const stats = await fs.lstat(resolved.resolvedPath);
const stat = statToSchema(stats);
const etag = computeEtag(stats.size, stats.mtimeMs);
const effectiveOffset = offset ?? 1;
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
const start = effectiveOffset - 1;
const stream = createReadStream(resolved.resolvedPath, { 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>${resolved.originalPath}</path>`,
`<resolvedPath>${resolved.resolvedPath}</resolvedPath>`,
`<type>file</type>`,
`<content>`,
prefixed.join('\n'),
'',
footer,
`</content>`,
].join('\n');
return {
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
encoding: 'utf8' as const,
content,
stat,
etag,
offset: effectiveOffset,
limit: effectiveLimit,
totalLines,
hasMore: hasMoreLines || truncatedByBytes,
};
}
export async function readBuffer(inputPath: string): Promise<{ buffer: Buffer; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
const buffer = await fs.readFile(resolved.resolvedPath);
return {
buffer,
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
};
}
export async function writeText(inputPath: string, data: string, opts?: WriteTextOptions) {
const resolved = resolveFilePath(inputPath);
const atomic = opts?.atomic !== false;
const mkdirp = opts?.mkdirp !== false;
if (mkdirp) {
await fs.mkdir(path.dirname(resolved.resolvedPath), { recursive: true });
}
const result = await withFileLock(resolved.resolvedPath, async () => {
if (opts?.expectedEtag) {
const existingStats = await fs.lstat(resolved.resolvedPath);
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
if (existingEtag !== opts.expectedEtag) {
throw new Error('File was modified (ETag mismatch)');
}
}
const buffer = Buffer.from(data, 'utf8');
if (atomic) {
const tempPath = `${resolved.resolvedPath}.tmp.${Date.now()}${Math.random().toString(36).slice(2)}`;
await fs.writeFile(tempPath, buffer);
await fs.rename(tempPath, resolved.resolvedPath);
} else {
await fs.writeFile(resolved.resolvedPath, buffer);
}
const stats = await fs.lstat(resolved.resolvedPath);
return { stat: statToSchema(stats), etag: computeEtag(stats.size, stats.mtimeMs) };
});
scheduleKnowledgeCommitIfNeeded(resolved);
return {
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
stat: result.stat,
etag: result.etag,
};
}
export async function editText(inputPath: string, oldString: string, newString: string, replaceAll = false) {
const resolved = resolveFilePath(inputPath);
await assertTextFile(resolved.resolvedPath);
const content = await fs.readFile(resolved.resolvedPath, 'utf8');
const occurrences = content.split(oldString).length - 1;
if (occurrences === 0) {
return { error: 'oldString not found in file' };
}
if (occurrences > 1 && !replaceAll) {
return { error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.` };
}
const newContent = replaceAll
? content.replaceAll(oldString, newString)
: content.replace(oldString, newString);
await writeText(inputPath, newContent, { atomic: true, mkdirp: true });
return {
success: true,
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
replacements: replaceAll ? occurrences : 1,
};
}
export async function mkdir(inputPath: string, recursive = true): Promise<{ ok: true; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
await fs.mkdir(resolved.resolvedPath, { recursive });
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
}
export async function rename(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
const source = resolveFilePath(from);
const dest = resolveFilePath(to);
await fs.access(source.resolvedPath);
const fromStats = await fs.lstat(source.resolvedPath);
if (!overwrite) {
try {
await fs.access(dest.resolvedPath);
throw new Error('Destination already exists');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
// destination does not exist
} else {
throw err;
}
}
}
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
await fs.rename(source.resolvedPath, dest.resolvedPath);
if (fromStats.isFile() && isKnowledgeMarkdownPath(source) && isKnowledgeMarkdownPath(dest) && source.workspaceRelPath && dest.workspaceRelPath) {
try {
await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, source.workspaceRelPath, dest.workspaceRelPath);
} catch (error) {
console.error('Failed to rewrite wiki backlinks after file rename:', error);
}
}
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
}
export async function copy(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
const source = resolveFilePath(from);
const dest = resolveFilePath(to);
const fromStats = await fs.lstat(source.resolvedPath);
if (fromStats.isDirectory()) {
throw new Error('Copying directories is not supported');
}
if (!overwrite) {
try {
await fs.access(dest.resolvedPath);
throw new Error('Destination already exists');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
// destination does not exist
} else {
throw err;
}
}
}
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
await fs.copyFile(source.resolvedPath, dest.resolvedPath);
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
}
export async function remove(inputPath: string, opts?: RemoveOptions): Promise<{ ok: true; path: string; resolvedPath: string; trashed?: string }> {
const resolved = resolveFilePath(inputPath);
const stats = await fs.lstat(resolved.resolvedPath);
const trash = opts?.trash !== false;
if (trash) {
const trashDir = path.join(WorkDir, '.trash');
await fs.mkdir(trashDir, { recursive: true });
const timestamp = Date.now();
const basename = path.basename(resolved.resolvedPath);
let finalTrashPath = path.join(trashDir, `${timestamp}-${basename}`);
let counter = 1;
while (true) {
try {
await fs.access(finalTrashPath);
finalTrashPath = path.join(trashDir, `${timestamp}-${counter}-${basename}`);
counter++;
} catch {
break;
}
}
await fs.rename(resolved.resolvedPath, finalTrashPath);
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, trashed: finalTrashPath };
}
if (stats.isDirectory()) {
if (!opts?.recursive) {
throw new Error('Cannot remove directory without recursive=true');
}
await fs.rm(resolved.resolvedPath, { recursive: true });
} else {
await fs.unlink(resolved.resolvedPath);
}
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath };
}
export async function glob(pattern: string, cwd?: string): Promise<{ files: string[]; resolvedFiles: string[]; count: number; pattern: string; cwd: string; resolvedCwd: string }> {
const root = resolveFilePath(cwd || '.');
const files = await globFiles(pattern, {
cwd: root.resolvedPath,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
});
const resolvedFiles = files.map(file => path.resolve(root.resolvedPath, file));
return {
files,
resolvedFiles,
count: files.length,
pattern,
cwd: cwd || '.',
resolvedCwd: root.resolvedPath,
};
}
export async function grep({
pattern,
searchPath,
fileGlob,
contextLines = 0,
maxResults = 100,
}: {
pattern: string;
searchPath?: string;
fileGlob?: string;
contextLines?: number;
maxResults?: number;
}) {
const root = resolveFilePath(searchPath || '.');
const stats = await fs.lstat(root.resolvedPath);
const candidates = stats.isDirectory()
? await globFiles(fileGlob || '**/*', {
cwd: root.resolvedPath,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
dot: false,
})
: [path.basename(root.resolvedPath)];
const baseDir = stats.isDirectory() ? root.resolvedPath : path.dirname(root.resolvedPath);
const regex = new RegExp(pattern, 'i');
const matches: Array<{ file: string; resolvedPath: string; line: number; content: string; before?: string[]; after?: string[] }> = [];
for (const candidate of candidates) {
if (matches.length >= maxResults) break;
const resolvedPath = stats.isDirectory() ? path.resolve(baseDir, candidate) : root.resolvedPath;
try {
await assertTextFile(resolvedPath);
const text = await fs.readFile(resolvedPath, 'utf8');
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
if (!regex.test(lines[index])) continue;
const before = contextLines > 0 ? lines.slice(Math.max(0, index - contextLines), index) : undefined;
const after = contextLines > 0 ? lines.slice(index + 1, Math.min(lines.length, index + 1 + contextLines)) : undefined;
matches.push({
file: stats.isDirectory() ? candidate : root.originalPath,
resolvedPath,
line: index + 1,
content: lines[index].trim(),
before,
after,
});
if (matches.length >= maxResults) break;
}
} catch {
// Skip unreadable and binary files.
}
}
return {
matches,
count: matches.length,
tool: 'internal-grep',
searchPath: searchPath || '.',
resolvedSearchPath: root.resolvedPath,
};
}

View file

@ -1,21 +1,21 @@
export function getRaw(): string {
return `---
tools:
workspace-writeFile:
file-writeText:
type: builtin
name: workspace-writeFile
workspace-readFile:
name: file-writeText
file-readText:
type: builtin
name: workspace-readFile
workspace-edit:
name: file-readText
file-editText:
type: builtin
name: workspace-edit
workspace-readdir:
name: file-editText
file-list:
type: builtin
name: workspace-readdir
workspace-mkdir:
name: file-list
file-mkdir:
type: builtin
name: workspace-mkdir
name: file-mkdir
---
# Agent Notes

View file

@ -267,7 +267,7 @@ async function createNotesFromBatch(
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 or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
message += `- Use file 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
@ -297,16 +297,16 @@ async function createNotesFromBatch(
if (event.type !== "tool-invocation") {
return;
}
if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") {
if (event.toolName !== "file-writeText" && event.toolName !== "file-editText") {
return;
}
const toolPath = extractPathFromToolInput(event.input);
if (!toolPath) {
return;
}
if (event.toolName === "workspace-writeFile") {
if (event.toolName === "file-writeText") {
notesCreated.add(toolPath);
} else if (event.toolName === "workspace-edit") {
} else if (event.toolName === "file-editText") {
notesModified.add(toolPath);
}
});

View file

@ -109,7 +109,7 @@ export interface Classification {
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
@ -128,7 +128,18 @@ When the thread is important, write a 1-2 sentence summary that captures the gis
When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is.
Apply the user's email-style guide (when provided below) match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice.
Format it like a real email, not one run-on block. Use actual line breaks: put the greeting on its own line, separate distinct paragraphs with a blank line, and put the sign-off and the name on their own lines. The example below illustrates only the line-break structure not the wording, tone, greeting, or sign-off to use:
Hi Tyrone,
Thanks for the follow-up sorry I missed your earlier note.
Could you resend it with a bit more context so I can get back to you properly?
Best,
John
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
- Propose 2-3 specific time windows from genuinely free slots, or
@ -144,10 +155,12 @@ Omit the draft when:
Be decisive pick exactly one importance label. Do not hedge.`;
function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
function userSentLatest(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
if (!userEmail) return false;
const latest = snapshot.messages[snapshot.messages.length - 1];
if (!latest) return false;
const needle = userEmail.toLowerCase();
return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle));
return (latest.from || '').toLowerCase().includes(needle);
}
function buildPrompt(
@ -206,7 +219,7 @@ export async function classifyThread(
userEmail: string | null,
options: ClassifyOptions = {},
): Promise<Classification> {
if (userReplied(snapshot, userEmail)) {
if (userSentLatest(snapshot, userEmail)) {
return { importance: 'important' };
}

View file

@ -188,18 +188,41 @@ export class GoogleClientFactory {
* Check if credentials are available and have required scopes
*/
static async hasValidCredentials(requiredScopes: string | string[]): Promise<boolean> {
const status = await this.getCredentialStatus(requiredScopes);
return status.hasRequiredScopes;
}
static async getCredentialStatus(requiredScopes: string | string[]): Promise<{
connected: boolean;
hasRequiredScopes: boolean;
missingScopes: string[];
}> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { tokens } = await oauthRepo.read(this.PROVIDER_NAME);
if (!tokens) {
return false;
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
return {
connected: false,
hasRequiredScopes: false,
missingScopes: scopesArray,
};
}
// Check if required scope(s) are present
const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes];
const granted = new Set(tokens.scopes ?? []);
const missingScopes = scopesArray.filter(scope => !granted.has(scope));
if (!tokens.scopes || tokens.scopes.length === 0) {
return false;
return {
connected: true,
hasRequiredScopes: false,
missingScopes,
};
}
return scopesArray.every(scope => tokens.scopes!.includes(scope));
return {
connected: true,
hasRequiredScopes: missingScopes.length === 0,
missingScopes,
};
}
/**

View file

@ -98,7 +98,7 @@ This brief refreshes every 15 minutes, so it should always reflect the **current
## 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:** File tools accept relative paths that resolve against the Rowboat workspace root. For workspace data, 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.
@ -136,8 +136,8 @@ This is the most time-sensitive section — it orients the user on what's coming
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\`)
1. Use \`file-list\` with path \`calendar_sync\` to list files
2. Use \`file-readText\` 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:
@ -160,8 +160,8 @@ If there are events, include them:
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\`)
1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`file-readText\` 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. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
@ -180,7 +180,7 @@ If there are events, include them:
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.
- **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 \`file-list\` 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 \`file-readText\`. 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.
@ -192,7 +192,7 @@ This is NOT a generic task list. These are the things the user should actually f
- **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.
- Search through \`knowledge/\` using \`file-grep\` and \`file-list\` 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.**
@ -221,4 +221,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

@ -78,13 +78,13 @@ async function labelEmailBatch(
});
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
message += `**Important:** Use workspace-relative paths with file-editText (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relativePath = path.relative(WorkDir, file.path);
const truncated = file.content.length > MAX_CONTENT_LENGTH
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use file-readText for full content ...]'
: file.content;
message += `## File ${i + 1}: ${relativePath}\n\n`;
@ -98,7 +98,7 @@ async function labelEmailBatch(
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'workspace-edit') {
if (event.toolName !== 'file-editText') {
return;
}
try {

View file

@ -3,15 +3,15 @@ import { renderTagSystemForEmails } from './tag_system.js';
export function getRaw(): string {
return `---
tools:
workspace-readFile:
file-readText:
type: builtin
name: workspace-readFile
workspace-edit:
name: file-readText
file-editText:
type: builtin
name: workspace-edit
workspace-readdir:
name: file-editText
file-list:
type: builtin
name: workspace-readdir
name: file-list
---
# Task
@ -70,7 +70,7 @@ ${renderTagSystemForEmails()}
- **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.
5. Use \`file-editText\` 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.

View file

@ -26,7 +26,7 @@ Every run message has this shape:
**Objective:**
<the user-authored objective usually 1-3 sentences describing what the note should keep being>
Start by calling \`workspace-readFile\` on \`<filePath>\` ... patch-style edits ...
Start by calling \`file-readText\` on \`<filePath>\` ... patch-style edits ...
For **manual** runs, an optional trailing block may appear:
@ -51,10 +51,10 @@ You own the **entire body below the H1** — you may freely add, edit, reorganiz
**Make incremental, patch-style edits not one-shot rewrites.**
The right pattern on every run:
1. \`workspace-readFile\` to fetch the current note.
1. \`file-readText\` to fetch the current note.
2. Decide on the *first* change you need to make (add a section, replace a stale figure, dedupe entries, fix an out-of-date paragraph).
3. \`workspace-edit\` to make that one change.
4. \`workspace-readFile\` again to confirm the result.
3. \`file-editText\` to make that one change.
4. \`file-readText\` again to confirm the result.
5. Decide the *next* change. Repeat.
Why patch-style:
@ -63,8 +63,8 @@ Why patch-style:
- It lets you abort partway if a tool call fails, leaving the note in a consistent partial state instead of a clobbered one.
Avoid:
- Calling \`workspace-writeFile\` to replace the entire body. That's the no-go path.
- Building up the entire new body in your head and emitting it in a single \`workspace-edit\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
- Calling \`file-writeText\` to replace the entire body. That's the no-go path.
- Building up the entire new body in your head and emitting it in a single \`file-editText\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
# Body Structure (defaults)
@ -105,9 +105,9 @@ When skipping, still end with a summary line (see "Final Summary" below) so the
You have the full workspace toolkit. Quick reference for common cases:
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`workspace-edit\` calls over one giant \`workspace-writeFile\`.
- **\`file-readText\`, \`file-editText\`, \`file-writeText\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`file-editText\` calls over one giant \`file-writeText\`.
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the objective needs information beyond the workspace.
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data.
- **\`file-grep\`, \`file-glob\`, \`file-list\`** — search the user's knowledge graph and synced data.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if the objective references attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when the objective 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.
@ -124,9 +124,9 @@ The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organ
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when the objective 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/")\`
- \`file-grep({ pattern: "Acme", searchPath: "knowledge/" })\`
- \`file-readText("knowledge/People/Sarah Chen.md")\`
- \`file-list("gmail_sync/")\`
# Failure & Fallback

View file

@ -30,7 +30,7 @@ function truncate(s: string | null | undefined, n = SUMMARY_LOG_LIMIT): string {
// Agent run message
// ---------------------------------------------------------------------------
const LIVE_NOTE_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call `workspace-edit`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.';
const LIVE_NOTE_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call `file-editText`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.';
const LIVE_NOTE_MANUAL_PAREN = 'user-triggered — either the Run button in the Live Note panel or the `run-live-note-agent` tool';
@ -44,8 +44,8 @@ function buildMessage(
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Workspace-relative path the agent's tools (workspace-readFile,
// workspace-edit) expect. Internal storage is knowledge/-relative.
// Workspace-relative path the agent's tools (file-readText,
// file-editText) expect. Internal storage is knowledge/-relative.
const wsPath = `knowledge/${filePath}`;
const baseMessage = `Update the live note at \`${wsPath}\`.
@ -55,7 +55,7 @@ function buildMessage(
**Objective:**
${live.objective}
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`workspace-edit\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
Start by calling \`file-readText\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`file-editText\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
return baseMessage + buildTriggerBlock({
trigger,

View file

@ -4,27 +4,27 @@ import { renderNoteEffectRules } from './tag_system.js';
export function getRaw(): string {
return `---
tools:
workspace-writeFile:
file-writeText:
type: builtin
name: workspace-writeFile
workspace-readFile:
name: file-writeText
file-readText:
type: builtin
name: workspace-readFile
workspace-edit:
name: file-readText
file-editText:
type: builtin
name: workspace-edit
workspace-readdir:
name: file-editText
file-list:
type: builtin
name: workspace-readdir
workspace-mkdir:
name: file-list
file-mkdir:
type: builtin
name: workspace-mkdir
workspace-grep:
name: file-mkdir
file-grep:
type: builtin
name: workspace-grep
workspace-glob:
name: file-grep
file-glob:
type: builtin
name: workspace-glob
name: file-glob
---
# Context
@ -92,17 +92,17 @@ You have access to these tools:
**For reading files:**
\`\`\`
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
file-readText({ path: "knowledge/People/Sarah Chen.md" })
\`\`\`
**For creating NEW files:**
\`\`\`
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
file-writeText({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\`
**For editing EXISTING files (preferred for updates):**
\`\`\`
workspace-edit({
file-editText({
path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
@ -111,27 +111,27 @@ workspace-edit({
**For listing directories:**
\`\`\`
workspace-readdir({ path: "knowledge/People" })
file-list({ path: "knowledge/People" })
\`\`\`
**For creating directories:**
\`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
file-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\`
**For searching files:**
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
file-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\`
**For finding files by pattern:**
\`\`\`
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
file-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
\`\`\`
**IMPORTANT:**
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
- Use \`workspace-writeFile\` only for creating new notes
- Use \`file-editText\` for updating existing notes (adding activity, updating fields)
- Use \`file-writeText\` only for creating new notes
- Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output
@ -158,7 +158,7 @@ ${renderNoteEffectRules()}
Read the source file and determine if it's a meeting or email.
\`\`\`
workspace-readFile({ path: "{source_file}" })
file-readText({ path: "{source_file}" })
\`\`\`
**Meeting indicators:**
@ -262,7 +262,7 @@ If processing, continue to Step 2.
# Step 2: Read and Parse Source File
\`\`\`
workspace-readFile({ path: "{source_file}" })
file-readText({ path: "{source_file}" })
\`\`\`
Extract metadata:
@ -359,7 +359,7 @@ From index, find matches for:
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
file-readText({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
**Why read these notes:**
@ -445,10 +445,10 @@ When multiple candidates match a variant, disambiguate:
**By organization (strongest signal):**
\`\`\`
# "David" could be David Kim or David Chen
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
file-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Output: **Organization:** [[Acme Corp]]
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
file-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
# Output: **Organization:** [[Other Corp]]
# Source is from Acme context "David" = "David Kim"
@ -456,14 +456,14 @@ workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David C
**By email (definitive):**
\`\`\`
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
file-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Exact email match is definitive
\`\`\`
**By role:**
\`\`\`
# Source mentions "their CTO"
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
file-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
# Filter results by organization context
\`\`\`
@ -959,7 +959,7 @@ Before writing, compare extracted content against existing notes.
## Check Activity Log
\`\`\`
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
file-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
@ -993,20 +993,20 @@ If new info contradicts existing:
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple write commands in a single response.
**For NEW entities (use workspace-writeFile):**
**For NEW entities (use file-writeText):**
\`\`\`
workspace-writeFile({
file-writeText({
path: "{knowledge_folder}/People/Jennifer.md",
data: "# Jennifer\\n\\n## Summary\\n..."
})
\`\`\`
**For EXISTING entities (use workspace-edit):**
- Read current content first with workspace-readFile
- Use workspace-edit to add activity entry at TOP (reverse chronological)
**For EXISTING entities (use file-editText):**
- Read current content first with file-readText
- Use file-editText to add activity entry at TOP (reverse chronological)
- Update fields using targeted edits
\`\`\`
workspace-edit({
file-editText({
path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
@ -1016,8 +1016,8 @@ 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
- Use \`file-writeText\` to create or rewrite the file when that is simpler and cleaner
- Use \`file-editText\` for small targeted edits only if that keeps the file deduped and readable
## 9b: Apply State Changes

View file

@ -3,15 +3,15 @@ import { renderTagSystemForNotes } from './tag_system.js';
export function getRaw(): string {
return `---
tools:
workspace-readFile:
file-readText:
type: builtin
name: workspace-readFile
workspace-edit:
name: file-readText
file-editText:
type: builtin
name: workspace-edit
workspace-readdir:
name: file-editText
file-list:
type: builtin
name: workspace-readdir
name: file-list
---
# Task
@ -23,7 +23,7 @@ You are a note tagging agent. Given a batch of knowledge notes (People, Organiza
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/).
3. Classify the note using the Rowboat Tag System (Note Tags section) appended below.
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below).
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
5. Use \`file-editText\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
6. If the note already has frontmatter (starts with \`---\`), skip it.
# Frontmatter Format

View file

@ -30,6 +30,10 @@ function formatEventTime(event: AnyEvent): string {
return `${startStr}${endStr}`;
}
function shouldSyncCalendarEvent(event: cal.Schema$Event): boolean {
return event.eventType !== 'workingLocation';
}
function formatEventBlock(event: AnyEvent, label: 'NEW' | 'UPDATED'): string {
const id = getStr(event, 'id') ?? '(unknown id)';
const title = getStr(event, 'summary') ?? '(no title)';
@ -347,6 +351,9 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD
console.log(`Found ${events.length} events.`);
for (const event of events) {
if (event.id) {
if (!shouldSyncCalendarEvent(event)) {
continue;
}
const result = await saveEvent(event, syncDir);
const attachmentsSaved = await processAttachments(drive, event, syncDir);
currentEventIds.add(event.id);

View file

@ -26,13 +26,21 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
}
})();
const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.modify';
const MAX_THREADS_IN_DIGEST = 10;
const RECENT_BACKFILL_INTERVAL_MS = 15 * 60 * 1000;
const nhm = new NodeHtmlMarkdown();
// Bump whenever snapshot-building logic changes in a way that should invalidate
// 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;
interface SnapshotCacheEntry {
historyId: string;
fetchedAt: string;
parserVersion?: number;
snapshot: GmailThreadSnapshot;
}
@ -55,6 +63,7 @@ function writeCachedSnapshot(threadId: string, historyId: string, snapshot: Gmai
const entry: SnapshotCacheEntry = {
historyId,
fetchedAt: new Date().toISOString(),
parserVersion: SNAPSHOT_PARSER_VERSION,
snapshot,
};
fs.writeFileSync(cachePath(threadId), JSON.stringify(entry), 'utf-8');
@ -77,6 +86,76 @@ export function saveMessageBodyHeight(threadId: string, messageId: string, heigh
}
}
function deleteCachedSnapshot(threadId: string): void {
try {
fs.rmSync(cachePath(threadId), { force: true });
} catch (err) {
console.warn(`[Gmail cache] delete failed for ${threadId}:`, err);
}
}
async function getGmailClientOrThrow() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Gmail is not connected.');
return google.gmail({ version: 'v1', auth });
}
export interface ThreadActionResult {
ok: boolean;
error?: string;
}
export async function archiveThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['INBOX'] },
});
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function trashThread(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.trash({ userId: 'me', id: threadId });
deleteCachedSnapshot(threadId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['UNREAD'] },
});
// Update local cache: clear unread on all messages in the thread.
const cached = readCachedSnapshot(threadId);
if (cached) {
for (const m of cached.snapshot.messages) m.unread = false;
cached.snapshot.unread = false;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] markRead write failed for ${threadId}:`, err);
}
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
interface SyncedThread {
threadId: string;
markdown: string;
@ -113,6 +192,7 @@ export interface GmailThreadSnapshot {
sizeBytes?: number;
savedPath: string;
}>;
messageIdHeader?: string;
}>;
}
@ -236,19 +316,24 @@ interface ExtractedAttachment {
* saveAttachment / processThread, so the renderer can hand them to
* shell.openPath via the existing IPC.
*/
function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart): ExtractedAttachment[] {
function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, html?: string): ExtractedAttachment[] {
const out: ExtractedAttachment[] = [];
const walk = (part: gmail.Schema$MessagePart): void => {
const filename = part.filename;
const attId = part.body?.attachmentId;
if (filename && attId) {
// Exclude only true inline images (image/* with a Content-ID, which
// get baked into bodyHtml as data URLs by inlineCidImages). Other
// parts with Content-ID — PDFs, .log files, .ics, etc. — are real
// attachments; Gmail just stamps Content-ID on most parts.
const cid = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
// Exclude only images that are genuinely inline — i.e. their Content-ID
// is actually referenced via `cid:` in the HTML body, so inlineCidImages
// already baked them in as data URLs. Gmail stamps a Content-ID on most
// parts (including real, separately-attached images like screenshots or
// scanned docs), so a Content-ID alone must NOT exclude an attachment;
// otherwise attached images silently disappear from the thread view.
const cidRaw = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const cid = cidRaw?.replace(/^<|>$/g, '').trim();
const mime = part.mimeType || '';
const isInlineImage = !!cid && mime.startsWith('image/');
const referencedInHtml = !!cid && !!html
&& new RegExp(`cid:${cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i').test(html);
const isInlineImage = mime.startsWith('image/') && referencedInHtml;
if (!isInlineImage) {
const safeName = `${msgId}_${cleanFilename(filename)}`;
out.push({
@ -505,6 +590,7 @@ async function buildAndCacheSnapshot(
threadData.historyId &&
cached &&
cached.historyId === threadData.historyId &&
cached.parserVersion === SNAPSHOT_PARSER_VERSION &&
cached.snapshot.importance
) {
return cached.snapshot;
@ -530,7 +616,7 @@ async function buildAndCacheSnapshot(
}
}
const isDraft = msg.labelIds?.includes('DRAFT') ?? false;
const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload) : [];
const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload, parts.html) : [];
return {
id: msg.id || undefined,
from: headerValue(headers, 'From') || 'Unknown',
@ -713,7 +799,9 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
} catch (error) {
console.error(`Error processing thread ${threadId}:`, error);
return null;
const status = getErrorStatus(error);
if (status === 404) return null;
throw error;
}
}
@ -757,20 +845,102 @@ async function pruneInboxCache(auth: OAuth2Client): Promise<void> {
}
}
function loadState(stateFile: string): { historyId?: string; last_sync?: string } {
function loadState(stateFile: string): { historyId?: string; last_sync?: string; last_recent_backfill?: string } {
if (fs.existsSync(stateFile)) {
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
return {};
}
function saveState(historyId: string, stateFile: string) {
function saveState(historyId: string, stateFile: string, extra: { last_recent_backfill?: string } = {}) {
const previous = loadState(stateFile);
fs.writeFileSync(stateFile, JSON.stringify({
historyId,
last_sync: new Date().toISOString()
last_sync: new Date().toISOString(),
last_recent_backfill: extra.last_recent_backfill ?? previous.last_recent_backfill,
...extra,
}, null, 2));
}
function getErrorStatus(error: unknown): number | undefined {
const status = (error as { response?: { status?: number } }).response?.status;
if (status) return status;
const code = Number((error as { code?: number | string }).code);
return Number.isFinite(code) ? code : undefined;
}
function recentDateQuery(lookbackDays: number): string {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - lookbackDays);
return pastDate.toISOString().split('T')[0].replace(/-/g, '/');
}
async function listRecentNonDeletedThreadIds(gmailClient: gmail.Gmail, lookbackDays: number): Promise<RecentThreadInfo[]> {
const dateQuery = recentDateQuery(lookbackDays);
const results: RecentThreadInfo[] = [];
const seen = new Set<string>();
let pageToken: string | undefined;
do {
const res = await gmailClient.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
pageToken,
});
for (const thread of res.data.threads || []) {
if (!thread.id || seen.has(thread.id)) continue;
seen.add(thread.id);
results.push({
threadId: thread.id,
historyId: thread.historyId || '',
snippet: thread.snippet || undefined,
});
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
return results;
}
function shouldRunRecentBackfill(stateFile: string): boolean {
const state = loadState(stateFile);
if (!state.last_recent_backfill) return true;
const lastRunMs = new Date(state.last_recent_backfill).getTime();
if (!Number.isFinite(lastRunMs)) return true;
return Date.now() - lastRunMs >= RECENT_BACKFILL_INTERVAL_MS;
}
async function backfillMissingRecentThreads(
auth: OAuth2Client,
syncDir: string,
attachmentsDir: string,
stateFile: string,
lookbackDays: number,
): Promise<SyncedThread[]> {
if (!shouldRunRecentBackfill(stateFile)) return [];
const gmailClient = google.gmail({ version: 'v1', auth });
const recentThreads = await listRecentNonDeletedThreadIds(gmailClient, lookbackDays);
const missingThreadIds = recentThreads
.map((thread) => thread.threadId)
.filter((threadId) => !fs.existsSync(path.join(syncDir, `${threadId}.md`)));
const synced: SyncedThread[] = [];
for (const threadId of missingThreadIds) {
const result = await processThread(auth, threadId, syncDir, attachmentsDir);
if (result) synced.push(result);
}
const profile = await gmailClient.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile, { last_recent_backfill: new Date().toISOString() });
if (missingThreadIds.length > 0) {
console.log(`Recent Gmail backfill synced ${synced.length}/${missingThreadIds.length} missing thread(s).`);
}
return synced;
}
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
const gmail = google.gmail({ version: 'v1', auth });
@ -814,6 +984,7 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
const res = await gmail.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
pageToken
});
@ -907,15 +1078,24 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
};
try {
const res = await gmail.users.history.list({
userId: 'me',
startHistoryId,
historyTypes: ['messageAdded']
});
const changes: gmail.Schema$History[] = [];
let pageToken: string | undefined;
do {
const res = await gmail.users.history.list({
userId: 'me',
startHistoryId,
historyTypes: ['messageAdded'],
maxResults: 500,
pageToken,
});
if (res.data.history) changes.push(...res.data.history);
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
const changes = res.data.history;
if (!changes || changes.length === 0) {
console.log("No new changes.");
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
await publishGmailSyncEvent(backfilled);
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
return;
@ -937,6 +1117,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
}
if (threadIds.size === 0) {
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
await publishGmailSyncEvent(backfilled);
const profile = await gmail.users.getProfile({ userId: 'me' });
saveState(profile.data.historyId!, stateFile);
return;
@ -961,6 +1143,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
const result = await processThread(auth, tid, syncDir, attachmentsDir);
if (result) synced.push(result);
}
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
synced.push(...backfilled);
await publishGmailSyncEvent(synced);
@ -1059,6 +1243,162 @@ async function performSync() {
}
}
// --- Send Reply ---
export interface SendReplyOptions {
threadId?: string;
to: string;
cc?: string;
bcc?: string;
subject: string;
bodyHtml: string;
bodyText: string;
inReplyTo?: string;
references?: string;
}
export interface SendReplyResult {
messageId?: string;
error?: string;
}
export interface GmailConnectionStatus {
connected: boolean;
hasRequiredScope: boolean;
missingScopes: string[];
email: string | null;
}
/** The connected Gmail address (cached). Used by the composer to exclude "me" from reply-all. */
export async function getAccountEmail(): Promise<string | null> {
const auth = await GoogleClientFactory.getClient();
if (!auth) return null;
return getUserEmail(auth);
}
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
let email: string | null = null;
if (status.connected) {
try {
email = await getAccountEmail();
} catch {
email = null;
}
}
return {
connected: status.connected,
hasRequiredScope: status.hasRequiredScopes,
missingScopes: status.missingScopes,
email,
};
}
function requireSafeHeaderValue(name: string, value: string): string {
if (/[\r\n]/.test(value)) {
throw new Error(`${name} cannot contain line breaks.`);
}
return value.trim();
}
function encodeRfc2047(text: string): string {
requireSafeHeaderValue('Subject', text);
// Only encode if non-ASCII chars present.
// eslint-disable-next-line no-control-regex
if (/^[\x00-\x7F]*$/.test(text)) return text;
return `=?UTF-8?B?${Buffer.from(text).toString('base64')}?=`;
}
function encodeMimeBase64(text: string): string {
return Buffer.from(text, 'utf8')
.toString('base64')
.match(/.{1,76}/g)
?.join('\r\n') ?? '';
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
const userEmail = await getUserEmail(auth);
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
const safeTo = requireSafeHeaderValue('To', opts.to);
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
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 boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
if (safeCc) headers.push(`Cc: ${safeCc}`);
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
const parts: string[] = [];
parts.push(`--${boundary}`);
parts.push('Content-Type: text/plain; charset="UTF-8"');
parts.push('Content-Transfer-Encoding: base64');
parts.push('');
parts.push(encodeMimeBase64(opts.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('');
parts.push(`--${boundary}--`);
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const requestBody: gmail.Schema$Message = { raw };
if (opts.threadId) requestBody.threadId = opts.threadId;
const res = await gmailClient.users.messages.send({
userId: 'me',
requestBody,
});
if (opts.threadId) {
// Clean up any Gmail-side drafts in this thread.
try {
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
const matching = (drafts.data.drafts || []).filter(
(d) => d.message?.threadId === opts.threadId && d.id
);
await Promise.all(
matching.map((d) =>
gmailClient.users.drafts.delete({ userId: 'me', id: d.id! })
)
);
} catch (cleanupErr) {
console.warn('[Gmail] Draft cleanup after send failed:', cleanupErr);
}
}
// Wake the sync loop so the cache picks up the new message.
triggerSync();
return { messageId: res.data.id || undefined };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
export async function init() {
console.log("Starting Gmail Sync (TS)...");
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);

View file

@ -91,13 +91,13 @@ async function tagNoteBatch(
});
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
message += `**Important:** Use workspace-relative paths with file-editText (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relativePath = path.relative(WorkDir, file.path);
const truncated = file.content.length > MAX_CONTENT_LENGTH
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]'
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use file-readText for full content ...]'
: file.content;
message += `## File ${i + 1}: ${relativePath}\n\n`;
@ -111,7 +111,7 @@ async function tagNoteBatch(
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'workspace-edit') {
if (event.toolName !== 'file-editText') {
return;
}
try {

View file

@ -1,20 +1,20 @@
---
tools:
workspace-readFile:
file-readText:
type: builtin
name: workspace-readFile
workspace-writeFile:
name: file-readText
file-writeText:
type: builtin
name: workspace-writeFile
workspace-readdir:
name: file-writeText
file-list:
type: builtin
name: workspace-readdir
workspace-mkdir:
name: file-list
file-mkdir:
type: builtin
name: workspace-mkdir
workspace-exists:
name: file-mkdir
file-exists:
type: builtin
name: workspace-exists
name: file-exists
executeCommand:
type: builtin
name: executeCommand
@ -41,8 +41,8 @@ All state is stored in `pre-built/email-draft/`:
On first run, check if state exists. If not, create it:
1. Use `workspace-exists` to check if `pre-built/email-draft/state.json` exists
2. If not, use `workspace-mkdir` to create `pre-built/email-draft/` and `pre-built/email-draft/drafts/`
1. Use `file-exists` to check if `pre-built/email-draft/state.json` exists
2. If not, use `file-mkdir` to create `pre-built/email-draft/` and `pre-built/email-draft/drafts/`
3. Initialize `state.json` with empty arrays and a timestamp of "1970-01-01T00:00:00Z"
## Processing Flow
@ -56,7 +56,7 @@ Read `pre-built/email-draft/state.json` to get:
### Step 2: Scan for New Emails
List emails in `gmail_sync/` folder using `workspace-readdir`.
List emails in `gmail_sync/` folder using `file-list`.
For each email file:
1. Extract the email ID from filename (e.g., `19048cf9c0317981.md``19048cf9c0317981`)

View file

@ -1,20 +1,20 @@
---
tools:
workspace-readFile:
file-readText:
type: builtin
name: workspace-readFile
workspace-writeFile:
name: file-readText
file-writeText:
type: builtin
name: workspace-writeFile
workspace-readdir:
name: file-writeText
file-list:
type: builtin
name: workspace-readdir
workspace-mkdir:
name: file-list
file-mkdir:
type: builtin
name: workspace-mkdir
workspace-exists:
name: file-mkdir
file-exists:
type: builtin
name: workspace-exists
name: file-exists
executeCommand:
type: builtin
name: executeCommand
@ -40,8 +40,8 @@ All state is stored in `pre-built/meeting-prep/`:
On first run, check if state exists. If not, create it:
1. Use `workspace-exists` to check if `pre-built/meeting-prep/state.json` exists
2. If not, use `workspace-mkdir` to create `pre-built/meeting-prep/` and `pre-built/meeting-prep/briefs/`
1. Use `file-exists` to check if `pre-built/meeting-prep/state.json` exists
2. If not, use `file-mkdir` to create `pre-built/meeting-prep/` and `pre-built/meeting-prep/briefs/`
3. Initialize `state.json` with empty `prepared` array and current timestamp
## Processing Flow
@ -54,7 +54,7 @@ Read `pre-built/meeting-prep/state.json` to get:
### Step 2: Scan for Upcoming Meetings
List calendar events in `calendar_sync/` folder using `workspace-readdir`.
List calendar events in `calendar_sync/` folder using `file-list`.
For each event file:
1. Read the JSON content

View file

@ -9,7 +9,7 @@ import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSecurityConfig } from "../config/security.js";
import { addFileAccessGrant, addToSecurityConfig } from "../config/security.js";
import { loadAgent } from "../agents/runtime.js";
import { getDefaultModelAndProvider } from "../models/defaults.js";
@ -60,7 +60,12 @@ export async function authorizePermission(runId: string, ev: z.infer<typeof Tool
&& e.toolCall.toolCallId === rest.toolCallId
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
);
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
if (permReqEvent?.permission?.kind === "file") {
await addFileAccessGrant({
operation: permReqEvent.permission.operation,
pathPrefix: permReqEvent.permission.pathPrefix,
});
} else if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
if (commandNames.length > 0) {
await addToSecurityConfig(commandNames);

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
globals: false,
clearMocks: true,
restoreMocks: true,
},
});

View file

@ -3,13 +3,22 @@ import { z } from 'zod';
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export const BillingUsageBucketSchema = z.object({
sanctionedCredits: z.number(),
usedCredits: z.number(),
availableCredits: z.number(),
});
export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
export const BillingInfoSchema = z.object({
userEmail: z.string().nullable(),
userId: z.string().nullable(),
subscriptionPlan: BillingPlanSchema.nullable(),
subscriptionStatus: z.string().nullable(),
trialExpiresAt: z.string().nullable(),
sanctionedCredits: z.number(),
availableCredits: z.number(),
monthly: BillingUsageBucketSchema,
daily: BillingUsageBucketSchema.extend({
usageDay: z.string(),
}),
});
export type BillingInfo = z.infer<typeof BillingInfoSchema>;

View file

@ -123,6 +123,7 @@ export const GmailThreadMessageSchema = z.object({
unread: z.boolean().optional(),
bodyHeight: z.number().int().positive().optional(),
attachments: z.array(GmailAttachmentSchema).optional(),
messageIdHeader: z.string().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;

View file

@ -148,6 +148,50 @@ const ipcSchemas = {
req: z.object({}),
res: z.object({}),
},
'gmail:sendReply': {
req: z.object({
threadId: z.string().min(1).optional(),
to: z.string().min(1),
cc: z.string().optional(),
bcc: z.string().optional(),
subject: z.string(),
bodyHtml: z.string(),
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
}),
res: z.object({
messageId: z.string().optional(),
error: z.string().optional(),
}),
},
'gmail:getConnectionStatus': {
req: z.object({}),
res: z.object({
connected: z.boolean(),
hasRequiredScope: z.boolean(),
missingScopes: z.array(z.string()),
email: z.string().nullable(),
}),
},
'gmail:getAccountEmail': {
req: z.object({}),
res: z.object({
email: z.string().nullable(),
}),
},
'gmail:archiveThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:trashThread': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:markThreadRead': {
req: z.object({ threadId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),

View file

@ -83,9 +83,23 @@ export const AskHumanResponseEvent = BaseRunEvent.extend({
response: z.string(),
});
export const ToolPermissionMetadata = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("command"),
commandNames: z.array(z.string()),
}),
z.object({
kind: z.literal("file"),
operation: z.enum(["read", "list", "search", "write", "delete"]),
paths: z.array(z.string()),
pathPrefix: z.string(),
}),
]);
export const ToolPermissionRequestEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-request"),
toolCall: ToolCallPart,
permission: ToolPermissionMetadata.optional(),
});
export const ToolPermissionResponseEvent = BaseRunEvent.extend({

238
apps/x/pnpm-lock.yaml generated
View file

@ -441,6 +441,9 @@ importers:
'@types/pdf-parse':
specifier: ^1.1.5
version: 1.1.5
vitest:
specifier: 'catalog:'
version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
packages/shared:
dependencies:
@ -3263,6 +3266,9 @@ packages:
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -3365,6 +3371,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/electron-squirrel-startup@1.0.2':
resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==}
@ -3572,6 +3581,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@4.1.7':
resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
'@vitest/mocker@4.1.7':
resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.7':
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
'@vitest/runner@4.1.7':
resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
'@vitest/snapshot@4.1.7':
resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
'@vitest/spy@4.1.7':
resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
'@vitest/utils@4.1.7':
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
'@vscode/sudo-prompt@9.3.2':
resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==}
@ -3764,6 +3802,10 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
@ -3940,6 +3982,10 @@ packages:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -4683,6 +4729,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -4717,6 +4766,10 @@ packages:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
@ -6218,6 +6271,9 @@ packages:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
ollama-ai-provider-v2@1.5.5:
resolution: {integrity: sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==}
engines: {node: '>=18'}
@ -7021,6 +7077,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -7099,10 +7158,16 @@ packages:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
@ -7244,6 +7309,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@ -7252,6 +7320,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tiptap-markdown@0.9.0:
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
peerDependencies:
@ -7497,6 +7569,7 @@ packages:
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-license@3.0.4:
@ -7565,6 +7638,47 @@ packages:
yaml:
optional: true
vitest@4.1.7:
resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.7
'@vitest/browser-preview': 4.1.7
'@vitest/browser-webdriverio': 4.1.7
'@vitest/coverage-istanbul': 4.1.7
'@vitest/coverage-v8': 4.1.7
'@vitest/ui': 4.1.7
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-jsonrpc@8.2.0:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'}
@ -7643,6 +7757,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@ -11306,6 +11425,11 @@ snapshots:
'@types/node': 25.0.3
'@types/responselike': 1.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.0.3
@ -11435,6 +11559,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/electron-squirrel-startup@1.0.2': {}
'@types/eslint-scope@3.7.7':
@ -11692,6 +11818,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.7':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.1.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
'@vitest/pretty-format@4.1.7':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.7':
dependencies:
'@vitest/utils': 4.1.7
pathe: 2.0.3
'@vitest/snapshot@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
'@vitest/utils': 4.1.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.7': {}
'@vitest/utils@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/sudo-prompt@9.3.2': {}
'@webassemblyjs/ast@1.14.1':
@ -11906,6 +12073,8 @@ snapshots:
arrify@2.0.1: {}
assertion-error@2.0.1: {}
async-lock@1.4.1: {}
async@1.5.2:
@ -12119,6 +12288,8 @@ snapshots:
adler-32: 1.3.1
crc-32: 1.2.2
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -12974,6 +13145,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
etag@1.8.1: {}
@ -13004,6 +13179,8 @@ snapshots:
signal-exit: 3.0.7
strip-eof: 1.0.0
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
express-rate-limit@7.5.1(express@5.2.1):
@ -14943,6 +15120,8 @@ snapshots:
object-keys@1.1.1:
optional: true
obug@2.1.1: {}
ollama-ai-provider-v2@1.5.5(zod@4.2.1):
dependencies:
'@ai-sdk/provider': 2.0.1
@ -15935,6 +16114,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@ -16014,8 +16195,12 @@ snapshots:
dependencies:
minipass: 3.3.6
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@4.1.0: {}
stream-browserify@3.0.0:
dependencies:
inherits: 2.0.4
@ -16182,6 +16367,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@ -16189,6 +16376,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyrainbow@3.1.0: {}
tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)):
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -16503,6 +16692,50 @@ snapshots:
terser: 5.46.0
yaml: 2.8.2
vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.54.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.0.3
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
terser: 5.46.0
yaml: 2.8.2
vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)):
dependencies:
'@vitest/expect': 4.1.7
'@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.1.7
'@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.0.3
transitivePeerDependencies:
- msw
vscode-jsonrpc@8.2.0: {}
vscode-languageserver-protocol@3.17.5:
@ -16606,6 +16839,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wmf@1.0.2: {}
word-wrap@1.2.5: {}

View file

@ -2,6 +2,9 @@ packages:
- apps/*
- packages/*
catalog:
vitest: 4.1.7
onlyBuiltDependencies:
- core-js
- electron