Merge pull request #412 from rowboatlabs/dev

Dev
This commit is contained in:
arkml 2026-02-27 20:36:22 +05:30 committed by GitHub
commit 983a4c578f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2049 additions and 242 deletions

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.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.
@ -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.
- Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## 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.
- 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 { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"]+|['"]+$/g, '');
@ -91,7 +93,7 @@ export async function executeCommand(
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility
shell: EXECUTION_SHELL,
});
return {
@ -125,7 +127,7 @@ export function executeCommandSync(
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
shell: '/bin/sh',
shell: EXECUTION_SHELL,
});
return {

View file

@ -31,6 +31,7 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/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 { search } from '@x/core/dist/search/search.js';
import { versionHistory } from '@x/core';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -105,6 +106,18 @@ let watcher: FSWatcher | null = null;
const changeQueue = new Set<string>();
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
*/
@ -283,6 +296,9 @@ export function stopServicesWatcher(): void {
* Add new handlers here as you add channels to IPCChannels
*/
export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@ -498,6 +514,19 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
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:query': async (_event, args) => {
return search(args.query, args.limit, args.types);

View file

@ -5,11 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
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 { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
@ -48,10 +49,12 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
import { OnboardingModal } from '@/components/onboarding-modal'
import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
import {
type ChatMessage,
type ChatTabViewState,
type ConversationItem,
type ToolCall,
@ -452,6 +455,7 @@ function ContentHeader({
function App() {
type ShortcutPane = 'left' | 'right'
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
// File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
@ -503,6 +507,13 @@ function App() {
const initialContentRef = useRef<string>('')
const renameInProgressRef = useRef(false)
// Version history state
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
oid: string
content: string
} | null>(null)
// Chat state
const [, setMessage] = useState<string>('')
const [conversation, setConversation] = useState<ConversationItem[]>([])
@ -596,6 +607,8 @@ function App() {
// File tab state
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(new Map())
const fileTabIdCounterRef = useRef(0)
const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
@ -1067,6 +1080,14 @@ function App() {
saveFile()
}, [debouncedContent, setHistory])
// Close version history panel when switching files
useEffect(() => {
if (versionHistoryPath && selectedPath !== versionHistoryPath) {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
}
}, [selectedPath, versionHistoryPath])
// Load runs list (all pages)
const loadRuns = useCallback(async () => {
try {
@ -1168,19 +1189,41 @@ function App() {
if (msg.role === 'user' || msg.role === 'assistant') {
// Extract text content from message
let textContent = ''
let msgAttachments: ChatMessage['attachments'] = undefined
if (typeof msg.content === 'string') {
textContent = msg.content
} else if (Array.isArray(msg.content)) {
// Extract text parts
textContent = msg.content
.filter((part: { type: string }) => part.type === 'text')
.map((part: { type: string; text?: string }) => part.text || '')
const contentParts = msg.content as Array<{
type: string
text?: string
path?: string
filename?: string
mimeType?: string
size?: number
toolCallId?: string
toolName?: string
arguments?: ToolUIPart['input']
}>
textContent = contentParts
.filter((part) => part.type === 'text')
.map((part) => part.text || '')
.join('')
const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path)
if (attachmentParts.length > 0) {
msgAttachments = attachmentParts.map((part) => ({
path: part.path!,
filename: part.filename || part.path!.split('/').pop() || part.path!,
mimeType: part.mimeType || 'application/octet-stream',
size: part.size,
}))
}
// Also extract tool-call parts from assistant messages
if (msg.role === 'assistant') {
for (const part of msg.content) {
if (part.type === 'tool-call') {
for (const part of contentParts) {
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
@ -1194,11 +1237,12 @@ function App() {
}
}
}
if (textContent) {
if (textContent || msgAttachments) {
items.push({
id: event.messageId,
role: msg.role,
content: textContent,
attachments: msgAttachments,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
})
}
@ -1615,20 +1659,35 @@ function App() {
return cleanup
}, [handleRunEvent])
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
const handlePromptSubmit = async (
message: PromptInputMessage,
mentions?: FileMention[],
stagedAttachments: StagedAttachment[] = []
) => {
if (isProcessing) return
const { text } = message;
const { text } = message
const userMessage = text.trim()
if (!userMessage) return
const hasAttachments = stagedAttachments.length > 0
if (!userMessage && !hasAttachments) return
setMessage('')
const userMessageId = `user-${Date.now()}`
setConversation(prev => [...prev, {
const displayAttachments: ChatMessage['attachments'] = hasAttachments
? stagedAttachments.map((attachment) => ({
path: attachment.path,
filename: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
thumbnailUrl: attachment.thumbnailUrl,
}))
: undefined
setConversation((prev) => [...prev, {
id: userMessageId,
role: 'user',
content: userMessage,
attachments: displayAttachments,
timestamp: Date.now(),
}])
@ -1644,42 +1703,98 @@ function App() {
newRunCreatedAt = run.createdAt
setRunId(currentRunId)
// Update active chat tab's runId to the new run
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t))
setChatTabs((prev) => prev.map((tab) => (
tab.id === activeChatTabId
? { ...tab, runId: currentRunId }
: tab
)))
isNewRun = true
}
// Read mentioned file contents and format message with XML context
let formattedMessage = userMessage
if (mentions && mentions.length > 0) {
const attachedFiles = await Promise.all(
mentions.map(async (m) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
return { path: m.path, content: result.data as string }
} catch (err) {
console.error('Failed to read mentioned file:', m.path, err)
return { path: m.path, content: `[Error reading file: ${m.path}]` }
}
})
)
let titleSource = userMessage
if (attachedFiles.length > 0) {
const filesXml = attachedFiles
.map(f => `<file path="${f.path}">\n${f.content}\n</file>`)
.join('\n')
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
if (hasAttachments) {
type ContentPart =
| { type: 'text'; text: string }
| {
type: 'attachment'
path: string
filename: string
mimeType: string
size?: number
}
const contentParts: ContentPart[] = []
if (mentions && mentions.length > 0) {
for (const mention of mentions) {
contentParts.push({
type: 'attachment',
path: mention.path,
filename: mention.displayName || mention.path.split('/').pop() || mention.path,
mimeType: 'text/markdown',
})
}
}
for (const attachment of stagedAttachments) {
contentParts.push({
type: 'attachment',
path: attachment.path,
filename: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
})
}
if (userMessage) {
contentParts.push({ type: 'text', text: userMessage })
} else {
titleSource = stagedAttachments[0]?.filename ?? ''
}
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
const attachmentPayload = contentParts as unknown as string
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: attachmentPayload,
})
} else {
// Legacy path: plain string with optional XML-formatted @mentions.
let formattedMessage = userMessage
if (mentions && mentions.length > 0) {
const attachedFiles = await Promise.all(
mentions.map(async (mention) => {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: mention.path })
return { path: mention.path, content: result.data as string }
} catch (err) {
console.error('Failed to read mentioned file:', mention.path, err)
return { path: mention.path, content: `[Error reading file: ${mention.path}]` }
}
})
)
if (attachedFiles.length > 0) {
const filesXml = attachedFiles
.map((file) => `<file path="${file.path}">\n${file.content}\n</file>`)
.join('\n')
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
}
}
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: formattedMessage,
})
titleSource = formattedMessage
}
await window.ipc.invoke('runs:createMessage', {
runId: currentRunId,
message: formattedMessage,
})
if (isNewRun) {
const inferredTitle = inferRunTitleFromMessage(formattedMessage)
setRuns(prev => {
const withoutCurrent = prev.filter(run => run.id !== currentRunId)
const inferredTitle = inferRunTitleFromMessage(titleSource)
setRuns((prev) => {
const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
return [{
id: currentRunId!,
title: inferredTitle,
@ -2036,6 +2151,13 @@ function App() {
}
return next
})
setEditorSessionByTabId((prev) => {
if (!(tabId in prev)) return prev
const next = { ...prev }
delete next[tabId]
return next
})
fileHistoryHandlersRef.current.delete(tabId)
}, [activeFileTabId, fileTabs, removeEditorCacheForPath])
const handleNewChatTab = useCallback(() => {
@ -2136,6 +2258,11 @@ function App() {
setFileTabs((prev) => prev.map((tab) => (
tab.id === activeFileTabId ? { ...tab, path } : tab
)))
// Rebinds this tab to a different note path: reset editor session to clear undo history.
setEditorSessionByTabId((prev) => ({
...prev,
[activeFileTabId]: (prev[activeFileTabId] ?? 0) + 1,
}))
return
}
}
@ -2352,6 +2479,46 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior).
useEffect(() => {
const handleHistoryKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod || e.altKey) return
const key = e.key.toLowerCase()
const wantsUndo = key === 'z' && !e.shiftKey
const wantsRedo = (key === 'z' && e.shiftKey) || (!isMac && key === 'y')
if (!wantsUndo && !wantsRedo) return
if (!selectedPath || !selectedPath.endsWith('.md') || !activeFileTabId) return
const target = e.target as EventTarget | null
if (target instanceof HTMLElement) {
const inTipTapEditor = Boolean(target.closest('.tiptap-editor'))
const inOtherTextInput = (
target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target.isContentEditable
) && !inTipTapEditor
if (inOtherTextInput) return
}
const handlers = fileHistoryHandlersRef.current.get(activeFileTabId)
if (!handlers) return
e.preventDefault()
e.stopPropagation()
if (wantsUndo) {
handlers.undo()
} else {
handlers.redo()
}
}
document.addEventListener('keydown', handleHistoryKeyDown, true)
return () => document.removeEventListener('keydown', handleHistoryKeyDown, true)
}, [activeFileTabId, isMac, selectedPath])
// Keyboard shortcuts for tab management
useEffect(() => {
const handleTabKeyDown = (e: KeyboardEvent) => {
@ -2794,6 +2961,18 @@ function App() {
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
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)
return (
<Message key={item.id} from={item.role}>
@ -3050,6 +3229,31 @@ function App() {
) : null}
</div>
)}
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (versionHistoryPath) {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
} else {
setVersionHistoryPath(selectedPath)
}
}}
className={cn(
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
versionHistoryPath && "bg-accent text-foreground"
)}
aria-label="Version history"
>
<HistoryIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !selectedTask && (
<Tooltip>
<TooltipTrigger asChild>
@ -3113,33 +3317,80 @@ function App() {
</div>
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{openMarkdownTabs.map((tab) => {
const isActive = activeFileTabId
? tab.id === activeFileTabId || tab.path === selectedPath
: tab.path === selectedPath
const tabContent = editorContentByPath[tab.path]
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
return (
<div
key={tab.id}
className={cn(
'min-h-0 flex-1 flex-col overflow-hidden',
isActive ? 'flex' : 'hidden'
)}
data-file-tab-panel={tab.id}
aria-hidden={!isActive}
>
<MarkdownEditor
content={tabContent}
onChange={(markdown) => handleEditorChange(tab.path, markdown)}
placeholder="Start writing..."
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
/>
</div>
)
})}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{openMarkdownTabs.map((tab) => {
const isActive = activeFileTabId
? tab.id === activeFileTabId || tab.path === selectedPath
: tab.path === selectedPath
const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path
const tabContent = isViewingHistory
? viewingHistoricalVersion.content
: editorContentByPath[tab.path]
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
return (
<div
key={tab.id}
className={cn(
'min-h-0 flex-1 flex-col overflow-hidden',
isActive ? 'flex' : 'hidden'
)}
data-file-tab-panel={tab.id}
aria-hidden={!isActive}
>
<MarkdownEditor
content={tabContent}
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
placeholder="Start writing..."
wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload}
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
onHistoryHandlersChange={(handlers) => {
if (handlers) {
fileHistoryHandlersRef.current.set(tab.id, handlers)
} else {
fileHistoryHandlersRef.current.delete(tab.id)
}
}}
editable={!isViewingHistory}
/>
</div>
)
})}
</div>
{versionHistoryPath && (
<VersionHistoryPanel
path={versionHistoryPath}
onClose={() => {
setVersionHistoryPath(null)
setViewingHistoricalVersion(null)
}}
onSelectVersion={(oid, content) => {
if (oid === null) {
setViewingHistoricalVersion(null)
} else {
setViewingHistoricalVersion({ oid, content })
}
}}
onRestore={async (oid) => {
try {
await window.ipc.invoke('knowledge:restore', {
path: versionHistoryPath.startsWith('knowledge/')
? versionHistoryPath.slice('knowledge/'.length)
: versionHistoryPath,
oid,
})
// Reload file content
const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath })
handleEditorChange(versionHistoryPath, result.data)
setViewingHistoricalVersion(null)
setVersionHistoryPath(null)
} catch (err) {
console.error('Failed to restore version:', err)
}
}}
/>
)}
</div>
) : (
<div className="flex-1 overflow-auto p-4">

View file

@ -1,7 +1,28 @@
import { useCallback, useEffect } from 'react'
import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ArrowUp,
AudioLines,
FileArchive,
FileCode2,
FileIcon,
FileSpreadsheet,
FileText,
FileVideo,
LoaderIcon,
Plus,
Square,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
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 {
type FileMention,
@ -10,9 +31,41 @@ import {
PromptInputTextarea,
usePromptInputController,
} 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
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 {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
@ -38,7 +91,10 @@ function ChatInputInner({
}: ChatInputInnerProps) {
const controller = usePromptInputController()
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
// Restore the tab draft when this input mounts.
useEffect(() => {
@ -59,12 +115,48 @@ function ChatInputInner({
}
}, [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(() => {
if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
controller.textInput.clear()
controller.mentions.clearMentions()
}, [canSubmit, message, onSubmit, controller])
setAttachments([])
}, [attachments, canSubmit, controller, message, onSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -88,11 +180,9 @@ function ChatInputInner({
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files)
.map((file) => window.electronUtils?.getPathForFile(file))
.filter(Boolean)
.filter(Boolean) as string[]
if (paths.length > 0) {
const currentText = controller.textInput.value
const pathText = paths.join(' ')
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
void addFiles(paths)
}
}
}
@ -103,50 +193,119 @@ function ChatInputInner({
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('drop', onDrop)
}
}, [controller, isActive])
}, [addFiles, isActive])
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
<PromptInputTextarea
placeholder="Type your message..."
onKeyDown={handleKeyDown}
autoFocus={isActive}
focusTrigger={isActive ? runId : undefined}
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/>
{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 className="rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
{attachments.map((attachment) => {
const attachmentType = getAttachmentTypeLabel(attachment)
const attachmentName = getAttachmentDisplayName(attachment)
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
return (
<span
key={attachment.id}
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"
>
<span
className={cn(
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
attachment.isImage && attachment.thumbnailUrl
? 'bg-muted'
: getAttachmentToneClass(attachmentType)
)}
>
{attachment.isImage && attachment.thumbnailUrl ? (
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
) : (
<Icon className="size-5" />
)}
</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>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
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"
>
<X className="size-3.5" />
</button>
</span>
)
})}
</div>
)}
<div className="flex items-center gap-2 px-4 py-4">
<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 = ''
}}
/>
<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>
<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"
/>
{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>
)
}
@ -155,7 +314,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onStop?: () => void
isProcessing: 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 { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
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 {
type ChatTabViewState,
@ -89,7 +90,7 @@ interface ChatSidebarProps {
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
@ -256,6 +257,18 @@ export function ChatSidebar({
const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
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)
return (
<Message key={item.id} from={item.role}>

View file

@ -195,6 +195,9 @@ interface MarkdownEditorProps {
placeholder?: string
wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null>
editorSessionKey?: number
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
editable?: boolean
}
type WikiLinkMatch = {
@ -278,6 +281,9 @@ export function MarkdownEditor({
placeholder = 'Start writing...',
wikiLinks,
onImageUpload,
editorSessionKey = 0,
onHistoryHandlersChange,
editable = true,
}: MarkdownEditorProps) {
const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
@ -299,6 +305,7 @@ export function MarkdownEditor({
)
const editor = useEditor({
editable,
extensions: [
StarterKit.configure({
heading: {
@ -400,7 +407,7 @@ export function MarkdownEditor({
return false
},
},
})
}, [editorSessionKey])
const orderedFiles = useMemo(() => {
if (!wikiLinks) return []
@ -489,12 +496,37 @@ export function MarkdownEditor({
isInternalUpdate.current = true
// Pre-process to preserve blank lines
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
}
}
}, [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
useEffect(() => {
if (editor) {

View file

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

View file

@ -167,14 +167,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
openai: { apiKey: "", baseURL: "", model: "" },
anthropic: { apiKey: "", baseURL: "", model: "" },
google: { apiKey: "", baseURL: "", model: "" },
openrouter: { apiKey: "", baseURL: "", model: "" },
aigateway: { apiKey: "", baseURL: "", model: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
model: parsed.model,
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
},
}))
}
@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
baseURL: activeConfig.baseURL.trim() || undefined,
},
model: activeConfig.model.trim(),
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)}
</div>
{/* Model selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading models...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateConfig(provider, { model: e.target.value })}
placeholder="Enter model"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateConfig(provider, { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
{/* Model selection - side by side */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant 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.model}
onChange={(e) => updateConfig(provider, { model: e.target.value })}
placeholder="Enter model"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateConfig(provider, { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{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) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
placeholder={activeConfig.model || "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((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* API Key */}

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,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 { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
export interface MessageAttachment {
path: string
filename: string
mimeType: string
size?: number
thumbnailUrl?: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
attachments?: MessageAttachment[]
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

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

View file

@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
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[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
});
break;
case "user":
result.push({
role: "user",
content: msg.content,
providerOptions,
});
if (typeof msg.content === 'string') {
// Legacy string — pass through unchanged
result.push({
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;
case "tool":
result.push({
@ -674,7 +706,12 @@ export async function* streamAgent({
// set up provider + model
const provider = createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model);
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
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;
while (true) {

View file

@ -1,5 +1,8 @@
import { skillCatalog } from "./skills/index.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.
@ -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.
- Keep user data safedouble-check before editing or deleting important resources.
${runtimeContextPrompt}
## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**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.
- 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 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.
## 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,11 +1,13 @@
import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
const EXECUTION_SHELL = getExecutionShell();
function sanitizeToken(token: string): string {
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
@ -85,7 +87,7 @@ export async function executeCommand(
cwd: options?.cwd,
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell: '/bin/sh', // use sh for cross-platform compatibility
shell: EXECUTION_SHELL,
});
return {
@ -145,7 +147,7 @@ export function executeCommandAbortable(
// Check if already aborted before spawning
if (options?.signal?.aborted) {
// 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();
return {
process: dummyProc,
@ -159,7 +161,7 @@ export function executeCommandAbortable(
}
const proc = spawn(command, [], {
shell: '/bin/sh',
shell: EXECUTION_SHELL,
cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'],
@ -273,7 +275,7 @@ export function executeCommandSync(
cwd: options?.cwd,
timeout: options?.timeout,
encoding: 'utf-8',
shell: '/bin/sh',
shell: EXECUTION_SHELL,
});
return {

View file

@ -1,12 +1,16 @@
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 = {
messageId: string;
message: string;
message: UserMessageContentType;
};
export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>;
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: string): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}

View file

@ -91,4 +91,9 @@ function ensureWelcomeFile() {
ensureDirs();
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

@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
export * as watcher from './workspace/watcher.js';
// 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

@ -15,6 +15,7 @@ import {
} from './graph_state.js';
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -320,6 +321,13 @@ async function buildGraphWithFiles(
// Save state after each successful batch
// This ensures partial progress is saved even if later batches fail
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) {
hadError = true;
console.error(`Error processing batch ${batchNumber}:`, error);
@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
// Save state after each batch
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) {
hadError = true;
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);

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

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

View file

@ -1,6 +1,6 @@
import z from "zod";
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 { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
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 id = await queue.enqueue(runId, message);
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 { WorkDir } from '../config/config.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
import { commitAll } from '../knowledge/version_history.js';
// ============================================================================
// 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(
relPath: string,
data: string,
@ -266,6 +282,11 @@ export async function writeFile(
const stat = statToSchema(stats, 'file');
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 {
path: relPath,
stat,

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': {
req: z.object({
runId: z.string(),
message: z.string(),
message: UserMessageContent,
}),
res: z.object({
messageId: z.string(),
@ -396,6 +397,30 @@ const ipcSchemas = {
req: z.object({ path: z.string() }),
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:query': {
req: z.object({

View file

@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
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({
role: z.literal("user"),
content: z.string(),
content: UserMessageContent,
providerOptions: ProviderOptions.optional(),
});

View file

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

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

@ -359,6 +359,9 @@ importers:
googleapis:
specifier: ^169.0.0
version: 169.0.0
isomorphic-git:
specifier: ^1.29.0
version: 1.37.2
mammoth:
specifier: ^1.11.0
version: 1.11.0
@ -3501,6 +3504,10 @@ packages:
abbrev@1.1.1:
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:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
@ -3627,6 +3634,9 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async@1.5.2:
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
@ -3641,6 +3651,10 @@ packages:
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
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:
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
engines: {node: '>=16.3.0'}
@ -3742,6 +3756,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -3762,6 +3779,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
@ -3825,6 +3846,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clean-git-ref@2.0.1:
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
@ -4256,6 +4280,9 @@ packages:
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
diff3@0.0.3:
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
@ -4496,6 +4523,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
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:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -4640,6 +4671,10 @@ packages:
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@ -5104,6 +5139,10 @@ packages:
is-arrayish@0.3.4:
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:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@ -5170,6 +5209,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-typed-array@1.1.15:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
@ -5184,6 +5227,9 @@ packages:
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isbinaryfile@4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'}
@ -5191,6 +5237,11 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-git@1.37.2:
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
engines: {node: '>=14.17'}
hasBin: true
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@ -5762,6 +5813,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minimisted@2.0.1:
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'}
@ -6169,6 +6223,10 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@ -6186,6 +6244,10 @@ packages:
points-on-path@0.2.1:
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:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -6220,6 +6282,10 @@ packages:
process-nextick-args@2.0.1:
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:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@ -6434,6 +6500,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
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:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -6649,12 +6719,21 @@ packages:
server-destroy@1.0.1:
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:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
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:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@ -6701,6 +6780,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
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:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@ -6928,6 +7013,10 @@ packages:
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
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:
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
@ -6998,6 +7087,10 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
typescript-eslint@8.50.1:
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -7272,6 +7365,10 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@ -11316,6 +11413,10 @@ snapshots:
abbrev@1.1.1: {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abs-svg-path@0.1.1: {}
accepts@2.0.0:
@ -11440,6 +11541,8 @@ snapshots:
arrify@2.0.1: {}
async-lock@1.4.1: {}
async@1.5.2:
optional: true
@ -11449,6 +11552,10 @@ snapshots:
author-regex@1.0.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
awilix@12.0.5:
dependencies:
camel-case: 4.1.2
@ -11568,6 +11675,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
cacache@16.1.3:
@ -11610,6 +11722,13 @@ snapshots:
es-errors: 1.3.0
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:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -11672,6 +11791,8 @@ snapshots:
dependencies:
clsx: 2.1.1
clean-git-ref@2.0.1: {}
clean-stack@2.2.0: {}
cli-cursor@3.1.0:
@ -12061,7 +12182,6 @@ snapshots:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
optional: true
define-properties@1.2.1:
dependencies:
@ -12095,6 +12215,8 @@ snapshots:
dfa@1.2.0: {}
diff3@0.0.3: {}
dingbat-to-unicode@1.0.1: {}
dir-compare@4.2.0:
@ -12451,6 +12573,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {}
events@3.3.0: {}
@ -12638,6 +12762,10 @@ snapshots:
unicode-properties: 1.4.1
unicode-trie: 2.0.0
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@ -12983,7 +13111,6 @@ snapshots:
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
optional: true
has-symbols@1.1.0: {}
@ -13251,6 +13378,8 @@ snapshots:
is-arrayish@0.3.4: {}
is-callable@1.2.7: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@ -13300,6 +13429,10 @@ snapshots:
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-url@1.2.4: {}
@ -13310,10 +13443,26 @@ snapshots:
isarray@1.0.0: {}
isarray@2.0.5: {}
isbinaryfile@4.0.10: {}
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:
dependencies:
'@isaacs/cliui': 8.0.2
@ -14139,6 +14288,10 @@ snapshots:
minimist@1.2.8: {}
minimisted@2.0.1:
dependencies:
minimist: 1.2.8
minipass-collect@1.0.2:
dependencies:
minipass: 3.3.6
@ -14506,6 +14659,8 @@ snapshots:
pify@2.3.0: {}
pify@4.0.1: {}
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
@ -14527,6 +14682,8 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {}
postcss@8.5.6:
@ -14565,6 +14722,8 @@ snapshots:
process-nextick-args@2.0.1: {}
process@0.11.10: {}
progress@2.0.3: {}
promise-inflight@1.0.1: {}
@ -14887,6 +15046,14 @@ snapshots:
string_decoder: 1.3.0
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: {}
rechoir@0.8.0:
@ -15175,10 +15342,25 @@ snapshots:
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: {}
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:
dependencies:
shebang-regex: 1.0.0
@ -15236,6 +15418,14 @@ snapshots:
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:
dependencies:
is-arrayish: 0.3.4
@ -15493,6 +15683,12 @@ snapshots:
unorm: 1.6.0
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:
optional: true
@ -15550,6 +15746,12 @@ snapshots:
media-typer: 1.1.0
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):
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)
@ -15827,6 +16029,16 @@ snapshots:
tr46: 0.0.3
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:
dependencies:
isexe: 2.0.0