mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
Compare commits
16 commits
25a1976394
...
31ec625bef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ec625bef | ||
|
|
23e1a89c9e | ||
|
|
a79263c5ef | ||
|
|
a4697fc281 | ||
|
|
83389e93fc | ||
|
|
c0493e8b9f | ||
|
|
31e35e00b8 | ||
|
|
f1d3b7b825 | ||
|
|
8aa0c1763a | ||
|
|
84aa980894 | ||
|
|
a59c42e22b | ||
|
|
566f4553b4 | ||
|
|
7cd661d726 | ||
|
|
eb4b11a530 | ||
|
|
6094ac508a | ||
|
|
4db42d17cf |
64 changed files with 3829 additions and 1040 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) {
|
|||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
|
|
|
|||
|
|
@ -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 & 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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>/\`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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**:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
204
apps/x/packages/core/src/filesystem/files.test.ts
Normal file
204
apps/x/packages/core/src/filesystem/files.test.ts
Normal 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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
641
apps/x/packages/core/src/filesystem/files.ts
Normal file
641
apps/x/packages/core/src/filesystem/files.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
7
apps/x/packages/core/tsconfig.build.json
Normal file
7
apps/x/packages/core/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
apps/x/packages/core/vitest.config.ts
Normal file
11
apps/x/packages/core/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
238
apps/x/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ packages:
|
|||
- apps/*
|
||||
- packages/*
|
||||
|
||||
catalog:
|
||||
vitest: 4.1.7
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- electron
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue