Merge dev into slack2, resolve command-executor shell conflict

Keep EXECUTION_SHELL from dev's OS-aware runtime context approach,
remove redundant getShell() function from slack2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
tusharmagar 2026-03-10 15:58:59 +05:30
commit 1094a763dc
58 changed files with 7697 additions and 3528 deletions

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js"; import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js"; import { WorkDir as BASE_DIR } from "../../config/config.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks. export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files. - Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
- Keep user data safedouble-check before editing or deleting important resources. - Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## Workspace access & scope ## Workspace access & scope
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually. - You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location. - If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.

View file

@ -0,0 +1,69 @@
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
export interface RuntimeContext {
platform: NodeJS.Platform;
osName: RuntimeOsName;
shellDialect: RuntimeShellDialect;
shellExecutable: string;
}
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
}
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
if (platform === 'win32') {
return {
platform,
osName: 'Windows',
shellDialect: 'windows-cmd',
shellExecutable: getExecutionShell(platform),
};
}
if (platform === 'darwin') {
return {
platform,
osName: 'macOS',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
if (platform === 'linux') {
return {
platform,
osName: 'Linux',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
return {
platform,
osName: 'Unknown',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
if (runtime.shellDialect === 'windows-cmd') {
return `## Runtime Platform (CRITICAL)
- Detected platform: **${runtime.platform}**
- Detected OS: **${runtime.osName}**
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
}
return `## Runtime Platform (CRITICAL)
- Detected platform: **${runtime.platform}**
- Detected OS: **${runtime.osName}**
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
- Do not assume Windows command syntax when the runtime is POSIX.`;
}

View file

@ -1,11 +1,13 @@
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js'; import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec); const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/; const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string { function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, ''); return token.trim().replace(/^['"]+|['"]+$/g, '');
@ -91,7 +93,7 @@ export async function executeCommand(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility shell: EXECUTION_SHELL,
}); });
return { return {
@ -125,7 +127,7 @@ export function executeCommandSync(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
encoding: 'utf-8', encoding: 'utf-8',
shell: '/bin/sh', shell: EXECUTION_SHELL,
}); });
return { return {

View file

@ -36,6 +36,8 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js'; import { search } from '@x/core/dist/search/search.js';
import { versionHistory } from '@x/core';
import { classifySchedule } from '@x/core/dist/knowledge/inline_tasks.js';
type InvokeChannels = ipc.InvokeChannels; type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels; type IPCChannels = ipc.IPCChannels;
@ -110,6 +112,18 @@ let watcher: FSWatcher | null = null;
const changeQueue = new Set<string>(); const changeQueue = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Emit knowledge commit event to all renderer windows
*/
function emitKnowledgeCommitEvent(): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('knowledge:didCommit', {});
}
}
}
/** /**
* Emit workspace change event to all renderer windows * Emit workspace change event to all renderer windows
*/ */
@ -288,6 +302,9 @@ export function stopServicesWatcher(): void {
* Add new handlers here as you add channels to IPCChannels * Add new handlers here as you add channels to IPCChannels
*/ */
export function setupIpcHandlers() { export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
registerIpcHandlers({ registerIpcHandlers({
'app:getVersions': async () => { 'app:getVersions': async () => {
// args is null for this channel (no request payload) // args is null for this channel (no request payload)
@ -527,9 +544,27 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream'; const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size }; return { data: buffer.toString('base64'), mimeType, size: stat.size };
}, },
// Knowledge version history handlers
'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path);
return { commits };
},
'knowledge:fileAtCommit': async (_event, args) => {
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
return { content };
},
'knowledge:restore': async (_event, args) => {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
// Search handler // Search handler
'search:query': async (_event, args) => { 'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types); return search(args.query, args.limit, args.types);
}, },
// Inline task schedule classification
'inline-task:classifySchedule': async (_event, args) => {
const schedule = await classifySchedule(args.instruction);
return { schedule };
},
}); });
} }

View file

@ -17,6 +17,9 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup"; import started from "electron-squirrel-startup";
@ -170,6 +173,15 @@ app.whenReady().then(async () => {
// start knowledge graph builder // start knowledge graph builder
initGraphBuilder(); initGraphBuilder();
// start email labeling service
initEmailLabeling();
// start note tagging service
initNoteTagging();
// start inline task service (@rowboat: mentions)
initInlineTasks();
// start background agent runner (scheduled agents) // start background agent runner (scheduled agents)
initAgentRunner(); initAgentRunner();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,820 @@
import * as React from 'react'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, X, Check, ListFilter, Filter, Search, Save } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { Command, CommandInput, CommandList, CommandItem, CommandEmpty, CommandGroup } from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { splitFrontmatter, extractAllFrontmatterValues } from '@/lib/frontmatter'
import { useDebounce } from '@/hooks/use-debounce'
interface TreeNode {
path: string
name: string
kind: 'file' | 'dir'
children?: TreeNode[]
stat?: { size: number; mtimeMs: number }
}
type NoteEntry = {
path: string
name: string
folder: string
fields: Record<string, string | string[]>
mtimeMs: number
}
type SortDir = 'asc' | 'desc'
type ActiveFilter = { category: string; value: string }
export type BaseConfig = {
name: string
visibleColumns: string[]
columnWidths: Record<string, number>
sort: { field: string; dir: SortDir }
filters: ActiveFilter[]
}
export const DEFAULT_BASE_CONFIG: BaseConfig = {
name: 'All Notes',
visibleColumns: ['name', 'folder', 'relationship', 'topic', 'status', 'mtimeMs'],
columnWidths: {},
sort: { field: 'mtimeMs', dir: 'desc' },
filters: [],
}
const PAGE_SIZE = 25
/** Built-in columns that don't come from frontmatter */
const BUILTIN_COLUMNS = ['name', 'folder', 'mtimeMs'] as const
type BuiltinColumn = (typeof BUILTIN_COLUMNS)[number]
const BUILTIN_LABELS: Record<BuiltinColumn, string> = {
name: 'Name',
folder: 'Folder',
mtimeMs: 'Last Modified',
}
/** Default pixel widths for columns */
const DEFAULT_WIDTHS: Record<string, number> = {
name: 200,
folder: 140,
mtimeMs: 140,
}
const DEFAULT_FRONTMATTER_WIDTH = 150
/** Convert key to title case: `first_met` → `First Met` */
function toTitleCase(key: string): string {
if (key in BUILTIN_LABELS) return BUILTIN_LABELS[key as BuiltinColumn]
return key
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
type BasesViewProps = {
tree: TreeNode[]
onSelectNote: (path: string) => void
config: BaseConfig
onConfigChange: (config: BaseConfig) => void
isDefaultBase: boolean
onSave: (name: string | null) => void
}
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
return nodes.flatMap((n) =>
n.kind === 'file' && n.name.endsWith('.md')
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
: n.children
? collectFiles(n.children)
: [],
)
}
function getFolder(path: string): string {
const parts = path.split('/')
if (parts.length >= 3) return parts[1]
return ''
}
function formatDate(ms: number): string {
if (!ms) return ''
const d = new Date(ms)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function filtersEqual(a: ActiveFilter, b: ActiveFilter): boolean {
return a.category === b.category && a.value === b.value
}
function hasFilter(filters: ActiveFilter[], f: ActiveFilter): boolean {
return filters.some((x) => filtersEqual(x, f))
}
/** Get the string values for a column from a note */
function getColumnValues(note: NoteEntry, column: string): string[] {
if (column === 'name') return [note.name]
if (column === 'folder') return [note.folder]
if (column === 'mtimeMs') return []
const v = note.fields[column]
if (!v) return []
return Array.isArray(v) ? v : [v]
}
/** Get a single sortable string for a column */
function getSortValue(note: NoteEntry, column: string): string | number {
if (column === 'name') return note.name
if (column === 'folder') return note.folder
if (column === 'mtimeMs') return note.mtimeMs
const v = note.fields[column]
if (!v) return ''
return Array.isArray(v) ? v[0] ?? '' : v
}
const isBuiltin = (col: string): col is BuiltinColumn =>
(BUILTIN_COLUMNS as readonly string[]).includes(col)
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) {
// Build notes instantly from tree
const notes = useMemo<NoteEntry[]>(() => {
return collectFiles(tree).map((f) => ({
path: f.path,
name: f.name,
folder: getFolder(f.path),
fields: {},
mtimeMs: f.mtimeMs,
}))
}, [tree])
// Frontmatter fields loaded async, keyed by path
const [fieldsByPath, setFieldsByPath] = useState<Map<string, Record<string, string | string[]>>>(new Map())
const loadGenRef = useRef(0)
// Load frontmatter in background batches
useEffect(() => {
const gen = ++loadGenRef.current
let cancelled = false
const paths = notes.map((n) => n.path)
async function load() {
const BATCH = 30
for (let i = 0; i < paths.length; i += BATCH) {
if (cancelled) return
const batch = paths.slice(i, i + BATCH)
const results = await Promise.all(
batch.map(async (p) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: p, encoding: 'utf8' })
const { raw } = splitFrontmatter(result.data)
return { path: p, fields: extractAllFrontmatterValues(raw) }
} catch {
return { path: p, fields: {} as Record<string, string | string[]> }
}
}),
)
if (cancelled || gen !== loadGenRef.current) return
setFieldsByPath((prev) => {
const next = new Map(prev)
for (const r of results) next.set(r.path, r.fields)
return next
})
}
}
load()
return () => { cancelled = true }
}, [notes])
// Merge tree-derived notes with async-loaded fields
const enrichedNotes = useMemo<NoteEntry[]>(() => {
if (fieldsByPath.size === 0) return notes
return notes.map((n) => {
const f = fieldsByPath.get(n.path)
return f ? { ...n, fields: f } : n
})
}, [notes, fieldsByPath])
// Collect all unique frontmatter property keys across all notes
const allPropertyKeys = useMemo<string[]>(() => {
const keys = new Set<string>()
for (const fields of fieldsByPath.values()) {
for (const k of Object.keys(fields)) keys.add(k)
}
return Array.from(keys).sort()
}, [fieldsByPath])
// Filterable categories: "folder" + all frontmatter keys
const filterCategories = useMemo<string[]>(() => {
return ['folder', ...allPropertyKeys]
}, [allPropertyKeys])
// All unique values per category, across all enriched notes
const valuesByCategory = useMemo<Record<string, string[]>>(() => {
const result: Record<string, Set<string>> = {}
for (const cat of filterCategories) result[cat] = new Set()
for (const note of enrichedNotes) {
for (const cat of filterCategories) {
for (const v of getColumnValues(note, cat)) {
if (v) result[cat]?.add(v)
}
}
}
const out: Record<string, string[]> = {}
for (const [cat, set] of Object.entries(result)) {
out[cat] = Array.from(set).sort((a, b) => a.localeCompare(b))
}
return out
}, [filterCategories, enrichedNotes])
const visibleColumns = config.visibleColumns
const columnWidths = config.columnWidths
const filters = config.filters
const sortField = config.sort.field
const sortDir = config.sort.dir
const [page, setPage] = useState(0)
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [saveName, setSaveName] = useState('')
const saveInputRef = useRef<HTMLInputElement>(null)
const [filterCategory, setFilterCategory] = useState<string | null>(null)
const handleSaveClick = useCallback(() => {
if (isDefaultBase) {
setSaveName('')
setSaveDialogOpen(true)
} else {
onSave(null)
}
}, [isDefaultBase, onSave])
const handleSaveConfirm = useCallback(() => {
const name = saveName.trim()
if (!name) return
setSaveDialogOpen(false)
onSave(name)
}, [saveName, onSave])
const getColWidth = useCallback((col: string) => {
return columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
}, [columnWidths])
// Column resize via drag
const resizingRef = useRef<{ col: string; startX: number; startW: number } | null>(null)
const configRef = useRef(config)
configRef.current = config
const onResizeStart = useCallback((col: string, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startW = configRef.current.columnWidths[col] ?? DEFAULT_WIDTHS[col] ?? DEFAULT_FRONTMATTER_WIDTH
resizingRef.current = { col, startX, startW }
const onMouseMove = (ev: MouseEvent) => {
if (!resizingRef.current) return
const delta = ev.clientX - resizingRef.current.startX
const newW = Math.max(60, resizingRef.current.startW + delta)
const c = configRef.current
const updated = { ...c, columnWidths: { ...c.columnWidths, [resizingRef.current!.col]: newW } }
onConfigChange(updated)
}
const onMouseUp = () => {
resizingRef.current = null
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}, [onConfigChange])
// Search
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearch = useDebounce(searchQuery, 250)
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!debouncedSearch.trim()) {
setSearchMatchPaths(null)
return
}
let cancelled = false
window.ipc.invoke('search:query', { query: debouncedSearch, limit: 200, types: ['knowledge'] })
.then((res: { results: { path: string }[] }) => {
if (!cancelled) {
setSearchMatchPaths(new Set(res.results.map((r) => r.path)))
}
})
.catch(() => {
if (!cancelled) setSearchMatchPaths(new Set())
})
return () => { cancelled = true }
}, [debouncedSearch])
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
if (prev) {
setSearchQuery('')
setSearchMatchPaths(null)
}
return !prev
})
}, [])
// Focus input when search opens
useEffect(() => {
if (searchOpen) searchInputRef.current?.focus()
}, [searchOpen])
// Reset page when filters or search change
useEffect(() => { setPage(0) }, [filters, searchMatchPaths])
// Filter (search + badge filters)
const filteredNotes = useMemo(() => {
let result = enrichedNotes
// Apply search filter
if (searchMatchPaths) {
result = result.filter((note) => searchMatchPaths.has(note.path))
}
// Apply badge filters
if (filters.length > 0) {
const byCategory = new Map<string, string[]>()
for (const f of filters) {
const vals = byCategory.get(f.category) ?? []
vals.push(f.value)
byCategory.set(f.category, vals)
}
result = result.filter((note) => {
for (const [category, requiredValues] of byCategory) {
const noteValues = getColumnValues(note, category)
if (!requiredValues.some((v) => noteValues.includes(v))) return false
}
return true
})
}
return result
}, [enrichedNotes, filters, searchMatchPaths])
// Sort
const sortedNotes = useMemo(() => {
return [...filteredNotes].sort((a, b) => {
const va = getSortValue(a, sortField)
const vb = getSortValue(b, sortField)
let cmp: number
if (typeof va === 'number' && typeof vb === 'number') {
cmp = va - vb
} else {
cmp = String(va).localeCompare(String(vb))
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filteredNotes, sortField, sortDir])
// Paginate
const totalPages = Math.max(1, Math.ceil(sortedNotes.length / PAGE_SIZE))
const clampedPage = Math.min(page, totalPages - 1)
const pageNotes = useMemo(
() => sortedNotes.slice(clampedPage * PAGE_SIZE, (clampedPage + 1) * PAGE_SIZE),
[sortedNotes, clampedPage],
)
const toggleFilter = useCallback((category: string, value: string) => {
const c = configRef.current
const f: ActiveFilter = { category, value }
const next = hasFilter(c.filters, f)
? c.filters.filter((x) => !filtersEqual(x, f))
: [...c.filters, f]
onConfigChange({ ...c, filters: next })
}, [onConfigChange])
const clearFilters = useCallback(() => {
onConfigChange({ ...configRef.current, filters: [] })
}, [onConfigChange])
const handleSort = useCallback((field: string) => {
const c = configRef.current
if (field === c.sort.field) {
onConfigChange({ ...c, sort: { field, dir: c.sort.dir === 'asc' ? 'desc' : 'asc' } })
} else {
onConfigChange({ ...c, sort: { field, dir: field === 'mtimeMs' ? 'desc' : 'asc' } })
}
}, [onConfigChange])
const toggleColumn = useCallback((key: string) => {
const c = configRef.current
const next = c.visibleColumns.includes(key)
? c.visibleColumns.filter((col) => col !== key)
: [...c.visibleColumns, key]
onConfigChange({ ...c, visibleColumns: next })
}, [onConfigChange])
const SortIcon = ({ field }: { field: string }) => {
if (sortField !== field) return null
return sortDir === 'asc'
? <ArrowUp className="size-3 inline ml-1" />
: <ArrowDown className="size-3 inline ml-1" />
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="shrink-0 border-b border-border px-4 py-2 flex items-center gap-3">
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<ListFilter className="size-3.5" />
Properties
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-0">
<Command>
<CommandInput placeholder="Search properties..." />
<CommandList>
<CommandEmpty>No properties found.</CommandEmpty>
<CommandGroup heading="Built-in">
{BUILTIN_COLUMNS.map((col) => (
<CommandItem key={col} onSelect={() => toggleColumn(col)}>
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(col) ? 'opacity-100' : 'opacity-0')} />
{BUILTIN_LABELS[col]}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Frontmatter">
{allPropertyKeys.map((key) => (
<CommandItem key={key} onSelect={() => toggleColumn(key)}>
<Check className={cn('size-3.5 mr-2', visibleColumns.includes(key) ? 'opacity-100' : 'opacity-0')} />
{toTitleCase(key)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Popover onOpenChange={(open) => { if (!open) setFilterCategory(null) }}>
<PopoverTrigger asChild>
<button className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground',
filters.length > 0 && 'text-foreground',
)}>
<Filter className="size-3.5" />
Filter
{filters.length > 0 && (
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight">
{filters.length}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className={cn('p-0', filterCategory ? 'w-[420px]' : 'w-[200px]')}>
<div className="flex h-[300px]">
{/* Left: categories */}
<div className={cn('overflow-auto', filterCategory ? 'w-[160px] border-r border-border' : 'flex-1')}>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Attributes</span>
{filters.length > 0 && (
<button
onClick={clearFilters}
className="text-[10px] text-muted-foreground hover:text-foreground"
>
Reset
</button>
)}
</div>
{filterCategories.map((cat) => {
const activeCount = filters.filter((f) => f.category === cat).length
const isSelected = filterCategory === cat
return (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors',
isSelected && 'bg-accent text-foreground',
!isSelected && 'text-muted-foreground',
)}
>
<span className="flex-1 truncate">{toTitleCase(cat)}</span>
{activeCount > 0 && (
<span className="rounded-full bg-primary text-primary-foreground px-1.5 text-[10px] font-medium leading-tight shrink-0">
{activeCount}
</span>
)}
</button>
)
})}
</div>
{/* Right: values for selected category */}
{filterCategory && (
<div className="flex-1 min-w-0 flex flex-col">
<Command className="flex-1 flex flex-col">
<CommandInput placeholder={`Search ${toTitleCase(filterCategory).toLowerCase()}...`} />
<CommandList className="flex-1 overflow-auto max-h-none">
<CommandEmpty>No values found.</CommandEmpty>
<CommandGroup>
{(valuesByCategory[filterCategory] ?? []).map((val) => {
const active = hasFilter(filters, { category: filterCategory, value: val })
return (
<CommandItem key={val} onSelect={() => toggleFilter(filterCategory, val)}>
<Check className={cn('size-3.5 mr-2 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{val}</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</PopoverContent>
</Popover>
<button
onClick={toggleSearch}
className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0',
searchOpen && 'text-foreground',
)}
>
<Search className="size-3.5" />
Search
</button>
{searchOpen && (
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search notes..."
className="flex-1 min-w-0 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
/>
{searchQuery && (
<span className="text-[10px] text-muted-foreground shrink-0">
{searchMatchPaths ? `${searchMatchPaths.size} matches` : '...'}
</span>
)}
<button
onClick={toggleSearch}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</div>
)}
<div className="flex-1" />
<button
onClick={handleSaveClick}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
>
<Save className="size-3.5" />
{isDefaultBase ? 'Save As' : 'Save'}
</button>
</div>
{/* Filter bar */}
{filters.length > 0 && (
<div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">
{sortedNotes.length} of {enrichedNotes.length} notes
</span>
{filters.map((f) => (
<button
key={`${f.category}:${f.value}`}
onClick={() => toggleFilter(f.category, f.value)}
className="inline-flex items-center gap-1 rounded-full bg-primary text-primary-foreground px-2 py-0.5 text-[11px] font-medium"
>
<span className="text-primary-foreground/60">{f.category}:</span>
{f.value}
<X className="size-3" />
</button>
))}
<button onClick={clearFilters} className="text-xs text-muted-foreground hover:text-foreground">
Clear all
</button>
</div>
</div>
)}
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm" style={{ tableLayout: 'fixed' }}>
<colgroup>
{visibleColumns.map((col) => (
<col key={col} style={{ width: getColWidth(col) }} />
))}
</colgroup>
<thead className="sticky top-0 bg-background border-b border-border z-10">
<tr>
{visibleColumns.map((col) => (
<th
key={col}
className="relative text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none group"
onClick={() => handleSort(col)}
>
<span className="truncate block">{toTitleCase(col)}<SortIcon field={col} /></span>
{/* Resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover:opacity-100 hover:!opacity-100 bg-border/60"
onMouseDown={(e) => onResizeStart(col, e)}
onClick={(e) => e.stopPropagation()}
/>
</th>
))}
</tr>
</thead>
<tbody>
{pageNotes.map((note) => (
<tr
key={note.path}
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectNote(note.path)}
>
{visibleColumns.map((col) => (
<td key={col} className="px-4 py-2 overflow-hidden">
<CellRenderer
note={note}
column={col}
filters={filters}
toggleFilter={toggleFilter}
/>
</td>
))}
</tr>
))}
{pageNotes.length === 0 && (
<tr>
<td colSpan={visibleColumns.length} className="px-4 py-8 text-center text-muted-foreground">
No notes found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="shrink-0 border-t border-border px-4 py-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{sortedNotes.length === 0
? '0 notes'
: `${clampedPage * PAGE_SIZE + 1}\u2013${Math.min((clampedPage + 1) * PAGE_SIZE, sortedNotes.length)} of ${sortedNotes.length}`}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<span className="text-xs text-muted-foreground px-2">
Page {clampedPage + 1} of {totalPages}
</span>
<button
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
{/* Save As dialog */}
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>Save Base</DialogTitle>
<DialogDescription>Choose a name for this base view.</DialogDescription>
</DialogHeader>
<input
ref={saveInputRef}
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveConfirm() }}
placeholder="e.g. Contacts, Projects..."
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<DialogFooter>
<button
onClick={() => setSaveDialogOpen(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleSaveConfirm}
disabled={!saveName.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/** Renders a single table cell based on the column type */
function CellRenderer({
note,
column,
filters,
toggleFilter,
}: {
note: NoteEntry
column: string
filters: ActiveFilter[]
toggleFilter: (category: string, value: string) => void
}) {
if (column === 'name') {
return <span className="font-medium truncate block">{note.name}</span>
}
if (column === 'folder') {
return <span className="text-muted-foreground truncate block">{note.folder}</span>
}
if (column === 'mtimeMs') {
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
}
// Frontmatter column
const value = note.fields[column]
if (!value) return null
if (Array.isArray(value)) {
return (
<div className="flex items-center gap-1 flex-wrap">
{value.map((v) => (
<CategoryBadge
key={v}
category={column}
value={v}
active={hasFilter(filters, { category: column, value: v })}
onClick={toggleFilter}
/>
))}
</div>
)
}
// Single string value — render as badge for filterability
return (
<CategoryBadge
category={column}
value={value}
active={hasFilter(filters, { category: column, value })}
onClick={toggleFilter}
/>
)
}
function CategoryBadge({
category,
value,
active,
onClick,
}: {
category: string
value: string
active: boolean
onClick: (category: string, value: string) => void
}) {
return (
<Badge
variant={active ? 'default' : 'secondary'}
className={cn(
'text-[10px] px-1.5 py-0 cursor-pointer',
!active && 'hover:bg-primary hover:text-primary-foreground',
)}
onClick={(e) => {
e.stopPropagation()
onClick(category, value)
}}
>
{value}
</Badge>
)
}

View file

@ -1,7 +1,36 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowUp, LoaderIcon, Square } from 'lucide-react' import {
ArrowUp,
AudioLines,
ChevronDown,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
LoaderIcon,
Plus,
Square,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
getAttachmentIconKind,
getAttachmentToneClass,
getAttachmentTypeLabel,
} from '@/lib/attachment-presentation'
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
type FileMention, type FileMention,
@ -10,9 +39,60 @@ import {
PromptInputTextarea, PromptInputTextarea,
usePromptInputController, usePromptInputController,
} from '@/components/ai-elements/prompt-input' } from '@/components/ai-elements/prompt-input'
import { toast } from 'sonner'
export type StagedAttachment = {
id: string
path: string
filename: string
mimeType: string
isImage: boolean
size: number
thumbnailUrl?: string
}
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
}
interface ConfiguredModel {
flavor: string
model: string
apiKey?: string
baseURL?: string
headers?: Record<string, string>
knowledgeGraphModel?: string
}
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
return AudioLines
case 'video':
return FileVideo
case 'spreadsheet':
return FileSpreadsheet
case 'archive':
return FileArchive
case 'code':
return FileCode2
case 'text':
return FileText
default:
return FileIcon
}
}
interface ChatInputInnerProps { interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
@ -38,7 +118,94 @@ function ChatInputInner({
}: ChatInputInnerProps) { }: ChatInputInnerProps) {
const controller = usePromptInputController() const controller = usePromptInputController()
const message = controller.textInput.value const message = controller.textInput.value
const canSubmit = Boolean(message.trim()) && !isProcessing const [attachments, setAttachments] = useState<StagedAttachment[]>([])
const [focusNonce, setFocusNonce] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
const [activeModelKey, setActiveModelKey] = useState('')
// Load model config from disk (on mount and whenever tab becomes active)
const loadModelConfig = useCallback(async () => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
const models: ConfiguredModel[] = []
if (parsed?.providers) {
for (const [flavor, entry] of Object.entries(parsed.providers)) {
const e = entry as Record<string, unknown>
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
const singleModel = typeof e.model === 'string' ? e.model : ''
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
for (const model of allModels) {
if (model) {
models.push({
flavor,
model,
apiKey: (e.apiKey as string) || undefined,
baseURL: (e.baseURL as string) || undefined,
headers: (e.headers as Record<string, string>) || undefined,
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
})
}
}
}
}
const defaultKey = parsed?.provider?.flavor && parsed?.model
? `${parsed.provider.flavor}/${parsed.model}`
: ''
models.sort((a, b) => {
const aKey = `${a.flavor}/${a.model}`
const bKey = `${b.flavor}/${b.model}`
if (aKey === defaultKey) return -1
if (bKey === defaultKey) return 1
return 0
})
setConfiguredModels(models)
if (defaultKey) {
setActiveModelKey(defaultKey)
}
} catch {
// No config yet
}
}, [])
useEffect(() => {
loadModelConfig()
}, [isActive, loadModelConfig])
// Reload when model config changes (e.g. from settings dialog)
useEffect(() => {
const handler = () => { loadModelConfig() }
window.addEventListener('models-config-changed', handler)
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
const handleModelChange = useCallback(async (key: string) => {
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
if (!entry) return
setActiveModelKey(key)
// Collect all models for this provider so the full list is preserved
const providerModels = configuredModels
.filter((m) => m.flavor === entry.flavor)
.map((m) => m.model)
try {
await window.ipc.invoke('models:saveConfig', {
provider: {
flavor: entry.flavor,
apiKey: entry.apiKey,
baseURL: entry.baseURL,
headers: entry.headers,
},
model: entry.model,
models: providerModels,
knowledgeGraphModel: entry.knowledgeGraphModel,
})
} catch {
toast.error('Failed to switch model')
}
}, [configuredModels])
// Restore the tab draft when this input mounts. // Restore the tab draft when this input mounts.
useEffect(() => { useEffect(() => {
@ -59,12 +226,48 @@ function ChatInputInner({
} }
}, [presetMessage, controller.textInput, onPresetMessageConsumed]) }, [presetMessage, controller.textInput, onPresetMessageConsumed])
const addFiles = useCallback(async (paths: string[]) => {
const newAttachments: StagedAttachment[] = []
for (const filePath of paths) {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
if (result.size > MAX_ATTACHMENT_SIZE) {
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
continue
}
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
const image = isImageMime(mime)
newAttachments.push({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
path: filePath,
filename: getFileDisplayName(filePath),
mimeType: mime,
isImage: image,
size: result.size,
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
})
} catch (err) {
console.error('Failed to read file:', filePath, err)
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
}
}
if (newAttachments.length > 0) {
setAttachments((prev) => [...prev, ...newAttachments])
setFocusNonce((value) => value + 1)
}
}, [])
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}, [])
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!canSubmit) return if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
controller.textInput.clear() controller.textInput.clear()
controller.mentions.clearMentions() controller.mentions.clearMentions()
}, [canSubmit, message, onSubmit, controller]) setAttachments([])
}, [attachments, canSubmit, controller, message, onSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -88,11 +291,9 @@ function ChatInputInner({
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files) const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file)) .map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) .filter(Boolean) as string[]
if (paths.length > 0) { if (paths.length > 0) {
const currentText = controller.textInput.value void addFiles(paths)
const pathText = paths.join(' ')
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
} }
} }
} }
@ -103,50 +304,150 @@ function ChatInputInner({
document.removeEventListener('dragover', onDragOver) document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop) document.removeEventListener('drop', onDrop)
} }
}, [controller, isActive]) }, [addFiles, isActive])
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none"> <div className="rounded-lg border border-border bg-background shadow-none">
<PromptInputTextarea {attachments.length > 0 && (
placeholder="Type your message..." <div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
onKeyDown={handleKeyDown} {attachments.map((attachment) => {
autoFocus={isActive} const attachmentType = getAttachmentTypeLabel(attachment)
focusTrigger={isActive ? runId : undefined} const attachmentName = getAttachmentDisplayName(attachment)
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
/>
{isProcessing ? ( return (
<Button <span
size="icon" key={attachment.id}
onClick={onStop} className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
title={isStopping ? 'Click again to force stop' : 'Stop generation'} >
className={cn( <span
'h-7 w-7 shrink-0 rounded-full transition-all', className={cn(
isStopping 'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' attachment.isImage && attachment.thumbnailUrl
: 'bg-primary text-primary-foreground hover:bg-primary/90' ? 'bg-muted'
)} : getAttachmentToneClass(attachmentType)
> )}
{isStopping ? ( >
<LoaderIcon className="h-4 w-4 animate-spin" /> {attachment.isImage && attachment.thumbnailUrl ? (
) : ( <img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
<Square className="h-3 w-3 fill-current" /> ) : (
)} <Icon className="size-5" />
</Button> )}
) : ( </span>
<Button <span className="min-w-0 flex-1">
size="icon" <span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
onClick={handleSubmit} <span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
disabled={!canSubmit} </span>
className={cn( <button
'h-7 w-7 shrink-0 rounded-full transition-all', type="button"
canSubmit onClick={() => removeAttachment(attachment.id)}
? 'bg-primary text-primary-foreground hover:bg-primary/90' className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
: 'bg-muted text-muted-foreground' >
)} <X className="size-3.5" />
> </button>
<ArrowUp className="h-4 w-4" /> </span>
</Button> )
})}
</div>
)} )}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (!files || files.length === 0) return
const paths = Array.from(files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean) as string[]
if (paths.length > 0) {
void addFiles(paths)
}
e.target.value = ''
}}
/>
<div className="px-4 pt-4 pb-2">
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Attach files"
>
<Plus className="h-4 w-4" />
</button>
<div className="flex-1" />
{configuredModels.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<span className="max-w-[150px] truncate">
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || 'Model'}
</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
{configuredModels.map((m) => {
const key = `${m.flavor}/${m.model}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m.model}</span>
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
isStopping
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'h-7 w-7 shrink-0 rounded-full transition-all',
canSubmit
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground'
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
) )
} }
@ -155,7 +456,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[] knowledgeFiles: string[]
recentFiles: string[] recentFiles: string[]
visibleFiles: string[] visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void onStop?: () => void
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean

View file

@ -0,0 +1,137 @@
import {
AudioLines,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import type { MessageAttachment } from '@/lib/chat-conversation'
import {
type AttachmentIconKind,
getAttachmentDisplayName,
getAttachmentIconKind,
getAttachmentToneClass,
getAttachmentTypeLabel,
} from '@/lib/attachment-presentation'
import { isImageMime, toFileUrl } from '@/lib/file-utils'
import { cn } from '@/lib/utils'
function getAttachmentIcon(kind: AttachmentIconKind) {
switch (kind) {
case 'audio':
return AudioLines
case 'video':
return FileVideo
case 'spreadsheet':
return FileSpreadsheet
case 'archive':
return FileArchive
case 'code':
return FileCode2
case 'text':
return FileText
default:
return FileIcon
}
}
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
useEffect(() => {
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
setSrc(nextSrc)
setTriedBase64(Boolean(attachment.thumbnailUrl))
}, [attachment.thumbnailUrl, fallbackFileUrl])
const loadBase64 = useMemo(
() => async () => {
try {
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
const mimeType = result.mimeType || attachment.mimeType || 'image/*'
setSrc(`data:${mimeType};base64,${result.data}`)
} catch {
// Keep current src; fallback rendering (broken image icon) is better than crashing.
}
},
[attachment.mimeType, attachment.path]
)
useEffect(() => {
if (attachment.thumbnailUrl || triedBase64) return
setTriedBase64(true)
void loadBase64()
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
return (
<img
src={src}
alt="Image attachment"
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
onError={() => {
if (triedBase64) return
setTriedBase64(true)
void loadBase64()
}}
/>
)
}
interface ChatMessageAttachmentsProps {
attachments: MessageAttachment[]
className?: string
}
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
if (attachments.length === 0) return null
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
return (
<div className={cn('flex flex-col items-end gap-2', className)}>
{imageAttachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{imageAttachments.map((attachment, index) => (
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{fileAttachments.map((attachment, index) => {
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
const attachmentName = getAttachmentDisplayName(attachment)
const attachmentType = getAttachmentTypeLabel(attachment)
return (
<span
key={`${attachment.path}-${index}`}
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
title={attachmentName}
>
<span
className={cn(
'flex size-12 shrink-0 items-center justify-center rounded-xl',
getAttachmentToneClass(attachmentType)
)}
>
<Icon className="size-6 shrink-0" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
</span>
</span>
)
})}
</div>
)}
</div>
)
}

View file

@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab } from '@/components/tab-bar' import { TabBar, type ChatTab } from '@/components/tab-bar'
import { ChatInputWithMentions } from '@/components/chat-input-with-mentions' import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
import { wikiLabel } from '@/lib/wiki-links' import { wikiLabel } from '@/lib/wiki-links'
import { import {
type ChatTabViewState, type ChatTabViewState,
@ -89,7 +90,7 @@ interface ChatSidebarProps {
isProcessing: boolean isProcessing: boolean
isStopping?: boolean isStopping?: boolean
onStop?: () => void onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[] knowledgeFiles?: string[]
recentFiles?: string[] recentFiles?: string[]
visibleFiles?: string[] visibleFiles?: string[]
@ -256,6 +257,18 @@ export function ChatSidebar({
const renderConversationItem = (item: ConversationItem, tabId: string) => { const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
if (item.attachments && item.attachments.length > 0) {
return (
<Message key={item.id} from={item.role}>
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
<ChatMessageAttachments attachments={item.attachments} />
</MessageContent>
{item.content && (
<MessageContent>{item.content}</MessageContent>
)}
</Message>
)
}
const { message, files } = parseAttachedFiles(item.content) const { message, files } = parseAttachedFiles(item.content)
return ( return (
<Message key={item.id} from={item.role}> <Message key={item.id} from={item.role}>

View file

@ -0,0 +1,252 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { ChevronRight, X, Plus } from 'lucide-react'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
interface FrontmatterPropertiesProps {
raw: string | null
onRawChange: (raw: string | null) => void
editable?: boolean
}
type FieldEntry = { key: string; value: string | string[] }
function fieldsFromRaw(raw: string | null): FieldEntry[] {
const record = extractAllFrontmatterValues(raw)
return Object.entries(record).map(([key, value]) => ({ key, value }))
}
function fieldsToRaw(fields: FieldEntry[]): string | null {
const record: Record<string, string | string[]> = {}
for (const { key, value } of fields) {
if (key.trim()) record[key.trim()] = value
}
return buildFrontmatter(record)
}
export function FrontmatterProperties({ raw, onRawChange, editable = true }: FrontmatterPropertiesProps) {
const [expanded, setExpanded] = useState(false)
const [fields, setFields] = useState<FieldEntry[]>(() => fieldsFromRaw(raw))
const [editingNewKey, setEditingNewKey] = useState(false)
const newKeyRef = useRef<HTMLInputElement>(null)
const lastCommittedRaw = useRef(raw)
// Sync local fields when raw changes externally (e.g. tab switch)
useEffect(() => {
if (raw !== lastCommittedRaw.current) {
setFields(fieldsFromRaw(raw))
lastCommittedRaw.current = raw
}
}, [raw])
useEffect(() => {
if (editingNewKey && newKeyRef.current) {
newKeyRef.current.focus()
}
}, [editingNewKey])
const commit = useCallback((updated: FieldEntry[]) => {
const newRaw = fieldsToRaw(updated)
lastCommittedRaw.current = newRaw
onRawChange(newRaw)
}, [onRawChange])
// For scalar fields: update local state immediately, commit on blur
const updateLocalValue = useCallback((index: number, newValue: string) => {
setFields(prev => {
const next = [...prev]
next[index] = { ...next[index], value: newValue }
return next
})
}, [])
const commitField = useCallback((index: number) => {
setFields(prev => {
commit(prev)
return prev
})
}, [commit])
// For array fields and structural changes: update + commit immediately
const updateAndCommit = useCallback((updater: (prev: FieldEntry[]) => FieldEntry[]) => {
setFields(prev => {
const next = updater(prev)
commit(next)
return next
})
}, [commit])
const removeField = useCallback((index: number) => {
updateAndCommit(prev => prev.filter((_, i) => i !== index))
}, [updateAndCommit])
const addField = useCallback((key: string) => {
const trimmed = key.trim()
if (!trimmed) return
if (fields.some(f => f.key === trimmed)) return
updateAndCommit(prev => [...prev, { key: trimmed, value: '' }])
setEditingNewKey(false)
}, [fields, updateAndCommit])
const count = fields.length
return (
<div className="frontmatter-properties">
<button
className="frontmatter-toggle"
onClick={() => setExpanded(!expanded)}
type="button"
>
<ChevronRight
size={14}
className={`frontmatter-chevron ${expanded ? 'expanded' : ''}`}
/>
<span className="frontmatter-label">
Properties{count > 0 ? ` (${count})` : ''}
</span>
</button>
{expanded && (
<div className="frontmatter-fields">
{fields.map((field, index) => (
<div key={`${field.key}-${index}`} className="frontmatter-row">
<span className="frontmatter-key" title={field.key}>
{field.key}
</span>
<div className="frontmatter-value-area">
{Array.isArray(field.value) ? (
<ArrayField
value={field.value}
editable={editable}
onChange={(v) => updateAndCommit(prev => {
const next = [...prev]
next[index] = { ...next[index], value: v }
return next
})}
/>
) : (
<input
className="frontmatter-input"
value={field.value}
readOnly={!editable}
onChange={(e) => updateLocalValue(index, e.target.value)}
onBlur={() => commitField(index)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
/>
)}
</div>
{editable && (
<button
className="frontmatter-remove"
onClick={() => removeField(index)}
type="button"
title="Remove property"
>
<X size={12} />
</button>
)}
</div>
))}
{editable && (
editingNewKey ? (
<div className="frontmatter-row frontmatter-new-row">
<input
ref={newKeyRef}
className="frontmatter-input frontmatter-new-key-input"
placeholder="Property name"
onKeyDown={(e) => {
if (e.key === 'Enter') {
addField(e.currentTarget.value)
} else if (e.key === 'Escape') {
setEditingNewKey(false)
}
}}
onBlur={(e) => {
if (e.currentTarget.value.trim()) {
addField(e.currentTarget.value)
} else {
setEditingNewKey(false)
}
}}
/>
</div>
) : (
<button
className="frontmatter-add"
onClick={() => setEditingNewKey(true)}
type="button"
>
<Plus size={12} />
<span>Add property</span>
</button>
)
)}
</div>
)}
</div>
)
}
function ArrayField({
value,
editable,
onChange,
}: {
value: string[]
editable: boolean
onChange: (v: string[]) => void
}) {
const removeItem = (index: number) => {
onChange(value.filter((_, i) => i !== index))
}
const addItem = (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
onChange([...value, trimmed])
}
return (
<div className="frontmatter-array">
{value.map((item, i) => (
<span key={i} className="frontmatter-chip">
<span className="frontmatter-chip-text">{item}</span>
{editable && (
<button
className="frontmatter-chip-remove"
onClick={() => removeItem(i)}
type="button"
>
<X size={10} />
</button>
)}
</span>
))}
{editable && (
<input
className="frontmatter-chip-input"
placeholder="Add..."
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addItem(e.currentTarget.value)
e.currentTarget.value = ''
} else if (e.key === 'Backspace' && !e.currentTarget.value && value.length > 0) {
removeItem(value.length - 1)
}
}}
onBlur={(e) => {
if (e.currentTarget.value.trim()) {
addItem(e.currentTarget.value)
e.currentTarget.value = ''
}
}}
/>
)}
</div>
)
}

View file

@ -1,6 +1,6 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state' import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view' import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
@ -8,6 +8,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list' import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' import TaskItem from '@tiptap/extension-task-item'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload' import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { Markdown } from 'tiptap-markdown' import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
@ -133,6 +134,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
}) })
}) })
blocks.push(listLines.join('\n')) blocks.push(listLines.join('\n'))
} else if (node.type === 'taskBlock') {
blocks.push('```task\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'codeBlock') { } else if (node.type === 'codeBlock') {
const lang = (node.attrs?.language as string) || '' const lang = (node.attrs?.language as string) || ''
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```') blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
@ -176,12 +179,26 @@ function getMarkdownWithBlankLines(editor: Editor): string {
return result return result
} }
import { EditorToolbar } from './editor-toolbar' import { EditorToolbar } from './editor-toolbar'
import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link' import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css' import '@/styles/editor.css'
type RowboatMentionMatch = {
range: { from: number; to: number }
}
type RowboatBlockEdit = {
/** ProseMirror position of the taskBlock node */
nodePos: number
/** Existing instruction text */
existingText: string
}
type WikiLinkConfig = { type WikiLinkConfig = {
files: string[] files: string[]
recent: string[] recent: string[]
@ -192,9 +209,16 @@ type WikiLinkConfig = {
interface MarkdownEditorProps { interface MarkdownEditorProps {
content: string content: string
onChange: (markdown: string) => void onChange: (markdown: string) => void
onPrimaryHeadingCommit?: () => void
preserveUntitledTitleHeading?: boolean
placeholder?: string placeholder?: string
wikiLinks?: WikiLinkConfig wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null> onImageUpload?: (file: File) => Promise<string | null>
editorSessionKey?: number
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
editable?: boolean
frontmatter?: string | null
onFrontmatterChange?: (raw: string | null) => void
} }
type WikiLinkMatch = { type WikiLinkMatch = {
@ -275,9 +299,16 @@ const TabIndentExtension = Extension.create({
export function MarkdownEditor({ export function MarkdownEditor({
content, content,
onChange, onChange,
onPrimaryHeadingCommit,
preserveUntitledTitleHeading = false,
placeholder = 'Start writing...', placeholder = 'Start writing...',
wikiLinks, wikiLinks,
onImageUpload, onImageUpload,
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
frontmatter,
onFrontmatterChange,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@ -286,8 +317,20 @@ export function MarkdownEditor({
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null) const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null) const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
const [wikiCommandValue, setWikiCommandValue] = useState<string>('') const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
const [activeRowboatMention, setActiveRowboatMention] = useState<RowboatMentionMatch | null>(null)
const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)
// @ mention autocomplete state (analogous to wiki-link state)
const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
const [atCommandValue, setAtCommandValue] = useState<string>('')
const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {})
// Keep ref in sync with state for the plugin to access // Keep ref in sync with state for the plugin to access
selectionHighlightRef.current = selectionHighlight selectionHighlightRef.current = selectionHighlight
@ -298,7 +341,70 @@ export function MarkdownEditor({
[] []
) )
useEffect(() => {
onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit
}, [onPrimaryHeadingCommit])
const maybeCommitPrimaryHeading = useCallback((view: EditorView) => {
const onCommit = onPrimaryHeadingCommitRef.current
if (!onCommit) return
const { selection, doc } = view.state
if (!selection.empty) return
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return
if (!['heading', 'paragraph'].includes($from.parent.type.name)) return
const firstNode = doc.firstChild
if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return
onCommit()
}, [])
const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => {
if (!preserveUntitledTitleHeading) return false
if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false
const { selection } = view.state
if (!selection.empty) return false
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return false
if ($from.parent.type.name !== 'heading') return false
const headingLevel = ((
$from.parent.attrs as { level?: number } | null | undefined
)?.level) ?? 0
if (headingLevel !== 1) return false
if ($from.parentOffset !== 0) return false
if ($from.parent.textContent.length > 0) return false
event.preventDefault()
return true
}, [preserveUntitledTitleHeading])
const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => {
if (!preserveUntitledTitleHeading) return
const { state, dispatch } = view
const { selection } = state
if (!selection.empty) return
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return
if ($from.parent.type.name !== 'paragraph') return
if ($from.parentOffset !== 0) return
if ($from.parent.textContent.length > 0) return
const headingType = state.schema.nodes.heading
if (!headingType) return
const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 })
dispatch(tr)
}, [preserveUntitledTitleHeading])
const editor = useEditor({ const editor = useEditor({
editable,
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
heading: { heading: {
@ -320,6 +426,7 @@ export function MarkdownEditor({
}, },
}), }),
ImageUploadPlaceholderExtension, ImageUploadPlaceholderExtension,
TaskBlockExtension,
WikiLink.configure({ WikiLink.configure({
onCreate: wikiLinks?.onCreate onCreate: wikiLinks?.onCreate
? (path) => { ? (path) => {
@ -352,11 +459,14 @@ export function MarkdownEditor({
markdown = postprocessMarkdown(markdown) markdown = postprocessMarkdown(markdown)
onChange(markdown) onChange(markdown)
}, },
onBlur: ({ editor }) => {
maybeCommitPrimaryHeading(editor.view)
},
editorProps: { editorProps: {
attributes: { attributes: {
class: 'prose prose-sm max-w-none focus:outline-none', class: 'prose prose-sm max-w-none focus:outline-none',
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (view, event) => {
const state = wikiKeyStateRef.current const state = wikiKeyStateRef.current
if (state.open) { if (state.open) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -389,9 +499,61 @@ export function MarkdownEditor({
} }
} }
// @ mention autocomplete keyboard handling
const atState = atKeyStateRef.current
if (atState.open) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
setActiveAtMention(null)
setAtAnchorPosition(null)
setAtCommandValue('')
return true
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (atState.options.length === 0) return true
event.preventDefault()
event.stopPropagation()
const currentIndex = Math.max(0, atState.options.indexOf(atState.value))
const delta = event.key === 'ArrowDown' ? 1 : -1
const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length
setAtCommandValue(atState.options[nextIndex])
return true
}
if (event.key === 'Enter' || event.key === 'Tab') {
if (atState.options.length === 0) return true
event.preventDefault()
event.stopPropagation()
const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0]
handleSelectAtMentionRef.current(selected)
return true
}
}
if (preventTitleHeadingDemotion(view, event)) {
return true
}
const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey
if (isPrintableKey) {
promoteFirstParagraphToTitleHeading(view)
}
if (
event.key === 'Enter'
&& !event.shiftKey
&& !event.ctrlKey
&& !event.metaKey
&& !event.altKey
) {
maybeCommitPrimaryHeading(view)
}
return false return false
}, },
handleClickOn: (_view, _pos, node, _nodePos, event) => { handleClickOn: (_view, _pos, node, nodePos, event) => {
if (node.type.name === 'wikiLink') { if (node.type.name === 'wikiLink') {
event.preventDefault() event.preventDefault()
wikiLinks?.onOpen?.(node.attrs.path) wikiLinks?.onOpen?.(node.attrs.path)
@ -400,7 +562,12 @@ export function MarkdownEditor({
return false return false
}, },
}, },
}) }, [
editorSessionKey,
maybeCommitPrimaryHeading,
preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading,
])
const orderedFiles = useMemo(() => { const orderedFiles = useMemo(() => {
if (!wikiLinks) return [] if (!wikiLinks) return []
@ -469,6 +636,118 @@ export function MarkdownEditor({
}) })
}, [editor, wikiLinks]) }, [editor, wikiLinks])
const updateRowboatMentionState = useCallback(() => {
if (!editor) return
const { selection } = editor.state
if (!selection.empty) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const { $from } = selection
if ($from.parent.type.spec.code) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
const textBefore = text.slice(0, $from.parentOffset)
// Match @rowboat at a word boundary (preceded by nothing or whitespace)
const match = textBefore.match(/(^|\s)@rowboat$/)
if (!match) {
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
return
}
const triggerStart = textBefore.length - '@rowboat'.length
const from = selection.from - (textBefore.length - triggerStart)
const to = selection.from
setActiveRowboatMention({ range: { from, to } })
const wrapper = wrapperRef.current
if (!wrapper) {
setRowboatAnchorTop(null)
return
}
const coords = editor.view.coordsAtPos(selection.from)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
setRowboatAnchorTop({
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
})
}, [editor])
// Detect @ trigger for autocomplete popover (similar to [[ detection)
const updateAtMentionState = useCallback(() => {
if (!editor) return
const { selection } = editor.state
if (!selection.empty) {
setActiveAtMention(null)
setAtAnchorPosition(null)
return
}
const { $from } = selection
// Skip code blocks
if ($from.parent.type.spec.code) {
setActiveAtMention(null)
setAtAnchorPosition(null)
return
}
// Skip inline code marks
if ($from.marks().some((mark) => mark.type.spec.code)) {
setActiveAtMention(null)
setAtAnchorPosition(null)
return
}
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
const textBefore = text.slice(0, $from.parentOffset)
// Find @ at a word boundary (start of line or preceded by whitespace)
const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/)
if (!atMatch) {
setActiveAtMention(null)
setAtAnchorPosition(null)
return
}
const query = atMatch[2] // text after @
// If the full "@rowboat" is already typed, let updateRowboatMentionState handle it
if (query === 'rowboat') {
setActiveAtMention(null)
setAtAnchorPosition(null)
return
}
const atSymbolOffset = textBefore.lastIndexOf('@')
const matchText = textBefore.slice(atSymbolOffset)
const range = { from: selection.from - matchText.length, to: selection.from }
setActiveAtMention({ range, query })
const wrapper = wrapperRef.current
if (!wrapper) {
setAtAnchorPosition(null)
return
}
const coords = editor.view.coordsAtPos(selection.from)
const wrapperRect = wrapper.getBoundingClientRect()
setAtAnchorPosition({
left: coords.left - wrapperRect.left,
top: coords.bottom - wrapperRect.top,
})
}, [editor])
useEffect(() => { useEffect(() => {
if (!editor || !wikiLinks) return if (!editor || !wikiLinks) return
editor.on('update', updateWikiLinkState) editor.on('update', updateWikiLinkState)
@ -479,6 +758,42 @@ export function MarkdownEditor({
} }
}, [editor, wikiLinks, updateWikiLinkState]) }, [editor, wikiLinks, updateWikiLinkState])
useEffect(() => {
if (!editor) return
editor.on('update', updateRowboatMentionState)
editor.on('selectionUpdate', updateRowboatMentionState)
return () => {
editor.off('update', updateRowboatMentionState)
editor.off('selectionUpdate', updateRowboatMentionState)
}
}, [editor, updateRowboatMentionState])
useEffect(() => {
if (!editor) return
editor.on('update', updateAtMentionState)
editor.on('selectionUpdate', updateAtMentionState)
return () => {
editor.off('update', updateAtMentionState)
editor.off('selectionUpdate', updateAtMentionState)
}
}, [editor, updateAtMentionState])
// When a tell-rowboat block is clicked, compute anchor and open popover
useEffect(() => {
if (!rowboatBlockEdit || !editor) return
const wrapper = wrapperRef.current
if (!wrapper) return
const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos)
const wrapperRect = wrapper.getBoundingClientRect()
const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
const pmRect = proseMirrorEl?.getBoundingClientRect()
setRowboatAnchorTop({
top: coords.top - wrapperRect.top + wrapper.scrollTop,
left: pmRect ? pmRect.left - wrapperRect.left : 0,
width: pmRect ? pmRect.width : wrapperRect.width,
})
}, [editor, rowboatBlockEdit])
// Update editor content when prop changes (e.g., file selection changes) // Update editor content when prop changes (e.g., file selection changes)
useEffect(() => { useEffect(() => {
if (editor && content !== undefined) { if (editor && content !== undefined) {
@ -489,12 +804,37 @@ export function MarkdownEditor({
isInternalUpdate.current = true isInternalUpdate.current = true
// Pre-process to preserve blank lines // Pre-process to preserve blank lines
const preprocessed = preprocessMarkdown(content) const preprocessed = preprocessMarkdown(content)
editor.commands.setContent(preprocessed) // Treat tab-open content as baseline: do not add hydration to undo history.
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
isInternalUpdate.current = false isInternalUpdate.current = false
} }
} }
}, [editor, content]) }, [editor, content])
useEffect(() => {
if (!onHistoryHandlersChange) return
if (!editor) {
onHistoryHandlersChange(null)
return
}
onHistoryHandlersChange({
undo: () => editor.chain().focus().undo().run(),
redo: () => editor.chain().focus().redo().run(),
})
return () => {
onHistoryHandlersChange(null)
}
}, [editor, onHistoryHandlersChange])
// Update editable state when prop changes
useEffect(() => {
if (editor) {
editor.setEditable(editable)
}
}, [editor, editable])
// Force re-render decorations when selection highlight changes // Force re-render decorations when selection highlight changes
useEffect(() => { useEffect(() => {
if (editor) { if (editor) {
@ -544,9 +884,89 @@ export function MarkdownEditor({
handleSelectWikiLinkRef.current = handleSelectWikiLink handleSelectWikiLinkRef.current = handleSelectWikiLink
}, [handleSelectWikiLink]) }, [handleSelectWikiLink])
const handleRowboatAdd = useCallback(async (instruction: string) => {
if (!editor) return
if (rowboatBlockEdit) {
// Editing existing taskBlock — update its data attribute
const { nodePos } = rowboatBlockEdit
const node = editor.state.doc.nodeAt(nodePos)
if (node && node.type.name === 'taskBlock') {
// Preserve existing schedule data
let updated: Record<string, unknown> = { instruction }
try {
const existing = JSON.parse(node.attrs.data || '{}')
updated = { ...existing, instruction }
} catch {
// Invalid JSON — just write new
}
const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) })
editor.view.dispatch(tr)
}
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
return
}
if (activeRowboatMention) {
// Classify schedule intent for new blocks
const blockData: Record<string, unknown> = { instruction }
try {
const result = await window.ipc.invoke('inline-task:classifySchedule', { instruction })
if (result.schedule) {
const { label, ...rest } = result.schedule
blockData.schedule = rest
blockData['schedule-label'] = label
}
} catch (error) {
console.error('[RowboatAdd] Schedule classification failed:', error)
}
editor
.chain()
.focus()
.insertContentAt(
{ from: activeRowboatMention.range.from, to: activeRowboatMention.range.to },
[
{ type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
{ type: 'paragraph' },
],
)
.run()
// Mark note as live
if (onFrontmatterChange) {
const fields = extractAllFrontmatterValues(frontmatter ?? null)
fields['live_note'] = 'true'
onFrontmatterChange(buildFrontmatter(fields))
}
setActiveRowboatMention(null)
setRowboatAnchorTop(null)
}
}, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange])
const handleRowboatRemove = useCallback(() => {
if (!editor || !rowboatBlockEdit) return
const { nodePos } = rowboatBlockEdit
const node = editor.state.doc.nodeAt(nodePos)
if (node) {
editor
.chain()
.focus()
.deleteRange({ from: nodePos, to: nodePos + node.nodeSize })
.run()
}
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
}, [editor, rowboatBlockEdit])
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
updateWikiLinkState() updateWikiLinkState()
}, [updateWikiLinkState]) updateAtMentionState()
}, [updateWikiLinkState, updateAtMentionState])
const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition) const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
const wikiOptions = useMemo(() => { const wikiOptions = useMemo(() => {
@ -574,6 +994,63 @@ export function MarkdownEditor({
setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0])) setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
}, [showWikiPopover, wikiOptions]) }, [showWikiPopover, wikiOptions])
// @ mention autocomplete options
const atMentionOptions = useMemo(() => [
{ value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' },
], [])
const filteredAtOptions = useMemo(() => {
if (!activeAtMention) return []
const q = activeAtMention.query.toLowerCase()
if (!q) return atMentionOptions
return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q))
}, [activeAtMention, atMentionOptions])
const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions])
const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0)
useEffect(() => {
atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue }
}, [showAtPopover, atOptionValues, atCommandValue])
// Keep @ cmdk selection in sync
useEffect(() => {
if (!showAtPopover) {
setAtCommandValue('')
return
}
if (atOptionValues.length === 0) {
setAtCommandValue('')
return
}
setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0]))
}, [showAtPopover, atOptionValues])
// @ mention selection handler
const handleSelectAtMention = useCallback((value: string) => {
if (!editor || !activeAtMention) return
if (value === 'rowboat') {
// Replace "@<partial>" with "@rowboat" — this triggers updateRowboatMentionState
editor
.chain()
.focus()
.insertContentAt(
{ from: activeAtMention.range.from, to: activeAtMention.range.to },
'@rowboat'
)
.run()
}
setActiveAtMention(null)
setAtAnchorPosition(null)
setAtCommandValue('')
}, [editor, activeAtMention])
useEffect(() => {
handleSelectAtMentionRef.current = handleSelectAtMention
}, [handleSelectAtMention])
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyDown = useCallback((event: React.KeyboardEvent) => { const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) { if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
@ -595,6 +1072,13 @@ export function MarkdownEditor({
onSelectionHighlight={setSelectionHighlight} onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder} onImageUpload={handleImageUploadWithPlaceholder}
/> />
{(frontmatter !== undefined) && onFrontmatterChange && (
<FrontmatterProperties
raw={frontmatter}
onRawChange={onFrontmatterChange}
editable={editable}
/>
)}
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}> <div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{wikiLinks ? ( {wikiLinks ? (
@ -651,6 +1135,64 @@ export function MarkdownEditor({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) : null} ) : null}
{/* @ mention autocomplete popover */}
<Popover
open={showAtPopover}
onOpenChange={(open) => {
if (!open) {
setActiveAtMention(null)
setAtAnchorPosition(null)
setAtCommandValue('')
}
}}
>
<PopoverAnchor asChild>
<span
className="wiki-link-anchor"
style={
atAnchorPosition
? { left: atAnchorPosition.left, top: atAnchorPosition.top }
: undefined
}
/>
</PopoverAnchor>
<PopoverContent
className="w-72 p-1"
align="start"
side="bottom"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
<CommandList>
{filteredAtOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={() => handleSelectAtMention(opt.value)}
>
<div className="flex flex-col">
<span className="font-medium">{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.description}</span>
</div>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<RowboatMentionPopover
open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
anchor={rowboatAnchorTop}
initialText={rowboatBlockEdit?.existingText ?? ''}
onAdd={handleRowboatAdd}
onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
onClose={() => {
setActiveRowboatMention(null)
setRowboatBlockEdit(null)
rowboatBlockEditRef.current = null
setRowboatAnchorTop(null)
}}
/>
</div> </div>
</div> </div>
) )

View file

@ -55,14 +55,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null) const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({ const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "" }, openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "" }, anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "" }, google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "" }, openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "" }, aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, "openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
}) })
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle", status: "idle",
@ -89,7 +89,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null) const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
const updateProviderConfig = useCallback( const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[provider]: { ...prev[provider], ...updates }, [provider]: { ...prev[provider], ...updates },
@ -306,6 +306,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const apiKey = activeConfig.apiKey.trim() || undefined const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim() const model = activeConfig.model.trim()
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
const providerConfig = { const providerConfig = {
provider: { provider: {
flavor: llmProvider, flavor: llmProvider,
@ -313,6 +314,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
baseURL, baseURL,
}, },
model, model,
knowledgeGraphModel,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
@ -691,39 +693,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span> <div className="space-y-2">
{modelsLoading ? ( <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {modelsLoading ? (
<Loader2 className="size-4 animate-spin" /> <div className="flex items-center gap-2 text-sm text-muted-foreground">
Loading models... <Loader2 className="size-4 animate-spin" />
</div> Loading...
) : showModelInput ? ( </div>
<Input ) : showModelInput ? (
value={activeConfig.model} <Input
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })} value={activeConfig.model}
placeholder="Enter model" onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
/> placeholder="Enter model"
) : ( />
<Select ) : (
value={activeConfig.model} <Select
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })} value={activeConfig.model}
> onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
<SelectTrigger> >
<SelectValue placeholder="Select a model" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Select a model" />
<SelectContent> </SelectTrigger>
{modelsForProvider.map((model) => ( <SelectContent>
<SelectItem key={model.id} value={model.id}> {modelsForProvider.map((model) => (
{model.name || model.id} <SelectItem key={model.id} value={model.id}>
</SelectItem> {model.name || model.id}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
)} </Select>
{modelsError && ( )}
<div className="text-xs text-destructive">{modelsError}</div> {modelsError && (
)} <div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
{showApiKey && ( {showApiKey && (

View file

@ -0,0 +1,109 @@
import { useState, useRef, useEffect } from 'react'
import { Loader2 } from 'lucide-react'
interface RowboatMentionPopoverProps {
open: boolean
anchor: { top: number; left: number; width: number } | null
initialText?: string
onAdd: (instruction: string) => void | Promise<void>
onRemove?: () => void
onClose: () => void
}
export function RowboatMentionPopover({ open, anchor, initialText = '', onAdd, onRemove, onClose }: RowboatMentionPopoverProps) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setText(initialText)
setLoading(false)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
}, [open, initialText])
// Close on outside click
useEffect(() => {
if (!open) return
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [open, onClose])
if (!open || !anchor) return null
const handleSubmit = async () => {
const trimmed = text.trim()
if (!trimmed || loading) return
setLoading(true)
try {
await onAdd(trimmed)
} finally {
setLoading(false)
}
setText('')
}
return (
<div
ref={containerRef}
className="absolute z-50"
style={{
top: anchor.top,
left: anchor.left,
width: anchor.width,
}}
>
<div className="relative border border-input rounded-md bg-popover shadow-sm">
<div className="flex items-start gap-1.5 px-3 pt-2 pb-8">
<span className="text-sm text-muted-foreground select-none shrink-0 leading-[1.5]">@rowboat</span>
<textarea
ref={textareaRef}
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none resize-none leading-[1.5]"
placeholder=""
rows={2}
value={text}
disabled={loading}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.shiftKey)) {
e.preventDefault()
void handleSubmit()
}
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}}
/>
</div>
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1.5">
{onRemove && (
<button
className="inline-flex items-center justify-center rounded px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={onRemove}
disabled={loading}
>
Remove
</button>
)}
<button
className="inline-flex items-center justify-center rounded bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!text.trim() || loading}
onClick={() => void handleSubmit()}
>
{loading ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
</button>
</div>
</div>
</div>
)
}

View file

@ -1,8 +1,8 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2 } from "lucide-react" import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react"
import { import {
Dialog, Dialog,
@ -18,11 +18,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context" import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner" import { toast } from "sonner"
type ConfigTab = "models" | "mcp" | "security" | "appearance" type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
interface TabConfig { interface TabConfig {
id: ConfigTab id: ConfigTab
@ -60,6 +61,13 @@ const tabs: TabConfig[] = [
icon: Palette, icon: Palette,
description: "Customize the look and feel", description: "Customize the look and feel",
}, },
{
id: "note-tagging",
label: "Note Tagging",
icon: Tags,
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
] ]
interface SettingsDialogProps { interface SettingsDialogProps {
@ -167,14 +175,15 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai") const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({ const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
openai: { apiKey: "", baseURL: "", model: "" }, const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
anthropic: { apiKey: "", baseURL: "", model: "" }, openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "" }, anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "" }, google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "" }, openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" }, aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" }, ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
}) })
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({}) const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false) const [modelsLoading, setModelsLoading] = useState(false)
@ -193,13 +202,14 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const showModelInput = isLocalProvider || modelsForProvider.length === 0 const showModelInput = isLocalProvider || modelsForProvider.length === 0
const isMoreProvider = moreProviders.some(p => p.id === provider) const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
const canTest = const canTest =
activeConfig.model.trim().length > 0 && primaryModel.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) && (!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0) (!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback( const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => { (prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({ setProviderConfigs(prev => ({
...prev, ...prev,
[prov]: { ...prev[prov], ...updates }, [prov]: { ...prev[prov], ...updates },
@ -209,6 +219,39 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
[] []
) )
const updateModelAt = useCallback(
(prov: LlmProviderFlavor, index: number, value: string) => {
setProviderConfigs(prev => {
const models = [...prev[prov].models]
models[index] = value
return { ...prev, [prov]: { ...prev[prov], models } }
})
setTestState({ status: "idle" })
},
[]
)
const addModel = useCallback(
(prov: LlmProviderFlavor) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
}))
},
[]
)
const removeModel = useCallback(
(prov: LlmProviderFlavor, index: number) => {
setProviderConfigs(prev => {
const models = prev[prov].models.filter((_, i) => i !== index)
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
})
setTestState({ status: "idle" })
},
[]
)
// Load current config from file // Load current config from file
useEffect(() => { useEffect(() => {
if (!dialogOpen) return if (!dialogOpen) return
@ -223,14 +266,42 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
if (parsed?.provider?.flavor && parsed?.model) { if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor) setProvider(flavor)
setProviderConfigs(prev => ({ setDefaultProvider(flavor)
...prev, setProviderConfigs(prev => {
[flavor]: { const next = { ...prev };
apiKey: parsed.provider.apiKey || "", // Hydrate all saved providers from the providers map
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), if (parsed.providers) {
model: parsed.model, for (const [key, entry] of Object.entries(parsed.providers)) {
}, if (key in next) {
})) const e = entry as any;
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
? e.models
: e.model ? [e.model] : [""];
next[key as LlmProviderFlavor] = {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
};
}
}
}
// Active provider takes precedence from top-level config,
// but only if it exists in the providers map (wasn't deleted)
if (parsed.providers?.[flavor]) {
const existingModels = next[flavor].models;
const activeModels = existingModels[0] === parsed.model
? existingModels
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
next[flavor] = {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
};
}
return next;
})
} }
} catch { } catch {
// No existing config or parse error - use defaults // No existing config or parse error - use defaults
@ -274,11 +345,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const next = { ...prev } const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const prov of cloudProviders) { for (const prov of cloudProviders) {
const models = modelsCatalog[prov] const catalog = modelsCatalog[prov]
if (models?.length && !next[prov].model) { if (catalog?.length && !next[prov].models[0]) {
const preferred = preferredDefaults[prov] const preferred = preferredDefaults[prov]
const hasPreferred = preferred && models.some(m => m.id === preferred) const hasPreferred = preferred && catalog.some(m => m.id === preferred)
next[prov] = { ...next[prov], model: hasPreferred ? preferred : (models[0]?.id || "") } const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "")
next[prov] = { ...next[prov], models: [defaultModel] }
} }
} }
return next return next
@ -289,18 +361,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
if (!canTest) return if (!canTest) return
setTestState({ status: "testing" }) setTestState({ status: "testing" })
try { try {
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
const providerConfig = { const providerConfig = {
provider: { provider: {
flavor: provider, flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined, apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined, baseURL: activeConfig.baseURL.trim() || undefined,
}, },
model: activeConfig.model.trim(), model: allModels[0] || "",
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
} }
const result = await window.ipc.invoke("models:test", providerConfig) const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) { if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig) await window.ipc.invoke("models:saveConfig", providerConfig)
setDefaultProvider(provider)
setTestState({ status: "success" }) setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Model configuration saved") toast.success("Model configuration saved")
} else { } else {
setTestState({ status: "error", error: result.error }) setTestState({ status: "error", error: result.error })
@ -312,24 +389,120 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} }
}, [canTest, provider, activeConfig]) }, [canTest, provider, activeConfig])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => ( const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
<button const config = providerConfigs[prov]
key={p.id} const allModels = config.models.map(m => m.trim()).filter(Boolean)
onClick={() => { if (!allModels[0]) return
setProvider(p.id) try {
setTestState({ status: "idle" }) await window.ipc.invoke("models:saveConfig", {
}} provider: {
className={cn( flavor: prov,
"rounded-md border px-3 py-2.5 text-left transition-colors", apiKey: config.apiKey.trim() || undefined,
provider === p.id baseURL: config.baseURL.trim() || undefined,
? "border-primary bg-primary/5" },
: "border-border hover:bg-accent" model: allModels[0],
)} models: allModels,
> knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
<div className="text-sm font-medium">{p.name}</div> })
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div> setDefaultProvider(prov)
</button> window.dispatchEvent(new Event('models-config-changed'))
) toast.success("Default provider updated")
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(result.data)
if (parsed?.providers?.[prov]) {
delete parsed.providers[prov]
}
// If the deleted provider is the current top-level active one,
// switch top-level config to the current default provider
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
const defConfig = providerConfigs[defaultProvider]
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
parsed.provider = {
flavor: defaultProvider,
apiKey: defConfig.apiKey.trim() || undefined,
baseURL: defConfig.baseURL.trim() || undefined,
}
parsed.model = defModels[0] || ""
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
data: JSON.stringify(parsed, null, 2),
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Provider configuration removed")
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{p.name}</span>
{isDefault && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteProvider(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
>
Remove
</span>
</div>
)}
</button>
)
}
if (configLoading) { if (configLoading) {
return ( return (
@ -362,40 +535,100 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)} )}
</div> </div>
{/* Model selection */} {/* Model selection - side by side */}
<div className="space-y-2"> <div className="grid grid-cols-2 gap-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span> {/* Assistant models (left column) */}
{modelsLoading ? ( <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<Loader2 className="size-4 animate-spin" /> {modelsLoading ? (
Loading models... <div className="flex items-center gap-2 text-sm text-muted-foreground">
</div> <Loader2 className="size-4 animate-spin" />
) : showModelInput ? ( Loading...
<Input </div>
value={activeConfig.model} ) : (
onChange={(e) => updateConfig(provider, { model: e.target.value })} <div className="space-y-2">
placeholder="Enter model" {activeConfig.models.map((model, index) => (
/> <div key={index} className="group/model relative">
) : ( {showModelInput ? (
<Select <Input
value={activeConfig.model} value={model}
onValueChange={(value) => updateConfig(provider, { model: value })} onChange={(e) => updateModelAt(provider, index, e.target.value)}
> placeholder="Enter model"
<SelectTrigger> />
<SelectValue placeholder="Select a model" /> ) : (
</SelectTrigger> <Select
<SelectContent> value={model}
{modelsForProvider.map((model) => ( onValueChange={(value) => updateModelAt(provider, index, value)}
<SelectItem key={model.id} value={model.id}> >
{model.name || model.id} <SelectTrigger>
</SelectItem> <SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{activeConfig.models.length > 1 && (
<button
onClick={() => removeModel(provider, index)}
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
>
<X className="size-3.5" />
</button>
)}
</div>
))} ))}
</SelectContent> <button
</Select> onClick={() => addModel(provider)}
)} className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
{modelsError && ( >
<div className="text-xs text-destructive">{modelsError}</div> <Plus className="size-3.5" />
)} Add assistant model
</button>
</div>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
{/* API Key */} {/* API Key */}
@ -460,6 +693,415 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
) )
} }
// --- Note Tagging Settings ---
interface TagDef {
tag: string
type: string
applicability: "email" | "notes" | "both"
description: string
example?: string
noteEffect?: "create" | "skip" | "none"
}
const NOTE_TAG_TYPE_ORDER = [
"relationship", "relationship-sub", "topic", "action", "status", "source",
]
const EMAIL_TAG_TYPE_ORDER = [
"relationship", "topic", "email-type", "filter", "action", "status",
]
const TAG_TYPE_LABELS: Record<string, string> = {
"relationship": "Relationship",
"relationship-sub": "Relationship Sub-Tags",
"topic": "Topic",
"email-type": "Email Type",
"filter": "Filter",
"action": "Action",
"status": "Status",
"source": "Source",
}
const DEFAULT_TAGS: TagDef[] = [
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
{ tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
{ tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
{ tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
{ tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
{ tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
{ tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
{ tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
{ tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
{ tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
{ tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
{ tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
{ tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
{ tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
{ tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
{ tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
{ tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
{ tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
{ tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
{ tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
{ tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
]
function TagGroupTable({
group,
tags,
collapsed,
onToggle,
onAdd,
onUpdate,
onRemove,
getGlobalIndex,
isEmail,
}: {
group: { type: string; label: string; tags: TagDef[] }
tags: TagDef[]
collapsed: boolean
onToggle: () => void
onAdd: () => void
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
onRemove: (index: number) => void
getGlobalIndex: (type: string, localIndex: number) => number
isEmail: boolean
}) {
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
{group.label}
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
</button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onAdd}
>
<Plus className="size-3 mr-1" />
Add
</Button>
</div>
{!collapsed && group.tags.length > 0 && (
<div className="border rounded-md overflow-hidden">
<div className={cn(
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
)}>
<div>Label</div>
<div>Description</div>
<div>Example</div>
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
<div />
</div>
{group.tags.map((tag, localIdx) => {
const globalIdx = getGlobalIndex(group.type, localIdx)
return (
<div key={globalIdx} className={cn(
"gap-1 border-t px-2 py-0.5 items-center grid",
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
)}>
<Input
value={tag.tag}
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
className="h-7 text-xs"
placeholder="tag-name"
title={tag.tag}
/>
<Input
value={tag.description}
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
className="h-7 text-xs"
placeholder="Description"
title={tag.description}
/>
<Input
value={tag.example || ""}
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
className="h-7 text-xs"
placeholder="Example"
title={tag.example || ""}
/>
{isEmail && (
<div className="flex justify-center">
<Switch
checked={tag.noteEffect === "skip"}
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
className="scale-75"
/>
</div>
)}
<button
onClick={() => onRemove(globalIdx)}
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
>
<X className="size-3.5" />
</button>
</div>
)
})}
</div>
)}
{!collapsed && group.tags.length === 0 && (
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
)}
</div>
)
}
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [tags, setTags] = useState<TagDef[]>([])
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
const parsed = JSON.parse(result.data)
setTags(parsed)
setOriginalTags(parsed)
} catch {
setTags([...DEFAULT_TAGS])
setOriginalTags([...DEFAULT_TAGS])
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const noteGroups = useMemo(() => {
const map = new Map<string, TagDef[]>()
for (const tag of tags) {
if (tag.applicability === "email") continue
const list = map.get(tag.type) ?? []
list.push(tag)
map.set(tag.type, list)
}
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
type,
label: TAG_TYPE_LABELS[type],
tags: map.get(type) ?? [],
}))
}, [tags])
const emailGroups = useMemo(() => {
const map = new Map<string, TagDef[]>()
for (const tag of tags) {
if (tag.applicability === "notes") continue
const list = map.get(tag.type) ?? []
list.push(tag)
map.set(tag.type, list)
}
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
type,
label: TAG_TYPE_LABELS[type],
tags: map.get(type) ?? [],
}))
}, [tags])
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
let count = 0
for (let i = 0; i < tags.length; i++) {
if (tags[i].type === type) {
if (count === localIndex) return i
count++
}
}
return -1
}, [tags])
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
}, [])
const removeTag = useCallback((index: number) => {
setTags(prev => prev.filter((_, i) => i !== index))
}, [])
const addTag = useCallback((type: string) => {
const isEmailSection = activeSection === "email"
const applicability = isEmailSection ? "email" as const : "notes" as const
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
const emailOnlyTypes = ["email-type", "filter"]
const notesOnlyTypes = ["relationship-sub", "source"]
let finalApplicability: "email" | "notes" | "both" = "both"
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
else finalApplicability = isEmailSection ? "email" : applicability
const newTag: TagDef = {
tag: "",
type,
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
description: "",
noteEffect: isEmailSection ? "create" : "none",
}
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
if (lastIndex === -1) {
setTags(prev => [...prev, newTag])
} else {
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
}
}, [tags, activeSection])
const handleSave = useCallback(async () => {
setSaving(true)
try {
await window.ipc.invoke("workspace:writeFile", {
path: "config/tags.json",
data: JSON.stringify(tags, null, 2),
})
setOriginalTags([...tags])
toast.success("Tag configuration saved")
} catch {
toast.error("Failed to save tag configuration")
} finally {
setSaving(false)
}
}, [tags])
const handleReset = useCallback(() => {
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
setTags([...DEFAULT_TAGS])
}, [])
const toggleGroup = useCallback((type: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(type)) next.delete(type)
else next.add(type)
return next
})
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-1 mb-3 border-b">
<button
onClick={() => setActiveSection("notes")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
activeSection === "notes"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<BookOpen className="size-3.5" />
Note Tags
</button>
<button
onClick={() => setActiveSection("email")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
activeSection === "email"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Mail className="size-3.5" />
Email Labels
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{currentGroups.map(group => (
<TagGroupTable
key={group.type}
group={group}
tags={tags}
collapsed={collapsedGroups.has(group.type)}
onToggle={() => toggleGroup(group.type)}
onAdd={() => addTag(group.type)}
onUpdate={updateTag}
onRemove={removeTag}
getGlobalIndex={getGlobalIndex}
isEmail={activeSection === "email"}
/>
))}
</div>
<div className="pt-3 border-t mt-3 flex items-center justify-between">
<div>
{hasChanges && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
Reset to defaults
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
)
}
// --- Main Settings Dialog --- // --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) { export function SettingsDialog({ children }: SettingsDialogProps) {
@ -483,7 +1125,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
} }
const loadConfig = useCallback(async (tab: ConfigTab) => { const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models") return if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
const tabConfig = tabs.find((t) => t.id === tab)! const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return if (!tabConfig.path) return
setLoading(true) setLoading(true)
@ -589,9 +1231,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div> </div>
{/* Content */} {/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}> <div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? ( {activeTab === "models" ? (
<ModelSettings dialogOpen={open} /> <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? ( ) : activeTab === "appearance" ? (
<AppearanceSettings /> <AppearanceSettings />
) : loading ? ( ) : loading ? (

View file

@ -16,6 +16,7 @@ import {
Mic, Mic,
Network, Network,
Pencil, Pencil,
Table2,
Plug, Plug,
LoaderIcon, LoaderIcon,
Settings, Settings,
@ -101,6 +102,7 @@ type KnowledgeActions = {
createNote: (parentPath?: string) => void createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => void createFolder: (parentPath?: string) => void
openGraph: () => void openGraph: () => void
openBases: () => void
expandAll: () => void expandAll: () => void
collapseAll: () => void collapseAll: () => void
rename: (path: string, newName: string, isDir: boolean) => Promise<void> rename: (path: string, newName: string, isDir: boolean) => Promise<void>
@ -855,6 +857,7 @@ function KnowledgeSection({
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FilePlus, label: "New Note", action: () => actions.createNote() },
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
{ icon: Network, label: "Graph View", action: () => actions.openGraph() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() },
{ icon: Table2, label: "Bases", action: () => actions.openBases() },
] ]
return ( return (

View file

@ -0,0 +1,177 @@
import { useEffect, useState, useCallback } from 'react'
import { X, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface CommitInfo {
oid: string
message: string
timestamp: number
author: string
}
interface VersionHistoryPanelProps {
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
onClose: () => void
onSelectVersion: (oid: string | null, content: string) => void // null = current
onRestore: (oid: string) => void
}
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
const d = new Date(unixSeconds * 1000)
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
return { date, time }
}
export function VersionHistoryPanel({
path,
onClose,
onSelectVersion,
onRestore,
}: VersionHistoryPanelProps) {
const [commits, setCommits] = useState<CommitInfo[]>([])
const [loading, setLoading] = useState(true)
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
const [error, setError] = useState<string | null>(null)
// Strip "knowledge/" prefix for IPC calls
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
const loadHistory = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
setCommits(result.commits)
} catch (err) {
console.error('Failed to load version history:', err)
setError('Failed to load history')
} finally {
setLoading(false)
}
}, [relPath])
useEffect(() => {
loadHistory()
}, [loadHistory])
// Refresh when new commits land
useEffect(() => {
return window.ipc.on('knowledge:didCommit', () => {
loadHistory()
})
}, [loadHistory])
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
if (isLatest) {
setSelectedOid(null)
// Read current file content
try {
const result = await window.ipc.invoke('workspace:readFile', { path })
onSelectVersion(null, result.data)
} catch (err) {
console.error('Failed to read current file:', err)
}
return
}
setSelectedOid(oid)
try {
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
onSelectVersion(oid, result.content)
} catch (err) {
console.error('Failed to load file at commit:', err)
}
}, [path, relPath, onSelectVersion])
const handleRestore = useCallback(() => {
if (selectedOid) {
onRestore(selectedOid)
}
}, [selectedOid, onRestore])
return (
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">Version history</span>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Close version history"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Commit list */}
<div className="flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
{error}
</div>
) : commits.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No history available
</div>
) : (
<div className="py-1">
{commits.map((commit, index) => {
const isLatest = index === 0
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
const { date, time } = formatTimestamp(commit.timestamp)
return (
<button
key={commit.oid}
type="button"
onClick={() => handleSelectCommit(commit.oid, isLatest)}
className={cn(
'w-full text-left px-3 py-2 transition-colors',
isSelected
? 'bg-accent'
: 'hover:bg-accent/50'
)}
>
<div className="flex items-center gap-1.5">
{!isLatest && (
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="text-sm text-foreground">
{date} &middot; {time}
</span>
</div>
{isLatest && (
<div className="text-xs text-muted-foreground mt-0.5">
Current version
</div>
)}
</button>
)
})}
</div>
)}
</div>
{/* Footer */}
{selectedOid && (
<div className="shrink-0 border-t border-border p-3">
<Button
variant="default"
size="sm"
className="w-full"
onClick={handleRestore}
>
Restore this version
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,98 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { CalendarClock, X } from 'lucide-react'
import { inlineTask } from '@x/shared'
function TaskBlockView({ node, deleteNode }: { node: { attrs: { data: string } }; deleteNode: () => void }) {
const raw = node.attrs.data
let instruction = ''
let scheduleLabel = ''
try {
const parsed = inlineTask.InlineTaskBlockSchema.parse(JSON.parse(raw))
instruction = parsed.instruction
scheduleLabel = parsed['schedule-label'] ?? ''
} catch {
// Fallback: show raw data
instruction = raw
}
return (
<NodeViewWrapper className="task-block-wrapper" data-type="task-block">
<div className="task-block-card">
<button
className="task-block-delete"
onClick={deleteNode}
aria-label="Delete task block"
>
<X size={14} />
</button>
<div className="task-block-content">
<span className="task-block-instruction"><span className="task-block-prefix">@rowboat</span> {instruction}</span>
{scheduleLabel && (
<span className="task-block-schedule">
<CalendarClock size={12} />
{scheduleLabel}
</span>
)}
</div>
</div>
</NodeViewWrapper>
)
}
export const TaskBlockExtension = Node.create({
name: 'taskBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '{}',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-task') || cls.includes('language-tell-rowboat')) {
return { data: code.textContent || '{}' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'task-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(TaskBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```task\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -0,0 +1,107 @@
import { getExtension } from '@/lib/file-utils'
export type AttachmentLike = {
filename?: string
path: string
mimeType: string
}
export type AttachmentIconKind =
| 'audio'
| 'video'
| 'spreadsheet'
| 'archive'
| 'code'
| 'text'
| 'file'
const ARCHIVE_EXTENSIONS = new Set([
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
])
const SPREADSHEET_EXTENSIONS = new Set([
'csv', 'tsv', 'xls', 'xlsx',
])
const CODE_EXTENSIONS = new Set([
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
])
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
if (attachment.filename) return attachment.filename
const fromPath = attachment.path.split(/[\\/]/).pop()
return fromPath || attachment.path
}
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
const ext = getExtension(getAttachmentDisplayName(attachment))
if (ext) return ext.toUpperCase()
const mediaType = attachment.mimeType.toLowerCase()
if (mediaType.startsWith('audio/')) return 'AUDIO'
if (mediaType.startsWith('video/')) return 'VIDEO'
if (mediaType.startsWith('text/')) return 'TEXT'
if (mediaType.startsWith('image/')) return 'IMAGE'
const [, subtypeRaw = 'file'] = mediaType.split('/')
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
return cleaned ? cleaned.toUpperCase() : 'FILE'
}
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
const mediaType = attachment.mimeType.toLowerCase()
const ext = getExtension(attachment.filename || attachment.path)
if (mediaType.startsWith('audio/')) return 'audio'
if (mediaType.startsWith('video/')) return 'video'
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
if (
mediaType.includes('json')
|| mediaType.includes('javascript')
|| mediaType.includes('typescript')
|| mediaType.includes('xml')
|| CODE_EXTENSIONS.has(ext)
) {
return 'code'
}
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
return 'text'
}
return 'file'
}
export function getAttachmentToneClass(typeLabel: string): string {
switch (typeLabel) {
case 'PDF':
return 'bg-red-500 text-white'
case 'CSV':
case 'XLS':
case 'XLSX':
case 'TSV':
return 'bg-emerald-500 text-white'
case 'ZIP':
case 'RAR':
case '7Z':
case 'TAR':
case 'GZ':
return 'bg-amber-500 text-white'
case 'MP3':
case 'WAV':
case 'M4A':
case 'FLAC':
case 'AAC':
return 'bg-fuchsia-500 text-white'
case 'MP4':
case 'MOV':
case 'AVI':
case 'WEBM':
return 'bg-violet-500 text-white'
default:
return 'bg-primary/85 text-primary-foreground'
}
}

