diff --git a/app/electron.vite.config.ts b/app/electron.vite.config.ts index 83f6faa..343a5cc 100644 --- a/app/electron.vite.config.ts +++ b/app/electron.vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve('src/main/index.ts'), + 'file-reference': resolve('src/main/file-reference.ts'), indexer: resolve('src/main/indexer.ts'), 'indexer-service': resolve('src/main/indexer-service.ts'), 'indexer-worker': resolve('src/main/indexer-worker.ts'), diff --git a/app/package.json b/app/package.json index 3d2983e..1d4bf70 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,8 @@ "verify:icons": "node scripts/verify-icons.mjs", "test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs", "test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs", - "test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs" + "test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs", + "test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs" }, "build": { "appId": "com.obelisk.app", diff --git a/app/src/main/file-reference.ts b/app/src/main/file-reference.ts new file mode 100644 index 0000000..1c8b585 --- /dev/null +++ b/app/src/main/file-reference.ts @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const EDITOR_SCHEMES: Record = { + vscode: 'vscode', + 'vscode-insiders': 'vscode-insiders', + cursor: 'cursor', + windsurf: 'windsurf', + zed: 'zed', +}; + +const DEFAULT_EDITOR_SCHEME = 'vscode'; + +interface FileReferenceQuery { + rawPath?: string | null; + cwd?: string | null; + roots?: (string | null | undefined)[]; +} + +interface EditorUrlOptions { + scheme?: string | null; + filePath: string; + line?: number | null; + column?: number | null; +} + +function normalizeRoots(roots: (string | null | undefined)[] = []): string[] { + const seen = new Set(); + for (const root of roots) { + if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) continue; + seen.add(path.resolve(root)); + } + return [...seen]; +} + +function isWithinRoots(candidate: string, roots: string[]): boolean { + return roots.some((root) => candidate === root || candidate.startsWith(root + path.sep)); +} + +// A reference may be absolute, relative to the message cwd, or project-relative with a +// leading slash (`/src/foo.ts`). path.join treats the leading slash as a no-op, so the same +// join covers the last two cases. +function fileReferenceCandidates({ rawPath, cwd, roots = [] }: FileReferenceQuery): string[] { + if (typeof rawPath !== 'string' || !rawPath.trim()) return []; + const cleaned = rawPath.trim(); + const bases = normalizeRoots([cwd, ...roots]); + const candidates: string[] = []; + if (path.isAbsolute(cleaned)) candidates.push(path.resolve(cleaned)); + for (const base of bases) candidates.push(path.resolve(base, `.${path.sep}${cleaned}`)); + return [...new Set(candidates)]; +} + +// Resolves to an existing file that lives inside one of the session's own roots. Transcript +// text is untrusted input, so containment is checked after realpath — a symlink pointing out +// of the project must not widen what the app will open. +function resolveFileReference(query: FileReferenceQuery): string | null { + const roots = normalizeRoots([query.cwd, ...(query.roots || [])]); + if (!roots.length) return null; + for (const candidate of fileReferenceCandidates(query)) { + if (!isWithinRoots(candidate, roots)) continue; + try { + const real = fs.realpathSync(candidate); + if (!isWithinRoots(real, roots)) continue; + if (fs.statSync(real).isFile()) return real; + } catch {} + } + return null; +} + +function buildEditorUrl({ scheme, filePath, line, column }: EditorUrlOptions): string { + const resolved = EDITOR_SCHEMES[String(scheme || '')] || DEFAULT_EDITOR_SCHEME; + let target = `${resolved}://file${encodeURI(filePath)}`; + const lineNumber = Number(line); + if (Number.isInteger(lineNumber) && lineNumber > 0) { + target += `:${lineNumber}`; + const columnNumber = Number(column); + if (Number.isInteger(columnNumber) && columnNumber > 0) target += `:${columnNumber}`; + } + return target; +} + +export { + DEFAULT_EDITOR_SCHEME, + EDITOR_SCHEMES, + buildEditorUrl, + fileReferenceCandidates, + isWithinRoots, + normalizeRoots, + resolveFileReference, +}; diff --git a/app/src/main/index.ts b/app/src/main/index.ts index dab7385..35a901c 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -9,6 +9,7 @@ import { writeHeartbeat } from './indexer.ts'; import { createIndexerService } from './indexer-service.ts'; import { createWorkerBuildIndex } from './indexer-worker-client.ts'; import { buildRecapExportQuery } from './recap-capture-query.ts'; +import { buildEditorUrl, DEFAULT_EDITOR_SCHEME, EDITOR_SCHEMES, resolveFileReference } from './file-reference.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts'; import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts'; @@ -298,6 +299,30 @@ async function stopBackgroundResources({ stopWorker = false } = {}) { closeDb(); } +function safeProtocol(url: string): string { + try { return new URL(url).protocol; } catch { return ''; } +} + +function isSameOrigin(url: string, currentUrl: string): boolean { + try { + return new URL(url).origin === new URL(currentUrl).origin; + } catch { + return false; + } +} + +// Reloads and in-app routing keep the same origin and pathname; anything else is a real +// navigation away from the renderer document. +function isSameDocumentNavigation(url: string, currentUrl: string): boolean { + try { + const target = new URL(url); + const current = new URL(currentUrl); + return target.origin === current.origin && target.pathname === current.pathname; + } catch { + return false; + } +} + function createWindow() { const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL; const shouldOpenDevTools = process.argv.includes('--devtools'); @@ -325,6 +350,25 @@ function createWindow() { } }); + // Keep the SPA in place: a Markdown link in a transcript must never replace the running + // renderer. External http(s) targets are handed to the system browser instead. + // Only genuinely external targets go to the browser. A same-origin URL is a local document + // reference — in dev that would hand the system browser a Vite-served source file. + const releaseNavigation = (url: string) => { + if (!/^https?:$/i.test(safeProtocol(url))) return; + if (isSameOrigin(url, win.webContents.getURL())) return; + shell.openExternal(url).catch(() => {}); + }; + win.webContents.setWindowOpenHandler(({ url }) => { + releaseNavigation(url); + return { action: 'deny' }; + }); + win.webContents.on('will-navigate', (event, url) => { + if (isSameDocumentNavigation(url, win.webContents.getURL())) return; + event.preventDefault(); + releaseNavigation(url); + }); + if (isDev) { win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173'); if (shouldOpenDevTools) { @@ -589,6 +633,39 @@ ipcMain.handle('db:readMemoryFile', (_, filePath) => { } catch { return null; } }); +// Every root this session actually worked in. A reference is only opened if it lands inside +// one of them, so untrusted transcript text cannot reach a file outside the session's own +// projects. Scoping is deliberately per session, not corpus-wide. +function querySessionFileRoots(sessionId: unknown): string[] { + if (!db || typeof sessionId !== 'string' || !sessionId) return []; + const roots: string[] = []; + try { + const rows = db.prepare( + `SELECT DISTINCT cwd FROM messages WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''` + ).all(sessionId); + for (const row of rows) roots.push(row.cwd); + const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId); + if (session?.project_path) roots.push(session.project_path); + } catch {} + return roots; +} + +ipcMain.handle('file-ref:open', async (_, ref) => { + const { sessionId, path: rawPath, cwd, line, column } = ref || {}; + const roots = querySessionFileRoots(sessionId); + // The renderer-supplied cwd only counts if this session actually recorded it. + const scopedCwd = typeof cwd === 'string' && roots.includes(cwd) ? cwd : null; + const filePath = resolveFileReference({ rawPath, cwd: scopedCwd, roots }); + if (!filePath) return { opened: false }; + const { editorScheme } = loadPersistedSettings(); + try { + await shell.openExternal(buildEditorUrl({ scheme: editorScheme, filePath, line, column })); + return { opened: true, path: filePath }; + } catch { + return { opened: false, path: filePath }; + } +}); + ipcMain.handle('db:archiveMemory', (_, id, reason) => { return runAppDbWrite(() => { db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`) @@ -814,6 +891,7 @@ ipcMain.handle('settings:get', () => { dbPath: dbFile, recapDir, autoRefresh: persisted.autoRefresh !== false, + editorScheme: persisted.editorScheme || DEFAULT_EDITOR_SCHEME, sources, memoryCount, sessionCount, diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index 3ee5097..eec82c8 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('obelisk', { getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid), getMemories: () => ipcRenderer.invoke('db:getMemories'), readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path), + openFileReference: (ref: { sessionId?: string | null; path: string; cwd?: string | null; line?: number | null; column?: number | null }) => + ipcRenderer.invoke('file-ref:open', ref), archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason), restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id), getProjects: () => ipcRenderer.invoke('db:getProjects'), diff --git a/app/src/renderer/src/file-references.mjs b/app/src/renderer/src/file-references.mjs new file mode 100644 index 0000000..a6d8635 --- /dev/null +++ b/app/src/renderer/src/file-references.mjs @@ -0,0 +1,112 @@ +// Two reference shapes appear in transcripts and need different handling: +// A. Markdown links with an absolute path — `[roadmap.md](/Users/me/proj/roadmap.md:162)` +// B. Inline code holding a project-relative path — `` `src/hooks/hook.ts:40` `` +// Both end up as an anchor carrying the split-out components; the main process resolves them. + +const REFERENCE_SUFFIX = /:(\d+)(?::(\d+)|-(\d+))?$/; +const INLINE_CODE_REFERENCE = /^[\w@.\-/]+\.[A-Za-z0-9]+:\d+(?::\d+|-\d+)?$/; +const LOCAL_HREF = /^(?:file:|\/|[A-Za-z]:[\\/])/; + +export function isLocalHref(href) { + return typeof href === 'string' && LOCAL_HREF.test(href.trim()); +} + +// Deliberately strict: an extension *and* a line number are both required. Plain inline code +// such as `package.json` or `useState` must never become a link. +export function isInlineCodeReference(text) { + return typeof text === 'string' && INLINE_CODE_REFERENCE.test(text.trim()); +} + +export function parseFileReference(raw) { + if (typeof raw !== 'string' || !raw.trim()) return null; + const text = raw.trim(); + const match = REFERENCE_SUFFIX.exec(text); + if (!match) return { path: text, line: null, column: null, endLine: null }; + return { + path: text.slice(0, match.index), + line: Number(match[1]), + column: match[2] ? Number(match[2]) : null, + endLine: match[3] ? Number(match[3]) : null, + }; +} + +// Only `file:` URLs are percent-encoded. Decoding a plain path would corrupt any filename +// that legitimately contains `%`. +export function hrefToPath(href) { + if (!href.startsWith('file:')) return href; + try { + return decodeURIComponent(new URL(href).pathname); + } catch { + return ''; + } +} + +function applyReference(el, raw, { cwd, sessionId }) { + const ref = parseFileReference(raw); + if (!ref?.path) return false; + el.classList.add('file-ref'); + el.dataset.filePath = ref.path; + if (cwd) el.dataset.fileCwd = cwd; + if (sessionId) el.dataset.fileSession = sessionId; + if (ref.line) el.dataset.fileLine = String(ref.line); + if (ref.column) el.dataset.fileColumn = String(ref.column); + if (ref.endLine) el.dataset.fileEndLine = String(ref.endLine); + return true; +} + +function markLinkReferences(rootEl, context) { + for (const anchor of rootEl.querySelectorAll('a[href]')) { + const href = anchor.getAttribute('href') || ''; + if (!isLocalHref(href)) continue; + const filePath = hrefToPath(href.trim()); + if (filePath) applyReference(anchor, filePath, context); + } +} + +// Fenced blocks render as
; only inline code is linkified, so a path quoted inside
+// a code sample never turns into navigation.
+function markInlineCodeReferences(rootEl, context) {
+  if (!context.cwd) return;
+  for (const code of rootEl.querySelectorAll('code')) {
+    if (code.closest('pre')) continue;
+    const text = code.textContent.trim();
+    if (!isInlineCodeReference(text)) continue;
+    const anchor = document.createElement('a');
+    anchor.textContent = text;
+    if (!applyReference(anchor, text, context)) continue;
+    code.replaceChildren(anchor);
+  }
+}
+
+// Cheap pre-filter on the rendered HTML. Most messages contain neither a link nor inline code,
+// and this runs for every rendered row — a string scan is far cheaper than walking the DOM.
+export function mayContainFileReference(html) {
+  return typeof html === 'string' && (html.includes(' target.removeEventListener('click', onClick);
+}
diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js
index 51b05a1..6584fbb 100644
--- a/app/src/renderer/src/main.js
+++ b/app/src/renderer/src/main.js
@@ -6,6 +6,7 @@ import router from './router.js';
 import { commitInitialData, fetchInitialData } from './data.js';
 import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
 import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
+import { installFileReferenceHandler } from './file-references.mjs';
 
 // Import shared renderer CSS globally
 import '../styles/base.css';
@@ -59,4 +60,6 @@ window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
   noteSessionUpdated(sessionLiveState, sessionId, currentSessionId);
 });
 
