diff --git a/.github/workflows/publish-skill.yml b/.github/workflows/publish-skill.yml new file mode 100644 index 0000000..dd983fa --- /dev/null +++ b/.github/workflows/publish-skill.yml @@ -0,0 +1,54 @@ +name: Publish Skill + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm ci + + - run: npm run build:skill + + - name: Push to obelisk-skill + env: + DEPLOY_KEY: ${{ secrets.SKILL_REPO_DEPLOY_KEY }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/skill_deploy + chmod 600 ~/.ssh/skill_deploy + export GIT_SSH_COMMAND="ssh -i ~/.ssh/skill_deploy -o StrictHostKeyChecking=no" + + SKILL_DIR=$(mktemp -d) + git clone --depth 1 git@github.com:tommy0103/obelisk-skill.git "$SKILL_DIR" || { + # First push: init an empty repo + git init "$SKILL_DIR" + git -C "$SKILL_DIR" remote add origin git@github.com:tommy0103/obelisk-skill.git + } + + # Replace all content with the fresh build + find "$SKILL_DIR" -mindepth 1 -not -path "$SKILL_DIR/.git*" -delete + cp -R dist/obelisk-skill/* "$SKILL_DIR/" + cp packaging/skill-README.md "$SKILL_DIR/README.md" + cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE" + + cd "$SKILL_DIR" + git add -A + if git diff --cached --quiet; then + echo "No changes to publish" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "publish: $(date -u +%Y-%m-%dT%H:%M:%SZ) from tommy0103/obelisk@${GITHUB_SHA::7}" + git push --force origin HEAD:main diff --git a/.gitignore b/.gitignore index e7fa23d..fe7675e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ .DS_Store plans/ .skillopt-backups -tests/ node_modules/ dist-renderer/ release/ -docs/ +.dev.docs .claude/ +dist/ +app/out/ +HANDOFF.md +.obelisk/ \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..0b07070 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,94 @@ +# Obelisk + +Obelisk is explicit memory infrastructure for coding agents: it indexes local +Claude Code and Codex transcripts into a queryable SQLite evidence layer, and a +CodeAct runtime lets an agent write a small query, run it, and answer from real +session history. This glossary pins the terms that are specific to Obelisk; it is +not a spec. + +## Runtime interface + +**Runtime interface**: +The public contract, expressed as four verbs — `build`, `search(text)`, +`query(code)`, `attune(code)`. Skill, CLI, and MCP are transports over this same +shape; none of them add their own retrieval surface. +_Avoid_: API, tool surface + +**CodeAct**: +The interaction style where an agent writes JavaScript that runs inside the +`query(code)` sandbox and returns JSON, rather than calling many fine-grained +tools. This is Obelisk's core design choice. +_Avoid_: tool-calling, function-calling + +**Helper**: +A convenience accessor available only inside the `query(code)` sandbox +(`overview`, `search`, `context`, `sql`, `memories`, …). Helpers are never +promoted to an external tool surface. + +## Indexing + +**Provider adapter**: +A pure per-source module (claude, codex, later opencode, pi, …) that discovers a +source's transcript files and parses one into a stream of records. It never opens +or writes a database; adding a source means adding one adapter. The shared pure +parse/discover helpers live in `packages/core/src/parsing.ts`, which imports only +node:fs/path/os — deliberately node:sqlite-free so the compiled providers can be +consumed by the app (whose Electron runtime has no `node:sqlite`). +_Avoid_: parse core, parser, ingest + +**Record**: +One normalized row destined for the index (session, message, tool call, tool +result, summary, subagent, workflow, …), emitted by a provider adapter before any +persistence happens. + +**Persist layer**: +The single shared, provider- and binding-agnostic writer that consumes records +from any adapter and writes them into an injected SQLite handle inside a +transaction. The binding is injected — `node:sqlite` (skill/CLI) or +`better-sqlite3` (app) — so there is one persist implementation, not one per +binding. +_Avoid_: writer, sink, DAO + +**Daemon indexing mode**: +Continuous incremental indexing driven by a long-lived process (the desktop app, +later a CLI daemon) that watches transcript directories and keeps the index fresh +as files change. +_Avoid_: watcher mode, live indexing + +**Passive pull mode**: +On-demand incremental indexing performed by the skill when there is no active +daemon: an invocation of the runtime brings the index up to date, then answers. +_Avoid_: lazy indexing, on-read indexing + +**index_state**: +The bookkeeping table shared by both indexing modes. It records, per transcript +path, the last-seen `mtime` and `lines_processed` (enabling resume-from-line +incremental indexing), plus heartbeat/last-build markers used for daemon +arbitration. + +**Daemon arbitration**: +The policy by which the passive pull mode detects a fresh daemon from the +`__app_heartbeat__` marker and skips every skill-side mutation, including schema +setup, indexing, checkpointing, and `attune`. The heartbeat alone means “the +daemon should write”; `__app_last_successful_build__` records coverage/freshness, +not ownership. Both indexing modes use the same persist layer. + +**Writer lease**: +The hard cross-process safety mutex behind daemon arbitration. A writer holds +`BEGIN IMMEDIATE` on `.obelisk/writer.lock.sqlite` for the complete mutation; +manual rebuild holds it through build, target-database replacement, and reopen. +The heartbeat expresses policy, while the writer lease prevents overlapping +writes during races, stale heartbeats, or processes from different versions. + +## Memory + +**Queryable session memory**: +The evidence layer — real sessions, messages, tool calls, subagents, workflows — +that an agent queries on demand. Obelisk deliberately does this instead of +implicit/ambient memory. +_Avoid_: implicit memory, ambient memory, auto-recall + +**Approved durable memory**: +Human-approved conclusions persisted as markdown plus a registry record, via +`attune(code)` calling `remember()`/`forget()`. Auditable and revocable. +_Avoid_: long-term memory, vector memory diff --git a/README.md b/README.md index 49e90cf..eea8ba2 100644 --- a/README.md +++ b/README.md @@ -42,22 +42,28 @@ For live app refresh, Obelisk watches `~/.claude/projects` and `~/.codex/session You can use obelisk like: ``` -/obelisk 上次 auth bug 最后到底改了哪些文件,为什么这么改 -/obelisk 这个文件最近在哪些 sessions 里被反复修改 -/obelisk 找出最近失败的 tool calls,它们分别发生在哪些任务里 -/obelisk 那个 review workflow 的 subagents 各自结论是什么 -/obelisk recap this week +/obelisk-skill 上次 auth bug 最后到底改了哪些文件,为什么这么改 +/obelisk-skill 这个文件最近在哪些 sessions 里被反复修改 +/obelisk-skill 找出最近失败的 tool calls,它们分别发生在哪些任务里 +/obelisk-skill 那个 review workflow 的 subagents 各自结论是什么 +/obelisk-skill recap this week ``` ### Install - - ```bash -npx skills add tommy0103/obelisk +npx skills add tommy0103/obelisk-skill ``` -Or manually: copy the skill into `.claude/skills/obelisk/`. +Or manually: copy `obelisk-skill/` into your project's `.claude/skills/` + +Then in any Claude Code session: + +``` +/obelisk-skill +``` + +First run builds the index (~5 seconds for 100 sessions). After that it rebuilds incrementally. ### How it works @@ -66,7 +72,7 @@ You ask a question ↓ Agent writes a JS query against the SQLite index ↓ -Runs it via node runtime.mjs --query + + +
+ + + diff --git a/app/src/renderer/js/app.js b/app/src/renderer/js/app.js new file mode 100644 index 0000000..8c41bfd --- /dev/null +++ b/app/src/renderer/js/app.js @@ -0,0 +1,235 @@ +// Entry point -- wires data, rendering, and event handlers together. +// No exports; this is the bootstrap module. + +import { loadInitialData } from './data.js'; +import { state, IS_MAC } from './state.js'; +import { + renderAll, + renderMemoryList, + renderSessionList, + setRoute, + setView, + setProject, + enterDetail, + archive, + restore, + setCursor, + navigateToSession, + switchView +} from './render.js'; +import { initKeyboard } from './keys.js'; + +// -- Helpers ---------------------------------------------------------------- + +let searchDebounceTimer = null; + +function debounce(fn, ms) { + return (...args) => { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => fn(...args), ms); + }; +} + +// -- Boot ------------------------------------------------------------------- + +document.addEventListener('DOMContentLoaded', async () => { + await loadInitialData(); + initKeyboard(); + + // -- Sidebar navigation (route switching + project filter) ---------------- + + const sidebar = document.querySelector('.sidebar'); + if (sidebar) { + sidebar.addEventListener('click', e => { + const item = e.target.closest('.sidebar-item'); + if (!item) return; + + const route = item.dataset.route; + const project = item.dataset.project; + const view = item.dataset.view; + + if (route) { + setRoute(route); + } else if (view) { + setView(view); + } else if (project !== undefined) { + setProject(project); + } + }); + } + + // -- Sidebar search (filter projects list) -------------------------------- + + const sidebarSearch = document.querySelector('.sidebar-search input'); + if (sidebarSearch) { + sidebarSearch.addEventListener('input', e => { + state.projectSearch = e.target.value; + renderAll(); + }); + } + + // -- Breadcrumb click (navigate back to list) ----------------------------- + + const breadcrumb = document.querySelector('.breadcrumb'); + if (breadcrumb) { + breadcrumb.addEventListener('click', e => { + const crumb = e.target.closest('[data-action]'); + if (!crumb) return; + const action = crumb.dataset.action; + if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); } + else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); } + else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); } + }); + } + + // -- Toolbar search with debounce ----------------------------------------- + + const searchInput = document.getElementById('search'); + if (searchInput) { + const handleSearch = debounce(value => { + state.query = value; + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + }, 200); + + searchInput.addEventListener('input', e => { + handleSearch(e.target.value); + }); + } + + // -- Sort toggle ----------------------------------------------------------- + + const sortToggle = document.querySelector('.sort-group'); + if (sortToggle) { + sortToggle.addEventListener('click', () => { + state.sortDesc = !state.sortDesc; + sortToggle.classList.toggle('desc', state.sortDesc); + sortToggle.classList.toggle('asc', !state.sortDesc); + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + }); + } + + // -- Search messages toggle ------------------------------------------------ + + const searchMsgsToggle = document.querySelector('.filter-toggle'); + if (searchMsgsToggle) { + searchMsgsToggle.addEventListener('click', () => { + state.includeMessageBodies = !state.includeMessageBodies; + searchMsgsToggle.classList.toggle('active', state.includeMessageBodies); + if (state.query) { + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + } + }); + } + + // -- #list click (memory rows: selection, actions, navigation) ------------ + + const list = document.getElementById('list'); + if (list) { + list.addEventListener('click', e => { + // Action buttons (archive/restore) + const action = e.target.closest('.row-action'); + if (action) { + e.stopPropagation(); + const row = action.closest('.row'); + const id = row?.dataset.id; + if (!id) return; + if (action.classList.contains('restore')) { + restore([id]); + } else { + archive([id]); + } + return; + } + + // Checkbox toggling + const checkbox = e.target.closest('.row-checkbox'); + if (checkbox) { + e.stopPropagation(); + const row = checkbox.closest('.row'); + const id = row?.dataset.id; + if (!id) return; + + if (e.shiftKey && state.cursorId) { + // Range select between cursor and clicked + const rows = Array.from(list.querySelectorAll('.row')); + const ids = rows.map(r => r.dataset.id); + const fromIdx = ids.indexOf(state.cursorId); + const toIdx = ids.indexOf(id); + const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + for (let i = lo; i <= hi; i++) { + state.selection.add(ids[i]); + } + } else if (e.metaKey || e.ctrlKey) { + // Toggle single + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + } else { + // Simple toggle + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + } + setCursor(id); + renderMemoryList(); + return; + } + + // Row click (navigate cursor / open detail) + const row = e.target.closest('.row'); + if (!row) return; + const id = row.dataset.id; + if (!id) return; + + if (e.shiftKey && state.cursorId) { + // Shift-click: range select + const rows = Array.from(list.querySelectorAll('.row')); + const ids = rows.map(r => r.dataset.id); + const fromIdx = ids.indexOf(state.cursorId); + const toIdx = ids.indexOf(id); + const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + for (let i = lo; i <= hi; i++) { + state.selection.add(ids[i]); + } + renderMemoryList(); + } else if ((IS_MAC ? e.metaKey : e.ctrlKey)) { + // Cmd/Ctrl-click: toggle selection + if (state.selection.has(id)) state.selection.delete(id); + else state.selection.add(id); + setCursor(id); + renderMemoryList(); + } else { + // Plain click: move cursor + setCursor(id); + renderMemoryList(); + } + }); + + // -- #list dblclick (open detail) ----------------------------------------- + + list.addEventListener('dblclick', e => { + const row = e.target.closest('.row'); + if (!row) return; + const id = row.dataset.id; + if (id) enterDetail(id); + }); + } + + // -- #session-list click (session rows) ------------------------------------ + + const sessionList = document.getElementById('session-list'); + if (sessionList) { + sessionList.addEventListener('click', e => { + const srow = e.target.closest('.srow'); + if (!srow) return; + const id = srow.dataset.sessionId; + if (id) navigateToSession(id); + }); + } + + // -- Start on memory view ------------------------------------------------- + + setRoute('memory'); + renderAll(); +}); diff --git a/app/src/renderer/js/data.js b/app/src/renderer/js/data.js new file mode 100644 index 0000000..81d605d --- /dev/null +++ b/app/src/renderer/js/data.js @@ -0,0 +1,380 @@ +// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state. +// All DB access goes through this module. + +import { state } from './state.js'; + +/** + * Load initial data from the DB and populate state.memories, state.sessions, + * and state.projects. + */ +export async function loadInitialData() { + const [rawMemories, rawSessions, stats, projects] = await Promise.all([ + window.obelisk.getMemories(), + window.obelisk.getSessions(), + window.obelisk.getStats(), + window.obelisk.getProjects() + ]); + + // Transform memories: DB records -> render-layer shape + state.memories = (rawMemories || []).map(m => ({ + ...m, + ts: m.created_at ? new Date(m.created_at).getTime() : 0, + archived: !!m.deleted_at, + archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null, + health: 'ok', + anchors: [], + markdown: null // loaded on demand via loadMemoryMarkdown + })); + + // Sessions: keep DB shape, add empty messages array for on-demand loading + state.sessions = (rawSessions || []).map(s => ({ + ...s, + messages: [] + })); + + state.projects = projects || []; + state.stats = stats || {}; +} + +/** + * Load full detail for a session: messages with inline tool_calls (each with + * result), summaries, subagents, and workflow data. + * + * Returns the assembled session object (also updates state.sessions entry). + */ +export async function loadSessionDetail(sessionId) { + const [messages, toolCalls, toolResults, subagents, workflows, summaries] = + await Promise.all([ + window.obelisk.getSessionMessages(sessionId), + window.obelisk.getSessionToolCalls(sessionId), + window.obelisk.getSessionToolResults(sessionId), + window.obelisk.getSessionSubagents(sessionId), + window.obelisk.getSessionWorkflows(sessionId), + window.obelisk.getSessionSummaries(sessionId) + ]); + + // Index tool results by tool_use_id for fast lookup + const resultsByCallId = {}; + for (const r of (toolResults || [])) { + resultsByCallId[r.tool_use_id] = r; + } + + // Index subagents by parent_tool_use_id + const subagentsByCallId = {}; + for (const sa of (subagents || [])) { + if (sa.parent_tool_use_id) { + subagentsByCallId[sa.parent_tool_use_id] = sa; + } + } + + // Group tool_calls by message_uuid, attaching result and subagent inline + const callsByMessageUuid = {}; + for (const tc of (toolCalls || [])) { + const call = { + id: tc.id, + name: tc.name, + input_json: tc.input_json, + result: resultsByCallId[tc.id] || null + }; + + // Attach subagent data if present + const sa = subagentsByCallId[tc.id]; + if (sa) { + call.subagent = { + agent_id: sa.agent_id, + agent_type: sa.agent_type, + description: sa.description + }; + } + + const msgUuid = tc.message_uuid; + if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; + callsByMessageUuid[msgUuid].push(call); + } + + // Attach workflow data to Workflow tool calls + for (const wf of (workflows || [])) { + for (const calls of Object.values(callsByMessageUuid)) { + for (const call of calls) { + if (call.name === 'Workflow' && !call.workflow) { + const resultText = call.result?.content || ''; + if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) { + call.workflow = { + run_id: wf.run_id, + workflow_name: wf.workflow_name, + status: wf.status, + duration_ms: wf.duration_ms, + total_tokens: wf.total_tokens, + agent_count: wf.agent_count, + agents: (wf.agents || []).map(a => ({ + agent_id: a.agent_id, + phase: a.phase, + label: a.label, + state: a.state, + tokens: a.tokens, + duration_ms: a.duration_ms, + })) + }; + } + } + } + } + } + + // Index summaries by session (summaries don't have per-message IDs in our schema) + const sessionSummaries = (summaries || []).map(s => ({ + source: s.source, + content: s.content, + timestamp: s.timestamp + })); + + // Assemble messages with tool_calls inline + const rawAssembled = (messages || []).map(msg => { + const assembled = { + uuid: msg.uuid, + type: msg.type || msg.role, + timestamp: msg.timestamp, + text: msg.text, + content_type: msg.content_type || null, + is_meta: msg.is_meta || 0 + }; + + const calls = callsByMessageUuid[msg.uuid]; + if (calls && calls.length > 0) { + assembled.tool_calls = calls; + } + + return assembled; + }); + + // Merge adjacent assistant messages: + // - tool_result user messages are skipped (results shown inside tool_call panels) + // - consecutive tool_use messages (separated by tool_results) merge into one + // - thinking messages merge into the next non-thinking assistant message + const assembledMessages = []; + for (let i = 0; i < rawAssembled.length; i++) { + const msg = rawAssembled[i]; + + // Skip tool_result user messages + if (msg.content_type === 'tool_result') continue; + + // For thinking messages, collect consecutive thinking blocks and attach to the next assistant + if (msg.type === 'assistant' && msg.content_type === 'thinking') { + const thinkingParts = [msg.text || '']; + let j = i + 1; + // Absorb consecutive thinking messages + while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { + thinkingParts.push(rawAssembled[j].text || ''); + j++; + } + // Find the next non-thinking assistant message to attach to + if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { + // Will be picked up by the next iteration; store thinking on it + rawAssembled[j]._thinking = thinkingParts.join('\n\n'); + i = j - 1; + continue; + } + // No following assistant message — render as standalone collapsed thinking + assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); + i = j - 1; + continue; + } + + // For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results) + if (msg.type === 'assistant' && msg.content_type === 'tool_use') { + const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; + if (msg._thinking) merged._thinking = msg._thinking; + let j = i + 1; + while (j < rawAssembled.length) { + const next = rawAssembled[j]; + if (next.content_type === 'tool_result') { j++; continue; } + if (next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); + if (next.text && !merged.text) merged.text = next.text; + j++; + continue; + } + break; + } + assembledMessages.push(merged); + i = j - 1; + } else { + // text or other assistant/user messages + const out = { ...msg }; + if (msg._thinking) out._thinking = msg._thinking; + assembledMessages.push(out); + } + } + + // Attach workflow data if present + const workflow = (workflows && workflows.length > 0) ? workflows[0] : null; + + // Build assembled session object + const session = state.sessions.find(s => s.id === sessionId); + const assembled = { + ...(session || {}), + id: sessionId, + messages: assembledMessages + }; + + if (workflow) { + assembled.workflow = workflow; + } + + // Update in-place in state.sessions + const idx = state.sessions.findIndex(s => s.id === sessionId); + if (idx !== -1) { + state.sessions[idx] = assembled; + } + + return assembled; +} + +/** + * Load full detail for a subagent conversation. + * Returns assembled messages with tool_calls inline. + */ +export async function loadSubagentDetail(agentId) { + const [messages, toolCalls, toolResults] = await Promise.all([ + window.obelisk.getSubagentMessages(agentId), + window.obelisk.getSubagentToolCalls(agentId), + window.obelisk.getSubagentToolResults(agentId), + ]); + + const resultsByCallId = {}; + for (const r of (toolResults || [])) { + resultsByCallId[r.tool_use_id] = r; + } + + const callsByMessageUuid = {}; + for (const tc of (toolCalls || [])) { + const call = { + id: tc.id, + name: tc.name, + input_json: tc.input_json, + result: resultsByCallId[tc.id] || null + }; + const msgUuid = tc.message_uuid; + if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; + callsByMessageUuid[msgUuid].push(call); + } + + const rawAssembled = (messages || []).map(msg => { + const assembled = { + uuid: msg.uuid, + type: msg.type || msg.role, + timestamp: msg.timestamp, + text: msg.text, + content_type: msg.content_type || null, + is_meta: msg.is_meta || 0 + }; + const calls = callsByMessageUuid[msg.uuid]; + if (calls && calls.length > 0) assembled.tool_calls = calls; + return assembled; + }); + + // Same merging logic as session detail + const assembledMessages = []; + for (let i = 0; i < rawAssembled.length; i++) { + const msg = rawAssembled[i]; + if (msg.content_type === 'tool_result') continue; + if (msg.type === 'assistant' && msg.content_type === 'thinking') { + const thinkingParts = [msg.text || '']; + let j = i + 1; + while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { + thinkingParts.push(rawAssembled[j].text || ''); + j++; + } + if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { + rawAssembled[j]._thinking = thinkingParts.join('\n\n'); + i = j - 1; + continue; + } + assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); + i = j - 1; + continue; + } + if (msg.type === 'assistant' && msg.content_type === 'tool_use') { + const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; + if (msg._thinking) merged._thinking = msg._thinking; + let j = i + 1; + while (j < rawAssembled.length) { + const next = rawAssembled[j]; + if (next.content_type === 'tool_result') { j++; continue; } + if (next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); + if (next.text && !merged.text) merged.text = next.text; + j++; + continue; + } + break; + } + assembledMessages.push(merged); + i = j - 1; + } else { + const out = { ...msg }; + if (msg._thinking) out._thinking = msg._thinking; + assembledMessages.push(out); + } + } + + return assembledMessages; +} + +const TEXT_LIMIT = 10000; + +/** + * Check if a message text was truncated during indexing. + */ +export function isTextTruncated(text) { + return text && text.length >= TEXT_LIMIT; +} + +/** + * Fetch the full untruncated text for a message from its source JSONL. + * Returns the full text string or null. + */ +export async function loadFullText(uuid) { + try { + return await window.obelisk.getMessageFullText(uuid); + } catch { + return null; + } +} + +/** + * Load the markdown content of a memory file. + * Returns the content string or null on failure. + */ +export async function loadMemoryMarkdown(memoryPath) { + try { + const content = await window.obelisk.readMemoryFile(memoryPath); + return content || null; + } catch { + return null; + } +} + +/** + * Archive a memory by id. Updates state after successful IPC call. + */ +export async function archiveMemory(id) { + await window.obelisk.archiveMemory(id); + const mem = state.memories.find(m => m.id === id); + if (mem) { + mem.archived = true; + mem.archivedAt = Date.now(); + } +} + +/** + * Restore an archived memory by id. Updates state after successful IPC call. + */ +export async function restoreMemory(id) { + await window.obelisk.restoreMemory(id); + const mem = state.memories.find(m => m.id === id); + if (mem) { + mem.archived = false; + mem.archivedAt = null; + } +} diff --git a/app/src/renderer/js/keys.js b/app/src/renderer/js/keys.js new file mode 100644 index 0000000..6b18101 --- /dev/null +++ b/app/src/renderer/js/keys.js @@ -0,0 +1,129 @@ +// Keyboard shortcut handling -- ported from the HTML mock. +// Registers a single document-level keydown listener that dispatches +// all navigation, mutation, and route-switching shortcuts. + +import { state, IS_MAC } from './state.js'; +import { + renderAll, + renderMemoryList, + renderSessionList, + renderStatus, + enterDetail, + exitDetail, + setRoute, + setView, + toggleSort, + moveCursor, + archive, + restore, + doUndo, + navigateToSession +} from './render.js'; + +export function initKeyboard() { + document.addEventListener('keydown', e => { + const inInput = + document.activeElement.tagName === 'INPUT' || + document.activeElement.tagName === 'TEXTAREA'; + const mod = IS_MAC ? e.metaKey : e.ctrlKey; + + // -- Route switching: Cmd+1/2/3/4 -- + if (mod && e.key === '1') { e.preventDefault(); setRoute('sessions'); return; } + if (mod && e.key === '2') { e.preventDefault(); setView('active'); return; } + if (mod && e.key === '3') { e.preventDefault(); setView('archived'); return; } + if (mod && e.key === '4') { e.preventDefault(); setView('broken'); return; } + + // -- Undo: Cmd+Z -- + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { + if (state.lastArchiveSnapshot) { e.preventDefault(); doUndo(); } + return; + } + + // -- When inside an input, only Escape is handled (to blur) -- + if (inInput) { + if (e.key === 'Escape') e.target.blur(); + return; + } + + // -- Search focus: / -- + if (e.key === '/') { + e.preventDefault(); + const searchInput = document.getElementById('search'); + if (searchInput) { searchInput.focus(); searchInput.select(); } + return; + } + + // -- Detail mode shortcuts -- + if (state.mode === 'detail') { + if (e.key === 'Escape') { e.preventDefault(); exitDetail(); return; } + if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) { + e.preventDefault(); + const m = state.memories.find(x => x.id === state.detailId); + if (m && m.archived) restore([m.id]); + else if (m) archive([m.id]); + return; + } + return; + } + + // -- List mode shortcuts -- + + // Navigation: j/k/arrows + if (e.key === 'j' || e.key === 'ArrowDown') { + e.preventDefault(); + if (state.route === 'memory') moveCursor(1, e.shiftKey); + return; + } + if (e.key === 'k' || e.key === 'ArrowUp') { + e.preventDefault(); + if (state.route === 'memory') moveCursor(-1, e.shiftKey); + return; + } + + // Open detail: Enter + if (e.key === 'Enter') { + e.preventDefault(); + if (state.route === 'memory' && state.cursorId) enterDetail(state.cursorId); + return; + } + + // Archive / Restore: d/D + if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) { + e.preventDefault(); + const ids = state.selection.size > 0 + ? Array.from(state.selection) + : state.cursorId ? [state.cursorId] : []; + if (!ids.length) return; + const m = state.memories.find(x => x.id === ids[0]); + if (state.view === 'archived' || (m && m.archived)) restore(ids); + else archive(ids); + return; + } + + // Undo: u + if (e.key === 'u') { + e.preventDefault(); + if (state.lastArchiveSnapshot) doUndo(); + return; + } + + // Sort toggle: s + if (e.key === 's') { e.preventDefault(); toggleSort(); return; } + + // Escape: clear selection or search + if (e.key === 'Escape') { + if (state.selection.size) { + state.selection.clear(); + renderMemoryList(); + renderStatus(); + } else if (state.query) { + state.query = ''; + const searchInput = document.getElementById('search'); + if (searchInput) searchInput.value = ''; + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); + } + return; + } + }); +} diff --git a/app/src/renderer/js/memory-list.js b/app/src/renderer/js/memory-list.js new file mode 100644 index 0000000..eddbf00 --- /dev/null +++ b/app/src/renderer/js/memory-list.js @@ -0,0 +1,269 @@ +// Memory list and detail rendering, extracted from render.js. + +import { state, FOLDER_SVG } from './state.js'; +import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js'; +import registry from './registry.js'; + +// --- Utilities (local copies to avoid importing render.js) --- + +function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } + +function pad2(n) { return String(n).padStart(2, '0'); } +function isSameDay(a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} +function fmtListTime(ts) { + const d = new Date(ts); + const now = new Date(); + const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + if (isSameDay(d, now)) return hhmm; + const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`; + if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`; + return `${d.getFullYear()}/${mmdd} ${hhmm}`; +} +function fmtRelative(ts) { + const diff = Date.now() - ts; + const min = 60000, hr = 3600000, day = 86400000; + if (diff < 0) return 'in the future'; + if (diff < min) return 'just now'; + if (diff < hr) return Math.floor(diff / min) + 'm ago'; + if (diff < day) return Math.floor(diff / hr) + 'h ago'; + if (diff < day * 30) return Math.floor(diff / day) + 'd ago'; + if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago'; + return Math.floor(diff / (day * 365)) + 'y ago'; +} + +function highlightPlain(text, query) { + if (!query) return escapeHTML(text); + const safe = escapeHTML(text); + const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return safe.replace(new RegExp(q, 'gi'), m => `${m}`); +} + +function sanitizeMarkdown(html) { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/\son\w+="[^"]*"/gi, ''); +} + +function highlightTextNodes(rootEl, query) { + if (!query) return; + const q = query.toLowerCase(); + const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + const text = node.nodeValue; + if (!text) continue; + const lower = text.toLowerCase(); + if (!lower.includes(q)) continue; + const frag = document.createDocumentFragment(); + let last = 0, i = lower.indexOf(q); + while (i !== -1) { + if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i))); + const mark = document.createElement('mark'); + mark.textContent = text.slice(i, i + q.length); + frag.appendChild(mark); + last = i + q.length; + i = lower.indexOf(q, last); + } + if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); + node.parentNode.replaceChild(frag, node); + } +} + +function renderMarkdown(text, opts = {}) { + if (text == null) return ''; + const html = sanitizeMarkdown(marked.parse(text)); + const cls = opts.variant === 'msg' ? 'markdown-msg' + : opts.variant === 'compact' ? 'markdown-compact' + : 'markdown-body'; + const container = document.createElement('div'); + container.className = cls; + container.innerHTML = html; + if (opts.query) highlightTextNodes(container, opts.query.trim()); + return container.outerHTML; +} + +// --- DOM helpers --- + +const $ = sel => document.querySelector(sel); + +function ensureVisible(el, wrapSel) { + const wrap = $(wrapSel); + if (!wrap || !el) return; + const elRect = el.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top); + else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10); +} + +// --- Data filtering (mirrors render.js) --- + +function dominantRowStatus(m) { + if (m.health === 'broken') return 'broken'; + if (m.health === 'partial') return 'partial'; + if (m.archived) return 'archived'; + return null; +} + +function statusGlyphHTML(status) { + if (!status) return ''; + const glyphs = { + broken: ``, + partial: ``, + archived: `` + }; + return `${glyphs[status] || ''}`; +} + +function formatProjectLabel(slug) { + if (!slug) return '(no project)'; + const session = state.sessions.find(s => s.project === slug && s.project_path); + if (session?.project_path) { + const parts = session.project_path.split('/'); + return parts.slice(-2).join('/'); + } + return slug.replace(/^-/, ''); +} + +export function visibleMemories() { + const q = state.query.trim().toLowerCase(); + return state.memories + .filter(m => { + if (state.view === 'archived') return m.archived; + return !m.archived; + }) + .filter(m => state.projectFilter === 'all' || m.project === state.projectFilter) + .filter(m => !q || (m.path || '').toLowerCase().includes(q) || (m.summary || '').toLowerCase().includes(q)) + .sort((a, b) => state.sortDesc ? b.ts - a.ts : a.ts - b.ts); +} + +// --- Memory list --- + +export function renderMemoryList() { + const items = visibleMemories(); + const list = $('#list'); + if (!list) return; + if (!items.length) { + list.innerHTML = `
No memories${state.view === 'archived' ? ' archived' : ''} here.${state.query ? 'Try a different search term.' : 'Press / to search.'}
`; + return; + } + list.innerHTML = items.map(m => renderMemoryRow(m)).join(''); + if (state.cursorId) { + const cursorEl = list.querySelector(`.row[data-id="${state.cursorId}"]`); + if (cursorEl) ensureVisible(cursorEl, '#list-wrap'); + } +} + +function renderMemoryRow(m) { + const isCursor = state.cursorId === m.id; + const isSelected = state.selection.has(m.id); + const q = state.query.trim(); + const showProjectPrefix = state.projectFilter === 'all'; + const status = dominantRowStatus(m); + const actionLabel = m.archived + ? `` + : ``; + return ` +
+ +
+
+ ${statusGlyphHTML(status)} + ${showProjectPrefix ? `${escapeHTML(formatProjectLabel(m.project))}/` : ''} + ${highlightPlain(m.path || '', q)} +
+
${highlightPlain(m.summary || '', q)}
+
+
+
${fmtListTime(m.ts)}
+
${actionLabel}
+
+
+ `; +} + +// --- Memory detail --- + +export async function renderMemoryDetail() { + const m = state.memories.find(x => x.id === state.detailId); + if (!m) return; + const detail = $('#detail'); + if (!detail) return; + + // Load markdown on demand + if (m.markdown === null && m.path) { + m.markdown = await loadMemoryMarkdown(m.path); + } + + const provenanceHTML = ` +
+ ${m.session_id ? `` : ''} + ${fmtRelative(m.ts)} +
+ `; + + let markdownHTML; + if (m.markdown == null) { + markdownHTML = `
File not found or empty.
`; + } else if (state.showSource) { + markdownHTML = `
${escapeHTML(m.markdown)}
`; + } else { + markdownHTML = renderMarkdown(m.markdown, { variant: 'body' }); + } + + detail.innerHTML = ` +
+
+ ${FOLDER_SVG} + ${escapeHTML(formatProjectLabel(m.project))} + ${m.archived ? 'archived' : ''} +
+
${escapeHTML(m.path)}
+
${escapeHTML(m.summary)}
+ ${provenanceHTML} +
+
+
+ Body + +
+ ${markdownHTML} +
+
+ + +
+ `; + + // Wire event listeners via registry (avoids circular imports) + $('#detail-back')?.addEventListener('click', () => { + if (registry.exitDetail) registry.exitDetail(); + }); + $('#detail-archive')?.addEventListener('click', () => { + if (m.archived) { if (registry.restore) registry.restore([m.id]); } + else { if (registry.archive) registry.archive([m.id]); } + }); + detail.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', e => { + e.stopPropagation(); + if (el.dataset.action === 'toggle-source') { + state.showSource = !state.showSource; + renderMemoryDetail(); + } else if (el.dataset.action === 'open-session' && el.dataset.session) { + if (registry.navigateToSession) registry.navigateToSession(el.dataset.session, null); + } + }); + }); +} diff --git a/app/src/renderer/js/registry.js b/app/src/renderer/js/registry.js new file mode 100644 index 0000000..5fb5f75 --- /dev/null +++ b/app/src/renderer/js/registry.js @@ -0,0 +1,3 @@ +// Shared registry to break circular dependencies between modules. +const registry = {}; +export default registry; diff --git a/app/src/renderer/js/render.js b/app/src/renderer/js/render.js new file mode 100644 index 0000000..00c409e --- /dev/null +++ b/app/src/renderer/js/render.js @@ -0,0 +1,246 @@ +// Rendering coordinator -- thin orchestration layer. +// Delegates to extracted modules; keeps only cross-module functions locally. + +import { state } from './state.js'; +import { archiveMemory, restoreMemory } from './data.js'; +import registry from './registry.js'; + +// --- Module imports --- +import { escapeHTML, $ } from './utils.js'; +import { renderSidebar, renderBreadcrumb, updateWindowTitle } from './sidebar.js'; +import { visibleMemories as _visibleMemories, renderMemoryList, renderMemoryDetail } from './memory-list.js'; +import { renderSessionList, renderSessionDetail } from './session-list.js'; +import { renderUsage } from './usage.js'; + +// --- Data filtering (coordinator owns the cross-module view) --- + +export function visibleMemories() { return _visibleMemories(); } + +export function visibleSessions() { + const q = state.query.trim().toLowerCase(); + return state.sessions + .filter(s => state.projectFilter === 'all' || s.project === state.projectFilter) + .map(s => { + if (!q) return { ...s, messageHit: null }; + const topMatch = (s.title || '').toLowerCase().includes(q) || + (s.project || '').toLowerCase().includes(q) || + (s.git_branch || '').toLowerCase().includes(q); + if (topMatch) return { ...s, messageHit: null }; + return null; + }) + .filter(Boolean) + .sort((a, b) => { + const ta = new Date(a.started_at || 0).getTime(); + const tb = new Date(b.started_at || 0).getTime(); + return state.sortDesc ? tb - ta : ta - tb; + }); +} + +// --- Cursor / selection --- + +export function flatList() { return visibleMemories(); } + +export function cursorIndex() { + const flat = flatList(); + if (!state.cursorId) return -1; + return flat.findIndex(m => m.id === state.cursorId); +} + +export function moveCursor(delta, extendSelection = false) { + const flat = flatList(); + if (!flat.length) return; + let idx = cursorIndex(); + if (idx === -1) idx = 0; + else idx = Math.max(0, Math.min(flat.length - 1, idx + delta)); + const newId = flat[idx].id; + if (extendSelection) { state.selection.add(state.cursorId); state.selection.add(newId); } + state.cursorId = newId; + renderMemoryList(); renderStatus(); +} + +export function setCursor(id, opts = {}) { + state.cursorId = id; + if (!opts.keepSelection) state.selection.clear(); + renderMemoryList(); renderStatus(); +} + +// --- Mutations --- + +export function archive(ids) { if (!ids.length) return; doMutation(ids, true); } +export function restore(ids) { if (!ids.length) return; doMutation(ids, false); } + +async function doMutation(ids, toArchived) { + for (const id of ids) { + if (toArchived) await archiveMemory(id); + else await restoreMemory(id); + } + state.selection = new Set(); + if (state.mode === 'detail' && state.route === 'memory' && ids.includes(state.detailId)) exitDetail(); + const flat = flatList(); + if (state.cursorId && !flat.find(m => m.id === state.cursorId)) state.cursorId = flat[0]?.id ?? null; + state.lastArchiveSnapshot = ids; + state.undoExpires = Date.now() + 5000; + clearInterval(state.undoTimer); + state.undoTimer = setInterval(() => { + if (Date.now() >= state.undoExpires) { state.lastArchiveSnapshot = null; clearInterval(state.undoTimer); } + renderStatus(); + }, 500); + renderAll(); +} + +export async function doUndo() { + if (!state.lastArchiveSnapshot) return; + for (const id of state.lastArchiveSnapshot) { + const m = state.memories.find(x => x.id === id); + if (m) { + if (m.archived) await restoreMemory(id); + else await archiveMemory(id); + } + } + state.lastArchiveSnapshot = null; + clearInterval(state.undoTimer); + renderAll(); +} + +// --- Navigation --- + +export function navigateToSession(sessionId, focusUuid) { + state.route = 'sessions'; state.mode = 'detail'; + state.detailId = sessionId; + state.subagentId = null; + state.subagentDescription = null; + state.pendingFocusUuid = focusUuid || null; + state.query = ''; + const searchEl = $('#search'); + if (searchEl) searchEl.value = ''; + switchView(); renderAll(); +} + +export function navigateToSubagent(agentId, description) { + state.subagentId = agentId; + state.subagentDescription = description || agentId; + switchView(); renderAll(); +} + +export function enterDetail(id) { state.detailId = id; state.mode = 'detail'; state.showSource = false; switchView(); renderAll(); } + +export function exitDetail() { + if (state.subagentId) { + state.subagentId = null; + state.subagentDescription = null; + switchView(); renderAll(); + return; + } + state.mode = 'list'; state.detailId = null; state.pendingFocusUuid = null; switchView(); renderAll(); +} + +export function setRoute(route) { + state.route = route; state.mode = 'list'; state.detailId = null; + state.cursorId = null; state.selection.clear(); + state.query = ''; const s = $('#search'); if (s) s.value = ''; + switchView(); renderAll(); + if (route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; } +} + +export function setView(v) { + state.route = 'memory'; state.view = v; state.mode = 'list'; state.detailId = null; + state.cursorId = null; state.selection.clear(); state.projectFilter = 'all'; + switchView(); renderAll(); + const flat = visibleMemories(); + if (flat.length) state.cursorId = flat[0].id; + renderMemoryList(); +} + +export function setProject(p) { + state.projectFilter = p; state.cursorId = null; state.selection.clear(); + state.mode = 'list'; state.detailId = null; + switchView(); renderAll(); + if (state.route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; } +} + +export function toggleSort() { + state.sortDesc = !state.sortDesc; + const btn = $('#sort-toggle'); + if (btn) { btn.classList.toggle('desc', state.sortDesc); btn.classList.toggle('asc', !state.sortDesc); } + const lbl = $('#sort-label'); + if (lbl) lbl.textContent = state.sortDesc ? 'newest' : 'oldest'; + if (state.route === 'sessions') renderSessionList(); + else renderMemoryList(); +} + +export function switchView() { + const showList = state.mode === 'list'; + const showSessions = state.route === 'sessions'; + const showUsage = state.route === 'usage'; + const inSessionDetail = !showList && showSessions; + const inSubagent = inSessionDetail && !!state.subagentId; + const el = (id, show) => { const e = $(id); if (e) e.style.display = show ? '' : 'none'; }; + el('#list-wrap', showList && !showSessions && !showUsage); + el('#detail-wrap', !showList && !showSessions && !showUsage); + el('#session-list-wrap', showList && showSessions); + el('#session-detail-wrap', inSessionDetail && !inSubagent); + el('#subagent-detail-wrap', inSubagent); + el('#usage-wrap', showUsage); + el('#search-wrap', showList && !showUsage); + el('#sort-toggle', showList && !showUsage); + el('#search-msgs-toggle', showList && showSessions); +} + +// --- Status bar --- + +export function renderStatus() { + const left = $('#status-left'); + const right = $('#status-right'); + if (!left || !right) return; + + if (state.route === 'sessions' && state.mode === 'list') { + right.innerHTML = ` open/ search`; + } else if (state.route === 'sessions' && state.mode === 'detail') { + right.innerHTML = `Esc back`; + } else if (state.mode === 'detail') { + right.innerHTML = `Esc backD archive`; + } else { + right.innerHTML = `↑↓ nav openD archive/ search`; + } + + if (state.lastArchiveSnapshot && state.undoExpires > Date.now()) { + const ids = state.lastArchiveSnapshot; + const secs = Math.ceil((state.undoExpires - Date.now()) / 1000); + const target = ids.length === 1 ? (state.memories.find(x => x.id === ids[0])?.path || '').split('/').pop() : `${ids.length} memories`; + left.innerHTML = `Action pending ${escapeHTML(target)}${secs}s`; + $('#undo-btn')?.addEventListener('click', doUndo); + return; + } + left.textContent = ''; +} + +// --- Master render --- + +export function renderAll() { + renderSidebar(); + renderBreadcrumb(); + switchView(); + if (state.route === 'usage') { + renderUsage(); + } else if (state.route === 'sessions') { + if (state.mode === 'list') renderSessionList(); + else renderSessionDetail(); + } else { + if (state.mode === 'list') renderMemoryList(); + else renderMemoryDetail(); + } + renderStatus(); + updateWindowTitle(); +} + +// --- Registry (break circular deps for child modules) --- + +registry.navigateToSession = navigateToSession; +registry.navigateToSubagent = navigateToSubagent; +registry.exitDetail = exitDetail; +registry.archive = archive; +registry.restore = restore; + +// --- Re-exports for app.js and keys.js --- + +export { renderMemoryList, renderSessionList, escapeHTML }; diff --git a/app/src/renderer/js/session-list.js b/app/src/renderer/js/session-list.js new file mode 100644 index 0000000..07acf75 --- /dev/null +++ b/app/src/renderer/js/session-list.js @@ -0,0 +1,530 @@ +// Session list and detail rendering module. +// Extracted from render.js -- all session/subagent DOM generation. + +import { state } from './state.js'; +import { loadSessionDetail, loadSubagentDetail, isTextTruncated, loadFullText } from './data.js'; +import { escapeHTML, highlightPlain, fmtListTime, fmtRelative, fmtClockTime, renderMarkdown, formatProjectLabel, $ } from './utils.js'; +import { FOLDER_SVG } from './state.js'; +import registry from './registry.js'; + +// --- Session list --- + +export function renderSessionList() { + const items = visibleSessions(); + const list = $('#session-list'); + if (!list) return; + if (!items.length) { + list.innerHTML = `
No sessions here.${state.query ? 'Try a different search term.' : 'Press / to search.'}
`; + return; + } + list.innerHTML = items.map(s => renderSessionRow(s)).join(''); +} + +function renderSessionRow(s) { + const q = state.query.trim(); + const showProjectPrefix = state.projectFilter === 'all'; + const startedTs = new Date(s.started_at || 0).getTime(); + return ` +
+
+
${highlightPlain(s.title || '(untitled)', q)}
+
+ ${showProjectPrefix ? `${escapeHTML(formatProjectLabel(s.project))}` : ''} + ${s.message_count || 0} msg +
+
+
${fmtListTime(startedTs)}
+
+ `; +} + +// --- Session detail --- + +export async function renderSessionDetail() { + // If viewing a subagent, render that instead + if (state.subagentId) { + return renderSubagentDetail(); + } + + const s = state.sessions.find(x => x.id === state.detailId); + if (!s) return; + const detail = $('#session-detail'); + if (!detail) return; + const wrap = $('#session-detail-wrap'); + + // If DOM was already built for this session, skip rebuild + if (detail.dataset.renderedSession === state.detailId) { + return; + } + + // Load messages on demand + if (!s.messages || s.messages.length === 0) { + const loaded = await loadSessionDetail(s.id); + if (loaded) Object.assign(s, loaded); + } + + const startedTs = new Date(s.started_at || 0).getTime(); + const headerHTML = ` +
+
+ ${FOLDER_SVG} + ${escapeHTML(formatProjectLabel(s.project))} + · + ${escapeHTML(s.project_path || '')} +
+
${escapeHTML(s.title || '(untitled)')}
+
+ ${fmtRelative(startedTs)} + + ${s.message_count || 0} messages + ${s.git_branch ? `${escapeHTML(s.git_branch)}` : ''} +
+
+ `; + + const messagesHTML = (s.messages || []).map((msg, idx) => renderMessage(msg, idx)).join(''); + detail.innerHTML = `
${headerHTML}
${messagesHTML}
`; + detail.dataset.renderedSession = state.detailId; + + // Progress bar: track scroll position relative to messages + const progressFill = detail.querySelector('#session-progress-fill'); + if (wrap && progressFill) { + const updateProgress = () => { + const msgs = detail.querySelectorAll('.msg, .wf-card'); + if (!msgs.length) return; + const wrapTop = wrap.getBoundingClientRect().top; + let topMsgIdx = 0; + for (let i = 0; i < msgs.length; i++) { + if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i; + else break; + } + const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100); + progressFill.style.width = pct + '%'; + + // Show/hide back-to-top button + const topBtn = detail.querySelector('#back-to-top'); + if (topBtn) topBtn.classList.toggle('show', wrap.scrollTop > 300); + }; + wrap.addEventListener('scroll', updateProgress); + updateProgress(); + } + + // Back to top button + const topBtn = document.createElement('button'); + topBtn.id = 'back-to-top'; + topBtn.className = 'back-to-top'; + topBtn.innerHTML = ``; + topBtn.addEventListener('click', () => { if (wrap) wrap.scrollTo({ top: 0, behavior: 'smooth' }); }); + detail.appendChild(topBtn); + + // Wire up tool call toggles + detail.querySelectorAll('.toolcall-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); }); + }); + detail.querySelectorAll('.summary-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); }); + }); + detail.querySelectorAll('.thinking-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); }); + }); + detail.querySelectorAll('.meta-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); }); + }); + detail.querySelectorAll('.truncated-btn').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const uuid = btn.dataset.uuid; + btn.textContent = 'Loading…'; + const fullText = await loadFullText(uuid); + if (fullText) { + const msgEl = btn.closest('.msg'); + const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact'); + const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg'; + const rendered = renderMarkdown(fullText, { variant, query: state.query }); + if (bodyEl) bodyEl.outerHTML = rendered; + btn.remove(); + } else { + btn.textContent = 'Failed to load full text'; + } + }); + }); + + // Subagent navigation + detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc); + }); + }); + + // Focus on pending message + if (state.pendingFocusUuid) { + const targetUuid = state.pendingFocusUuid; + requestAnimationFrame(() => { + const target = detail.querySelector(`.msg[data-uuid="${targetUuid}"]`); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + requestAnimationFrame(() => { + target.classList.add('is-focused'); + setTimeout(() => target.classList.remove('is-focused'), 1200); + }); + } + }); + state.pendingFocusUuid = null; + } +} + +async function renderSubagentDetail() { + const detail = $('#subagent-detail'); + if (!detail) return; + const wrap = $('#subagent-detail-wrap'); + if (wrap) wrap.scrollTop = 0; + + const messages = await loadSubagentDetail(state.subagentId); + + const headerHTML = ` +
+
+ SUBAGENT +
+
${escapeHTML(state.subagentDescription || state.subagentId)}
+
+ ${messages.length} messages +
+
+ `; + + const messagesHTML = messages.map((msg, idx) => { + return renderMessage(msg, idx, { isSubagent: true }); + }).join(''); + + detail.innerHTML = `
${headerHTML}
${messagesHTML}
`; + + // Wire up toggles + detail.querySelectorAll('.toolcall-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); }); + }); + detail.querySelectorAll('.summary-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); }); + }); + detail.querySelectorAll('.thinking-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); }); + }); + detail.querySelectorAll('.meta-toggle').forEach(btn => { + btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); }); + }); + detail.querySelectorAll('.truncated-btn').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const uuid = btn.dataset.uuid; + btn.textContent = 'Loading…'; + const fullText = await loadFullText(uuid); + if (fullText) { + const msgEl = btn.closest('.msg'); + const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact'); + const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg'; + const rendered = renderMarkdown(fullText, { variant, query: state.query }); + if (bodyEl) bodyEl.outerHTML = rendered; + btn.remove(); + } else { + btn.textContent = 'Failed to load full text'; + } + }); + }); + // Nested subagent navigation + detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc); + }); + }); + + // Progress bar + const progressFill = detail.querySelector('#session-progress-fill'); + if (wrap && progressFill) { + const updateProgress = () => { + const msgs = detail.querySelectorAll('.msg'); + if (!msgs.length) return; + const wrapTop = wrap.getBoundingClientRect().top; + let topMsgIdx = 0; + for (let i = 0; i < msgs.length; i++) { + if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i; + else break; + } + const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100); + progressFill.style.width = pct + '%'; + }; + wrap.addEventListener('scroll', updateProgress); + updateProgress(); + } +} + +function renderMessage(msg, idx, opts = {}) { + const isUser = msg.type === 'user'; + const isThinking = msg.content_type === 'thinking'; + const isMeta = msg.is_meta === 1; + const tools = (msg.tool_calls || []).map(renderToolCall).join(''); + + // In subagent context, all user text messages are prompts (from main agent or human) + let roleLabel = isUser ? 'You' : 'Assistant'; + if (opts.isSubagent && isUser) { + roleLabel = 'Prompt'; + } + + // Meta messages: collapsed by default, shown as a small system indicator + if (isMeta) { + const preview = (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80); + const truncated = isTextTruncated(msg.text); + return ` +
+
+ +
+ ${renderMarkdown(msg.text, { variant: 'compact', query: state.query })} + ${truncated ? `` : ''} +
+
+
+ `; + } + + // Workflow as standalone card (not inside assistant bubble) + const workflowCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); + if (workflowCall && !isUser) { + const wf = workflowCall.workflow; + const wfName = wf.workflow_name || 'Workflow'; + const agents = wf.agents || []; + + const phases = {}; + for (const a of agents) { + const phase = a.phase || 'Other'; + if (!phases[phase]) phases[phase] = []; + phases[phase].push(a); + } + + const phasesHTML = Object.entries(phases).map(([phase, agentList]) => ` +
+
${escapeHTML(phase)}
+ ${agentList.map(a => ` + + `).join('')} +
+ `).join(''); + + // Render other tool calls (non-workflow) if any + const otherTools = (msg.tool_calls || []).filter(tc => tc !== workflowCall).map(renderToolCall).join(''); + + return ` +
+
+ + ${escapeHTML(wfName)} + ${agents.length} agents + ${wf.status ? `${escapeHTML(wf.status)}` : ''} +
+
${phasesHTML}
+
+ ${otherTools ? `
${otherTools}
` : ''} + `; + } + + // Standalone thinking message (no following assistant to attach to) + if (isThinking) { + return ` +
+
+ +
${renderMarkdown(msg.text, { variant: 'msg', query: state.query })}
+
+
+ `; + } + + // Thinking block attached to this message (merged from preceding thinking messages) + let thinkingHTML = ''; + if (msg._thinking) { + thinkingHTML = ` +
+ +
${renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })}
+
+ `; + } + + const truncated = isTextTruncated(msg.text); + let textHTML = msg.text ? renderMarkdown(msg.text, { variant: 'msg', query: state.query }) : (tools ? '' : '
(no text content)
'); + if (truncated) { + textHTML += ``; + } + + let summaryHTML = ''; + if (msg.summary) { + summaryHTML = ` +
+ +
${renderMarkdown(msg.summary.content, { variant: 'compact' })}
+
+ `; + } + + return ` +
+
+ ${roleLabel} + ${msg.timestamp ? fmtClockTime(msg.timestamp) : ''} +
+ ${thinkingHTML} + ${textHTML} + ${tools ? `
${tools}
` : ''} + ${summaryHTML} +
+ `; +} + +function renderToolCall(tc) { + const isError = tc.result && tc.result.is_error; + + // Special rendering for Agent/Task tool calls (subagents) + if (tc.name === 'Agent' || tc.name === 'Task') { + let parsed = {}; + try { parsed = JSON.parse(tc.input_json || '{}'); } catch {} + const agentType = parsed.subagent_type || parsed.agentType || 'Agent'; + const description = parsed.description || parsed.prompt?.slice(0, 80) || ''; + const resultContent = tc.result?.content || ''; + const subagentId = tc.subagent?.agent_id || null; + + return ` +
+ ` : ''} + +
+ ${parsed.prompt ? `
Prompt
${escapeHTML(parsed.prompt.slice(0, 500))}${parsed.prompt.length > 500 ? '…' : ''}
` : ''} + ${resultContent ? `
Result
${renderMarkdown(resultContent, { variant: 'compact' })}
` : ''} +
+
+ `; + } + + // Special rendering for Workflow tool calls + if (tc.name === 'Workflow') { + let parsed = {}; + try { parsed = JSON.parse(tc.input_json || '{}'); } catch {} + const wf = tc.workflow; + const wfName = wf?.workflow_name || parsed.name || 'Workflow'; + const wfStatus = wf?.status || ''; + const agents = wf?.agents || []; + + // Group agents by phase + const phases = {}; + for (const a of agents) { + const phase = a.phase || 'Other'; + if (!phases[phase]) phases[phase] = []; + phases[phase].push(a); + } + + const phasesHTML = Object.entries(phases).map(([phase, agentList]) => ` +
+
${escapeHTML(phase)}
+
+ ${agentList.map(a => ` + + `).join('')} +
+
+ `).join(''); + + const agentListHTML = agents.length ? ` +
Agents · ${agents.length}
+
${phasesHTML}
+ ` : ''; + + return ` +
+ +
+ ${agentListHTML} +
+
+ `; + } + + let argPreview = ''; + try { + const j = JSON.parse(tc.input_json || '{}'); + if (j.file_path) argPreview = j.file_path; + else if (j.command) argPreview = j.command; + else if (j.path) argPreview = j.path; + else if (j.description) argPreview = j.description; + else argPreview = JSON.stringify(j).slice(0, 100); + } catch { argPreview = (tc.input_json || '').slice(0, 100); } + + return ` +
+ +
+
Input
+
${escapeHTML(tc.input_json || '')}
+ ${tc.result ? `
${isError ? 'Error' : 'Output'}
${escapeHTML(tc.result.content || '(empty)')}
` : ''} +
+
+ `; +} + +// --- Private helper: visibleSessions (same logic as render.js) --- + +function visibleSessions() { + const q = state.query.trim().toLowerCase(); + return state.sessions + .filter(s => state.projectFilter === 'all' || s.project === state.projectFilter) + .map(s => { + if (!q) return { ...s, messageHit: null }; + const topMatch = (s.title || '').toLowerCase().includes(q) || + (s.project || '').toLowerCase().includes(q) || + (s.git_branch || '').toLowerCase().includes(q); + if (topMatch) return { ...s, messageHit: null }; + return null; + }) + .filter(Boolean) + .sort((a, b) => { + const ta = new Date(a.started_at || 0).getTime(); + const tb = new Date(b.started_at || 0).getTime(); + return state.sortDesc ? tb - ta : ta - tb; + }); +} diff --git a/app/src/renderer/js/sidebar.js b/app/src/renderer/js/sidebar.js new file mode 100644 index 0000000..13aca10 --- /dev/null +++ b/app/src/renderer/js/sidebar.js @@ -0,0 +1,137 @@ +// Sidebar, breadcrumb, and window title rendering. +// Extracted from render.js for modularity. + +import { state, FOLDER_SVG } from './state.js'; +import { $, $$, escapeHTML, formatProjectLabel } from './utils.js'; + +// --- Data helpers (sidebar-local) --- + +function projectCountsForCurrentRoute() { + const counts = {}; + if (state.route === 'sessions') { + for (const s of state.sessions) if (s.project) counts[s.project] = (counts[s.project] || 0) + 1; + } else { + for (const m of state.memories) { + const matches = state.view === 'archived' ? m.archived : !m.archived; + if (!matches) continue; + if (m.project) counts[m.project] = (counts[m.project] || 0) + 1; + } + } + return counts; +} + +// --- Sidebar --- + +export function renderSidebar() { + const activeCount = state.memories.filter(m => !m.archived).length; + const archivedCount = state.memories.filter(m => m.archived).length; + const el = id => $(id); + if (el('#count-sessions')) el('#count-sessions').textContent = state.sessions.length; + if (el('#count-memory-total')) el('#count-memory-total').textContent = activeCount + archivedCount; + if (el('#count-active')) el('#count-active').textContent = activeCount; + if (el('#count-archived')) el('#count-archived').textContent = archivedCount; + + $$('.sidebar-item').forEach(item => { + let isActive = false; + if (item.dataset.route === 'sessions' && state.route === 'sessions' && state.projectFilter === 'all') isActive = true; + else if (item.dataset.route === 'usage' && state.route === 'usage') isActive = true; + else if (item.dataset.route === 'memory' && item.dataset.view === state.view && state.projectFilter === 'all') isActive = true; + else if (item.dataset.project && item.dataset.project === state.projectFilter) isActive = true; + if (item.dataset.route === 'memory' && !item.classList.contains('sub')) isActive = false; + item.classList.toggle('active', isActive); + }); + + const counts = projectCountsForCurrentRoute(); + let projects = [...new Set( + (state.route === 'sessions' ? state.sessions : state.memories) + .filter(item => { + if (state.route === 'sessions') return true; + return state.view === 'archived' ? item.archived : !item.archived; + }) + .map(item => item.project) + .filter(Boolean) + )]; + if (state.projectSearch) { + const q = state.projectSearch.toLowerCase(); + projects = projects.filter(p => p.toLowerCase().includes(q)); + } + projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b))); + const projectsEl = $('#sidebar-projects'); + if (projectsEl) { + projectsEl.innerHTML = projects.map(p => ` + + `).join('') || `
No projects
`; + } +} + +// --- Breadcrumb --- + +export function renderBreadcrumb() { + const bc = $('#breadcrumb'); + if (!bc) return; + if (state.route === 'sessions') { + if (state.mode === 'detail') { + const s = state.sessions.find(x => x.id === state.detailId); + if (!s) return; + if (state.subagentId) { + bc.innerHTML = `//${escapeHTML((state.subagentDescription || '').slice(0, 40))}`; + } else { + bc.innerHTML = `/${escapeHTML(s.title || s.id)}`; + } + } else { + let html = ``; + if (state.projectFilter !== 'all') html += `/${escapeHTML(formatProjectLabel(state.projectFilter))}`; + bc.innerHTML = html; + } + } else if (state.route === 'usage') { + bc.innerHTML = `Usage`; + } else { + if (state.mode === 'detail') { + const m = state.memories.find(x => x.id === state.detailId); + if (!m) return; + bc.innerHTML = `/${escapeHTML(m.path.split('/').pop())}`; + } else { + let html = ``; + if (state.projectFilter !== 'all') html += `/${escapeHTML(formatProjectLabel(state.projectFilter))}`; + bc.innerHTML = html; + } + } +} + +// --- Window title --- + +export function updateWindowTitle() { + const appName = 'Obelisk'; + let scopeText = ''; + if (state.route === 'usage') { + scopeText = 'Usage'; + } else if (state.route === 'sessions') { + if (state.mode === 'detail') { + const s = state.sessions.find(x => x.id === state.detailId); + scopeText = s ? `Sessions · ${s.title}` : 'Sessions'; + } else { + const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : ''; + scopeText = `Sessions${proj}`; + } + } else { + if (state.mode === 'detail') { + const m = state.memories.find(x => x.id === state.detailId); + scopeText = m ? `Memory · ${m.path.split('/').pop()}` : 'Memory'; + } else { + const viewLabel = state.view === 'archived' ? 'Archived' : 'Active'; + const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : ''; + scopeText = `Memory · ${viewLabel}${proj}`; + } + } + const titleEl = $('#titlebar-text'); + if (titleEl) { + const truncated = scopeText.length > 50 ? scopeText.slice(0, 50) + '…' : scopeText; + titleEl.innerHTML = `${appName}${escapeHTML(truncated)}`; + titleEl.title = `${appName} — ${scopeText}`; + } + document.title = `${appName} — ${scopeText}`; +} diff --git a/app/src/renderer/js/state.js b/app/src/renderer/js/state.js new file mode 100644 index 0000000..5588778 --- /dev/null +++ b/app/src/renderer/js/state.js @@ -0,0 +1,34 @@ +// App state -- single source of truth for the renderer process. +// Loaded collections (memories, sessions, projects) start empty and are +// populated from the DB at boot. + +export const state = { + memories: [], + sessions: [], + projects: [], + route: 'memory', + view: 'active', // 'active' | 'archived' + mode: 'list', // 'list' | 'detail' + detailId: null, + subagentId: null, + subagentDescription: null, + pendingFocusUuid: null, + query: '', + projectFilter: 'all', + projectSearch: '', + sortDesc: true, + includeMessageBodies: false, + cursorId: null, + selection: new Set(), + showSource: false, + lastArchiveSnapshot: null, + undoTimer: null, + undoExpires: 0 +}; + +// Platform detection +export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); + +// SVG icon constants +export const FOLDER_SVG = ``; +export const FILE_SVG = ``; diff --git a/app/src/renderer/js/usage.js b/app/src/renderer/js/usage.js new file mode 100644 index 0000000..96e9627 --- /dev/null +++ b/app/src/renderer/js/usage.js @@ -0,0 +1,573 @@ +// Usage rendering module -- heatmap, weekly chart, cumulative chart. +// Extracted from render.js with identical logic. + +import { state } from './state.js'; +import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js'; +import registry from './registry.js'; + +function navigateToSession(sessionId, focusUuid) { + if (registry.navigateToSession) { + registry.navigateToSession(sessionId, focusUuid); + } +} + +export async function renderUsage() { + const usage = $('#usage'); + if (!usage) return; + + const data = await window.obelisk.getUsageStats(); + const { daily, totalTokens, peakDay, longestTurn } = data; + + // Build heatmap: 52 weeks x 7 days grid + const today = new Date(); + const dayMs = 86400000; + // Start from the first Sunday on or after 364 days ago (full weeks only) + let startDate = new Date(today.getTime() - 364 * dayMs); + startDate.setHours(0, 0, 0, 0); + const daysUntilSunday = (7 - startDate.getDay()) % 7; + startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); + + const dailyMap = {}; + for (const d of daily) dailyMap[d.day] = d.tokens; + + const values = daily.map(d => d.tokens).filter(Boolean); + const maxTokens = Math.max(...values, 1); + + // Generate cells (startDate is always a Sunday now) + const cells = []; + for (let i = 0; i < 371; i++) { + const date = new Date(startDate.getTime() + i * dayMs); + if (date > today) break; + const key = date.toISOString().slice(0, 10); + const tokens = dailyMap[key] || 0; + const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4)); + const col = Math.floor(i / 7); + const row = i % 7; + cells.push({ key, tokens, level, col, row, date }); + } + + const maxCol = cells.length ? cells[cells.length - 1].col : 0; + const cellSize = 11; + const cellGap = 2; + const step = cellSize + cellGap; + const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label + const gridHeight = 7 * step; + + const cellsHTML = cells.map(c => { + const x = c.col * step; + const y = c.row * step; + return ``; + }).join(''); + + // Month labels + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const monthLabels = []; + let lastMonth = -1; + for (const c of cells) { + const m = c.date.getMonth(); + if (m !== lastMonth && c.row === 0) { + monthLabels.push({ col: c.col, label: months[m] }); + lastMonth = m; + } + } + const monthLabelsHTML = monthLabels.map(m => + `${m.label}` + ).join(''); + + // Streak calculation — check gaps between consecutive active days + let longestStreak = 0; + let streak = 0; + const sortedDays = [...daily].filter(d => d.tokens > 0).sort((a, b) => a.day.localeCompare(b.day)); + for (let i = 0; i < sortedDays.length; i++) { + if (i === 0) { + streak = 1; + } else { + const prev = new Date(sortedDays[i - 1].day).getTime(); + const curr = new Date(sortedDays[i].day).getTime(); + if (curr - prev === dayMs) { + streak++; + } else { + streak = 1; + } + } + if (streak > longestStreak) longestStreak = streak; + } + // Current streak: find the most recent active day, then count consecutive days backwards + let currentStreak = 0; + let startedCounting = false; + for (let i = 0; i <= 365; i++) { + const d = new Date(today.getTime() - i * dayMs).toISOString().slice(0, 10); + if (dailyMap[d] && dailyMap[d] > 0) { + startedCounting = true; + currentStreak++; + } else if (startedCounting) { + break; + } + } + + usage.innerHTML = ` +
+ Token activity +
+ + + +
+
+ +
+
+ ${fmtTokens(totalTokens)} + Lifetime tokens +
+
+ ${peakDay ? fmtTokens(peakDay.tokens) : '—'} + Peak tokens +
+
+ ${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'} + Longest task +
+
+ ${currentStreak}d + Current streak +
+
+ ${longestStreak}d + Longest streak +
+
+ +
+ + ${cellsHTML} + ${monthLabelsHTML} + +
+ Less + + More +
+
+ +
+ `; + + // Heatmap tooltip + const heatmapTooltip = document.createElement('div'); + heatmapTooltip.className = 'chart-tooltip'; + usage.appendChild(heatmapTooltip); + usage.querySelectorAll('.heatmap-cell[data-label]').forEach(cell => { + cell.addEventListener('mouseenter', () => { + heatmapTooltip.textContent = cell.dataset.label; + heatmapTooltip.classList.add('show'); + }); + cell.addEventListener('mousemove', e => { + positionTooltip(heatmapTooltip, e.clientX, e.clientY); + }); + cell.addEventListener('mouseleave', () => heatmapTooltip.classList.remove('show')); + cell.addEventListener('click', () => { + usage.querySelectorAll('.heatmap-cell.selected').forEach(c => c.classList.remove('selected')); + cell.classList.add('selected'); + const date = cell.dataset.label.match(/on (.+)$/)?.[1] || ''; + const dateKey = cell.getAttribute('data-label').split(' tokens')[0]; // not ideal + // Extract ISO date from cells array by matching position + const allCells = [...usage.querySelectorAll('.heatmap-cell[data-label]')]; + const idx = allCells.indexOf(cell); + if (idx >= 0 && idx < cells.length) { + showDaySessions(cells[idx].key); + } + }); + }); + + async function showDaySessions(dateKey) { + const panel = usage.querySelector('#day-sessions'); + if (!panel) return; + const dayStart = dateKey + 'T00:00:00'; + const dayEnd = dateKey + 'T23:59:59'; + + // Find sessions active on this day + const daySessions = state.sessions.filter(s => { + if (!s.started_at) return false; + const end = s.ended_at || s.started_at; + return s.started_at <= dayEnd && end >= dayStart; + }); + + // Classify each session + const classified = daySessions.map(s => { + const isNew = s.started_at.slice(0, 10) === dateKey; + let kind = 'continued'; // default: session spans this day + if (isNew) { + // Check if this project had any session before this one + const hasEarlierSession = state.sessions.some( + other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at + ); + kind = hasEarlierSession ? 'new-session' : 'new-workspace'; + } + return { ...s, kind }; + }); + + if (!classified.length) { + panel.innerHTML = `
${fmtTooltipDate(dateKey)} — no sessions
`; + return; + } + + // Group by kind for visual hierarchy + const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); + const newSessions = classified.filter(s => s.kind === 'new-session'); + const continued = classified.filter(s => s.kind === 'continued'); + + let html = `
${fmtTooltipDate(dateKey)}
`; + + if (newWorkspaces.length) { + html += ` +
+
+ + Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''} +
+
+ ${newWorkspaces.map(s => ` + + `).join('')} +
+
+ `; + } + + if (newSessions.length) { + // Group new sessions by project + const byProject = {}; + for (const s of newSessions) { + const p = s.project || '(none)'; + if (!byProject[p]) byProject[p] = []; + byProject[p].push(s); + } + html += ` +
+
+ + + Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''} +
+
+ ${newSessions.map(s => ` + + `).join('')} +
+
+ `; + } + + if (continued.length) { + html += ` +
+
+ + Continued ${continued.length} session${continued.length > 1 ? 's' : ''} +
+
+ ${continued.map(s => ` + + `).join('')} +
+
+ `; + } + + html += `
`; + panel.innerHTML = html; + panel.querySelectorAll('.activity-item').forEach(row => { + row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); + }); + } + + // Tab switching + usage.querySelectorAll('.usage-tab').forEach(tab => { + tab.addEventListener('click', () => { + usage.querySelectorAll('.usage-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const view = tab.dataset.view; + const heatmap = usage.querySelector('.heatmap-container'); + const chart = usage.querySelector('#usage-chart'); + if (view === 'daily') { + heatmap.style.display = ''; chart.style.display = 'none'; + } else { + heatmap.style.display = 'none'; chart.style.display = ''; + if (view === 'weekly') renderWeeklyChart(chart, daily); + else renderCumulativeChart(chart, daily); + } + }); + }); + + // Default: show current month's activity with "show more" for previous months + let loadedMonths = 0; + showNextMonth(); + + function showNextMonth() { + const panel = usage.querySelector('#day-sessions'); + if (!panel) return; + const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths, 1); + const year = targetDate.getFullYear(); + const month = targetDate.getMonth(); + loadedMonths++; + + const monthHTML = buildMonthHTML(year, month); + + // Remove existing "show more" button + const existing = panel.querySelector('.show-more-btn'); + if (existing) existing.remove(); + + panel.insertAdjacentHTML('beforeend', monthHTML); + + // Add "show more" button + const btn = document.createElement('button'); + btn.className = 'show-more-btn'; + btn.textContent = 'Show more activity'; + btn.addEventListener('click', () => showNextMonth()); + panel.appendChild(btn); + + // Wire up session links + panel.querySelectorAll('.activity-item:not([data-wired])').forEach(row => { + row.setAttribute('data-wired', '1'); + row.addEventListener('click', () => navigateToSession(row.dataset.sessionId)); + }); + } + + function buildMonthHTML(year, month) { + const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`; + const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`; + const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; + + const monthSessions = state.sessions.filter(s => { + if (!s.started_at) return false; + const end = s.ended_at || s.started_at; + return s.started_at < nextMonth && end >= monthStart; + }); + + const classified = monthSessions.map(s => { + const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth; + let kind = 'continued'; + if (startedInMonth) { + const hasEarlierSession = state.sessions.some( + other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at + ); + kind = hasEarlierSession ? 'new-session' : 'new-workspace'; + } + return { ...s, kind }; + }); + + const newWorkspaces = classified.filter(s => s.kind === 'new-workspace'); + const newSessions = classified.filter(s => s.kind === 'new-session'); + const continued = classified.filter(s => s.kind === 'continued'); + + const headerText = `${monthNames[month]} ${year}`; + let html = `
${headerText}
`; + + if (newWorkspaces.length) { + html += ` +
+
+ + Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''} +
+
+ ${newWorkspaces.map(s => ` + + `).join('')} +
+
+ `; + } + + if (newSessions.length) { + const byProject = {}; + for (const s of newSessions) { const p = s.project || '(none)'; if (!byProject[p]) byProject[p] = []; byProject[p].push(s); } + html += ` +
+
+ + + Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''} +
+
+ ${newSessions.map(s => ` + + `).join('')} +
+
+ `; + } + + if (continued.length) { + html += ` +
+
+ + Continued ${continued.length} session${continued.length > 1 ? 's' : ''} +
+
+ ${continued.map(s => ` + + `).join('')} +
+
+ `; + } + + if (!classified.length) html += `
No sessions this month.
`; + html += `
`; + return html; + } +} + +export function renderWeeklyChart(container, daily) { + // Build 52 weekly buckets aligned to the same time range as the heatmap + const today = new Date(); + const dayMs = 86400000; + let startDate = new Date(today.getTime() - 364 * dayMs); + startDate.setHours(0, 0, 0, 0); + const daysUntilSunday = (7 - startDate.getDay()) % 7; + startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs); + + const dailyMap = {}; + for (const d of daily) dailyMap[d.day] = d.tokens; + + // Aggregate into weeks + const weeks = []; + for (let w = 0; w < 53; w++) { + const weekStart = new Date(startDate.getTime() + w * 7 * dayMs); + if (weekStart > today) break; + let tokens = 0; + for (let d = 0; d < 7; d++) { + const date = new Date(weekStart.getTime() + d * dayMs); + if (date > today) break; + const key = date.toISOString().slice(0, 10); + tokens += dailyMap[key] || 0; + } + weeks.push({ weekStart, tokens }); + } + + if (!weeks.length) { container.innerHTML = '
No data
'; return; } + + const maxVal = Math.max(...weeks.map(w => w.tokens), 1); + const barWidth = 10; + const barGap = 3; + const chartHeight = 120; + const chartWidth = weeks.length * (barWidth + barGap); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + + // Month labels + const labels = []; + let lastMonth = -1; + for (let i = 0; i < weeks.length; i++) { + const m = weeks[i].weekStart.getMonth(); + if (m !== lastMonth) { labels.push({ i, label: months[m] }); lastMonth = m; } + } + + const barsHTML = weeks.map((w, i) => { + const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0; + const x = i * (barWidth + barGap); + return ``; + }).join(''); + + const labelsHTML = labels.map(l => { + const x = l.i * (barWidth + barGap); + return `${l.label}`; + }).join(''); + + container.innerHTML = ` +
+ + ${barsHTML} + ${labelsHTML} + + `; + + // Tooltip on hover + const tooltip = container.querySelector('#chart-tooltip'); + container.querySelectorAll('.bar-fill').forEach(bar => { + bar.addEventListener('mouseenter', e => { + tooltip.textContent = bar.dataset.label; + tooltip.classList.add('show'); + }); + bar.addEventListener('mousemove', e => { + positionTooltip(tooltip, e.clientX, e.clientY); + }); + bar.addEventListener('mouseleave', () => tooltip.classList.remove('show')); + }); +} + +export function renderCumulativeChart(container, daily) { + const sorted = [...daily].sort((a, b) => a.day.localeCompare(b.day)); + if (!sorted.length) { container.innerHTML = '
No data
'; return; } + + let cumulative = 0; + const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; }); + const maxVal = points[points.length - 1].total; + + const chartWidth = 700; + const chartHeight = 140; + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + + // Scale x by index, y by value + const xScale = (i) => (i / (points.length - 1)) * chartWidth; + const yScale = (v) => chartHeight - (v / maxVal) * chartHeight; + + // Build path + const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`); + const linePath = pathParts.join(' '); + const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`; + + // Month labels + const labels = []; + let lastMonth = -1; + for (let i = 0; i < points.length; i++) { + const m = new Date(points[i].day).getMonth(); + if (m !== lastMonth) { labels.push({ x: xScale(i), label: months[m] }); lastMonth = m; } + } + const labelsHTML = labels.map(l => `${l.label}`).join(''); + + // Invisible hover dots for tooltip + const dotsHTML = points.map((p, i) => { + return ``; + }).join(''); + + container.innerHTML = ` +
+ + + + ${dotsHTML} + ${labelsHTML} + + `; + + const tooltip = container.querySelector('#chart-tooltip-cum'); + container.querySelectorAll('.cumulative-dot').forEach(dot => { + dot.addEventListener('mouseenter', e => { + tooltip.textContent = dot.dataset.label; + tooltip.classList.add('show'); + }); + dot.addEventListener('mousemove', e => { + positionTooltip(tooltip, e.clientX, e.clientY); + }); + dot.addEventListener('mouseleave', () => tooltip.classList.remove('show')); + }); +} diff --git a/app/src/renderer/js/utils.js b/app/src/renderer/js/utils.js new file mode 100644 index 0000000..570819c --- /dev/null +++ b/app/src/renderer/js/utils.js @@ -0,0 +1,194 @@ +// Utility functions extracted from render.js +// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state). + +import { state } from './state.js'; + +// --- Time / formatting --- + +export function pad2(n) { return String(n).padStart(2, '0'); } + +export function isSameDay(a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +export function fmtListTime(ts) { + const d = new Date(ts); + const now = new Date(); + const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + if (isSameDay(d, now)) return hhmm; + const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`; + if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`; + return `${d.getFullYear()}/${mmdd} ${hhmm}`; +} + +export function fmtRelative(ts) { + const diff = Date.now() - ts; + const min = 60000, hr = 3600000, day = 86400000; + if (diff < 0) return 'in the future'; + if (diff < min) return 'just now'; + if (diff < hr) return Math.floor(diff / min) + 'm ago'; + if (diff < day) return Math.floor(diff / hr) + 'h ago'; + if (diff < day * 30) return Math.floor(diff / day) + 'd ago'; + if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago'; + return Math.floor(diff / (day * 365)) + 'y ago'; +} + +export function fmtClockTime(iso) { + const d = new Date(iso); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +export function fmtSize(bytes) { + if (!bytes) return '-'; + if (bytes < 1024) return bytes + 'B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K'; + return (bytes / 1024 / 1024).toFixed(1) + 'M'; +} + +// --- HTML / Markdown --- + +export function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } + +export function highlightPlain(text, query) { + if (!query) return escapeHTML(text); + const safe = escapeHTML(text); + const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return safe.replace(new RegExp(q, 'gi'), m => `${m}`); +} + +export function sanitizeMarkdown(html) { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/\son\w+="[^"]*"/gi, ''); +} + +export function highlightTextNodes(rootEl, query) { + if (!query) return; + const q = query.toLowerCase(); + const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + const text = node.nodeValue; + if (!text) continue; + const lower = text.toLowerCase(); + if (!lower.includes(q)) continue; + const frag = document.createDocumentFragment(); + let last = 0, i = lower.indexOf(q); + while (i !== -1) { + if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i))); + const mark = document.createElement('mark'); + mark.textContent = text.slice(i, i + q.length); + frag.appendChild(mark); + last = i + q.length; + i = lower.indexOf(q, last); + } + if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); + node.parentNode.replaceChild(frag, node); + } +} + +export function renderMarkdown(text, opts = {}) { + if (text == null) return ''; + const html = sanitizeMarkdown(marked.parse(text)); + const cls = opts.variant === 'msg' ? 'markdown-msg' + : opts.variant === 'compact' ? 'markdown-compact' + : 'markdown-body'; + const container = document.createElement('div'); + container.className = cls; + container.innerHTML = html; + if (opts.query) highlightTextNodes(container, opts.query.trim()); + return container.outerHTML; +} + +// --- Duration / tokens / tooltip --- + +export function fmtDuration(ms) { + if (!ms) return '—'; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const parts = []; + if (d) parts.push(`${d}d`); + if (h) parts.push(`${h}h`); + if (m) parts.push(`${m}m`); + if (sec || !parts.length) parts.push(`${sec}s`); + return parts.join(' '); +} + +export function fmtTokens(n) { + if (!n) return '0'; + if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B'; + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; + return String(n); +} + +export function fmtTooltipDate(isoDay) { + const d = new Date(isoDay + 'T00:00:00'); + const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const day = d.getDate(); + const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th'; + const thisYear = new Date().getFullYear(); + if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`; + return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`; +} + +export function positionTooltip(el, x, y) { + const pad = 12; + const rect = el.getBoundingClientRect(); + let left = x + pad; + if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad; + el.style.left = left + 'px'; + el.style.top = (y - 28) + 'px'; +} + +// --- DOM helpers --- + +export const $ = sel => document.querySelector(sel); +export const $$ = sel => Array.from(document.querySelectorAll(sel)); + +export function ensureVisible(el, wrapSel) { + const wrap = $(wrapSel); + if (!wrap || !el) return; + const elRect = el.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top); + else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10); +} + +// --- Project label --- + +export function formatProjectLabel(slug) { + if (!slug) return '(no project)'; + // Use project_path if available from sessions, otherwise show slug as-is + const session = state.sessions.find(s => s.project === slug && s.project_path); + if (session?.project_path) { + const parts = session.project_path.split('/'); + return parts.slice(-2).join('/'); + } + return slug.replace(/^-/, ''); +} + +// --- Row status --- + +export function dominantRowStatus(m) { + if (m.health === 'broken') return 'broken'; + if (m.health === 'partial') return 'partial'; + if (m.archived) return 'archived'; + return null; +} + +export function statusGlyphHTML(status) { + if (!status) return ''; + const glyphs = { + broken: ``, + partial: ``, + archived: `` + }; + return `${glyphs[status] || ''}`; +} diff --git a/app/src/renderer/src/App.vue b/app/src/renderer/src/App.vue new file mode 100644 index 0000000..d889807 --- /dev/null +++ b/app/src/renderer/src/App.vue @@ -0,0 +1,552 @@ + + + diff --git a/app/src/renderer/src/assets/recap-cards.html b/app/src/renderer/src/assets/recap-cards.html new file mode 100644 index 0000000..e0e9b3d --- /dev/null +++ b/app/src/renderer/src/assets/recap-cards.html @@ -0,0 +1,1621 @@ + + + + + + +Obelisk · Recap · themed + + + + +
+ + +
+
+ + Obelisk +
+ + Recap · Week 24 + +
+ + The Architect +
+ +
+ + +
+ + +
+
+ + +
+
+ +
+
+ + Week 24 + + 01 · 05 +
+
+
+
The Architect
+
从零设计了一个完整的 memory 系统。
+ +
+
+
+ MTWTFSS +
+
+ + +
+
+ + +
+
+ + Your thinking path + + 02 · 05 +
+
Five questions, five turns.
+
每一天的 prompt 都是一道分叉路口 — 你每次都选了往简单的那条走。
+ +
+
+
+
+ + +
+
+ + Your vibe this week + + 03 · 05 +
+
A short character study.
+
从你说过的话里,能看出一个人。
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ patience + saint +
+
+
+ +
+
若无必要,勿增实体。
+
— your most philosophical moment
+
+
+
+ + +
+
+ + Are you a workflow enjoyer? + + 04 · 05 +
+
Three workflows. Forty-two agents.
+
你召唤了机器军团。结果各有不同。
+ +
+
+ 3 workflows + · + 42 agents +
+ +
+ +
+
Verdict —
+
Mostly tolerated.
+
+
+
+ + +
+
+ + The week, carved. + + 05 · 05 +
+ +
+
+
19
+
days · streak still active
+
+ +
+ 847 messages exchanged +
+ +
+ "好的开始做吧" + — most-said phrase +
+ +
See you next week.
+
+
+ +
+
+ + + + +
+ + + + + diff --git a/app/src/renderer/src/components/Sidebar.vue b/app/src/renderer/src/components/Sidebar.vue new file mode 100644 index 0000000..8184df1 --- /dev/null +++ b/app/src/renderer/src/components/Sidebar.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/app/src/renderer/src/components/Toolbar.vue b/app/src/renderer/src/components/Toolbar.vue new file mode 100644 index 0000000..c6f227f --- /dev/null +++ b/app/src/renderer/src/components/Toolbar.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/ClosingCard.vue b/app/src/renderer/src/components/recap/ClosingCard.vue new file mode 100644 index 0000000..2eb4c34 --- /dev/null +++ b/app/src/renderer/src/components/recap/ClosingCard.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/CoverCard.vue b/app/src/renderer/src/components/recap/CoverCard.vue new file mode 100644 index 0000000..9d1d6c8 --- /dev/null +++ b/app/src/renderer/src/components/recap/CoverCard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/PathCard.vue b/app/src/renderer/src/components/recap/PathCard.vue new file mode 100644 index 0000000..cebd3e9 --- /dev/null +++ b/app/src/renderer/src/components/recap/PathCard.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/VibeCard.vue b/app/src/renderer/src/components/recap/VibeCard.vue new file mode 100644 index 0000000..7322f61 --- /dev/null +++ b/app/src/renderer/src/components/recap/VibeCard.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/WorkflowCard.vue b/app/src/renderer/src/components/recap/WorkflowCard.vue new file mode 100644 index 0000000..bdba627 --- /dev/null +++ b/app/src/renderer/src/components/recap/WorkflowCard.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/src/renderer/src/components/recap/archetypes.js b/app/src/renderer/src/components/recap/archetypes.js new file mode 100644 index 0000000..2d087b6 --- /dev/null +++ b/app/src/renderer/src/components/recap/archetypes.js @@ -0,0 +1,21 @@ +export const PALETTES = { + architect: { tc: '#a78bfa', tc2: '#c4b5fd', glow: 'rgba(167,139,250,0.40)', mid: 'rgba(167,139,250,0.22)', soft: 'rgba(167,139,250,0.10)' }, + debugger: { tc: '#fbbf24', tc2: '#fde68a', glow: 'rgba(251,191,36,0.40)', mid: 'rgba(251,191,36,0.22)', soft: 'rgba(251,191,36,0.10)' }, + shipper: { tc: '#f472b6', tc2: '#fda4af', glow: 'rgba(244,114,182,0.40)', mid: 'rgba(244,114,182,0.22)', soft: 'rgba(244,114,182,0.10)' }, + curator: { tc: '#67e8f9', tc2: '#a5f3fc', glow: 'rgba(103,232,249,0.40)', mid: 'rgba(103,232,249,0.22)', soft: 'rgba(103,232,249,0.10)' }, + director: { tc: '#fcd34d', tc2: '#fde68a', glow: 'rgba(252,211,77,0.40)', mid: 'rgba(252,211,77,0.22)', soft: 'rgba(252,211,77,0.10)' }, + cartographer: { tc: '#34d399', tc2: '#6ee7b7', glow: 'rgba(52,211,153,0.40)', mid: 'rgba(52,211,153,0.22)', soft: 'rgba(52,211,153,0.10)' }, + wanderer: { tc: '#64748b', tc2: '#94a3b8', glow: 'rgba(100,116,139,0.45)', mid: 'rgba(100,116,139,0.25)', soft: 'rgba(100,116,139,0.12)' }, +}; + +export const ARCHETYPE_NAMES = { + architect: 'The Architect', + debugger: 'The Debugger', + shipper: 'The Shipper', + curator: 'The Curator', + director: 'The Director', + cartographer: 'The Cartographer', + wanderer: 'The Wanderer', +}; + +export const ARCH_KEYS = ['architect', 'debugger', 'shipper', 'curator', 'director', 'cartographer', 'wanderer']; diff --git a/app/src/renderer/src/components/recap/card-base.css b/app/src/renderer/src/components/recap/card-base.css new file mode 100644 index 0000000..3067b33 --- /dev/null +++ b/app/src/renderer/src/components/recap/card-base.css @@ -0,0 +1,70 @@ +.card { + position: absolute; inset: 0; + border-radius: 14px; + background: linear-gradient(165deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.015) 100%); + border: 1px solid var(--hairline-strong); + box-shadow: + 0 30px 80px rgba(0,0,0,0.5), + 0 12px 32px rgba(0,0,0,0.3), + inset 0 1px 0 rgba(255,255,255,0.08); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.card[data-themed]::before { + content: ''; + position: absolute; pointer-events: none; z-index: 0; + width: 60%; height: 50%; bottom: 0; right: 0; + background: radial-gradient(ellipse at 100% 100%, var(--tg-mid) 0%, transparent 70%); + opacity: 0.6; + transition: background var(--theme-ease); +} +.card[data-themed]::after { + content: ''; + position: absolute; pointer-events: none; z-index: 0; + left: 0; right: 0; bottom: 0; height: 1px; + background: linear-gradient(to right, transparent 0%, var(--tg) 50%, transparent 100%); + opacity: 0.6; + transition: background var(--theme-ease); +} + +.eyebrow { + display: flex; align-items: center; gap: 10px; + padding: 22px 28px 0; + font-family: var(--font-mono); font-size: 12px; + color: var(--muted); letter-spacing: 0.01em; + position: relative; z-index: 1; +} +.eyebrow .diamond { + width: 6px; height: 6px; + background: var(--tc); transform: rotate(45deg); + box-shadow: 0 0 8px var(--tg); flex-shrink: 0; + transition: background var(--theme-ease), box-shadow var(--theme-ease); +} +.eyebrow-spacer { flex: 1; } +.eyebrow .slot { + color: var(--muted-2); font-variant-numeric: tabular-nums; +} + +.card-title { + padding: 18px 36px 6px; + font-family: var(--font-serif); font-size: 30px; + letter-spacing: -0.015em; font-weight: 500; + color: var(--fg); line-height: 1.2; + position: relative; z-index: 1; +} +.card-deck-text { + padding: 0 36px 22px; + font-size: 15px; color: var(--fg-3); + line-height: 1.55; font-style: italic; + font-family: var(--font-serif); + position: relative; z-index: 1; +} + +.section-label { + font-family: var(--font-serif); font-style: italic; + font-size: 13px; color: var(--muted); +} diff --git a/app/src/renderer/src/components/recap/seals.js b/app/src/renderer/src/components/recap/seals.js new file mode 100644 index 0000000..da1d73f --- /dev/null +++ b/app/src/renderer/src/components/recap/seals.js @@ -0,0 +1,19 @@ +export const MINI_SEALS = { + architect: ``, + debugger: ``, + shipper: ``, + curator: ``, + director: ``, + cartographer: ``, + wanderer: ``, +}; + +export const CORNER_SEALS = { + architect: ``, + debugger: ``, + shipper: ``, + curator: ``, + director: ``, + cartographer: ``, + wanderer: ``, +}; diff --git a/app/src/renderer/src/data.js b/app/src/renderer/src/data.js new file mode 100644 index 0000000..af5ba5e --- /dev/null +++ b/app/src/renderer/src/data.js @@ -0,0 +1,408 @@ +// Data loading layer -- bridges Electron IPC (window.obelisk.*) to reactive store. +// All DB access goes through this module. + +import { markRaw } from 'vue'; +import { state, clearUndo } from './store.js'; + +/** + * Load initial data from the DB and populate state.memories, state.sessions, + * and state.projects. + */ +export async function loadInitialData() { + const [rawMemories, rawSessions, stats, projects] = await Promise.all([ + window.obelisk.getMemories(), + window.obelisk.getSessions({ source: 'all', limit: 1000 }), + window.obelisk.getStats(), + window.obelisk.getProjects() + ]); + + // Transform memories: DB records -> render-layer shape + state.memories = (rawMemories || []).map(m => ({ + ...m, + ts: m.created_at ? new Date(m.created_at).getTime() : 0, + archived: !!m.deleted_at, + archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null, + anchors: m.anchors ? (typeof m.anchors === 'string' ? JSON.parse(m.anchors) : m.anchors) : [], + markdown: null // loaded on demand via loadMemoryMarkdown + })); + + // Sessions: merge with existing data to preserve already-loaded messages + const existingSessions = new Map(state.sessions.map(s => [s.id, s])); + state.sessions = (rawSessions || []).map(s => { + const existing = existingSessions.get(s.id); + return { + ...s, + messages: existing?.messages?.length ? existing.messages : [] + }; + }); + + state.projects = projects || []; + state.stats = stats || {}; + state.loaded = true; +} + +/** + * Load full detail for a session: messages with inline tool_calls (each with + * result), summaries, subagents, and workflow data. + * + * Returns the assembled session object (also updates state.sessions entry). + */ +export async function loadSessionDetail(sessionId) { + const [messages, toolCalls, toolResults, subagents, workflows, summaries] = + await Promise.all([ + window.obelisk.getSessionMessages(sessionId), + window.obelisk.getSessionToolCalls(sessionId), + window.obelisk.getSessionToolResults(sessionId), + window.obelisk.getSessionSubagents(sessionId), + window.obelisk.getSessionWorkflows(sessionId), + window.obelisk.getSessionSummaries(sessionId) + ]); + + // Index tool results by tool_use_id for fast lookup + const resultsByCallId = {}; + for (const r of (toolResults || [])) { + resultsByCallId[r.tool_use_id] = r; + } + + // Index subagents by parent_tool_use_id + const subagentsByCallId = {}; + for (const sa of (subagents || [])) { + if (sa.parent_tool_use_id) { + subagentsByCallId[sa.parent_tool_use_id] = sa; + } + } + + // Group tool_calls by message_uuid, attaching result and subagent inline + const callsByMessageUuid = {}; + for (const tc of (toolCalls || [])) { + const call = { + id: tc.id, + name: tc.name, + input_json: tc.input_json, + result: resultsByCallId[tc.id] || null + }; + + // Attach subagent data if present + const sa = subagentsByCallId[tc.id]; + if (sa) { + call.subagent = { + agent_id: sa.agent_id, + agent_type: sa.agent_type, + description: sa.description + }; + } + + const msgUuid = tc.message_uuid; + if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; + callsByMessageUuid[msgUuid].push(call); + } + + // Attach workflow data to Workflow tool calls + for (const wf of (workflows || [])) { + for (const calls of Object.values(callsByMessageUuid)) { + for (const call of calls) { + if (call.name === 'Workflow' && !call.workflow) { + const resultText = call.result?.content || ''; + if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) { + call.workflow = { + run_id: wf.run_id, + workflow_name: wf.workflow_name, + status: wf.status, + duration_ms: wf.duration_ms, + total_tokens: wf.total_tokens, + agent_count: wf.agent_count, + agents: (wf.agents || []).map(a => ({ + agent_id: a.agent_id, + phase: a.phase, + label: a.label, + state: a.state, + tokens: a.tokens, + duration_ms: a.duration_ms, + })) + }; + } + } + } + } + } + + // Index summaries by session + const sessionSummaries = (summaries || []).map(s => ({ + source: s.source, + content: s.content, + timestamp: s.timestamp + })); + + // Assemble messages with tool_calls inline + const META_RE = /^\s*<(task-notification|command-name|local-command|system-reminder)/; + const rawAssembled = (messages || []).map(msg => { + const assembled = { + uuid: msg.uuid, + type: msg.type || msg.role, + timestamp: msg.timestamp, + text: msg.text, + content_type: msg.content_type || null, + is_meta: msg.is_meta || (msg.text && META_RE.test(msg.text) ? 1 : 0) + }; + + const calls = callsByMessageUuid[msg.uuid]; + if (calls && calls.length > 0) { + assembled.tool_calls = calls; + } + + return assembled; + }); + + // Merge adjacent assistant messages: + // - tool_result user messages are skipped (results shown inside tool_call panels) + // - consecutive tool_use messages (separated by tool_results) merge into one + // - thinking messages merge into the next non-thinking assistant message + const assembledMessages = []; + for (let i = 0; i < rawAssembled.length; i++) { + const msg = rawAssembled[i]; + + // Skip tool_result user messages + if (msg.content_type === 'tool_result') continue; + + // For thinking messages, collect consecutive thinking blocks and attach to the next assistant + if (msg.type === 'assistant' && msg.content_type === 'thinking') { + const thinkingParts = [msg.text || '']; + let j = i + 1; + while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { + thinkingParts.push(rawAssembled[j].text || ''); + j++; + } + if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { + rawAssembled[j]._thinking = thinkingParts.join('\n\n'); + i = j - 1; + continue; + } + assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); + i = j - 1; + continue; + } + + // For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results and skill meta) + if (msg.type === 'assistant' && msg.content_type === 'tool_use') { + const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; + if (msg._thinking) merged._thinking = msg._thinking; + + // If this is a Skill-only message, don't merge with subsequent tool_use — keep it standalone + const isSkillOnly = merged.tool_calls.length === 1 && merged.tool_calls[0].name === 'Skill'; + + let j = i + 1; + while (j < rawAssembled.length) { + const next = rawAssembled[j]; + if (next.content_type === 'tool_result') { j++; continue; } + // Absorb skill.md meta message into the skill tool call + if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) { + merged._skillMd = next.text; + j++; + continue; + } + if (!isSkillOnly && next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); + if (next.text && !merged.text) merged.text = next.text; + j++; + continue; + } + break; + } + assembledMessages.push(merged); + i = j - 1; + } else { + const out = { ...msg }; + if (msg._thinking) out._thinking = msg._thinking; + // For text assistant messages, absorb following tool_use messages (Codex pattern) + if (msg.type === 'assistant' && msg.content_type !== 'tool_use' && msg.content_type !== 'thinking') { + if (!out.tool_calls) out.tool_calls = []; + let j = i + 1; + while (j < rawAssembled.length) { + const next = rawAssembled[j]; + if (next.content_type === 'tool_result') { j++; continue; } + if (next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) out.tool_calls.push(...next.tool_calls); + j++; + continue; + } + break; + } + if (!out.tool_calls.length) delete out.tool_calls; + i = j - 1; + } + assembledMessages.push(out); + } + } + + // Attach workflow data if present + const workflow = (workflows && workflows.length > 0) ? workflows[0] : null; + + // Build assembled session object + const session = state.sessions.find(s => s.id === sessionId); + const assembled = { + ...(session || {}), + id: sessionId, + messages: assembledMessages + }; + + if (workflow) { + assembled.workflow = workflow; + } + + // Update in-place in state.sessions + const idx = state.sessions.findIndex(s => s.id === sessionId); + if (idx !== -1) { + state.sessions[idx] = assembled; + } + + return assembled; +} + +/** + * Load full detail for a subagent conversation. + * Returns assembled messages with tool_calls inline. + */ +export async function loadSubagentDetail(agentId) { + const [messages, toolCalls, toolResults] = await Promise.all([ + window.obelisk.getSubagentMessages(agentId), + window.obelisk.getSubagentToolCalls(agentId), + window.obelisk.getSubagentToolResults(agentId), + ]); + + const resultsByCallId = {}; + for (const r of (toolResults || [])) { + resultsByCallId[r.tool_use_id] = r; + } + + const callsByMessageUuid = {}; + for (const tc of (toolCalls || [])) { + const call = { + id: tc.id, + name: tc.name, + input_json: tc.input_json, + result: resultsByCallId[tc.id] || null + }; + const msgUuid = tc.message_uuid; + if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = []; + callsByMessageUuid[msgUuid].push(call); + } + + const rawAssembled = (messages || []).map(msg => { + const assembled = { + uuid: msg.uuid, + type: msg.type || msg.role, + timestamp: msg.timestamp, + text: msg.text, + content_type: msg.content_type || null, + is_meta: msg.is_meta || 0 + }; + const calls = callsByMessageUuid[msg.uuid]; + if (calls && calls.length > 0) assembled.tool_calls = calls; + return assembled; + }); + + // Same merging logic as session detail + const assembledMessages = []; + for (let i = 0; i < rawAssembled.length; i++) { + const msg = rawAssembled[i]; + if (msg.content_type === 'tool_result') continue; + if (msg.type === 'assistant' && msg.content_type === 'thinking') { + const thinkingParts = [msg.text || '']; + let j = i + 1; + while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') { + thinkingParts.push(rawAssembled[j].text || ''); + j++; + } + if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') { + rawAssembled[j]._thinking = thinkingParts.join('\n\n'); + i = j - 1; + continue; + } + assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); + i = j - 1; + continue; + } + if (msg.type === 'assistant' && msg.content_type === 'tool_use') { + const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; + if (msg._thinking) merged._thinking = msg._thinking; + let j = i + 1; + while (j < rawAssembled.length) { + const next = rawAssembled[j]; + if (next.content_type === 'tool_result') { j++; continue; } + if (next.type === 'assistant' && next.content_type === 'tool_use') { + if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); + if (next.text && !merged.text) merged.text = next.text; + j++; + continue; + } + break; + } + assembledMessages.push(merged); + i = j - 1; + } else { + const out = { ...msg }; + if (msg._thinking) out._thinking = msg._thinking; + assembledMessages.push(out); + } + } + + return assembledMessages; +} + +const TEXT_LIMIT = 10000; + +/** + * Check if a message text was truncated during indexing. + */ +export function isTextTruncated(text) { + return text && text.length >= TEXT_LIMIT; +} + +/** + * Fetch the full untruncated text for a message from its source JSONL. + * Returns the full text string or null. + */ +export async function loadFullText(uuid) { + try { + return await window.obelisk.getMessageFullText(uuid); + } catch { + return null; + } +} + +/** + * Load the markdown content of a memory file. + * Returns the content string or null on failure. + */ +export async function loadMemoryMarkdown(memoryPath) { + try { + const content = await window.obelisk.readMemoryFile(memoryPath); + return content || null; + } catch { + return null; + } +} + +/** + * Archive a memory by id. Updates state after successful IPC call. + */ +export async function archiveMemory(id) { + await window.obelisk.archiveMemory(id); + const mem = state.memories.find(m => m.id === id); + if (mem) { + mem.archived = true; + mem.archivedAt = Date.now(); + } +} + +/** + * Restore an archived memory by id. Updates state after successful IPC call. + */ +export async function restoreMemory(id) { + await window.obelisk.restoreMemory(id); + const mem = state.memories.find(m => m.id === id); + if (mem) { + mem.archived = false; + mem.archivedAt = null; + } +} diff --git a/app/src/renderer/src/main.js b/app/src/renderer/src/main.js new file mode 100644 index 0000000..fdb2a79 --- /dev/null +++ b/app/src/renderer/src/main.js @@ -0,0 +1,42 @@ +// Vue 3 application entry point for Obelisk. + +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router.js'; +import { loadInitialData } from './data.js'; +import { noteSessionUpdated, sessionLiveState } from './session-live.mjs'; + +// Import all original CSS globally +import '../styles/base.css'; +import '../styles/sidebar.css'; +import '../styles/toolbar.css'; +import '../styles/list.css'; +import '../styles/detail.css'; + +const app = createApp(App); + +app.use(router); + +// Load data on startup +router.isReady().then(() => { + loadInitialData(); +}); + +// Refresh data when window regains focus +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + loadInitialData(); + } +}); + +window.obelisk?.onIndexUpdated?.(() => { + loadInitialData(); +}); + +window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => { + const route = router.currentRoute.value; + const currentSessionId = route.name === 'SessionDetail' ? String(route.params.id || '') : null; + noteSessionUpdated(sessionLiveState, sessionId, currentSessionId); +}); + +app.mount('#app'); diff --git a/app/src/renderer/src/mock/recap-2026-W24.json b/app/src/renderer/src/mock/recap-2026-W24.json new file mode 100644 index 0000000..3512631 --- /dev/null +++ b/app/src/renderer/src/mock/recap-2026-W24.json @@ -0,0 +1,101 @@ +{ + "schema_version": "obelisk.recap.v1", + "kind": "weekly", + "generated_at": "2026-06-14T03:00:00+08:00", + + "period": { + "label": "Week 24", + "start": "2026-06-08", + "end": "2026-06-14", + "timezone": "Asia/Shanghai" + }, + + "source": { + "project": "-Users-tomiya-Code-quiet-zero", + "session_ids": ["defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "9d259960-8eae-4bae-947c-081420bb5626"], + "memory_ids": ["mem-1781021027286-51v0qh"] + }, + + "metrics": { + "sessions": 12, + "messages": 847, + "tokens": 2400000, + "active_days": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0], + "streak_days": 19, + "workflows": 3, + "workflow_agents": 42, + "corrections": 12 + }, + + "persona": { + "archetype": "architect", + "title": "The Architect", + "claim": "从零设计了一个完整的 memory 系统。", + "tone": "affectionate_teasing" + }, + + "cards": [ + { + "type": "cover", + "badge": "Week 24", + "title": "The Architect", + "claim": "从零设计了一个完整的 memory 系统。", + "activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0], + "footer": "12 sessions · 2.4M tokens" + }, + { + "type": "thinking_path", + "title": "Five questions, five turns.", + "items": [ + { "day": "Mon", "prompt": "为什么要把 session 编译成 wiki?", "turn": "raw SQLite, no wiki" }, + { "day": "Tue", "prompt": "buildWhere 是什么", "turn": "unified filter opts, not DSL" }, + { "day": "Wed", "prompt": "failures() 90% 误报", "turn": "is_error in JSONL" }, + { "day": "Thu", "prompt": "memory 层需要清理机制吗", "turn": "soft-delete, human-only" }, + { "day": "Fri", "prompt": "热力图不选中默认显示本月", "turn": "GitHub-style activity timeline" } + ] + }, + { + "type": "vibe", + "title": "A short character study.", + "voice_lines": [ + { "label": "catchphrase", "text": "这太丑了", "count": 4 }, + { "label": "highest praise", "text": "可以" }, + { "label": "late night", "text": "你在干什么", "time": "02:47 AM" } + ], + "meter": { + "label": "patience", + "value": 0.78, + "caption": "saint" + }, + "quote": { + "text": "若无必要,勿增实体。", + "caption": "your most philosophical moment" + } + }, + { + "type": "workflow", + "title": "Three workflows. Forty-two agents.", + "deck": "你召唤了机器军团。结果各有不同。", + "stats": "3 workflows · 42 agents", + "items": [ + { "name": "hono-plugin-review", "reaction": "完美" }, + { "name": "vue-migration", "reaction": "你这页面完全和之前的不一样…" }, + { "name": "split-render-js", "reaction": "可以" } + ], + "verdict": "Mostly tolerated." + }, + { + "type": "closing", + "headline": "19 days", + "receipts": ["847 messages exchanged", "12 corrections · 47 approvals"], + "most_said_phrase": "好的开始做吧", + "signoff": "See you next week." + } + ], + + "evidence": [ + { "id": "ev-1", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-1", "summary": "User said '若无必要,勿增实体' when discussing query builder" }, + { "id": "ev-2", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-2", "summary": "User said '这太丑了' about panel design" }, + { "id": "ev-3", "summary": "12 corrections vs 47 approvals in session messages" } + ] +} diff --git a/app/src/renderer/src/router.js b/app/src/renderer/src/router.js new file mode 100644 index 0000000..c2ccba9 --- /dev/null +++ b/app/src/renderer/src/router.js @@ -0,0 +1,90 @@ +// Vue Router configuration for Obelisk. +// Routes map to the main content views; sidebar navigation drives route changes. + +import { createRouter, createWebHashHistory } from 'vue-router'; + +// Lazy-loaded view components (will be created as Vue SFCs later) +const SessionList = () => import('./views/SessionList.vue'); +const SessionDetail = () => import('./views/SessionDetail.vue'); +const SubagentDetail = () => import('./views/SubagentDetail.vue'); +const MemoryList = () => import('./views/MemoryList.vue'); +const MemoryDetail = () => import('./views/MemoryDetail.vue'); +const Activity = () => import('./views/Activity.vue'); +const Recap = () => import('./views/RecapList.vue'); +const RecapDetail = () => import('./views/RecapDetail.vue'); +const RecapExport = () => import('./views/RecapExport.vue'); +const Settings = () => import('./views/Settings.vue'); + +const routes = [ + { + path: '/sessions', + name: 'SessionList', + component: SessionList + }, + { + path: '/sessions/:id', + name: 'SessionDetail', + component: SessionDetail, + props: true, + meta: { keepAlive: true } + }, + { + path: '/sessions/:id/agent/:agentId', + name: 'SubagentDetail', + component: SubagentDetail, + props: true + }, + { + path: '/memory', + name: 'MemoryList', + component: MemoryList + }, + { + path: '/memory/:id', + name: 'MemoryDetail', + component: MemoryDetail, + props: true + }, + { + path: '/activity', + name: 'Activity', + component: Activity + }, + { + path: '/recap', + name: 'Recap', + component: Recap + }, + { + path: '/recap/:id', + name: 'RecapDetail', + component: RecapDetail, + props: true + }, + { + path: '/recap-export', + name: 'RecapExport', + component: RecapExport + }, + { + path: '/settings', + name: 'Settings', + component: Settings + }, + { + path: '/', + redirect: '/memory' + }, + { + // Catch-all redirect + path: '/:pathMatch(.*)*', + redirect: '/memory' + } +]; + +const router = createRouter({ + history: createWebHashHistory(), + routes +}); + +export default router; diff --git a/app/src/renderer/src/session-live.mjs b/app/src/renderer/src/session-live.mjs new file mode 100644 index 0000000..a969469 --- /dev/null +++ b/app/src/renderer/src/session-live.mjs @@ -0,0 +1,31 @@ +export function createSessionLiveState() { + return { + dirtySessions: new Set(), + }; +} + +export const sessionLiveState = createSessionLiveState(); + +export function noteSessionUpdated(live, sessionId, currentSessionId = null) { + if (!sessionId) return { reload: false, sessionId: null }; + if (sessionId === currentSessionId) { + live.dirtySessions.delete(sessionId); + return { reload: true, sessionId }; + } + live.dirtySessions.add(sessionId); + return { reload: false, sessionId }; +} + +export function clearSessionDirty(sessionId, live = sessionLiveState) { + if (sessionId) live.dirtySessions.delete(sessionId); +} + +export function consumeSessionDirty(live, sessionId) { + if (!sessionId || !live.dirtySessions.has(sessionId)) return false; + live.dirtySessions.delete(sessionId); + return true; +} + +export function consumeGlobalSessionDirty(sessionId) { + return consumeSessionDirty(sessionLiveState, sessionId); +} diff --git a/app/src/renderer/src/sidebar-projects.mjs b/app/src/renderer/src/sidebar-projects.mjs new file mode 100644 index 0000000..7f38a24 --- /dev/null +++ b/app/src/renderer/src/sidebar-projects.mjs @@ -0,0 +1,52 @@ +function countByProject(items) { + const counts = {}; + for (const item of items) { + if (item.project) counts[item.project] = (counts[item.project] || 0) + 1; + } + return counts; +} + +function orderedProjectSlugs(projectCounts, projects, formatProjectLabel) { + const seen = new Set(); + const ordered = []; + + for (const project of projects || []) { + const slug = project?.project; + if (!slug || !projectCounts[slug] || seen.has(slug)) continue; + seen.add(slug); + ordered.push(slug); + } + + const missing = Object.keys(projectCounts) + .filter(slug => !seen.has(slug)) + .sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b))); + + return ordered.concat(missing); +} + +export function buildSidebarProjects({ + routeType, + sessions = [], + memories = [], + projects = [], + view = 'active', + search = '', + formatProjectLabel = slug => slug, +} = {}) { + const items = routeType === 'sessions' + ? sessions + : memories.filter(memory => view === 'archived' ? memory.archived : !memory.archived); + const counts = countByProject(items); + const q = search.trim().toLowerCase(); + + return orderedProjectSlugs(counts, projects, formatProjectLabel) + .filter(slug => { + if (!q) return true; + return formatProjectLabel(slug).toLowerCase().includes(q); + }) + .map(slug => ({ + slug, + label: formatProjectLabel(slug), + count: counts[slug] || 0, + })); +} diff --git a/app/src/renderer/src/store.js b/app/src/renderer/src/store.js new file mode 100644 index 0000000..48a1a9f --- /dev/null +++ b/app/src/renderer/src/store.js @@ -0,0 +1,130 @@ +// Reactive store -- Vue 3 reactive() replaces the plain object from state.js. +// All state fields are ported; action functions mutate the reactive state. + +import { reactive, markRaw } from 'vue'; + +export const state = reactive({ + memories: [], + sessions: [], + projects: [], + stats: {}, + route: 'memory', + view: 'active', // 'active' | 'archived' + mode: 'list', // 'list' | 'detail' + detailId: null, + subagentId: null, + subagentDescription: null, + pendingFocusUuid: null, + query: '', + projectFilter: 'all', + sourceFilter: 'all', + projectSearch: '', + sortDesc: true, + includeMessageBodies: false, + cursorId: null, + selection: markRaw(new Set()), + showSource: false, + lastArchiveSnapshot: null, + undoTimer: null, + undoExpires: 0, + loaded: false +}); + +// Platform detection +export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform); + +// SVG icon constants +export const FOLDER_SVG = ``; +export const FILE_SVG = ``; + +// --- Action functions --- + +export function setRoute(route) { + state.route = route; + state.mode = 'list'; + state.detailId = null; + state.cursorId = null; + state.selection = markRaw(new Set()); + state.query = ''; +} + +export function setView(v) { + state.route = 'memory'; + state.view = v; + state.mode = 'list'; + state.detailId = null; + state.cursorId = null; + state.selection = markRaw(new Set()); + state.projectFilter = 'all'; +} + +export function setProject(p) { + state.projectFilter = p; + state.cursorId = null; + state.selection = markRaw(new Set()); + state.mode = 'list'; + state.detailId = null; +} + +export function toggleSort() { + state.sortDesc = !state.sortDesc; +} + +export function enterDetail(id) { + state.detailId = id; + state.mode = 'detail'; + state.showSource = false; +} + +export function exitDetail() { + if (state.subagentId) { + state.subagentId = null; + state.subagentDescription = null; + return; + } + state.mode = 'list'; + state.detailId = null; + state.pendingFocusUuid = null; +} + +export function navigateToSession(sessionId, focusUuid) { + state.route = 'sessions'; + state.mode = 'detail'; + state.detailId = sessionId; + state.subagentId = null; + state.subagentDescription = null; + state.pendingFocusUuid = focusUuid || null; + state.query = ''; +} + +export function navigateToSubagent(agentId, description) { + state.subagentId = agentId; + state.subagentDescription = description || agentId; +} + +export function setCursor(id, opts = {}) { + state.cursorId = id; + if (!opts.keepSelection) { + state.selection = markRaw(new Set()); + } +} + +export function setQuery(q) { + state.query = q; +} + +export function setProjectSearch(q) { + state.projectSearch = q; +} + +export function toggleIncludeMessageBodies() { + state.includeMessageBodies = !state.includeMessageBodies; +} + +export function clearUndo() { + state.lastArchiveSnapshot = null; + if (state.undoTimer) { + clearInterval(state.undoTimer); + state.undoTimer = null; + } +} diff --git a/app/src/renderer/src/utils.js b/app/src/renderer/src/utils.js new file mode 100644 index 0000000..743b58c --- /dev/null +++ b/app/src/renderer/src/utils.js @@ -0,0 +1,165 @@ +// Utility functions ported from the vanilla JS utils.js. +// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store). + +import { state } from './store.js'; + +// --- Time / formatting --- + +export function pad2(n) { return String(n).padStart(2, '0'); } + +export function isSameDay(a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +export function fmtListTime(ts) { + const d = new Date(ts); + const now = new Date(); + const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + if (isSameDay(d, now)) return hhmm; + const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`; + if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`; + return `${d.getFullYear()}/${mmdd} ${hhmm}`; +} + +export function fmtRelative(ts) { + const diff = Date.now() - ts; + const min = 60000, hr = 3600000, day = 86400000; + if (diff < 0) return 'in the future'; + if (diff < min) return 'just now'; + if (diff < hr) return Math.floor(diff / min) + 'm ago'; + if (diff < day) return Math.floor(diff / hr) + 'h ago'; + if (diff < day * 30) return Math.floor(diff / day) + 'd ago'; + if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago'; + return Math.floor(diff / (day * 365)) + 'y ago'; +} + +export function fmtClockTime(iso) { + const d = new Date(iso); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +export function fmtSize(bytes) { + if (!bytes) return '-'; + if (bytes < 1024) return bytes + 'B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K'; + return (bytes / 1024 / 1024).toFixed(1) + 'M'; +} + +// --- HTML / Markdown --- + +export function escapeHTML(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>'); +} + +export function highlightPlain(text, query) { + if (!query) return escapeHTML(text); + const safe = escapeHTML(text); + const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return safe.replace(new RegExp(q, 'gi'), m => `${m}`); +} + +export function sanitizeMarkdown(html) { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/\son\w+="[^"]*"/gi, ''); +} + +export function highlightTextNodes(rootEl, query) { + if (!query) return; + const q = query.toLowerCase(); + const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + const text = node.nodeValue; + if (!text) continue; + const lower = text.toLowerCase(); + if (!lower.includes(q)) continue; + const frag = document.createDocumentFragment(); + let last = 0, i = lower.indexOf(q); + while (i !== -1) { + if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i))); + const mark = document.createElement('mark'); + mark.textContent = text.slice(i, i + q.length); + frag.appendChild(mark); + last = i + q.length; + i = lower.indexOf(q, last); + } + if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); + node.parentNode.replaceChild(frag, node); + } +} + +export function renderMarkdown(text, opts = {}) { + if (text == null) return ''; + // marked is loaded globally via CDN in index.html + const html = sanitizeMarkdown(window.marked.parse(text)); + const cls = opts.variant === 'msg' ? 'markdown-msg' + : opts.variant === 'compact' ? 'markdown-compact' + : 'markdown-body'; + const container = document.createElement('div'); + container.className = cls; + container.innerHTML = html; + if (opts.query) highlightTextNodes(container, opts.query.trim()); + return container.outerHTML; +} + +// --- Duration / tokens / tooltip --- + +export function fmtDuration(ms) { + if (!ms) return '—'; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const parts = []; + if (d) parts.push(`${d}d`); + if (h) parts.push(`${h}h`); + if (m) parts.push(`${m}m`); + if (sec || !parts.length) parts.push(`${sec}s`); + return parts.join(' '); +} + +export function fmtTokens(n) { + if (!n) return '0'; + if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B'; + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'; + return String(n); +} + +export function fmtTooltipDate(isoDay) { + const d = new Date(isoDay + 'T00:00:00'); + const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const day = d.getDate(); + const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th'; + const thisYear = new Date().getFullYear(); + if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`; + return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`; +} + +export function positionTooltip(el, x, y) { + const pad = 12; + const rect = el.getBoundingClientRect(); + let left = x + pad; + if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad; + el.style.left = left + 'px'; + el.style.top = (y - 28) + 'px'; +} + +// --- Project label --- + +export function formatProjectLabel(slug) { + if (!slug) return '(no project)'; + // Find the shortest project_path for this slug (most likely the project root) + const sessions = state.sessions.filter(s => s.project === slug && s.project_path); + if (sessions.length) { + const shortest = sessions.reduce((a, b) => a.project_path.length <= b.project_path.length ? a : b); + const parts = shortest.project_path.split('/'); + return parts[parts.length - 1]; + } + return slug.replace(/^-/, ''); +} diff --git a/app/src/renderer/src/views/Activity.vue b/app/src/renderer/src/views/Activity.vue new file mode 100644 index 0000000..1320564 --- /dev/null +++ b/app/src/renderer/src/views/Activity.vue @@ -0,0 +1,953 @@ + + + + + diff --git a/app/src/renderer/src/views/MemoryDetail.vue b/app/src/renderer/src/views/MemoryDetail.vue new file mode 100644 index 0000000..64fd7c0 --- /dev/null +++ b/app/src/renderer/src/views/MemoryDetail.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/src/renderer/src/views/MemoryList.vue b/app/src/renderer/src/views/MemoryList.vue new file mode 100644 index 0000000..0ea0ef9 --- /dev/null +++ b/app/src/renderer/src/views/MemoryList.vue @@ -0,0 +1,786 @@ + + + + + diff --git a/app/src/renderer/src/views/RecapDetail.vue b/app/src/renderer/src/views/RecapDetail.vue new file mode 100644 index 0000000..aab3f15 --- /dev/null +++ b/app/src/renderer/src/views/RecapDetail.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/app/src/renderer/src/views/RecapExport.vue b/app/src/renderer/src/views/RecapExport.vue new file mode 100644 index 0000000..c3d7e19 --- /dev/null +++ b/app/src/renderer/src/views/RecapExport.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/app/src/renderer/src/views/RecapList.vue b/app/src/renderer/src/views/RecapList.vue new file mode 100644 index 0000000..d7f09a3 --- /dev/null +++ b/app/src/renderer/src/views/RecapList.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue new file mode 100644 index 0000000..85e3ca6 --- /dev/null +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -0,0 +1,1001 @@ + + + + + diff --git a/app/src/renderer/src/views/SessionList.vue b/app/src/renderer/src/views/SessionList.vue new file mode 100644 index 0000000..0a1fd12 --- /dev/null +++ b/app/src/renderer/src/views/SessionList.vue @@ -0,0 +1,448 @@ + + + + + diff --git a/app/src/renderer/src/views/Settings.vue b/app/src/renderer/src/views/Settings.vue new file mode 100644 index 0000000..0bf9376 --- /dev/null +++ b/app/src/renderer/src/views/Settings.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/app/src/renderer/src/views/SubagentDetail.vue b/app/src/renderer/src/views/SubagentDetail.vue new file mode 100644 index 0000000..54230f7 --- /dev/null +++ b/app/src/renderer/src/views/SubagentDetail.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/app/src/renderer/styles/base.css b/app/src/renderer/styles/base.css new file mode 100644 index 0000000..5217f48 --- /dev/null +++ b/app/src/renderer/styles/base.css @@ -0,0 +1,107 @@ +:root { + --bg: #0a0b14; + --bg-2: #11131f; + --surface: rgba(255,255,255,0.03); + --surface-strong: rgba(255,255,255,0.06); + --surface-hi: rgba(255,255,255,0.09); + --fg: rgba(255,255,255,0.92); + --fg-2: rgba(255,255,255,0.72); + --muted: rgba(255,255,255,0.48); + --muted-2: rgba(255,255,255,0.28); + --edge-hi: rgba(255,255,255,0.08); + --edge-lo: rgba(0,0,0,0.35); + --hairline: rgba(255,255,255,0.05); + --hairline-strong: rgba(255,255,255,0.08); + --accent: #a78bfa; + --accent-2: #c4b5fd; + --accent-glow: rgba(167,139,250,0.35); + --accent-soft: rgba(167,139,250,0.12); + --danger: #f87171; + --danger-soft: rgba(248,113,113,0.12); + --warn: #fbbf24; + --warn-soft: rgba(251,191,36,0.14); + --workflow: #f59e0b; + --workflow-soft: rgba(245,158,11,0.12); + --workflow-strong: rgba(245,158,11,0.28); + --user-bubble: rgba(167,139,250,0.08); + --user-bubble-border: rgba(167,139,250,0.18); + --asst-bubble: rgba(255,255,255,0.025); + --asst-bubble-border: rgba(255,255,255,0.06); + --font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif; + --font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace; + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --row-h: 88px; + --row-h-session: 64px; + --row-h-compact: 28px; + --col-sidebar: 220px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } +body { + color: var(--fg); + font: var(--text-base)/1.4 var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + background-color: var(--bg); + background-image: + radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.14), transparent 55%), + radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.12), transparent 60%), + radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.16), transparent 60%), + linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%); +} +body::before { + content: ''; + position: fixed; inset: 0; + pointer-events: none; z-index: 1; + opacity: 0.3; + background-image: url("data:image/svg+xml;utf8,"); + mix-blend-mode: overlay; +} +button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; } +button:disabled { cursor: not-allowed; } +input { font: inherit; color: inherit; } +::selection { background: var(--accent-soft); color: var(--fg); } +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); background-clip: padding-box; border: 2px solid transparent; } + +.titlebar { + height: 32px; width: 100%; + -webkit-app-region: drag; + background: rgba(0,0,0,0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--hairline); + flex-shrink: 0; z-index: 100; + display: flex; align-items: center; justify-content: center; + padding: 0 16px 0 78px; +} +.titlebar-text { + font-size: var(--text-sm); + color: var(--muted); + font-weight: 500; + letter-spacing: -0.005em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 100%; user-select: none; pointer-events: none; + display: inline-block; +} +.titlebar-text .app-name { color: var(--fg-2); font-weight: 600; } +.titlebar-text .sep { margin: 0 6px; color: var(--muted-2); } +.titlebar-text .scope { color: var(--muted); } +.titlebar-text .scope-leaf { color: var(--fg-2); font-family: var(--font-mono); font-size: 11.5px; } + +button, input, .row, .sidebar-item, .toolbar-btn, +.row-action, .row-checkbox, .crumb, .provenance-link, +.banner-action, .source-toggle, .anchor-link, +.session-link, .msg-tool, .toolcall-toggle, +.agent-indicator, .agent-row, .filter-toggle, +.summary-toggle { + -webkit-app-region: no-drag; +} + +.app { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; } +.columns { flex: 1; display: grid; grid-template-columns: var(--col-sidebar) 1fr; min-height: 0; } diff --git a/app/src/renderer/styles/detail.css b/app/src/renderer/styles/detail.css new file mode 100644 index 0000000..af14c15 --- /dev/null +++ b/app/src/renderer/styles/detail.css @@ -0,0 +1,1210 @@ +.detail { max-width: 720px; margin: 0 auto; padding: 32px 32px 60px; } +.detail-wide { max-width: 860px; margin: 0 auto; padding: 28px 32px 60px; } + +/* Usage page */ +.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; } +.usage-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 24px; +} +.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; } +.usage-subtitle { font-size: 13px; color: var(--muted); } + +.usage-view-tabs { display: flex; gap: 0; } +.usage-tab { + padding: 5px 12px; font-size: 12px; font-family: var(--font-mono); + color: var(--muted); background: transparent; + border: 1px solid var(--hairline); cursor: pointer; + transition: all 0.1s; +} +.usage-tab:first-child { border-radius: 4px 0 0 4px; } +.usage-tab:last-child { border-radius: 0 4px 4px 0; } +.usage-tab:not(:first-child) { border-left: 0; } +.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); } +.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); } + +.usage-stats { + display: flex; gap: 0; margin-bottom: 32px; + border-radius: 8px; + background: var(--surface); border: 1px solid var(--hairline); + overflow: hidden; +} +.usage-stat { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 4px; padding: 16px 12px; + border-right: 1px solid var(--hairline); +} +.usage-stat:last-child { border-right: 0; } +.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; } +.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; } + +.heatmap-container { margin-top: 8px; } +.heatmap { display: block; width: 100%; height: auto; } +.heatmap-cell { transition: opacity 0.08s; } +.heatmap-cell.level-0 { fill: var(--surface-strong); } +.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); } +.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); } +.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); } +.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); } +.heatmap-cell:hover { opacity: 0.7; } +.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; } +.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); } + +/* Day sessions panel (below heatmap on click) */ +.day-sessions { margin-top: 24px; } +.day-sessions-header { + font-size: 14px; font-weight: 600; color: var(--fg); + margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px; + border-bottom: 1px solid var(--hairline); +} +.day-sessions-header:first-child { margin-top: 0; } + +.day-activity-timeline { + display: flex; flex-direction: column; gap: 20px; + padding-left: 16px; border-left: 2px solid var(--hairline); +} + +.activity-group { position: relative; } +.activity-group-header { + display: flex; align-items: center; gap: 10px; + margin-bottom: 8px; font-size: 14px; color: var(--fg); + font-weight: 500; +} +.activity-icon { + width: 24px; height: 24px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; flex-shrink: 0; + margin-left: -28px; + border: 2px solid var(--bg); +} +.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; } +.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); } +.activity-icon.continued { background: var(--surface-strong); color: var(--muted); } + +.activity-group-title { font-size: 13px; } +.activity-group.continued .activity-group-title { color: var(--muted); } + +.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; } +.activity-item { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; border-radius: 5px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.activity-item:hover { background: var(--surface-strong); } +.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; } +.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; } +.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; } + +.activity-group.continued .activity-item-name { color: var(--fg-2); } + +.show-more-btn { + display: block; width: 100%; margin-top: 20px; + padding: 8px; border-radius: 4px; + background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2); + color: var(--accent-2); font-size: 12px; font-family: var(--font-mono); + cursor: pointer; transition: all 0.1s; text-align: center; +} +.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); } + +.heatmap-legend { + display: flex; align-items: center; gap: 6px; + margin-top: 12px; justify-content: flex-end; +} +.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); } + +/* Chart container (weekly / cumulative) */ +.chart-container { margin-top: 8px; overflow-x: auto; } +.chart-container svg { display: block; width: 100%; max-height: 160px; } + +.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; } +.bar-fill:hover { opacity: 1; } + +.cumulative-area { fill: rgba(99, 102, 241, 0.12); } +.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; } +.cumulative-dot:hover { opacity: 1; } + +/* Chart tooltip */ +.chart-tooltip { + position: fixed; z-index: 200; + padding: 5px 10px; border-radius: 4px; + background: rgba(30, 35, 50, 0.95); + border: 1px solid var(--hairline-strong); + color: var(--fg-2); + font-family: var(--font-mono); font-size: 11px; + pointer-events: none; opacity: 0; + white-space: nowrap; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.chart-tooltip.show { opacity: 1; } + +/* Session progress bar */ +.session-progress { + position: sticky; top: 0; z-index: 10; + height: 2px; background: var(--hairline); + margin: 0 -32px 0; + flex-shrink: 0; +} +.session-progress-fill { + height: 100%; background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + transition: width 0.15s ease-out; + width: 0%; +} + +.detail-banner { + display: flex; align-items: flex-start; gap: 10px; + padding: 10px 12px; border-radius: 6px; margin-bottom: 18px; + font-size: var(--text-base); line-height: 1.5; +} +.detail-banner.broken { background: var(--danger-soft); border: 1px solid rgba(248,113,113,0.25); color: var(--fg); } +.detail-banner.partial { background: var(--warn-soft); border: 1px solid rgba(251,191,36,0.25); color: var(--fg); } +.detail-banner-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; } +.detail-banner.broken .detail-banner-icon { color: var(--danger); } +.detail-banner.partial .detail-banner-icon { color: var(--warn); } +.detail-banner-body { flex: 1; min-width: 0; } +.detail-banner-body strong { font-weight: 600; } +.detail-banner-body ul { margin-top: 4px; padding-left: 16px; color: var(--fg-2); font-size: var(--text-sm); } +.detail-banner-body li { list-style: disc; margin: 2px 0; } +.detail-banner-actions { display: flex; gap: 6px; margin-top: 8px; } +.banner-action { + height: 24px; padding: 0 10px; border-radius: 4px; + border: 1px solid var(--hairline-strong); + background: var(--surface); + color: var(--fg-2); font-size: var(--text-sm); + transition: all 0.1s; +} +.banner-action:hover { background: var(--surface-strong); color: var(--fg); } +.banner-action.danger { color: var(--danger); border-color: rgba(248,113,113,0.3); } +.banner-action.danger:hover { background: var(--danger-soft); } + +.detail-header { margin-bottom: 24px; } +.detail-eyebrow { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--muted); + margin-bottom: 14px; flex-wrap: wrap; +} +.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); } +.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; } +.detail-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); } +.detail-eyebrow .sep { color: var(--muted-2); margin: 0 2px; } +.detail-eyebrow .archived-tag { + color: var(--accent-2); + display: inline-flex; align-items: center; gap: 5px; + margin-left: auto; +} +.detail-eyebrow .archived-tag::before { + content: ''; width: 6px; height: 6px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 6px var(--accent-glow); +} +.detail-path { + font-family: var(--font-mono); font-size: 17px; font-weight: 500; + color: var(--fg); line-height: 1.5; + word-break: break-all; margin-bottom: 16px; +} +.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; } +.detail-meta { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + font-family: var(--font-mono); font-size: var(--text-sm); + color: var(--muted); font-variant-numeric: tabular-nums; + padding-bottom: 16px; border-bottom: 1px solid var(--hairline); +} +.detail-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + +.session-link { + color: var(--accent-2); border: 0; background: transparent; + padding: 2px 5px; margin: -2px 0; border-radius: 3px; + font: inherit; cursor: pointer; transition: all 0.1s; + text-decoration: underline; text-decoration-color: var(--accent-soft); + text-underline-offset: 3px; + display: inline-flex; align-items: center; gap: 5px; +} +.session-link:hover { background: var(--accent-soft); color: var(--accent-2); text-decoration-color: var(--accent-2); } +.session-link svg { width: 11px; height: 11px; } + +.markdown-section { margin: 28px 0 8px; } +.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.markdown-toolbar-label { + font-size: 10.5px; color: var(--muted); + font-weight: 500; letter-spacing: 0.04em; flex: 1; +} +.source-toggle { + height: 22px; padding: 0 8px; border-radius: 4px; + border: 1px solid var(--hairline-strong); background: var(--surface); + color: var(--muted); font-size: var(--text-sm); + transition: all 0.1s; +} +.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); } +.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); } + +.markdown-body { font-size: var(--text-md); line-height: 1.65; color: var(--fg); word-wrap: break-word; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1.5em 0 0.5em; line-height: 1.3; +} +.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; } +.markdown-body h1 { font-size: 20px; } +.markdown-body h2 { font-size: 17px; } +.markdown-body h3 { font-size: 15px; } +.markdown-body p { margin: 0.6em 0; } +.markdown-body ul, .markdown-body ol { margin: 0.6em 0; padding-left: 24px; } +.markdown-body li { margin: 0.2em 0; } +.markdown-body code { + font-family: var(--font-mono); font-size: 12.5px; + background: rgba(255,255,255,0.06); padding: 1px 5px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-body pre { + background: rgba(0,0,0,0.4); + border: 1px solid var(--hairline); + border-radius: 6px; padding: 12px 14px; + overflow-x: auto; margin: 0.8em 0; +} +.markdown-body pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 12px; line-height: 1.55; } +.markdown-body blockquote { + margin: 0.8em 0; padding: 0 0 0 14px; + border-left: 2px solid var(--accent-soft); + color: var(--fg-2); font-style: italic; +} +.markdown-body a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-body hr { border: 0; border-top: 1px solid var(--hairline); margin: 1.5em 0; } +.markdown-body table { border-collapse: collapse; margin: 0.8em 0; font-size: 12.5px; } +.markdown-body th, .markdown-body td { border: 1px solid var(--hairline); padding: 6px 10px; text-align: left; } +.markdown-body th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-source { + background: rgba(0,0,0,0.3); border: 1px solid var(--hairline); + border-radius: 6px; padding: 14px 16px; + font-family: var(--font-mono); font-size: 12px; line-height: 1.55; + color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word; +} + +.markdown-compact { font-size: var(--text-sm); line-height: 1.55; color: var(--fg-2); word-wrap: break-word; } +.markdown-compact h1, .markdown-compact h2, .markdown-compact h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1em 0 0.4em; line-height: 1.3; color: var(--fg); +} +.markdown-compact h1:first-child, .markdown-compact h2:first-child, .markdown-compact h3:first-child { margin-top: 0; } +.markdown-compact h1 { font-size: var(--text-md); } +.markdown-compact h2 { font-size: var(--text-base); } +.markdown-compact h3 { font-size: var(--text-sm); } +.markdown-compact p { margin: 0.5em 0; } +.markdown-compact p:first-child { margin-top: 0; } +.markdown-compact p:last-child { margin-bottom: 0; } +.markdown-compact ul, .markdown-compact ol { margin: 0.5em 0; padding-left: 20px; } +.markdown-compact li { margin: 0.15em 0; } +.markdown-compact code { + font-family: var(--font-mono); font-size: 11.5px; + background: rgba(255,255,255,0.06); padding: 1px 4px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-compact pre { + background: rgba(0,0,0,0.4); border: 1px solid var(--hairline); + border-radius: 4px; padding: 8px 10px; + overflow-x: auto; margin: 0.6em 0; +} +.markdown-compact pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11px; line-height: 1.5; } +.markdown-compact blockquote { + margin: 0.6em 0; padding: 0 0 0 12px; + border-left: 2px solid var(--accent-soft); + color: var(--muted); font-style: italic; +} +.markdown-compact strong { color: var(--fg); font-weight: 600; } +.markdown-compact a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-compact table { border-collapse: collapse; margin: 0.6em 0; font-size: 11.5px; } +.markdown-compact th, .markdown-compact td { border: 1px solid var(--hairline); padding: 4px 8px; text-align: left; } +.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.markdown-msg { + font-size: var(--text-base); + line-height: 1.75; + color: var(--fg); + word-wrap: break-word; + font-family: 'Helvetica Neue', 'Inter', -apple-system, system-ui, 'PingFang SC', 'Hiragino Sans GB', sans-serif; + letter-spacing: 0.005em; +} +.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 { + font-weight: 600; letter-spacing: -0.01em; + margin: 1em 0 0.4em; line-height: 1.3; +} +.markdown-msg h1:first-child, .markdown-msg h2:first-child, .markdown-msg h3:first-child { margin-top: 0; } +.markdown-msg h1 { font-size: 16px; } +.markdown-msg h2 { font-size: 15px; } +.markdown-msg h3 { font-size: var(--text-md); } +.markdown-msg p { margin: 0.5em 0; } +.markdown-msg p:first-child { margin-top: 0; } +.markdown-msg p:last-child { margin-bottom: 0; } +.markdown-msg ul, .markdown-msg ol { margin: 0.5em 0; padding-left: 22px; } +.markdown-msg li { margin: 0.18em 0; } +.markdown-msg code { + font-family: var(--font-mono); font-size: 12px; + background: rgba(255,255,255,0.06); padding: 1px 5px; + border-radius: 3px; color: var(--accent-2); +} +.markdown-msg pre { + background: rgba(0,0,0,0.4); border: 1px solid var(--hairline); + border-radius: 5px; padding: 10px 12px; + overflow-x: auto; margin: 0.7em 0; +} +.markdown-msg pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11.5px; line-height: 1.55; } +.markdown-msg blockquote { + margin: 0.6em 0; padding: 0 0 0 12px; + border-left: 2px solid var(--accent-soft); + color: var(--fg-2); font-style: italic; +} +.markdown-msg strong { font-weight: 600; } +.markdown-msg a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.markdown-msg table { border-collapse: collapse; margin: 0.6em 0; font-size: 12px; } +.markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; } +.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; } +.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.detail-section-divider { + display: flex; align-items: center; gap: 10px; + margin: 32px 0 14px; color: var(--muted); + font-size: 10.5px; font-weight: 500; letter-spacing: 0.04em; +} +.detail-section-divider::after { content: ''; flex: 1; height: 1px; background: var(--hairline); } +.detail-section-divider .count { font-family: var(--font-mono); color: var(--fg-2); } +.anchor-list { display: flex; flex-direction: column; gap: 4px; } +.anchor-link { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border-radius: 4px; + color: var(--fg-2); font-family: var(--font-mono); font-size: 12px; + transition: all 0.1s; cursor: pointer; + text-align: left; border: 0; background: transparent; width: 100%; +} +.anchor-link:hover { background: var(--surface-strong); color: var(--fg); } +.anchor-link .anchor-icon { width: 12px; height: 12px; color: var(--muted); flex-shrink: 0; } +.anchor-link:hover .anchor-icon { color: var(--accent-2); } +.anchor-link .anchor-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.anchor-link .anchor-line { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.anchor-link:disabled { + color: var(--muted-2); + text-decoration: line-through; text-decoration-color: var(--danger); text-decoration-thickness: 1px; + cursor: not-allowed; +} +.anchor-link:disabled .anchor-icon { color: var(--danger); } +.anchor-link:disabled:hover { background: transparent; color: var(--muted-2); } + +.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; } +.detail-actions .btn { + height: 30px; padding: 0 14px; border-radius: 6px; + font-size: var(--text-base); font-weight: 500; + transition: all 0.1s; + display: inline-flex; align-items: center; gap: 8px; + border: 1px solid var(--hairline-strong); + color: var(--fg-2); background: var(--surface); +} +.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); } +.detail-actions .btn.danger { color: var(--danger); } +.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); } +.detail-actions .btn.primary { color: var(--accent-2); } +.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); } +.detail-actions .btn .kbd { + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px; + line-height: 1.4; +} + +.session-header { + margin-bottom: 28px; padding-bottom: 20px; + border-bottom: 1px solid var(--hairline); +} +.session-eyebrow { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--muted); + margin-bottom: 12px; flex-wrap: wrap; +} +.session-eyebrow .project-icon { width: 13px; height: 13px; } +.session-eyebrow .project-name { color: var(--fg-2); font-weight: 500; } +.session-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); } +.session-eyebrow .sep { color: var(--muted-2); margin: 0 2px; } +.session-eyebrow .via { + display: inline-flex; align-items: center; gap: 5px; + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); + letter-spacing: 0.02em; + padding: 1px 7px; background: rgba(255,255,255,0.04); + border: 1px solid var(--hairline); border-radius: 3px; + margin-left: 6px; +} +.session-eyebrow .via .via-dot { width: 4px; height: 4px; border-radius: 50%; } +.session-eyebrow .via .via-dot.claude { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); } +.session-eyebrow .via .via-dot.codex { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); } +.session-title { + font-size: 22px; font-weight: 600; color: var(--fg); + line-height: 1.3; margin-bottom: 14px; letter-spacing: -0.01em; +} +.session-meta-inline { + display: flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: var(--text-sm); + color: var(--muted); font-variant-numeric: tabular-nums; + flex-wrap: wrap; +} +.session-meta-inline .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + +.timeline { display: flex; flex-direction: column; gap: 14px; } +.msg { + border-radius: 8px; padding: 12px 14px; + border: 1px solid; position: relative; + transition: border-color 0.6s ease-out, box-shadow 0.6s ease-out; +} +.msg.user { background: var(--user-bubble); border-color: var(--user-bubble-border); } +.msg.assistant { background: var(--asst-bubble); border-color: var(--asst-bubble-border); } +.msg.is-focused { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow); + animation: focus-pulse 2s ease-out forwards; +} +@keyframes focus-pulse { + 0% { box-shadow: 0 0 0 2px var(--accent), 0 0 30px var(--accent-glow); } + 70% { box-shadow: 0 0 0 1px var(--accent), 0 0 15px var(--accent-glow); } + 100% { box-shadow: none; border-color: var(--asst-bubble-border); } +} +.msg-head { + display: flex; align-items: center; gap: 8px; + font-size: 11px; color: var(--muted); + margin-bottom: 8px; font-family: var(--font-mono); +} +.msg-head .role { + font-weight: 600; color: var(--fg-2); + text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em; +} +.msg.user .msg-head .role { color: var(--accent-2); } +.msg-head .when { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.msg-text { + font-size: var(--text-base); line-height: 1.55; color: var(--fg); + white-space: pre-wrap; word-wrap: break-word; +} +.msg-text.empty-text { color: var(--muted-2); font-style: italic; } +.msg-text mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } + +.msg-summary { + margin-top: 12px; + border: 1px solid var(--hairline); + border-left: 3px solid var(--accent-soft); + border-radius: 5px; + background: rgba(167,139,250,0.04); + overflow: hidden; +} +.summary-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 12px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.summary-toggle:hover { background: rgba(167,139,250,0.06); } +.summary-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-summary.open .summary-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.summary-toggle .label { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--accent-2); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; +} +.summary-toggle .source { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-left: 4px; } +.summary-body { + display: none; padding: 8px 14px 12px; + border-top: 1px solid var(--hairline); +} +.msg-summary.open .summary-body { display: block; } + +.msg-tools { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; } +.msg-tool { + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.2); + overflow: hidden; transition: border-color 0.1s; +} +.msg-tool.is-error { border-color: rgba(248,113,113,0.3); background: var(--danger-soft); } +.toolcall-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 6px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.toolcall-toggle:hover { background: rgba(255,255,255,0.03); } +.toolcall-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.toolcall-toggle .tool-icon { + width: 14px; height: 14px; color: var(--accent-2); flex-shrink: 0; + display: inline-flex; align-items: center; +} +.toolcall-toggle .tool-icon svg { width: 14px; height: 14px; } +.msg-tool.is-error .toolcall-toggle .tool-icon { color: var(--danger); } +.toolcall-toggle .tool-name { + font-family: var(--font-mono); font-size: 11px; + color: var(--accent-2); font-weight: 600; flex-shrink: 0; +} +.msg-tool.is-error .toolcall-toggle .tool-name { color: var(--danger); } +.toolcall-toggle .tool-arg { + font-family: var(--font-mono); font-size: 11px; color: var(--fg-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.toolcall-toggle .tool-error { + font-size: 10px; color: var(--danger); + padding: 1px 6px; background: rgba(248,113,113,0.18); border-radius: 3px; + flex-shrink: 0; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.toolcall-body { + display: none; + border-top: 1px solid var(--hairline); + background: rgba(0,0,0,0.32); +} +.msg-tool.open .toolcall-body { display: block; } + +.toolcall-body-strip { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--hairline); + background: rgba(0,0,0,0.18); +} +.toolcall-body-strip .strip-label { + font-family: var(--font-mono); font-size: 10px; + color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase; +} +.toolcall-body-strip .spacer { flex: 1; } +.raw-toggle { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 7px; border-radius: 3px; + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + border: 1px solid var(--hairline); cursor: pointer; transition: all 0.1s; +} +.raw-toggle:hover { color: var(--fg-2); border-color: var(--hairline-strong); background: var(--surface-strong); } +.raw-toggle.active { color: var(--accent-2); border-color: var(--accent-soft); background: var(--accent-soft); } + +.toolcall-pretty { padding: 10px 12px; } +.toolcall-pretty.hidden { display: none; } + +.toolcall-body .tc-section { + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + letter-spacing: 0.05em; text-transform: uppercase; + margin: 0 0 5px; font-weight: 500; +} + +.toolcall-raw { + display: none; padding: 12px 14px; max-height: 400px; overflow: auto; +} +.toolcall-raw.show { display: block; } +.toolcall-raw .tc-section { + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + letter-spacing: 0.05em; text-transform: uppercase; + margin: 0 0 5px; font-weight: 500; +} +.toolcall-raw .tc-section + pre { margin-bottom: 12px; } +.toolcall-raw pre { + font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; + color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word; +} + +/* File reference chip */ +.file-ref { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 8px; border-radius: 4px; + background: rgba(255,255,255,0.04); border: 1px solid var(--hairline); + font-family: var(--font-mono); font-size: 11.5px; color: var(--fg); +} +.file-ref .file-line { color: var(--muted); margin-left: 2px; } + +/* File content viewer */ +.file-content { + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.4); overflow: hidden; +} +.file-content-head { + display: flex; align-items: center; gap: 10px; + padding: 6px 10px; background: rgba(255,255,255,0.02); + border-bottom: 1px solid var(--hairline); + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); +} +.file-content-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; } +.file-content-head .meta { margin-left: auto; } +.file-content-body { + display: grid; grid-template-columns: max-content 1fr; + font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; + max-height: 320px; overflow: auto; +} +.file-content-body.collapsed { max-height: 180px; } +.file-content-body .gutter { + padding: 6px 10px 6px 12px; color: var(--muted-2); user-select: none; + text-align: right; background: rgba(255,255,255,0.015); + border-right: 1px solid var(--hairline); white-space: pre; +} +.file-content-body .code { padding: 6px 12px; color: var(--fg-2); white-space: pre; overflow-x: auto; } +.file-content-expand { + display: flex; align-items: center; justify-content: center; gap: 6px; + padding: 6px; border-top: 1px solid var(--hairline); + background: rgba(255,255,255,0.02); + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); + width: 100%; cursor: pointer; transition: all 0.1s; border: none; +} +.file-content-expand:hover { color: var(--fg-2); background: var(--surface-strong); } + +/* Diff view */ +.diff-view { + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.4); overflow: hidden; +} +.diff-view-head { + display: flex; align-items: center; gap: 10px; + padding: 6px 10px; background: rgba(255,255,255,0.02); + border-bottom: 1px solid var(--hairline); + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); +} +.diff-view-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; } +.diff-view-head .stats { margin-left: auto; display: flex; gap: 8px; } +.diff-view-head .stat-add { color: rgba(165,180,252,0.85); } +.diff-view-head .stat-del { color: rgba(249,168,212,0.7); } +.diff-body { + display: grid; grid-template-columns: max-content 1fr; + font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; + max-height: 380px; overflow: auto; +} +.diff-body .diff-gutter { + padding: 0 10px 0 12px; color: var(--muted-2); user-select: none; + text-align: right; background: rgba(255,255,255,0.015); + border-right: 1px solid var(--hairline); white-space: pre; +} +.diff-body .diff-line { padding: 0 12px; white-space: pre; } +.diff-body .diff-line.add { background: rgba(99,102,241,0.06); color: rgba(165,180,252,0.85); } +.diff-body .diff-line.del { background: rgba(236,72,153,0.06); color: rgba(249,168,212,0.6); text-decoration: line-through; text-decoration-color: rgba(249,168,212,0.25); } +.diff-body .diff-line.context { color: var(--fg-2); } +.diff-body .diff-gutter.add { background: rgba(99,102,241,0.04); color: rgba(99,102,241,0.55); } +.diff-body .diff-gutter.del { background: rgba(236,72,153,0.04); color: rgba(236,72,153,0.45); } + +/* Terminal view */ +.terminal-view { + border: 1px solid var(--hairline); border-radius: 5px; + background: #07090f; overflow: hidden; + font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; +} +.terminal-prompt-line { + display: flex; gap: 8px; padding: 8px 12px; + background: rgba(255,255,255,0.03); +} +.terminal-prompt-line .prompt-marker { color: #4ade80; font-weight: 600; user-select: none; flex-shrink: 0; } +.terminal-prompt-line .prompt-cmd { color: var(--fg); white-space: pre-wrap; word-break: break-all; } +.terminal-divider { + height: 1px; + background: rgba(255,255,255,0.06); +} +.terminal-output { + padding: 8px 12px; color: rgba(255,255,255,0.68); + white-space: pre-wrap; word-wrap: break-word; + max-height: 300px; overflow: auto; + border-left: 2px solid rgba(255,255,255,0.06); + margin-left: 10px; +} +.terminal-output.is-error { + color: #fca5a5; + border-left-color: rgba(248,113,113,0.3); +} + +/* Field grid (generic fallback) */ +.field-grid { + display: grid; grid-template-columns: max-content 1fr; + gap: 4px 14px; font-family: var(--font-mono); font-size: 11.5px; + align-items: start; +} +.field-grid .field-key { color: var(--muted); font-weight: 500; padding-top: 1px; } +.field-grid .field-val { color: var(--fg-2); word-break: break-word; min-width: 0; } +.field-grid .field-val .literal-string { color: var(--accent-2); } +.field-grid .field-val .literal-num { color: #fcd34d; } +.field-grid .field-val .literal-bool { color: #4ade80; } +.field-grid .field-val .literal-null { color: var(--muted); font-style: italic; } + +/* Long string expand */ +.lit-string-long { display: inline; color: var(--accent-2); cursor: pointer; } +.lit-string-long .long-rest { display: none; } +.lit-string-long.open .long-rest { display: inline; } +.lit-string-long.open .more-btn { display: none; } +.lit-string-long .more-btn { + display: inline-block; margin-left: 6px; + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + padding: 0 5px; border: 1px solid var(--hairline-strong); + border-radius: 3px; vertical-align: middle; cursor: pointer; + transition: all 0.1s; +} +.lit-string-long .more-btn:hover { color: var(--fg-2); border-color: var(--hairline); background: var(--surface-strong); } + +/* Body section label */ +.body-label { + font-family: var(--font-mono); font-size: 9.5px; + color: var(--muted); letter-spacing: 0.08em; + text-transform: uppercase; font-weight: 600; + margin-bottom: 6px; +} + +/* Result chip */ +.result-chip { + display: inline-flex; align-items: center; gap: 6px; + margin-top: 10px; padding: 5px 10px; border-radius: 4px; + background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.18); + font-size: 11.5px; color: var(--fg-2); +} +.result-chip.error { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.25); } + +/* Tool action label */ +.tool-action-label { + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + letter-spacing: 0.05em; text-transform: uppercase; + margin-bottom: 6px; +} + +/* Auto-table for list-of-objects output */ +.auto-table-wrap { + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.32); overflow: hidden; +} +.auto-table-head { + padding: 6px 10px; background: rgba(255,255,255,0.02); + border-bottom: 1px solid var(--hairline); + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); + display: flex; align-items: center; gap: 8px; +} +.auto-table-head .h-label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; } +.auto-table-head .h-meta { margin-left: auto; } +.auto-table-scroll { max-height: 360px; overflow: auto; } +.auto-table { + width: 100%; border-collapse: collapse; + font-family: var(--font-mono); font-size: 11.5px; +} +.auto-table th, .auto-table td { + padding: 6px 10px; text-align: left; + border-bottom: 1px solid var(--hairline); + vertical-align: top; line-height: 1.45; +} +.auto-table th { + background: rgba(255,255,255,0.025); + font-weight: 500; font-size: 10px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.04em; + position: sticky; top: 0; white-space: nowrap; +} +.auto-table tr:last-child td { border-bottom: 0; } +.auto-table tr:hover td { background: rgba(255,255,255,0.015); } + +/* Skill card (standalone timeline item) */ +.skill-card { + display: flex; align-items: flex-start; gap: 12px; + padding: 12px 14px; + border-radius: 8px; + background: rgba(6, 182, 212, 0.04); + border: 1px solid rgba(6, 182, 212, 0.15); + border-left: 3px solid rgba(6, 182, 212, 0.5); +} +.skill-card-icon { + width: 28px; height: 28px; flex-shrink: 0; + display: grid; place-items: center; + border-radius: 6px; + background: rgba(6, 182, 212, 0.1); + color: #67e8f9; +} +.skill-card-icon svg { width: 14px; height: 14px; } +.skill-card-body { flex: 1; min-width: 0; } +.skill-card-header { + display: flex; align-items: center; gap: 8px; + margin-bottom: 4px; +} +.skill-card-badge { + font-family: var(--font-mono); font-size: 10px; + font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; + color: #67e8f9; + padding: 1px 6px; border-radius: 3px; + background: rgba(6, 182, 212, 0.15); +} +.skill-card-name { + font-size: 14px; font-weight: 500; color: var(--fg); +} +.skill-card-args { + font-size: 12px; color: var(--fg-3); line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} + +.skill-card-md { margin-top: 8px; } +.skill-md-toggle { + display: flex; align-items: center; gap: 6px; + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); cursor: pointer; + border: none; background: none; padding: 2px 0; + transition: color 0.1s; +} +.skill-md-toggle:hover { color: var(--fg-2); } +.skill-md-toggle .chevron { + width: 8px; height: 8px; transition: transform 0.15s; +} +.skill-card.skill-md-open .skill-md-toggle .chevron { transform: rotate(90deg); } +.skill-md-body { + display: none; + margin-top: 8px; padding: 12px; + max-height: 400px; overflow-y: auto; + border: 1px solid var(--hairline); border-radius: 5px; + background: rgba(0,0,0,0.3); +} +.skill-card.skill-md-open .skill-md-body { display: block; } + +/* Skill badge (inside assistant bubble, for mixed messages) */ +.skill-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + border-radius: 4px; + background: var(--surface); + border: 1px solid var(--hairline); + font-family: var(--font-mono); font-size: 11px; +} +.skill-badge .skill-label { color: var(--muted); } +.skill-badge .skill-name { color: var(--accent-2); font-weight: 500; } + +.tc-subagent { + margin-top: 8px; padding: 8px 10px; + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 4px; background: var(--workflow-soft); +} +.tc-subagent-head { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10.5px; + color: var(--workflow); margin-bottom: 6px; +} +.tc-subagent-head svg { width: 11px; height: 11px; flex-shrink: 0; } +.tc-subagent-head .label { + font-weight: 600; text-transform: uppercase; + font-size: 9.5px; letter-spacing: 0.06em; +} +.tc-subagent-head .type { + color: var(--fg-2); font-weight: 500; + text-transform: none; letter-spacing: 0; +} + +.agent-indicator { + margin-top: 10px; display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; border-radius: 5px; + border: 1px solid var(--workflow-soft); + background: var(--workflow-soft); + font-family: var(--font-mono); font-size: 11px; + color: var(--workflow); + cursor: pointer; transition: all 0.1s; text-align: left; +} +.agent-indicator:hover { background: var(--workflow-strong); border-color: var(--workflow); } +.agent-indicator svg { width: 11px; height: 11px; flex-shrink: 0; } +.agent-indicator .chevron { transition: transform 0.15s; } +.agent-indicator.open .chevron { transform: rotate(90deg); } +.agent-indicator .label { color: var(--fg-2); } +.agent-indicator .meta { color: var(--muted); margin-left: 4px; } + +.workflow-card { + margin-top: 8px; + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 6px; + background: rgba(0,0,0,0.18); + overflow: hidden; display: none; +} +.workflow-card.show { display: block; } +.workflow-card-head { + padding: 10px 12px; + border-bottom: 1px solid var(--hairline); + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + font-family: var(--font-mono); font-size: 11px; +} +.workflow-card-head .title { + color: var(--fg); font-weight: 600; + font-size: var(--text-sm); font-family: var(--font-sans); + letter-spacing: -0.005em; +} +.workflow-card-head .status { + padding: 1px 6px; border-radius: 3px; + font-size: 10px; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.workflow-card-head .status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-card-head .status.failed { background: var(--danger-soft); color: var(--danger); } +.workflow-card-head .meta { margin-left: auto; color: var(--muted); } +.agent-list { display: flex; flex-direction: column; } +.agent-row { + display: grid; grid-template-columns: 80px 1fr auto; gap: 10px; + align-items: center; padding: 10px 12px; + border-bottom: 1px solid var(--hairline); + transition: background 0.08s; cursor: pointer; + text-align: left; + border-left: 0; border-right: 0; border-top: 0; + background: transparent; color: inherit; font: inherit; width: 100%; +} +.agent-row:last-child { border-bottom: 0; } +.agent-row:hover { background: rgba(255,255,255,0.03); } +.agent-row .phase { + font-family: var(--font-mono); font-size: 10px; + color: var(--workflow); + text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; + padding: 2px 6px; background: var(--workflow-soft); + border-radius: 3px; text-align: center; +} +.agent-row .agent-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.agent-row .agent-label { + font-size: var(--text-sm); color: var(--fg); + font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.agent-row .agent-meta { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); +} +.agent-row .agent-state { + padding: 1px 5px; border-radius: 3px; + font-size: 9.5px; text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; +} +.agent-row .agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; } +.agent-row .agent-state.error { background: var(--danger-soft); color: var(--danger); } +.agent-row .chevron-r { + width: 8px; height: 8px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.agent-row.open .chevron-r { transform: rotate(90deg); color: var(--workflow); } +.agent-detail { + display: none; padding: 10px 14px 14px 102px; + border-bottom: 1px solid var(--hairline); + background: rgba(0,0,0,0.25); +} +.agent-detail.show { display: block; } + +/* Muted messages (all except first) - no opacity change */ + +/* Thinking messages - collapsed by default */ +.msg-thinking { + border: 1px solid var(--hairline); + border-radius: 5px; + background: rgba(0,0,0,0.15); + overflow: hidden; +} +.thinking-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.thinking-toggle:hover { background: rgba(255,255,255,0.03); } +.thinking-toggle .chevron { + width: 8px; height: 8px; color: var(--muted); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-thinking.open .thinking-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } +.thinking-toggle .thinking-label { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + flex-shrink: 0; +} +.thinking-toggle .thinking-preview { + font-size: 11px; color: var(--muted-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.thinking-body { + display: none; padding: 8px 14px 12px; + border-top: 1px solid var(--hairline); +} +.msg-thinking.open .thinking-body { display: block; } + +/* Truncated message button */ +.truncated-btn { + display: block; width: 100%; + margin-top: 8px; padding: 6px 12px; + font-family: var(--font-mono); font-size: 11px; + color: var(--accent-2); background: var(--accent-soft); + border: 1px solid rgba(167,139,250,0.2); + border-radius: 4px; cursor: pointer; + text-align: center; transition: all 0.1s; +} +.truncated-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); } + +/* Meta (system) messages - collapsed by default */ +.msg.meta { + border-color: var(--hairline); + background: transparent; + padding: 4px 10px; +} +.msg-meta-collapsed { + border: 1px solid var(--hairline); + border-radius: 4px; + background: rgba(0,0,0,0.1); + overflow: hidden; +} +.meta-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 5px 10px; + cursor: pointer; transition: background 0.08s; + text-align: left; border: 0; background: transparent; + color: inherit; font: inherit; +} +.meta-toggle:hover { background: rgba(255,255,255,0.03); } +.meta-toggle .chevron { + width: 8px; height: 8px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.msg-meta-collapsed.open .meta-toggle .chevron { transform: rotate(90deg); color: var(--muted); } +.meta-toggle .meta-label { + font-family: var(--font-mono); font-size: 10px; + color: var(--muted-2); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + flex-shrink: 0; +} +.meta-toggle .meta-preview { + font-size: 11px; color: var(--muted-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 1; min-width: 0; +} +.meta-body { + display: none; padding: 6px 12px 10px; + border-top: 1px solid var(--hairline); +} +.msg-meta-collapsed.open .meta-body { display: block; } + +/* Agent tool call special styling */ +.msg-tool.agent-call { border-color: var(--workflow-soft); border-left: 2px solid var(--workflow); } +.msg-tool.agent-call .tool-name { color: var(--workflow); } +.agent-prompt { + font-size: 12px; color: var(--fg-2); line-height: 1.5; + padding: 8px 10px; margin-bottom: 8px; + background: rgba(0,0,0,0.2); border-radius: 4px; + white-space: pre-wrap; word-wrap: break-word; +} +.agent-result { max-height: 400px; overflow-y: auto; } +.agent-nav-btn { + margin-left: auto; padding: 2px 8px; + font-family: var(--font-mono); font-size: 10.5px; + color: var(--workflow); background: var(--workflow-soft); + border: 1px solid rgba(245,158,11,0.25); border-radius: 3px; + cursor: pointer; transition: all 0.1s; + white-space: nowrap; +} +.agent-nav-btn:hover { background: rgba(245,158,11,0.25); border-color: var(--workflow); } + +/* Workflow agent list inside tool call */ +.workflow-agent-list { display: flex; flex-direction: column; gap: 12px; } +.workflow-phase-group {} +.workflow-phase-header { + font-family: var(--font-mono); font-size: 11px; + color: var(--muted); text-transform: uppercase; + letter-spacing: 0.04em; font-weight: 500; + padding: 4px 0 4px; margin-bottom: 2px; + border-bottom: 1px solid var(--hairline); +} +.workflow-phase-agents { display: flex; flex-direction: column; gap: 1px; } +.workflow-agent-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 10px; border-radius: 4px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.workflow-agent-row:hover { background: rgba(255,255,255,0.04); } +.workflow-agent-label { font-size: 12px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workflow-agent-row:hover .workflow-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.workflow-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 1px 5px; border-radius: 3px; flex-shrink: 0; } +.workflow-agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-agent-state.error { background: var(--danger-soft); color: var(--danger); } +.workflow-status { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: auto; } +.workflow-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.workflow-status.failed { background: var(--danger-soft); color: var(--danger); } + +/* Standalone workflow card (outside assistant bubble) */ +.wf-card { + border: 1px solid var(--workflow-soft); + border-left: 3px solid var(--workflow); + border-radius: 8px; + background: rgba(245, 158, 11, 0.04); + padding: 0; overflow: hidden; +} +.wf-card-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--workflow-soft); + background: rgba(245, 158, 11, 0.06); +} +.wf-card-icon { font-size: 14px; } +.wf-card-name { font-size: 14px; font-weight: 600; color: var(--fg); font-family: var(--font-mono); } +.wf-card-count { font-size: 11px; color: var(--muted); font-family: var(--font-mono); } +.wf-card-status { font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; border-radius: 3px; margin-left: auto; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; } +.wf-card-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; } +.wf-card-status.failed { background: var(--danger-soft); color: var(--danger); } +.wf-card-body { padding: 12px 16px; } +.wf-card-phase { margin-bottom: 12px; } +.wf-card-phase:last-child { margin-bottom: 0; } +.wf-card-phase-title { + font-family: var(--font-mono); font-size: 11px; + color: var(--workflow); text-transform: uppercase; + letter-spacing: 0.05em; font-weight: 600; + margin-bottom: 4px; padding-bottom: 4px; + border-bottom: 1px solid var(--hairline); +} +.wf-card-agent { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; border-radius: 4px; + background: transparent; border: 0; + cursor: pointer; transition: background 0.08s; + text-align: left; width: 100%; + font: inherit; color: inherit; +} +.wf-card-agent:hover { background: rgba(245, 158, 11, 0.08); } +.wf-card-agent-label { font-size: 13px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wf-card-agent:hover .wf-card-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; } +.wf-card-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 3px; flex-shrink: 0; } +.wf-card-agent-state.error { background: var(--danger-soft); color: var(--danger); } +.wf-card-agent-arrow { color: var(--muted-2); font-size: 12px; flex-shrink: 0; transition: color 0.08s, transform 0.08s; } +.wf-card-agent:hover .wf-card-agent-arrow { color: var(--accent-2); transform: translateX(2px); } + +/* Back to parent session link */ +.back-to-bar { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 0; margin-bottom: 12px; + font-size: 12px; font-family: var(--font-mono); + color: var(--accent-2); background: none; + border: none; cursor: pointer; + transition: color 0.1s; +} +.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; } + +/* Back to top floating button */ +/* Message pagination nav */ +.msg-nav { + position: fixed; bottom: 16px; + left: 50%; transform: translateX(-50%); + display: flex; align-items: center; gap: 4px; + padding: 5px 8px; + border-radius: 8px; + background: rgba(10, 11, 20, 0.85); + border: 1px solid var(--hairline-strong); + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + z-index: 10; +} +.msg-nav-btn { + width: 28px; height: 28px; + display: grid; place-items: center; + border-radius: 5px; border: none; background: none; + color: var(--muted); cursor: pointer; + transition: all 0.1s; +} +.msg-nav-btn:hover:not(:disabled) { color: var(--fg); background: var(--surface-strong); } +.msg-nav-btn:disabled { opacity: 0.25; cursor: default; } +.msg-nav-btn svg { width: 13px; height: 13px; } +.msg-nav-pos { + font-family: var(--font-mono); font-size: 11px; + color: var(--muted); padding: 0 8px; + font-variant-numeric: tabular-nums; +} +.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; } diff --git a/app/src/renderer/styles/list.css b/app/src/renderer/styles/list.css new file mode 100644 index 0000000..28dfaae --- /dev/null +++ b/app/src/renderer/styles/list.css @@ -0,0 +1,163 @@ + .list-wrap, .detail-wrap, .session-list-wrap, .session-detail-wrap { + flex: 1; overflow-y: auto; min-height: 0; + } + + .row { + display: grid; grid-template-columns: 22px 1fr auto; + align-items: start; column-gap: 12px; + padding: 14px 16px 14px 14px; + min-height: var(--row-h); + cursor: pointer; user-select: none; + border-bottom: 1px solid var(--hairline); + transition: background 0.06s; position: relative; + } + .row:last-child { border-bottom: 0; } + .row:hover { background: rgba(255,255,255,0.025); } + .row.cursor { background: var(--surface); } + .row.cursor::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--muted-2); + } + .row.selected { background: var(--accent-soft); } + .row.selected::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); + } + .row.cursor.selected { background: rgba(167,139,250,0.16); } + .row-checkbox { + width: 18px; height: 18px; margin-top: 1px; + border-radius: 4px; border: 1.5px solid var(--muted-2); + background: transparent; cursor: pointer; + display: grid; place-items: center; + opacity: 0; transition: all 0.1s; + justify-self: center; + } + .row:hover .row-checkbox, + .row.selected .row-checkbox, + .row.cursor .row-checkbox { opacity: 1; } + .row-checkbox:hover { border-color: var(--accent); } + .row-checkbox.checked { + background: var(--accent); border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); + } + .row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; } + .row-checkbox.checked svg { opacity: 1; } + .row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; } + .row-path { + font-family: var(--font-mono); font-size: var(--text-md); + font-weight: 500; color: var(--fg); line-height: 1.4; + display: flex; align-items: center; gap: 6px; min-width: 0; + } + .row-status { + display: inline-flex; align-items: center; justify-content: center; + width: 14px; height: 14px; flex-shrink: 0; + } + .row-status svg { width: 100%; height: 100%; } + .row-status.broken { color: var(--danger); } + .row-status.partial { color: var(--warn); } + .row-status.archived { color: var(--muted-2); } + .row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; } + .row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; } + .row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .row-path mark, .row-summary mark, .srow-title mark, .srow-snippet mark { + background: var(--accent-soft); color: var(--accent-2); + padding: 0 2px; border-radius: 2px; + } + .row-summary { + font-size: var(--text-base); color: var(--fg-2); + line-height: 1.5; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; word-break: break-word; + } + .row-right { + display: flex; flex-direction: column; align-items: flex-end; + gap: 10px; flex-shrink: 0; padding-top: 1px; + } + .row-meta { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; white-space: nowrap; + display: flex; gap: 8px; + } + .row:hover .row-meta { color: var(--muted-2); } + .row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; } + .row:hover .row-actions, .row.cursor .row-actions { opacity: 1; } + .row-action { + height: 24px; padding: 0 8px; border-radius: 4px; + color: var(--muted); font-size: var(--text-sm); + display: inline-flex; align-items: center; gap: 5px; + transition: all 0.1s; border: 1px solid transparent; + } + .row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); } + .row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); } + .row-action.restore { color: var(--accent-2); } + .row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); } + .row-action .kbd { + font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2); + padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4; + } + .row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); } + .row.archived .row-path, .row.archived .row-summary { color: var(--muted); } + .row.archived .row-path .project-prefix { color: var(--muted-2); } + + .srow { + display: grid; grid-template-columns: 1fr auto; + align-items: start; column-gap: 12px; + padding: 12px 16px; + min-height: var(--row-h-session); + cursor: pointer; user-select: none; + border-bottom: 1px solid var(--hairline); + transition: background 0.06s; position: relative; + } + .srow:hover { background: rgba(255,255,255,0.025); } + .srow.cursor { background: var(--surface); } + .srow.cursor::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: 2px; background: var(--muted-2); + } + .srow-obelisk { + position: absolute; left: 0; bottom: 0; + width: 3px; border-radius: 1.5px 1.5px 0 0; + } + .srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; } + .srow-title { + font-size: var(--text-md); font-weight: 500; color: var(--fg); + line-height: 1.35; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .srow-meta { + font-family: var(--font-mono); font-size: 11px; + color: var(--muted); + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + } + .srow-meta .project-tag { color: var(--fg-2); font-weight: 500; } + .srow-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; } + .srow-snippet { + margin-top: 4px; + font-size: var(--text-sm); color: var(--fg-2); line-height: 1.4; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; + padding-left: 14px; border-left: 2px solid var(--accent-soft); + } + .srow-snippet .snippet-label { + font-family: var(--font-mono); font-size: 9.5px; + color: var(--accent-2); letter-spacing: 0.04em; + text-transform: uppercase; margin-right: 6px; + } + .srow-right { + font-family: var(--font-mono); font-size: 11px; + color: var(--fg-2); text-align: right; + font-variant-numeric: tabular-nums; + flex-shrink: 0; padding-top: 2px; white-space: nowrap; + display: flex; flex-direction: column; gap: 2px; + } + .srow-right .srow-created { font-size: 10px; color: var(--muted); } + + .empty { + flex: 1; display: flex; align-items: center; justify-content: center; + color: var(--muted-2); font-size: var(--text-sm); + padding: 60px 20px; text-align: center; + flex-direction: column; gap: 8px; + } + .empty .hint { font-size: 11px; color: var(--muted-2); } diff --git a/app/src/renderer/styles/sidebar.css b/app/src/renderer/styles/sidebar.css new file mode 100644 index 0000000..cc800ed --- /dev/null +++ b/app/src/renderer/styles/sidebar.css @@ -0,0 +1,179 @@ +.sidebar { + border-right: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.2); + display: flex; flex-direction: column; + min-height: 0; min-width: 0; overflow: hidden; +} +.sidebar-brand { + display: flex; align-items: center; gap: 8px; + padding: 0 14px; height: 36px; + position: relative; + border-bottom: 1px solid var(--hairline); + flex-shrink: 0; +} +.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); } +.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); } +.sidebar-section { padding: 8px 6px; flex-shrink: 0; } +.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; } +.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); } +.sidebar-spacer { flex: 1; min-height: 0; } +.sidebar-bottom { margin-top: auto; } + +/* Source health dots — each dot = one source, colored by brand + status */ +.source-health { + display: inline-flex; align-items: center; gap: 3px; + padding: 4px 6px; border-radius: 4px; margin-left: auto; + cursor: pointer; transition: background 0.1s; +} +.source-health:hover { background: var(--surface-strong); } +.source-health .h-dot { + width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; +} +.source-health .h-dot.claude-ok { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.6); } +.source-health .h-dot.claude-warn { background: rgba(217,119,87,0.4); } +.source-health .h-dot.claude-error { background: rgba(217,119,87,0.25); } +.source-health .h-dot.codex-ok { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.6); } +.source-health .h-dot.codex-warn { background: rgba(16,163,127,0.4); } +.source-health .h-dot.codex-error { background: rgba(16,163,127,0.25); } +.source-health .h-dot.off { background: var(--muted-3); } + +/* Sources popover */ +.sources-popover { + position: absolute; top: 100%; left: 0; margin-top: 6px; + width: 260px; background: rgba(20, 22, 38, 0.98); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--hairline-strong); border-radius: 8px; + box-shadow: 0 12px 40px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05); + opacity: 0; transform: translateY(-4px); + pointer-events: none; transition: all 0.15s; z-index: 200; overflow: hidden; +} +.sources-popover.show { opacity: 1; transform: translateY(0); pointer-events: auto; } +.sp-head { + padding: 10px 14px 8px; border-bottom: 1px solid var(--hairline); + font-size: 11.5px; color: var(--muted); +} +.sp-list { padding: 6px 0; } +.sp-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 14px; cursor: pointer; transition: background 0.08s; + width: 100%; text-align: left; border: none; background: none; color: inherit; +} +.sp-row:hover { background: rgba(255,255,255,0.03); } +.sp-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.sp-dot.claude { background: #d97757; box-shadow: 0 0 6px rgba(217,119,87,0.5); } +.sp-dot.codex { background: #10a37f; box-shadow: 0 0 6px rgba(16,163,127,0.5); } +.sp-dot.off { background: var(--muted-3); } +.sp-body { flex: 1; min-width: 0; } +.sp-name { font-size: 12.5px; color: var(--fg); font-weight: 500; display: flex; align-items: baseline; gap: 6px; } +.sp-name .sp-count { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-weight: 400; } +.sp-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; } +.sp-meta.warn { color: #fbbf24; } +.sp-meta.error { color: #f87171; } +.sp-foot { + padding: 8px 14px; border-top: 1px solid var(--hairline); background: rgba(0,0,0,0.2); +} +.sp-foot button { + font-size: 11.5px; color: var(--accent-2); border: none; background: none; + cursor: pointer; border-bottom: 1px solid rgba(167,139,250,0.4); padding-bottom: 1px; + transition: all 0.12s; +} +.sp-foot button:hover { color: var(--accent); border-bottom-color: var(--accent); } + +/* Project noise fold */ +.project-fold { + display: flex; align-items: center; gap: 8px; + padding: 0 10px; height: 26px; border-radius: 5px; + color: var(--muted); font-size: 12px; + cursor: pointer; user-select: none; transition: all 0.08s; + width: 100%; text-align: left; border: none; background: none; +} +.project-fold:hover { background: var(--surface-strong); color: var(--fg-2); } +.project-fold.expanded { color: var(--fg-3); } +.project-fold .chev { + width: 9px; height: 9px; color: var(--muted-2); + transition: transform 0.15s; flex-shrink: 0; +} +.project-fold.expanded .chev { transform: rotate(90deg); color: var(--muted); } +.project-fold .label { flex: 1; } +.project-fold .count { + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + font-variant-numeric: tabular-nums; letter-spacing: 0.02em; +} + +.sidebar-item.noise { opacity: 0.6; } +.sidebar-item.noise .icon { color: var(--muted-2); } +.sidebar-item.noise .label { + font-family: var(--font-mono); font-size: 11.5px; + color: var(--muted); letter-spacing: 0.005em; +} +.sidebar-item.noise:hover { opacity: 1; } + +.sidebar-section-title { + padding: 4px 10px 6px; + font-size: 10.5px; color: var(--muted); + font-weight: 500; letter-spacing: 0.04em; + display: flex; align-items: center; justify-content: space-between; +} +.sidebar-section-title .filter-toggle { + display: inline-flex; align-items: center; gap: 4px; + font-family: var(--font-mono); font-size: 10px; color: var(--muted); + letter-spacing: 0.02em; cursor: pointer; + padding: 2px 6px; border-radius: 3px; + text-transform: lowercase; transition: all 0.1s; + white-space: nowrap; flex-shrink: 0; + background: none; border: 1px solid transparent; + width: auto; max-width: none; +} +.sidebar-section-title .filter-toggle svg { width: 10px; height: 10px; flex-shrink: 0; } +.sidebar-section-title .filter-toggle:hover { color: var(--fg-2); background: var(--surface); border-color: var(--hairline-strong); } +.sidebar-section-title .filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: rgba(167,139,250,0.35); } +.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; } +.sidebar-search input { + width: 100%; height: 24px; + padding: 0 8px 0 24px; + border: 1px solid var(--hairline); border-radius: 4px; + background: var(--surface); + font-size: var(--text-sm); color: var(--fg); + transition: all 0.1s; +} +.sidebar-search input::placeholder { color: var(--muted-2); } +.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); } +.sidebar-search-icon { + position: absolute; left: 14px; top: 12px; transform: translateY(-50%); + width: 11px; height: 11px; color: var(--muted); pointer-events: none; +} +.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; } +.sidebar-item { + display: flex; align-items: center; gap: 8px; + padding: 0 10px; height: var(--row-h-compact); + border-radius: 5px; + color: var(--fg-2); font-size: var(--text-base); + cursor: pointer; user-select: none; + transition: background 0.08s; position: relative; + width: 100%; text-align: left; +} +.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); } +.sidebar-item.active { background: var(--accent-soft); color: var(--fg); } +.sidebar-item.active::before { + content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px; + width: 2px; background: var(--accent); border-radius: 1px; + box-shadow: 0 0 8px var(--accent-glow); +} +.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; } +.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); } +.sidebar-item.warning .icon { color: var(--danger); } +.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-item .badge { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--muted); font-variant-numeric: tabular-nums; + line-height: 1; min-width: 22px; text-align: right; + flex-shrink: 0; padding: 2px 0; +} +.sidebar-item.active .badge { color: var(--fg-2); } +.sidebar-item.warning .badge { + color: var(--danger); background: var(--danger-soft); + padding: 2px 6px; border-radius: 8px; + margin-right: -6px; min-width: 22px; +} +.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); } +.sidebar-item.sub .icon { width: 12px; height: 12px; } diff --git a/app/src/renderer/styles/statusbar.css b/app/src/renderer/styles/statusbar.css new file mode 100644 index 0000000..373ea9b --- /dev/null +++ b/app/src/renderer/styles/statusbar.css @@ -0,0 +1,29 @@ +.statusbar { + height: 24px; flex-shrink: 0; + border-top: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.3); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + display: flex; align-items: center; gap: 12px; + padding: 0 12px; + font-size: 10.5px; color: var(--muted); + font-family: var(--font-mono); +} +.statusbar .status-left { display: flex; gap: 8px; flex: 1; } +.statusbar .status-right { display: flex; gap: 8px; } +.statusbar .kbd-hint { display: inline-flex; align-items: center; gap: 4px; transition: opacity 0.15s; } +.statusbar .kbd-hint.secondary { opacity: 0; } +.statusbar:hover .kbd-hint.secondary { opacity: 1; } +.statusbar .kbd { + color: var(--fg-2); padding: 0 4px; + border: 1px solid var(--hairline-strong); border-radius: 3px; line-height: 1.4; +} +.status-pending { color: var(--accent-2); display: flex; align-items: center; gap: 8px; } +.status-pending strong { color: var(--fg); font-weight: 500; } +.status-pending .undo-btn { + color: var(--accent-2); padding: 0 6px; + border: 1px solid var(--accent-soft); border-radius: 3px; + transition: all 0.1s; +} +.status-pending .undo-btn:hover { background: var(--accent-soft); color: var(--fg); } +.status-pending .timer { color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/app/src/renderer/styles/toolbar.css b/app/src/renderer/styles/toolbar.css new file mode 100644 index 0000000..301cdbd --- /dev/null +++ b/app/src/renderer/styles/toolbar.css @@ -0,0 +1,158 @@ + .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + .toolbar { + height: 44px; flex-shrink: 0; + display: flex; align-items: center; gap: 10px; + padding: 0 14px; + border-bottom: 1px solid var(--hairline-strong); + background: rgba(0,0,0,0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + position: relative; z-index: 50; + } + .breadcrumb { display: flex; align-items: center; gap: 6px; min-width: 0; } + .crumb { + font-size: var(--text-md); color: var(--muted); + padding: 4px 6px; border-radius: 4px; + cursor: pointer; transition: all 0.1s; + display: inline-flex; align-items: center; gap: 6px; + line-height: 1; border: 0; background: transparent; + white-space: nowrap; text-decoration: none; + } + .crumb:hover { background: var(--surface-strong); color: var(--fg-2); } + .crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; } + .crumb.terminal:hover { background: transparent; } + .crumb svg { width: 13px; height: 13px; color: var(--muted); } + .crumb.filename { + font-family: var(--font-mono); font-weight: 500; color: var(--fg); + overflow: hidden; text-overflow: ellipsis; min-width: 0; + } + .crumb-sep { color: var(--muted-2); font-size: var(--text-md); user-select: none; } + .toolbar-spacer { flex: 1; } + .toolbar-search { width: 220px; position: relative; } + .toolbar-search input { + width: 100%; height: 26px; + padding: 0 30px 0 26px; + border: 1px solid var(--hairline); border-radius: 5px; + background: var(--surface); + font-size: var(--text-base); color: var(--fg); + transition: all 0.12s; + } + .toolbar-search input::placeholder { color: var(--muted-2); } + .toolbar-search input:focus { + outline: 0; border-color: var(--accent); + background: var(--surface-strong); + box-shadow: 0 0 0 2px var(--accent-soft); + } + .toolbar-search-icon { + position: absolute; left: 8px; top: 50%; transform: translateY(-50%); + width: 12px; height: 12px; color: var(--muted); pointer-events: none; + } + .toolbar-search-kbd { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + font-family: var(--font-mono); font-size: 10px; color: var(--muted-2); + padding: 1px 5px; + border: 1px solid var(--hairline); border-radius: 3px; + pointer-events: none; line-height: 1.2; + } + .toolbar-search input:focus ~ .toolbar-search-kbd, + .toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd { opacity: 0; } + .filter-toggle { + height: 26px; width: 26px; border-radius: 5px; + color: var(--muted); display: inline-grid; place-items: center; + transition: all 0.1s; border: 1px solid transparent; + } + .filter-toggle:hover { color: var(--fg-2); background: var(--surface-strong); } + .filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: var(--accent-soft); } + .filter-toggle svg { width: 13px; height: 13px; } + + .sort-group { + display: inline-flex; align-items: center; gap: 2px; + height: 26px; padding: 0 4px 0 8px; + border-radius: 5px; cursor: pointer; + color: var(--muted); font-size: var(--text-sm); + transition: background 0.1s, color 0.1s; + } + .sort-group:hover { background: var(--surface-strong); color: var(--fg-2); } + .sort-group .label { font-family: var(--font-mono); letter-spacing: 0.02em; } + .sort-group svg { width: 13px; height: 13px; } + .sort-group .arrow-up, .sort-group .arrow-down { transition: opacity 0.12s; } + .sort-group.desc .arrow-up { opacity: 0.25; } + .sort-group.desc .arrow-down { opacity: 1; } + .sort-group.asc .arrow-up { opacity: 1; } + .sort-group.asc .arrow-down { opacity: 0.25; } + + .tab-group { + display: inline-flex; + border: 1px solid var(--hairline-strong); border-radius: 5px; + overflow: hidden; height: 26px; + } + .tab-group button { + padding: 0 12px; font-size: 12px; color: var(--muted); + border: none; background: none; cursor: pointer; + border-right: 1px solid var(--hairline-strong); + display: inline-flex; align-items: center; transition: all 0.1s; + font-family: inherit; + } + .tab-group button:last-child { border-right: 0; } + .tab-group button:hover { background: var(--surface); color: var(--fg-2); } + .tab-group button.active { background: var(--accent-soft); color: var(--accent-2); } + + /* Source filter */ + .source-filter-wrap { position: relative; } + .filter-btn { + display: inline-flex; align-items: center; gap: 6px; + height: 26px; padding: 0 10px; + border: 1px solid var(--hairline-strong); border-radius: 5px; + background: var(--surface); color: var(--fg-2); + font-size: 11.5px; font-weight: 500; cursor: pointer; + transition: all 0.12s; + } + .filter-btn:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); } + .filter-btn.active { border-color: rgba(167,139,250,0.35); background: var(--accent-soft); color: var(--accent-2); } + .filter-btn svg { width: 11px; height: 11px; } + .filter-btn .filter-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: 0.04em; } + .filter-btn.active .filter-label { color: var(--accent); } + + .filter-dropdown { + position: absolute; top: calc(100% + 6px); right: 0; + width: 220px; background: rgba(20, 22, 38, 0.98); + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--hairline-strong); border-radius: 8px; + box-shadow: 0 12px 40px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.05); + opacity: 0; transform: translateY(-4px); + pointer-events: none; transition: all 0.15s; + z-index: 100; padding: 6px; + } + .filter-dropdown.show { opacity: 1; transform: translateY(0); pointer-events: auto; } + .fd-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 5px; cursor: pointer; + transition: background 0.08s; + } + .fd-row:hover { background: rgba(255,255,255,0.03); } + .fd-row .fd-check { + width: 14px; height: 14px; + border: 1.5px solid var(--muted-2); border-radius: 3px; + flex-shrink: 0; display: grid; place-items: center; + transition: all 0.1s; + } + .fd-row.checked .fd-check { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 6px var(--accent-glow); } + .fd-row .fd-check svg { width: 10px; height: 10px; color: var(--bg); opacity: 0; } + .fd-row.checked .fd-check svg { opacity: 1; } + .fd-row .fd-name { font-size: 12.5px; color: var(--fg-2); flex: 1; } + .fd-row.checked .fd-name { color: var(--fg); } + .fd-divider { height: 1px; background: var(--hairline); margin: 4px 6px; } + + .toolbar-action-primary { + display: inline-flex; align-items: center; gap: 5px; + height: 26px; padding: 0 12px; + border: 1px solid rgba(167,139,250,0.35); border-radius: 5px; + background: var(--accent-soft); color: var(--accent-2); + font-size: 12px; font-weight: 500; cursor: pointer; + transition: all 0.12s; + } + .toolbar-action-primary:hover { + background: rgba(167,139,250,0.18); border-color: var(--accent); + color: var(--fg); box-shadow: 0 0 12px rgba(167,139,250,0.20); + } + .toolbar-action-primary .plus { font-size: 14px; line-height: 1; opacity: 0.8; font-weight: 400; } diff --git a/app/tests/electron-concurrency-child.mjs b/app/tests/electron-concurrency-child.mjs new file mode 100644 index 0000000..75cc68a --- /dev/null +++ b/app/tests/electron-concurrency-child.mjs @@ -0,0 +1,27 @@ +import { createRequire } from 'node:module'; +import { createInterface } from 'node:readline'; + +const require = createRequire(import.meta.url); +const Database = require('better-sqlite3'); + +const [mode, payloadJson] = process.argv.slice(2); +const payload = JSON.parse(payloadJson || '{}'); + +if (mode === 'holder') { + const db = new Database(payload.lockPath); + db.pragma('busy_timeout = 0'); + db.exec('BEGIN IMMEDIATE'); + process.stdout.write('READY\n'); + const input = createInterface({ input: process.stdin }); + await new Promise(resolve => input.once('line', resolve)); + db.exec('ROLLBACK'); + db.close(); + input.close(); +} else if (mode === 'build') { + const { buildIndex } = await import('../out/main/indexer.js'); + process.stdout.write('STARTING\n'); + const result = buildIndex(payload.options); + process.stdout.write(`RESULT ${JSON.stringify(result)}\n`); +} else { + throw new Error(`Unknown concurrency child mode: ${mode}`); +} diff --git a/app/tests/electron-concurrency.mjs b/app/tests/electron-concurrency.mjs new file mode 100644 index 0000000..42bc264 --- /dev/null +++ b/app/tests/electron-concurrency.mjs @@ -0,0 +1,158 @@ +// Real Electron/better-sqlite3 concurrency test (docs/adr/0006 Phase 2). +// Run: cd app && npx electron tests/electron-concurrency.mjs +// +// Exercises actual dual-connection contention against a WAL database using the +// Electron-ABI better-sqlite3 that the app uses in production. +import { app } from 'electron'; +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createInterface } from 'node:readline'; +import { once } from 'node:events'; +import { setTimeout as delay } from 'node:timers/promises'; +import Database from 'better-sqlite3'; +import { buildIndex } from '../out/main/indexer.js'; + +let failures = 0; +const childScript = join(dirname(fileURLToPath(import.meta.url)), 'electron-concurrency-child.mjs'); +function assert(condition, msg) { + if (!condition) { console.error('FAIL:', msg); failures++; } + else console.log('PASS:', msg); +} + +function spawnChild(mode, payload) { + return spawn(process.execPath, [childScript, mode, JSON.stringify(payload)], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: ['pipe', 'pipe', 'inherit'], + }); +} + +function lineReader(child) { + const lines = []; + const waiters = []; + createInterface({ input: child.stdout }).on('line', line => { + lines.push(line); + for (const waiter of [...waiters]) { + if (!line.startsWith(waiter.prefix)) continue; + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(line); + } + }); + return { + waitFor(prefix) { + const existing = lines.find(line => line.startsWith(prefix)); + if (existing) return Promise.resolve(existing); + return new Promise(resolve => waiters.push({ prefix, resolve })); + }, + }; +} + +async function waitForSuccess(child) { + let code = child.exitCode; + if (code === null) [code] = await once(child, 'exit'); + assert(code === 0, `child exited successfully, code=${code}`); +} + +async function run() { + const home = mkdtempSync(join(tmpdir(), 'obelisk-electron-concurrency-')); + const dbPath = join(home, '.obelisk', 'obelisk.sqlite'); + const projectsDir = join(home, '.claude', 'projects'); + const projDir = join(projectsDir, '-proj'); + mkdirSync(join(home, '.obelisk'), { recursive: true }); + mkdirSync(projDir, { recursive: true }); + + function msg(uuid) { + return JSON.stringify({ + uuid, type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp', + message: { role: 'user', content: `concurrent ${uuid}` }, + }) + '\n'; + } + for (let i = 0; i < 20; i++) { + writeFileSync(join(projDir, `s${i}.jsonl`), msg(`m${i}`)); + } + + console.log('--- Test 1: buildIndex with real better-sqlite3 ---'); + const result = buildIndex({ + force: true, + claudeDir: join(home, '.claude'), + codexDir: join(home, '.codex'), + projectsDir, + dbPath, + DatabaseImpl: Database, + }); + assert(result.files === 20, `indexed 20 files, got ${result.files}`); + assert(result.skipped === 0, `no files skipped, got ${result.skipped}`); + + console.log('--- Test 2: concurrent reader during write ---'); + const reader = new Database(dbPath, { readonly: true }); + reader.pragma('journal_mode = WAL'); + const readStmt = reader.prepare('SELECT COUNT(*) AS c FROM sessions'); + const beforeCount = readStmt.get().c; + assert(beforeCount === 20, `reader sees 20 sessions, got ${beforeCount}`); + // Incremental build concurrent with open reader + const result2 = buildIndex({ + force: false, + claudeDir: join(home, '.claude'), + codexDir: join(home, '.codex'), + projectsDir, + dbPath, + DatabaseImpl: Database, + }); + assert(result2.skipped === 0, `concurrent build no skips, got ${result2.skipped}`); + const duringCount = readStmt.get().c; + assert(duringCount === 20, `reader snapshot stable, got ${duringCount}`); + reader.close(); + + console.log('--- Test 3: a real concurrent writer releases within the lease budget ---'); + const lockPath = join(dirname(dbPath), 'writer.lock.sqlite'); + const buildOptions = { + force: false, + claudeDir: join(home, '.claude'), + codexDir: join(home, '.codex'), + projectsDir, + dbPath, + writerLeaseWaitMs: 1500, + }; + const holder = spawnChild('holder', { lockPath }); + const holderLines = lineReader(holder); + await holderLines.waitFor('READY'); + const contendedBuild = spawnChild('build', { options: buildOptions }); + const buildLines = lineReader(contendedBuild); + await buildLines.waitFor('STARTING'); + const startedAt = Date.now(); + await delay(200); + holder.stdin.write('release\n'); + const resultLine = await buildLines.waitFor('RESULT '); + const result3 = JSON.parse(resultLine.slice('RESULT '.length)); + const waitedMs = Date.now() - startedAt; + assert(result3.deferred === false, `contended build completed, reason=${result3.reason}`); + assert(result3.skipped === 0, `contended build skipped no files, got ${result3.skipped}`); + assert(waitedMs >= 150, `build overlapped the held lease for ${waitedMs}ms`); + await Promise.all([waitForSuccess(holder), waitForSuccess(contendedBuild)]); + + console.log('--- Test 4: persistent writer contention is bounded ---'); + const persistentHolder = spawnChild('holder', { lockPath }); + const persistentHolderLines = lineReader(persistentHolder); + await persistentHolderLines.waitFor('READY'); + const boundedBuild = spawnChild('build', { options: { ...buildOptions, writerLeaseWaitMs: 200 } }); + const boundedLines = lineReader(boundedBuild); + await boundedLines.waitFor('STARTING'); + const boundedStartedAt = Date.now(); + const boundedResultLine = await boundedLines.waitFor('RESULT '); + const boundedResult = JSON.parse(boundedResultLine.slice('RESULT '.length)); + const boundedMs = Date.now() - boundedStartedAt; + assert(boundedResult.deferred === true, 'persistent contention returns deferred'); + assert(boundedResult.reason === 'writer_busy', `persistent contention reason=${boundedResult.reason}`); + assert(boundedMs < 1000, `persistent contention returned within budget (${boundedMs}ms)`); + persistentHolder.stdin.write('release\n'); + await Promise.all([waitForSuccess(persistentHolder), waitForSuccess(boundedBuild)]); + + rmSync(home, { recursive: true, force: true }); + console.log('---'); + console.log(failures ? `${failures} TEST(S) FAILED` : 'ALL TESTS PASSED'); + process.exitCode = failures ? 1 : 0; +} + +app.whenReady().then(run).finally(() => app.quit()); diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..5d183db --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noImplicitAny": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": false, + "erasableSyntaxOnly": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/main/**/*", "src/preload/**/*"], + "exclude": ["node_modules", "out", "dist", "release", "src/renderer"] +} diff --git a/app/yarnball.md b/app/yarnball.md new file mode 100644 index 0000000..47a1b7c --- /dev/null +++ b/app/yarnball.md @@ -0,0 +1,8 @@ + +--- 2026-06-18 Activity noise folding phase --- +- Task: fold noise sessions/projects in Activity.vue (monthly + daily lists) +- Noise rules already used: NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i in App.vue; SessionList: !s.title means noise +- Activity.vue has 3 group types per block: newWorkspaces / newSessions / continued +- Approach: per-block compute noise split, render normal first then single fold banner toggling all noise in that block +- Default collapsed; banner says 'N hidden — likely test/throwaway runs' + diff --git a/docs/adr/0001-parse-core-and-persist-layers.md b/docs/adr/0001-parse-core-and-persist-layers.md new file mode 100644 index 0000000..0e91924 --- /dev/null +++ b/docs/adr/0001-parse-core-and-persist-layers.md @@ -0,0 +1,49 @@ +# Indexing is a registry of pure provider adapters over one shared persist layer + +> Revised 2026-07-08. The first draft framed the parse layer as a single "parse +> core" with "two thin persist layers, one per binding." That was wrong on both +> axes and is corrected below: the parse layer is a *registry of per-provider +> adapters* (driven by the multi-provider roadmap), and there is *one* shared +> persist layer, not one per binding. + +**Context.** Obelisk had two divergent full indexers — the former +`scripts/indexer.mjs` (`node:sqlite`, skill/runtime) and `app/indexer.js` +(`better-sqlite3`, Electron +app) — that duplicated the same Claude and Codex JSONL parsing and had silently +diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`, +message-count accumulation). Two forces shape the fix: (1) the roadmap will add +more transcript sources — opencode, pi, and others — so the parse layer must be +*pluggable*, not one monolith; (2) `node:sqlite` and `better-sqlite3` share the +same `prepare/run/get/all` API, so persistence is *already* nearly +binding-agnostic and does not need a per-binding implementation. + +**Decision.** Split indexing along two orthogonal axes. + +- **Provider axis — a registry of pure adapters.** Each source (claude, codex, + later opencode, pi, …) is a provider adapter implementing + `discover(opts) → files` and `parse(file, fromLine) → Iterable`. An + adapter is *pure*: it emits normalized records and never touches a database. + Adding a source means adding one adapter and registering it; nothing else + changes. `parse` is a streaming iterator, preserving memory-friendly indexing + and the `lines_processed` resume-from-line semantics in `index_state`. +- **Persist axis — one shared orchestration.** A single provider-agnostic, + binding-agnostic layer consumes records from any adapter and writes them: + incremental `index_state` bookkeeping, FTS maintenance, and the canonical + **upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the + drift on 2026-07-08. The database handle is *injected*, so `node:sqlite` + (skill/CLI) and `better-sqlite3` (app) run the same code — there is no + per-binding persist layer. + +**Two indexing modes** share all of the above and differ only in trigger: +**daemon mode** (app/CLI watches and keeps the index fresh) and **passive pull +mode** (skill indexes on invocation when no daemon is active). They never write +concurrently — passive mode detects a fresh daemon via heartbeat markers in +`index_state` (**daemon arbitration**). + +**Consequences.** Golden tests anchor on each adapter's `parse` output (feed +fixture JSONL, assert the yielded record sequence) — independent of binding and +persistence. The app's richer changed-path discovery becomes a `discover` +strategy injected into the shared orchestration, not a fork of it. The Electron +main process migrates to ESM (ADR-0003) to import the shared core. The real work +is disentangling the currently interleaved parse-and-write inside `indexJsonl` / +`indexCodexJsonl` into (pure adapter parse) + (shared persist). diff --git a/docs/adr/0002-two-tier-runtime-contract.md b/docs/adr/0002-two-tier-runtime-contract.md new file mode 100644 index 0000000..fc589f0 --- /dev/null +++ b/docs/adr/0002-two-tier-runtime-contract.md @@ -0,0 +1,26 @@ +# The runtime contract is two-tier, with api-reference.md authoritative + +**Context.** Before the TypeScript migration and module extraction, we need to +pin what "the contract" is so refactoring cannot silently change observable +behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry +surface; agents actually depend on the *return shapes* of the sandbox helpers +(`search`, `overview`, `memories`, …), which are already documented in +`references/api-reference.md` and relied on by every example in +`references/query-patterns.md`. Current behavior is good and there is no reason to +change it during migration. + +**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden +tests):** the four-verb CLI I/O envelope (file/args → pretty JSON on stdout, +`{error, stack}` error envelope, exit codes) and the sandbox contract (`sql()` +read-only enforcement, `attune` exposing only `remember`/`forget`, the set of +globals/helpers available inside `query`/`attune`). **Tier 2 (locked to +api-reference.md):** each helper's documented return shape — not frozen forever, +but never allowed to drift silently; contract tests assert the live shape matches +`references/api-reference.md`, so changing a helper forces a doc change plus a +deliberate version bump. `references/api-reference.md` is therefore promoted from +description to authoritative contract, and Phase 1 becomes "make it authoritative +and enforce it," not "write a new contract doc." + +**Consequences.** Behavior is preserved across the TS/module refactor by +construction: the golden and contract tests fail if any observable shape moves. +The cost is that helper shapes can no longer be reshaped casually mid-migration. diff --git a/docs/adr/0003-core-typescript-esm-precompiled.md b/docs/adr/0003-core-typescript-esm-precompiled.md new file mode 100644 index 0000000..fa4c9be --- /dev/null +++ b/docs/adr/0003-core-typescript-esm-precompiled.md @@ -0,0 +1,25 @@ +# Core is authored in TypeScript, shipped as precompiled ESM JavaScript + +**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill +runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while +the skill artifact must install with **zero build step** on the user's machine +(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra +its checkable contracts, but raises how the compiled output is shipped and which +module format it targets. + +**Decision.** Author all of Core in the `@obelisk/core` npm workspace +(`packages/core`) in TypeScript and compile it ahead-of-time to +**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled* +ESM JS, so installing the skill never runs a build. Rather than have Core +dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it +can `import` the same compiled Core. TypeScript source is the single source of +truth; the build step lives in the main repo (`build:skill`), never on the user's +machine. + +**Consequences.** A one-time ESM migration of the Electron main process (Phase 5), +in exchange for no dual-build maintenance and a single module format across skill, +CLI, and app. The shipped skill artifact contains compiled JS, not TS. The +renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only +adds root tooling (package.json, tsconfig, ESLint); it does not touch the app. +The app imports Core source so electron-vite can bundle it, while package and +skill builds compile the same workspace source to JavaScript. diff --git a/docs/adr/0004-skill-artifact-readable-not-bundled.md b/docs/adr/0004-skill-artifact-readable-not-bundled.md new file mode 100644 index 0000000..f9c0233 --- /dev/null +++ b/docs/adr/0004-skill-artifact-readable-not-bundled.md @@ -0,0 +1,22 @@ +# The skill artifact ships readable compiled JS, deliberately not bundled + +**Context.** Obelisk reads a user's entire local Claude Code and Codex history, +so auditability is the foundation of trust: before a user lets the skill loose on +their data, they must be able to read what it does. The obvious way to shrink a +clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`, +but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The +"don't drag the whole repo into the user's skills dir" concern is real but +separate — it is solved by shipping *only Core*, not by bundling. + +**Decision.** The skill artifact ships **readable, non-bundled, non-minified** +compiled JavaScript emitted straight from `tsc` (module structure and comments +preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and +`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and +`tests/`, which is what keeps it small. Bundling into one file is deliberately +rejected: it trades auditability for marginal size, the wrong trade for a +history-reading tool. The public TS source in the main repo allows cross-checking. + +**Consequences.** The installed skill is a few readable files rather than one +blob; a future contributor may be tempted to "optimize" by bundling — this ADR +records that the un-bundled form is intentional. Small artifact size comes from +scoping the artifact to Core, handled by `build:skill`, not from a bundler. diff --git a/docs/adr/0005-app-electron-vite-ts-esm.md b/docs/adr/0005-app-electron-vite-ts-esm.md new file mode 100644 index 0000000..97ac1a5 --- /dev/null +++ b/docs/adr/0005-app-electron-vite-ts-esm.md @@ -0,0 +1,61 @@ +# The app builds with electron-vite (TS + ESM), packages with electron-builder + +**Context.** The desktop app must consume the shared TypeScript/ESM Core +(`providers/*` + `persist`) instead of maintaining its own duplicate indexer, and +the app itself should be TypeScript + ESM long-term. The app previously ran raw +CommonJS on Electron's Node with only the Vue renderer built by Vite; the main +process had no build step, and Electron's bundled Node (20 on Electron 33) can +neither strip TypeScript nor use `node:sqlite`. Options for the main-process build +were a hand-rolled tsc/esbuild step, `vite-plugin-electron`, or `electron-vite`. + +**Decision.** Adopt **electron-vite** to build all three processes (main, preload, +renderer) as TypeScript + ESM, and keep **electron-builder** for packaging +(dmg/nsis/AppImage). electron-vite is purpose-built for the Electron three-process +model and handles the parts a DIY build would force us to hand-maintain forever +(per-process module format, native-module externalization, dev reload). Specific +decisions within this: + +- **Preload is emitted as CommonJS** even though the app is ESM: the sandboxed + renderer (sandbox is on by default since Electron 20, and we keep it on for + security) does not support ESM preload. Source stays ESM; only the preload + output format is CJS. `main` loads `../preload/index.js`. +- **The app consumes the Core from source**: electron-vite/rollup bundles + `packages/core/src/providers/*` + `packages/core/src/persist.ts` (and their + `packages/core/src/parsing.ts` dependency) into the app's main/worker build, + injecting `better-sqlite3`. This + works because the provider→parsing import graph is node:sqlite-free (ADR-0001), + so nothing drags `node:sqlite` into the app. The `dist/` from `build:core` + (ADR-0003) remains for the skill artifact; the app does not need it. +- **better-sqlite3 stays the app's binding**, externalized (not bundled) and + unpacked from the asar. +- **The app main + preload source is TypeScript with types at its seams**, but + under a *deliberately more lenient* project than the runtime core. `app/tsconfig.json` + keeps `strict` on yet sets `noImplicitAny: false`, because the app mostly + orchestrates the already-strictly-typed core (`packages/core/src/`), and annotating every + internal SQLite-handle helper would be high-cost, low-value churn. Types are + added where they matter: the core-consumption seam (`BuildIndexOptions`/ + `BuildIndexResult`, `FileInfo`), the service/worker factories, and the IPC + bridge. Module-to-module specifiers use the real `.ts` extension (mirroring + Core source, since Node's type-stripping does not rewrite `.js`→`.ts`), which + needs `allowImportingTsExtensions` (safe under the project's `noEmit`); the + worker's *runtime* path stays `indexer-worker.js` because that is the built + output. `@types/better-sqlite3` is a devDependency for the injected binding. + +**Two-tier typechecking.** `npm run typecheck` runs the root project (`packages/core/src/` + +`tests/`, fully strict including `noImplicitAny`) and then the app project. The +root project **excludes the app-importing tests** (`tests/app-*.test.mjs`, +`tests/recap-capture-query.test.mjs`): those tests import app source, which would +otherwise drag the lenient app files into the strict root program and fail on +implicit `any`. The app source is instead covered by `app/tsconfig.json`, so +nothing loses type coverage — the strict core and the lenient app are checked by +the project that owns each, and never mixed. + +**Consequences.** The app is restructured into `src/{main,preload,renderer}` with +`electron.vite.config.ts`; each main module is a build input so relative imports +between them and the indexer worker (`{ type: 'module' }`) resolve at runtime. +`npm run dev` is `electron-vite dev`. Tests that loaded app modules moved +to ESM imports, and `app-main-settings` was rewritten from CJS `Module._load` +mocking to `node:test` `mock.module` (needs `--experimental-test-module-mocks`). +A future contributor may be tempted to make the preload ESM or disable the +sandbox — this ADR records that CJS preload under an on sandbox is the intended, +secure default. diff --git a/docs/adr/0006-write-transaction-rollback-and-concurrency.md b/docs/adr/0006-write-transaction-rollback-and-concurrency.md new file mode 100644 index 0000000..447f9fa --- /dev/null +++ b/docs/adr/0006-write-transaction-rollback-and-concurrency.md @@ -0,0 +1,76 @@ +# Write-transaction rollback safety and SQLite concurrency + +**Context.** The app surfaced `Obelisk index build failed: cannot rollback - no +transaction is active`. That text was a secondary cleanup failure. SQLite had +already ended the transaction, then the catch block's unguarded `ROLLBACK` +threw over the primary exception and turned a skippable per-file failure into a +whole-build failure. The masked exception was not preserved, so contention +(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than +a proven historical fact. It is plausible because daemon builds, manual +rebuilds, skill passive-pull indexing, heartbeat writes, and reads share one WAL +database. + +`busy_timeout` alone is not a correctness fix. In particular, +`SQLITE_BUSY_SNAPSHOT` is not made safe by waiting longer, and retrying only the +failed statement can replay part of a transaction. + +**Decision.** Use one transaction primitive plus two explicit coordination +layers. + +- `packages/core/src/tx.ts` owns the binding-agnostic + `runWriteTransaction(db, work)`. + Adapters expose transaction state from better-sqlite3's `inTransaction` and + node:sqlite's `isTransaction`. The primitive performs `BEGIN IMMEDIATE`, runs + `work` exactly once, commits, and attempts rollback only when the binding says + a transaction is active or its state is unknown. Cleanup never masks the + primary exception. Diagnostics record phase, SQLite code, rollback outcome, + transaction state, label, and attempts. +- Retry is an upper-layer policy in `packages/core/src/write-coordinator.ts`, never hidden + inside the transaction primitive. Only an idempotent whole transaction that + failed during work/commit with `SQLITE_BUSY*` and is confirmed inactive may be + retried. The default is three attempts within a one-second budget with short + backoff. BEGIN contention is deferred to the build scheduler; an active or + unknown post-error transaction aborts the build. +- Per-file failures remain warnings and are reported in `skippedFiles`; finalize + failures propagate. `affectedSessionIds` is updated only after the relevant + commit. Force cleanup is one atomic, retryable transaction, and finalize is + likewise retried as a complete idempotent transaction. +- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the skill + opens no write connection and performs no migration, schema setup, checkpoint, + index build, or `attune`. `__app_last_successful_build__` remains an + observability/freshness marker and is not required for ownership. The skill + checks ownership again after acquiring the hard lease to close the TOCTOU + window. Search/query connections are read-only. +- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety + mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or + bounded waiting; release is idempotent. App builds and heartbeats, skill builds + and attune, app schema/legacy migrations and memory mutations, and manual + rebuild all participate. Manual rebuild's main process owns the lease across + worker build, atomic target replacement, and database reopen; the worker uses + the explicit `caller-held` mode. +- The app's in-process indexer service permits one build at a time. A lease + deferral retains changed paths and schedules a short retry without announcing + a successful build. Service start publishes the ownership heartbeat + immediately, then refreshes it periodically. +- Index-writer and skill read connections use an explicit 250 ms SQLite busy + timeout inside the larger bounded coordination budget. The long-lived app + query connection retains a 5 s timeout; heartbeat is deliberately non-blocking + (`0 ms`) so it never stalls the Electron main thread. Builds use + `BEGIN IMMEDIATE`. Routine checkpointing is `PASSIVE`; blocking `TRUNCATE` is + reserved for explicit maintenance. + +**Verification.** Fast tests inject auto-rollback and BUSY failures to prove the +primary error is preserved, retry replays the whole transaction, persistent +per-file failure is skipped, force cleanup is atomic, and affected-session state +is commit-aware. The Electron harness uses real Electron-ABI better-sqlite3 and +two child processes: one holds the SQLite writer lease until signalled, while +the other runs synchronous `buildIndex`. It verifies both release-within-budget +success and bounded `writer_busy` deferral. Separate arbitration tests prove a +heartbeat-only daemon marker keeps query and attune paths read-only. + +**Consequences.** Heartbeat and lease have deliberately different jobs: the +heartbeat decides who should write, while the lease guarantees writers cannot +overlap when policy information races or is stale. A single bad transcript can +still be skipped so the index self-heals on a later build; structural/finalize +failures remain visible. Longer timeouts must not replace the transaction and +ownership rules recorded here. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0ddb69d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +// Flat ESLint config for the Obelisk root (Core + skill runtime + tests). +// Scope: the ESM/TS sources under packages/core/src/ and tests/. The Electron app has its +// own package and toolchain and is intentionally excluded (see docs/adr/0003). + +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + 'node_modules/**', + 'app/**', + 'dist/**', + 'release/**', + '.dev.docs/**', + '.obelisk/**', + '.claude/**', + ], + }, + js.configs.recommended, + { + files: ['**/*.{js,mjs}'], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: { ...globals.node }, + }, + rules: { + // Empty catch is an intentional pattern here (best-effort JSON.parse etc.). + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, + { + files: ['**/*.ts'], + extends: [...tseslint.configs.recommended], + languageOptions: { + globals: { ...globals.node }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + // Provider adapters parse untyped external transcript JSON; `any` at those + // boundaries is deliberate, not a smell. + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e9cb13b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1291 @@ +{ + "name": "obelisk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obelisk", + "version": "0.1.0", + "license": "AGPL-3.0", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.1.1", + "eslint": "^10.6.0", + "globals": "^17.7.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@obelisk/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/core": { + "name": "@obelisk/core", + "version": "0.1.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3651406 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "obelisk", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Explicit memory infrastructure for coding agents — a queryable SQLite evidence layer over local Claude Code and Codex history, plus human-approved durable memory.", + "license": "AGPL-3.0", + "workspaces": [ + "packages/*" + ], + "scripts": { + "test": "node --experimental-test-module-mocks --test tests/*.test.mjs", + "typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json", + "lint": "eslint .", + "build:core": "npm run build --workspace @obelisk/core", + "build:skill": "rm -rf dist/obelisk-skill && tsc -p tsconfig.skill.json && cp packages/core/src/schema.sql dist/obelisk-skill/scripts/ && cp SKILL.md dist/obelisk-skill/ && cp -R references dist/obelisk-skill/references && cp packaging/skill-package.json dist/obelisk-skill/package.json", + "publish:skill": "npm run build:skill && packaging/publish-skill.sh" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.1.1", + "eslint": "^10.6.0", + "globals": "^17.7.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.63.0" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..780e887 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@obelisk/core", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Shared indexing and query core for Obelisk transports.", + "exports": { + ".": "./dist/core.js", + "./db": "./dist/db.js", + "./indexer": "./dist/indexer.js", + "./parsing": "./dist/parsing.js", + "./persist": "./dist/persist.js", + "./providers/claude": "./dist/providers/claude.js", + "./providers/codex": "./dist/providers/codex.js", + "./providers/types": "./dist/providers/types.js", + "./query": "./dist/query.js", + "./sqlite-types": "./dist/sqlite-types.js", + "./tx": "./dist/tx.js", + "./write-coordinator": "./dist/write-coordinator.js", + "./writer-lease": "./dist/writer-lease.js" + }, + "files": [ + "dist", + "src/schema.sql" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/schema.sql dist/schema.sql", + "typecheck": "tsc --noEmit -p tsconfig.json" + } +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts new file mode 100644 index 0000000..cea1174 --- /dev/null +++ b/packages/core/src/core.ts @@ -0,0 +1,90 @@ +// Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md). +// +// The single shared implementation behind every transport. runtime.js (skill), +// and later the CLI and MCP server, are thin shells over these four functions; +// none of them re-implement retrieval or own the DB lifecycle. +// +// Authored in TypeScript with erasable-only syntax so Node can run it directly +// via type stripping in development, while the skill artifact ships readable, +// non-bundled tsc output. Core source lives in the @obelisk/core workspace. + +import { createContext, runInNewContext } from 'node:vm'; + +import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.ts'; +import { buildIndex, shouldSkipBuild } from './indexer.ts'; +import { createQueryApi, createAttuneApi } from './query.ts'; +import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; + +export { buildIndex, DB_PATH }; + +type SandboxApi = Record; + +// Run a user-supplied CodeAct script inside the query/attune sandbox. The script +// body runs as an async IIFE with a 30s timeout; its `return` value is resolved. +function runInSandbox(api: SandboxApi, scriptContent: string): Promise { + const sandbox = { + ...api, JSON, Math, Array, Object, Set, Map, Date, RegExp, + parseInt, parseFloat, String, Number, Boolean, Error, Promise, console, setTimeout, + }; + const ctx = createContext(sandbox); + return runInNewContext(`(async()=>{${scriptContent}})()`, ctx, { timeout: 30000 }); +} + +// FTS search over indexed message text. Refreshes the index, then queries. +export function searchText(text: string, opts?: Record): unknown { + buildIndex(); + const db = openReadDb(); + try { + return createQueryApi(db).search(text, opts); + } finally { + db.close(); + } +} + +// Execute a read-only CodeAct query script and resolve its returned value. +export async function executeQuery(scriptContent: string): Promise { + buildIndex(); + const db = openReadDb(); + try { + return await runInSandbox(createQueryApi(db), scriptContent); + } finally { + db.close(); + } +} + +// Execute a memory-mutation CodeAct script (remember/forget only). +export async function executeAttune(scriptContent: string): Promise { + const build = buildIndex() as { reason?: string } | undefined; + if (build?.reason === 'daemon_active') { + throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops'); + } + if (build?.reason === 'writer_busy' || build?.reason === 'database_busy') { + throw new Error('Obelisk index writer is busy; attune was not applied'); + } + const lease = acquireWriterLease({ + lockPath: writerLockPathFor(DB_PATH), + openDb: openWriterLeaseDb, + waitMs: 1000, + }); + if (!lease) throw new Error('Obelisk index writer is busy; attune was not applied'); + try { + // Close the heartbeat TOCTOU window after acquiring the hard lease. + const ownershipDb = openReadDb(); + try { + const ownership = shouldSkipBuild(ownershipDb, { ignoreRecentBuild: true }); + if (ownership.reason === 'daemon_active') { + throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops'); + } + } finally { + ownershipDb.close(); + } + const db = openDb(); + try { + return await runInSandbox(createAttuneApi(db), scriptContent); + } finally { + db.close(); + } + } finally { + lease.release(); + } +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts new file mode 100644 index 0000000..5f6ad25 --- /dev/null +++ b/packages/core/src/db.ts @@ -0,0 +1,79 @@ +// node:sqlite lifecycle and migrations for the Core package. +import { createRequire } from 'node:module'; +import { CLAUDE_DIR, CODEX_DIR, TEXT_LIMIT, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines } from './parsing.ts'; +import { configureConnection } from './tx.ts'; +import type { NodeSqliteDb, SqliteDb } from './sqlite-types.ts'; +const require = createRequire(import.meta.url); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { DatabaseSync } = require('node:sqlite'); + +const OBELISK_DIR = path.join(os.homedir(), '.obelisk'); +const LEGACY_DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite'); +const DB_PATH = path.join(OBELISK_DIR, 'obelisk.sqlite'); +const SCHEMA = fs.readFileSync(new URL('./schema.sql', import.meta.url), 'utf8'); + +function migrateLegacyDbIfNeeded() { + if (fs.existsSync(DB_PATH)) return; + if (!fs.existsSync(LEGACY_DB_PATH)) return; + fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + fs.copyFileSync(LEGACY_DB_PATH, DB_PATH); +} + +function openDb(): NodeSqliteDb { + migrateLegacyDbIfNeeded(); + fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + const db = new DatabaseSync(DB_PATH); + configureConnection(db, { busyTimeoutMs: 250 }); + migrateExistingColumns(db); + db.exec(SCHEMA); + migrateDb(db); + return db; +} + +// Queries and daemon-arbitration checks must never migrate/configure the index. +// The caller is responsible for ensuring the database exists first. +function openReadDb(): NodeSqliteDb { + const db = new DatabaseSync(DB_PATH, { readOnly: true }); + db.exec('PRAGMA busy_timeout=250'); + return db; +} + +function openWriterLeaseDb(lockPath: string): NodeSqliteDb { + return new DatabaseSync(lockPath); +} + +function ensureColumn(db: SqliteDb, table: string, column: string, definition: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); + if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +function tableExists(db: SqliteDb, table: string): boolean { + return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table)); +} + +function migrateExistingColumns(db: SqliteDb): void { + if (tableExists(db, 'sessions')) ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'"); + if (tableExists(db, 'messages')) { + ensureColumn(db, 'messages', 'content_type', 'TEXT'); + ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0'); + ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'"); + } + if (tableExists(db, 'memories')) { + ensureColumn(db, 'memories', 'anchors', 'TEXT'); + ensureColumn(db, 'memories', 'deleted_at', 'TEXT'); + ensureColumn(db, 'memories', 'deleted_reason', 'TEXT'); + } +} + +function migrateDb(db: SqliteDb): void { + migrateExistingColumns(db); +} + +function rebuildMemoryFts(db: SqliteDb): void { + db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')"); +} + + +export { CLAUDE_DIR, CODEX_DIR, OBELISK_DIR, DB_PATH, TEXT_LIMIT, openDb, openReadDb, openWriterLeaseDb, rebuildMemoryFts, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, fs, path, os }; diff --git a/packages/core/src/indexer.ts b/packages/core/src/indexer.ts new file mode 100644 index 0000000..043e5de --- /dev/null +++ b/packages/core/src/indexer.ts @@ -0,0 +1,310 @@ +// Passive-pull indexing orchestration for the Core package. +import { DB_PATH, openDb, openReadDb, openWriterLeaseDb, rebuildMemoryFts } from './db.ts'; +import { + CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, fs, path, isDir, readLines, + inferProjectPath, discoverJsonlFiles, discoverCodexJsonlFiles, codexDbId, readCodexGuardianThreadInfo, +} from './parsing.ts'; +import { persist } from './persist.ts'; +import { nodeSqliteTransactionAdapter } from './tx.ts'; +import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts'; +import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts'; +import { parse as claudeParse } from './providers/claude.ts'; +import { parse as codexParse } from './providers/codex.ts'; +import type { Cursor, IndexRecord } from './providers/types.ts'; +import type { ClaudeJsonlFile } from './parsing.ts'; +import type { NodeSqliteDb, SqliteRow } from './sqlite-types.ts'; + +const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl'); + +type JsonRecord = Record; + +interface SkippedFile { + path: string; + error: string; + diagnostics?: unknown; +} + +interface BuildCheckOptions { + now?: number; + ignoreRecentBuild?: boolean; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + + +function needsReindex(db: NodeSqliteDb, fp: string) { + const mt = fs.statSync(fp).mtimeMs; + const row = db.prepare('SELECT mtime, lines_processed FROM index_state WHERE jsonl_path = ?').get(fp); + if (!row) return { needed: true, skip: 0 }; + return mt > row.mtime ? { needed: true, skip: row.lines_processed } : { needed: false, skip: 0 }; +} + + +function indexCodexSessionIndex(db: NodeSqliteDb): void { + const indexPath = path.join(CODEX_DIR, 'session_index.jsonl'); + if (!fs.existsSync(indexPath)) return; + readLines(indexPath, (line) => { + let item: JsonRecord; + try { + item = JSON.parse(line); + } catch (e) { + process.stderr.write(`Warning: malformed Codex session index line: ${errorMessage(e)}\n`); + return; + } + if (!item.id || !item.thread_name) return; + db.prepare('UPDATE sessions SET title=COALESCE(title, ?), ended_at=COALESCE(ended_at, ?) WHERE id=? AND source=?') + .run(item.thread_name, item.updated_at || null, codexDbId(item.id), 'codex'); + }); +} + +function refreshSessionProjectPaths(db: NodeSqliteDb): void { + const sessions = db.prepare('SELECT id, project FROM sessions').all(); + const cwdStmt = db.prepare(` + SELECT cwd + FROM messages + WHERE session_id = ? AND cwd IS NOT NULL AND cwd != '' + ORDER BY timestamp IS NULL, timestamp + `); + const update = db.prepare('UPDATE sessions SET project_path = ? WHERE id = ?'); + for (const session of sessions) { + const cwds = cwdStmt.all(session.id).map((row: SqliteRow) => row.cwd); + const projectPath = inferProjectPath(session.project, cwds); + if (projectPath) update.run(projectPath, session.id); + } +} + +function indexSubagentMeta(db: NodeSqliteDb, fi: ClaudeJsonlFile): void { + if (!fi.isSubagent) return; + const mp = fi.path.replace('.jsonl', '.meta.json'); + if (!fs.existsSync(mp)) return; + let meta: JsonRecord; + try { + meta = JSON.parse(fs.readFileSync(mp, 'utf8')); + } catch (e) { + process.stderr.write(`Warning: failed to read subagent meta ${mp}: ${errorMessage(e)}\n`); + return; + } + const tok = db.prepare('SELECT COALESCE(SUM(input_tokens),0)+COALESCE(SUM(output_tokens),0) as t FROM messages WHERE agent_id=?').get(fi.agentId); + const ts = db.prepare('SELECT MIN(timestamp) as t0, MAX(timestamp) as t1 FROM messages WHERE agent_id=?').get(fi.agentId); + const dur = ts?.t0 && ts?.t1 ? new Date(ts.t1).getTime() - new Date(ts.t0).getTime() : null; + if (fi.workflowRunId) { + db.prepare('INSERT OR REPLACE INTO workflow_agents (agent_id,run_id,session_id,agent_type,description) VALUES(?,?,?,?,?)').run(fi.agentId, fi.workflowRunId, fi.sessionId, meta.agentType||null, meta.description||null); + } else { + db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0); + } +} + +function indexWorkflows(db: NodeSqliteDb): void { + if (!fs.existsSync(PROJECTS_DIR)) return; + let projects; + try { projects = fs.readdirSync(PROJECTS_DIR); } catch { return; } + for (const proj of projects) { + const pp = path.join(PROJECTS_DIR, proj); + if (!isDir(pp)) continue; + let entries; + try { entries = fs.readdirSync(pp); } catch { continue; } + for (const sd of entries) { + const wd = path.join(pp, sd, 'workflows'); + if (!isDir(wd)) continue; + let wfFiles; + try { wfFiles = fs.readdirSync(wd); } catch { continue; } + for (const f of wfFiles) { + if (!f.endsWith('.json')) continue; + let wf: JsonRecord; + try { + wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8')); + } catch (e) { + process.stderr.write(`Warning: failed to read workflow ${f}: ${errorMessage(e)}\n`); + continue; + } + if (!wf.runId) continue; + const ac = db.prepare('SELECT COUNT(*) as c FROM workflow_agents WHERE run_id=?').get(wf.runId); + db.prepare('INSERT OR REPLACE INTO workflows (run_id,session_id,task_id,script,result_json,timestamp,agent_count,duration_ms,total_tokens,status,workflow_name) VALUES(?,?,?,?,?,?,?,?,?,?,?)').run( + wf.runId, sd, wf.taskId||null, wf.script||null, + wf.result ? JSON.stringify(wf.result) : null, wf.timestamp||null, ac?.c||0, + wf.durationMs||null, wf.totalTokens||null, wf.status||null, wf.workflowName||null); + const progress = wf.workflowProgress || []; + for (const item of progress) { + if (item.type !== 'workflow_agent' || !item.agentId) continue; + db.prepare('UPDATE workflow_agents SET phase=?, label=?, model=?, state=?, duration_ms=?, tokens=?, tool_calls=? WHERE agent_id=?').run( + item.phaseTitle||null, item.label||null, item.model||null, item.state||null, + item.durationMs||null, item.tokens||null, item.toolCalls||null, 'agent-' + item.agentId); + } + } + } + } +} + +function indexHistory(db: NodeSqliteDb): void { + if (!fs.existsSync(HISTORY_PATH)) return; + readLines(HISTORY_PATH, (line) => { + let item: JsonRecord; + try { + item = JSON.parse(line); + } catch (e) { + process.stderr.write(`Warning: malformed history line: ${errorMessage(e)}\n`); + return; + } + if (item.sessionId && item.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(item.title, item.sessionId); + }); +} + +const BUILD_DEBOUNCE_MS = 30000; +const APP_HEARTBEAT_FRESH_MS = 60000; + +function shouldSkipBuild(db: NodeSqliteDb, { now = Date.now(), ignoreRecentBuild = false }: BuildCheckOptions = {}) { + const appHeartbeat = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__app_heartbeat__'").get(); + if (appHeartbeat && now - appHeartbeat.mtime < APP_HEARTBEAT_FRESH_MS) { + return { skip: true, reason: 'daemon_active' }; + } + if (!ignoreRecentBuild) { + const last = db.prepare("SELECT mtime FROM index_state WHERE jsonl_path='__last_build__'").get(); + if (last && now - last.mtime < BUILD_DEBOUNCE_MS) { + return { skip: true, reason: 'recent_build' }; + } + } + return { skip: false }; +} + +function isMissingIndexStateTable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /no such table:\s*(?:main\.)?index_state\b/i.test(message); +} + +function inspectBuildOwnership({ force = false }: { force?: boolean } = {}) { + if (!fs.existsSync(DB_PATH)) return { skip: false }; + const db = openReadDb(); + try { + return shouldSkipBuild(db, { ignoreRecentBuild: force }); + } catch (error) { + // A missing table means the write path must initialize a new/legacy index. + // Any other read failure leaves daemon ownership unknown, so fail closed. + if (isMissingIndexStateTable(error)) return { skip: false }; + throw error; + } finally { + db.close(); + } +} + +// A one-shot record stream that retracts a session, for routing guardian sweeps +// through persist (the single db writer) instead of deleting rows directly. +function* guardianDelete(sessionId: string): Generator { + yield { kind: 'delete-session', sessionId }; + return null; +} + +function buildIndex({ force = false }: { force?: boolean } = {}) { + const ownership = inspectBuildOwnership({ force }); + if (ownership.skip) return ownership; + const lease = acquireWriterLease({ + lockPath: writerLockPathFor(DB_PATH), + openDb: openWriterLeaseDb, + }); + if (!lease) return { skip: true, reason: 'writer_busy' }; + try { + // Ownership may change between the first read and lease acquisition. + const ownershipAfterLease = inspectBuildOwnership({ force }); + if (ownershipAfterLease.skip) return ownershipAfterLease; + + const db = openDb(); + const txDb = nodeSqliteTransactionAdapter(db); + const skippedFiles: SkippedFile[] = []; + try { + try { + if (force) { + runRetryableWriteTransaction(txDb, () => { + db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run(); + // Clearing index_state alone re-indexes existing files but leaves rows for + // files that no longer exist on disk (stale sessions accumulate). A force + // build is a clean rebuild: drop every derived table, then re-index from the + // current files. `memories` is the durable, human-approved layer and is never + // cleared; messages_fts is repopulated by the 'rebuild' command in finalize. + for (const table of ['messages', 'tool_calls', 'tool_results', 'sessions', 'summaries', 'subagents', 'workflows', 'workflow_agents']) { + db.prepare(`DELETE FROM ${table}`).run(); + } + }, { label: 'force-cleanup' }); + } + } catch (error) { + if (isBeginBusyFailure(error)) { + return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; + } + throw error; + } + + const files = [ + ...discoverJsonlFiles(), + ...discoverCodexJsonlFiles(), + ]; + for (const f of files) { + try { + runRetryableWriteTransaction(txDb, () => { + if (f.source === 'codex') { + // Codex goes through the pure adapter + shared persist (docs/adr/0001), + // full-reparse (countMode 'total') when the file changed. An unchanged + // file is not reparsed, but is still swept for stale guardian rows: a + // guardian/auto-review thread must never linger in the index, even if it + // was indexed before guardian detection removed it. + const { needed } = needsReindex(db, f.path); + if (needed) { + persist(db, { key: f.path, sessionId: '' }, codexParse({ key: f.path, sessionId: '' }, null)); + } else { + const guardian = readCodexGuardianThreadInfo(f.path); + if (guardian) { + const sessionId = codexDbId(guardian.threadRawId); + if (sessionId) persist(db, { key: f.path, sessionId: '' }, guardianDelete(sessionId)); + } + } + } else { + // Claude transcripts now go through the pure adapter + shared persist + // (docs/adr/0001). needsReindex keeps the "skip unchanged file" fast path; + // the cursor's line count drives incremental resume inside parse(). + const { needed, skip } = needsReindex(db, f.path); + if (needed) { + const unit = { key: f.path, sessionId: f.sessionId, project: f.project, isSubagent: f.isSubagent, agentId: f.agentId }; + persist(db, unit, claudeParse(unit, skip > 0 ? `0:${skip}` : null)); + } + indexSubagentMeta(db, f); + } + }, { label: `file:${f.path}` }); + } catch (e) { + if (isBeginBusyFailure(e)) { + return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; + } + if (hasUnusableTransaction(e)) throw e; + // A per-file failure is skippable: log and move on. + const error = e as { message?: unknown; obelisk?: unknown } | null; + const message = errorMessage(e); + skippedFiles.push({ path: f.path, error: message, diagnostics: error?.obelisk }); + process.stderr.write(`Warning: failed to index ${f.path}: ${message}\n`); + } + } + // Finalize is one transaction and is NOT swallowed: a finalize failure fails + // the build (a half-finalized index would be inconsistent). + try { + runRetryableWriteTransaction(txDb, () => { + indexWorkflows(db); + refreshSessionProjectPaths(db); + indexHistory(db); + indexCodexSessionIndex(db); + db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')"); + rebuildMemoryFts(db); + db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now()); + }, { label: 'finalize' }); + } catch (error) { + if (isBeginBusyFailure(error)) { + return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles }; + } + throw error; + } + return { skip: false, skipped: skippedFiles.length, skippedFiles }; + } finally { + db.close(); + } + } finally { + lease.release(); + } +} + +export { buildIndex, inferProjectPath, refreshSessionProjectPaths, shouldSkipBuild }; diff --git a/packages/core/src/parsing.ts b/packages/core/src/parsing.ts new file mode 100644 index 0000000..6816d8c --- /dev/null +++ b/packages/core/src/parsing.ts @@ -0,0 +1,349 @@ +// Core's pure parse/discover helpers — node:sqlite-free by construction, so the compiled +// providers can be consumed by the app (better-sqlite3 / a Node without +// node:sqlite). Originally extracted verbatim from db/indexer; it now exposes a +// typed seam while remaining limited to node:fs/path/os. +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const CLAUDE_DIR = path.join(os.homedir(), '.claude'); +const CODEX_DIR = path.join(os.homedir(), '.codex'); +const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); +const CODEX_SESSIONS_DIR = path.join(CODEX_DIR, 'sessions'); +const TEXT_LIMIT = 10000; + +type JsonRecord = Record; +type JsonValue = any; + +export interface ClaudeJsonlFile { + path: string; + sessionId: string; + project: string; + isSubagent: boolean; + agentId?: string; + workflowRunId?: string; + source?: 'claude'; +} + +export interface CodexJsonlFile { + path: string; + source: 'codex'; +} + +interface CodexLineRecord { + lineNum: number; + obj: JsonRecord; +} + +// ---- message/text helpers ---- +function trunc(s: any): any { + return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s; +} + +function truncJson(obj: JsonValue, limit = TEXT_LIMIT): string | null { + if (obj === null || obj === undefined) return null; + const walk = (v: JsonValue): JsonValue => { + if (typeof v === 'string') return v.length > limit ? v.slice(0, limit) + '...[truncated]' : v; + if (Array.isArray(v)) return v.map(walk); + if (typeof v === 'object' && v !== null) { + const out: JsonRecord = {}; + for (const [k, val] of Object.entries(v)) out[k] = walk(val); + return out; + } + return v; + }; + return JSON.stringify(walk(obj)); +} + +function extractText(content: JsonValue): string | null { + if (typeof content === 'string') return trunc(content); + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const b of content) { + if (b.type === 'text' && b.text) parts.push(b.text); + else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking); + } + return parts.length ? trunc(parts.join('\n')) : null; +} + +function extractContentType(content: JsonValue): string { + if (typeof content === 'string') return 'text'; + if (!Array.isArray(content) || !content.length) return 'unknown'; + const types = new Set(); + let sawUnknown = false; + for (const b of content) { + if (!b || typeof b !== 'object') { sawUnknown = true; continue; } + if (b.type === 'text') types.add('text'); + else if (b.type === 'thinking') types.add('thinking'); + else if (b.type === 'tool_use') types.add('tool_use'); + else if (b.type === 'tool_result') types.add('tool_result'); + else sawUnknown = true; + } + return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown'; +} + +const COMMAND_ENVELOPE_RE = /^\s*([^<]+<\/command-name>|<(?:task-notification|system-reminder)\b| boolean | void): void { + const fd = fs.openSync(filePath, 'r'); + const bufSize = 64 * 1024; + const buf = Buffer.alloc(bufSize); + let remainder = ''; + let bytesRead; + try { + while ((bytesRead = fs.readSync(fd, buf, 0, bufSize)) > 0) { + const chunk = remainder + buf.toString('utf8', 0, bytesRead); + const lines = chunk.split('\n'); + remainder = lines.pop() ?? ''; + for (const line of lines) { + if (line && callback(line) === false) return; + } + } + if (remainder) callback(remainder); + } finally { + fs.closeSync(fd); + } +} + +// ---- project-path + discovery helpers ---- +function legacyProjectPathFromSlug(project: string | null | undefined): string | null { + if (!project) return null; + return '/' + project.replace(/-/g, '/').replace(/^\//, ''); +} + +function normalizeObservedCwd(cwd: unknown): string | null { + if (typeof cwd !== 'string' || !cwd.trim() || !path.isAbsolute(cwd)) return null; + return path.normalize(cwd); +} + +function projectSlugFromPath(projectPath: string | null): string | null { + const normalized = normalizeObservedCwd(projectPath); + if (!normalized) return null; + return '-' + normalized.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-'); +} + +function inferProjectPath(project: string | null | undefined, observedCwds: unknown[] = []): string | null { + const byPath = new Map(); + for (const cwd of observedCwds) { + const normalized = normalizeObservedCwd(cwd); + if (!normalized) continue; + const current = byPath.get(normalized) || { path: normalized, count: 0, first: byPath.size }; + current.count++; + byPath.set(normalized, current); + } + const best = [...byPath.values()].sort((a, b) => b.count - a.count || a.first - b.first)[0]; + return best?.path || legacyProjectPathFromSlug(project); +} + +function discoverJsonlFiles(): ClaudeJsonlFile[] { + const files: ClaudeJsonlFile[] = []; + if (!fs.existsSync(PROJECTS_DIR)) return files; + let projects; + try { projects = fs.readdirSync(PROJECTS_DIR); } catch (e) { process.stderr.write(`Warning: cannot read projects dir: ${e instanceof Error ? e.message : String(e)}\n`); return files; } + for (const proj of projects) { + const projPath = path.join(PROJECTS_DIR, proj); + if (!isDir(projPath)) continue; + let entries; + try { entries = fs.readdirSync(projPath); } catch { continue; } + for (const f of entries) { + if (f.endsWith('.jsonl')) + files.push({ path: path.join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false }); + } + for (const sd of entries) { + const saDir = path.join(projPath, sd, 'subagents'); + if (!isDir(saDir)) continue; + let saEntries; + try { saEntries = fs.readdirSync(saDir); } catch { continue; } + for (const sf of saEntries) { + if (sf.endsWith('.jsonl')) + files.push({ path: path.join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) }); + } + const wfRoot = path.join(saDir, 'workflows'); + if (!isDir(wfRoot)) continue; + let wfDirs; + try { wfDirs = fs.readdirSync(wfRoot); } catch { continue; } + for (const wfDir of wfDirs) { + const wfPath = path.join(wfRoot, wfDir); + if (!isDir(wfPath)) continue; + let wfEntries; + try { wfEntries = fs.readdirSync(wfPath); } catch { continue; } + for (const wf of wfEntries) { + if (wf.endsWith('.jsonl')) + files.push({ path: path.join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir }); + } + } + } + } + return files; +} + +function discoverCodexJsonlFiles(): CodexJsonlFile[] { + const files: CodexJsonlFile[] = []; + if (!fs.existsSync(CODEX_SESSIONS_DIR)) return files; + const walk = (dir: string): void => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + const fp = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fp); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push({ path: fp, source: 'codex' }); + } + } + }; + walk(CODEX_SESSIONS_DIR); + return files; +} + +// ---- Codex pure helpers ---- +function codexDbId(id: unknown): string | null { + if (!id) return null; + const raw = String(id).replace(/^codex:/, ''); + return `codex:${raw}`; +} + +function codexRawId(id: unknown): string | null { + return id ? String(id).replace(/^codex:/, '') : null; +} + +function codexLineUuid(threadId: unknown, lineNum: number): string { + return `codex:${codexRawId(threadId)}:${String(lineNum).padStart(6, '0')}`; +} + +function codexCallId(callId: unknown): string | null { + if (!callId) return null; + return `codex:${String(callId).replace(/^codex:/, '')}`; +} + +function codexParentThreadId(meta: JsonRecord): string | null { + const subagent = meta?.source?.subagent; + return subagent?.thread_spawn?.parent_thread_id + || meta?.forked_from_id + || subagent?.parent_thread_id + || null; +} + +function codexIsGuardianThread(meta: JsonRecord, records: CodexLineRecord[] = []): boolean { + const subagent = meta?.source?.subagent; + if (subagent?.other === 'guardian') return true; + if (meta?.thread_source !== 'subagent') return false; + return records.some(({ obj }) => obj?.payload?.model === 'codex-auto-review' || obj?.model === 'codex-auto-review'); +} + +function readCodexGuardianThreadInfo(filePath: string): { threadRawId: string; lineNum: number } | null { + const records: CodexLineRecord[] = []; + let metaRecord: CodexLineRecord | null = null; + let lineNum = 0; + readLines(filePath, (line) => { + lineNum++; + let obj: JsonRecord; + try { + obj = JSON.parse(line); + } catch { + return; + } + records.push({ lineNum, obj }); + if (obj?.type === 'session_meta' && obj.payload?.id) { + metaRecord = { lineNum, obj }; + if (obj.payload?.source?.subagent?.other === 'guardian') return false; + if (obj.payload?.thread_source !== 'subagent') return false; + } + if (metaRecord && codexIsGuardianThread(metaRecord.obj.payload, records)) return false; + }); + const capturedMeta = metaRecord as CodexLineRecord | null; + const meta = capturedMeta?.obj?.payload; + if (!meta || !codexIsGuardianThread(meta, records)) return null; + const threadRawId = codexRawId(meta.id); + return threadRawId ? { threadRawId, lineNum } : null; +} + +function codexAgentNickname(meta: JsonRecord): string | null { + return meta?.agent_nickname + || meta?.source?.subagent?.thread_spawn?.agent_nickname + || null; +} + +function codexAgentRole(meta: JsonRecord): string | null { + return meta?.agent_role + || meta?.source?.subagent?.thread_spawn?.agent_role + || null; +} + +function parseCodexJsonInput(value: JsonValue): JsonValue { + if (value === null || value === undefined || value === '') return {}; + if (typeof value !== 'string') return value; + try { return JSON.parse(value); } catch { return value; } +} + +function codexUsage(payload: JsonRecord) { + const usage = payload?.info?.last_token_usage || payload?.info?.total_token_usage || payload?.last_token_usage || null; + if (!usage) return {}; + return { + inputTokens: usage.input_tokens ?? null, + outputTokens: usage.output_tokens ?? null, + }; +} + +function codexEventText(payload: JsonRecord): string | null { + if (typeof payload?.message === 'string') return payload.message; + if (Array.isArray(payload?.text_elements) && payload.text_elements.length) { + const parts = payload.text_elements.map((item: JsonValue) => typeof item === 'string' ? item : item?.text).filter(Boolean); + if (parts.length) return parts.join('\n'); + } + if (typeof payload?.text === 'string') return payload.text; + return null; +} + +function codexMessagePayloadText(payload: JsonRecord): string | null { + if (!Array.isArray(payload?.content)) return null; + const parts: string[] = []; + for (const block of payload.content) { + if (typeof block?.text === 'string') parts.push(block.text); + } + return parts.length ? parts.join('\n') : null; +} + +function codexVisibleMessageKey(role: unknown, text: unknown): string { + return `${role || ''}\u0000${text || ''}`; +} + +function codexToolInput(payload: JsonRecord): JsonValue { + if (payload?.type === 'custom_tool_call') return parseCodexJsonInput(payload.input); + if (payload?.type === 'tool_search_call') return parseCodexJsonInput(payload.arguments); + if (payload?.type === 'web_search_call') return { action: payload.action || null }; + return parseCodexJsonInput(payload?.arguments); +} + +function codexToolOutput(payload: JsonRecord): string | null { + if (typeof payload?.output === 'string') return payload.output; + if (payload?.output !== undefined) return JSON.stringify(payload.output); + if (payload?.tools !== undefined) return JSON.stringify(payload.tools); + if (payload?.execution !== undefined) return JSON.stringify(payload.execution); + return null; +} + +export { + fs, path, os, CLAUDE_DIR, CODEX_DIR, PROJECTS_DIR, CODEX_SESSIONS_DIR, TEXT_LIMIT, + trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, + legacyProjectPathFromSlug, normalizeObservedCwd, projectSlugFromPath, inferProjectPath, + discoverJsonlFiles, discoverCodexJsonlFiles, + codexDbId, codexRawId, codexLineUuid, codexCallId, codexParentThreadId, codexIsGuardianThread, + readCodexGuardianThreadInfo, codexAgentNickname, codexAgentRole, parseCodexJsonInput, + codexUsage, codexEventText, codexMessagePayloadText, codexVisibleMessageKey, codexToolInput, codexToolOutput, +}; diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts new file mode 100644 index 0000000..c1b888f --- /dev/null +++ b/packages/core/src/persist.ts @@ -0,0 +1,125 @@ +// Shared Core persist layer (see docs/adr/0001). +// +// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream +// from any adapter's parse() and writes rows into the injected database handle +// (node:sqlite for the skill/CLI, better-sqlite3 for the app — they share the +// prepare/run/get API). It is the ONLY layer that touches the database and the +// only place that knows the schema. Adapters stay pure. +// +// Write semantics are the canonical ones reconciled from the drift: messages +// upsert via ON CONFLICT; sessions merge with any existing row (started_at MIN, +// ended_at MAX, message_count reset-or-accumulate, fill-if-null for the rest); +// turn-duration is a targeted UPDATE; delete-session cascades. The generator's +// return value is the new cursor, persisted verbatim into index_state. + +import type { Cursor, IndexRecord, IndexUnit } from './providers/types.ts'; +import type { SqliteDb } from './sqlite-types.ts'; + +const minStr = (a: string | null, b: string | null) => (a == null ? b : b == null ? a : a < b ? a : b); +const maxStr = (a: string | null, b: string | null) => (a == null ? b : b == null ? a : a > b ? a : b); + +function statements(db: SqliteDb) { + return { + msg: db.prepare(` + INSERT INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill,source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(uuid) DO UPDATE SET + session_id=excluded.session_id, type=excluded.type, parent_uuid=excluded.parent_uuid, + timestamp=excluded.timestamp, role=excluded.role, text=excluded.text, + content_type=excluded.content_type, is_meta=excluded.is_meta, model=excluded.model, + is_sidechain=excluded.is_sidechain, agent_id=excluded.agent_id, + input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens, + cwd=excluded.cwd, skill=excluded.skill, source=excluded.source`), + tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'), + tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'), + sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'), + ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path,source) VALUES (?,?,?,?,?,?,?,?,?,?,?)'), + sub: db.prepare(` + INSERT INTO subagents (agent_id,session_id,parent_tool_use_id,agent_type,description,duration_ms,total_tokens) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(agent_id) DO UPDATE SET + session_id=excluded.session_id, + parent_tool_use_id=COALESCE(excluded.parent_tool_use_id, subagents.parent_tool_use_id), + agent_type=COALESCE(excluded.agent_type, subagents.agent_type), + description=COALESCE(excluded.description, subagents.description), + duration_ms=COALESCE(excluded.duration_ms, subagents.duration_ms), + total_tokens=COALESCE(excluded.total_tokens, subagents.total_tokens)`), + turn: db.prepare('UPDATE messages SET turn_duration_ms=? WHERE uuid=?'), + idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'), + getSession: db.prepare('SELECT * FROM sessions WHERE id=?'), + }; +} + +// Cascade-delete every row belonging to a session/thread (guardian retraction). +function deleteSession(db: SqliteDb, sessionId: string) { + db.prepare('DELETE FROM tool_results WHERE session_id=? OR message_uuid IN (SELECT uuid FROM messages WHERE session_id=? OR agent_id=?)').run(sessionId, sessionId, sessionId); + db.prepare('DELETE FROM tool_calls WHERE session_id=? OR message_uuid IN (SELECT uuid FROM messages WHERE session_id=? OR agent_id=?)').run(sessionId, sessionId, sessionId); + db.prepare('DELETE FROM messages WHERE session_id=? OR agent_id=?').run(sessionId, sessionId); + db.prepare('DELETE FROM subagents WHERE agent_id=? OR session_id=?').run(sessionId, sessionId); + db.prepare('DELETE FROM summaries WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM sessions WHERE id=?').run(sessionId); +} + +// Consume one unit's record stream into the database and return the new cursor +// (also written to index_state). `db` is any SQLite handle sharing prepare/run. +export function persist(db: SqliteDb, unit: IndexUnit, gen: Generator): Cursor { + const st = statements(db); + + const write = (r: IndexRecord) => { + switch (r.kind) { + case 'message': + st.msg.run(r.uuid, r.session_id, r.type, r.parent_uuid, r.timestamp, r.role, r.text, r.content_type, r.is_meta, r.model, r.is_sidechain, r.agent_id, r.input_tokens, r.output_tokens, r.cwd, r.skill, r.source); + break; + case 'tool_call': + st.tc.run(r.id, r.message_uuid, r.session_id, r.name, r.input_json, r.file_path); + break; + case 'tool_result': + st.tr.run(r.tool_use_id, r.message_uuid, r.session_id, r.content, r.file_path, r.is_error); + break; + case 'summary': + st.sum.run(r.id, r.session_id, r.timestamp, r.source, r.content); + break; + case 'subagent': + st.sub.run(r.agent_id, r.session_id, r.parent_tool_use_id ?? null, r.agent_type ?? null, r.description ?? null, r.duration_ms ?? null, r.total_tokens ?? null); + break; + case 'message-turn-duration': + st.turn.run(r.turn_duration_ms, r.uuid); + break; + case 'session': { + const prev = st.getSession.get(r.id); + // 'delta' accumulates onto the existing count (line-incremental adapters); + // 'total' replaces it (full-reparse adapters). + const message_count = r.countMode === 'delta' ? (prev?.message_count || 0) + r.message_count : r.message_count; + st.ses.run( + r.id, + r.title ?? prev?.title ?? null, + r.project ?? prev?.project ?? null, + prev?.project_path ?? null, // authoritative project_path is set by refreshSessionProjectPaths + minStr(prev?.started_at ?? null, r.started_at), + maxStr(prev?.ended_at ?? null, r.ended_at), + r.git_branch ?? prev?.git_branch ?? null, + r.version ?? prev?.version ?? null, + message_count, + r.jsonl_path, + r.source, + ); + break; + } + case 'delete-session': + deleteSession(db, r.sessionId); + break; + default: + throw new Error(`persist: unhandled record kind ${(r as { kind: string }).kind}`); + } + }; + + let step = gen.next(); + while (!step.done) { write(step.value); step = gen.next(); } + const cursor = step.value; + + if (cursor != null) { + const [mtime, lines] = cursor.split(':'); + st.idx.run(unit.key, Number(mtime), Number(lines)); + } + return cursor; +} diff --git a/packages/core/src/providers/claude.ts b/packages/core/src/providers/claude.ts new file mode 100644 index 0000000..52715f8 --- /dev/null +++ b/packages/core/src/providers/claude.ts @@ -0,0 +1,131 @@ +// Claude Code provider adapter in Core (see docs/adr/0001). +// +// Pure: discovers Claude transcript files and parses one into a record stream. +// It never touches the Obelisk database. The per-line logic mirrors the original +// indexJsonl exactly, but yields IndexRecords instead of writing rows; the shared +// persist layer consumes them. Session aggregates here reflect only THIS chunk +// (started_at/ended_at/message_count); persist merges them with any existing row. + +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const fs = require('node:fs'); + +import { + extractText, extractContentType, extractMessageIsMeta, + filePath, trunc, truncJson, readLines, discoverJsonlFiles, +} from '../parsing.ts'; + +import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, Provider } from './types.ts'; + +// Claude cursor encodes the file mtime and the number of lines already indexed: +// ":". mtime lets discovery detect change; lines lets +// parse resume without reprocessing. +function cursorToSkip(cursor: Cursor): number { + if (!cursor) return 0; + const n = Number(cursor.split(':')[1]); + return Number.isFinite(n) ? n : 0; +} + +export const name = 'claude'; + +export function discover(_ctx: DiscoverContext): IndexUnit[] { + return discoverJsonlFiles().map((f: any) => ({ + key: f.path, + sessionId: f.sessionId, + project: f.project, + isSubagent: f.isSubagent, + agentId: f.agentId, + meta: f.workflowRunId ? { workflowRunId: f.workflowRunId } : undefined, + })); +} + +export function* parse(unit: IndexUnit, cursor: Cursor): Generator { + const skip = cursorToSkip(cursor); + const mtime = fs.statSync(unit.key).mtimeMs; + const isSubagent = unit.isSubagent === true; + const records: IndexRecord[] = []; + const sm = { + started_at: null as string | null, + ended_at: null as string | null, + git_branch: null as string | null, + version: null as string | null, + title: null as string | null, + n: 0, + }; + + let lineNum = 0; + readLines(unit.key, (line: string) => { + lineNum++; + if (lineNum <= skip) return; + let obj: any; + try { obj = JSON.parse(line); } catch { return; } + const sid = unit.sessionId; + const ts = obj.timestamp || null; + + if (obj.type === 'ai-title' && obj.aiTitle) { sm.title = obj.aiTitle; return; } + if (obj.type === 'system' && obj.subtype === 'away_summary' && obj.content) { + records.push({ kind: 'summary', id: obj.uuid || `${sid}-away-${ts}`, session_id: sid, timestamp: ts, source: 'away_summary', content: obj.content }); + return; + } + if (obj.type === 'system' && obj.subtype === 'turn_duration' && obj.parentUuid && obj.durationMs) { + records.push({ kind: 'message-turn-duration', uuid: obj.parentUuid, turn_duration_ms: obj.durationMs }); + return; + } + if (obj.type !== 'user' && obj.type !== 'assistant') return; + + if (ts && (!sm.started_at || ts < sm.started_at)) sm.started_at = ts; + if (ts && (!sm.ended_at || ts > sm.ended_at)) sm.ended_at = ts; + if (obj.gitBranch) sm.git_branch = obj.gitBranch; + if (obj.version) sm.version = obj.version; + sm.n++; + + const msg = obj.message || {}; + const text = extractText(msg.content); + const contentType = extractContentType(msg.content); + const isMeta = extractMessageIsMeta(obj, text); + const usage = msg.usage || {}; + const aid = isSubagent ? (unit.agentId ?? null) : (obj.agentId || null); + + if (obj.uuid) { + records.push({ + kind: 'message', uuid: obj.uuid, session_id: sid, type: obj.type, + parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type, + text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null, + is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid, + input_tokens: usage.input_tokens || null, output_tokens: usage.output_tokens || null, + cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude', + }); + } + + if (obj.type === 'assistant' && Array.isArray(msg.content)) { + for (const b of msg.content) { + if (b.type === 'tool_use' && b.id) + records.push({ kind: 'tool_call', id: b.id, message_uuid: obj.uuid, session_id: sid, name: b.name, input_json: truncJson(b.input || {}) as string, file_path: filePath(b.name, b.input) }); + } + } + + if (obj.type === 'user' && Array.isArray(msg.content)) { + for (const b of msg.content) { + if (b.type !== 'tool_result' || !b.tool_use_id) continue; + const rt = typeof b.content === 'string' ? b.content + : Array.isArray(b.content) ? b.content.map((c: any) => c.text || '').join('\n') : ''; + records.push({ kind: 'tool_result', tool_use_id: b.tool_use_id, message_uuid: obj.uuid, session_id: sid, content: trunc(rt), file_path: obj.toolUseResult?.filePath || null, is_error: b.is_error ? 1 : 0 }); + } + } + }); + + // Subagent transcripts do not own a session row (matches indexJsonl). + if (!isSubagent) { + records.push({ + kind: 'session', id: unit.sessionId, title: sm.title, project: unit.project || null, + started_at: sm.started_at, ended_at: sm.ended_at, git_branch: sm.git_branch, + version: sm.version, message_count: sm.n, countMode: skip > 0 ? 'delta' : 'total', + jsonl_path: unit.key, source: 'claude', + }); + } + + yield* records; + return `${mtime}:${lineNum}`; +} + +export const claudeProvider: Provider = { name, discover, parse }; diff --git a/packages/core/src/providers/codex.ts b/packages/core/src/providers/codex.ts new file mode 100644 index 0000000..05ecd4c --- /dev/null +++ b/packages/core/src/providers/codex.ts @@ -0,0 +1,220 @@ +// Codex provider adapter in Core (see docs/adr/0001). +// +// Pure: discovers Codex rollout files and parses one into a record stream. It +// never touches the Obelisk database. Unlike claude, codex is a FULL-REPARSE +// adapter: it buffers every line and re-emits every record on each run, because +// the event_msg ↔ response_item dedup needs whole-file (bidirectional) knowledge +// (the matching pair sits ±1 line apart but in either order). Hence the session +// record uses countMode 'total' (persist replaces the count, never accumulates). +// The per-line logic mirrors the original indexCodexJsonl. + +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const fs = require('node:fs'); + +import { + trunc, truncJson, readLines, + discoverCodexJsonlFiles, normalizeObservedCwd, projectSlugFromPath, + codexRawId, codexDbId, codexCallId, codexLineUuid, codexParentThreadId, + codexIsGuardianThread, codexAgentNickname, codexAgentRole, codexUsage, + codexEventText, codexMessagePayloadText, codexVisibleMessageKey, + codexToolInput, codexToolOutput, +} from '../parsing.ts'; + +import type { Cursor, DiscoverContext, IndexRecord, IndexUnit, MessageRecord, Provider } from './types.ts'; + +export const name = 'codex'; + +export function discover(_ctx: DiscoverContext): IndexUnit[] { + return discoverCodexJsonlFiles().map((f: any) => ({ key: f.path, sessionId: '', meta: { source: 'codex' } })); +} + +export function* parse(unit: IndexUnit, _cursor: Cursor): Generator { + const mtime = fs.statSync(unit.key).mtimeMs; + const records: { lineNum: number; obj: any }[] = []; + let lineNum = 0; + readLines(unit.key, (line: string) => { + lineNum++; + try { records.push({ lineNum, obj: JSON.parse(line) }); } catch { /* skip malformed */ } + }); + const outCursor = `${mtime}:${lineNum}`; + + const metaRecord = records.find(r => r.obj?.type === 'session_meta' && r.obj.payload?.id); + if (!metaRecord) return outCursor; + + const meta = metaRecord.obj.payload; + const threadRawId = codexRawId(meta.id) as string; + if (codexIsGuardianThread(meta, records)) { + yield { kind: 'delete-session', sessionId: codexDbId(threadRawId) as string }; + return outCursor; + } + + const parentRawId = codexParentThreadId(meta); + const sessionId = codexDbId(parentRawId || threadRawId) as string; + const agentId = (parentRawId ? codexDbId(threadRawId) : null) as string | null; + const isSidechain: 0 | 1 = agentId ? 1 : 0; + const project = projectSlugFromPath(normalizeObservedCwd(meta.cwd)); + const lineUuid = (n: number): string => codexLineUuid(threadRawId, n) as string; + + const out: IndexRecord[] = []; + const msgByUuid = new Map(); + const sm = { + started_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null, + ended_at: (meta.timestamp || metaRecord.obj.timestamp || null) as string | null, + git_branch: (meta.git?.branch || null) as string | null, + version: (meta.cli_version || null) as string | null, + title: null as string | null, + n: 0, + lastMessageUuid: null as string | null, + lastTextAssistantUuid: null as string | null, + totalInputTokens: 0, + totalOutputTokens: 0, + }; + + let currentCwd = normalizeObservedCwd(meta.cwd); + let currentModel: string | null = null; + const eventMessageKeys = new Set(); + const callMessageUuids = new Map(); + + const updateBounds = (ts: string | null) => { + if (!ts) return; + if (!sm.started_at || ts < sm.started_at) sm.started_at = ts; + if (!sm.ended_at || ts > sm.ended_at) sm.ended_at = ts; + }; + + const insertMessage = ({ uuid, type, role, text = null, contentType = 'text', timestamp, isMeta = 0 }: { + uuid: string; type: string; role: string; text?: string | null; contentType?: string; timestamp: string | null; isMeta?: 0 | 1; + }) => { + const rec: MessageRecord = { + kind: 'message', uuid, session_id: sessionId, type, parent_uuid: sm.lastMessageUuid, + timestamp: timestamp || null, role, text: trunc(text), content_type: contentType, + is_meta: isMeta, model: currentModel, is_sidechain: isSidechain, agent_id: agentId, + input_tokens: null, output_tokens: null, cwd: currentCwd, skill: null, source: 'codex', + }; + out.push(rec); + msgByUuid.set(uuid, rec); + sm.lastMessageUuid = uuid; + if (!agentId) sm.n++; + if (type === 'assistant' && contentType === 'text') sm.lastTextAssistantUuid = uuid; + updateBounds(timestamp); + return uuid; + }; + + // First pass: collect visible event_msg keys so duplicate response_items drop. + for (const { obj } of records) { + if (obj?.type !== 'event_msg') continue; + const payload = obj.payload || {}; + if (payload.type !== 'user_message' && payload.type !== 'agent_message') continue; + const text = codexEventText(payload); + if (text === null) continue; + eventMessageKeys.add(codexVisibleMessageKey(payload.type === 'user_message' ? 'user' : 'assistant', text)); + } + + for (const { lineNum: currentLine, obj } of records) { + const ts = obj.timestamp || null; + if (obj.type === 'session_meta') { + if (obj.payload?.cwd) currentCwd = normalizeObservedCwd(obj.payload.cwd) || currentCwd; + if (obj.payload?.git?.branch) sm.git_branch = obj.payload.git.branch; + if (obj.payload?.cli_version) sm.version = obj.payload.cli_version; + updateBounds(obj.payload?.timestamp || ts); + continue; + } + if (obj.type === 'turn_context') { + currentCwd = normalizeObservedCwd(obj.payload?.cwd) || currentCwd; + currentModel = obj.payload?.model || currentModel; + updateBounds(ts); + continue; + } + if (obj.type === 'event_msg') { + const payload = obj.payload || {}; + if (payload.type === 'user_message' || payload.type === 'agent_message' || payload.type === 'agent_reasoning') { + const text = codexEventText(payload); + if (text === null) continue; + const isReasoning = payload.type === 'agent_reasoning'; + insertMessage({ + uuid: lineUuid(currentLine), + type: payload.type === 'user_message' ? 'user' : 'assistant', + role: payload.type === 'user_message' ? 'user' : 'assistant', + text, contentType: isReasoning ? 'thinking' : 'text', timestamp: ts, + }); + continue; + } + if (payload.type === 'collab_agent_spawn_end' && payload.call_id && payload.new_thread_id) { + const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts }); + const toolId = codexCallId(payload.call_id) as string; + const description = payload.new_agent_nickname || payload.new_agent_role || 'Agent'; + const input = { + description, subagent_type: payload.new_agent_role || 'Agent', prompt: payload.prompt || '', + new_thread_id: payload.new_thread_id, model: payload.model || null, reasoning_effort: payload.reasoning_effort || null, + }; + out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name: 'Agent', input_json: truncJson(input) as string, file_path: null }); + callMessageUuids.set(toolId, uuid); + out.push({ kind: 'subagent', agent_id: codexDbId(payload.new_thread_id) as string, session_id: sessionId, parent_tool_use_id: toolId, agent_type: payload.new_agent_role || null, description }); + continue; + } + if (payload.type === 'task_complete') { + if (sm.lastTextAssistantUuid && payload.duration_ms !== undefined) { + out.push({ kind: 'message-turn-duration', uuid: sm.lastTextAssistantUuid, turn_duration_ms: payload.duration_ms || null }); + } + updateBounds(ts); + continue; + } + if (payload.type === 'token_count') { + const usage = codexUsage(payload); + if (usage.inputTokens !== null) sm.totalInputTokens = usage.inputTokens; + if (usage.outputTokens !== null) sm.totalOutputTokens = usage.outputTokens; + if (sm.lastTextAssistantUuid && (usage.inputTokens !== null || usage.outputTokens !== null)) { + const rec = msgByUuid.get(sm.lastTextAssistantUuid); + if (rec) { rec.input_tokens = usage.inputTokens; rec.output_tokens = usage.outputTokens; } + } + continue; + } + if (payload.type === 'thread_name_updated' && payload.thread_name) sm.title = payload.thread_name; + continue; + } + if (obj.type !== 'response_item') continue; + const payload = obj.payload || {}; + if (payload.type === 'message' && payload.role !== 'developer') { + const text = codexMessagePayloadText(payload); + const role = payload.role || 'assistant'; + if (text !== null && !eventMessageKeys.has(codexVisibleMessageKey(role, text))) { + insertMessage({ uuid: lineUuid(currentLine), type: role === 'user' ? 'user' : 'assistant', role, text, contentType: 'text', timestamp: ts }); + } + continue; + } + if (['function_call', 'custom_tool_call', 'tool_search_call', 'web_search_call'].includes(payload.type) && payload.call_id) { + const uuid = insertMessage({ uuid: lineUuid(currentLine), type: 'assistant', role: 'assistant', text: null, contentType: 'tool_use', timestamp: ts }); + const name = payload.name || payload.tool || payload.type.replace(/_call$/, ''); + const toolId = codexCallId(payload.call_id) as string; + out.push({ kind: 'tool_call', id: toolId, message_uuid: uuid, session_id: sessionId, name, input_json: truncJson(codexToolInput(payload)) as string, file_path: null }); + callMessageUuids.set(toolId, uuid); + continue; + } + if (['function_call_output', 'custom_tool_call_output', 'tool_search_output'].includes(payload.type) && payload.call_id) { + const toolId = codexCallId(payload.call_id) as string; + out.push({ kind: 'tool_result', tool_use_id: toolId, message_uuid: callMessageUuids.get(toolId) || '', session_id: sessionId, content: trunc(codexToolOutput(payload) || ''), file_path: null, is_error: payload.is_error ? 1 : 0 }); + } + } + + if (agentId) { + const started = sm.started_at ? new Date(sm.started_at).getTime() : null; + const ended = sm.ended_at ? new Date(sm.ended_at).getTime() : null; + const tokenTotal = (sm.totalInputTokens || 0) + (sm.totalOutputTokens || 0); + out.push({ + kind: 'subagent', agent_id: agentId, session_id: sessionId, + agent_type: codexAgentRole(meta), description: codexAgentNickname(meta), + duration_ms: started && ended ? ended - started : null, total_tokens: tokenTotal || null, + }); + } else { + out.push({ + kind: 'session', id: sessionId, title: sm.title, project, + started_at: sm.started_at, ended_at: sm.ended_at, git_branch: sm.git_branch, version: sm.version, + message_count: sm.n, countMode: 'total', jsonl_path: unit.key, source: 'codex', + }); + } + + yield* out; + return outCursor; +} + +export const codexProvider: Provider = { name, discover, parse }; diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts new file mode 100644 index 0000000..e72587f --- /dev/null +++ b/packages/core/src/providers/types.ts @@ -0,0 +1,226 @@ +// Core provider contract (see docs/adr/0001). +// +// The indexing layer splits along two orthogonal axes: +// - Provider axis: pure per-source adapters (claude, codex, later opencode, +// pi, …) that discover their own work and parse it into records. A source is +// NOT assumed to be a single JSONL file — an adapter may read a SQLite store, +// a directory tree, etc. So discovery, change-detection, and resume cursoring +// are all adapter-owned and format-specific. +// - Persist axis: one shared, provider- and binding-agnostic orchestration +// that consumes the records and writes them (index_state, FTS, upsert). +// +// This file defines only the shapes crossing that boundary. Record fields mirror +// the columns in packages/core/src/schema.sql; keep them in sync. Types only — no runtime +// code — so consumers must import with `import type`. + +// Opaque per-unit resume/watermark token. The orchestration stores it verbatim +// (in index_state) and hands it back on the next run; ONLY the adapter that +// produced it interprets it. A JSONL adapter might encode `"${mtime}:${lines}"`; +// a SQLite-backed adapter might encode a rowid or timestamp high-water mark. +export type Cursor = string | null; + +// One unit of work an adapter has discovered. It is not necessarily a file: for +// a file-based source `key` is the path; for a DB-backed source it might be +// `"${dbPath}#${internalId}"`. `meta` carries adapter-private data (e.g. the +// resolved file path or source handle) that the orchestration passes back to +// parse() untouched. +export interface IndexUnit { + /** Stable identity used as the index_state cursor key. */ + key: string; + /** Session id this unit indexes into. */ + sessionId: string; + /** Project slug (dash-encoded path), when the source exposes one. */ + project?: string; + /** Set for subagent transcripts, whose messages carry an agent id. */ + isSubagent?: boolean; + agentId?: string; + /** Adapter-private payload, opaque to the orchestration. */ + meta?: unknown; +} + +/** Context the orchestration provides to discovery. */ +export interface DiscoverContext { + /** Look up the cursor persisted for a unit key on a previous run. */ + lastCursor(key: string): Cursor; + /** When set (daemon changed-path mode), restrict discovery to these paths. */ + changedPaths?: string[]; +} + +/** Discriminated union of everything an adapter's parse can emit. Each record + * kind maps to one schema table (see packages/core/src/schema.sql); `delete-session` is + * the exception — a retraction op, not a table. Sources without a table + * (history.jsonl, codex session_index.jsonl) are not records: adapters fold them + * into the SessionRecord they already emit. */ +export type IndexRecord = + | SessionRecord + | MessageRecord + | ToolCallRecord + | ToolResultRecord + | SummaryRecord + | SubagentRecord + | WorkflowRecord + | WorkflowAgentRecord + | MessageTurnDurationRecord + | DeleteSessionRecord; + +export interface MessageRecord { + kind: 'message'; + uuid: string; + session_id: string; + type: string; + parent_uuid: string | null; + timestamp: string | null; + role: string | null; + text: string | null; + content_type: string | null; + is_meta: 0 | 1; + model: string | null; + is_sidechain: 0 | 1; + agent_id: string | null; + input_tokens: number | null; + output_tokens: number | null; + cwd: string | null; + skill: string | null; + source: string; +} + +export interface ToolCallRecord { + kind: 'tool_call'; + id: string; + message_uuid: string; + session_id: string; + name: string; + input_json: string; + file_path: string | null; +} + +export interface ToolResultRecord { + kind: 'tool_result'; + tool_use_id: string; + message_uuid: string; + session_id: string; + content: string; + file_path: string | null; + is_error: 0 | 1; +} + +export interface SummaryRecord { + kind: 'summary'; + id: string; + session_id: string; + timestamp: string | null; + source: string; + content: string; +} + +// One codex subagent. Like workflow_agent, a row can be contributed by more than +// one point in the parse (the spawn event vs the agent's own thread), so non-key +// fields are optional and persist merges them column-wise with COALESCE. +export interface SubagentRecord { + kind: 'subagent'; + agent_id: string; + session_id: string; + parent_tool_use_id?: string | null; + agent_type?: string | null; + description?: string | null; + duration_ms?: number | null; + total_tokens?: number | null; +} + +// A workflow run. `agent_count` is intentionally absent: it is a derived +// aggregate (COUNT of workflow_agents for this run) that persist computes, since +// the agents may be indexed on different runs than the workflow metadata. +export interface WorkflowRecord { + kind: 'workflow'; + run_id: string; + session_id: string; + task_id: string | null; + script: string | null; + result_json: string | null; + timestamp: string | null; + duration_ms: number | null; + total_tokens: number | null; + status: string | null; + workflow_name: string | null; +} + +// One workflow agent. A single row is contributed by TWO independent units, in +// any order: the subagent .meta.json unit fills agent_type/description; the +// workflow run json unit fills phase/label/model/state/duration_ms/tokens/ +// tool_calls. So every optional field a unit does not know is omitted, and +// persist merges column-wise (ON CONFLICT(agent_id) DO UPDATE SET +// col=COALESCE(excluded.col, col)). All contributors MUST use the same unified +// agent_id key so the merge lands on the same row. +export interface WorkflowAgentRecord { + kind: 'workflow_agent'; + agent_id: string; + run_id: string; + session_id: string; + agent_type?: string | null; + description?: string | null; + phase?: string | null; + label?: string | null; + model?: string | null; + state?: string | null; + duration_ms?: number | null; + tokens?: number | null; + tool_calls?: number | null; +} + +// Update op (not a table): sets messages.turn_duration_ms for a message that was +// (or will be) inserted by a separate line, possibly on a different run. Persist +// applies it as a targeted UPDATE, so it never clobbers other message columns. +export interface MessageTurnDurationRecord { + kind: 'message-turn-duration'; + uuid: string; + turn_duration_ms: number | null; +} + +// Retraction op (not a table). The adapter emits this when a previously-indexed +// session must be removed — e.g. a Codex guardian/auto-review thread. Persist +// executes the cascade delete across all tables for that session. +export interface DeleteSessionRecord { + kind: 'delete-session'; + sessionId: string; +} + +// Session-level aggregate. Emitted once, after the unit's records are produced, +// because started_at/ended_at/message_count are computed across the stream. +// title/ended_at may be enriched by the adapter from source-specific auxiliary +// files (claude history.jsonl, codex session_index.jsonl); persist upserts with +// fill-if-null (COALESCE) so those never clobber a value already present. +// project_path is NOT set here — the orchestration's global pass derives it from +// persisted message cwds (refreshSessionProjectPaths). +// +// countMode tells persist how to treat message_count, because providers differ: +// a line-incremental adapter (claude) yields only new messages ('delta', persist +// accumulates onto the existing row); a full-reparse adapter (codex) yields every +// message each run ('total', persist replaces). A 'delta' parse from an empty +// cursor is equivalent to 'total'. +export interface SessionRecord { + kind: 'session'; + id: string; + title: string | null; + project: string | null; + started_at: string | null; + ended_at: string | null; + git_branch: string | null; + version: string | null; + message_count: number; + countMode: 'total' | 'delta'; + jsonl_path: string; + source: string; +} + +// A transcript source. Pure: it never touches the Obelisk database. It owns its +// own discovery, change-detection, and resume cursoring, because those are +// format-specific (file mtime, DB watermark, …). `parse` is a generator that +// yields records for one unit and RETURNS the new cursor to persist. +export interface Provider { + /** Stable source tag stored on rows, e.g. 'claude' | 'codex'. */ + readonly name: string; + /** Discover units needing (re)indexing, using stored cursors to detect change. */ + discover(ctx: DiscoverContext): IndexUnit[]; + /** Stream records for one unit resuming from `cursor`; return the new cursor. */ + parse(unit: IndexUnit, cursor: Cursor): Generator; +} diff --git a/scripts/query.mjs b/packages/core/src/query.ts similarity index 81% rename from scripts/query.mjs rename to packages/core/src/query.ts index 20bc96e..1b905b8 100644 --- a/scripts/query.mjs +++ b/packages/core/src/query.ts @@ -1,15 +1,58 @@ -import { readLines, fs, path } from './db.mjs'; +// Query and attune sandbox helpers for the Core package. +import { readLines, fs, path } from './db.ts'; +import type { SqliteDb, SqliteRow } from './sqlite-types.ts'; -function normalizeOpts(optsOrScalar, scalarKey = 'sessionId') { +type DbRow = SqliteRow; + +interface QueryOptions extends Record { + limit?: number; + sessionId?: string; + sessions?: string[]; + project?: string; + after?: string; + before?: string; + cwd?: string; + branch?: string; + source?: string; + includeMeta?: boolean; + query?: string; + projectLimit?: number; + memoryLimit?: number; +} + +interface ColumnAliases { + sessionId: string; + project: string; + timestamp: string; + branch: string; + source?: string; +} + +interface RememberInput { + path: string; + session_id?: string; + message_start?: string; + message_end?: string; + summary: string; + project?: string; + anchors?: unknown; +} + +interface ForgetInput { + id: string; + reason: string; +} + +function normalizeOpts(optsOrScalar: QueryOptions | string | number | null | undefined, scalarKey = 'sessionId'): QueryOptions { if (optsOrScalar == null) return {}; if (typeof optsOrScalar === 'string') return { [scalarKey]: optsOrScalar }; if (typeof optsOrScalar === 'number') return { limit: optsOrScalar }; return optsOrScalar; } -function buildWhere(opts, aliases) { - const clauses = []; - const params = []; +function buildWhere(opts: QueryOptions, aliases: ColumnAliases) { + const clauses: string[] = []; + const params: any[] = []; if (opts.sessionId) { clauses.push(`${aliases.sessionId} = ?`); params.push(opts.sessionId); } if (opts.sessions?.length) { clauses.push(`${aliases.sessionId} IN (${opts.sessions.map(() => '?').join(',')})`); @@ -28,7 +71,7 @@ function buildWhere(opts, aliases) { const BASH_EXIT_PAT = 'Exit code %'; -function assertReadOnlySql(sql) { +function assertReadOnlySql(sql: unknown): void { const text = String(sql || '').trim(); if (!/^(SELECT|WITH)\b/i.test(text)) { throw new Error('sql() only supports read-only SELECT/WITH queries'); @@ -40,7 +83,7 @@ function assertReadOnlySql(sql) { const CJK_TEXT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; -function assertEnglishMemoryText(value, label) { +function assertEnglishMemoryText(value: unknown, label: string): void { const text = String(value || ''); if (!text.trim()) return; if (CJK_TEXT_RE.test(text)) { @@ -49,7 +92,7 @@ function assertEnglishMemoryText(value, label) { } } -function buildSafeFtsQuery(text) { +function buildSafeFtsQuery(text: unknown): string { const tokens = String(text || '').match(/[\p{Letter}\p{Number}]+/gu) || []; return tokens .slice(0, 12) @@ -57,43 +100,53 @@ function buildSafeFtsQuery(text) { .join(' '); } -function createQueryApi(db) { - const q = (sql, ...p) => { +function createQueryApi(db: SqliteDb) { + const q = (sql: string, ...p: any[]) => { assertReadOnlySql(sql); return db.prepare(sql).all(...p); }; - const normalizeOverviewOpts = (optsOrScalar) => { + const normalizeOverviewOpts = (optsOrScalar: QueryOptions | string | number | null | undefined): QueryOptions => { if (optsOrScalar == null) return {}; if (typeof optsOrScalar === 'string') return { project: optsOrScalar }; if (typeof optsOrScalar === 'number') return { limit: optsOrScalar }; return optsOrScalar; }; - const search = (text, opts = {}) => { + const search = (text: string, opts: QueryOptions = {}) => { const { limit = 20, sessionId, project, after, before, cwd, source, includeMeta = false } = opts; let where = 'WHERE mf.text MATCH ?'; - const p = [text]; - if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); } - if (project) { where += ' AND s.project LIKE ?'; p.push(project); } - if (after) { where += ' AND m.timestamp>?'; p.push(after); } - if (before) { where += ' AND m.timestamp?'; filterParams.push(after); } + if (before) { where += ' AND m.timestamp { + ${where} ORDER BY rank LIMIT ?`); + const runMatch = (matchText: string): DbRow[] => stmt.all(matchText, ...filterParams, limit); + // Honor raw FTS5 syntax when the query is valid, but never crash on ordinary + // input (hyphens, punctuation) that FTS5 would parse as operators: fall back + // to safe per-token quoting, the same tokenization memories() uses. + let rows; + try { + rows = runMatch(text); + } catch { + const safe = buildSafeFtsQuery(text); + rows = safe ? runMatch(safe) : []; + } + return rows.map((r: DbRow) => { const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; const ctx = db.prepare( `SELECT uuid,text,content_type,is_meta,role,timestamp,model,COALESCE(source, 'claude') as source FROM messages WHERE session_id=? AND uuid!=? ${metaClause} ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6` - ).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1); + ).all(r.session_id, r.uuid, r.timestamp).sort((a: DbRow, b: DbRow) => a.timestamp < b.timestamp ? -1 : 1); const sourceValue = r.m_source || r.s_source || 'claude'; return { message: { uuid: r.uuid, text: r.text, content_type: r.content_type, is_meta: r.is_meta || 0, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd, source: sourceValue }, @@ -104,14 +157,14 @@ function createQueryApi(db) { }); }; - const context = (uuid) => { + const context = (uuid: string) => { const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); if (!msg) return null; const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id); - const chain = []; - let cur = msg; + const chain: DbRow[] = []; + let cur: DbRow | undefined = msg; while (cur?.parent_uuid) { cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid); if (cur) chain.unshift(cur); } - let subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null; + const subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null; let workflow = null; if (msg.agent_id) { const wa = db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id); @@ -120,33 +173,33 @@ function createQueryApi(db) { return { message: msg, parentChain: chain, session, subagent, workflow }; }; - const trace = (uuid) => { - const chain = []; + const trace = (uuid: string) => { + const chain: DbRow[] = []; let cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid); - while (cur) { chain.unshift(cur); cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : null; } + while (cur) { chain.unshift(cur); cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : undefined; } return chain; }; - const thread = (sid, opts = {}) => { + const thread = (sid: string, opts: QueryOptions = {}) => { const includeMeta = opts?.includeMeta === true; const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0'; return db.prepare(`SELECT * FROM messages WHERE session_id=? ${metaClause} ORDER BY timestamp`).all(sid); }; - const subagents = (optsOrSid) => { + const subagents = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const needsJoin = opts.project || opts.branch || opts.source; const { where, params } = buildWhere(opts, { sessionId: 'sa.session_id', project: 's.project', timestamp: 'sa.session_id', branch: 's.git_branch', source: 's.source' }); params.push(limit); const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=sa.session_id' : ''; - return db.prepare(`SELECT sa.* FROM subagents sa ${join} WHERE ${where} LIMIT ?`).all(...params).map(r => { + return db.prepare(`SELECT sa.* FROM subagents sa ${join} WHERE ${where} LIMIT ?`).all(...params).map((r: DbRow) => { const c = db.prepare('SELECT COUNT(*) as c FROM messages WHERE agent_id=?').get(r.agent_id); return { ...r, messageCount: c?.c || 0 }; }); }; - const workflows = (optsOrSid) => { + const workflows = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const needsJoin = opts.project || opts.branch || opts.source; @@ -156,36 +209,36 @@ function createQueryApi(db) { return db.prepare(`SELECT w.* FROM workflows w ${join} WHERE ${where} ORDER BY w.timestamp DESC LIMIT ?`).all(...params); }; - const workflowTree = (runId) => { + const workflowTree = (runId: string) => { const wf = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(runId); if (!wf) return null; let result = null; - try { result = JSON.parse(wf.result_json); } catch {} - const agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(runId).map(a => { + try { result = JSON.parse(wf.result_json); } catch { /* keep the raw result nullable */ } + const agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(runId).map((a: DbRow) => { const mc = db.prepare('SELECT COUNT(*) as c FROM messages WHERE agent_id=?').get(a.agent_id); return { ...a, messageCount: mc?.c || 0 }; }); return { ...wf, result, agents }; }; - const fileHistory = (fp, opts = {}) => { + const fileHistory = (fp: string, opts: QueryOptions = {}) => { const { limit = 200, after, before, source } = opts; let where = 'tc.file_path=?'; - const params = [fp]; + const params: any[] = [fp]; if (after) { where += ' AND m.timestamp > ?'; params.push(after); } if (before) { where += ' AND m.timestamp < ?'; params.push(before); } if (source && source !== 'all') { where += " AND COALESCE(s.source, 'claude') = ?"; params.push(source); } params.push(limit); return db.prepare( `SELECT tc.*,s.title as s_title,s.project as s_project,m.timestamp as ts FROM tool_calls tc LEFT JOIN sessions s ON s.id=tc.session_id LEFT JOIN messages m ON m.uuid=tc.message_uuid WHERE ${where} ORDER BY m.timestamp LIMIT ?` - ).all(...params).map(r => ({ + ).all(...params).map((r: DbRow) => ({ toolCall: { id: r.id, message_uuid: r.message_uuid, name: r.name, input_json: r.input_json }, session: { id: r.session_id, title: r.s_title, project: r.s_project }, timestamp: r.ts, })); }; - const failures = (optsOrSid) => { + const failures = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 50 } = opts; const needsJoin = opts.project || opts.branch || opts.source; @@ -194,7 +247,7 @@ function createQueryApi(db) { const errorCond = `(tr.is_error = 1 OR tr.content LIKE '${BASH_EXIT_PAT}')`; const allParams = [...filterParams, limit]; const rows = db.prepare(`SELECT tr.* FROM tool_results tr ${join} LEFT JOIN messages rm ON rm.uuid=tr.message_uuid WHERE ${errorCond} AND ${where} ORDER BY rm.timestamp DESC LIMIT ?`).all(...allParams); - return rows.map(r => { + return rows.map((r: DbRow) => { const tc = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(r.tool_use_id); const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(r.session_id); const rm = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid); @@ -203,7 +256,7 @@ function createQueryApi(db) { }); }; - const sessions = (optsOrN) => { + const sessions = (optsOrN?: QueryOptions | number | string) => { const opts = normalizeOpts(optsOrN, 'sessionId'); const { limit = 50 } = opts; const { where, params } = buildWhere(opts, { sessionId: 's.id', project: 's.project', timestamp: 's.started_at', branch: 's.git_branch', source: 's.source' }); @@ -213,7 +266,7 @@ function createQueryApi(db) { const recent = (n = 10) => sessions({ limit: n }); - const summaries = (optsOrSid) => { + const summaries = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 100 } = opts; const { where, params } = buildWhere(opts, { sessionId: 'su.session_id', project: 's.project', timestamp: 'su.timestamp', branch: 's.git_branch', source: 's.source' }); @@ -221,21 +274,21 @@ function createQueryApi(db) { return db.prepare(`SELECT su.*, s.title as session_title, s.project FROM summaries su LEFT JOIN sessions s ON s.id=su.session_id WHERE ${where} ORDER BY su.timestamp DESC LIMIT ?`).all(...params); }; - const overview = (optsOrScalar) => { + const overview = (optsOrScalar?: QueryOptions | string | number) => { const opts = normalizeOverviewOpts(optsOrScalar); const cwd = process.cwd(); const sessionLimit = opts.limit ?? 8; const projectLimit = opts.projectLimit ?? 20; const memoryLimit = opts.memoryLimit ?? 100; - const projectDescriptor = (row, source, confidence) => row ? ({ + const projectDescriptor = (row: DbRow | null, source: string, confidence: string) => row ? ({ project: row.project, project_path: row.project_path || null, source, confidence, }) : null; - const latestProjectByPattern = (pattern) => { + const latestProjectByPattern = (pattern: string): DbRow | undefined => { const fromSessions = db.prepare(` SELECT project, project_path FROM sessions @@ -267,8 +320,8 @@ function createQueryApi(db) { GROUP BY project, project_path `).all(); const byProjectPath = paths - .filter(r => cwd === r.project_path || cwd.startsWith(r.project_path + path.sep)) - .sort((a, b) => b.project_path.length - a.project_path.length || String(b.last_seen || '').localeCompare(String(a.last_seen || '')))[0]; + .filter((r: DbRow) => cwd === r.project_path || cwd.startsWith(r.project_path + path.sep)) + .sort((a: DbRow, b: DbRow) => b.project_path.length - a.project_path.length || String(b.last_seen || '').localeCompare(String(a.last_seen || '')))[0]; if (byProjectPath) return projectDescriptor(byProjectPath, 'cwd_project_path', 'exact'); const byMessageCwd = db.prepare(` @@ -321,7 +374,7 @@ function createQueryApi(db) { LEFT JOIN memory_stats ms ON ms.project = n.project ORDER BY COALESCE(ss.last_session_at, ms.last_memory_at) DESC LIMIT ? - `).all(projectLimit).map(row => { + `).all(projectLimit).map((row: DbRow) => { const branches = db.prepare(` SELECT git_branch FROM sessions @@ -329,7 +382,7 @@ function createQueryApi(db) { GROUP BY git_branch ORDER BY MAX(COALESCE(ended_at, started_at)) DESC LIMIT 5 - `).all(row.project).map(r => r.git_branch); + `).all(row.project).map((r: DbRow) => r.git_branch); return { ...row, recent_branches: branches }; }); @@ -397,7 +450,7 @@ function createQueryApi(db) { }; }; - const resolveJsonlPath = (messageUuid) => { + const resolveJsonlPath = (messageUuid: string): string | null => { const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(messageUuid); if (!msg) return null; if (msg.source === 'codex' || String(messageUuid).startsWith('codex:')) { @@ -433,7 +486,7 @@ function createQueryApi(db) { return null; }; - const findCodexRawLine = (jsonlPath, uuid) => { + const findCodexRawLine = (jsonlPath: string | null, uuid: string): string | null => { const match = /^codex:[^:]+:(\d+)$/.exec(String(uuid)); if (!match || !jsonlPath || !fs.existsSync(jsonlPath)) return null; const targetLine = Number(match[1]); @@ -448,18 +501,18 @@ function createQueryApi(db) { return found; }; - const findRawLine = (jsonlPath, uuid) => { + const findRawLine = (jsonlPath: string | null, uuid: string): string | null => { if (!jsonlPath || !fs.existsSync(jsonlPath)) return null; if (String(uuid).startsWith('codex:')) return findCodexRawLine(jsonlPath, uuid); let found = null; readLines(jsonlPath, (line) => { if (!line.includes(uuid)) return; - try { const obj = JSON.parse(line); if (obj.uuid === uuid) { found = line; return false; } } catch {} + try { const obj = JSON.parse(line); if (obj.uuid === uuid) { found = line; return false; } } catch { /* skip malformed JSONL lines */ } }); return found; }; - const raw = (messageUuid, opts = {}) => { + const raw = (messageUuid: string, opts: { offset?: number; limit?: number } = {}) => { const { offset = 0, limit = 10000 } = opts; const jsonlPath = resolveJsonlPath(messageUuid); const line = findRawLine(jsonlPath, messageUuid); @@ -473,7 +526,7 @@ function createQueryApi(db) { }; }; - const memories = (optsOrSid) => { + const memories = (optsOrSid?: QueryOptions | string) => { const opts = normalizeOpts(optsOrSid); const { limit = 50, query } = opts; assertEnglishMemoryText(query, 'memories() query'); @@ -485,7 +538,7 @@ function createQueryApi(db) { branch: 's.git_branch', source: 's.source', }); - let where = baseWhere + ' AND mem.deleted_at IS NULL'; + const where = baseWhere + ' AND mem.deleted_at IS NULL'; const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : ''; const hasQuery = String(query || '').trim().length > 0; const ftsQuery = buildSafeFtsQuery(query); @@ -510,8 +563,8 @@ function createQueryApi(db) { return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories, overview }; } -function createAttuneApi(db) { - const resolveMemoryPath = (memoryPath, sessionId) => { +function createAttuneApi(db: SqliteDb) { + const resolveMemoryPath = (memoryPath: string, sessionId?: string): string => { let base = null; if (sessionId) { base = db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId)?.project_path || null; @@ -529,7 +582,7 @@ function createAttuneApi(db) { return resolved; }; - const normalizeAnchors = (anchors) => { + const normalizeAnchors = (anchors: unknown): string | null => { if (anchors == null) return null; let parsed = anchors; if (typeof anchors === 'string') { @@ -550,7 +603,7 @@ function createAttuneApi(db) { return parsed.length ? JSON.stringify(parsed) : null; }; - const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }) => { + const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }: RememberInput) => { if (!memoryPath || !summary) throw new Error('remember() requires path and summary'); assertEnglishMemoryText(summary, 'remember() summary'); const normalizedPath = resolveMemoryPath(memoryPath, session_id); @@ -563,7 +616,7 @@ function createAttuneApi(db) { return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at }; }; - const forget = ({ id, reason }) => { + const forget = ({ id, reason }: ForgetInput) => { const deletionReason = String(reason || '').trim(); if (!id || !deletionReason) throw new Error('forget() requires id and reason'); const row = db.prepare('SELECT id, deleted_at, deleted_reason FROM memories WHERE id=?').get(id); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts new file mode 100644 index 0000000..b9c7af5 --- /dev/null +++ b/packages/core/src/runtime.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Skill transport: a typed thin CLI shell over the Obelisk Core package. +// It only parses args, reads script files, prints JSON, and owns the uniform +// { error, stack } + exit-1 error envelope. All logic lives in Core. + +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const fs = require('node:fs'); +const path = require('node:path'); + +import { DB_PATH, buildIndex, searchText, executeQuery, executeAttune } from './core.ts'; + +async function main() { + const args = process.argv.slice(2); + // Uniform error envelope across all four verbs: a failure is reported as + // { error, stack } on stdout with exit code 1, never a raw crash on stderr. + const fail = (e: unknown): void => { + const error = e instanceof Error ? e : new Error(String(e)); + process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n'); + process.exitCode = 1; + }; + const emit = (r: unknown): void => { + process.stdout.write(JSON.stringify(r, null, 2) + '\n'); + }; + + if (args[0] === '--build') { + try { + buildIndex({ force: true }); + process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n'); + } catch (e) { fail(e); } + return; + } + if (args[0] === '--search' && args[1]) { + try { emit(searchText(args.slice(1).join(' '))); } catch (e) { fail(e); } + return; + } + if (args[0] === '--query' && args[1]) { + try { emit(await executeQuery(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); } + return; + } + if (args[0] === '--attune' && args[1]) { + try { emit(await executeAttune(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); } + return; + } + process.stderr.write('Usage:\n node runtime.js --build\n node runtime.js --search "text"\n node runtime.js --query \n node runtime.js --attune \n'); + process.exitCode = 1; +} + +main(); diff --git a/scripts/schema.sql b/packages/core/src/schema.sql similarity index 99% rename from scripts/schema.sql rename to packages/core/src/schema.sql index 199280c..c7ef67f 100644 --- a/scripts/schema.sql +++ b/packages/core/src/schema.sql @@ -1,3 +1,4 @@ +-- Shared Obelisk Core schema. CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT, started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT, diff --git a/packages/core/src/sqlite-types.ts b/packages/core/src/sqlite-types.ts new file mode 100644 index 0000000..4e0f4ef --- /dev/null +++ b/packages/core/src/sqlite-types.ts @@ -0,0 +1,21 @@ +// Minimal structural types shared by node:sqlite and better-sqlite3 consumers. +// SQLite rows and bindings are dynamic at this boundary; domain records become +// strongly typed after parsing, in providers/types.ts. + +export type SqliteRow = Record; + +export interface SqliteStatement { + all(...bindings: any[]): SqliteRow[]; + get(...bindings: any[]): SqliteRow | undefined; + run(...bindings: any[]): unknown; +} + +export interface SqliteDb { + exec(sql: string): unknown; + prepare(sql: string): SqliteStatement; + close(): void; +} + +export interface NodeSqliteDb extends SqliteDb { + readonly isTransaction: boolean; +} diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts new file mode 100644 index 0000000..03f5e89 --- /dev/null +++ b/packages/core/src/tx.ts @@ -0,0 +1,138 @@ +// Binding-agnostic SQLite write plumbing shared from the Core package +// (docs/adr/0006). The injected db must expose `exec(sql)`; this works for both +// node:sqlite (skill/CLI) and better-sqlite3 (app), same injection model as +// `persist`. + +export interface WriteTxDb { + exec(sql: string): unknown; + inTransaction(): boolean; +} + +export interface SqliteConnection { + exec(sql: string): unknown; +} + +type Phase = 'begin' | 'work' | 'commit' | 'rollback'; + +export interface WriteTxDiagnostics { + phase: Phase; + code: string | null; + label?: string; + rollbackSucceeded: boolean | null; + rollbackError: string | null; + transactionActive: boolean | null; + attempts: number; +} + +export interface WriteTxOptions { + // Diagnostic label for this transaction (e.g. a file path or 'finalize'). + label?: string; +} + +const BUSY_MESSAGE = /SQLITE_BUSY|database is locked|database is busy/i; + +function busyCode(error: unknown): string | null { + const raw = error as { code?: unknown; errcode?: unknown; message?: unknown } | null; + const code = (raw?.code ?? raw?.errcode); + if (typeof code === 'string' && code.startsWith('SQLITE_BUSY')) return code; + if (typeof raw?.message === 'string' && BUSY_MESSAGE.test(raw.message)) return 'SQLITE_BUSY'; + return null; +} + +function errorCode(error: unknown): string | null { + const raw = error as { code?: unknown } | null; + return typeof raw?.code === 'string' ? raw.code : null; +} + +interface BetterSqliteHandle { + exec(sql: string): unknown; + readonly inTransaction: boolean; +} + +interface NodeSqliteHandle { + exec(sql: string): unknown; + readonly isTransaction: boolean; +} + +export function betterSqliteTransactionAdapter(db: BetterSqliteHandle): WriteTxDb { + return { + exec: sql => db.exec(sql), + inTransaction: () => db.inTransaction, + }; +} + +export function nodeSqliteTransactionAdapter(db: NodeSqliteHandle): WriteTxDb { + return { + exec: sql => db.exec(sql), + inTransaction: () => db.isTransaction, + }; +} + +function transactionState(db: WriteTxDb): boolean | null { + try { + return db.inTransaction(); + } catch { + return null; + } +} + +function attachDiagnostics(error: unknown, diagnostics: WriteTxDiagnostics): void { + if (!error || typeof error !== 'object') return; + try { + (error as { obelisk?: WriteTxDiagnostics }).obelisk = diagnostics; + } catch { + // Frozen/native errors must still be rethrown unchanged. + } +} + +// Runs `work` exactly once inside a transaction and returns its value. Retry and +// scheduling policy belongs to the build coordinator, which knows the operation's +// idempotency and total time budget. Cleanup never masks the primary exception. +export function runWriteTransaction(db: WriteTxDb, work: () => T, options: WriteTxOptions = {}): T { + const { label } = options; + let phase: Phase = 'begin'; + try { + db.exec('BEGIN IMMEDIATE'); + phase = 'work'; + const value = work(); + phase = 'commit'; + db.exec('COMMIT'); + return value; + } catch (error) { + let rollbackSucceeded: boolean | null = null; + let rollbackError: string | null = null; + const activeBeforeRollback = transactionState(db); + if (activeBeforeRollback !== false) { + try { + db.exec('ROLLBACK'); + rollbackSucceeded = true; + } catch (rollbackFailure) { + rollbackSucceeded = false; + rollbackError = rollbackFailure instanceof Error ? rollbackFailure.message : String(rollbackFailure); + } + } + const busy = busyCode(error); + const diagnostics: WriteTxDiagnostics = { + phase, + code: busy ?? errorCode(error), + label, + rollbackSucceeded, + rollbackError, + transactionActive: transactionState(db), + attempts: 1, + }; + attachDiagnostics(error, diagnostics); + throw error; + } +} + +// Applies the connection-level pragmas used by every Obelisk writer/reader. Uses +// exec (not better-sqlite3's .pragma) so one implementation covers both bindings. +// busy_timeout is a real behavior change for node:sqlite (no default); it is set +// explicitly for better-sqlite3 too, whose own default already happens to be +// 5000ms. It is NOT the concurrency fix — see docs/adr/0006. +export function configureConnection(db: SqliteConnection, { busyTimeoutMs = 5000 } = {}): void { + db.exec(`PRAGMA busy_timeout=${busyTimeoutMs}`); + db.exec('PRAGMA journal_mode=WAL'); + db.exec('PRAGMA synchronous=NORMAL'); +} diff --git a/packages/core/src/write-coordinator.ts b/packages/core/src/write-coordinator.ts new file mode 100644 index 0000000..d13b2f2 --- /dev/null +++ b/packages/core/src/write-coordinator.ts @@ -0,0 +1,95 @@ +// Core's bounded retry policy above the transaction primitive. Callers opt in only for +// idempotent work; BEGIN contention and an uncertain/live transaction are never +// retried here. + +import { runWriteTransaction, type WriteTxDb, type WriteTxOptions } from './tx.ts'; + +interface TransactionDiagnostics { + phase?: string; + code?: string | null; + transactionActive?: boolean | null; + attempts?: number; +} + +export interface WriteRetryOptions { + maxAttempts?: number; + budgetMs?: number; + retryDelayMs?: number; + now?: () => number; + sleep?: (ms: number) => void; +} + +function diagnostics(error: unknown): TransactionDiagnostics | null { + if (!error || typeof error !== 'object') return null; + return (error as { obelisk?: TransactionDiagnostics }).obelisk ?? null; +} + +function isBusyCode(code: unknown): boolean { + return typeof code === 'string' && code.startsWith('SQLITE_BUSY'); +} + +function syncSleep(ms: number): void { + if (ms <= 0) return; + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + // Bounded attempts still prevent an infinite retry loop. + } +} + +export function isBeginBusyFailure(error: unknown): boolean { + const info = diagnostics(error); + return ( + info?.phase === 'begin' && + isBusyCode(info.code) && + info.transactionActive === false + ); +} + +export function hasUnusableTransaction(error: unknown): boolean { + const info = diagnostics(error); + return Boolean(info && info.transactionActive !== false); +} + +export function isRetryableWriteFailure(error: unknown): boolean { + const info = diagnostics(error); + return ( + (info?.phase === 'work' || info?.phase === 'commit') && + isBusyCode(info.code) && + info.transactionActive === false + ); +} + +export function runWithWriteRetry(operation: () => T, { + maxAttempts = 3, + budgetMs = 1000, + retryDelayMs = 25, + now = Date.now, + sleep = syncSleep, +}: WriteRetryOptions = {}): T { + const startedAt = now(); + for (let attempt = 1; ; attempt += 1) { + try { + return operation(); + } catch (error) { + const info = diagnostics(error); + if (info) info.attempts = attempt; + if (!isRetryableWriteFailure(error) || attempt >= maxAttempts) throw error; + const remaining = budgetMs - (now() - startedAt); + if (remaining <= 0) throw error; + sleep(Math.min(retryDelayMs * attempt, remaining)); + } + } +} + +export function runRetryableWriteTransaction( + db: WriteTxDb, + work: () => T, + transactionOptions: WriteTxOptions = {}, + retryOptions: WriteRetryOptions = {}, +): T { + return runWithWriteRetry( + () => runWriteTransaction(db, work, transactionOptions), + retryOptions, + ); +} diff --git a/packages/core/src/writer-lease.ts b/packages/core/src/writer-lease.ts new file mode 100644 index 0000000..18957d4 --- /dev/null +++ b/packages/core/src/writer-lease.ts @@ -0,0 +1,91 @@ +// Cross-process single-writer lease shared by every Obelisk mutation. The +// lock lives in a dedicated SQLite database so node:sqlite and better-sqlite3 +// share identical locking semantics on every supported platform. + +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export interface WriterLeaseDb { + exec(sql: string): unknown; + close(): void; +} + +export interface WriterLease { + release(): void; +} + +export interface AcquireWriterLeaseOptions { + lockPath: string; + openDb: (path: string) => WriterLeaseDb; + waitMs?: number; + retryDelayMs?: number; + now?: () => number; + sleep?: (ms: number) => void; +} + +const BUSY_MESSAGE = /SQLITE_BUSY|database is locked|database is busy/i; + +function isBusy(error: unknown): boolean { + const raw = error as { code?: unknown; errcode?: unknown; message?: unknown } | null; + const code = raw?.code ?? raw?.errcode; + return ( + (typeof code === 'string' && code.startsWith('SQLITE_BUSY')) || + (typeof raw?.message === 'string' && BUSY_MESSAGE.test(raw.message)) + ); +} + +function syncSleep(ms: number): void { + if (ms <= 0) return; + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + // If synchronous sleeping is unavailable, the bounded attempt count below + // still prevents an infinite acquisition loop. + } +} + +export function writerLockPathFor(dbPath: string): string { + return join(dirname(dbPath), 'writer.lock.sqlite'); +} + +export function acquireWriterLease({ + lockPath, + openDb, + waitMs = 0, + retryDelayMs = 25, + now = Date.now, + sleep = syncSleep, +}: AcquireWriterLeaseOptions): WriterLease | null { + mkdirSync(dirname(lockPath), { recursive: true }); + const startedAt = now(); + const maxAttempts = waitMs > 0 ? Math.ceil(waitMs / Math.max(1, retryDelayMs)) + 1 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const db = openDb(lockPath); + try { + db.exec('PRAGMA busy_timeout=0'); + db.exec('BEGIN IMMEDIATE'); + let released = false; + return { + release() { + if (released) return; + released = true; + try { + db.exec('ROLLBACK'); + } catch { + // Closing the connection releases any remaining SQLite lock. + } finally { + db.close(); + } + }, + }; + } catch (error) { + db.close(); + if (!isBusy(error)) throw error; + const remaining = waitMs - (now() - startedAt); + if (remaining <= 0 || attempt + 1 >= maxAttempts) return null; + sleep(Math.min(retryDelayMs, remaining)); + } + } + return null; +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..ca53dab --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..49508cd --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/packaging/publish-skill.sh b/packaging/publish-skill.sh new file mode 100755 index 0000000..91ecc65 --- /dev/null +++ b/packaging/publish-skill.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +SKILL_DIR="dist/obelisk-skill" +REMOTE="git@github.com:tommy0103/obelisk-skill.git" + +if [ ! -d "$SKILL_DIR/scripts" ]; then + echo "Error: run 'npm run build:skill' first" >&2 + exit 1 +fi + +cp packaging/skill-README.md "$SKILL_DIR/README.md" +cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE" + +cd "$SKILL_DIR" +rm -rf .git +git init +git remote add origin "$REMOTE" +git add -A +git commit -m "publish: $(date -u +%Y-%m-%dT%H:%M:%SZ)" +git push --force origin HEAD:main diff --git a/packaging/skill-LICENSE b/packaging/skill-LICENSE new file mode 100644 index 0000000..9bb811e --- /dev/null +++ b/packaging/skill-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025–2026 tommy0103 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/skill-README.md b/packaging/skill-README.md new file mode 100644 index 0000000..f614d0a --- /dev/null +++ b/packaging/skill-README.md @@ -0,0 +1,29 @@ +# Obelisk Skill + +Explicit memory infrastructure for coding agents — a queryable SQLite evidence +layer over local Claude Code and Codex session history. + +## Install + +```bash +npx skills add tommy0103/obelisk-skill +``` + +Then in any Claude Code session: + +``` +/obelisk +``` + +## Source + +This repository is **auto-published** from the compiled skill artifact of +[tommy0103/obelisk](https://github.com/tommy0103/obelisk). Do not open pull +requests here — contribute to the source repo instead. + +## License + +MIT — see [LICENSE](LICENSE) in this repository. The +[source repository](https://github.com/tommy0103/obelisk) is AGPL-3.0; this +compiled skill artifact is explicitly relicensed under MIT by the copyright +holder. diff --git a/packaging/skill-package.json b/packaging/skill-package.json new file mode 100644 index 0000000..885c1b7 --- /dev/null +++ b/packaging/skill-package.json @@ -0,0 +1,7 @@ +{ + "name": "obelisk-skill", + "version": "0.1.0", + "type": "module", + "description": "Obelisk skill artifact — readable compiled Core (providers + persist + runtime) over local Claude Code and Codex history. Built by `npm run build:skill`; sources live in the main repo.", + "license": "MIT" +} diff --git a/references/api-reference.md b/references/api-reference.md index de52c96..dc3def0 100644 --- a/references/api-reference.md +++ b/references/api-reference.md @@ -1,7 +1,7 @@ # Obelisk -- Helper API Reference -Detailed reference for globals available inside `runtime.mjs --query` and -`runtime.mjs --attune` scripts. +Detailed reference for globals available inside `runtime.js --query` and +`runtime.js --attune` scripts. - Use `references/schema.md` for raw SQL table/field/join checks. - Use `references/query-patterns.md` for copyable retrieval plans. @@ -16,7 +16,7 @@ memory mutation helpers. ### Read Helpers -These globals are available only in `runtime.mjs --query` scripts: +These globals are available only in `runtime.js --query` scripts: ```js sql, search, context, trace, thread, raw, @@ -31,7 +31,7 @@ helpers is treated as `sessionId`; passing a number is treated as `limit`. ### Mutation Helpers -These globals are available only in `runtime.mjs --attune` scripts: +These globals are available only in `runtime.js --attune` scripts: ```js remember, forget @@ -76,6 +76,11 @@ Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower FTS rank sorts earlier; prefer returned order unless deliberately inspecting FTS ranking. +Valid FTS5 syntax in `text` is honored. Input that FTS5 would reject as +malformed (for example a hyphenated term like `foo-bar`) does not error: it +falls back to safe per-token quoting — the same tokenization `memories()` uses — +so ordinary text never crashes the query. + #### `context(uuid)` Full indexed context around one message. @@ -368,7 +373,11 @@ well as `Edit`/`Write`. Returns: ```js -Array<{ toolCall, session, timestamp }> +Array<{ + toolCall: { id, message_uuid, name, input_json }, + session: { id, title, project }, + timestamp +}> ``` Use raw SQL with `ORDER BY m.timestamp DESC` when you need newest-first file @@ -404,7 +413,7 @@ not a counting primitive. #### `remember(record)` Register a human-approved markdown memory file. Available only in -`runtime.mjs --attune` scripts. +`runtime.js --attune` scripts. | Param | Type | Description | | --- | --- | --- | @@ -430,7 +439,7 @@ Returns: #### `forget(record)` Archive a human-approved memory record. Available only in -`runtime.mjs --attune` scripts. +`runtime.js --attune` scripts. | Param | Type | Description | | --- | --- | --- | diff --git a/references/query-patterns.md b/references/query-patterns.md index a4b462b..cece720 100644 --- a/references/query-patterns.md +++ b/references/query-patterns.md @@ -1,6 +1,6 @@ # Obelisk Query Patterns -These are copyable CodeAct patterns for `runtime.mjs --query` scripts plus +These are copyable CodeAct patterns for `runtime.js --query` scripts plus `--attune` memory mutation patterns. They are not new APIs. Adapt them to the user's scope and return compact evidence. @@ -179,7 +179,7 @@ Use this only after the user approves writing memory and the markdown file already exists. `remember()` validates the file and stores a normalized absolute path, so keep the script small and return the registered record. -Run this script with `runtime.mjs --attune