View file

@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai'
import z from 'zod' import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
export interface MessageAttachment {
path: string
filename: string
mimeType: string
size?: number
thumbnailUrl?: string
}
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
attachments?: MessageAttachment[]
timestamp: number timestamp: number
} }

View file

@ -0,0 +1,61 @@
const IMAGE_MIMES = new Set([
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
]);
const EXTENSION_TO_MIME: Record<string, string> = {
// Images
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
avif: 'image/avif', tiff: 'image/tiff',
// Text / code
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
csv: 'text/csv', xml: 'text/xml',
js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
yml: 'text/yaml', toml: 'text/toml',
py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
// Documents
pdf: 'application/pdf',
// Archives
zip: 'application/zip',
};
export function isImageMime(mimeType: string): boolean {
return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
}
export function getMimeFromExtension(ext: string): string {
const normalized = ext.toLowerCase().replace(/^\./, '');
return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
}
export function getFileDisplayName(filePath: string): string {
return filePath.split('/').pop() || filePath;
}
export function getExtension(filePath: string): string {
const name = filePath.split('/').pop() || '';
const dotIndex = name.lastIndexOf('.');
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
}
export function toFileUrl(filePath: string): string {
if (!filePath) return filePath;
if (
filePath.startsWith('data:') ||
filePath.startsWith('file://') ||
filePath.startsWith('http://') ||
filePath.startsWith('https://')
) {
return filePath;
}
const normalized = filePath.replace(/\\/g, '/');
const encoded = encodeURI(normalized);
if (/^[A-Za-z]:\//.test(normalized)) {
return `file:///${encoded}`;
}
return `file://${encoded}`;
}