+installFileReferenceHandler();
+
 app.mount('#app');
diff --git a/app/src/renderer/src/session-timeline-presentation.mjs b/app/src/renderer/src/session-timeline-presentation.mjs
index fee7471..80d030a 100644
--- a/app/src/renderer/src/session-timeline-presentation.mjs
+++ b/app/src/renderer/src/session-timeline-presentation.mjs
@@ -252,6 +252,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
   const toolArgPreviews = new Map();
   const toolIcons = new Map();
   const workflowAgentGroups = new Map();
+  const refs = { cwd: message.cwd || null, sessionId: message.session_id || null };
 
   for (const toolCall of toolCalls) {
     const input = parseToolInput(toolCall);
@@ -263,7 +264,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
       toolPrettyHtml.set(toolCall.id, renderPrettyTool(toolCall));
     }
     if ((toolCall.name === 'Agent' || toolCall.name === 'Task') && toolCall.result?.content) {
-      toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact' }));
+      toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact', ...refs }));
     }
     if (toolCall.name === 'Workflow') workflowAgentGroups.set(toolCall.id, groupWorkflowAgents(toolCall.workflow));
   }
@@ -271,14 +272,14 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
   const effectiveText = expandedText ?? message.text;
   return {
     messageHtml: message.text
-      ? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query })
+      ? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query, ...refs })
       : '',
     thinkingHtml: message._thinking
