commit bc6d9dfa84f339c326f7a521774507f45eb08961 Author: multipleof4 Date: Wed Sep 2 13:43:59 2026 -0700 feat: initial dsh-context-plugin implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c9385f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ee7542 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# DeepSeek Harness Context Plugin (`dsh-context-plugin`) + +A dynamic Cordis plugin for DeepSeek Harness (DSH) that adds an interactive UI window to scan workspace files and ingest them directly into the active chat session as individual prompt turns. + +## Features + +- **Interactive UI Window**: + - Rendered in `shell.overlay` with support for minimize and close. + - Quick launcher button docked in `conversation.session.header.actions`. + - Button dynamically displays the active workspace root directory. + - Editable input override to type or change the workspace directory path. +- **Header Link Formatting**: + - Configurable switch between standard Linux `file:///` URIs and IDE `vscode://file/` URIs. + - Link format: `[``](file://)`. +- **Dynamic Backtick Escaping**: + - Dynamically calculates enclosing code fence backtick length ($\ge 3$ backticks) so files containing markdown or nested code fences never break the outer block. +- **Binary / Non-Text File Detection**: + - Inspects file extensions and scans the first 8 KB for null bytes (`\0`). + - Replaces binary contents with `*Binary file - content not displayed.*`. +- **Git & Ignore Rules**: + - Always ignores `.git/` across all directories. + - Respects `.gitignore` and `.dshignore` rules from the workspace root (including negation `!` patterns). + - Skips walking ignored directories entirely for performance. +- **Real-Time Progress Feedback**: + - Live animated spinner and counter (`Reading 5/42...`, `Injecting 5/42...`). + - Visual progress bar. + - Interactive file selection checklist before ingestion. +- **Individual User Prompt Turns**: + - Dispatches each file as an individual prompt turn into the chat session queue. + +## Prompt Turn Format + +```markdown +[`relative/path/to/file`](file:///absolute/path/to/workspace/relative/path/to/file) + +```{extension} +{file_contents} +``` +``` + +## Structure + +- `src/host.js`: Node.js host-side plugin handling filesystem queries, ignore rule resolution, and binary checks. +- `src/client.js`: Browser client-side plugin rendering the floating overlay window and header action button in Cordis slots. diff --git a/package.json b/package.json new file mode 100644 index 0000000..4534934 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "dsh-context-plugin", + "version": "0.1.0", + "description": "DeepSeek Harness plugin for scanning and ingesting workspace files into chat context turns", + "main": "src/host.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "deepseek-harness", + "cordis", + "plugin", + "context", + "workspace" + ], + "author": "", + "license": "MIT" +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..4ec10b8 --- /dev/null +++ b/src/client.js @@ -0,0 +1,745 @@ +/** + * DeepSeek Harness Context Plugin - Client Implementation + * + * Registers: + * 1. An interactive floating UI window in `shell.overlay` + * 2. An action button in `conversation.session.header.actions` + * + * Features: + * - Displays active workspace path with editable input override + * - Configurable link switch between `file:///` and `vscode://file/` + * - Scans workspace files respecting .gitignore, .dshignore, and .git + * - Detects binary files and replaces content with notice + * - Dynamically calculates code fence backticks (>= 3 backticks) + * - Shows loading spinner and progress counter (Reading X/N, Injecting X/N) + * - Delivers each file as an individual prompt turn into the chat session + */ + +function clientPlugin() { + return { + inject: ['timer'], + apply(ctx) { + const h = React.createElement; + const slots = ctx.get('slots'); + if (!slots) return; + + // Global window state emitter so Header Action Button and Overlay Window stay in sync + const listeners = new Set(); + const windowState = { + isOpen: true, + isMinimized: false + }; + + function updateWindowState(patch) { + Object.assign(windowState, patch); + for (const cb of listeners) cb(); + } + + function useSharedWindowState() { + const [state, setState] = React.useState({ ...windowState }); + React.useEffect(() => { + const cb = () => setState({ ...windowState }); + listeners.add(cb); + return () => listeners.delete(cb); + }, []); + return state; + } + + // Inject custom CSS styling + if (typeof styles !== 'undefined' && styles.insert) { + styles.insert(` + .dsh-ctx-overlay-container { + position: fixed; + top: 60px; + right: 24px; + z-index: 10000; + pointer-events: auto; + font-family: inherit; + } + .dsh-ctx-window { + width: 470px; + max-width: calc(100vw - 48px); + background: var(--dsw-alias-bg-overlay, #1c1d22); + border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.18)); + border-radius: 12px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45); + color: var(--dsw-alias-label-primary, #f0f0f0); + overflow: hidden; + display: flex; + flex-direction: column; + animation: dshCtxFadeIn 0.2s ease-out; + } + @keyframes dshCtxFadeIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } + } + .dsh-ctx-header { + padding: 10px 14px; + background: var(--dsw-alias-bg-layer-1, #23242a); + border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.1)); + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; + } + .dsh-ctx-title { + font-weight: 600; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; + } + .dsh-ctx-header-btn { + background: transparent; + border: none; + color: var(--dsw-alias-label-secondary, #999); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; + transition: all 0.15s; + } + .dsh-ctx-header-btn:hover { + background: rgba(255,255,255,0.1); + color: var(--dsw-alias-label-primary, #fff); + } + .dsh-ctx-body { + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + font-size: 12px; + } + .dsh-ctx-field { + display: flex; + flex-direction: column; + gap: 5px; + } + .dsh-ctx-label { + font-size: 11px; + font-weight: 500; + color: var(--dsw-alias-label-secondary, #aaa); + text-transform: uppercase; + letter-spacing: 0.5px; + } + .dsh-ctx-input-row { + display: flex; + gap: 6px; + } + .dsh-ctx-input { + flex: 1; + background: var(--dsw-alias-bg-layer-2, #141417); + border: 1px solid var(--dsw-alias-border-l2, #333); + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + font-family: monospace; + color: var(--dsw-alias-label-primary, #f0f0f0); + outline: none; + } + .dsh-ctx-input:focus { + border-color: var(--dsw-alias-brand-primary, #3b82f6); + } + .dsh-ctx-btn { + background: var(--dsw-alias-bg-layer-2, #2a2b33); + border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.15)); + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-primary, #fff); + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + } + .dsh-ctx-btn:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1, #363842); + border-color: var(--dsw-alias-border-l1, rgba(255,255,255,0.3)); + } + .dsh-ctx-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .dsh-ctx-btn-primary { + background: var(--dsw-alias-brand-primary, #2563eb); + border-color: transparent; + color: #fff; + font-weight: 600; + padding: 9px 14px; + font-size: 13px; + } + .dsh-ctx-btn-primary:hover:not(:disabled) { + background: #1d4ed8; + } + .dsh-ctx-switch-row { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--dsw-alias-bg-layer-2, #141417); + border: 1px solid var(--dsw-alias-border-l2, #333); + border-radius: 6px; + padding: 4px 8px; + } + .dsh-ctx-switch-pills { + display: flex; + background: var(--dsw-alias-bg-layer-1, #23242a); + border-radius: 5px; + padding: 2px; + } + .dsh-ctx-pill { + padding: 3px 10px; + font-size: 11px; + border-radius: 4px; + cursor: pointer; + border: none; + background: transparent; + color: var(--dsw-alias-label-secondary, #999); + font-weight: 500; + transition: all 0.15s; + } + .dsh-ctx-pill.active { + background: var(--dsw-alias-brand-primary, #2563eb); + color: #fff; + box-shadow: 0 1px 4px rgba(0,0,0,0.2); + } + .dsh-ctx-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .dsh-ctx-badge { + font-size: 10px; + padding: 2px 7px; + border-radius: 4px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + color: var(--dsw-alias-label-secondary, #aaa); + } + .dsh-ctx-badge.found { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.3); + color: #4ade80; + } + .dsh-ctx-file-list { + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--dsw-alias-border-l2, #333); + border-radius: 6px; + background: var(--dsw-alias-bg-layer-2, #141417); + padding: 4px; + } + .dsh-ctx-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 6px; + font-size: 11px; + font-family: monospace; + border-radius: 4px; + transition: background 0.1s; + } + .dsh-ctx-file-item:hover { + background: rgba(255,255,255,0.05); + } + .dsh-ctx-file-info { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dsh-ctx-file-tag { + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(255,255,255,0.1); + } + .dsh-ctx-file-tag.bin { + background: rgba(239, 68, 68, 0.2); + color: #f87171; + } + .dsh-ctx-progress { + display: flex; + flex-direction: column; + gap: 6px; + background: var(--dsw-alias-bg-layer-2, #141417); + border: 1px solid var(--dsw-alias-border-l2, #333); + border-radius: 6px; + padding: 8px 10px; + } + .dsh-ctx-progress-bar-bg { + height: 6px; + background: rgba(255,255,255,0.1); + border-radius: 3px; + overflow: hidden; + } + .dsh-ctx-progress-bar-fill { + height: 100%; + background: var(--dsw-alias-brand-primary, #3b82f6); + transition: width 0.15s ease; + } + .dsh-ctx-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(255,255,255,0.3); + border-radius: 50%; + border-top-color: #fff; + animation: dshCtxSpin 0.7s linear infinite; + } + @keyframes dshCtxSpin { + to { transform: rotate(360deg); } + } + .dsh-ctx-min-badge { + background: var(--dsw-alias-bg-overlay, #1c1d22); + border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.25)); + border-radius: 20px; + padding: 6px 14px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-primary, #fff); + transition: transform 0.15s; + } + .dsh-ctx-min-badge:hover { + transform: scale(1.03); + } + .dsh-ctx-notice-success { + padding: 8px 12px; + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.35); + border-radius: 6px; + color: #4ade80; + font-size: 12px; + } + .dsh-ctx-notice-error { + padding: 8px 12px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.35); + color: #f87171; + font-size: 12px; + } + `); + } + + // UI Window Component mounted in shell.overlay + function ContextOverlayWindow(props) { + const win = useSharedWindowState(); + const sessionsState = props.useSessions ? props.useSessions(s => s) : null; + const workspacesState = props.useWorkspaces ? props.useWorkspaces(s => s) : null; + + const [workspacePath, setWorkspacePath] = React.useState(''); + const [scheme, setScheme] = React.useState('file'); // 'file' | 'vscode' + const [scanResult, setScanResult] = React.useState(null); + const [selectedFiles, setSelectedFiles] = React.useState(new Set()); + const [phase, setPhase] = React.useState('idle'); // 'idle' | 'scanning' | 'reading' | 'injecting' | 'completed' | 'error' + const [progress, setProgress] = React.useState({ current: 0, total: 0, file: '', phaseText: '' }); + const [errorMessage, setErrorMessage] = React.useState(''); + const [successMessage, setSuccessMessage] = React.useState(''); + + // Attempt automatic workspace path detection from Host and Client + React.useEffect(() => { + host.call('get-workspace-root', {}).then(res => { + if (res && res.workspaceRoot) { + setWorkspacePath(prev => prev || res.workspaceRoot); + } + }).catch(() => {}); + }, []); + + React.useEffect(() => { + if (!workspacePath && workspacesState?.items?.length) { + const currentSessionId = sessionsState?.current; + let found = null; + if (currentSessionId) { + found = workspacesState.items.find(w => w.sessionIds && w.sessionIds.includes(currentSessionId)); + } + if (!found) found = workspacesState.items[0]; + if (found && found.path) { + setWorkspacePath(found.path); + } + } + }, [workspacesState, sessionsState, workspacePath]); + + // Scan files in workspace + const handleScan = React.useCallback(async () => { + setPhase('scanning'); + setErrorMessage(''); + setSuccessMessage(''); + try { + const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() }); + if (res.rootPath) { + setWorkspacePath(res.rootPath); + } + setScanResult(res); + setSelectedFiles(new Set(res.files.map(f => f.relativePath))); + setPhase('idle'); + } catch (err) { + setErrorMessage('Scan failed: ' + (err.message || String(err))); + setPhase('error'); + } + }, [workspacePath]); + + // Toggle file selection + const toggleFile = (relPath) => { + setSelectedFiles(prev => { + const next = new Set(prev); + if (next.has(relPath)) next.delete(relPath); + else next.add(relPath); + return next; + }); + }; + + const toggleAll = () => { + if (!scanResult) return; + if (selectedFiles.size === scanResult.files.length) { + setSelectedFiles(new Set()); + } else { + setSelectedFiles(new Set(scanResult.files.map(f => f.relativePath))); + } + }; + + // Ingest files into chat session + const handleIngest = async () => { + setErrorMessage(''); + setSuccessMessage(''); + + // 1. Resolve active session + let session = null; + try { + const sessionsService = ctx.get('sessions'); + const currentSessionId = sessionsState?.current || props.sessionId; + if (currentSessionId && sessionsService) { + const binding = sessionsService.binding(currentSessionId); + session = binding?.session; + } + } catch (e) { + console.error('Session resolution error', e); + } + + if (!session) { + setErrorMessage('No active session found. Please open a chat session before ingesting files.'); + return; + } + + // 2. Scan if not already scanned + let targetFiles = []; + if (!scanResult || scanResult.files.length === 0) { + setPhase('scanning'); + setProgress({ current: 0, total: 0, file: '', phaseText: 'Scanning workspace files...' }); + try { + const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() }); + if (res.rootPath) setWorkspacePath(res.rootPath); + setScanResult(res); + targetFiles = res.files; + setSelectedFiles(new Set(res.files.map(f => f.relativePath))); + } catch (err) { + setErrorMessage('Scan failed: ' + (err.message || String(err))); + setPhase('error'); + return; + } + } else { + targetFiles = scanResult.files.filter(f => selectedFiles.has(f.relativePath)); + } + + if (targetFiles.length === 0) { + setErrorMessage('No files selected for ingestion.'); + setPhase('idle'); + return; + } + + const total = targetFiles.length; + + // 3. Process each file + try { + for (let i = 0; i < total; i++) { + const file = targetFiles[i]; + const idx = i + 1; + + // Step 3a: Read file + setPhase('reading'); + setProgress({ + current: idx, + total, + file: file.relativePath, + phaseText: `Reading ${idx}/${total}: ${file.relativePath}` + }); + + const readRes = await host.call('read-file', { + absolutePath: file.absolutePath, + relativePath: file.relativePath, + isBinaryByExt: file.isBinaryByExt, + extension: file.extension + }); + + // Step 3b: Build header link + const abs = file.absolutePath.startsWith('/') ? file.absolutePath : ('/' + file.absolutePath); + const headerLink = scheme === 'vscode' + ? `[\`${file.relativePath}\`](vscode://file${abs})` + : `[\`${file.relativePath}\`](file://${abs})`; + + // Step 3c: Format the individual turn + const fence = readRes.fence || '```'; + const ext = readRes.extension || ''; + const content = readRes.content || ''; + const normalizedContent = content.endsWith('\n') ? content : (content + '\n'); + const turnText = `${headerLink}\n\n${fence}${ext}\n${normalizedContent}${fence}`; + + // Step 3d: Inject prompt turn + setPhase('injecting'); + setProgress({ + current: idx, + total, + file: file.relativePath, + phaseText: `Injecting ${idx}/${total}: ${file.relativePath}` + }); + + const promptOutcome = await session.prompt([{ type: 'text', text: turnText }], 'queue'); + if (!promptOutcome || !promptOutcome.ok) { + console.error('Failed prompt turn for', file.relativePath, promptOutcome); + } + + // Delay to smoothly queue messages + await ctx.timeout(100); + } + + setPhase('completed'); + setSuccessMessage(`Successfully injected ${total} file${total > 1 ? 's' : ''} into chat session!`); + } catch (err) { + setErrorMessage('Ingestion error: ' + (err.message || String(err))); + setPhase('error'); + } + }; + + if (!win.isOpen) { + // Render a compact floating pill when closed/hidden + return h('div', { className: 'dsh-ctx-overlay-container' }, + h('button', { + className: 'dsh-ctx-min-badge', + onClick: () => updateWindowState({ isOpen: true, isMinimized: false }) + }, + h('span', null, '📁'), + h('span', null, 'Context Ingester') + ) + ); + } + + if (win.isMinimized) { + return h('div', { className: 'dsh-ctx-overlay-container' }, + h('div', { + className: 'dsh-ctx-min-badge', + onClick: () => updateWindowState({ isMinimized: false }) + }, + h('span', null, '📁'), + h('span', null, workspacePath ? workspacePath.split('/').pop() || workspacePath : 'Context Ingester'), + scanResult ? h('span', { style: { opacity: 0.7 } }, `(${scanResult.files.length} files)`) : null, + h('span', { style: { marginLeft: '6px', fontSize: '10px' } }, '▲') + ) + ); + } + + const isBusy = phase === 'scanning' || phase === 'reading' || phase === 'injecting'; + const currentPathDisplay = workspacePath || '(auto-detecting...)'; + + return h('div', { className: 'dsh-ctx-overlay-container' }, + h('div', { className: 'dsh-ctx-window' }, + // Window Header + h('div', { className: 'dsh-ctx-header' }, + h('div', { className: 'dsh-ctx-title' }, + h('span', null, '📁'), + h('span', null, 'Workspace Context Ingestion') + ), + h('div', { style: { display: 'flex', gap: '4px' } }, + h('button', { + className: 'dsh-ctx-header-btn', + title: 'Minimize', + onClick: () => updateWindowState({ isMinimized: true }) + }, '—'), + h('button', { + className: 'dsh-ctx-header-btn', + title: 'Close', + onClick: () => updateWindowState({ isOpen: false }) + }, '✕') + ) + ), + + // Window Body + h('div', { className: 'dsh-ctx-body' }, + // Workspace Path Row + h('div', { className: 'dsh-ctx-field' }, + h('div', { className: 'dsh-ctx-label' }, 'Workspace Directory:'), + h('div', { className: 'dsh-ctx-input-row' }, + h('input', { + className: 'dsh-ctx-input', + type: 'text', + value: workspacePath, + placeholder: '/path/to/workspace', + disabled: isBusy, + onChange: (e) => setWorkspacePath(e.target.value) + }), + h('button', { + className: 'dsh-ctx-btn', + disabled: isBusy || !workspacePath.trim(), + onClick: handleScan + }, phase === 'scanning' ? h('span', { className: 'dsh-ctx-spinner' }) : 'Scan') + ) + ), + + // Header Link Protocol Switch + h('div', { className: 'dsh-ctx-switch-row' }, + h('span', { style: { fontWeight: 500 } }, 'Header Link Protocol:'), + h('div', { className: 'dsh-ctx-switch-pills' }, + h('button', { + className: `dsh-ctx-pill ${scheme === 'file' ? 'active' : ''}`, + disabled: isBusy, + onClick: () => setScheme('file') + }, 'file:///'), + h('button', { + className: `dsh-ctx-pill ${scheme === 'vscode' ? 'active' : ''}`, + disabled: isBusy, + onClick: () => setScheme('vscode') + }, 'vscode://file/') + ) + ), + + // Ignore Rules Badges + h('div', { className: 'dsh-ctx-badges' }, + h('span', { className: 'dsh-ctx-badge' }, '🚫 .git/ (Always ignored)'), + h('span', { className: `dsh-ctx-badge ${scanResult?.hasGitignore ? 'found' : ''}` }, + scanResult?.hasGitignore ? '✓ .gitignore (Active)' : '○ .gitignore' + ), + h('span', { className: `dsh-ctx-badge ${scanResult?.hasDshignore ? 'found' : ''}` }, + scanResult?.hasDshignore ? '✓ .dshignore (Active)' : '○ .dshignore' + ), + scanResult?.ignoredCount ? h('span', { className: 'dsh-ctx-badge' }, `${scanResult.ignoredCount} files ignored`) : null + ), + + // Scanned File List (if scanned) + scanResult && h('div', { className: 'dsh-ctx-field' }, + h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, + h('div', { className: 'dsh-ctx-label' }, `Files to Ingest (${selectedFiles.size}/${scanResult.files.length}):`), + h('button', { + className: 'dsh-ctx-header-btn', + style: { fontSize: '11px', textDecoration: 'underline' }, + disabled: isBusy, + onClick: toggleAll + }, selectedFiles.size === scanResult.files.length ? 'Deselect All' : 'Select All') + ), + h('div', { className: 'dsh-ctx-file-list' }, + scanResult.files.map(f => { + const isSelected = selectedFiles.has(f.relativePath); + return h('div', { + key: f.relativePath, + className: 'dsh-ctx-file-item', + onClick: () => !isBusy && toggleFile(f.relativePath) + }, + h('div', { className: 'dsh-ctx-file-info' }, + h('input', { + type: 'checkbox', + checked: isSelected, + disabled: isBusy, + onChange: () => {} + }), + h('span', null, f.relativePath) + ), + h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } }, + h('span', { className: `dsh-ctx-file-tag ${f.isBinaryByExt ? 'bin' : ''}` }, + f.isBinaryByExt ? 'binary' : (f.extension || 'txt') + ), + h('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '10px' } }, + f.size > 1024 ? `${(f.size / 1024).toFixed(1)}k` : `${f.size}b` + ) + ) + ); + }) + ) + ), + + // Progress Bar & Status (when reading/injecting) + isBusy && h('div', { className: 'dsh-ctx-progress' }, + h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px' } }, + h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, + h('span', { className: 'dsh-ctx-spinner' }), + h('span', { style: { fontWeight: 500 } }, progress.phaseText || 'Processing...') + ), + progress.total > 0 && h('span', { style: { opacity: 0.8 } }, + `${Math.round((progress.current / progress.total) * 100)}%` + ) + ), + progress.total > 0 && h('div', { className: 'dsh-ctx-progress-bar-bg' }, + h('div', { + className: 'dsh-ctx-progress-bar-fill', + style: { width: `${(progress.current / progress.total) * 100}%` } + }) + ) + ), + + // Error Notice + errorMessage && h('div', { className: 'dsh-ctx-notice-error' }, errorMessage), + + // Success Notice + successMessage && h('div', { className: 'dsh-ctx-notice-success' }, successMessage), + + // Primary Action Button showing workspace path + h('button', { + className: 'dsh-ctx-btn dsh-ctx-btn-primary', + disabled: isBusy || !workspacePath.trim(), + onClick: handleIngest + }, + isBusy ? [ + h('span', { key: 'sp', className: 'dsh-ctx-spinner' }), + h('span', { key: 'tx' }, progress.phaseText || 'Processing...') + ] : [ + h('span', { key: 'ic' }, '📥'), + h('span', { key: 'lb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, + `Ingest: ${currentPathDisplay}` + ) + ] + ) + ) + ) + ); + } + + // Header Action Button mounted in conversation.session.header.actions + function HeaderActionButton(props) { + return h('button', { + className: 'dsh-ctx-btn', + style: { + padding: '4px 10px', + fontSize: '12px', + gap: '6px' + }, + title: 'Open Workspace Context Ingestion Window', + onClick: () => { + updateWindowState({ isOpen: true, isMinimized: false }); + } + }, + h('span', null, '📁'), + h('span', null, 'Context Ingester') + ); + } + + // Register components in Cordis slots + slots.inject('shell.overlay', () => slots.register( + { name: 'shell.overlay', id: 'workspace-context-overlay' }, + ContextOverlayWindow + )); + + slots.inject('conversation.session.header.actions', () => slots.register( + { name: 'conversation.session.header.actions', id: 'workspace-context-action', order: 15 }, + HeaderActionButton + )); + } + }; +} + +module.exports = clientPlugin; diff --git a/src/host.js b/src/host.js new file mode 100644 index 0000000..22a832c --- /dev/null +++ b/src/host.js @@ -0,0 +1,278 @@ +/** + * DeepSeek Harness Context Plugin - Host Implementation + * + * Provides RPC endpoints for: + * - Detecting workspace root directory + * - Scanning files respecting .gitignore, .dshignore, and .git rules + * - Reading text/binary file contents with dynamic backtick code fences + */ + +function hostPlugin() { + return { + apply(ctx) { + const fs = ctx.get('fs'); + const sandboxPolicy = ctx.get('sandboxPolicy'); + + // Helper: get current workspace root + harness.handle('get-workspace-root', async () => { + try { + const root = sandboxPolicy?.workspaceRoot || ''; + return { workspaceRoot: root }; + } catch (e) { + return { workspaceRoot: '' }; + } + }); + + // Helper: parse gitignore / dshignore rules + function parseIgnoreRules(text) { + if (!text || typeof text !== 'string') return []; + const lines = text.split(/\r?\n/); + const rules = []; + for (let rawLine of lines) { + let line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + let negated = false; + if (line.startsWith('!')) { + negated = true; + line = line.slice(1).trim(); + } + if (!line) continue; + const dirOnly = line.endsWith('/'); + if (dirOnly) line = line.slice(0, -1); + if (!line) continue; + const hasSlash = line.includes('/'); + let pattern = line; + if (pattern.startsWith('/')) pattern = pattern.slice(1); + + // Convert glob pattern to regular expression + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*' && pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 2; + } else { + re += '.*'; + i += 1; + } + } else if (c === '*') { + re += '[^/]*'; + } else if (c === '?') { + re += '[^/]'; + } else if ('[\\]{}()+^$|.,'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + + let regex; + if (hasSlash) { + regex = new RegExp('^' + re + '(?:/.*)?$'); + } else { + regex = new RegExp('(?:^|/)' + re + '(?:/.*)?$'); + } + + rules.push({ raw: rawLine, pattern, negated, dirOnly, regex }); + } + return rules; + } + + function isPathIgnored(relPath, isDir, rules) { + // 1. Always ignore .git/ at any depth + const parts = relPath.split('/'); + if (parts.includes('.git')) return true; + + // 2. Evaluate ignore rules in order + let ignored = false; + for (const rule of rules) { + if (rule.dirOnly && !isDir) continue; + if (rule.regex.test(relPath)) { + ignored = !rule.negated; + } + } + return ignored; + } + + const BINARY_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif', 'avif', + 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', + 'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', + 'zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'iso', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'obj', 'class', 'pyc', 'pyo', 'wasm', + 'woff', 'woff2', 'ttf', 'eot', 'otf', + 'db', 'sqlite', 'sqlite3' + ]); + + function getExt(filename) { + const idx = filename.lastIndexOf('.'); + if (idx <= 0) return ''; + return filename.slice(idx + 1).toLowerCase(); + } + + // Endpoint: scan-workspace + harness.handle('scan-workspace', async (args) => { + if (!fs) throw new Error('fs service is not available on Host'); + const rootPath = args?.workspacePath || sandboxPolicy?.workspaceRoot || '.'; + const rootTarget = await fs.resolve(rootPath); + const canonicalRoot = fs.processPath(rootTarget); + + // Read .gitignore in workspace root + let gitignoreContent = ''; + let hasGitignore = false; + try { + const giTarget = await fs.resolve('.gitignore', { cwd: canonicalRoot }); + const giStat = await fs.stat(giTarget); + if (giStat && giStat.type === 'file') { + gitignoreContent = await fs.readText(giTarget); + hasGitignore = true; + } + } catch (e) {} + + // Read .dshignore in workspace root + let dshignoreContent = ''; + let hasDshignore = false; + try { + const diTarget = await fs.resolve('.dshignore', { cwd: canonicalRoot }); + const diStat = await fs.stat(diTarget); + if (diStat && diStat.type === 'file') { + dshignoreContent = await fs.readText(diTarget); + hasDshignore = true; + } + } catch (e) {} + + const rules = [ + ...parseIgnoreRules(gitignoreContent), + ...parseIgnoreRules(dshignoreContent) + ]; + + const files = []; + let ignoredCount = 0; + const queue = [{ target: rootTarget, relDir: '' }]; + + while (queue.length > 0) { + const item = queue.shift(); + let entries = []; + try { + entries = await fs.listDir(item.target); + } catch (e) { + continue; + } + + for (const entry of entries) { + const name = entry.name; + const relPath = item.relDir ? (item.relDir + '/' + name) : name; + const isDir = entry.type === 'directory'; + + if (isPathIgnored(relPath, isDir, rules)) { + ignoredCount++; + continue; + } + + if (isDir) { + queue.push({ target: entry.target, relDir: relPath }); + } else if (entry.type === 'file') { + const absPath = fs.processPath(entry.target); + const ext = getExt(name); + const isBin = BINARY_EXTENSIONS.has(ext); + files.push({ + relativePath: relPath, + absolutePath: absPath, + extension: ext, + size: entry.size || 0, + isBinaryByExt: isBin + }); + } + } + } + + files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + return { + rootPath: canonicalRoot, + hasGitignore, + hasDshignore, + ignoredCount, + files + }; + }); + + // Helper: calculate dynamic backtick fence (>= 3 backticks, exceeding any inner sequence) + function getBacktickFence(str) { + if (!str) return '```'; + const matches = str.match(/`+/g); + if (!matches) return '```'; + let maxLen = 0; + for (const m of matches) { + if (m.length > maxLen) maxLen = m.length; + } + return '`'.repeat(Math.max(3, maxLen + 1)); + } + + // Endpoint: read-file + harness.handle('read-file', async (args) => { + if (!fs) throw new Error('fs service is not available on Host'); + const { absolutePath, relativePath, isBinaryByExt, extension } = args; + + if (isBinaryByExt) { + return { + relativePath, + absolutePath, + extension: extension || '', + content: '*Binary file - content not displayed.*', + isBinary: true, + fence: '```' + }; + } + + try { + const target = await fs.resolve(absolutePath); + // Null-byte check on first 8KB of bytes + const bytes = await fs.readBytes(target, undefined, 8192); + let hasNull = false; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) { + hasNull = true; + break; + } + } + + if (hasNull) { + return { + relativePath, + absolutePath, + extension: extension || '', + content: '*Binary file - content not displayed.*', + isBinary: true, + fence: '```' + }; + } + + const text = await fs.readText(target); + const fence = getBacktickFence(text); + return { + relativePath, + absolutePath, + extension: extension || '', + content: text, + isBinary: false, + fence + }; + } catch (err) { + return { + relativePath, + absolutePath, + extension: extension || '', + content: '*Binary file - content not displayed.*', + isBinary: true, + fence: '```' + }; + } + }); + } + }; +} + +module.exports = hostPlugin;