View file

@ -0,0 +1,367 @@
/**
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
* in knowledge notes and email files.
*/
/** Split content into raw frontmatter block and body text. */
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
return { raw: null, body: content }
}
const endIndex = content.indexOf('\n---', 3)
if (endIndex === -1) {
return { raw: null, body: content }
}
// raw includes both delimiters and the trailing newline after closing ---
const closingEnd = endIndex + 4 // '\n---' is 4 chars
const raw = content.slice(0, closingEnd)
// body starts after the closing --- and its trailing newline
let body = content.slice(closingEnd)
if (body.startsWith('\n')) {
body = body.slice(1)
}
return { raw, body }
}
/** Re-prepend raw frontmatter before body when saving. */
export function joinFrontmatter(raw: string | null, body: string): string {
if (!raw) return body
return raw + '\n' + body
}
/** Structured frontmatter fields extracted from categorized YAML. */
export type FrontmatterFields = {
relationship: string | null
relationship_sub: string[]
topic: string[]
email_type: string[]
action: string[]
status: string | null
source: string[]
}
/**
* Extract structured tag categories from raw frontmatter YAML.
*
* Handles both the new categorized format (top-level keys) and the legacy
* flat `tags:` list. For legacy notes the flat tags are mapped into
* categories using known tag values.
*/
export function extractFrontmatterFields(raw: string | null): FrontmatterFields {
const fields: FrontmatterFields = {
relationship: null,
relationship_sub: [],
topic: [],
email_type: [],
action: [],
status: null,
source: [],
}
if (!raw) return fields
const lines = raw.split('\n')
let currentKey: string | null = null
for (const line of lines) {
// Top-level key detection
const topMatch = line.match(/^(\w+):\s*(.*)$/)
if (topMatch || line === '---') {
currentKey = null
}
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
if (key in fields) {
currentKey = key
if (value) {
const field = fields[key as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(value)
} else {
// single-value field
;(fields as Record<string, unknown>)[key] = value
}
currentKey = null // inline value, no list follows
}
continue
}
// Legacy flat tags: — parse and distribute into categories
if (key === 'tags') {
currentKey = '__legacy_tags'
continue
}
}
// List items under a categorized key
if (currentKey && currentKey !== '__legacy_tags') {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const value = itemMatch[1].trim()
const field = fields[currentKey as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(value)
} else {
;(fields as Record<string, unknown>)[currentKey] = value
}
}
continue
}
// Legacy flat tag items → map into categories
if (currentKey === '__legacy_tags') {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const tag = itemMatch[1].trim()
const cat = LEGACY_TAG_TO_CATEGORY[tag]
if (cat) {
const field = fields[cat as keyof FrontmatterFields]
if (Array.isArray(field)) {
(field as string[]).push(tag)
} else if (!(fields as Record<string, unknown>)[cat]) {
;(fields as Record<string, unknown>)[cat] = tag
}
}
}
continue
}
}
return fields
}
/**
* Extract ALL top-level YAML key/value pairs from raw frontmatter.
* Returns a flat record where scalar values are strings and list values are string[].
* Skips `---` delimiters and blank lines.
*/
export function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {}
if (!raw) return result
const lines = raw.split('\n')
let currentKey: string | null = null
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null
continue
}
// Top-level key: value
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/)
if (topMatch) {
const key = topMatch[1]
const value = topMatch[2].trim()
if (value) {
result[key] = value
currentKey = null
} else {
// List will follow
currentKey = key
result[key] = []
}
continue
}
// List item under current key
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/)
if (itemMatch) {
const arr = result[currentKey]
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim())
}
}
}
}
return result
}
/**
* Convert a Record of frontmatter fields back to a raw YAML frontmatter string.
* Returns null if no non-empty fields remain.
*/
export function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
if (value.length === 0) continue
lines.push(`${key}:`)
for (const item of value) {
if (item.trim()) lines.push(` - ${item.trim()}`)
}
} else {
const trimmed = (value ?? '').trim()
if (!trimmed) continue
lines.push(`${key}: ${trimmed}`)
}
}
if (lines.length === 0) return null
return `---\n${lines.join('\n')}\n---`
}
/** Map known tag values → category for legacy flat-list frontmatter. */
const LEGACY_TAG_TO_CATEGORY: Record<string, string> = {
// relationship
investor: 'relationship', customer: 'relationship', prospect: 'relationship',
partner: 'relationship', vendor: 'relationship', product: 'relationship',
candidate: 'relationship', team: 'relationship', advisor: 'relationship',
personal: 'relationship', press: 'relationship', community: 'relationship',
government: 'relationship',
// relationship_sub
primary: 'relationship_sub', secondary: 'relationship_sub',
'executive-assistant': 'relationship_sub', cc: 'relationship_sub',
'referred-by': 'relationship_sub', former: 'relationship_sub',
champion: 'relationship_sub', blocker: 'relationship_sub',
// topic
sales: 'topic', support: 'topic', legal: 'topic', finance: 'topic',
hiring: 'topic', fundraising: 'topic', travel: 'topic', event: 'topic',
shopping: 'topic', health: 'topic', learning: 'topic', research: 'topic',
// email_type
intro: 'email_type', followup: 'email_type',
// action
'action-required': 'action', urgent: 'action', waiting: 'action',
// status
active: 'status', archived: 'status', stale: 'status',
// source
email: 'source', meeting: 'source', browser: 'source',
'web-search': 'source', manual: 'source', import: 'source',
}
/** Tag category keys used in the categorized frontmatter format. */
const TAG_CATEGORY_KEYS = new Set([
'relationship',
'relationship_sub',
'topic',
'email_type',
'action',
'status',
'source',
])
/** Keys that are metadata, not tags — skip when collecting tags. */
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
/**
* Extract tags from raw frontmatter YAML.
*
* Handles three formats:
* - Legacy flat list: `tags:` followed by ` - value` items
* - Categorized format: top-level keys like `relationship: customer` or
* `topic:` followed by ` - value` list items
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
* where values can be single strings or ` - value` arrays
*
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
*/
export function extractTags(raw: string | null): string[] {
if (!raw) return []
const lines = raw.split('\n')
const tags: string[] = []
let inTags = false
let inLabels = false
let inLabelSubKey = false
let inCategoryList = false
for (const line of lines) {
// Top-level key detection — resets all nested state
if (/^\w/.test(line) || line === '---') {
inTags = false
inLabels = false
inLabelSubKey = false
inCategoryList = false
}
// Legacy note format: tags:
if (/^tags:\s*$/.test(line)) {
inTags = true
inLabels = false
inCategoryList = false
continue
}
// Email format: labels:
if (/^labels:\s*$/.test(line)) {
inLabels = true
inTags = false
inCategoryList = false
continue
}
// Categorized format: top-level tag category key
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
if (topKeyMatch) {
const key = topKeyMatch[1]
const inlineValue = topKeyMatch[2].trim()
if (TAG_CATEGORY_KEYS.has(key)) {
if (inlineValue) {
// Single value: `relationship: customer`
tags.push(inlineValue)
inCategoryList = false
} else {
// List follows: `topic:\n - sales`
inCategoryList = true
}
continue
}
}
// Collect tag items under `tags:`
if (inTags) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Collect list items under a category key
if (inCategoryList) {
const match = line.match(/^\s+-\s+(.+)$/)
if (match) {
tags.push(match[1].trim())
}
continue
}
// Handle labels: nested structure
if (inLabels) {
// Sub-key like ` relationship:` or ` topics:`
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
if (subKeyMatch) {
const key = subKeyMatch[1]
const inlineValue = subKeyMatch[2].trim()
if (METADATA_KEYS.has(key)) {
inLabelSubKey = false
continue
}
if (inlineValue) {
// Inline value like ` type: person`
tags.push(inlineValue)
inLabelSubKey = false
} else {
// Array follows
inLabelSubKey = true
}
continue
}
// Array item under a sub-key like ` - value`
if (inLabelSubKey) {
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
if (itemMatch) {
tags.push(itemMatch[1].trim())
}
}
}
}
return tags
}