-      ? renderMarkdown(message._thinking, { variant: 'msg', query })
-      : (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query }) : ''),
-    skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact' }) : '',
+      ? renderMarkdown(message._thinking, { variant: 'msg', query, ...refs })
+      : (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query, ...refs }) : ''),
+    skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact', ...refs }) : '',
     summaryHtml: message.summary?.content
-      ? renderMarkdown(message.summary.content, { variant: 'compact' })
+      ? renderMarkdown(message.summary.content, { variant: 'compact', ...refs })
       : '',
     toolInputs,
     toolInputText,
diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js
index 743b58c..6998a1d 100644
--- a/app/src/renderer/src/utils.js
+++ b/app/src/renderer/src/utils.js
@@ -2,6 +2,7 @@
 // Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
 
 import { state } from './store.js';
+import { markFileReferences, mayContainFileReference } from './file-references.mjs';
 
 // --- Time / formatting ---
 
@@ -102,6 +103,11 @@ export function renderMarkdown(text, opts = {}) {
   const container = document.createElement('div');
   container.className = cls;
   container.innerHTML = html;
+  // Without a cwd or session to resolve against, a reference could never be opened — leaving it
+  // unmarked keeps it from looking actionable.
+  if ((opts.cwd || opts.sessionId) && mayContainFileReference(html)) {
+    markFileReferences(container, { cwd: opts.cwd, sessionId: opts.sessionId });
+  }
   if (opts.query) highlightTextNodes(container, opts.query.trim());
   return container.outerHTML;
 }
