diff --git a/apps/cli/src/application/assistant/instructions.ts b/apps/cli/src/application/assistant/instructions.ts index b6e49cf0..b22425c6 100644 --- a/apps/cli/src/application/assistant/instructions.ts +++ b/apps/cli/src/application/assistant/instructions.ts @@ -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 safe—double-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. diff --git a/apps/cli/src/application/assistant/runtime-context.ts b/apps/cli/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/cli/src/application/assistant/runtime-context.ts @@ -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.`; +} diff --git a/apps/cli/src/application/lib/command-executor.ts b/apps/cli/src/application/lib/command-executor.ts index 814d9801..cd16f05e 100644 --- a/apps/cli/src/application/lib/command-executor.ts +++ b/apps/cli/src/application/lib/command-executor.ts @@ -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 { diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b0757881..4d272275 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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(); let debounceTimer: ReturnType | 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); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 4f8859ef..af6a4740 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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(null) @@ -503,6 +507,13 @@ function App() { const initialContentRef = useRef('') const renameInProgressRef = useRef(false) + // Version history state + const [versionHistoryPath, setVersionHistoryPath] = useState(null) + const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{ + oid: string + content: string + } | null>(null) + // Chat state const [, setMessage] = useState('') const [conversation, setConversation] = useState([]) @@ -596,6 +607,8 @@ function App() { // File tab state const [fileTabs, setFileTabs] = useState([]) const [activeFileTabId, setActiveFileTabId] = useState(null) + const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) + const fileHistoryHandlersRef = useRef>(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 => `\n${f.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\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) => `\n${file.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\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 ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( @@ -3050,6 +3229,31 @@ function App() { ) : null} )} + {selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && ( + + + + + Version history + + )} {!selectedPath && !isGraphOpen && !selectedTask && ( @@ -3113,33 +3317,80 @@ function App() { ) : selectedPath ? ( selectedPath.endsWith('.md') ? ( -
- {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 ( -
- handleEditorChange(tab.path, markdown)} - placeholder="Start writing..." - wikiLinks={wikiLinkConfig} - onImageUpload={handleImageUpload} - /> -
- ) - })} +
+
+ {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 ( +
+ { 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} + /> +
+ ) + })} +
+ {versionHistoryPath && ( + { + 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) + } + }} + /> + )}
) : (
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 31bcba17..d3554c00 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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([]) + const [focusNonce, setFocusNonce] = useState(0) + const fileInputRef = useRef(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 ( -
- - {isProcessing ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => { + const attachmentType = getAttachmentTypeLabel(attachment) + const attachmentName = getAttachmentDisplayName(attachment) + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + + return ( + + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + + + {attachmentName} + {attachmentType} + + + + ) + })} +
)} +
+ { + 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 = '' + }} + /> + + + {isProcessing ? ( + + ) : ( + + )} +
) } @@ -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 diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx new file mode 100644 index 00000000..298e5f03 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx @@ -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 ( + Image attachment { + 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 ( +
+ {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + ))} +
+ )} + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => { + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + const attachmentName = getAttachmentDisplayName(attachment) + const attachmentType = getAttachmentTypeLabel(attachment) + return ( + + + + + + {attachmentName} + {attachmentType} + + + ) + })} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 170e8869..f020cdae 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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 ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 74ad10b8..6bcaef29 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -195,6 +195,9 @@ interface MarkdownEditorProps { placeholder?: string wikiLinks?: WikiLinkConfig onImageUpload?: (file: File) => Promise + 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(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) { diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 4855cab7..9398f2fe 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [modelsCatalog, setModelsCatalog] = useState>({}) const [modelsLoading, setModelsLoading] = useState(false) const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - 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>({ + 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) { )}
-
- Model - {modelsLoading ? ( -
- - Loading models... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} +
+
+ Assistant model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ Knowledge graph model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{showApiKey && ( diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 840b9cf4..2948ae02 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -167,14 +167,14 @@ const defaultBaseURLs: Partial> = { function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) { const [provider, setProvider] = useState("openai") - const [providerConfigs, setProviderConfigs] = useState>({ - 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>({ + 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>({}) 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 }) { )}
- {/* Model selection */} -
- Model - {modelsLoading ? ( -
- - Loading models... -
- ) : showModelInput ? ( - updateConfig(provider, { model: e.target.value })} - placeholder="Enter model" - /> - ) : ( - - )} - {modelsError && ( -
{modelsError}
- )} + {/* Model selection - side by side */} +
+
+ Assistant model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { model: e.target.value })} + placeholder="Enter model" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ +
+ Knowledge graph model + {modelsLoading ? ( +
+ + Loading... +
+ ) : showModelInput ? ( + updateConfig(provider, { knowledgeGraphModel: e.target.value })} + placeholder={activeConfig.model || "Enter model"} + /> + ) : ( + + )} +
{/* API Key */} diff --git a/apps/x/apps/renderer/src/components/version-history-panel.tsx b/apps/x/apps/renderer/src/components/version-history-panel.tsx new file mode 100644 index 00000000..f8d03d38 --- /dev/null +++ b/apps/x/apps/renderer/src/components/version-history-panel.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [selectedOid, setSelectedOid] = useState(null) // null = current/latest + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+ Version history + +
+ + {/* Commit list */} +
+ {loading ? ( +
+ Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : commits.length === 0 ? ( +
+ No history available +
+ ) : ( +
+ {commits.map((commit, index) => { + const isLatest = index === 0 + const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid + const { date, time } = formatTimestamp(commit.timestamp) + + return ( + + ) + })} +
+ )} +
+ + {/* Footer */} + {selectedOid && ( +
+ +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/lib/attachment-presentation.ts b/apps/x/apps/renderer/src/lib/attachment-presentation.ts new file mode 100644 index 00000000..7ddedd30 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/attachment-presentation.ts @@ -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' + } +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 4dcd4c8c..830e250b 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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 } diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts new file mode 100644 index 00000000..3ac3431a --- /dev/null +++ b/apps/x/apps/renderer/src/lib/file-utils.ts @@ -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 = { + // 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}`; +} diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index bcfac93d..53495637 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -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", diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index abeeb53a..0aeb167f 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise> { 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[]): ModelMessage[] { const result: ModelMessage[] = []; for (const msg of messages) { @@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer[]): 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) { diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index c0365c0f..96b50bb3 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -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 safe—double-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\\\\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 diff --git a/apps/x/packages/core/src/application/assistant/runtime-context.ts b/apps/x/packages/core/src/application/assistant/runtime-context.ts new file mode 100644 index 00000000..f1011c2c --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/runtime-context.ts @@ -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.`; +} diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 870f6a0c..947f49a0 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -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 { diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts index c60ecd1f..2b864840 100644 --- a/apps/x/packages/core/src/application/lib/message-queue.ts +++ b/apps/x/packages/core/src/application/lib/message-queue.ts @@ -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; type EnqueuedMessage = { messageId: string; - message: string; + message: UserMessageContentType; }; export interface IMessageQueue { - enqueue(runId: string, message: string): Promise; + enqueue(runId: string, message: UserMessageContentType): Promise; dequeue(runId: string): Promise; } @@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue { this.idGenerator = idGenerator; } - async enqueue(runId: string, message: string): Promise { + async enqueue(runId: string, message: UserMessageContentType): Promise { if (!this.store[runId]) { this.store[runId] = []; } diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index caefad82..4a91e101 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -91,4 +91,9 @@ function ensureWelcomeFile() { ensureDirs(); ensureDefaultConfigs(); -ensureWelcomeFile(); \ No newline at end of file +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); +}); diff --git a/apps/x/packages/core/src/index.ts b/apps/x/packages/core/src/index.ts index b7718cba..0eab08e3 100644 --- a/apps/x/packages/core/src/index.ts +++ b/apps/x/packages/core/src/index.ts @@ -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'; \ No newline at end of file +export { initConfigs } from './config/initConfigs.js'; + +// Knowledge version history +export * as versionHistory from './knowledge/version_history.js'; diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index a1b7e135..a119dfa6 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -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 { // 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); diff --git a/apps/x/packages/core/src/knowledge/version_history.ts b/apps/x/packages/core/src/knowledge/version_history.ts new file mode 100644 index 00000000..a6504f67 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/version_history.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + // Normalize path separators for git (always forward slashes) + const filepath = knowledgeRelPath.replace(/\\/g, '/'); + + let commits: Awaited>; + 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 { + 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 { + 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 { + 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'); +} diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 15873c49..5d563f1f 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo { const messageEvent = event as z.infer; 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; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 75f71d5f..0f123497 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -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): Promise return run; } -export async function createMessage(runId: string, message: string): Promise { +export async function createMessage(runId: string, message: UserMessageContentType): Promise { const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message); const runtime = container.resolve('agentRuntime'); diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts index cd872f39..de1fe212 100644 --- a/apps/x/packages/core/src/workspace/workspace.ts +++ b/apps/x/packages/core/src/workspace/workspace.ts @@ -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 | 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, diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 71491f8f..b5803ffc 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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({ diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index 702b103a..be761853 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -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(), }); diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 14e91689..48085e9f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -10,4 +10,5 @@ export const LlmProvider = z.object({ export const LlmModelConfig = z.object({ provider: LlmProvider, model: z.string(), + knowledgeGraphModel: z.string().optional(), }); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index fa8765d6..d3525c4f 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -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