View file

@ -237,6 +237,200 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Frontmatter properties panel between toolbar and editor content */
.frontmatter-properties {
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background-color: var(--background);
font-size: 13px;
color: var(--foreground);
}
.frontmatter-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 12px;
background: none;
border: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
font-size: 12px;
user-select: none;
}
.frontmatter-toggle:hover {
color: var(--foreground);
background-color: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.frontmatter-chevron {
transition: transform 0.15s ease;
flex-shrink: 0;
}
.frontmatter-chevron.expanded {
transform: rotate(90deg);
}
.frontmatter-label {
font-weight: 500;
}
.frontmatter-fields {
padding: 2px 12px 6px 30px;
}
.frontmatter-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.frontmatter-key {
flex-shrink: 0;
width: 110px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.frontmatter-value-area {
flex: 1;
min-width: 0;
}
.frontmatter-input {
width: 100%;
background: none;
border: none;
border-bottom: 1px solid transparent;
padding: 2px 4px;
font-size: 13px;
color: var(--foreground);
outline: none;
}
.frontmatter-input:focus {
border-bottom-color: var(--primary);
}
.frontmatter-input:read-only {
cursor: default;
}
.frontmatter-new-key-input {
width: 110px;
flex-shrink: 0;
}
.frontmatter-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
}
.frontmatter-row:hover .frontmatter-remove {
opacity: 1;
}
.frontmatter-remove:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.frontmatter-add {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 4px;
margin-top: 2px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
}
.frontmatter-add:hover {
color: var(--foreground);
}
/* Array field chips */
.frontmatter-array {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-height: 24px;
}
.frontmatter-chip {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
line-height: 18px;
padding: 0 6px;
border-radius: 9999px;
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
white-space: nowrap;
user-select: none;
}
.dark .frontmatter-chip {
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
}
.frontmatter-chip-text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.frontmatter-chip-remove {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
margin-left: 2px;
}
.frontmatter-chip-remove:hover {
color: var(--foreground);
}
.frontmatter-chip-input {
background: none;
border: none;
outline: none;
font-size: 12px;
color: var(--foreground);
width: 60px;
padding: 2px 0;
}
.frontmatter-chip-input::placeholder {
color: color-mix(in srgb, var(--foreground) 30%, transparent);
}
.editor-toolbar .separator { .editor-toolbar .separator {
width: 1px; width: 1px;
height: 1.5rem; height: 1.5rem;
@ -337,6 +531,83 @@
transition: width 0.3s ease; transition: width 0.3s ease;
} }
/* Task block */
.tiptap-editor .ProseMirror .task-block-wrapper {
margin: 8px 0;
}
.tiptap-editor .ProseMirror .task-block-card {
position: relative;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background-color: color-mix(in srgb, var(--muted) 40%, transparent);
cursor: default;
transition: background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .task-block-delete {
position: absolute;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
opacity: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.tiptap-editor .ProseMirror .task-block-card:hover .task-block-delete {
opacity: 1;
}
.tiptap-editor .ProseMirror .task-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .task-block-card:hover {
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
.tiptap-editor .ProseMirror .task-block-wrapper.ProseMirror-selectednode .task-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
.tiptap-editor .ProseMirror .task-block-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tiptap-editor .ProseMirror .task-block-prefix {
color: var(--primary);
font-weight: 600;
}
.tiptap-editor .ProseMirror .task-block-instruction {
font-size: 14px;
line-height: 1.4;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .task-block-schedule {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
}
/* Dark mode overrides */ /* Dark mode overrides */
.dark .tiptap-editor .ProseMirror { .dark .tiptap-editor .ProseMirror {
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
@ -358,6 +629,10 @@
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.dark .tiptap-editor .ProseMirror pre code {
color: inherit;
}
.dark .tiptap-editor .ProseMirror code { .dark .tiptap-editor .ProseMirror code {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
color: #ff7b72; color: #ff7b72;

View file

@ -27,6 +27,7 @@
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"google-auth-library": "^10.5.0", "google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
"googleapis": "^169.0.0", "googleapis": "^169.0.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0", "node-html-markdown": "^2.0.0",

View file

@ -2,7 +2,6 @@ import { jsonSchema, ModelMessage } from "ai";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { WorkDir } from "../config/config.js"; import { WorkDir } from "../config/config.js";
import { getNoteCreationStrictness } from "../config/note_creation_config.js";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js"; import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js"; import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
@ -25,9 +24,10 @@ import { IRunsLock } from "../runs/lock.js";
import { IAbortRegistry } from "../runs/abort-registry.js"; import { IAbortRegistry } from "../runs/abort-registry.js";
import { PrefixLogger } from "@x/shared"; import { PrefixLogger } from "@x/shared";
import { parse } from "yaml"; import { parse } from "yaml";
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js"; import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js";
import { raw as noteCreationLowRaw } from "../knowledge/note_creation_low.js"; import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
import { raw as noteCreationHighRaw } from "../knowledge/note_creation_high.js"; import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js";
import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
export interface IAgentRuntime { export interface IAgentRuntime {
trigger(runId: string): Promise<void>; trigger(runId: string): Promise<void>;
@ -316,19 +316,7 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
} }
if (id === 'note_creation') { if (id === 'note_creation') {
const strictness = getNoteCreationStrictness(); const raw = getNoteCreationRaw();
let raw = '';
switch (strictness) {
case 'medium':
raw = noteCreationMediumRaw;
break;
case 'low':
raw = noteCreationLowRaw;
break;
case 'high':
raw = noteCreationHighRaw;
break;
}
let agent: z.infer<typeof Agent> = { let agent: z.infer<typeof Agent> = {
name: id, name: id,
instructions: raw, instructions: raw,
@ -353,10 +341,91 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return agent; return agent;
} }
if (id === 'labeling_agent') {
const labelingAgentRaw = getLabelingAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: labelingAgentRaw,
};
if (labelingAgentRaw.startsWith("---")) {
const end = labelingAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = labelingAgentRaw.slice(3, end).trim();
const content = labelingAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
if (id === 'note_tagging_agent') {
const noteTaggingAgentRaw = getNoteTaggingAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: noteTaggingAgentRaw,
};
if (noteTaggingAgentRaw.startsWith("---")) {
const end = noteTaggingAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = noteTaggingAgentRaw.slice(3, end).trim();
const content = noteTaggingAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
if (id === 'inline_task_agent') {
const inlineTaskAgentRaw = getInlineTaskAgentRaw();
let agent: z.infer<typeof Agent> = {
name: id,
instructions: inlineTaskAgentRaw,
};
if (inlineTaskAgentRaw.startsWith("---")) {
const end = inlineTaskAgentRaw.indexOf("\n---", 3);
if (end !== -1) {
const fm = inlineTaskAgentRaw.slice(3, end).trim();
const content = inlineTaskAgentRaw.slice(end + 4).trim();
const yaml = parse(fm);
const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml);
agent = {
...agent,
...parsed,
instructions: content,
};
}
}
return agent;
}
const repo = container.resolve<IAgentsRepo>('agentsRepo'); const repo = container.resolve<IAgentsRepo>('agentsRepo');
return await repo.fetch(id); return await repo.fetch(id);
} }
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] { export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = []; const result: ModelMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
@ -400,11 +469,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
}); });
break; break;
case "user": case "user":
result.push({ if (typeof msg.content === 'string') {
role: "user", // Legacy string — pass through unchanged
content: msg.content, result.push({
providerOptions, role: "user",
}); content: msg.content,
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
const textSegments: string[] = [];
const attachmentLines: string[] = [];
for (const part of msg.content) {
if (part.type === "attachment") {
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
} else {
textSegments.push(part.text);
}
}
if (attachmentLines.length > 0) {
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
}
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
}
break; break;
case "tool": case "tool":
result.push({ result.push({
@ -674,7 +769,12 @@ export async function* streamAgent({
// set up provider + model // set up provider + model
const provider = createProvider(modelConfig.provider); const provider = createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model); const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent"];
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
? modelConfig.knowledgeGraphModel
: modelConfig.model;
const model = provider.languageModel(modelId);
logger.log(`using model: ${modelId}`);
let loopCounter = 0; let loopCounter = 0;
while (true) { while (true) {

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.js"; import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js"; import { WorkDir as BASE_DIR } from "../../config/config.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything. export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
@ -150,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files. - Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
- Keep user data safedouble-check before editing or deleting important resources. - Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## Workspace Access & Scope ## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. - **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. - **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). - **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:** **CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command). - 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. - You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`. - NEVER say "I can only run commands inside ~/.rowboat" 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 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 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 - they are on macOS. - NEVER ask what OS the user is on if runtime platform is already available.
- Load the \`organize-files\` skill for guidance on file organization tasks. - Load the \`organize-files\` skill for guidance on file organization tasks.
## Builtin Tools vs Shell Commands ## Builtin Tools vs Shell Commands

View file

@ -0,0 +1,69 @@
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
export interface RuntimeContext {
platform: NodeJS.Platform;
osName: RuntimeOsName;
shellDialect: RuntimeShellDialect;
shellExecutable: string;
}
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
}
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
if (platform === 'win32') {
return {
platform,
osName: 'Windows',
shellDialect: 'windows-cmd',
shellExecutable: getExecutionShell(platform),
};
}
if (platform === 'darwin') {
return {
platform,
osName: 'macOS',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
if (platform === 'linux') {
return {
platform,
osName: 'Linux',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
return {
platform,
osName: 'Unknown',
shellDialect: 'posix-sh',
shellExecutable: getExecutionShell(platform),
};
}
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
if (runtime.shellDialect === 'windows-cmd') {
return `## Runtime Platform (CRITICAL)
- Detected platform: **${runtime.platform}**
- Detected OS: **${runtime.osName}**
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
}
return `## Runtime Platform (CRITICAL)
- Detected platform: **${runtime.platform}**
- Detected OS: **${runtime.osName}**
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
- Do not assume Windows command syntax when the runtime is POSIX.`;
}

View file

@ -1,25 +1,14 @@
import { exec, execSync, spawn, ChildProcess } from 'child_process'; import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { existsSync } from 'fs';
import { promisify } from 'util'; import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js'; import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec); const execPromise = promisify(exec);
function getShell(): string {
if (process.platform !== 'win32') return '/bin/sh';
// On Windows, try Git Bash first, then fall back to cmd.exe
const gitBashPaths = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
];
for (const p of gitBashPaths) {
if (existsSync(p)) return p;
}
return 'cmd.exe';
}
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string { function sanitizeToken(token: string): string {
return token.trim().replace(/^['"()]+|['"()]+$/g, ''); return token.trim().replace(/^['"()]+|['"()]+$/g, '');
@ -99,7 +88,7 @@ export async function executeCommand(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: getShell(), // use sh for cross-platform compatibility shell: EXECUTION_SHELL,
}); });
return { return {
@ -159,7 +148,7 @@ export function executeCommandAbortable(
// Check if already aborted before spawning // Check if already aborted before spawning
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
// Return a dummy process and a resolved result // Return a dummy process and a resolved result
const dummyProc = spawn('true', { shell: true }); const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
dummyProc.kill(); dummyProc.kill();
return { return {
process: dummyProc, process: dummyProc,
@ -173,7 +162,7 @@ export function executeCommandAbortable(
} }
const proc = spawn(command, [], { const proc = spawn(command, [], {
shell: getShell(), shell: EXECUTION_SHELL,
cwd: options?.cwd, cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@ -287,7 +276,11 @@ export function executeCommandSync(
cwd: options?.cwd, cwd: options?.cwd,
timeout: options?.timeout, timeout: options?.timeout,
encoding: 'utf-8', encoding: 'utf-8',
<<<<<<< HEAD
shell: getShell(), shell: getShell(),
=======
shell: EXECUTION_SHELL,
>>>>>>> dev
}); });
return { return {

View file

@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
import { UserMessageContent } from "@x/shared/dist/message.js";
import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
type EnqueuedMessage = { type EnqueuedMessage = {
messageId: string; messageId: string;
message: string; message: UserMessageContentType;
}; };
export interface IMessageQueue { export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>; enqueue(runId: string, message: UserMessageContentType): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>; dequeue(runId: string): Promise<EnqueuedMessage | null>;
} }
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator; this.idGenerator = idGenerator;
} }
async enqueue(runId: string, message: string): Promise<string> { async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
if (!this.store[runId]) { if (!this.store[runId]) {
this.store[runId] = []; this.store[runId] = [];
} }

View file

@ -23,7 +23,7 @@ function ensureDefaultConfigs() {
const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json"); const noteCreationConfig = path.join(WorkDir, "config", "note_creation.json");
if (!fs.existsSync(noteCreationConfig)) { if (!fs.existsSync(noteCreationConfig)) {
fs.writeFileSync(noteCreationConfig, JSON.stringify({ fs.writeFileSync(noteCreationConfig, JSON.stringify({
strictness: "high", strictness: "medium",
configured: false configured: false
}, null, 2)); }, null, 2));
} }
@ -91,4 +91,9 @@ function ensureWelcomeFile() {
ensureDirs(); ensureDirs();
ensureDefaultConfigs(); ensureDefaultConfigs();
ensureWelcomeFile(); ensureWelcomeFile();
// Initialize version history repo (async, fire-and-forget on startup)
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
console.error('[VersionHistory] Failed to init repo:', err);
});

View file

@ -11,7 +11,7 @@ interface NoteCreationConfig {
} }
const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json'); const CONFIG_FILE = path.join(WorkDir, 'config', 'note_creation.json');
const DEFAULT_STRICTNESS: NoteCreationStrictness = 'high'; const DEFAULT_STRICTNESS: NoteCreationStrictness = 'medium';
/** /**
* Read the full config file. * Read the full config file.

View file

@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
export * as watcher from './workspace/watcher.js'; export * as watcher from './workspace/watcher.js';
// Config initialization // Config initialization
export { initConfigs } from './config/initConfigs.js'; export { initConfigs } from './config/initConfigs.js';
// Knowledge version history
export * as versionHistory from './knowledge/version_history.js';

View file

@ -1,7 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { autoConfigureStrictnessIfNeeded } from '../config/strictness_analyzer.js';
import { createRun, createMessage } from '../runs/runs.js'; import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js'; import { bus } from '../runs/bus.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
@ -15,6 +14,7 @@ import {
} from './graph_state.js'; } from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js'; import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js'; import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
/** /**
* Build obsidian-style knowledge graph by running topic extraction * Build obsidian-style knowledge graph by running topic extraction
@ -320,6 +320,13 @@ async function buildGraphWithFiles(
// Save state after each successful batch // Save state after each successful batch
// This ensures partial progress is saved even if later batches fail // This ensures partial progress is saved even if later batches fail
saveState(state); saveState(state);
// Commit knowledge changes to version history
try {
await commitAll('Knowledge update', 'Rowboat');
} catch (err) {
console.error(`[GraphBuilder] Failed to commit version history:`, err);
}
} catch (error) { } catch (error) {
hadError = true; hadError = true;
console.error(`Error processing batch ${batchNumber}:`, error); console.error(`Error processing batch ${batchNumber}:`, error);
@ -355,7 +362,19 @@ export async function buildGraph(sourceDir: string): Promise<void> {
console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`); console.log(`[buildGraph] State loaded. Previously processed: ${previouslyProcessedCount} files`);
// Get files that need processing (new or changed) // Get files that need processing (new or changed)
const filesToProcess = getFilesToProcess(sourceDir, state); let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
if (sourceDir.endsWith('gmail_sync')) {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
} catch {
return false;
}
});
}
if (filesToProcess.length === 0) { if (filesToProcess.length === 0) {
console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`); console.log(`[buildGraph] No new or changed files to process in ${path.basename(sourceDir)}`);
@ -467,6 +486,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
// Save state after each batch // Save state after each batch
saveState(state); saveState(state);
// Commit knowledge changes to version history
try {
await commitAll('Knowledge update', 'Rowboat');
} catch (err) {
console.error(`[GraphBuilder] Failed to commit version history:`, err);
}
} catch (error) { } catch (error) {
hadError = true; hadError = true;
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error); console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
@ -510,8 +536,6 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
async function processAllSources(): Promise<void> { async function processAllSources(): Promise<void> {
console.log('[GraphBuilder] Checking for new content in all sources...'); console.log('[GraphBuilder] Checking for new content in all sources...');
// Auto-configure strictness on first run if not already done
autoConfigureStrictnessIfNeeded();
let anyFilesProcessed = false; let anyFilesProcessed = false;
@ -540,7 +564,19 @@ async function processAllSources(): Promise<void> {
} }
try { try {
const filesToProcess = getFilesToProcess(sourceDir, state); let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
if (folder === 'gmail_sync') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
} catch {
return false;
}
});
}
if (filesToProcess.length > 0) { if (filesToProcess.length > 0) {
console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`); console.log(`[GraphBuilder] Found ${filesToProcess.length} new/changed files in ${folder}`);

View file

@ -0,0 +1,27 @@
import { BuiltinTools } from '../application/lib/builtin-tools.js';
export function getRaw(): string {
const toolEntries = Object.keys(BuiltinTools)
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
.join('\n');
return `---
model: gpt-5.2
tools:
${toolEntries}
---
# Task
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
# Instructions
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
3. Use the surrounding note content as context for the task.
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content it must read naturally as part of the document.
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
6. Keep the result concise and well-formatted in markdown.
7. Do not modify the original note file the service will handle inserting your response.
`;
}

View file

@ -0,0 +1,626 @@
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { createProvider } from '../models/models.js';
import { inlineTask } from '@x/shared';
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
const INLINE_TASK_AGENT = 'inline_task_agent';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
// ---------------------------------------------------------------------------
// Minimal frontmatter helpers (duplicated from renderer to avoid cross-package
// dependency — can be moved to shared later).
// ---------------------------------------------------------------------------
function splitFrontmatter(content: string): { raw: string | null; body: string } {
if (!content.startsWith('---')) {
return { raw: null, body: content };
}
const endIndex = content.indexOf('\n---', 3);
if (endIndex === -1) {
return { raw: null, body: content };
}
const closingEnd = endIndex + 4;
const raw = content.slice(0, closingEnd);
let body = content.slice(closingEnd);
if (body.startsWith('\n')) {
body = body.slice(1);
}
return { raw, body };
}
function joinFrontmatter(raw: string | null, body: string): string {
if (!raw) return body;
return raw + '\n' + body;
}
function extractAllFrontmatterValues(raw: string | null): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
if (!raw) return result;
const lines = raw.split('\n');
let currentKey: string | null = null;
for (const line of lines) {
if (line === '---' || line.trim() === '') {
currentKey = null;
continue;
}
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
if (topMatch) {
const key = topMatch[1];
const value = topMatch[2].trim();
if (value) {
result[key] = value;
currentKey = null;
} else {
currentKey = key;
result[key] = [];
}
continue;
}
if (currentKey) {
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch) {
const arr = result[currentKey];
if (Array.isArray(arr)) {
arr.push(itemMatch[1].trim());
}
}
}
}
return result;
}
function buildFrontmatter(fields: Record<string, string | string[]>): string | null {
const lines: string[] = [];
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) {
if (value.length === 0) continue;
lines.push(`${key}:`);
for (const item of value) {
if (item.trim()) lines.push(` - ${item.trim()}`);
}
} else {
const trimmed = (value ?? '').trim();
if (!trimmed) continue;
lines.push(`${key}: ${trimmed}`);
}
}
if (lines.length === 0) return null;
return `---\n${lines.join('\n')}\n---`;
}
// ---------------------------------------------------------------------------
// Schedule types
// ---------------------------------------------------------------------------
type InlineTaskSchedule =
| { type: 'cron'; expression: string; startDate: string; endDate: string; label: string }
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string; label: string }
| { type: 'once'; runAt: string; label: string };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function scanDirectoryRecursive(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
const files: string[] = [];
const entries = fs.readdirSync(dir);
for (const entry of entries) {
if (entry.startsWith('.')) continue;
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...scanDirectoryRecursive(fullPath));
} else if (stat.isFile() && entry.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
}
});
});
}
/**
* Extract the assistant's final text response from a run's log.
*/
async function extractAgentResponse(runId: string): Promise<string | null> {
const run = await fetchRun(runId);
// Walk backwards through the log to find the last assistant message
for (let i = run.log.length - 1; i >= 0; i--) {
const event = run.log[i];
if (event.type === 'message' && event.message.role === 'assistant') {
const content = event.message.content;
if (typeof content === 'string') {
return content;
}
// Content may be an array of parts — concatenate text parts
if (Array.isArray(content)) {
const text = content
.filter((p) => p.type === 'text')
.map((p) => (p as { type: 'text'; text: string }).text)
.join('');
return text || null;
}
}
}
return null;
}
interface InlineTask {
instruction: string;
schedule: InlineTaskSchedule | null;
/** Line index of the opening ```task fence in the body */
startLine: number;
/** Line index of the closing ``` fence */
endLine: number;
}
/**
* Parse the tell-rowboat block content (JSON format).
* Returns { instruction, schedule } or null if not valid JSON.
* Also supports legacy @rowboat format.
*/
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
const raw = contentLines.join('\n').trim();
try {
const data = JSON.parse(raw);
const parsed = inlineTask.InlineTaskBlockSchema.safeParse(data);
if (parsed.success) {
return {
instruction: parsed.data.instruction,
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
lastRunAt: parsed.data.lastRunAt ?? null,
};
}
// Fallback for blocks that have instruction but don't fully match schema
if (data && typeof data === 'object' && data.instruction) {
return {
instruction: data.instruction,
schedule: data.schedule ?? null,
lastRunAt: data.lastRunAt ?? null,
};
}
} catch {
// Legacy format: @rowboat lines + optional schedule: JSON line
}
// Legacy fallback: parse @rowboat instruction and schedule: line
let schedule: InlineTaskSchedule | null = null;
const instructionLines: string[] = [];
for (const cl of contentLines) {
const schedMatch = cl.trim().match(/^schedule:\s*(.+)$/);
if (schedMatch) {
try {
const obj = JSON.parse(schedMatch[1]);
if (obj && typeof obj === 'object' && obj.type) {
schedule = obj as InlineTaskSchedule;
}
} catch { /* not JSON schedule, skip */ }
} else if (!/^schedule-config:\s/.test(cl.trim())) {
instructionLines.push(cl);
}
}
const firstRowboatLine = instructionLines.find(l => l.trim().startsWith('@rowboat'));
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
if (!instruction) return null;
return { instruction, schedule, lastRunAt: null };
}
/**
* Determine if a scheduled task is due to run.
*/
function isScheduledTaskDue(schedule: InlineTaskSchedule, lastRunAt: string | null): boolean {
const now = new Date();
// Check startDate/endDate bounds for cron and window
if (schedule.type === 'cron' || schedule.type === 'window') {
if (schedule.startDate && now < new Date(schedule.startDate)) return false;
if (schedule.endDate && now > new Date(schedule.endDate)) return false;
}
switch (schedule.type) {
case 'cron': {
if (!lastRunAt) return true; // Never run → due
try {
const lastRun = new Date(lastRunAt);
const interval = CronExpressionParser.parse(schedule.expression, {
currentDate: lastRun,
});
const nextRun = interval.next().toDate();
return now >= nextRun;
} catch {
return false;
}
}
case 'window': {
if (!lastRunAt) return true;
try {
const lastRun = new Date(lastRunAt);
const interval = CronExpressionParser.parse(schedule.cron, {
currentDate: lastRun,
});
const nextDate = interval.next().toDate();
// Check if we're within the time window
const [startHour, startMin] = schedule.startTime.split(':').map(Number);
const [endHour, endMin] = schedule.endTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
// The cron date must have passed and we need to be in the time window
return now >= nextDate && nowMinutes >= startMinutes && nowMinutes <= endMinutes;
} catch {
return false;
}
}
case 'once': {
if (lastRunAt) return false; // Already ran
const runAt = new Date(schedule.runAt);
return now >= runAt;
}
}
}
/**
* Find ```tell-rowboat code blocks in a note body and return tasks that are pending execution.
*/
function findPendingTasks(body: string): InlineTask[] {
const tasks: InlineTask[] = [];
const lines = body.split('\n');
let i = 0;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
const startLine = i;
i++;
const contentLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
contentLines.push(lines[i]);
i++;
}
const endLine = i; // line with closing ```
const parsed = parseBlockContent(contentLines);
if (parsed) {
const { instruction, schedule, lastRunAt } = parsed;
if (schedule) {
if (isScheduledTaskDue(schedule, lastRunAt)) {
tasks.push({ instruction, schedule, startLine, endLine });
}
} else {
// One-time task: skip if already ran
if (!lastRunAt) {
tasks.push({ instruction, schedule: null, startLine, endLine });
}
}
}
}
i++;
}
return tasks;
}
/**
* Insert the agent result below the tell-rowboat code block in the body.
* Returns the updated body string.
*/
function insertResultBelow(body: string, endLine: number, result: string): string {
const lines = body.split('\n');
// Insert a blank line + result after the closing ``` fence
lines.splice(endLine + 1, 0, '', result);
return lines.join('\n');
}
/**
* Determine if a note has any "live" tell-rowboat tasks.
* A task is live if:
* - It's a one-time task that hasn't been completed yet
* - It's a scheduled task whose endDate hasn't passed (or has no endDate)
* - It's a scheduled task before its startDate (will run in the future)
*/
function hasLiveTasks(body: string): boolean {
const now = new Date();
const lines = body.split('\n');
let i = 0;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('```task') || trimmed.startsWith('```tell-rowboat')) {
i++;
const contentLines: string[] = [];
while (i < lines.length && lines[i].trim() !== '```') {
contentLines.push(lines[i]);
i++;
}
const parsed = parseBlockContent(contentLines);
if (!parsed) { i++; continue; }
const { schedule, lastRunAt } = parsed;
if (schedule) {
if (schedule.type === 'cron' || schedule.type === 'window') {
const endDate = schedule.endDate;
if (!endDate || now <= new Date(endDate)) {
return true;
}
} else if (schedule.type === 'once') {
if (!lastRunAt) {
return true;
}
}
} else {
// One-time task without schedule: live if never ran
if (!lastRunAt) {
return true;
}
}
}
i++;
}
return false;
}
// ---------------------------------------------------------------------------
// Block data helpers
// ---------------------------------------------------------------------------
/**
* Update the JSON content inside a task code block to include lastRunAt.
* Replaces the content lines between the opening and closing fences.
*/
function updateBlockData(body: string, startLine: number, endLine: number, lastRunAt: string): string {
const lines = body.split('\n');
// Content is between startLine+1 and endLine-1
const contentLines = lines.slice(startLine + 1, endLine);
const raw = contentLines.join('\n').trim();
try {
const data = JSON.parse(raw);
data.lastRunAt = lastRunAt;
const updatedJson = JSON.stringify(data);
// Replace content lines with the updated JSON (single line)
lines.splice(startLine + 1, endLine - startLine - 1, updatedJson);
} catch {
// Not valid JSON — skip update
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Main processing
// ---------------------------------------------------------------------------
async function processInlineTasks(): Promise<void> {
console.log('[InlineTasks] Checking live notes...');
if (!fs.existsSync(KNOWLEDGE_DIR)) {
console.log('[InlineTasks] Knowledge directory not found');
return;
}
const allFiles = scanDirectoryRecursive(KNOWLEDGE_DIR);
let totalProcessed = 0;
for (const filePath of allFiles) {
let content: string;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const { raw, body } = splitFrontmatter(content);
const fields = extractAllFrontmatterValues(raw);
// Only process files marked as live
if (fields['live_note'] !== 'true') continue;
const tasks = findPendingTasks(body);
if (tasks.length === 0) {
// No pending tasks — check if still live, update if not
const live = hasLiveTasks(body);
if (!live) {
fields['live_note'] = 'false';
// Remove rowboat_tasks if present (legacy cleanup)
delete fields['rowboat_tasks'];
const newRaw = buildFrontmatter(fields);
const newContent = joinFrontmatter(newRaw, body);
try {
fs.writeFileSync(filePath, newContent, 'utf-8');
const rel = path.relative(WorkDir, filePath);
console.log(`[InlineTasks] Marked ${rel} as no longer live`);
} catch { /* ignore */ }
}
continue;
}
const relativePath = path.relative(WorkDir, filePath);
console.log(`[InlineTasks] Found ${tasks.length} pending task(s) in ${relativePath}`);
// Process tasks one at a time, bottom-up so line indices stay valid
// (inserting content shifts lines below, so process from bottom to top)
const sortedTasks = [...tasks].sort((a, b) => b.endLine - a.endLine);
let currentBody = body;
for (const task of sortedTasks) {
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
try {
const run = await createRun({ agentId: INLINE_TASK_AGENT });
const message = [
`Execute the following instruction from the note "${relativePath}":`,
'',
`**Instruction:** ${task.instruction}`,
'',
'**Full note content for context:**',
'```markdown',
content,
'```',
].join('\n');
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
const result = await extractAgentResponse(run.id);
if (result) {
currentBody = insertResultBelow(currentBody, task.endLine, result);
// Update the block JSON with lastRunAt
const timestamp = new Date().toISOString();
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
totalProcessed++;
console.log(`[InlineTasks] Task completed`);
} else {
console.warn(`[InlineTasks] No response from agent for task`);
}
} catch (error) {
console.error(`[InlineTasks] Error processing task:`, error);
}
}
// Update frontmatter — only manage live_note, remove legacy rowboat_tasks
const live = hasLiveTasks(currentBody);
fields['live_note'] = live ? 'true' : 'false';
delete fields['rowboat_tasks'];
const newRaw = buildFrontmatter(fields);
const newContent = joinFrontmatter(newRaw, currentBody);
try {
fs.writeFileSync(filePath, newContent, 'utf-8');
console.log(`[InlineTasks] Updated ${relativePath}`);
} catch (error) {
console.error(`[InlineTasks] Error writing ${relativePath}:`, error);
}
}
if (totalProcessed > 0) {
console.log(`[InlineTasks] Done. Processed ${totalProcessed} task(s).`);
} else {
console.log('[InlineTasks] No pending tasks found');
}
}
/**
* Classify whether an instruction contains a scheduling intent using the user's configured LLM.
* Returns a schedule object or null for one-time tasks.
*/
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const provider = createProvider(config.provider);
const model = provider.languageModel(config.model);
const now = new Date();
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const localEnd = defaultEnd.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const nowISO = now.toISOString();
const defaultEndISO = defaultEnd.toISOString();
const systemPrompt = `You classify whether a user instruction contains a scheduling intent.
If the instruction implies a recurring or future-scheduled task, return a JSON object with the schedule.
If the instruction is a one-time immediate task, return null.
Every schedule object MUST include a "label" field: a short, plain-English description starting with "runs" that includes the end date (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12").
Schedule types:
1. "cron" recurring schedule. Return: {"type":"cron","expression":"<cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
Use standard 5-field cron (minute hour day-of-month month day-of-week).
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
Override these if the user specifies a duration (e.g. "for the next 3 days" endDate = now + 3 days) or a start (e.g. "starting next Monday").
Example: "every morning at 8am" {"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until Mar 12"}
2. "window" recurring with a time window. Return: {"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}
Use when the user specifies a range like "between 8am and 10am". Same startDate/endDate defaults and override rules as cron.
3. "once" run once at a specific future time. Return: {"type":"once","runAt":"<ISO 8601 datetime>","label":"<human readable>"}
Use when the user says "tomorrow at 3pm", "next Friday", etc. No startDate/endDate for once.
Current local time: ${localNow}
Timezone: ${tz}
Current UTC time: ${nowISO}
Default end time (local): ${localEnd}
Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
try {
const result = await generateText({
model,
system: systemPrompt,
prompt: instruction,
});
let text = result.text.trim();
console.log('[classifySchedule] LLM response:', text);
// Strip markdown code fences if the LLM wraps the JSON
text = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
if (text === 'null' || text === '') {
return null;
}
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== 'object' || !parsed.type) {
return null;
}
return parsed as InlineTaskSchedule;
} catch (error) {
console.error('[classifySchedule] Error:', error);
return null;
}
}
/**
* Main entry point runs as independent polling service
*/
export async function init() {
console.log('[InlineTasks] Starting Inline Task Service...');
console.log(`[InlineTasks] Will check for task blocks every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run
await processInlineTasks();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
try {
await processInlineTasks();
} catch (error) {
console.error('[InlineTasks] Error in main loop:', error);
}
}
}

View file

@ -0,0 +1,269 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
loadLabelingState,
saveLabelingState,
markFileAsLabeled,
type LabelingState,
} from './labeling_state.js';
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
const BATCH_SIZE = 15;
const LABELING_AGENT = 'labeling_agent';
const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const MAX_CONTENT_LENGTH = 8000;
/**
* Find email files that haven't been labeled yet
*/
function getUnlabeledEmails(state: LabelingState): string[] {
if (!fs.existsSync(GMAIL_SYNC_DIR)) {
return [];
}
const unlabeled: string[] = [];
function traverse(dir: string) {
const entries = fs.readdirSync(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
traverse(fullPath);
} else if (stat.isFile() && entry.endsWith('.md')) {
// Skip if already tracked in state
if (state.processedFiles[fullPath]) {
continue;
}
// Skip if file already has frontmatter
try {
const content = fs.readFileSync(fullPath, 'utf-8');
if (content.startsWith('---')) {
continue;
}
} catch {
continue;
}
unlabeled.push(fullPath);
}
}
}
traverse(GMAIL_SYNC_DIR);
return unlabeled;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
}
});
});
}
/**
* Label a batch of email files using the labeling agent
*/
async function labelEmailBatch(
files: { path: string; content: string }[]
): Promise<{ runId: string; filesEdited: Set<string> }> {
const run = await createRun({
agentId: LABELING_AGENT,
});
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`;
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;
message += `## File ${i + 1}: ${relativePath}\n\n`;
message += truncated;
message += `\n\n---\n\n`;
}
const filesEdited = new Set<string>();
const unsubscribe = await bus.subscribe(run.id, async (event) => {
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'workspace-edit') {
return;
}
try {
const parsed = JSON.parse(event.input) as { path?: string };
if (typeof parsed.path === 'string') {
filesEdited.add(parsed.path);
}
} catch {
// ignore parse errors
}
});
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
unsubscribe();
return { runId: run.id, filesEdited };
}
/**
* Process all unlabeled emails in batches
*/
async function processUnlabeledEmails(): Promise<void> {
console.log('[EmailLabeling] Checking for unlabeled emails...');
const state = loadLabelingState();
const unlabeled = getUnlabeledEmails(state);
if (unlabeled.length === 0) {
console.log('[EmailLabeling] No unlabeled emails found');
return;
}
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
const run = await serviceLogger.startRun({
service: 'email_labeling',
message: `Labeling ${unlabeled.length} email${unlabeled.length === 1 ? '' : 's'}`,
trigger: 'timer',
});
const relativeFiles = unlabeled.map(f => path.relative(WorkDir, f));
const limitedFiles = limitEventItems(relativeFiles);
await serviceLogger.log({
type: 'changes_identified',
service: run.service,
runId: run.runId,
level: 'info',
message: `Found ${unlabeled.length} unlabeled email${unlabeled.length === 1 ? '' : 's'}`,
counts: { emails: unlabeled.length },
items: limitedFiles.items,
truncated: limitedFiles.truncated,
});
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
let totalEdited = 0;
let hadError = false;
for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) {
const batchPaths = unlabeled.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
try {
// Read file contents for the batch
const files: { path: string; content: string }[] = [];
for (const filePath of batchPaths) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
files.push({ path: filePath, content });
} catch (error) {
console.error(`[EmailLabeling] Error reading ${filePath}:`, error);
}
}
if (files.length === 0) {
continue;
}
console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
await serviceLogger.log({
type: 'progress',
service: run.service,
runId: run.runId,
level: 'info',
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
step: 'batch',
current: batchNumber,
total: totalBatches,
details: { filesInBatch: files.length },
});
const result = await labelEmailBatch(files);
totalEdited += result.filesEdited.size;
// Only mark files that were actually edited by the agent
for (const file of files) {
const relativePath = path.relative(WorkDir, file.path);
if (result.filesEdited.has(relativePath)) {
markFileAsLabeled(file.path, state);
}
}
saveLabelingState(state);
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
} catch (error) {
hadError = true;
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
context: { batchNumber },
});
}
}
state.lastRunTime = new Date().toISOString();
saveLabelingState(state);
await serviceLogger.log({
type: 'run_complete',
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Email labeling complete: ${totalEdited} files labeled`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: {
totalEmails: unlabeled.length,
filesLabeled: totalEdited,
},
});
console.log(`[EmailLabeling] Done. ${totalEdited} emails labeled.`);
}
/**
* Main entry point - runs as independent polling service
*/
export async function init() {
console.log('[EmailLabeling] Starting Email Labeling Service...');
console.log(`[EmailLabeling] Will check for unlabeled emails every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run
await processUnlabeledEmails();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
try {
await processUnlabeledEmails();
} catch (error) {
console.error('[EmailLabeling] Error in main loop:', error);
}
}
}