diff --git a/app/src/renderer/src/views/Settings.vue b/app/src/renderer/src/views/Settings.vue
index 407d094..98b7d55 100644
--- a/app/src/renderer/src/views/Settings.vue
+++ b/app/src/renderer/src/views/Settings.vue
@@ -7,6 +7,8 @@ const sources = ref([]);
 const dbPath = ref('');
 const recapPath = ref('');
 const autoRefresh = ref(true);
+const editorScheme = ref('vscode');
+const editorSchemes = ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed'];
 const memoryCount = ref(0);
 const rebuilding = ref(false);
 const version = ref('0.1.0');
@@ -22,9 +24,15 @@ async function loadSettings() {
   dbPath.value = s.dbPath || '';
   recapPath.value = s.recapDir || '~/.obelisk/recap';
   autoRefresh.value = s.autoRefresh !== false;
+  editorScheme.value = s.editorScheme || 'vscode';
   memoryCount.value = s.memoryCount || 0;
 }
 
+async function saveEditorScheme(value) {
+  editorScheme.value = value;
+  await saveSetting('editorScheme', value);
+}
+
 async function browseSourcePath(source) {
   if (!window.obelisk?.browseFolder) return;
   const result = await window.obelisk.browseFolder();
@@ -168,6 +176,25 @@ function fmtRelative(iso) {
         
       
 
+      
+      
+
+

Editor

+

Which editor opens file references found in session transcripts.

+
+
+
+
Editor URL scheme
+
Clicking src/app.ts:42 jumps to that line.
+
+
+ +
+
+
+
diff --git a/app/src/renderer/src/views/SubagentDetail.vue b/app/src/renderer/src/views/SubagentDetail.vue index 54230f7..03bc2fd 100644 --- a/app/src/renderer/src/views/SubagentDetail.vue +++ b/app/src/renderer/src/views/SubagentDetail.vue @@ -37,6 +37,10 @@ async function handleLoadFull(uuid, el) { el.remove(); } } + +function renderMsgMarkdown(msg, text, variant) { + return renderMarkdown(text, { variant, cwd: msg?.cwd || null, sessionId: msg?.session_id || null }); +} @@ -81,7 +85,7 @@ async function handleLoadFull(uuid, el) { System {{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }} -
+
@@ -96,9 +100,9 @@ async function handleLoadFull(uuid, el) { Thinking -
+
-
+
(no text content)