View file

@ -0,0 +1,59 @@
import { renderTagSystemForEmails } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2
tools:
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
---
# Task
You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels.
${renderTagSystemForEmails()}
# Instructions
1. For each email file provided in the message, read its content carefully.
2. Classify the email using the taxonomy above. Be accurate and conservative only apply labels that clearly fit.
3. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
5. If the email already has frontmatter (starts with \`---\`), skip it.
# Frontmatter Format
\`\`\`yaml
---
labels:
relationship:
- Investor
topics:
- Fundraising
- Finance
type: Intro
filter:
- Promotion
action: FYI
processed: true
labeled_at: "2026-02-28T12:00:00Z"
---
\`\`\`
# Rules
- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays).
- \`type\` and \`action\` are single values (strings), not arrays.
- \`relationship\`, \`topics\`, and \`filter\` are arrays.
- Use the exact label values from the taxonomy do not invent new ones.
- The \`labeled_at\` timestamp should be the current time in ISO 8601 format.
- Process all files in the batch. Do not skip any unless they already have frontmatter.
`;
}

View file

@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
const STATE_FILE = path.join(WorkDir, 'labeling_state.json');
export interface LabelingState {
processedFiles: Record<string, { labeledAt: string }>;
lastRunTime: string;
}
export function loadLabelingState(): LabelingState {
if (fs.existsSync(STATE_FILE)) {
try {
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
} catch (error) {
console.error('Error loading labeling state:', error);
}
}
return {
processedFiles: {},
lastRunTime: new Date(0).toISOString(),
};
}
export function saveLabelingState(state: LabelingState): void {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch (error) {
console.error('Error saving labeling state:', error);
throw error;
}
}
export function markFileAsLabeled(filePath: string, state: LabelingState): void {
state.processedFiles[filePath] = {
labeledAt: new Date().toISOString(),
};
}
export function resetLabelingState(): void {
const emptyState: LabelingState = {
processedFiles: {},
lastRunTime: new Date().toISOString(),
};
saveLabelingState(emptyState);
}

View file

@ -1,4 +1,8 @@
export const raw = `--- import { renderNoteTypesBlock } from './note_system.js';
import { renderNoteEffectRules } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2 model: gpt-5.2
tools: tools:
workspace-writeFile: workspace-writeFile:
@ -130,25 +134,15 @@ Either:
--- ---
# The Core Rule: Medium Strictness # The Core Rule: Label-Based Filtering
**MEDIUM STRICTNESS MODE** **Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip.
**Meetings create notes because:** **Meetings and voice memos always create notes** no label check needed.
- You chose to spend time with these people
- If you met them, they matter enough to track
- Meeting transcripts have rich context
**Emails can create notes if:** **For emails, read the YAML frontmatter labels and apply these rules:**
- The email contains personalized content (not mass mail)
- The sender seems relevant to your work (business context, not consumer services)
- The email is part of a meaningful exchange (not one-off transactional)
**Skip creating notes for:** ${renderNoteEffectRules()}
- Mass emails and newsletters
- Automated/transactional emails
- Consumer service providers (utilities, subscriptions, etc.)
- Cold sales outreach with no prior relationship indication
--- ---
@ -217,168 +211,40 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data
--- ---
# Step 1: Source Filtering # Step 1: Source Filtering (Label-Based)
## Skip These Sources (Both Meetings and Emails) ## For Meetings and Voice Memos
Always process no filtering needed.
### Mass Emails and Newsletters ## For Emails Read YAML Frontmatter
**Indicators:** Emails have YAML frontmatter with labels prepended by the labeling agent:
- Sent to a list (To: contains multiple addresses, or undisclosed-recipients)
- Unsubscribe link in body or footer
- From a no-reply or marketing address (noreply@, newsletter@, marketing@, hello@)
- Generic greeting ("Hi there", "Dear subscriber", "Hello!")
- Promotional language ("Don't miss out", "Limited time", "% off")
- Mailing list headers (List-Unsubscribe, Mailing-List)
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
**Action:** SKIP with reason "Newsletter/mass email" \`\`\`yaml
---
labels:
relationship:
- Investor
topics:
- Fundraising
type: Intro
filter: []
action: FYI
processed: true
labeled_at: "2026-02-28T12:00:00Z"
---
\`\`\`
### Product Updates & Changelogs ## Decision Rules
**Indicators:** ${renderNoteEffectRules()}
- Subject contains: "changelog", "what's new", "product update", "release notes", "v1.x", "new features"
- Content describes feature releases, bug fixes, or product changes
- Sent to all users/customers (not personalized to you specifically)
- From tools/SaaS you use: Cal.com, Notion, Slack, Linear, Figma, etc.
- No action required from you purely informational
- Written in announcement style, not conversational
**Examples to SKIP:**
- "Cal.com Changelog v6.1" product update
- "What's new in Notion - January 2026" feature announcement
- "Introducing new Slack features" product marketing
- "Linear Release Notes" changelog
**Action:** SKIP with reason "Product update/changelog"
### Cold Outreach / Sales Emails
**THE RULE: If someone emails you offering services and you never responded, SKIP.**
It doesn't matter how personalized, detailed, or relevant the pitch seems. If:
1. They initiated contact (you didn't reach out first)
2. They're offering services/products
3. You never replied or engaged
Then it's cold outreach and should be SKIPPED. Do NOT create notes for cold outreach senders or their organizations.
**EXCEPTION:** If they reference a prior real-world interaction, CREATE a note:
- "Great meeting you at [conference/event]"
- "Following up on our conversation at..."
- "It was nice chatting at [place]"
- "[Mutual contact] suggested I reach out after we met"
This indicates a real relationship that started offline, not cold outreach.
**Indicators:**
- Unsolicited contact from someone you've never interacted with
- Offering services you didn't request (HR, payroll, compliance, bookkeeping, recruiting, dev shops, marketing, etc.)
- Sales-y language: "wanted to reach out", "thought this might help", "quick question about your..."
- Mentions your company growth/funding/hiring/tech stack as a hook
- Attaches "free guides", "case studies", "resources", or "frameworks"
- Asks for a call/meeting without any prior relationship
- From domains you've never contacted or met with before
- No existing note for this person or organization
- **No reply from the user in the email thread**
**Examples to SKIP:**
- "Saw you raised funding, wanted to reach out about our services"
- "Quick question about your bookkeeping/compliance/hiring"
- "Shared this guide that might help with [your problem]"
- "Noticed you're scaling, we help startups with..."
- "Would love 15 minutes to show you how we can help"
- Detailed pitch about HR/payroll/India expansion services (still cold outreach!)
- Follow-up emails to previous cold outreach that got no response
**Key distinction:**
- **You reaching out to a vendor** worth tracking (you initiated)
- **You replied to their outreach** worth tracking (you engaged)
- **Vendor cold emailing you with no response** SKIP (no relationship exists)
**IMPORTANT: CC'd people on cold outreach**
When an email is identified as cold outreach, skip notes for ALL parties involved:
- The sender (the person doing the outreach)
- Anyone CC'd on the email (colleagues of the sender, other contacts they're trying to connect)
- The organization they represent
If someone only appears in your memory as "CC'd on outreach emails from [Sender]", they don't warrant a note — they're just incidentally included in cold outreach, not a real relationship.
**Action:** SKIP with reason "Cold outreach/sales email - no engagement from user"
### Automated/Transactional
**Indicators:**
- From automated systems (notifications@, alerts@, no-reply@)
- Password resets, login alerts, shipping notifications
- Calendar invites without substance
- Receipts and invoices (unless from key vendor/customer)
- GitHub/Jira/Slack notifications
**Action:** SKIP with reason "Automated/transactional"
### Low-Signal
**Indicators:**
- Very short with no substance ("Thanks!", "Sounds good", "Got it")
- Only contains forwarded message with no commentary
- Auto-replies ("I'm out of office")
**Action:** SKIP with reason "Low signal"
### Consumer Services (Medium strictness specific)
**Indicators:**
- From consumer service companies (utilities, streaming, retail)
- Account management emails
- Subscription confirmations
- Delivery notifications
**Action:** SKIP with reason "Consumer service"
### Infrastructure & SaaS Providers
**Skip emails from these types of services:**
- Domain registrars: GoDaddy, Namecheap, Google Domains, Cloudflare
- Hosting providers: AWS, Google Cloud, Azure, DigitalOcean, Heroku, Vercel, Netlify
- Email providers: Google Workspace, Microsoft 365, Zoho
- Payment processors: Stripe, PayPal, Square, Razorpay
- Developer tools: GitHub, GitLab, Bitbucket, npm, Docker Hub
- Analytics: Google Analytics, Mixpanel, Amplitude, Segment
- Auth providers: Auth0, Okta, Firebase Auth
- Support platforms: Zendesk, Intercom, Freshdesk
- HR/Payroll: Gusto, Rippling, Deel, Remote
**Indicators:**
- Automated system notifications (renewal reminders, usage alerts, security notices)
- No personalized content from a human
- From domains like @godaddy.com, @aws.amazon.com, @stripe.com, etc.
- Templates about account status, billing, or technical alerts
**Action:** SKIP with reason "Infrastructure/SaaS provider notification"
## Email-Specific Processing (Medium Strictness)
For emails, evaluate if the content is personalized and business-relevant:
**Create note if:**
- The email is personally addressed and substantive
- The sender appears to be from a business/organization relevant to your work
- The content discusses work, projects, opportunities, or professional topics
- It's a warm intro from anyone (not just existing contacts)
- It's a thoughtful cold outreach that's specific to your work
**Do not create note if:**
- Clearly mass/templated email
- Consumer service interaction
- Generic sales pitch with no personalization
## Filter Decision Output ## Filter Decision Output
If skipping: If skipping:
\`\`\` \`\`\`
SKIP SKIP
Reason: {reason} Reason: Labels indicate skip-only categories: {list the labels}
\`\`\` \`\`\`
If processing, continue to Step 2. If processing, continue to Step 2.
@ -552,16 +418,16 @@ Resolution Map:
- "the integration" "Acme Integration" (same project) - "the integration" "Acme Integration" (same project)
\`\`\` \`\`\`
## 4b: Apply Source Type Rules (Medium Strictness) ## 4b: Apply Source Type Rules
**If source_type == "meeting":** **If source_type == "meeting" or "voice_memo":**
- Resolved entities Update existing notes - Resolved entities Update existing notes
- New entities that pass filters Create new notes - New entities that pass filters Create new notes
**If source_type == "email" (MEDIUM STRICTNESS):** **If source_type == "email":**
- The email already passed label-based filtering in Step 1
- Resolved entities Update existing notes - Resolved entities Update existing notes
- New entities Create notes IF the email is personalized and business-relevant - New entities Create notes (the labels already confirmed this email is worth processing)
- New entities from cold sales pitches without personalization Skip
## 4c: Disambiguation Rules ## 4c: Disambiguation Rules
@ -628,39 +494,23 @@ For entities not resolved to existing notes, determine if they warrant new notes
## People ## People
### Who Gets a Note (Medium Strictness) ### Who Gets a Note
**CREATE a note for people who are:** **CREATE a note for people who are:**
- External (not @user.domain) - External (not @user.domain)
- Attendees in meetings - Attendees in meetings
- Email correspondents sending personalized, business-relevant content - Email correspondents (emails that reach this step already passed label-based filtering)
- Decision makers or contacts at customers, prospects, or partners - Decision makers or contacts at customers, prospects, or partners
- Investors or potential investors - Investors or potential investors
- Candidates you are interviewing - Candidates you are interviewing
- Advisors or mentors - Advisors or mentors
- Key collaborators - Key collaborators
- Introducers who connect you to valuable contacts - Introducers who connect you to valuable contacts
- Anyone reaching out with a specific, relevant opportunity
**DO NOT create notes for:** **DO NOT create notes for:**
- Transactional service providers (bank employees, support reps)
- One-time administrative contacts
- Large group meeting attendees you didn't interact with - Large group meeting attendees you didn't interact with
- Internal colleagues (@user.domain) - Internal colleagues (@user.domain)
- Assistants handling only logistics - Assistants handling only logistics
- Generic role-based contacts
- Consumer service representatives
- Generic cold sales outreach with no personalization
### The Relevance Test (Medium Strictness)
Ask: Is this person relevant to my professional work or goals?
- Sarah Chen, VP Engineering evaluating your product **Yes, create note**
- James from HSBC who set up your account **No, skip**
- Investor reaching out about your company **Yes, create note**
- Cold recruiter with a generic pitch **No, skip**
- Someone reaching out about a specific opportunity **Yes, create note**
### Role Inference ### Role Inference
@ -1025,153 +875,18 @@ After writing, verify links go both ways.
--- ---
# Note Templates ${renderNoteTypesBlock()}
## People
\`\`\`markdown
# {Full Name}
## Info
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
**Organization:** [[Organizations/{organization}]] or leave blank
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them, what you're working on together.}
## Connected to
- [[Organizations/{Organization}]] works at
- [[People/{Person}]] {colleague, introduced by, reports to}
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Organizations
\`\`\`markdown
# {Organization Name}
## Info
**Type:** {company|team|institution|other}
**Industry:** {industry or leave blank}
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
**Domain:** {primary email domain}
**Aliases:** {comma-separated: short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
## People
- [[People/{Person}]] {role}
## Contacts
{For transactional contacts who don't get their own notes}
## Projects
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Projects
\`\`\`markdown
# {Project Name}
## Info
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
## People
- [[People/{Person}]] {role}
## Organizations
- [[Organizations/{Org}]] {customer|partner|etc.}
## Related
- [[Topics/{Topic}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Timeline
**{YYYY-MM-DD}** ({meeting|email})
{What happened.}
## Decisions
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
## Open items
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}
\`\`\`
## Topics
\`\`\`markdown
# {Topic Name}
## About
{1-2 sentences: What this topic covers.}
**Keywords:** {comma-separated}
**Aliases:** {other ways this topic is referenced}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}
- [[Organizations/{Org}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Log
**{YYYY-MM-DD}** ({meeting|email}: {title})
{Summary with [[Folder/Name]] links}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}
\`\`\`
--- ---
# Summary: Medium Strictness Rules # Summary: Label-Based Rules
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? | | Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------| |-------------|---------------|----------------|------------------------|
| Meeting | Yes | Yes | Yes | | Meeting | Yes | Yes | Yes |
| Voice memo | Yes | Yes | Yes | | Voice memo | Yes | Yes | Yes |
| Email (personalized, business-relevant) | Yes | Yes | Yes | | Email (has create label) | Yes | Yes | Yes |
| Email (mass/automated/consumer) | No (SKIP) | No | No | | Email (only skip labels) | No (SKIP) | No | No |
| Email (cold outreach with personalization) | Yes | Yes | Yes |
| Email (generic cold outreach) | No | No | No |
**Voice memo activity format:** Always include a link to the source voice memo: **Voice memo activity format:** Always include a link to the source voice memo:
\`\`\` \`\`\`
@ -1198,7 +913,7 @@ Before completing, verify:
**Source Type:** **Source Type:**
- [ ] Correctly identified as meeting or email - [ ] Correctly identified as meeting or email
- [ ] Applied correct medium strictness rules - [ ] Applied label-based filtering rules correctly
**Resolution:** **Resolution:**
- [ ] Extracted all name variants from source - [ ] Extracted all name variants from source
@ -1233,4 +948,5 @@ Before completing, verify:
- [ ] Dates are YYYY-MM-DD - [ ] Dates are YYYY-MM-DD
- [ ] Bidirectional links are consistent - [ ] Bidirectional links are consistent
- [ ] New notes in correct folders - [ ] New notes in correct folders
`; `;
}

File diff suppressed because it is too large Load diff

View file

@ -1,874 +0,0 @@
export const raw = `---
model: gpt-5.2
tools:
workspace-writeFile:
type: builtin
name: workspace-writeFile
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
workspace-grep:
type: builtin
name: workspace-grep
workspace-glob:
type: builtin
name: workspace-glob
---
# Task
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
1. **Determine source type (meeting or email)**
2. **Evaluate if the source is worth processing**
3. **Search for all existing related notes**
4. **Resolve entities to canonical names**
5. Identify new entities worth tracking
6. Extract structured information (decisions, commitments, key facts)
7. **Detect state changes (status updates, resolved items, role changes)**
8. Create new notes or update existing notes
9. **Apply state changes to existing notes**
The core rule: **Capture broadly. Meetings, voice memos, and emails create notes for most external contacts.**
You have full read access to the existing knowledge directory. Use this extensively to:
- Find existing notes for people, organizations, projects mentioned
- Resolve ambiguous names (find existing note for "David")
- Understand existing relationships before updating
- Avoid creating duplicate notes
- Maintain consistency with existing content
- **Detect when new information changes the state of existing notes**
# Inputs
1. **source_file**: Path to a single file to process (email or meeting transcript)
2. **knowledge_folder**: Path to Obsidian vault (read/write access)
3. **user**: Information about the owner of this memory
- name: e.g., "Arj"
- email: e.g., "arj@rowboat.com"
- domain: e.g., "rowboat.com"
4. **knowledge_index**: A pre-built index of all existing notes (provided in the message)
# Knowledge Base Index
**IMPORTANT:** You will receive a pre-built index of all existing notes at the start of each request. This index contains:
- All people notes with their names, emails, aliases, and organizations
- All organization notes with their names, domains, and aliases
- All project notes with their names and statuses
- All topic notes with their names and keywords
**USE THE INDEX for entity resolution instead of grep/search commands.** This is much faster.
When you need to:
- Check if a person exists Look up by name/email/alias in the index
- Find an organization Look up by name/domain in the index
- Resolve "David" to a full name Check index for people with that name/alias + organization context
**Only use \`cat\` to read full note content** when you need details not in the index (e.g., existing activity logs, open items).
# Tools Available
You have access to these tools:
**For reading files:**
\`\`\`
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
\`\`\`
**For creating NEW files:**
\`\`\`
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\`
**For editing EXISTING files (preferred for updates):**
\`\`\`
workspace-edit({
path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
})
\`\`\`
**For listing directories:**
\`\`\`
workspace-readdir({ path: "knowledge/People" })
\`\`\`
**For creating directories:**
\`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\`
**For searching files:**
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\`
**For finding files by pattern:**
\`\`\`
workspace-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
- Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output
Either:
- **SKIP** with reason, if source should be ignored
- Updated or new markdown files in notes_folder
---
# The Core Rule: Low Strictness - Capture Broadly
**LOW STRICTNESS MODE**
This mode prioritizes comprehensive capture over selectivity. The goal is to never miss a potentially important contact.
**Meetings create notes for:**
- All external attendees (anyone not @user.domain)
**Emails create notes for:**
- Any personalized email from an identifiable sender
- Anyone who reaches out directly
- Any external contact who communicates with you
**Only skip:**
- Obvious automated/system emails (no human sender)
- Mass newsletters with unsubscribe links
- Truly anonymous or unidentifiable senders
**Philosophy:** It's better to have a note you don't need than to miss tracking someone important.
---
# Step 0: Determine Source Type
Read the source file and determine if it's a meeting or email.
\`\`\`
workspace-readFile({ path: "{source_file}" })
\`\`\`
**Meeting indicators:**
- Has \`Attendees:\` field
- Has \`Meeting:\` title
- Transcript format with speaker labels
**Email indicators:**
- Has \`From:\` and \`To:\` fields
- Has \`Subject:\` field
- Email signature
**Voice memo indicators:**
- Has \`**Type:** voice memo\` field
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Set processing mode:**
- \`source_type = "meeting"\` → Create notes for all external attendees
- \`source_type = "email"\` → Create notes for sender if identifiable human
- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings)
---
## Calendar Invite Emails
Emails containing calendar invites (\`.ics\` attachments) are **high signal** - a scheduled meeting means this person matters.
**How to identify:**
- Subject contains "Invitation:", "Accepted:", "Declined:", or "Updated:"
- Has \`.ics\` attachment reference
**Rules:**
1. **CREATE a note for the primary contact** - the person you're meeting with
2. **Skip automated notifications** - from calendar-no-reply@google.com with no human sender
3. **Skip "Accepted/Declined" responses** - just RSVP confirmations
Once a note exists, subsequent emails will enrich it. When the meeting happens, the transcript adds more detail.
---
# Step 1: Source Filtering (Minimal)
## Skip Only These Sources
### Mass Newsletters
**Indicators (must have MULTIPLE of these):**
- Unsubscribe link in body or footer
- From a marketing address (noreply@, newsletter@, marketing@)
- Sent to multiple recipients or undisclosed-recipients
- Sent via marketing platforms (via sendgrid, via mailchimp, etc.)
**Action:** SKIP with reason "Mass newsletter"
### Purely Automated (No Human Sender)
**Indicators:**
- From automated systems with no human behind them (alerts@, notifications@)
- Password resets, login alerts
- System notifications (GitHub automated, CI/CD alerts)
- Receipt confirmations with no human contact info
**Action:** SKIP with reason "Automated system message"
### Truly Low-Signal
**Indicators (must be clearly content-free):**
- Body is ONLY "Thanks!", "Got it", "OK" with nothing else
- Auto-replies ("I'm out of office") with no human context
**Action:** SKIP with reason "No substantive content"
## Process Everything Else
**Important:** When in doubt, PROCESS. In low strictness mode, we err on the side of capturing more.
If skipping:
\`\`\`
SKIP
Reason: {reason}
\`\`\`
If processing, continue to Step 2.
---
# Step 2: Read and Parse Source File
\`\`\`
workspace-readFile({ path: "{source_file}" })
\`\`\`
Extract metadata:
**For meetings:**
- **Date:** From header or filename
- **Title:** Meeting name
- **Attendees:** List of participants
- **Duration:** If available
**For emails:**
- **Date:** From \`Date:\` header
- **Subject:** From \`Subject:\` header
- **From:** Sender email/name
- **To/Cc:** Recipients
## 2a: Exclude Self
Never create or update notes for:
- The user (matches user.name, user.email, or @user.domain)
- Anyone @{user.domain} (colleagues at user's company)
Filter these out from attendees/participants before proceeding.
## 2b: Extract All Name Variants
From the source, collect every way entities are referenced:
**People variants:**
- Full names: "Sarah Chen"
- First names only: "Sarah"
- Last names only: "Chen"
- Initials: "S. Chen"
- Email addresses: "sarah@acme.com"
- Roles/titles: "their CTO", "the VP of Engineering"
**Organization variants:**
- Full names: "Acme Corporation"
- Short names: "Acme"
- Abbreviations: "AC"
- Email domains: "@acme.com"
**Project variants:**
- Explicit names: "Project Atlas"
- Descriptive references: "the integration", "the pilot", "the deal"
Create a list of all variants found.
---
# Step 3: Look Up Existing Notes in Index
**Use the provided knowledge_index to find existing notes. Do NOT use grep commands.**
## 3a: Look Up People
For each person variant (name, email, alias), check the index:
\`\`\`
From index, find matches for:
- "Sarah Chen" Check People table for matching name
- "Sarah" Check People table for matching name or alias
- "sarah@acme.com" Check People table for matching email
- "@acme.com" Check People table for matching organization or check Organizations for domain
\`\`\`
## 3b: Look Up Organizations
\`\`\`
From index, find matches for:
- "Acme Corp" Check Organizations table for matching name
- "Acme" Check Organizations table for matching name or alias
- "acme.com" Check Organizations table for matching domain
\`\`\`
## 3c: Look Up Projects and Topics
\`\`\`
From index, find matches for:
- "the pilot" Check Projects table for related names
- "SOC 2" Check Topics table for matching keywords
\`\`\`
## 3d: Read Full Notes When Needed
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
**Why read these notes:**
- Find canonical names (David David Kim)
- Check Aliases fields for known variants
- Understand existing relationships
- See organization context for disambiguation
- Check what's already captured (avoid duplicates)
- Review open items (some might be resolved)
- **Check current status fields (might need updating)**
- **Check current roles (might have changed)**
## 3e: Matching Criteria
Use these criteria to determine if a variant matches an existing note:
**People matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| First name "Sarah" | Full name "Sarah Chen" | Same organization context |
| Email "sarah@acme.com" | Email field | Exact match |
| Email domain "@acme.com" | Organization "Acme Corp" | Domain matches org |
| Role "VP Engineering" | Role field | Same org + same role |
| First name + company context | Full name + Organization | Company matches |
| Any variant | Aliases field | Listed in aliases |
**Organization matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| "Acme" | "Acme Corp" | Substring match |
| "Acme Corporation" | "Acme Corp" | Same root name |
| "@acme.com" | Domain field | Domain matches |
| Any variant | Aliases field | Listed in aliases |
**Project matching:**
| Source has | Note has | Match if |
|------------|----------|----------|
| "the pilot" | "Acme Pilot" | Same org context in source |
| "integration project" | "Acme Integration" | Same org + similar type |
| "Series A" | "Series A Fundraise" | Unique identifier match |
---
# Step 4: Resolve Entities to Canonical Names
Using the search results from Step 3, resolve each variant to a canonical name.
## 4a: Build Resolution Map
Create a mapping from every source reference to its canonical form.
## 4b: Apply Source Type Rules (Low Strictness)
**If source_type == "meeting":**
- Resolved entities Update existing notes
- New entities Create new notes for ALL external attendees
**If source_type == "email" (LOW STRICTNESS):**
- Resolved entities Update existing notes
- New entities Create notes for the sender and any mentioned contacts
## 4c: Disambiguation Rules
When multiple candidates match a variant, disambiguate by:
1. Email match (definitive)
2. Organization context (strong signal)
3. Role match
4. Recency (tiebreaker)
## 4d: Resolution Map Output
Final resolution map before proceeding:
\`\`\`
RESOLVED (use canonical name with absolute path):
- "Sarah", "Sarah Chen", "sarah@acme.com" [[People/Sarah Chen]]
NEW ENTITIES (create notes):
- "Jennifer" (CTO, Acme Corp) Create [[People/Jennifer]]
AMBIGUOUS (create with disambiguation note):
- "Mike" (no context) Create [[People/Mike]] with note about ambiguity
\`\`\`
---
# Step 5: Identify New Entities (Low Strictness - Capture Broadly)
For entities not resolved to existing notes, create notes for most of them.
## People
### Who Gets a Note (Low Strictness)
**CREATE a note for:**
- ALL external meeting attendees (not @user.domain)
- ALL email senders with identifiable names/emails
- Anyone CC'd on emails who seems relevant
- Anyone mentioned by name in conversations
- Cold outreach senders (even if unsolicited)
- Sales reps, recruiters, service providers
- Anyone who might be useful to remember later
**DO NOT create notes for:**
- Internal colleagues (@user.domain)
- Truly anonymous/unidentifiable senders
- System-generated sender names with no human behind them
### The Low Strictness Test
Ask: Could this person ever be useful to remember?
- Sarah Chen, VP Engineering **Yes, create note**
- James from HSBC **Yes, create note** (might need banking help again)
- Random recruiter **Yes, create note** (might want to contact later)
- Cold sales person **Yes, create note** (might be relevant someday)
- Support rep **Yes, create note** (might need them again)
### Role Inference
If role is not explicitly stated, infer from context. Write "Unknown" only if truly impossible to infer anything.
### Relationship Type Guide (Low Strictness)
| Relationship Type | Create People Notes? | Create Org Note? |
|-------------------|----------------------|------------------|
| Customer | Yes all contacts | Yes |
| Prospect | Yes all contacts | Yes |
| Investor | Yes | Yes |
| Partner | Yes all contacts | Yes |
| Vendor | Yes all contacts | Yes |
| Bank/Financial | Yes | Yes |
| Candidate | Yes | No |
| Recruiter | Yes | Optional |
| Service provider | Yes | Optional |
| Cold outreach | Yes | Optional |
| Support interaction | Yes | Optional |
## Organizations
**CREATE a note if:**
- Anyone from that org is mentioned or contacted you
- The org is mentioned in any context
**Only skip:**
- Organizations you genuinely can't identify
## Projects
**CREATE a note if:**
- Discussed in meeting or email
- Any indication of ongoing work or collaboration
## Topics
**CREATE a note if:**
- Mentioned more than once
- Seems like a recurring theme
---
# Step 6: Extract Content
For each entity that has or will have a note, extract relevant content.
## Decisions
Extract what was decided, when, by whom, and why.
## Commitments
Extract who committed to what, and any deadlines.
## Key Facts
Key facts should be **substantive information** not commentary about missing data.
**Extract if:**
- Specific numbers, dates, or metrics
- Preferences or working style
- Background information
- Authority or decision process
- Concerns or constraints
- What they're working on or interested in
**Never include:**
- Meta-commentary about missing data
- Obvious facts already in Info section
- Placeholder text
**If there are no substantive key facts, leave the section empty.**
## Open Items
**Include:**
- Commitments made
- Requests received
- Next steps discussed
- Follow-ups agreed
**Never include:**
- Data gaps or research tasks
- Wishes or hypotheticals
## Summary
The summary should answer: **"Who is this person and why do I know them?"**
Write 2-3 sentences covering their role/function, context of the relationship, and what you're discussing.
## Activity Summary
One line summarizing this source's relevance to the entity:
\`\`\`
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
\`\`\`
**For voice memos:** Include a link to the voice memo file using the Path field:
\`\`\`
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
\`\`\`
---
# Step 7: Detect State Changes
Review the extracted content for signals that existing note fields should be updated.
## 7a: Project Status Changes
Look for signals like "approved", "on hold", "cancelled", "completed", etc.
## 7b: Open Item Resolution
Look for signals that tracked items are now complete.
## 7c: Role/Title Changes
Look for new titles in signatures or explicit announcements.
## 7d: Organization/Relationship Changes
Look for company changes, partnership announcements, etc.
## 7e: Build State Change List
Compile all detected state changes before writing.
---
# Step 8: Check for Duplicates and Conflicts
Before writing:
- Check if already processed this source
- Skip duplicate key facts
- Handle conflicting information by noting both versions
---
# Step 9: Write Updates
## 9a: Create and Update Notes
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one write/edit command.
- 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):**
\`\`\`
workspace-writeFile({
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)
- Update fields using targeted edits
\`\`\`
workspace-edit({
path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
})
\`\`\`
## 9b: Apply State Changes
Update all fields identified in Step 7.
## 9c: Update Aliases
Add newly discovered name variants to Aliases field.
## 9d: Writing Rules
- **Always use absolute paths** with format \`[[Folder/Name]]\` for all links
- Use YYYY-MM-DD format for dates
- Be concise: one line per activity entry
- Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
---
# Step 10: Ensure Bidirectional Links
After writing, verify links go both ways.
## Absolute Link Format
**IMPORTANT:** Always use absolute links:
\`\`\`markdown
[[People/Sarah Chen]]
[[Organizations/Acme Corp]]
[[Projects/Acme Integration]]
[[Topics/Security Compliance]]
\`\`\`
## Bidirectional Link Rules
| If you add... | Then also add... |
|---------------|------------------|
| Person Organization | Organization Person |
| Person Project | Project Person |
| Project Organization | Organization Project |
| Project Topic | Topic Project |
| Person Person | Person Person (reverse) |
---
# Note Templates
## People
\`\`\`markdown
# {Full Name}
## Info
**Role:** {role, inferred role, or Unknown}
**Organization:** [[Organizations/{organization}]] or leave blank
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them.}
## Connected to
- [[Organizations/{Organization}]] works at
- [[People/{Person}]] {relationship}
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Organizations
\`\`\`markdown
# {Organization Name}
## Info
**Type:** {company|team|institution|other}
**Industry:** {industry or leave blank}
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
**Domain:** {primary email domain}
**Aliases:** {short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
## People
- [[People/{Person}]] {role}
## Contacts
{For contacts who have their own notes}
## Projects
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}
\`\`\`
## Projects
\`\`\`markdown
# {Project Name}
## Info
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
## People
- [[People/{Person}]] {role}
## Organizations
- [[Organizations/{Org}]] {relationship}
## Related
- [[Topics/{Topic}]] {relationship}
## Timeline
**{YYYY-MM-DD}** ({meeting|email|voice memo})
{What happened.}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only.}
## Key facts
{Substantive facts only.}
\`\`\`
## Topics
\`\`\`markdown
# {Topic Name}
## About
{1-2 sentences: What this topic covers.}
**Keywords:** {comma-separated}
**Aliases:** {other references}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}
- [[Organizations/{Org}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Log
**{YYYY-MM-DD}** ({meeting|email}: {title})
{Summary}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only.}
## Key facts
{Substantive facts only.}
\`\`\`
---
# Summary: Low Strictness Rules
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------|
| Meeting | Yes ALL external attendees | Yes | Yes |
| Voice memo | Yes all mentioned entities | Yes | Yes |
| Email (any human sender) | Yes | Yes | Yes |
| Email (automated/newsletter) | No (SKIP) | No | No |
**Voice memo activity format:** Always include a link to the source voice memo:
\`\`\`
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
\`\`\`
**Philosophy:** Capture broadly, filter later if needed.
---
# Error Handling
1. **Missing data:** Leave blank or write "Unknown"
2. **Ambiguous names:** Create note with disambiguation note
3. **Conflicting info:** Note both versions
4. **grep returns nothing:** Create new notes
5. **State change unclear:** Log in activity but don't change the field
6. **Note file malformed:** Log warning, attempt partial update
7. **Shell command fails:** Log error, continue
---
# Quality Checklist
Before completing, verify:
**Source Type:**
- [ ] Correctly identified as meeting or email
- [ ] Applied low strictness rules (capture broadly)
**Resolution:**
- [ ] Extracted all name variants
- [ ] Searched existing notes
- [ ] Built resolution map
- [ ] Used absolute paths \`[[Folder/Name]]\`
**Filtering:**
- [ ] Excluded only self and @user.domain
- [ ] Created notes for all external contacts
- [ ] Only skipped obvious automated/newsletters
**Content Quality:**
- [ ] Summaries describe relationship
- [ ] Roles inferred where possible
- [ ] Key facts are substantive
- [ ] Open items are commitments/next steps
**State Changes:**
- [ ] Detected and applied state changes
- [ ] Logged changes in activity
**Structure:**
- [ ] All links use \`[[Folder/Name]]\` format
- [ ] Activity entries reverse chronological
- [ ] Dates are YYYY-MM-DD
- [ ] Bidirectional links consistent
`;

View file

@ -0,0 +1,202 @@
import path from "path";
import fs from "fs";
import { WorkDir } from "../config/config.js";
export interface NoteTypeDefinition {
type: string;
folder: string;
template: string;
extractionGuide: string;
}
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{
type: "People",
folder: "People",
template: `# {Full Name}
## Info
**Role:** {role, or inferred role with qualifier, or leave blank if truly unknown}
**Organization:** [[Organizations/{organization}]] or leave blank
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them, what you're working on together.}
## Connected to
- [[Organizations/{Organization}]] works at
- [[People/{Person}]] {colleague, introduced by, reports to}
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}`,
extractionGuide:
"Look for: name, role, organization, email, aliases, relationship context",
},
{
type: "Organizations",
folder: "Organizations",
template: `# {Organization Name}
## Info
**Type:** {company|team|institution|other}
**Industry:** {industry or leave blank}
**Relationship:** {customer|prospect|partner|competitor|vendor|other}
**Domain:** {primary email domain}
**Aliases:** {comma-separated: short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
## People
- [[People/{Person}]] {role}
## Contacts
{For transactional contacts who don't get their own notes}
## Projects
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
## Open items
{Commitments and next steps only. Leave empty if none.}`,
extractionGuide:
"Look for: organization name, type, industry, relationship, domain, key people, projects",
},
{
type: "Projects",
folder: "Projects",
template: `# {Project Name}
## Info
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
## People
- [[People/{Person}]] {role}
## Organizations
- [[Organizations/{Org}]] {customer|partner|etc.}
## Related
- [[Topics/{Topic}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Timeline
**{YYYY-MM-DD}** ({meeting|email})
{What happened.}
## Decisions
- **{YYYY-MM-DD}**: {Decision}. {Rationale}.
## Open items
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}`,
extractionGuide:
"Look for: project name, type, status, people involved, organizations, timeline, decisions",
},
{
type: "Topics",
folder: "Topics",
template: `# {Topic Name}
## About
{1-2 sentences: What this topic covers.}
**Keywords:** {comma-separated}
**Aliases:** {other ways this topic is referenced}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}
- [[Organizations/{Org}]] {relationship}
- [[Projects/{Project}]] {relationship}
## Log
**{YYYY-MM-DD}** ({meeting|email}: {title})
{Summary with [[Folder/Name]] links}
## Decisions
- **{YYYY-MM-DD}**: {Decision}
## Open items
{Commitments and next steps only. Leave empty if none.}
## Key facts
{Substantive facts only. Leave empty if none.}`,
extractionGuide:
"Look for: topic name, keywords, related people/orgs/projects, decisions, key facts",
},
];
// ── Disk-backed config with mtime caching ──────────────────────────────────
export const NOTES_CONFIG_PATH = path.join(WorkDir, "config", "notes.json");
let cachedNoteTypeDefinitions: NoteTypeDefinition[] | null = null;
let cachedMtimeMs: number | null = null;
function ensureNotesConfigSync(): void {
if (!fs.existsSync(NOTES_CONFIG_PATH)) {
fs.writeFileSync(
NOTES_CONFIG_PATH,
JSON.stringify(DEFAULT_NOTE_TYPE_DEFINITIONS, null, 2) + "\n",
"utf8",
);
}
}
export function getNoteTypeDefinitions(): NoteTypeDefinition[] {
ensureNotesConfigSync();
try {
const stats = fs.statSync(NOTES_CONFIG_PATH);
if (cachedNoteTypeDefinitions && cachedMtimeMs === stats.mtimeMs) {
return cachedNoteTypeDefinitions;
}
const content = fs.readFileSync(NOTES_CONFIG_PATH, "utf8");
cachedNoteTypeDefinitions = JSON.parse(content);
cachedMtimeMs = stats.mtimeMs;
return cachedNoteTypeDefinitions!;
} catch {
cachedNoteTypeDefinitions = null;
cachedMtimeMs = null;
return DEFAULT_NOTE_TYPE_DEFINITIONS;
}
}
// ── Render helper ────────────────────────────────────────────────────────
export function renderNoteTypesBlock(): string {
const defs = getNoteTypeDefinitions();
const sections = defs.map(
(d) =>
`## ${d.type}\n\`\`\`markdown\n${d.template}\n\`\`\``,
);
return `# Note Templates\n\n${sections.join("\n\n")}`;
}

View file

@ -0,0 +1,132 @@
import { renderTagSystemForNotes } from './tag_system.js';
export function getRaw(): string {
return `---
model: gpt-5.2
tools:
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
---
# Task
You are a note tagging agent. Given a batch of knowledge notes (People, Organizations, Projects, Topics), you will classify each note and prepend YAML frontmatter with categorized tags and Info attributes.
# Instructions
1. For each note file provided in the message, read its content carefully.
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/).
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).
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.
6. If the note already has frontmatter (starts with \`---\`), skip it.
# Frontmatter Format
Tags are organized by **category** (not a flat list). Each tag category is a top-level YAML key. Use a plain string for single values, or a YAML list for multiple values.
Info attributes from the \`## Info\` section are also included as top-level keys.
\`\`\`yaml
---
relationship: customer
relationship_sub: primary
topic:
- sales
- fundraising
source: email
status: active
action: action-required
role: VP Engineering
organization: Acme Corp
email: sarah@acme.com
first_met: "2024-06-15"
last_seen: "2025-01-20"
---
\`\`\`
## Tag category keys
Use these exact keys for each tag category:
| Category | Key | Single or multi | Example |
|----------|-----|-----------------|---------|
| Relationship | \`relationship\` | single | \`relationship: customer\` |
| Relationship sub | \`relationship_sub\` | single or multi | \`relationship_sub: primary\` |
| Topic | \`topic\` | single or multi | \`topic: sales\` or list |
| Email type | \`email_type\` | single or multi | \`email_type: followup\` |
| Action | \`action\` | single or multi | \`action: action-required\` |
| Status | \`status\` | single | \`status: active\` |
| Source | \`source\` | single or multi | \`source: email\` or list |
**Rules:**
- Use a plain string when there's only one value: \`topic: sales\`
- Use a YAML list when there are multiple values:
\`\`\`yaml
topic:
- sales
- fundraising
\`\`\`
- **Omit a category entirely** if no tags apply for it. Do not include empty keys.
- Only use tag values from the Rowboat Tag System do not invent new tags.
# Info Attribute Extraction Rules
Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys:
1. **Convert keys to snake_case**: e.g. \`**First met:**\`\`first_met\`, \`**Last activity:**\`\`last_activity\`, \`**Last seen:**\`\`last_seen\`.
2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\`\`Acme Corp\`. Extract just the display name (last path segment).
3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter.
4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`.
5. **Aliases as list**: If the value is comma-separated (like Aliases), store as a YAML list:
\`\`\`yaml
aliases:
- Sarah
- sarah@acme.com
\`\`\`
**Per note type, extract these fields:**
- **People**: role, organization, email, aliases, first_met, last_seen
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
- **Projects**: type, status, started, last_activity
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
Note: For Organizations, the Info \`**Relationship:**\` field is separate from the \`relationship\` tag category. Include both — the Info field as \`info_relationship\` and the tag as \`relationship\`.
# Tag Selection Rules
1. **Always include at least one relationship or topic tag** every note must be classifiable.
2. **Always include a source tag** \`email\` or \`meeting\` based on what the note's Activity section shows.
3. **Default status is \`active\`** for all new tags.
4. **For People notes**, include:
- One primary relationship tag (e.g. \`customer\`, \`investor\`, \`prospect\`)
- Relationship sub-tags if applicable (e.g. \`primary\`, \`champion\`, \`former\`)
- Topic tags based on what you're working on together
- Source tags based on the Activity section
- Action tags if there are open items
5. **For Organization notes**, include:
- One primary relationship tag
- Topic tags based on the relationship context
- Source tags
6. **For Project notes**, include:
- Topic tags based on project type
- Source tags
- Action tags if there are open items
7. **For Topic notes**, include:
- The relevant topic tag
- Source tags
8. **Only use tags from the Rowboat Tag System** do not invent new tags.
9. Process all files in the batch. Do not skip any unless they already have frontmatter.
---
${renderTagSystemForNotes()}
`;
}

View file

@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
const STATE_FILE = path.join(WorkDir, 'note_tagging_state.json');
export interface NoteTaggingState {
processedFiles: Record<string, { taggedAt: string }>;
lastRunTime: string;
}
export function loadNoteTaggingState(): NoteTaggingState {
if (fs.existsSync(STATE_FILE)) {
try {
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
} catch (error) {
console.error('Error loading note tagging state:', error);
}
}
return {
processedFiles: {},
lastRunTime: new Date(0).toISOString(),
};
}
export function saveNoteTaggingState(state: NoteTaggingState): void {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch (error) {
console.error('Error saving note tagging state:', error);
throw error;
}
}
export function markNoteAsTagged(filePath: string, state: NoteTaggingState): void {
state.processedFiles[filePath] = {
taggedAt: new Date().toISOString(),
};
}
export function resetNoteTaggingState(): void {
const emptyState: NoteTaggingState = {
processedFiles: {},
lastRunTime: new Date().toISOString(),
};
saveNoteTaggingState(emptyState);
}

View file

@ -0,0 +1,274 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
loadNoteTaggingState,
saveNoteTaggingState,
markNoteAsTagged,
type NoteTaggingState,
} from './note_tagging_state.js';
import { getNoteTypeDefinitions } from './note_system.js';
const SYNC_INTERVAL_MS = 30 * 1000; // 30 seconds
const BATCH_SIZE = 15;
const NOTE_TAGGING_AGENT = 'note_tagging_agent';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const MAX_CONTENT_LENGTH = 8000;
/**
* Find knowledge notes that haven't been tagged yet
*/
function getUntaggedNotes(state: NoteTaggingState): string[] {
if (!fs.existsSync(KNOWLEDGE_DIR)) {
return [];
}
const untagged: string[] = [];
const noteFolders = getNoteTypeDefinitions().map(d => d.folder);
for (const folder of noteFolders) {
const folderPath = path.join(KNOWLEDGE_DIR, folder);
if (!fs.existsSync(folderPath)) {
continue;
}
const entries = fs.readdirSync(folderPath);
for (const entry of entries) {
const fullPath = path.join(folderPath, entry);
const stat = fs.statSync(fullPath);
if (!stat.isFile() || !entry.endsWith('.md')) {
continue;
}
// Skip if already tracked in state
if (state.processedFiles[fullPath]) {
continue;
}
// Skip if file already has frontmatter
try {
const content = fs.readFileSync(fullPath, 'utf-8');
if (content.startsWith('---')) {
continue;
}
} catch {
continue;
}
untagged.push(fullPath);
}
}
return untagged;
}
/**
* Wait for a run to complete by listening for run-processing-end event
*/
async function waitForRunCompletion(runId: string): Promise<void> {
return new Promise(async (resolve) => {
const unsubscribe = await bus.subscribe('*', async (event) => {
if (event.type === 'run-processing-end' && event.runId === runId) {
unsubscribe();
resolve();
}
});
});
}
/**
* Tag a batch of note files using the tagging agent
*/
async function tagNoteBatch(
files: { path: string; content: string }[]
): Promise<{ runId: string; filesEdited: Set<string> }> {
const run = await createRun({
agentId: NOTE_TAGGING_AGENT,
});
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`;
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;
message += `## File ${i + 1}: ${relativePath}\n\n`;
message += truncated;
message += `\n\n---\n\n`;
}
const filesEdited = new Set<string>();
const unsubscribe = await bus.subscribe(run.id, async (event) => {
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'workspace-edit') {
return;
}
try {
const parsed = JSON.parse(event.input) as { path?: string };
if (typeof parsed.path === 'string') {
filesEdited.add(parsed.path);
}
} catch {
// ignore parse errors
}
});
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
unsubscribe();
return { runId: run.id, filesEdited };
}
/**
* Process all untagged notes in batches
*/
async function processUntaggedNotes(): Promise<void> {
console.log('[NoteTagging] Checking for untagged notes...');
const state = loadNoteTaggingState();
const untagged = getUntaggedNotes(state);
if (untagged.length === 0) {
console.log('[NoteTagging] No untagged notes found');
return;
}
console.log(`[NoteTagging] Found ${untagged.length} untagged notes`);
const run = await serviceLogger.startRun({
service: 'note_tagging',
message: `Tagging ${untagged.length} note${untagged.length === 1 ? '' : 's'}`,
trigger: 'timer',
});
const relativeFiles = untagged.map(f => path.relative(WorkDir, f));
const limitedFiles = limitEventItems(relativeFiles);
await serviceLogger.log({
type: 'changes_identified',
service: run.service,
runId: run.runId,
level: 'info',
message: `Found ${untagged.length} untagged note${untagged.length === 1 ? '' : 's'}`,
counts: { notes: untagged.length },
items: limitedFiles.items,
truncated: limitedFiles.truncated,
});
const totalBatches = Math.ceil(untagged.length / BATCH_SIZE);
let totalEdited = 0;
let hadError = false;
for (let i = 0; i < untagged.length; i += BATCH_SIZE) {
const batchPaths = untagged.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
try {
const files: { path: string; content: string }[] = [];
for (const filePath of batchPaths) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
files.push({ path: filePath, content });
} catch (error) {
console.error(`[NoteTagging] Error reading ${filePath}:`, error);
}
}
if (files.length === 0) {
continue;
}
console.log(`[NoteTagging] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`);
await serviceLogger.log({
type: 'progress',
service: run.service,
runId: run.runId,
level: 'info',
message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`,
step: 'batch',
current: batchNumber,
total: totalBatches,
details: { filesInBatch: files.length },
});
const result = await tagNoteBatch(files);
totalEdited += result.filesEdited.size;
// Only mark files that were actually edited by the agent
for (const file of files) {
const relativePath = path.relative(WorkDir, file.path);
if (result.filesEdited.has(relativePath)) {
markNoteAsTagged(file.path, state);
}
}
saveNoteTaggingState(state);
console.log(`[NoteTagging] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files tagged`);
} catch (error) {
hadError = true;
console.error(`[NoteTagging] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
context: { batchNumber },
});
}
}
state.lastRunTime = new Date().toISOString();
saveNoteTaggingState(state);
await serviceLogger.log({
type: 'run_complete',
service: run.service,
runId: run.runId,
level: hadError ? 'error' : 'info',
message: `Note tagging complete: ${totalEdited} notes tagged`,
durationMs: Date.now() - run.startedAt,
outcome: hadError ? 'error' : 'ok',
summary: {
totalNotes: untagged.length,
notesTagged: totalEdited,
},
});
console.log(`[NoteTagging] Done. ${totalEdited} notes tagged.`);
}
/**
* Main entry point - runs as independent polling service
*/
export async function init() {
console.log('[NoteTagging] Starting Note Tagging Service...');
console.log(`[NoteTagging] Will check for untagged notes every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run
await processUntaggedNotes();
// Periodic polling
while (true) {
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
try {
await processUntaggedNotes();
} catch (error) {
console.error('[NoteTagging] Error in main loop:', error);
}
}
}

View file

@ -0,0 +1,230 @@
import path from "path";
import fs from "fs";
import { WorkDir } from "../config/config.js";
export type TagApplicability = 'email' | 'notes' | 'both';
export type TagType =
| 'relationship'
| 'relationship-sub'
| 'topic'
| 'email-type'
| 'filter'
| 'action'
| 'status'
| 'source';
export type NoteEffect = 'create' | 'skip' | 'none';
export interface TagDefinition {
tag: string;
type: TagType;
applicability: TagApplicability;
description: string;
example?: string;
/** Whether an email with this tag should create notes ('create'), be skipped ('skip'), or has no effect on note creation ('none'). */
noteEffect?: NoteEffect;
}
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
// ── Relationship (both) ──────────────────────────────────────────────
{ tag: 'investor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' },
{ tag: 'customer', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
{ tag: 'prospect', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
{ tag: 'product', type: 'relationship', applicability: 'both', noteEffect: 'skip', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
{ tag: 'candidate', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
{ tag: 'advisor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' },
{ tag: 'personal', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
{ tag: 'press', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' },
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
{ tag: 'government', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' },
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
{ tag: 'primary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' },
{ tag: 'secondary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' },
{ tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' },
{ tag: 'cc', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person who\'s CC\'d but not actively engaged', example: 'Manager looped in for visibility on deal.' },
{ tag: 'referred-by', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person who made an introduction or referral', example: 'David Park — Investor who intro\'d you to Sarah.' },
{ tag: 'former', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Previously held this relationship, no longer active', example: 'John — Former customer who churned last year.' },
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
// ── Topic (both) ─────────────────────────────────────────────────────
{ tag: 'sales', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' },
{ tag: 'support', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' },
{ tag: 'legal', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
{ tag: 'hiring', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' },
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
{ tag: 'travel', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
{ tag: 'shopping', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
{ tag: 'health', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
{ tag: 'learning', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
{ tag: 'research', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' },
// ── Email Type ───────────────────────────────────────────────────────
{ tag: 'intro', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' },
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
{ tag: 'scheduling', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' },
{ tag: 'newsletter', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
{ tag: 'notification', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' },
// ── Filter (email only) ──────────────────────────────────────────────
{ tag: 'spam', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
{ tag: 'promotion', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
{ tag: 'social', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
{ tag: 'forums', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
// ── Action ───────────────────────────────────────────────────────────
{ tag: 'action-required', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
{ tag: 'fyi', type: 'action', applicability: 'email', noteEffect: 'skip', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' },
{ tag: 'urgent', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' },
{ tag: 'waiting', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Waiting on a response from them' },
// ── Status (email) ───────────────────────────────────────────────────
{ tag: 'unread', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Not yet processed' },
{ tag: 'to-reply', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Need to respond' },
{ tag: 'done', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Handled, can be archived' },
// ── Source (notes only) ──────────────────────────────────────────────
{ tag: 'email', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from email' },
{ tag: 'meeting', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from meeting transcript' },
{ tag: 'browser', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Content captured from web browsing' },
{ tag: 'web-search', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Information from web search' },
{ tag: 'manual', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Manually entered by user' },
{ tag: 'import', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Imported from another system' },
// ── Status (notes) ──────────────────────────────────────────────────
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
];
// ── Disk-backed config with mtime caching ──────────────────────────────────
export const TAGS_CONFIG_PATH = path.join(WorkDir, "config", "tags.json");
let cachedTagDefinitions: TagDefinition[] | null = null;
let cachedMtimeMs: number | null = null;
function ensureTagsConfigSync(): void {
if (!fs.existsSync(TAGS_CONFIG_PATH)) {
fs.writeFileSync(
TAGS_CONFIG_PATH,
JSON.stringify(DEFAULT_TAG_DEFINITIONS, null, 2) + "\n",
"utf8",
);
}
}
export function getTagDefinitions(): TagDefinition[] {
ensureTagsConfigSync();
try {
const stats = fs.statSync(TAGS_CONFIG_PATH);
if (cachedTagDefinitions && cachedMtimeMs === stats.mtimeMs) {
return cachedTagDefinitions;
}
const content = fs.readFileSync(TAGS_CONFIG_PATH, "utf8");
cachedTagDefinitions = JSON.parse(content);
cachedMtimeMs = stats.mtimeMs;
return cachedTagDefinitions!;
} catch {
cachedTagDefinitions = null;
cachedMtimeMs = null;
return DEFAULT_TAG_DEFINITIONS;
}
}
// ── Render helpers ───────────────────────────────────────────────────────
const TYPE_ORDER: TagType[] = [
'relationship', 'relationship-sub', 'topic', 'email-type',
'filter', 'action', 'status', 'source',
];
const TYPE_LABELS: Record<TagType, string> = {
'relationship': 'Relationship',
'relationship-sub': 'Relationship Sub-Tags',
'topic': 'Topic',
'email-type': 'Email Type',
'filter': 'Filter',
'action': 'Action',
'status': 'Status',
'source': 'Source',
};
function renderTagGroups(tags: TagDefinition[]): string {
const groups = new Map<TagType, TagDefinition[]>();
for (const tag of tags) {
const list = groups.get(tag.type) ?? [];
list.push(tag);
groups.set(tag.type, list);
}
const sections: string[] = [];
for (const type of TYPE_ORDER) {
const group = groups.get(type);
if (!group || group.length === 0) continue;
const label = TYPE_LABELS[type];
const rows = group.map(t => {
const example = t.example ?? '';
return `| ${t.tag} | ${t.description} | ${example} |`;
});
sections.push(
`## ${label}\n\n` +
`| Tag | Description | Example |\n` +
`|-----|-------------|---------|\n` +
rows.join('\n'),
);
}
return `# Tag System Reference\n\n${sections.join('\n\n')}`;
}
export function renderNoteEffectRules(): string {
const tags = getTagDefinitions();
const skipByType = new Map<string, string[]>();
const createByType = new Map<string, string[]>();
for (const t of tags) {
const effect = t.noteEffect ?? 'none';
if (effect === 'none') continue;
const label = TYPE_LABELS[t.type] ?? t.type;
const map = effect === 'skip' ? skipByType : createByType;
const list = map.get(label) ?? [];
list.push(t.tag.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '));
map.set(label, list);
}
const formatList = (map: Map<string, string[]>) =>
Array.from(map.entries()).map(([type, tags]) => `- **${type}:** ${tags.join(', ')}`).join('\n');
return [
`**SKIP if the email has ANY of these labels (skip labels override everything):**`,
formatList(skipByType),
``,
`**CREATE/UPDATE notes if the email has ANY of these labels (and no skip labels present):**`,
formatList(createByType),
``,
`**Logic:** If even one label falls in the "skip" list, skip the email — skip labels are hard filters that override create labels.`,
].join('\n');
}
export function renderTagSystemForNotes(): string {
const tags = getTagDefinitions().filter(t => t.applicability !== 'email');
return renderTagGroups(tags);
}
export function renderTagSystemForEmails(): string {
const tags = getTagDefinitions().filter(t => t.applicability !== 'notes');
return renderTagGroups(tags);
}

View file

@ -0,0 +1,243 @@
import fs from 'node:fs';
import path from 'node:path';
import git from 'isomorphic-git';
import { WorkDir } from '../config/config.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
// Simple promise-based mutex to serialize commits
let commitLock: Promise<void> = Promise.resolve();
// Commit listeners for notifying other layers (e.g. renderer refresh)
type CommitListener = () => void;
const commitListeners: CommitListener[] = [];
export function onCommit(listener: CommitListener): () => void {
commitListeners.push(listener);
return () => {
const idx = commitListeners.indexOf(listener);
if (idx >= 0) commitListeners.splice(idx, 1);
};
}
/**
* Initialize a git repo in the knowledge directory if one doesn't exist.
* Stages all existing .md files and makes an initial commit.
*/
export async function initRepo(): Promise<void> {
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
if (fs.existsSync(gitDir)) {
return;
}
// Ensure knowledge dir exists
if (!fs.existsSync(KNOWLEDGE_DIR)) {
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
}
await git.init({ fs, dir: KNOWLEDGE_DIR });
// Stage all existing .md files
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
for (const file of files) {
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
}
if (files.length > 0) {
await git.commit({
fs,
dir: KNOWLEDGE_DIR,
message: 'Initial snapshot',
author: { name: 'Rowboat', email: 'local' },
});
}
}
/**
* Recursively find all .md files relative to the knowledge dir.
*/
function getAllMdFiles(baseDir: string, relDir: string): string[] {
const results: string[] = [];
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
let entries: string[];
try {
entries = fs.readdirSync(absDir);
} catch {
return results;
}
for (const entry of entries) {
if (entry === '.git' || entry.startsWith('.')) continue;
const fullPath = path.join(absDir, entry);
const relPath = relDir ? `${relDir}/${entry}` : entry;
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
results.push(...getAllMdFiles(baseDir, relPath));
} else if (entry.endsWith('.md')) {
results.push(relPath);
}
}
return results;
}
/**
* Stage all changes to .md files and commit. No-op if nothing changed.
* Serialized via a promise lock to prevent concurrent git index corruption.
*/
export async function commitAll(message: string, authorName: string): Promise<void> {
const prev = commitLock;
let resolve: () => void;
commitLock = new Promise(r => { resolve = r; });
await prev;
try {
await commitAllInner(message, authorName);
} finally {
resolve!();
}
}
async function commitAllInner(message: string, authorName: string): Promise<void> {
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
let hasChanges = false;
for (const [filepath, head, workdir, stage] of matrix) {
// Skip non-md files
if (!filepath.endsWith('.md')) continue;
// [filepath, HEAD, WORKDIR, STAGE]
// Unchanged: [f, 1, 1, 1]
if (head === 1 && workdir === 1 && stage === 1) continue;
hasChanges = true;
if (workdir === 0) {
// File deleted from workdir
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
} else {
// File added or modified
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
}
}
if (!hasChanges) return;
await git.commit({
fs,
dir: KNOWLEDGE_DIR,
message,
author: { name: authorName, email: 'local' },
});
for (const listener of commitListeners) {
try { listener(); } catch { /* ignore */ }
}
}
export interface CommitInfo {
oid: string;
message: string;
timestamp: number;
author: string;
}
const MAX_FILE_HISTORY = 50;
/**
* Get commit history for a specific file.
* Returns commits where the file content changed, most recent first.
* Capped at MAX_FILE_HISTORY entries.
*/
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
// Normalize path separators for git (always forward slashes)
const filepath = knowledgeRelPath.replace(/\\/g, '/');
let commits: Awaited<ReturnType<typeof git.log>>;
try {
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
} catch {
return [];
}
if (commits.length === 0) return [];
const result: CommitInfo[] = [];
// Walk through commits and check if file changed between consecutive commits
for (let i = 0; i < commits.length; i++) {
if (result.length >= MAX_FILE_HISTORY) break;
const commit = commits[i]!;
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
const parentOid = parentCommit
? await getBlobOidAtCommit(parentCommit.oid, filepath)
: null;
// Include this commit if:
// - The file existed and changed from parent
// - The file was added (parentOid is null but currentOid exists)
// - The file was deleted (currentOid is null but parentOid exists)
if (currentOid !== parentOid) {
result.push({
oid: commit.oid,
message: commit.commit.message.trim(),
timestamp: commit.commit.author.timestamp,
author: commit.commit.author.name,
});
}
}
return result;
}
/**
* Get the blob OID for a file at a specific commit, or null if not found.
*/
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
try {
const result = await git.readBlob({
fs,
dir: KNOWLEDGE_DIR,
oid: commitOid,
filepath,
});
// Compute a content hash from the blob to compare
return result.oid;
} catch {
return null;
}
}
/**
* Read file content at a specific commit.
*/
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
const filepath = knowledgeRelPath.replace(/\\/g, '/');
const result = await git.readBlob({
fs,
dir: KNOWLEDGE_DIR,
oid,
filepath,
});
return Buffer.from(result.blob).toString('utf-8');
}
/**
* Restore a file to its content at a given commit, then commit the restoration.
*/
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
const content = await getFileAtCommit(knowledgeRelPath, oid);
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
// Ensure parent directory exists
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(absPath, content, 'utf-8');
const filename = path.basename(knowledgeRelPath);
await commitAll(`Restored ${filename}`, 'You');
}

View file

@ -34,6 +34,26 @@ export class FSModelConfigRepo implements IModelConfigRepo {
} }
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> { async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); let existingProviders: Record<string, Record<string, unknown>> = {};
try {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw);
existingProviders = existing.providers || {};
} catch {
// No existing config
}
existingProviders[config.provider.flavor] = {
...existingProviders[config.provider.flavor],
apiKey: config.provider.apiKey,
baseURL: config.provider.baseURL,
headers: config.provider.headers,
model: config.model,
models: config.models,
knowledgeGraphModel: config.knowledgeGraphModel,
};
const toWrite = { ...config, providers: existingProviders };
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
} }
} }

View file

@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer<typeof MessageEvent>; const messageEvent = event as z.infer<typeof MessageEvent>;
if (messageEvent.message.role === 'user') { if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content; const content = messageEvent.message.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate to 100 chars if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
if (!cleaned) continue; // Skip if only attached files/mentions } else {
textContent = content
.filter(p => p.type === 'text')
.map(p => p.text)
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }
} }
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') { if (msg.role === 'user') {
// Found first user message - use as title // Found first user message - use as title
const content = msg.content; const content = msg.content;
if (typeof content === 'string' && content.trim()) { let textContent: string | undefined;
// Clean attached-files XML and @mentions, then truncate if (typeof content === 'string') {
const cleaned = cleanContentForTitle(content); textContent = content;
} else {
textContent = content
.filter(p => p.type === 'text')
.map(p => p.text)
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (cleaned) { if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
} }

View file

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

View file

@ -6,6 +6,7 @@ import { z } from 'zod';
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js'; import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
import { commitAll } from '../knowledge/version_history.js';
// ============================================================================ // ============================================================================
// Path Utilities // Path Utilities
@ -218,6 +219,21 @@ export async function readFile(
}; };
} }
// Debounced commit for knowledge file edits
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
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);
}
export async function writeFile( export async function writeFile(
relPath: string, relPath: string,
data: string, data: string,
@ -266,6 +282,11 @@ export async function writeFile(
const stat = statToSchema(stats, 'file'); const stat = statToSchema(stats, 'file');
const etag = computeEtag(stats.size, stats.mtimeMs); const etag = computeEtag(stats.size, stats.mtimeMs);
// Schedule a debounced version history commit for knowledge files
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
scheduleKnowledgeCommit(path.basename(relPath));
}
return { return {
path: relPath, path: relPath,
stat, stat,

View file

@ -6,5 +6,6 @@ export * as workspace from './workspace.js';
export * as mcp from './mcp.js'; export * as mcp from './mcp.js';
export * as agentSchedule from './agent-schedule.js'; export * as agentSchedule from './agent-schedule.js';
export * as agentScheduleState from './agent-schedule-state.js'; export * as agentScheduleState from './agent-schedule-state.js';
export * as serviceEvents from './service-events.js'; export * as serviceEvents from './service-events.js'
export * as inlineTask from './inline-task.js';
export { PrefixLogger }; export { PrefixLogger };

View file

@ -0,0 +1,33 @@
import { z } from 'zod';
export const InlineTaskScheduleSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('cron'),
expression: z.string(),
startDate: z.string(),
endDate: z.string(),
}),
z.object({
type: z.literal('window'),
cron: z.string(),
startTime: z.string(),
endTime: z.string(),
startDate: z.string(),
endDate: z.string(),
}),
z.object({
type: z.literal('once'),
runAt: z.string(),
}),
]);
export type InlineTaskSchedule = z.infer<typeof InlineTaskScheduleSchema>;
export const InlineTaskBlockSchema = z.object({
instruction: z.string(),
schedule: InlineTaskScheduleSchema.optional(),
'schedule-label': z.string().optional(),
lastRunAt: z.string().optional(),
});
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js'; import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js'; import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': { 'runs:createMessage': {
req: z.object({ req: z.object({
runId: z.string(), runId: z.string(),
message: z.string(), message: UserMessageContent,
}), }),
res: z.object({ res: z.object({
messageId: z.string(), messageId: z.string(),
@ -419,6 +420,30 @@ const ipcSchemas = {
req: z.object({ path: z.string() }), req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
}, },
// Knowledge version history channels
'knowledge:history': {
req: z.object({ path: RelPath }),
res: z.object({
commits: z.array(z.object({
oid: z.string(),
message: z.string(),
timestamp: z.number(),
author: z.string(),
})),
}),
},
'knowledge:fileAtCommit': {
req: z.object({ path: RelPath, oid: z.string() }),
res: z.object({ content: z.string() }),
},
'knowledge:restore': {
req: z.object({ path: RelPath, oid: z.string() }),
res: z.object({ ok: z.literal(true) }),
},
'knowledge:didCommit': {
req: z.object({}),
res: z.null(),
},
// Search channels // Search channels
'search:query': { 'search:query': {
req: z.object({ req: z.object({
@ -435,6 +460,19 @@ const ipcSchemas = {
})), })),
}), }),
}, },
// Inline task schedule classification
'inline-task:classifySchedule': {
req: z.object({
instruction: z.string(),
}),
res: z.object({
schedule: z.union([
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string(), label: z.string() }),
z.object({ type: z.literal('once'), runAt: z.string(), label: z.string() }),
]).nullable(),
}),
},
} as const; } as const;
// ============================================================================ // ============================================================================

View file

@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
ToolCallPart, ToolCallPart,
]); ]);
// A piece of user-typed text within a content array
export const UserTextPart = z.object({
type: z.literal("text"),
text: z.string(),
});
// An attachment within a content array
export const UserAttachmentPart = z.object({
type: z.literal("attachment"),
path: z.string(), // absolute file path
filename: z.string(), // display name ("photo.png")
mimeType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
});
// Any single part of a user message (text or attachment)
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
// Named type for user message content — used everywhere instead of repeating the union
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
export const UserMessage = z.object({ export const UserMessage = z.object({
role: z.literal("user"), role: z.literal("user"),
content: z.string(), content: UserMessageContent,
providerOptions: ProviderOptions.optional(), providerOptions: ProviderOptions.optional(),
}); });

View file

@ -10,4 +10,6 @@ export const LlmProvider = z.object({
export const LlmModelConfig = z.object({ export const LlmModelConfig = z.object({
provider: LlmProvider, provider: LlmProvider,
model: z.string(), model: z.string(),
models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),
}); });

View file

@ -7,6 +7,8 @@ export const ServiceName = z.enum([
'fireflies', 'fireflies',
'granola', 'granola',
'voice_memo', 'voice_memo',
'email_labeling',
'note_tagging',
]); ]);
const ServiceEventBase = z.object({ const ServiceEventBase = z.object({

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

@ -359,6 +359,9 @@ importers:
googleapis: googleapis:
specifier: ^169.0.0 specifier: ^169.0.0
version: 169.0.0 version: 169.0.0
isomorphic-git:
specifier: ^1.29.0
version: 1.37.2
mammoth: mammoth:
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.11.0 version: 1.11.0
@ -3501,6 +3504,10 @@ packages:
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abs-svg-path@0.1.1: abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
@ -3627,6 +3634,9 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'} engines: {node: '>=8'}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async@1.5.2: async@1.5.2:
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==} resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
@ -3641,6 +3651,10 @@ packages:
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
awilix@12.0.5: awilix@12.0.5:
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==} resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
engines: {node: '>=16.3.0'} engines: {node: '>=16.3.0'}
@ -3742,6 +3756,9 @@ packages:
buffer@5.7.1: buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3762,6 +3779,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.4: call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3825,6 +3846,9 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clean-git-ref@2.0.1:
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
clean-stack@2.2.0: clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -4256,6 +4280,9 @@ packages:
dfa@1.2.0: dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
diff3@0.0.3:
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
dingbat-to-unicode@1.0.1: dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
@ -4496,6 +4523,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@5.0.1: eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -4640,6 +4671,10 @@ packages:
fontkit@2.0.4: fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1: foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -5104,6 +5139,10 @@ packages:
is-arrayish@0.3.4: is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
is-core-module@2.16.1: is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5170,6 +5209,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-typed-array@1.1.15:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'}
is-unicode-supported@0.1.0: is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5184,6 +5227,9 @@ packages:
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isbinaryfile@4.0.10: isbinaryfile@4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'} engines: {node: '>= 8.0.0'}
@ -5191,6 +5237,11 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-git@1.37.2:
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
engines: {node: '>=14.17'}
hasBin: true
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@ -5762,6 +5813,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minimisted@2.0.1:
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
minipass-collect@1.0.2: minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -6169,6 +6223,10 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pkce-challenge@5.0.1: pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
@ -6186,6 +6244,10 @@ packages:
points-on-path@0.2.1: points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -6220,6 +6282,10 @@ packages:
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
progress@2.0.3: progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@ -6434,6 +6500,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@ -6649,12 +6719,21 @@ packages:
server-destroy@1.0.1: server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5: setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sha.js@2.4.12:
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
engines: {node: '>= 0.10'}
hasBin: true
shebang-command@1.2.0: shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6701,6 +6780,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@ -6928,6 +7013,10 @@ packages:
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==} resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
to-data-view@1.1.0: to-data-view@1.1.0:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
@ -6998,6 +7087,10 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
typescript-eslint@8.50.1: typescript-eslint@8.50.1:
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -7272,6 +7365,10 @@ packages:
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
which@1.3.1: which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true hasBin: true
@ -11316,6 +11413,10 @@ snapshots:
abbrev@1.1.1: {} abbrev@1.1.1: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abs-svg-path@0.1.1: {} abs-svg-path@0.1.1: {}
accepts@2.0.0: accepts@2.0.0:
@ -11440,6 +11541,8 @@ snapshots:
arrify@2.0.1: {} arrify@2.0.1: {}
async-lock@1.4.1: {}
async@1.5.2: async@1.5.2:
optional: true optional: true
@ -11449,6 +11552,10 @@ snapshots:
author-regex@1.0.0: {} author-regex@1.0.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
awilix@12.0.5: awilix@12.0.5:
dependencies: dependencies:
camel-case: 4.1.2 camel-case: 4.1.2
@ -11568,6 +11675,11 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {} bytes@3.1.2: {}
cacache@16.1.3: cacache@16.1.3:
@ -11610,6 +11722,13 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 function-bind: 1.1.2
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4: call-bound@1.0.4:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -11672,6 +11791,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
clean-git-ref@2.0.1: {}
clean-stack@2.2.0: {} clean-stack@2.2.0: {}
cli-cursor@3.1.0: cli-cursor@3.1.0:
@ -12061,7 +12182,6 @@ snapshots:
es-define-property: 1.0.1 es-define-property: 1.0.1
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
optional: true
define-properties@1.2.1: define-properties@1.2.1:
dependencies: dependencies:
@ -12095,6 +12215,8 @@ snapshots:
dfa@1.2.0: {} dfa@1.2.0: {}
diff3@0.0.3: {}
dingbat-to-unicode@1.0.1: {} dingbat-to-unicode@1.0.1: {}
dir-compare@4.2.0: dir-compare@4.2.0:
@ -12451,6 +12573,8 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {} eventemitter3@5.0.1: {}
events@3.3.0: {} events@3.3.0: {}
@ -12638,6 +12762,10 @@ snapshots:
unicode-properties: 1.4.1 unicode-properties: 1.4.1
unicode-trie: 2.0.0 unicode-trie: 2.0.0
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1: foreground-child@3.3.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -12983,7 +13111,6 @@ snapshots:
has-property-descriptors@1.0.2: has-property-descriptors@1.0.2:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
optional: true
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
@ -13251,6 +13378,8 @@ snapshots:
is-arrayish@0.3.4: {} is-arrayish@0.3.4: {}
is-callable@1.2.7: {}
is-core-module@2.16.1: is-core-module@2.16.1:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
@ -13300,6 +13429,10 @@ snapshots:
is-stream@2.0.1: {} is-stream@2.0.1: {}
is-typed-array@1.1.15:
dependencies:
which-typed-array: 1.1.20
is-unicode-supported@0.1.0: {} is-unicode-supported@0.1.0: {}
is-url@1.2.4: {} is-url@1.2.4: {}
@ -13310,10 +13443,26 @@ snapshots:
isarray@1.0.0: {} isarray@1.0.0: {}
isarray@2.0.5: {}
isbinaryfile@4.0.10: {} isbinaryfile@4.0.10: {}
isexe@2.0.0: {} isexe@2.0.0: {}
isomorphic-git@1.37.2:
dependencies:
async-lock: 1.4.1
clean-git-ref: 2.0.1
crc-32: 1.2.2
diff3: 0.0.3
ignore: 5.3.2
minimisted: 2.0.1
pako: 1.0.11
pify: 4.0.1
readable-stream: 4.7.0
sha.js: 2.4.12
simple-get: 4.0.1
jackspeak@3.4.3: jackspeak@3.4.3:
dependencies: dependencies:
'@isaacs/cliui': 8.0.2 '@isaacs/cliui': 8.0.2
@ -14139,6 +14288,10 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minimisted@2.0.1:
dependencies:
minimist: 1.2.8
minipass-collect@1.0.2: minipass-collect@1.0.2:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
@ -14506,6 +14659,8 @@ snapshots:
pify@2.3.0: {} pify@2.3.0: {}
pify@4.0.1: {}
pkce-challenge@5.0.1: {} pkce-challenge@5.0.1: {}
pkg-types@1.3.1: pkg-types@1.3.1:
@ -14527,6 +14682,8 @@ snapshots:
path-data-parser: 0.1.0 path-data-parser: 0.1.0
points-on-curve: 0.2.0 points-on-curve: 0.2.0
possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.6: postcss@8.5.6:
@ -14565,6 +14722,8 @@ snapshots:
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
process@0.11.10: {}
progress@2.0.3: {} progress@2.0.3: {}
promise-inflight@1.0.1: {} promise-inflight@1.0.1: {}
@ -14887,6 +15046,14 @@ snapshots:
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdirp@4.1.2: {} readdirp@4.1.2: {}
rechoir@0.8.0: rechoir@0.8.0:
@ -15175,10 +15342,25 @@ snapshots:
server-destroy@1.0.1: {} server-destroy@1.0.1: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.3.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
setimmediate@1.0.5: {} setimmediate@1.0.5: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sha.js@2.4.12:
dependencies:
inherits: 2.0.4
safe-buffer: 5.2.1
to-buffer: 1.2.2
shebang-command@1.2.0: shebang-command@1.2.0:
dependencies: dependencies:
shebang-regex: 1.0.0 shebang-regex: 1.0.0
@ -15236,6 +15418,14 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
dependencies: dependencies:
is-arrayish: 0.3.4 is-arrayish: 0.3.4
@ -15493,6 +15683,12 @@ snapshots:
unorm: 1.6.0 unorm: 1.6.0
optional: true optional: true
to-buffer@1.2.2:
dependencies:
isarray: 2.0.5
safe-buffer: 5.2.1
typed-array-buffer: 1.0.3
to-data-view@1.1.0: to-data-view@1.1.0:
optional: true optional: true
@ -15550,6 +15746,12 @@ snapshots:
media-typer: 1.1.0 media-typer: 1.1.0
mime-types: 3.0.2 mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
is-typed-array: 1.1.15
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@ -15827,6 +16029,16 @@ snapshots:
tr46: 0.0.3 tr46: 0.0.3
webidl-conversions: 3.0.1 webidl-conversions: 3.0.1
which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.8
call-bound: 1.0.4
for-each: 0.3.5
get-proto: 1.0.1
gopd: 1.2.0
has-tostringtag: 1.0.2
which@1.3.1: which@1.3.1:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0