Merge branch 'refactor/migrate' into main

Phases 0–7 of the Obelisk engineering refactor: provider-adapter architecture
(ADR-0001), shared persist + tx module, TypeScript workspace (packages/core),
electron-vite app migration, writer-lease concurrency, and CI skill publish.
This commit is contained in:
tommy0103
2026-07-12 01:12:32 +08:00
133 changed files with 33179 additions and 1108 deletions
+54
View File
@@ -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
+5 -2
View File
@@ -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/
+94
View File
@@ -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
+89 -25
View File
@@ -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 <your question>
```
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 <script>
Runs it via node $SKILL_DIR/scripts/runtime.js --query <script>
Reads the JSON result, answers in natural language
```
@@ -75,7 +81,7 @@ Core API: `search()`, `context()`, `sql()`, plus structured helpers (`sessions`,
### Memory layer
When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `runtime.mjs --remember`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence.
When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `runtime.js --attune <script>`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence.
## App: A surface for human
@@ -110,30 +116,88 @@ Full-text search via FTS5 covers all layers.
## Structure
```
scripts/ # Skill runtime (zero npm deps, Node 22 built-in sqlite)
├── schema.sql # Executable SQLite schema
├── runtime.mjs # Indexer + query runtime
├── db.mjs # Schema init, migrations
├── indexer.mjs # JSONL discovery + incremental indexing
└── query.mjs # Query API (search, sessions, memories, etc)
packages/core/ # @obelisk/core npm workspace (TypeScript + ESM)
├── src/
│ ├── providers/
│ │ ├── types.ts # Provider + IndexRecord contract
│ │ ├── claude.ts # Claude Code adapter (line-incremental)
│ │ └── codex.ts # Codex adapter (full-reparse)
│ ├── persist.ts # Binding-agnostic record writer (upsert/merge)
│ ├── tx.ts # Write transaction + connection config
│ ├── write-coordinator.ts # Bounded retry policy
│ ├── writer-lease.ts # Cross-process single-writer lease (SQLite lock DB)
│ ├── core.ts # buildIndex / searchText / executeQuery / executeAttune
│ ├── indexer.ts # Skill orchestration (discover → persist → finalize)
│ ├── parsing.ts # Pure helpers (node:sqlite-free, app-consumable)
│ ├── db.ts # node:sqlite lifecycle + migrations
│ ├── query.ts # Query/attune sandbox API (helpers)
│ ├── runtime.ts # Thin CLI shell (--build/--search/--query/--attune)
│ └── schema.sql # SQLite schema (single source of truth)
├── package.json
└── dist/ # Generated package JS, declarations, and schema
references/ # Agent-readable docs (progressive disclosure)
references/ # Agent-readable docs (progressive disclosure)
├── schema.md
├── api-reference.md
├── query-patterns.md
├── retrieval-semantics.md
├── pitfalls.md
├── recap-patterns.md
├── recap/ # Per-card pattern + writing references
└── pitfalls.md
├── recap-writing.md
└── recap/ # Per-card pattern + writing references
├── overview.md
├── pattern1-cover.md … pattern5-closing.md
└── writing1-cover.md … writing5-closing.md
SKILL.md # Skill definition + API + retrieval strategy
app/ # Electron desktop app (electron-vite + Vue)
├── src/main/ # TypeScript main process (consumes shared core)
├── src/preload/ # CJS preload (sandbox)
├── src/renderer/ # Vue renderer
└── electron.vite.config.ts
packaging/ # Skill publish infrastructure
├── skill-package.json
├── skill-README.md
├── skill-LICENSE # MIT (relicensed for the skill artifact)
└── publish-skill.sh
SKILL.md # Skill definition (installed with the artifact)
CONTEXT.md # Project glossary
docs/adr/ # Architecture decision records (00010006)
```
The optional `/obelisk recap` flow is loaded only for explicit `/obelisk recap` intent.
It starts at `references/recap/overview.md` and proceeds card-by-card:
- `references/recap/pattern1-cover.md` + `references/recap/writing1-cover.md`
- `references/recap/pattern2-thinking.md` + `references/recap/writing2-thinking.md`
- `references/recap/pattern3-vibe.md` + `references/recap/writing3-vibe.md`
- `references/recap/pattern4-workflow.md` + `references/recap/writing4-workflow.md`
- `references/recap/pattern5-closing.md` + `references/recap/writing5-closing.md`
### Generated build outputs
- `packages/core/dist/` is produced by `npm run build:core`. It is the compiled
`@obelisk/core` package: JavaScript, type declarations, and `schema.sql`.
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
install-ready skill artifact: readable plain JavaScript under `scripts/`,
`SKILL.md`, references, and the skill package metadata.
Both directories are generated and should not be edited by hand. The Electron
app imports `packages/core/src/` directly so electron-vite can bundle Core.
## Implementation Notes
- Index rebuilds incrementally — only new/modified JSONL files are re-parsed
- Skill side uses Node 22 built-in `node:sqlite`; zero npm dependencies
- Older `~/.claude/obelisk.sqlite` databases are copied forward to `~/.obelisk/obelisk.sqlite` on first open
- `~/.obelisk/recap/` watched for new recap JSON files (agent writes, app renders)
The index rebuilds incrementally — only new or modified JSONL files are re-parsed.
When the optional app is running, it is the active indexer: it watches Claude
project files and builds in a worker thread. A fresh `__app_heartbeat__` alone
means the daemon owns writes, so the skill remains read-only; a separate SQLite
writer lease prevents cross-process writes from overlapping. The
`__app_last_successful_build__` marker records index freshness, not ownership.
Zero npm dependencies. Uses Node 22's built-in node:sqlite with FTS5. The entire runtime is ~400 lines.
20K lines of scattered JSONL → something the agent can search() and sql() against in milliseconds.
---
+3 -3
View File
@@ -37,7 +37,7 @@ The skill directory is provided as `$SKILL_DIR` at invocation time.
Fast keyword search:
```bash
node $SKILL_DIR/scripts/runtime.mjs --search "keyword"
node $SKILL_DIR/scripts/runtime.js --search "keyword"
```
Custom query:
@@ -46,7 +46,7 @@ Custom query:
2. Run:
```bash
node $SKILL_DIR/scripts/runtime.mjs --query /tmp/q.mjs
node $SKILL_DIR/scripts/runtime.js --query /tmp/q.mjs
```
3. Parse JSON stdout and answer with concise evidence.
@@ -315,7 +315,7 @@ return remember({
Run the registration script with:
```bash
node $SKILL_DIR/scripts/runtime.mjs --attune /tmp/register-memory.mjs
node $SKILL_DIR/scripts/runtime.js --attune /tmp/register-memory.mjs
```
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`.
+38
View File
@@ -0,0 +1,38 @@
import { resolve } from 'node:path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import vue from '@vitejs/plugin-vue';
// The app main/preload/renderer are TypeScript + ESM. Each main-process module
// is its own rollup input so it is emitted to out/main/<name>.js and the
// relative imports between them (and `new Worker(__dirname/indexer-worker.js)`)
// resolve to the built .js at runtime.
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve('src/main/index.ts'),
indexer: resolve('src/main/indexer.ts'),
'indexer-service': resolve('src/main/indexer-service.ts'),
'indexer-worker': resolve('src/main/indexer-worker.ts'),
'indexer-worker-client': resolve('src/main/indexer-worker-client.ts'),
'recap-capture-query': resolve('src/main/recap-capture-query.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
// Electron sandbox does not support ESM preload — emit CJS index.js
// (main loads ../preload/index.js) even though the project is ESM.
output: { format: 'cjs', entryFileNames: '[name].js' },
},
},
},
renderer: {
plugins: [vue()],
},
});
+7368
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
{
"name": "obelisk",
"version": "0.1.0",
"description": "Memory management for Obelisk — let Claude Code search its own memory",
"main": "out/main/index.js",
"scripts": {
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "electron-vite build && electron-builder",
"pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder --mac --win --linux",
"dist:mac": "electron-vite build && electron-builder --mac",
"dist:win": "electron-vite build && electron-builder --win",
"dist:linux": "electron-vite build && electron-builder --linux",
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs"
},
"build": {
"appId": "com.obelisk.app",
"productName": "Obelisk",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release"
},
"mac": {
"icon": "build/icon.icns",
"target": [
"dmg",
"zip"
],
"category": "public.app-category.developer-tools"
},
"win": {
"target": [
"nsis",
"portable"
]
},
"linux": {
"icon": "build/icon.png",
"target": [
"AppImage",
"deb"
],
"category": "Development"
},
"files": [
"out/**"
],
"asarUnpack": [
"node_modules/better-sqlite3/**/*"
],
"extraResources": [
{
"from": "../packages/core/src/schema.sql",
"to": "scripts/schema.sql"
}
]
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"chokidar": "^4.0.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@vitejs/plugin-vue": "^5.0.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"electron-vite": "^5.0.0",
"vite": "^6.0.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"type": "module"
}
+941
View File
@@ -0,0 +1,941 @@
import { app, BrowserWindow, ipcMain, clipboard, dialog, shell } from 'electron';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import chokidar from 'chokidar';
import { writeHeartbeat } from './indexer.ts';
import { createIndexerService } from './indexer-service.ts';
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
import { buildRecapExportQuery } from './recap-capture-query.ts';
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function detectClaudeDir() {
// macOS / Linux: ~/.claude
if (process.platform !== 'win32') {
return path.join(os.homedir(), '.claude');
}
// Windows: Claude Code runs in WSL, data lives at \\wsl.localhost\<distro>\home\<user>\.claude
const distros = ['Ubuntu', 'Ubuntu-24.04', 'Ubuntu-22.04', 'Debian', 'openSUSE-Leap', 'kali-linux'];
for (const distro of distros) {
const homePath = path.join('\\\\wsl.localhost', distro, 'home');
if (!fs.existsSync(homePath)) continue;
try {
const users = fs.readdirSync(homePath);
for (const user of users) {
const claudeDir = path.join(homePath, user, '.claude');
if (fs.existsSync(claudeDir)) return claudeDir;
}
} catch {}
}
// Fallback: native Windows path (for future native Claude Code on Windows)
return path.join(os.homedir(), '.claude');
}
const DEFAULT_CLAUDE_DIR = detectClaudeDir();
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
let db;
let indexerService;
let indexerWorker;
type WriterLeaseMode = 'acquire' | 'caller-held';
function acquireAppWriterLease(dbPath: string, waitMs = 0) {
return acquireWriterLease({
lockPath: writerLockPathFor(dbPath),
openDb: lockPath => new Database(lockPath),
waitMs,
});
}
function getConfiguredClaudeDir() {
const persisted = loadPersistedSettings();
return persisted.claudeDir || DEFAULT_CLAUDE_DIR;
}
function getConfiguredCodexDir() {
const persisted = loadPersistedSettings();
return persisted.codexDir || DEFAULT_CODEX_DIR;
}
function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir(), codexDir = getConfiguredCodexDir()) {
return {
claudeDir,
codexDir,
dbPath: path.join(OBELISK_DIR, 'obelisk.sqlite'),
projectsDir: path.join(claudeDir, 'projects'),
};
}
function migrateLegacyDbIfNeeded(
paths = getPathsForClaudeDir(),
{ writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {},
) {
if (fs.existsSync(paths.dbPath)) return;
const legacyDbPath = path.join(paths.claudeDir, 'obelisk.sqlite');
if (!fs.existsSync(legacyDbPath)) return;
const lease = writerLeaseMode === 'acquire' ? acquireAppWriterLease(paths.dbPath) : null;
if (writerLeaseMode === 'acquire' && !lease) return false;
try {
if (fs.existsSync(paths.dbPath)) return true;
fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true });
fs.copyFileSync(legacyDbPath, paths.dbPath);
return true;
} catch (error) {
console.warn?.(`Obelisk legacy DB migration skipped: ${(error as Error).message}`);
return false;
} finally {
lease?.release();
}
}
function rebuildTempDbPath(dbPath) {
return path.join(
path.dirname(dbPath),
`${path.basename(dbPath)}.rebuild-${process.pid}-${Date.now()}.tmp`,
);
}
function dbFileSet(dbPath) {
return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
}
function cleanupDbFiles(dbPath) {
for (const filePath of dbFileSet(dbPath)) {
try {
fs.rmSync(filePath, { force: true });
} catch {}
}
}
function replaceDbWithTemp(tempDbPath, dbPath) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
for (const sidecar of [`${dbPath}-wal`, `${dbPath}-shm`]) {
try {
fs.rmSync(sidecar, { force: true });
} catch {}
}
fs.renameSync(tempDbPath, dbPath);
for (const suffix of ['-wal', '-shm']) {
const tempSidecar = `${tempDbPath}${suffix}`;
if (!fs.existsSync(tempSidecar)) continue;
fs.renameSync(tempSidecar, `${dbPath}${suffix}`);
}
}
function resolveSchemaPath() {
const candidates = [
path.join(__dirname, 'schema.sql'),
path.join(__dirname, '..', '..', '..', 'packages', 'core', 'src', 'schema.sql'),
path.join(__dirname, '..', 'scripts', 'schema.sql'),
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
].filter((c): c is string => Boolean(c));
return candidates.find(p => fs.existsSync(p));
}
function ensureColumn(db, table, column, definition) {
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, table) {
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
}
function migrateExistingColumns(db) {
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) {
if (typeof db.exec !== 'function' || typeof db.prepare !== 'function') return;
migrateExistingColumns(db);
const schemaPath = resolveSchemaPath();
if (schemaPath) db.exec(fs.readFileSync(schemaPath, 'utf8'));
migrateExistingColumns(db);
}
function closeDb() {
if (db) db.close();
db = null;
}
function openDb(
dbPath = getPathsForClaudeDir().dbPath,
{ writerLeaseMode = 'acquire' }: { writerLeaseMode?: WriterLeaseMode } = {},
) {
closeDb();
if (!fs.existsSync(dbPath)) return null;
db = new Database(dbPath, { readonly: false });
db.pragma('busy_timeout = 5000');
const lease = writerLeaseMode === 'acquire' ? acquireAppWriterLease(dbPath) : null;
if (writerLeaseMode === 'caller-held' || lease) {
try {
db.pragma('journal_mode = WAL');
migrateDb(db);
} finally {
lease?.release();
}
}
return db;
}
function runAppDbWrite(work: () => void): boolean {
if (!db) return false;
const lease = acquireAppWriterLease(getPathsForClaudeDir().dbPath, 250);
if (!lease) {
throw new Error('Obelisk index writer is busy; memory change was not applied');
}
try {
work();
return true;
} finally {
lease.release();
}
}
function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) {
const affectedSessionIds = Array.isArray(result.affectedSessionIds)
? [...new Set(result.affectedSessionIds.filter(Boolean))]
: [];
const payload = { affectedSessionIds };
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:index-updated', payload);
for (const sessionId of affectedSessionIds) {
win.webContents.send('obelisk:session-updated', { sessionId });
}
}
}
function sourceWhereClause(opts: { includeCodex?: boolean; source?: string } = {}, column = "source"): { sql: string; params: unknown[] } {
if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] };
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
}
function appendWhere(sql, params, clause) {
if (!clause) return sql;
return `${sql}${sql.includes(' WHERE ') ? ' AND ' : ' WHERE '}${clause}`;
}
function startIndexerService({ buildOnStart = false } = {}) {
const paths = getPathsForClaudeDir();
migrateLegacyDbIfNeeded(paths);
const codexSessionsDir = path.join(paths.codexDir, 'sessions');
indexerService = createIndexerService({
projectsDir: paths.projectsDir,
watchDirs: [paths.projectsDir, codexSessionsDir],
buildIndex: async ({ reason, changedPaths }) => {
const result = await indexerWorker.buildIndex({
reason,
changedPaths,
claudeDir: paths.claudeDir,
codexDir: paths.codexDir,
projectsDir: paths.projectsDir,
dbPath: paths.dbPath,
});
if (result?.deferred) {
if (Array.isArray(result.affectedSessionIds) && result.affectedSessionIds.length) {
notifyIndexUpdated(result);
}
} else {
openDb(paths.dbPath);
notifyIndexUpdated(result);
}
return result;
},
writeHeartbeat: () => writeHeartbeat({ dbPath: paths.dbPath }),
});
indexerService.start({ buildOnStart });
return indexerService;
}
function startBackgroundResources({ runStartupBuild = false } = {}) {
if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
const paths = getPathsForClaudeDir();
migrateLegacyDbIfNeeded(paths);
openDb(paths.dbPath);
if (!indexerService) {
const service = startIndexerService({ buildOnStart: false });
if (runStartupBuild) service.runBuildNow('startup');
}
if (!obeliskWatcher) startObeliskWatcher();
}
async function stopIndexerServiceAndWait({ waitForIdle = true } = {}) {
const service = indexerService;
if (!service) return;
service.stop();
if (waitForIdle && typeof service.idle === 'function') await service.idle();
if (indexerService === service) indexerService = null;
}
async function stopBackgroundResources({ stopWorker = false } = {}) {
await stopIndexerServiceAndWait();
if (stopWorker && indexerWorker) {
indexerWorker.stop();
indexerWorker = null;
}
if (obeliskWatcher) {
const watcher = obeliskWatcher;
obeliskWatcher = null;
if (typeof watcher.close === 'function') await Promise.resolve(watcher.close());
}
closeDb();
}
function createWindow() {
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
const shouldOpenDevTools = process.argv.includes('--devtools');
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 500,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 14, y: 10 },
backgroundColor: '#0a0b14',
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
devTools: isDev || shouldOpenDevTools,
},
});
// Prevent Electron's built-in zoom so Cmd+=/- reaches the renderer
win.webContents.on('before-input-event', (event, input) => {
if ((input.meta || input.control) && ['+', '=', '-', '0'].includes(input.key)) {
win.webContents.setZoomLevel(0);
}
});
if (isDev) {
win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173');
if (shouldOpenDevTools) {
win.webContents.openDevTools();
}
} else {
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
}
}
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
let obeliskWatcher: import("chokidar").FSWatcher | null = null;
function startObeliskWatcher() {
if (obeliskWatcher) return obeliskWatcher;
if (!fs.existsSync(OBELISK_DIR)) {
fs.mkdirSync(OBELISK_DIR, { recursive: true });
}
obeliskWatcher = chokidar.watch(OBELISK_DIR, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
ignored: (p, stats) => {
if (stats?.isDirectory()) return false;
if (!stats) return false;
return !p.endsWith('.md') && !p.endsWith('.json');
},
});
obeliskWatcher.on('add', onObeliskChange);
obeliskWatcher.on('change', onObeliskChange);
obeliskWatcher.on('unlink', onObeliskChange);
return obeliskWatcher;
}
function onObeliskChange(filePath) {
if (filePath.startsWith(RECAP_DIR)) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:recap-updated', filePath);
}
}
}
app.whenReady().then(() => {
startBackgroundResources({ runStartupBuild: true });
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
startBackgroundResources({ runStartupBuild: true });
createWindow();
}
});
});
app.on('before-quit', () => {
void stopBackgroundResources({ stopWorker: true });
});
app.on('window-all-closed', () => {
void stopBackgroundResources({ stopWorker: true });
if (process.platform !== 'darwin') app.quit();
});
// --- IPC Handlers ---
ipcMain.handle('db:getSessions', (_, opts = {}) => {
if (!db) return [];
const { project, limit = 200 } = opts;
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path, source FROM sessions`;
const params: unknown[] = [];
const sourceFilter = sourceWhereClause(opts);
if (sourceFilter.sql) {
sql = appendWhere(sql, params, sourceFilter.sql);
params.push(...sourceFilter.params);
}
if (project) { sql = appendWhere(sql, params, `project LIKE ?`); params.push(project); }
sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`;
params.push(limit);
return db.prepare(sql).all(...params);
});
ipcMain.handle('db:getSessionMessages', (_, sessionId) => {
if (!db) return [];
return db.prepare(`
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
m.content_type, m.is_meta, m.source
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp, m.uuid
`).all(sessionId);
});
ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionToolResults', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionSubagents', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => {
if (!db) return [];
const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId);
for (const wf of workflows) {
wf.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(wf.run_id);
}
return workflows;
});
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
if (!db) return [];
return db.prepare(`
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
m.content_type, m.is_meta, m.source
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp, m.uuid
`).all(agentId);
});
ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
if (!db) return [];
return db.prepare(`
SELECT tc.* FROM tool_calls tc
JOIN messages m ON m.uuid = tc.message_uuid
WHERE m.agent_id = ?
`).all(agentId);
});
ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
if (!db) return [];
return db.prepare(`
SELECT tr.* FROM tool_results tr
JOIN messages m ON m.uuid = tr.message_uuid
WHERE m.agent_id = ?
`).all(agentId);
});
ipcMain.handle('db:getSessionSummaries', (_, sessionId) => {
if (!db) return [];
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId);
});
ipcMain.handle('db:getMemories', () => {
if (!db) return [];
return db.prepare(`
SELECT id, session_id, project, message_start, message_end, path, anchors, summary, created_at, deleted_at, deleted_reason
FROM memories ORDER BY created_at DESC
`).all();
});
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
if (!db) return null;
const msg = db.prepare('SELECT session_id, agent_id, source FROM messages WHERE uuid=?').get(uuid);
if (!msg) return null;
if (msg.source === 'codex' || String(uuid).startsWith('codex:')) {
const match = /^codex:([^:]+):(\d+)$/.exec(String(uuid));
if (!match) return null;
const rawThreadId = match[1];
const targetLine = Number(match[2]);
let jsonlPath: string | null = null;
if (!msg.agent_id) {
jsonlPath = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id)?.jsonl_path || null;
}
if (!jsonlPath) {
jsonlPath = db.prepare(`
SELECT jsonl_path FROM index_state
WHERE jsonl_path LIKE ? AND jsonl_path LIKE '%.jsonl'
ORDER BY length(jsonl_path) ASC
LIMIT 1
`).get(`%${rawThreadId}.jsonl`)?.jsonl_path || null;
}
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter(Boolean);
const line = lines[targetLine - 1];
if (!line) return null;
try {
const obj = JSON.parse(line);
const payload = obj.payload || {};
if (obj.type === 'event_msg') {
if (typeof payload.message === 'string') return payload.message;
if (typeof payload.text === 'string') return payload.text;
}
if (obj.type === 'response_item' && payload.type === 'message' && Array.isArray(payload.content)) {
const parts = payload.content.map(b => b.text).filter(Boolean);
return parts.join('\n') || null;
}
} catch {}
return null;
}
// Resolve JSONL path
let jsonlPath: string | null = null;
if (msg.agent_id) {
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
if (wa) {
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(wa.session_id);
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), wa.session_id, 'subagents', 'workflows', wa.run_id, wa.agent_id + '.jsonl');
}
if (!jsonlPath) {
const sa = db.prepare('SELECT agent_id, session_id FROM subagents WHERE agent_id=?').get(msg.agent_id);
if (sa) {
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(sa.session_id);
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), sa.session_id, 'subagents', sa.agent_id + '.jsonl');
}
}
}
if (!jsonlPath) {
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id);
if (ses) jsonlPath = ses.jsonl_path;
}
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
// Scan JSONL for the message UUID and extract full text
const data = fs.readFileSync(jsonlPath, 'utf-8');
const lines = data.split('\n');
for (const line of lines) {
if (!line.includes(uuid)) continue;
try {
const obj = JSON.parse(line);
if (obj.uuid !== uuid) continue;
const content = obj.message?.content;
if (typeof content === 'string') return 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.join('\n') || null;
} catch { continue; }
}
return null;
});
ipcMain.handle('db:readMemoryFile', (_, filePath) => {
try {
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
return null;
} catch { return null; }
});
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
return runAppDbWrite(() => {
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
.run(new Date().toISOString(), reason || 'Archived via panel', id);
});
});
ipcMain.handle('db:restoreMemory', (_, id) => {
return runAppDbWrite(() => {
db.prepare(`UPDATE memories SET deleted_at = NULL, deleted_reason = NULL WHERE id = ?`).run(id);
});
});
ipcMain.handle('db:getProjects', (_, opts = {}) => {
if (!db) return [];
const sourceFilter = sourceWhereClause(opts);
const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : '';
return db.prepare(`
SELECT project, project_path, COUNT(*) as session_count,
MAX(COALESCE(ended_at, started_at)) as last_active
FROM sessions ${where ? `${where} AND` : 'WHERE'} project IS NOT NULL
GROUP BY project ORDER BY last_active DESC
`).all(...sourceFilter.params);
});
ipcMain.handle('db:getStats', (_, opts = {}) => {
if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 };
const sourceFilter = sourceWhereClause(opts);
const where = sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : '';
const sessions = db.prepare(`SELECT COUNT(*) as c FROM sessions ${where}`).get(...sourceFilter.params)?.c || 0;
const memories = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
const memoriesArchived = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NOT NULL').get()?.c || 0;
return { sessions, memories, memoriesArchived };
});
ipcMain.handle('db:getUsageStats', (_, opts = {}) => {
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
const sourceFilter = sourceWhereClause(opts, 'source');
const sourceSql = sourceFilter.sql ? `AND ${sourceFilter.sql}` : '';
const daily = db.prepare(`
SELECT DATE(timestamp) as day,
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
FROM messages
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
${sourceSql}
GROUP BY DATE(timestamp)
ORDER BY day
`).all(...sourceFilter.params);
const totalTokens = db.prepare(`
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
FROM messages
${sourceFilter.sql ? `WHERE ${sourceFilter.sql}` : ''}
`).get(...sourceFilter.params)?.total || 0;
const peakDay = db.prepare(`
SELECT DATE(timestamp) as day,
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
FROM messages
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
${sourceSql}
GROUP BY DATE(timestamp)
ORDER BY tokens DESC
LIMIT 1
`).get(...sourceFilter.params) || null;
const longestTurn = db.prepare(`
SELECT turn_duration_ms, uuid, session_id, timestamp
FROM messages
WHERE turn_duration_ms IS NOT NULL
${sourceSql}
ORDER BY turn_duration_ms DESC
LIMIT 1
`).get(...sourceFilter.params) || null;
return { daily, totalTokens, peakDay, longestTurn };
});
// --- Capture ---
const EXPORT_WIDTH = 540;
const EXPORT_HEIGHT = 675;
async function createExportCapture(parentWin, query) {
const exportWin = new BrowserWindow({
width: EXPORT_WIDTH,
height: EXPORT_HEIGHT,
show: false,
webPreferences: {
preload: path.join(__dirname, '..', 'preload', 'index.js'),
contextIsolation: true,
nodeIntegration: false,
offscreen: true,
deviceScaleFactor: 2,
} as Electron.WebPreferences,
});
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
const url = isDev
? `${process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173'}/#/recap-export?${query}`
: `file://${path.join(__dirname, '..', 'renderer', 'index.html')}#/recap-export?${query}`;
await exportWin.loadURL(url);
await waitForExportReady(exportWin.webContents);
const image = await exportWin.webContents.capturePage({
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
});
exportWin.close();
return image;
}
async function waitForExportReady(webContents, timeoutMs = 2500) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const ready = await webContents.executeJavaScript('window.__OBELISK_RECAP_EXPORT_READY__ === true', true);
if (ready) return true;
} catch {}
await new Promise(r => setTimeout(r, 50));
}
return false;
}
ipcMain.handle('capture:export', async (event, { cardIdx, archetype, filename } = {}) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
const image = await createExportCapture(win, query);
const { filePath } = await dialog.showSaveDialog(win, {
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
});
if (!filePath) return null;
fs.writeFileSync(filePath, image.toPNG());
return filePath;
});
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype, filename } = {}) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
const image = await createExportCapture(win, query);
clipboard.writeImage(image);
return true;
});
// --- Recap files ---
ipcMain.handle('recap:list', () => {
if (!fs.existsSync(RECAP_DIR)) return [];
return fs.readdirSync(RECAP_DIR)
.filter(f => f.endsWith('.json'))
.sort()
.reverse();
});
ipcMain.handle('recap:read', (_, filename) => {
const filePath = path.join(RECAP_DIR, path.basename(filename));
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch { return null; }
});
// --- Settings ---
const SETTINGS_PATH = path.join(OBELISK_DIR, 'settings.json');
function loadPersistedSettings() {
try {
if (fs.existsSync(SETTINGS_PATH)) return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
} catch {}
return {};
}
function savePersistedSettings(settings) {
if (!fs.existsSync(OBELISK_DIR)) fs.mkdirSync(OBELISK_DIR, { recursive: true });
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
}
ipcMain.handle('settings:get', () => {
const persisted = loadPersistedSettings();
const { claudeDir, codexDir, dbPath: dbFile } = getPathsForClaudeDir(
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
persisted.codexDir || DEFAULT_CODEX_DIR,
);
const recapDir = persisted.recapDir || RECAP_DIR;
const claudeExists = fs.existsSync(claudeDir);
const codexExists = fs.existsSync(codexDir);
let claudeSessionCount = 0;
let codexSessionCount = 0;
let memoryCount = 0;
let claudeLastIndexed = '';
let codexLastIndexed = '';
if (db) {
try {
claudeSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get()?.c || 0;
codexSessionCount = db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source = 'codex'").get()?.c || 0;
memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
const claudeLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE COALESCE(source, 'claude') = 'claude'").get();
claudeLastIndexed = claudeLatest?.t || '';
const codexLatest = db.prepare("SELECT MAX(started_at) as t FROM sessions WHERE source = 'codex'").get();
codexLastIndexed = codexLatest?.t || '';
} catch {}
}
return {
claudeDir,
codexDir,
dbPath: dbFile,
recapDir,
autoRefresh: persisted.autoRefresh !== false,
sources: [
{
id: 'claude',
name: 'Claude Code',
vendor: 'Anthropic',
path: claudeDir,
exists: claudeExists,
sessionCount: claudeSessionCount,
lastIndexed: claudeLastIndexed,
status: claudeExists ? 'ok' : 'error',
statusText: claudeExists ? 'Connected' : 'Folder not found',
},
{
id: 'codex',
name: 'Codex',
vendor: 'OpenAI',
path: codexDir,
exists: codexExists,
sessionCount: codexSessionCount,
lastIndexed: codexLastIndexed,
status: codexExists ? (codexSessionCount > 0 ? 'ok' : 'warn') : 'error',
statusText: codexExists ? (codexSessionCount > 0 ? 'Connected' : 'No sessions found') : 'Folder not found',
},
],
memoryCount,
sessionCount: claudeSessionCount + codexSessionCount,
lastIndexed: claudeLastIndexed,
status: claudeExists ? 'ok' : 'error',
statusText: claudeExists ? 'Connected' : 'Folder not found',
};
});
ipcMain.handle('settings:set', async (_, key, value) => {
const persisted = loadPersistedSettings();
if (value === null) {
delete persisted[key];
} else {
persisted[key] = value;
}
savePersistedSettings(persisted);
if (key === 'autoRefresh') {
if (value === false && indexerService) {
await stopIndexerServiceAndWait();
} else if (value !== false && indexerService) {
await stopIndexerServiceAndWait();
startIndexerService({ buildOnStart: false });
}
}
if (key === 'claudeDir' || key === 'codexDir') {
await stopIndexerServiceAndWait();
const paths = getPathsForClaudeDir(
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
persisted.codexDir || DEFAULT_CODEX_DIR,
);
migrateLegacyDbIfNeeded(paths);
openDb(paths.dbPath);
if (persisted.autoRefresh !== false) {
startIndexerService({ buildOnStart: true });
}
notifyIndexUpdated();
}
return true;
});
ipcMain.handle('settings:browseFolder', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory'],
title: 'Select Claude Code data folder',
});
if (filePaths && filePaths[0]) return filePaths[0];
return null;
});
ipcMain.handle('settings:revealPath', (_, p) => {
if (fs.existsSync(p)) shell.showItemInFolder(p);
});
ipcMain.handle('settings:rebuildIndex', async () => {
if (!indexerWorker) return null;
const persisted = loadPersistedSettings();
const paths = getPathsForClaudeDir(
persisted.claudeDir || DEFAULT_CLAUDE_DIR,
persisted.codexDir || DEFAULT_CODEX_DIR,
);
const tempDbPath = rebuildTempDbPath(paths.dbPath);
const shouldRestartWatcher = persisted.autoRefresh !== false;
await stopIndexerServiceAndWait({ waitForIdle: false });
if (indexerWorker) {
await Promise.resolve(indexerWorker.stop());
indexerWorker = createWorkerBuildIndex();
}
cleanupDbFiles(tempDbPath);
let writerLease: ReturnType<typeof acquireWriterLease> = null;
try {
const writerLeasePath = writerLockPathFor(paths.dbPath);
writerLease = acquireWriterLease({
lockPath: writerLeasePath,
openDb: lockPath => new Database(lockPath),
waitMs: 2000,
});
if (!writerLease) {
return {
files: 0,
latestSourceMtime: 0,
affectedSessionIds: [],
ftsRebuilt: false,
skipped: 0,
skippedFiles: [],
deferred: true,
reason: 'writer_busy',
};
}
migrateLegacyDbIfNeeded(paths, { writerLeaseMode: 'caller-held' });
const result = await indexerWorker.buildIndex({
reason: 'manual-rebuild',
force: true,
claudeDir: paths.claudeDir,
codexDir: paths.codexDir,
projectsDir: paths.projectsDir,
dbPath: tempDbPath,
preserveDbPath: fs.existsSync(paths.dbPath) ? paths.dbPath : null,
writerLeasePath,
writerLeaseMode: 'caller-held',
});
if (result?.deferred) return result;
closeDb();
replaceDbWithTemp(tempDbPath, paths.dbPath);
openDb(paths.dbPath, { writerLeaseMode: 'caller-held' });
notifyIndexUpdated(result);
return result;
} finally {
try {
cleanupDbFiles(tempDbPath);
if (!db) {
try {
openDb(paths.dbPath, {
writerLeaseMode: writerLease ? 'caller-held' : 'acquire',
});
} catch (error) {
console.warn?.(`Obelisk DB reopen after rebuild failed: ${(error as Error).message}`);
}
}
} finally {
writerLease?.release();
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
}
}
});
+259
View File
@@ -0,0 +1,259 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import chokidarModule from 'chokidar';
const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
const DEFAULT_DEBOUNCE_MS = 2000;
const DEFAULT_STABILITY_MS = 500;
const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_WATCH_RETRY_MS = 5000;
const DEFAULT_DEFERRED_RETRY_MS = 250;
type TimerHandle = ReturnType<typeof setTimeout>;
interface Timers {
setTimeout: (fn: () => void, ms?: number) => TimerHandle;
clearTimeout: (handle: TimerHandle) => void;
setInterval?: (fn: () => void, ms?: number) => TimerHandle;
clearInterval?: (handle: TimerHandle) => void;
}
interface Watcher {
close(): unknown;
}
interface IndexerBuildResult {
deferred?: boolean;
}
type IndexerBuild = (args: {
reason?: string;
changedPaths?: string[];
}) => IndexerBuildResult | void | Promise<IndexerBuildResult | void>;
interface IndexerServiceOptions {
projectsDir?: string;
watchDirs?: string | string[];
debounceMs?: number;
stabilityMs?: number;
heartbeatMs?: number;
watchRetryMs?: number;
deferredRetryMs?: number;
buildIndex?: IndexerBuild;
writeHeartbeat?: () => unknown;
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null;
chokidar?: any;
timers?: Timers;
logger?: { warn?: (msg: string) => void };
}
function createIndexerService({
projectsDir = DEFAULT_PROJECTS_DIR,
watchDirs = [projectsDir],
debounceMs = DEFAULT_DEBOUNCE_MS,
stabilityMs = DEFAULT_STABILITY_MS,
heartbeatMs = DEFAULT_HEARTBEAT_MS,
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
deferredRetryMs = DEFAULT_DEFERRED_RETRY_MS,
buildIndex,
writeHeartbeat = () => {},
watchProjects,
chokidar,
timers = {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
},
logger = console,
}: IndexerServiceOptions = {}) {
if (typeof buildIndex !== 'function') throw new Error('createIndexerService() requires buildIndex');
const watch = watchProjects || ((onChange) => {
const roots = [...new Set((Array.isArray(watchDirs) ? watchDirs : [watchDirs]).filter(Boolean))];
const existingRoots = roots.filter(root => fs.existsSync(root));
if (!existingRoots.length) return null;
const watchers: any[] = [];
const onFileChange = (filename) => {
const name = filename ? String(filename) : '';
if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name);
};
for (const root of existingRoots) {
const watcher = (chokidar || chokidarModule).watch(root, {
cwd: root,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: Math.max(stabilityMs, 500),
pollInterval: 100,
},
ignored: (targetPath, stats) => {
if (stats?.isDirectory()) return false;
if (!stats) return false;
return !String(targetPath).endsWith('.jsonl') && !String(targetPath).endsWith('.json');
},
});
watcher
.on('add', onFileChange)
.on('change', onFileChange)
.on('unlink', onFileChange)
.on('error', (error) => {
logger.warn?.(`Obelisk watcher failed: ${(error as Error).message}`);
});
watchers.push(watcher);
}
return {
close() {
return Promise.all(watchers.map(w => Promise.resolve(w.close?.())));
},
};
});
let buildTimer: TimerHandle | null = null;
let stabilityTimer: TimerHandle | null = null;
let heartbeatTimer: TimerHandle | null = null;
let watchRetryTimer: TimerHandle | null = null;
let deferredRetryTimer: TimerHandle | null = null;
let watcher: Watcher | null = null;
let stopped = false;
let running = false;
let pending = false;
let lastReason: string | null = null;
let changedPaths = new Set<string>();
let idlePromise = Promise.resolve();
const addChangedPath = (changedPath?: string | string[]) => {
if (Array.isArray(changedPath)) {
for (const p of changedPath) addChangedPath(p);
return;
}
const name = changedPath ? String(changedPath) : '';
if (name) changedPaths.add(name);
};
const takeChangedPaths = () => {
if (!changedPaths.size) return undefined;
const paths = [...changedPaths];
changedPaths = new Set();
return paths;
};
const publishHeartbeat = () => {
try {
return writeHeartbeat();
} catch (error) {
logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`);
return false;
}
};
const runBuildNow = (reason = "manual", paths: string[] | undefined = undefined) => {
addChangedPath(paths);
if (stopped) return idlePromise;
if (running) {
pending = true;
return idlePromise;
}
running = true;
pending = false;
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => {
const result = await buildIndex({ reason, changedPaths: buildChangedPaths });
if (result?.deferred) {
addChangedPath(buildChangedPaths);
if (!stopped && !deferredRetryTimer) {
deferredRetryTimer = timers.setTimeout(() => {
deferredRetryTimer = null;
runBuildNow('writer-lease');
}, deferredRetryMs);
}
return;
}
publishHeartbeat();
})()
.catch((error) => {
// A build in flight when the service is stopped (e.g. a manual rebuild
// tears down the worker) is a deliberate cancellation, not a failure.
if (!stopped) logger.warn?.(`Obelisk index build failed: ${(error as Error).message}`);
})
.finally(() => {
running = false;
if (pending && !stopped) {
pending = false;
runBuildNow('pending');
}
});
return idlePromise;
};
const scheduleBuild = (reason = "change", changedPath: string | undefined = undefined) => {
if (stopped) return;
addChangedPath(changedPath);
lastReason = reason;
if (running) pending = true;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
deferredRetryTimer = null;
if (buildTimer) timers.clearTimeout(buildTimer);
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
buildTimer = timers.setTimeout(() => {
buildTimer = null;
if (stabilityMs <= 0) {
runBuildNow(lastReason || reason);
return;
}
stabilityTimer = timers.setTimeout(() => {
stabilityTimer = null;
runBuildNow(lastReason || reason);
}, stabilityMs);
}, debounceMs);
};
const startWatching = () => {
if (stopped || watcher) return;
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) {
watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null;
startWatching();
}, watchRetryMs);
}
};
const start = ({ buildOnStart = true } = {}) => {
stopped = false;
publishHeartbeat();
if (buildOnStart) scheduleBuild('startup');
startWatching();
if (typeof timers.setInterval === 'function') {
heartbeatTimer = timers.setInterval(() => {
publishHeartbeat();
}, heartbeatMs);
}
};
const stop = () => {
stopped = true;
pending = false;
if (buildTimer) timers.clearTimeout(buildTimer);
buildTimer = null;
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
stabilityTimer = null;
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
watchRetryTimer = null;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
deferredRetryTimer = null;
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
heartbeatTimer = null;
if (watcher?.close) watcher.close();
watcher = null;
};
return {
start,
stop,
scheduleBuild,
runBuildNow,
idle: () => idlePromise,
};
}
export { createIndexerService };
+81
View File
@@ -0,0 +1,81 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
interface WorkerMessage {
id: number;
result?: unknown;
error?: { message: string; stack?: string };
}
interface PendingBuild {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
interface WorkerBuildIndexOptions {
workerPath?: string;
WorkerImpl?: typeof Worker;
}
function createWorkerBuildIndex({
// indexer-worker.js is the built worker output emitted next to this module.
workerPath = path.join(__dirname, 'indexer-worker.js'),
WorkerImpl = Worker,
}: WorkerBuildIndexOptions = {}) {
let worker: Worker | null = null;
let nextId = 1;
const pending = new Map<number, PendingBuild>();
const rejectPending = (error: Error) => {
for (const { reject } of pending.values()) reject(error);
pending.clear();
};
const ensureWorker = (): Worker => {
if (worker) return worker;
const active = new WorkerImpl(workerPath, { type: 'module' } as ConstructorParameters<typeof Worker>[1]);
worker = active;
active.on('message', (message: WorkerMessage) => {
const current = pending.get(message.id);
if (!current) return;
pending.delete(message.id);
if (message.error) {
const error = new Error(message.error.message);
error.stack = message.error.stack;
current.reject(error);
} else {
current.resolve(message.result);
}
});
active.on('error', (error: Error) => {
rejectPending(error);
worker = null;
});
active.on('exit', (code: number) => {
if (pending.size) rejectPending(new Error(`Indexer worker exited with code ${code}`));
worker = null;
});
return active;
};
const buildIndex = (args: Record<string, unknown> = {}) => new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ensureWorker().postMessage({ id, args });
});
const stop = () => {
const current = worker;
worker = null;
const termination = current?.terminate ? Promise.resolve(current.terminate()) : Promise.resolve();
rejectPending(new Error('Indexer worker stopped'));
return termination;
};
return { buildIndex, stop };
}
export { createWorkerBuildIndex };
+20
View File
@@ -0,0 +1,20 @@
import { parentPort } from 'node:worker_threads';
import { buildIndex } from './indexer.ts';
if (!parentPort) throw new Error('indexer-worker must run as a worker thread');
const port = parentPort;
port.on('message', ({ id, args }: { id: number; args?: Record<string, unknown> }) => {
try {
const result = buildIndex(args || {});
port.postMessage({ id, result });
} catch (error) {
port.postMessage({
id,
error: {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
});
}
});
+773
View File
@@ -0,0 +1,773 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import { parse as claudeParse } from '../../../packages/core/src/providers/claude.ts';
import { parse as codexParse } from '../../../packages/core/src/providers/codex.ts';
import { persist } from '../../../packages/core/src/persist.ts';
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from '../../../packages/core/src/write-coordinator.ts';
import {
inferProjectPath,
isDir,
readLines,
codexDbId,
codexRawId,
codexParentThreadId,
readCodexGuardianThreadInfo,
} from '../../../packages/core/src/parsing.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
const DEFAULT_OBELISK_DIR = path.join(os.homedir(), '.obelisk');
const DEFAULT_DB_PATH = path.join(DEFAULT_OBELISK_DIR, 'obelisk.sqlite');
const DEFAULT_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, 'projects');
const DEFAULT_HISTORY_PATH = path.join(DEFAULT_CLAUDE_DIR, 'history.jsonl');
interface FileInfo {
path: string;
sessionId?: string;
project?: string;
isSubagent?: boolean;
agentId?: string;
workflowRunId?: string;
source?: string;
}
function resolveSchemaPath() {
const candidates = [
path.join(__dirname, 'schema.sql'),
path.join(__dirname, '..', '..', '..', 'packages', 'core', 'src', 'schema.sql'),
process.resourcesPath ? path.join(process.resourcesPath, 'scripts', 'schema.sql') : null,
].filter((c): c is string => Boolean(c));
const found = candidates.find(p => fs.existsSync(p));
if (!found) throw new Error('Obelisk schema.sql not found');
return found;
}
function installSchema(db, schemaPath = resolveSchemaPath()) {
db.exec(fs.readFileSync(schemaPath, 'utf8'));
migrateDb(db);
}
function openIndexDb({ dbPath = DEFAULT_DB_PATH, schemaPath = resolveSchemaPath(), DatabaseImpl = Database }: { dbPath?: string; schemaPath?: string; DatabaseImpl?: new (dbPath: string) => any } = {}) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseImpl(dbPath);
configureConnection(db, { busyTimeoutMs: 250 });
installSchema(db, schemaPath);
return db;
}
function ensureColumn(db, table, column, definition) {
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 migrateDb(db) {
ensureColumn(db, 'sessions', 'source', "TEXT DEFAULT 'claude'");
ensureColumn(db, 'messages', 'content_type', 'TEXT');
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
ensureColumn(db, 'messages', 'source', "TEXT DEFAULT 'claude'");
ensureColumn(db, 'memories', 'anchors', 'TEXT');
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
}
function copyMemoriesFromDb(db, sourceDbPath) {
if (!sourceDbPath || !fs.existsSync(sourceDbPath)) return false;
db.prepare('ATTACH DATABASE ? AS previous_obelisk').run(sourceDbPath);
try {
const hasMemories = db.prepare(`
SELECT name FROM previous_obelisk.sqlite_master
WHERE type='table' AND name='memories'
`).get();
if (!hasMemories) return false;
const sourceColumns = new Set(
db.prepare('PRAGMA previous_obelisk.table_info(memories)').all().map(column => column.name),
);
const targetColumns = [
'id',
'session_id',
'project',
'message_start',
'message_end',
'path',
'anchors',
'summary',
'created_at',
'deleted_at',
'deleted_reason',
];
const selectList = targetColumns
.map(column => sourceColumns.has(column) ? column : `NULL AS ${column}`)
.join(',');
db.exec(`
INSERT OR REPLACE INTO memories (${targetColumns.join(',')})
SELECT ${selectList} FROM previous_obelisk.memories
`);
return true;
} finally {
db.exec('DETACH DATABASE previous_obelisk');
}
}
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined }: { projectsDir?: string; changedPaths?: string[] } = {}) {
if (Array.isArray(changedPaths) && changedPaths.length) {
const changedFiles = discoverJsonlFilesForChanges({ projectsDir, changedPaths });
if (changedFiles.length) return changedFiles;
}
return discoverJsonlFilesFull({ projectsDir });
}
function normalizeChangedPath(projectsDir, changedPath) {
if (!changedPath) return null;
const raw = String(changedPath);
return path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.join(projectsDir, raw));
}
function jsonlFileInfoFromPath(projectsDir, changedPath) {
let fp = normalizeChangedPath(projectsDir, changedPath);
if (fp?.toLowerCase().endsWith('.meta.json')) {
fp = fp.slice(0, -'.meta.json'.length) + '.jsonl';
}
if (!fp || !fp.endsWith('.jsonl')) return null;
if (!fs.existsSync(fp)) return null;
const rel = path.relative(projectsDir, fp);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const parts = rel.split(path.sep);
const project = parts[0];
if (!project) return null;
if (parts.length === 2) {
const filename = parts[1];
return { path: fp, sessionId: filename.slice(0, -6), project, isSubagent: false };
}
if (parts.length === 4 && parts[2] === 'subagents') {
const filename = parts[3];
return { path: fp, sessionId: parts[1], project, isSubagent: true, agentId: filename.slice(0, -6) };
}
if (parts.length === 6 && parts[2] === 'subagents' && parts[3] === 'workflows') {
const filename = parts[5];
return {
path: fp,
sessionId: parts[1],
project,
isSubagent: true,
agentId: filename.slice(0, -6),
workflowRunId: parts[4],
};
}
return null;
}
function sessionIdFromChangedPath(projectsDir, changedPath) {
const fp = normalizeChangedPath(projectsDir, changedPath);
if (!fp) return null;
const rel = path.relative(projectsDir, fp);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const parts = rel.split(path.sep);
if (parts.length === 2 && parts[1].endsWith('.jsonl')) {
return fs.existsSync(fp) ? parts[1].slice(0, -6) : null;
}
if (parts.length >= 3) return fs.existsSync(fp) ? parts[1] || null : null;
return null;
}
function dedupeFileInfos(files) {
const byPath = new Map();
for (const file of files) byPath.set(file.path, file);
return [...byPath.values()];
}
function discoverJsonlFilesForChanges({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = [] }: { projectsDir?: string; changedPaths?: string[] } = {}) {
const files: FileInfo[] = [];
for (const changedPath of changedPaths) {
const info = jsonlFileInfoFromPath(projectsDir, changedPath);
if (info) files.push(info);
}
return dedupeFileInfos(files);
}
function discoverJsonlFilesFull({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
const files: FileInfo[] = [];
if (!fs.existsSync(projectsDir)) return files;
let projects;
try { projects = fs.readdirSync(projectsDir); } catch { return files; }
for (const proj of projects) {
const projPath = path.join(projectsDir, 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({ codexDir = DEFAULT_CODEX_DIR, changedPaths = undefined }: { codexDir?: string; changedPaths?: string[] } = {}) {
if (Array.isArray(changedPaths) && changedPaths.length) {
const changedFiles = discoverCodexJsonlFilesForChanges({ codexDir, changedPaths });
if (changedFiles.length) return changedFiles;
return [];
}
return discoverCodexJsonlFilesFull({ codexDir });
}
function codexSessionsDir(codexDir = DEFAULT_CODEX_DIR) {
return path.join(codexDir, 'sessions');
}
function normalizeChangedPathForRoot(rootDir, changedPath) {
if (!changedPath) return null;
const raw = String(changedPath);
return path.isAbsolute(raw) ? path.normalize(raw) : path.normalize(path.join(rootDir, raw));
}
function isPathInside(rootDir, candidate) {
if (!rootDir || !candidate) return false;
const rel = path.relative(rootDir, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function discoverCodexJsonlFilesForChanges({ codexDir = DEFAULT_CODEX_DIR, changedPaths = [] }: { codexDir?: string; changedPaths?: string[] } = {}) {
const files: FileInfo[] = [];
const sessionsDir = codexSessionsDir(codexDir);
for (const changedPath of changedPaths) {
const rootRelativePath = normalizeChangedPathForRoot(codexDir, changedPath);
if (!rootRelativePath) continue;
if (path.normalize(rootRelativePath) === path.join(codexDir, 'session_index.jsonl')) {
return discoverCodexJsonlFilesFull({ codexDir });
}
const sessionRelativePath = normalizeChangedPathForRoot(sessionsDir, changedPath);
const fp = isPathInside(sessionsDir, rootRelativePath) ? rootRelativePath : sessionRelativePath;
if (!fp || !fp.endsWith(".jsonl") || !isPathInside(sessionsDir, fp)) continue;
if (!fs.existsSync(fp)) continue;
files.push({ path: fp, source: 'codex' });
}
return dedupeFileInfos(files);
}
function discoverCodexJsonlFilesFull({ codexDir = DEFAULT_CODEX_DIR } = {}) {
const root = codexSessionsDir(codexDir);
const files: FileInfo[] = [];
if (!fs.existsSync(root)) return files;
const stack = [root];
while (stack.length) {
const current = stack.pop()!;
let entries;
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
const fp = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fp);
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
files.push({ path: fp, source: 'codex' });
}
}
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
function needsReindex(db, fp) {
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, mtime: mt };
return mt > row.mtime ? { needed: true, skip: row.lines_processed, mtime: mt } : { needed: false, skip: 0, mtime: mt };
}
// Index one Claude transcript via the shared provider + persist core.
// Returns { sessionId, path } when reindexed, undefined when skipped.
function indexClaudeFile(db, file) {
const { needed, skip, mtime } = needsReindex(db, file.path);
if (!needed) return undefined;
const unit = {
key: file.path,
sessionId: file.sessionId,
project: file.project,
isSubagent: file.isSubagent,
agentId: file.agentId,
};
const cursor = skip > 0 ? `${mtime}:${skip}` : null;
persist(db, unit, claudeParse(unit, cursor));
return { sessionId: file.sessionId, path: file.path };
}
function codexSessionMeta(filePath) {
let meta: any = null;
readLines(filePath, (line) => {
let obj;
try { obj = JSON.parse(line); } catch { return; }
if (obj?.type === 'session_meta' && obj.payload?.id) {
meta = obj.payload;
return false;
}
});
return meta;
}
// Index one Codex rollout via the shared provider + persist core (full reparse).
// Returns { sessionId, path } when reindexed, undefined when skipped.
function indexCodexFile(db, file) {
const { needed } = needsReindex(db, file.path);
const guardian = readCodexGuardianThreadInfo(file.path);
if (!needed) {
if (guardian) {
persist(db, { key: file.path, sessionId: '' }, (function* () {
yield { kind: "delete-session", sessionId: codexDbId(guardian.threadRawId) as string };
return null;
})());
}
return undefined;
}
const unit = { key: file.path, sessionId: '' };
persist(db, unit, codexParse(unit, null));
if (guardian) return undefined;
const meta = codexSessionMeta(file.path);
const sessionId = meta ? codexDbId(codexParentThreadId(meta) || codexRawId(meta.id)) : undefined;
return { sessionId, path: file.path };
}
function indexCodexSessionIndex(db, { codexDir = DEFAULT_CODEX_DIR } = {}) {
const indexPath = path.join(codexDir, 'session_index.jsonl');
if (!fs.existsSync(indexPath)) return;
readLines(indexPath, (line) => {
let item;
try {
item = JSON.parse(line);
} catch (error) {
console.warn(`Warning: malformed Codex session index line: ${(error as Error).message}`);
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) {
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 => row.cwd);
const projectPath = inferProjectPath(session.project, cwds);
if (projectPath) update.run(projectPath, session.id);
}
}
function indexSubagentMeta(db, fi) {
if (!fi.isSubagent) return false;
const mp = fi.path.replace('.jsonl', '.meta.json');
if (!fs.existsSync(mp)) return false;
let meta;
try {
meta = JSON.parse(fs.readFileSync(mp, 'utf8'));
} catch (error) {
console.warn(`Warning: failed to read subagent meta ${mp}: ${(error as Error).message}`);
return false;
}
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);
}
return true;
}
function indexWorkflows(db, { projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
if (!fs.existsSync(projectsDir)) return;
let projects;
try { projects = fs.readdirSync(projectsDir); } catch { return; }
for (const proj of projects) {
const pp = path.join(projectsDir, 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;
try {
wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8'));
} catch (error) {
console.warn(`Warning: failed to read workflow ${f}: ${(error as Error).message}`);
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, { historyPath = DEFAULT_HISTORY_PATH } = {}) {
if (!fs.existsSync(historyPath)) return;
readLines(historyPath, (line) => {
let item;
try {
item = JSON.parse(line);
} catch (error) {
console.warn(`Warning: malformed history line: ${(error as Error).message}`);
return;
}
if (item.sessionId && item.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(item.title, item.sessionId);
});
}
function rebuildFts(db) {
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
}
// PASSIVE by default: it checkpoints what it can without blocking concurrent
// readers/writers, so it is safe to run after every build. A blocking TRUNCATE
// (which reclaims the -wal file but needs exclusive access and can contend with
// the daemon + queries) is reserved for maintenance/exit — pass mode explicitly.
function checkpointDb(db, mode = 'PASSIVE') {
try {
db.pragma(`wal_checkpoint(${mode})`);
} catch {}
}
const MESSAGE_FTS_TRIGGERS = [
'messages_fts_ai',
'messages_fts_ad',
'messages_fts_au',
];
function dropMessageFtsTriggers(db) {
for (const trigger of MESSAGE_FTS_TRIGGERS) {
db.exec(`DROP TRIGGER IF EXISTS ${trigger}`);
}
}
function ensureFtsReady(db, { force = false } = {}) {
const marker = '__fts_triggers_ready__';
const ready = db.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?').get(marker);
if (ready && !force) return false;
rebuildFts(db);
writeIndexMarker(db, marker);
return true;
}
function writeIndexMarker(db, key, value = Date.now()) {
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(key, value);
}
function writeHeartbeat({
dbPath = DEFAULT_DB_PATH,
writerLeasePath = writerLockPathFor(dbPath),
DatabaseImpl = Database,
LockDatabaseImpl = DatabaseImpl,
} = {}) {
if (!fs.existsSync(dbPath)) return;
const lease = acquireWriterLease({
lockPath: writerLeasePath,
openDb: lockPath => new LockDatabaseImpl(lockPath),
});
if (!lease) return false;
try {
const db = new DatabaseImpl(dbPath);
configureConnection(db, { busyTimeoutMs: 0 });
const txDb = betterSqliteTransactionAdapter(db);
try {
runWriteTransaction(txDb, () => writeIndexMarker(db, '__app_heartbeat__'), { label: 'heartbeat' });
return true;
} finally {
db.close();
}
} finally {
lease.release();
}
}
interface BuildIndexOptions {
claudeDir?: string;
codexDir?: string;
projectsDir?: string;
historyPath?: string;
dbPath?: string;
schemaPath?: string;
DatabaseImpl?: new (dbPath: string) => any;
LockDatabaseImpl?: new (dbPath: string) => any;
force?: boolean;
changedPaths?: string[];
preserveDbPath?: string | null;
writerLeasePath?: string;
writerLeaseWaitMs?: number;
writerLeaseMode?: 'acquire' | 'caller-held';
}
interface SkippedFile {
path: string;
error: string;
diagnostics?: unknown;
}
interface BuildIndexResult {
files: number;
latestSourceMtime: number;
affectedSessionIds: string[];
ftsRebuilt: boolean;
skipped: number;
skippedFiles: SkippedFile[];
deferred: boolean;
reason?: string;
}
function deferredBuildResult(
reason: string,
overrides: Partial<Omit<BuildIndexResult, 'deferred' | 'reason'>> = {},
): BuildIndexResult {
return {
files: 0,
latestSourceMtime: 0,
affectedSessionIds: [],
ftsRebuilt: false,
skipped: 0,
skippedFiles: [],
...overrides,
deferred: true,
reason,
};
}
function buildIndex({
claudeDir = DEFAULT_CLAUDE_DIR,
codexDir = path.join(path.dirname(claudeDir), '.codex'),
projectsDir = path.join(claudeDir, 'projects'),
historyPath = path.join(claudeDir, 'history.jsonl'),
dbPath = DEFAULT_DB_PATH,
schemaPath = resolveSchemaPath(),
DatabaseImpl = Database,
LockDatabaseImpl = DatabaseImpl,
force = false,
changedPaths = undefined,
preserveDbPath = null,
writerLeasePath = writerLockPathFor(dbPath),
writerLeaseWaitMs = 2000,
writerLeaseMode = 'acquire',
}: BuildIndexOptions = {}): BuildIndexResult {
if (writerLeaseMode !== 'acquire' && writerLeaseMode !== 'caller-held') {
throw new Error(`Unknown writer lease mode: ${writerLeaseMode}`);
}
let lease: ReturnType<typeof acquireWriterLease> = null;
if (writerLeaseMode === 'acquire') {
lease = acquireWriterLease({
lockPath: writerLeasePath,
openDb: lockPath => new LockDatabaseImpl(lockPath),
waitMs: writerLeaseWaitMs,
});
if (!lease) {
return deferredBuildResult('writer_busy');
}
}
try {
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
const txDb = betterSqliteTransactionAdapter(db);
let messageFtsTriggersDropped = false;
try {
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
copyMemoriesFromDb(db, preserveDbPath);
}
const files = [
...discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }),
...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }),
];
const latestSourceMtime = files.reduce((latest, file) => {
try {
return Math.max(latest, fs.statSync(file.path).mtimeMs);
} catch {
return latest;
}
}, 0);
try {
if (force) {
runRetryableWriteTransaction(txDb, () => {
dropMessageFtsTriggers(db);
db.prepare("DELETE FROM index_state WHERE substr(jsonl_path, 1, 2) != '__'").run();
db.prepare("DELETE FROM messages").run();
db.prepare("DELETE FROM tool_calls").run();
db.prepare("DELETE FROM tool_results").run();
db.prepare("DELETE FROM sessions").run();
db.prepare("DELETE FROM summaries").run();
db.prepare("DELETE FROM subagents").run();
db.prepare("DELETE FROM workflows").run();
db.prepare("DELETE FROM workflow_agents").run();
}, { label: 'force-cleanup' });
messageFtsTriggersDropped = true;
}
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
files: files.length,
latestSourceMtime,
});
}
throw error;
}
const affectedSessionIds = new Set<string>();
const finalizeAffectedSessionIds = new Set<string>();
const changedMetaJsonlPaths = new Set<string>();
if (Array.isArray(changedPaths)) {
for (const changedPath of changedPaths) {
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
const normalizedChangedPath = normalizeChangedPath(projectsDir, changedPath);
const isMetaChange = normalizedChangedPath?.toLowerCase().endsWith('.meta.json');
if (isMetaChange && normalizedChangedPath) {
changedMetaJsonlPaths.add(
normalizedChangedPath.slice(0, -'.meta.json'.length) + '.jsonl',
);
}
// Transcript files report their session only after their own transaction
// commits. Workflow changes are applied during finalize, so stage those
// IDs until the finalize transaction commits. Meta files map back to their
// transcript transaction and are reported only after that commit.
if (sessionId && !changedPath.toLowerCase().endsWith('.jsonl') && !isMetaChange) {
finalizeAffectedSessionIds.add(sessionId);
}
}
}
const skipped: SkippedFile[] = [];
for (const file of files) {
try {
// The write is committed before affectedSessionIds is updated, so a
// failed/rolled-back file never reports a phantom updated session.
const indexed = runRetryableWriteTransaction(txDb, () => {
const result = file.source === 'codex' ? indexCodexFile(db, file) : indexClaudeFile(db, file);
const metaIndexed = file.source !== 'codex' && indexSubagentMeta(db, file);
if (!result?.sessionId && metaIndexed && changedMetaJsonlPaths.has(file.path)) {
return { sessionId: file.sessionId, path: file.path };
}
return result;
}, { label: `file:${file.path}` });
if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId);
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
files: files.length,
latestSourceMtime,
affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length,
skippedFiles: skipped,
});
}
if (hasUnusableTransaction(error)) throw error;
skipped.push({ path: file.path, error: (error as Error).message, diagnostics: (error as { obelisk?: unknown }).obelisk });
console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`);
}
}
let ftsRebuilt = false;
// Finalize is one transaction; a failure here fails the whole build (the
// index would otherwise be left inconsistent).
try {
runRetryableWriteTransaction(txDb, () => {
indexWorkflows(db, { projectsDir });
refreshSessionProjectPaths(db);
indexHistory(db, { historyPath });
indexCodexSessionIndex(db, { codexDir });
if (messageFtsTriggersDropped) installSchema(db, schemaPath);
ftsRebuilt = ensureFtsReady(db, { force });
writeIndexMarker(db, '__last_build__');
writeIndexMarker(db, '__app_last_successful_build__');
writeIndexMarker(db, '__indexer_owner_app__');
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
}, { label: 'finalize' });
} catch (error) {
if (isBeginBusyFailure(error)) {
return deferredBuildResult('database_busy', {
files: files.length,
latestSourceMtime,
affectedSessionIds: [...affectedSessionIds],
skipped: skipped.length,
skippedFiles: skipped,
});
}
throw error;
}
for (const sessionId of finalizeAffectedSessionIds) affectedSessionIds.add(sessionId);
return {
files: files.length,
latestSourceMtime,
affectedSessionIds: [...affectedSessionIds],
ftsRebuilt,
skipped: skipped.length,
skippedFiles: skipped,
deferred: false,
};
} finally {
if (messageFtsTriggersDropped) {
try {
installSchema(db, schemaPath);
} catch (error) {
console.warn(`Warning: failed to restore message FTS triggers: ${(error as Error).message}`);
}
}
checkpointDb(db);
db.close();
}
} finally {
lease?.release();
}
}
export {
buildIndex,
writeHeartbeat,
openIndexDb,
discoverJsonlFiles,
inferProjectPath,
};
+24
View File
@@ -0,0 +1,24 @@
import path from 'node:path';
function cleanRecapFilename(filename?: string | null): string {
if (!filename) return '';
return path.basename(String(filename));
}
interface RecapExportQueryOptions {
cardIdx?: number | string;
archetype?: string;
filename?: string;
}
function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' }: RecapExportQueryOptions = {}): string {
const params = new URLSearchParams();
const cardNumber = Number(cardIdx);
params.set('card', Number.isFinite(cardNumber) ? String(cardNumber) : '0');
if (archetype) params.set('arch', String(archetype));
const safeFilename = cleanRecapFilename(filename);
if (safeFilename) params.set('file', safeFilename);
return params.toString();
}
export { buildRecapExportQuery, cleanRecapFilename };
+46
View File
@@ -0,0 +1,46 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('obelisk', {
getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts),
getSessionMessages: (id: string) => ipcRenderer.invoke('db:getSessionMessages', id),
getSessionToolCalls: (id: string) => ipcRenderer.invoke('db:getSessionToolCalls', id),
getSessionToolResults: (id: string) => ipcRenderer.invoke('db:getSessionToolResults', id),
getSessionSubagents: (id: string) => ipcRenderer.invoke('db:getSessionSubagents', id),
getSessionWorkflows: (id: string) => ipcRenderer.invoke('db:getSessionWorkflows', id),
getSubagentMessages: (agentId: string) => ipcRenderer.invoke('db:getSubagentMessages', agentId),
getSubagentToolCalls: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId),
getSubagentToolResults: (agentId: string) => ipcRenderer.invoke('db:getSubagentToolResults', agentId),
getSessionSummaries: (id: string) => ipcRenderer.invoke('db:getSessionSummaries', id),
getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid),
getMemories: () => ipcRenderer.invoke('db:getMemories'),
readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path),
archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason),
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
getProjects: () => ipcRenderer.invoke('db:getProjects'),
getStats: () => ipcRenderer.invoke('db:getStats'),
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
onIndexUpdated: (callback: (payload: unknown) => void) => {
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
ipcRenderer.on('obelisk:index-updated', listener);
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
},
onSessionUpdated: (callback: (payload: unknown) => void) => {
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
ipcRenderer.on('obelisk:session-updated', listener);
return () => ipcRenderer.removeListener('obelisk:session-updated', listener);
},
captureExport: (opts?: unknown) => ipcRenderer.invoke('capture:export', opts),
copyImage: (opts?: unknown) => ipcRenderer.invoke('capture:copy', opts),
recapList: () => ipcRenderer.invoke('recap:list'),
recapRead: (filename: string) => ipcRenderer.invoke('recap:read', filename),
onRecapUpdated: (callback: (filePath: unknown) => void) => {
const listener = (_: IpcRendererEvent, filePath: unknown) => callback(filePath);
ipcRenderer.on('obelisk:recap-updated', listener);
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
},
getSettings: () => ipcRenderer.invoke('settings:get'),
browseFolder: () => ipcRenderer.invoke('settings:browseFolder'),
setSetting: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value),
revealPath: (p: string) => ipcRenderer.invoke('settings:revealPath', p),
rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'),
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Obelisk</title>
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/main.js"></script>
</body>
</html>
+235
View File
@@ -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();
});
+380
View File
@@ -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;
}
}
+129
View File
@@ -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;
}
});
}
+269
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 => `<mark>${m}</mark>`);
}
function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
}
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 = `<div class="empty">No memories${state.view === 'archived' ? ' archived' : ''} here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
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
? `<button class="row-action restore" data-action="restore">Restore<span class="kbd">D</span></button>`
: `<button class="row-action danger" data-action="archive">Archive<span class="kbd">D</span></button>`;
return `
<div class="row ${isCursor ? 'cursor' : ''} ${isSelected ? 'selected' : ''} ${m.archived ? 'archived' : ''}" data-id="${m.id}">
<button class="row-checkbox ${isSelected ? 'checked' : ''}" data-action="check" aria-label="Select">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M2.5 6.5l2.5 2.5 4.5-5"/></svg>
</button>
<div class="row-body">
<div class="row-path">
${statusGlyphHTML(status)}
${showProjectPrefix ? `<span class="project-prefix">${escapeHTML(formatProjectLabel(m.project))}</span><span class="project-prefix-sep">/</span>` : ''}
<span class="path-text">${highlightPlain(m.path || '', q)}</span>
</div>
<div class="row-summary">${highlightPlain(m.summary || '', q)}</div>
</div>
<div class="row-right">
<div class="row-meta"><span>${fmtListTime(m.ts)}</span></div>
<div class="row-actions">${actionLabel}</div>
</div>
</div>
`;
}
// --- 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 = `
<div class="detail-meta">
${m.session_id ? `<button class="session-link" data-action="open-session" data-session="${m.session_id}">
${FOLDER_SVG}<span>Source session</span>
</button><span class="dot"></span>` : ''}
<span>${fmtRelative(m.ts)}</span>
</div>
`;
let markdownHTML;
if (m.markdown == null) {
markdownHTML = `<div style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>`;
} else if (state.showSource) {
markdownHTML = `<pre class="markdown-source">${escapeHTML(m.markdown)}</pre>`;
} else {
markdownHTML = renderMarkdown(m.markdown, { variant: 'body' });
}
detail.innerHTML = `
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon">${FOLDER_SVG}</span>
<span class="project-name">${escapeHTML(formatProjectLabel(m.project))}</span>
${m.archived ? '<span class="archived-tag">archived</span>' : ''}
</div>
<div class="detail-path">${escapeHTML(m.path)}</div>
<div class="detail-summary">${escapeHTML(m.summary)}</div>
${provenanceHTML}
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button class="source-toggle ${state.showSource ? 'active' : ''}" data-action="toggle-source" ${m.markdown == null ? 'disabled' : ''}>
${state.showSource ? 'Show rendered' : 'Show source'}
</button>
</div>
${markdownHTML}
</div>
<div class="detail-actions">
<button class="btn" id="detail-back">Back<span class="kbd">Esc</span></button>
<button class="btn ${m.archived ? 'primary' : 'danger'}" id="detail-archive">
${m.archived ? 'Restore' : 'Archive'}<span class="kbd">D</span>
</button>
</div>
`;
// 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);
}
});
});
}
+3
View File
@@ -0,0 +1,3 @@
// Shared registry to break circular dependencies between modules.
const registry = {};
export default registry;
+246
View File
@@ -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 = `<span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
} else if (state.route === 'sessions' && state.mode === 'detail') {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span>`;
} else if (state.mode === 'detail') {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span><span class="kbd-hint"><span class="kbd">D</span> archive</span>`;
} else {
right.innerHTML = `<span class="kbd-hint"><span class="kbd">↑↓</span> nav</span><span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">D</span> archive</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
}
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 = `<span class="status-pending">Action pending <strong>${escapeHTML(target)}</strong><button class="undo-btn" id="undo-btn">Undo</button><span class="timer">${secs}s</span></span>`;
$('#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 };
+530
View File
@@ -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 = `<div class="empty">No sessions here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
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 `
<div class="srow ${state.cursorId === s.id ? 'cursor' : ''}" data-session-id="${s.id}">
<div class="srow-body">
<div class="srow-title">${highlightPlain(s.title || '(untitled)', q)}</div>
<div class="srow-meta">
${showProjectPrefix ? `<span class="project-tag">${escapeHTML(formatProjectLabel(s.project))}</span><span class="dot"></span>` : ''}
<span>${s.message_count || 0} msg</span>
</div>
</div>
<div class="srow-right">${fmtListTime(startedTs)}</div>
</div>
`;
}
// --- 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 = `
<div class="session-header">
<div class="session-eyebrow">
<span class="project-icon">${FOLDER_SVG}</span>
<span class="project-name">${escapeHTML(formatProjectLabel(s.project))}</span>
<span class="sep">·</span>
<span class="project-path">${escapeHTML(s.project_path || '')}</span>
</div>
<div class="session-title">${escapeHTML(s.title || '(untitled)')}</div>
<div class="session-meta-inline">
<span>${fmtRelative(startedTs)}</span>
<span class="dot"></span>
<span>${s.message_count || 0} messages</span>
${s.git_branch ? `<span class="dot"></span><span>${escapeHTML(s.git_branch)}</span>` : ''}
</div>
</div>
`;
const messagesHTML = (s.messages || []).map((msg, idx) => renderMessage(msg, idx)).join('');
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
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 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>`;
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 = `
<div class="session-header">
<div class="session-eyebrow">
<span class="meta-label" style="font-size:11px;">SUBAGENT</span>
</div>
<div class="session-title">${escapeHTML(state.subagentDescription || state.subagentId)}</div>
<div class="session-meta-inline">
<span>${messages.length} messages</span>
</div>
</div>
`;
const messagesHTML = messages.map((msg, idx) => {
return renderMessage(msg, idx, { isSubagent: true });
}).join('');
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
// 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 `
<div class="msg meta" data-uuid="${msg.uuid}">
<div class="msg-meta-collapsed">
<button class="meta-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">${escapeHTML(preview)}</span>
</button>
<div class="meta-body">
${renderMarkdown(msg.text, { variant: 'compact', query: state.query })}
${truncated ? `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>` : ''}
</div>
</div>
</div>
`;
}
// 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]) => `
<div class="wf-card-phase">
<div class="wf-card-phase-title">${escapeHTML(phase)}</div>
${agentList.map(a => `
<button class="wf-card-agent" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
<span class="wf-card-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
${a.state === 'error' ? `<span class="wf-card-agent-state error">error</span>` : ''}
<span class="wf-card-agent-arrow">→</span>
</button>
`).join('')}
</div>
`).join('');
// Render other tool calls (non-workflow) if any
const otherTools = (msg.tool_calls || []).filter(tc => tc !== workflowCall).map(renderToolCall).join('');
return `
<div class="wf-card" data-uuid="${msg.uuid}">
<div class="wf-card-header">
<span class="wf-card-icon">⚙</span>
<span class="wf-card-name">${escapeHTML(wfName)}</span>
<span class="wf-card-count">${agents.length} agents</span>
${wf.status ? `<span class="wf-card-status ${wf.status}">${escapeHTML(wf.status)}</span>` : ''}
</div>
<div class="wf-card-body">${phasesHTML}</div>
</div>
${otherTools ? `<div class="msg assistant" data-uuid="${msg.uuid}-tools"><div class="msg-tools">${otherTools}</div></div>` : ''}
`;
}
// Standalone thinking message (no following assistant to attach to)
if (isThinking) {
return `
<div class="msg assistant" data-uuid="${msg.uuid}">
<div class="msg-thinking">
<button class="thinking-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body">${renderMarkdown(msg.text, { variant: 'msg', query: state.query })}</div>
</div>
</div>
`;
}
// Thinking block attached to this message (merged from preceding thinking messages)
let thinkingHTML = '';
if (msg._thinking) {
thinkingHTML = `
<div class="msg-thinking">
<button class="thinking-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body">${renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })}</div>
</div>
`;
}
const truncated = isTextTruncated(msg.text);
let textHTML = msg.text ? renderMarkdown(msg.text, { variant: 'msg', query: state.query }) : (tools ? '' : '<div class="msg-text empty-text">(no text content)</div>');
if (truncated) {
textHTML += `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>`;
}
let summaryHTML = '';
if (msg.summary) {
summaryHTML = `
<div class="msg-summary">
<button class="summary-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="label">Session summary</span>
<span class="source">${escapeHTML(msg.summary.source || '')}</span>
</button>
<div class="summary-body">${renderMarkdown(msg.summary.content, { variant: 'compact' })}</div>
</div>
`;
}
return `
<div class="msg ${isUser ? 'user' : 'assistant'}" data-uuid="${msg.uuid}">
<div class="msg-head">
<span class="role">${roleLabel}</span>
<span class="when">${msg.timestamp ? fmtClockTime(msg.timestamp) : ''}</span>
</div>
${thinkingHTML}
${textHTML}
${tools ? `<div class="msg-tools">${tools}</div>` : ''}
${summaryHTML}
</div>
`;
}
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 `
<div class="msg-tool agent-call">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">${escapeHTML(agentType)}</span>
<span class="tool-arg">${escapeHTML(description)}</span>
${isError ? '<span class="tool-error">error</span>' : ''}
${subagentId ? `<button class="agent-nav-btn" data-action="open-subagent" data-agent-id="${subagentId}" data-agent-desc="${escapeHTML(description)}">View conversation →</button>` : ''}
</button>
<div class="toolcall-body">
${parsed.prompt ? `<div class="tc-section">Prompt</div><div class="agent-prompt">${escapeHTML(parsed.prompt.slice(0, 500))}${parsed.prompt.length > 500 ? '…' : ''}</div>` : ''}
${resultContent ? `<div class="tc-section">Result</div><div class="agent-result">${renderMarkdown(resultContent, { variant: 'compact' })}</div>` : ''}
</div>
</div>
`;
}
// 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]) => `
<div class="workflow-phase-group">
<div class="workflow-phase-header">${escapeHTML(phase)}</div>
<div class="workflow-phase-agents">
${agentList.map(a => `
<button class="workflow-agent-row" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
<span class="workflow-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
<span class="workflow-agent-state ${a.state || ''}">${escapeHTML(a.state || '')}</span>
</button>
`).join('')}
</div>
</div>
`).join('');
const agentListHTML = agents.length ? `
<div class="tc-section">Agents · ${agents.length}</div>
<div class="workflow-agent-list">${phasesHTML}</div>
` : '';
return `
<div class="msg-tool agent-call">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">Workflow</span>
<span class="tool-arg">${escapeHTML(wfName)}</span>
${wfStatus ? `<span class="workflow-status ${wfStatus}">${escapeHTML(wfStatus)}</span>` : ''}
${isError ? '<span class="tool-error">error</span>' : ''}
</button>
<div class="toolcall-body">
${agentListHTML}
</div>
</div>
`;
}
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 `
<div class="msg-tool ${isError ? 'is-error' : ''}">
<button class="toolcall-toggle">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">${escapeHTML(tc.name)}</span>
<span class="tool-arg">${escapeHTML(argPreview)}</span>
${isError ? '<span class="tool-error">error</span>' : ''}
</button>
<div class="toolcall-body">
<div class="tc-section">Input</div>
<pre>${escapeHTML(tc.input_json || '')}</pre>
${tc.result ? `<div class="tc-section">${isError ? 'Error' : 'Output'}</div><pre>${escapeHTML(tc.result.content || '(empty)')}</pre>` : ''}
</div>
</div>
`;
}
// --- 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;
});
}
+137
View File
@@ -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 => `
<button class="sidebar-item ${state.projectFilter === p ? 'active' : ''}" data-project="${p}">
<span class="icon">${FOLDER_SVG}</span>
<span class="label">${escapeHTML(formatProjectLabel(p))}</span>
<span class="badge">${counts[p] || 0}</span>
</button>
`).join('') || `<div style="padding:8px 10px;font-size:11px;color:var(--muted-2);">No projects</div>`;
}
}
// --- 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 = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><button class="crumb" data-action="goto-session-detail">${escapeHTML((s.title || s.id).slice(0, 30))}</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML((state.subagentDescription || '').slice(0, 40))}</span>`;
} else {
bc.innerHTML = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(s.title || s.id)}</span>`;
}
} else {
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-sessions">Sessions</button>`;
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
bc.innerHTML = html;
}
} else if (state.route === 'usage') {
bc.innerHTML = `<span class="crumb terminal">Usage</span>`;
} else {
if (state.mode === 'detail') {
const m = state.memories.find(x => x.id === state.detailId);
if (!m) return;
bc.innerHTML = `<button class="crumb" data-action="goto-memory">Memory</button><span class="crumb-sep">/</span><span class="crumb terminal filename">${escapeHTML(m.path.split('/').pop())}</span>`;
} else {
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-memory">Memory</button>`;
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
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 = `<span class="app-name">${appName}</span><span class="sep">—</span><span class="scope">${escapeHTML(truncated)}</span>`;
titleEl.title = `${appName}${scopeText}`;
}
document.title = `${appName}${scopeText}`;
}
+34
View File
@@ -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 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
+573
View File
@@ -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 `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="2" class="heatmap-cell level-${c.level}" data-label="${fmtTokens(c.tokens)} tokens on ${fmtTooltipDate(c.key)}"></rect>`;
}).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 =>
`<text x="${m.col * step}" y="${gridHeight + 14}" class="heatmap-month">${m.label}</text>`
).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 = `
<div class="usage-header">
<span class="usage-title">Token activity</span>
<div class="usage-view-tabs">
<button class="usage-tab active" data-view="daily">Daily</button>
<button class="usage-tab" data-view="weekly">Weekly</button>
<button class="usage-tab" data-view="cumulative">Cumulative</button>
</div>
</div>
<div class="usage-stats">
<div class="usage-stat">
<span class="usage-stat-value">${fmtTokens(totalTokens)}</span>
<span class="usage-stat-label">Lifetime tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${peakDay ? fmtTokens(peakDay.tokens) : '—'}</span>
<span class="usage-stat-label">Peak tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'}</span>
<span class="usage-stat-label">Longest task</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${currentStreak}d</span>
<span class="usage-stat-label">Current streak</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">${longestStreak}d</span>
<span class="usage-stat-label">Longest streak</span>
</div>
</div>
<div class="heatmap-container">
<svg class="heatmap" width="${gridWidth}" height="${gridHeight + 20}" viewBox="0 0 ${gridWidth} ${gridHeight + 20}">
${cellsHTML}
${monthLabelsHTML}
</svg>
<div class="heatmap-legend">
<span class="heatmap-legend-label">Less</span>
<svg width="70" height="11"><rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/><rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/><rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/><rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/><rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/></svg>
<span class="heatmap-legend-label">More</span>
</div>
</div>
<div class="chart-container" id="usage-chart" style="display:none;"></div>
<div class="day-sessions" id="day-sessions"></div>
`;
// 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 = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)} — no sessions</div>`;
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 = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)}</div><div class="day-activity-timeline">`;
if (newWorkspaces.length) {
html += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon workspace">★</span>
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newWorkspaces.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
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 += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newSessions.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (continued.length) {
html += `
<div class="activity-group continued">
<div class="activity-group-header">
<span class="activity-icon continued">↳</span>
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${continued.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
html += `</div>`;
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 = `<div class="day-sessions-header">${headerText}</div><div class="day-activity-timeline">`;
if (newWorkspaces.length) {
html += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon workspace">★</span>
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newWorkspaces.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
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 += `
<div class="activity-group">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${newSessions.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (continued.length) {
html += `
<div class="activity-group continued">
<div class="activity-group-header">
<span class="activity-icon continued">↳</span>
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
</div>
<div class="activity-group-items">
${continued.map(s => `
<button class="activity-item" data-session-id="${s.id}">
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
</button>
`).join('')}
</div>
</div>
`;
}
if (!classified.length) html += `<div style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>`;
html += `</div>`;
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 = '<div class="empty">No data</div>'; 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 `<rect x="${x}" y="${chartHeight - h}" width="${barWidth}" height="${Math.max(h, 0.5)}" rx="2" class="bar-fill" data-label="Week of ${w.weekStart.toISOString().slice(0, 10)}: ${fmtTokens(w.tokens)}"></rect>`;
}).join('');
const labelsHTML = labels.map(l => {
const x = l.i * (barWidth + barGap);
return `<text x="${x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`;
}).join('');
container.innerHTML = `
<div class="chart-tooltip" id="chart-tooltip"></div>
<svg class="weekly-chart" viewBox="0 0 ${chartWidth + 20} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet">
${barsHTML}
${labelsHTML}
</svg>
`;
// 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 = '<div class="empty">No data</div>'; 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 => `<text x="${l.x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`).join('');
// Invisible hover dots for tooltip
const dotsHTML = points.map((p, i) => {
return `<circle cx="${xScale(i).toFixed(1)}" cy="${yScale(p.total).toFixed(1)}" r="6" class="cumulative-dot" data-label="${p.day}: ${fmtTokens(p.total)} total"/>`;
}).join('');
container.innerHTML = `
<div class="chart-tooltip" id="chart-tooltip-cum"></div>
<svg viewBox="0 0 ${chartWidth} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet" class="cumulative-chart">
<path d="${areaPath}" class="cumulative-area"/>
<path d="${linePath}" class="cumulative-line"/>
${dotsHTML}
${labelsHTML}
</svg>
`;
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'));
});
}
+194
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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 => `<mark>${m}</mark>`);
}
export function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
}
+552
View File
@@ -0,0 +1,552 @@
<script setup>
import { computed, watch, ref, provide } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
IS_MAC,
FOLDER_SVG,
setRoute,
setView,
setProject,
setQuery,
setProjectSearch,
toggleSort,
toggleIncludeMessageBodies
} from './store.js';
import { formatProjectLabel } from './utils.js';
import { buildSidebarProjects } from './sidebar-projects.mjs';
const router = useRouter();
const route = useRoute();
// --- Sidebar data ---
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
const totalMemoryCount = computed(() => state.memories.length);
const sessionCount = computed(() => state.sessions.length);
const currentRouteType = computed(() => {
const name = route.name;
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
if (name === 'Activity') return 'activity';
if (name === 'Recap' || name === 'RecapDetail') return 'recap';
if (name === 'Settings') return 'settings';
return 'memory';
});
const sidebarProjectsForCurrentScope = (search = state.projectSearch) => buildSidebarProjects({
routeType: currentRouteType.value,
sessions: state.sessions,
memories: state.memories,
projects: state.projects,
view: state.view,
search,
formatProjectLabel,
});
const sidebarProjects = computed(() => sidebarProjectsForCurrentScope());
const NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i;
const normalProjects = computed(() => sidebarProjects.value.filter(p => p.count > 1 || !NOISE_PROJECT_RE.test(p.label)));
const noiseProjects = computed(() => sidebarProjects.value.filter(p => p.count <= 1 && NOISE_PROJECT_RE.test(p.label)));
const showNoiseProjects = ref(false);
const totalProjectCount = computed(() => {
return sidebarProjectsForCurrentScope('').length;
});
// --- Toolbar visibility ---
const showToolbar = computed(() => {
const r = route.name;
return r === 'SessionList' || r === 'MemoryList';
});
const showSearchMsgsToggle = computed(() => {
return route.name === 'SessionList';
});
// --- Window title ---
const windowTitle = computed(() => {
const appName = 'Obelisk';
let scopeText = '';
if (route.name === 'Activity') {
scopeText = 'Activity';
} else if (route.name === 'Recap') {
scopeText = 'Recap';
} else if (route.name === 'RecapDetail') {
scopeText = `Recap · ${route.params.id}`;
} else if (route.name === 'Settings') {
scopeText = 'Settings';
} else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id);
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
} else {
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
scopeText = `Sessions${proj}`;
}
} else {
if (route.name === 'MemoryDetail') {
const m = state.memories.find(x => x.id === route.params.id);
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}`;
}
}
return { appName, scopeText };
});
watch(() => windowTitle.value.scopeText, (scopeText) => {
document.title = `${windowTitle.value.appName}${scopeText}`;
}, { immediate: true });
// --- Navigation helpers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'activity') {
router.push('/activity');
} else if (routeName === 'recap') {
router.push('/recap');
} else {
router.push('/memory');
}
}
function handleSidebarView(view) {
setView(view);
router.push('/memory');
}
function handleClearProject() {
setProject('all');
}
function handleSidebarProject(slug) {
setProject(slug);
if (currentRouteType.value === 'sessions') router.push('/sessions');
else router.push('/memory');
}
function handleProjectSearch(e) {
setProjectSearch(e.target.value);
}
// --- Search ---
let searchTimer = null;
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setQuery(value);
}, 200);
}
function handleToggleSort() {
toggleSort();
}
function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
// --- Keep-alive includes ---
const keepAliveIncludes = ['SessionDetail'];
const isExportRoute = computed(() => route.name === 'RecapExport');
// --- Source health dots ---
const sourceDots = ref([]);
const sourceDetails = ref([]);
const showSourcePopover = ref(false);
async function loadSourceDots() {
if (!window.obelisk?.getSettings) return;
const s = await window.obelisk.getSettings();
sourceDots.value = (s.sources || []).map(src => ({ id: src.id, status: src.status }));
sourceDetails.value = s.sources || [];
}
loadSourceDots();
// --- Recap ---
const recapGenerateOpen = ref(false);
function setRecapKind(k) {
router.replace({ path: '/recap', query: { kind: k } });
}
// --- Source filter ---
const showSourceFilter = ref(false);
const sourceFilterActive = computed(() => state.sourceFilter !== 'all' && state.sourceFilter !== undefined);
const sourceFilterLabel = computed(() => {
if (!state.sourceFilter || state.sourceFilter === 'all') return 'All sources';
return state.sourceFilter === 'claude' ? 'Claude Code' : 'Codex';
});
function toggleSourceFilter() { showSourceFilter.value = !showSourceFilter.value; }
function setSourceFilter(id) {
state.sourceFilter = id;
showSourceFilter.value = false;
}
provide('recapGenerateOpen', recapGenerateOpen);
</script>
<template>
<router-view v-if="isExportRoute" />
<div class="app" v-else>
<div class="titlebar">
<div class="titlebar-text" id="titlebar-text">
<span class="app-name">{{ windowTitle.appName }}</span>
<span class="sep"></span>
<span class="scope">{{ windowTitle.scopeText }}</span>
</div>
</div>
<div class="columns">
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="icon-aurora" cx="50%" cy="62%" r="55%">
<stop offset="0%" stop-color="#ec4899" stop-opacity="0.8"/>
<stop offset="45%" stop-color="#a855f7" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="icon-stone-lit" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cbd5e1"/>
<stop offset="100%" stop-color="#475569"/>
</linearGradient>
</defs>
<ellipse cx="20" cy="22" rx="15" ry="11" fill="url(#icon-aurora)"/>
<ellipse cx="20" cy="21" rx="9" ry="7" fill="url(#icon-aurora)" opacity="0.7"/>
<circle cx="8" cy="13" r="0.7" fill="#fff" opacity="0.9"/>
<circle cx="32" cy="11" r="0.9" fill="#fff" opacity="0.95"/>
<circle cx="34" cy="22" r="0.5" fill="#fff" opacity="0.7"/>
<polygon points="20,7 16.5,12 23.5,12" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 16.5,12 17.5,33 20,33" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 23.5,12 22.5,33 20,33" fill="#1e293b"/>
<rect x="15.5" y="33" width="9" height="1.6" rx="0.3" fill="#0f172a"/>
</svg>
<span class="name">Obelisk</span>
<button class="source-health" title="Connected sources" @click="showSourcePopover = !showSourcePopover">
<span v-for="src in sourceDots" :key="src.id" class="h-dot" :class="src.id + '-' + src.status"></span>
</button>
<div class="sources-popover" :class="{ show: showSourcePopover }">
<div class="sp-head">Connected sources</div>
<div class="sp-list">
<button v-for="src in sourceDetails" :key="src.id" class="sp-row" @click="router.push('/settings')">
<span class="sp-dot" :class="src.id"></span>
<div class="sp-body">
<div class="sp-name">{{ src.name }} <span class="sp-count" v-if="src.sessionCount">{{ src.sessionCount }} sessions</span></div>
<div class="sp-meta" :class="src.status">{{ src.statusText }}</div>
</div>
</button>
</div>
<div class="sp-foot">
<button @click="router.push('/settings'); showSourcePopover = false">Manage in Settings </button>
</div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<button
class="sidebar-item"
:class="{ active: currentRouteType === 'sessions' && state.projectFilter === 'all' }"
@click="handleSidebarRoute('sessions')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z" stroke-linejoin="round"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<button
class="sidebar-item"
:class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2.5" y="2.5" width="11" height="11" rx="2"/>
<path d="M5 8h6M5 5.5h6M5 10.5h4" stroke-linecap="round"/>
</svg>
<span class="label">Memory</span>
<span class="badge">{{ totalMemoryCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')"
>
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2" fill="currentColor"/>
</svg>
<span class="label">Active</span>
<span class="badge">{{ activeCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: currentRouteType === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
@click="handleSidebarView('archived')"
>
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2"/>
</svg>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button
class="sidebar-item"
:class="{ active: route.name === 'Activity' }"
@click="handleSidebarRoute('activity')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="10" width="2.5" height="4"/>
<rect x="6" y="6" width="2.5" height="8"/>
<rect x="10" y="3" width="2.5" height="11"/>
</svg>
<span class="label">Activity</span>
</button>
<button
class="sidebar-item"
:class="{ active: route.name === 'Recap' }"
@click="handleSidebarRoute('recap')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 2h10v12H3z"/>
<path d="M6 5h4M6 8h4M6 11h2"/>
</svg>
<span class="label">Recap</span>
</button>
</div>
<div class="sidebar-section projects" v-if="currentRouteType === 'sessions' || currentRouteType === 'memory'">
<div class="sidebar-section-title">
<span>Projects</span>
<button v-if="noiseProjects.length" class="filter-toggle" :class="{ active: showNoiseProjects }" @click.stop="showNoiseProjects = !showNoiseProjects">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 6h8M2 3h8M2 9h5"/>
</svg>
{{ showNoiseProjects ? 'hide noise' : 'show all' }}
</button>
</div>
<div class="sidebar-search" v-if="totalProjectCount >= 6">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input
type="text"
placeholder="Filter projects…"
autocomplete="off"
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div class="sidebar-list" id="sidebar-projects">
<button
v-for="p in normalProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2 5.5V12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 14 12V6.5A1.5 1.5 0 0 0 12.5 5H8.3L7 3.5H3.5A1.5 1.5 0 0 0 2 5z"/>
</svg>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<!-- Noise projects fold -->
<button v-if="noiseProjects.length" class="project-fold" :class="{ expanded: showNoiseProjects }" @click="showNoiseProjects = !showNoiseProjects">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span class="label">{{ noiseProjects.length }} test projects hidden</span>
<span class="count">{{ noiseProjects.length }}</span>
</button>
<template v-if="showNoiseProjects">
<button
v-for="p in noiseProjects"
:key="p.slug"
class="sidebar-item noise"
:class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2 5.5V12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 14 12V6.5A1.5 1.5 0 0 0 12.5 5H8.3L7 3.5H3.5A1.5 1.5 0 0 0 2 5z"/>
</svg>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
</template>
</div>
</div>
<div class="sidebar-section sidebar-bottom">
<button
class="sidebar-item"
:class="{ active: route.name === 'Settings' }"
@click="router.push('/settings')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<line x1="3" y1="4" x2="13" y2="4"/>
<line x1="3" y1="8" x2="13" y2="8"/>
<line x1="3" y1="12" x2="13" y2="12"/>
<circle cx="9.5" cy="4" r="1.7" fill="var(--bg)"/>
<circle cx="5.5" cy="8" r="1.7" fill="var(--bg)"/>
<circle cx="11" cy="12" r="1.7" fill="var(--bg)"/>
</svg>
<span class="label">Settings</span>
</button>
</div>
</aside>
<main class="main">
<div class="toolbar">
<div class="breadcrumb" id="breadcrumb">
<template v-if="showToolbar">
<template v-if="state.projectFilter !== 'all'">
<button class="crumb" @click="handleClearProject">
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
</button>
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
</template>
<template v-else>
<span class="crumb terminal">
{{ state.route === 'sessions' ? 'Sessions' : state.route === 'memory' ? 'Memory' : 'Activity' }}
</span>
</template>
</template>
<template v-else>
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
Sessions
</router-link>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
</router-link>
</template>
<template v-if="route.name === 'SessionDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
</span>
</template>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.agentId }}</span>
</template>
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
Memory
</router-link>
<template v-if="route.name === 'MemoryDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal filename">
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
</span>
</template>
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
<span v-if="route.name === 'Recap'" class="crumb terminal">Recap</span>
<span v-if="route.name === 'Settings'" class="crumb terminal">Settings</span>
<router-link v-if="route.name === 'RecapDetail'" class="crumb" to="/recap">Recap</router-link>
<template v-if="route.name === 'RecapDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.id }}</span>
</template>
</template>
</div>
<div class="toolbar-spacer"></div>
<!-- Recap toolbar actions -->
<template v-if="route.name === 'Recap'">
<div class="tab-group">
<button :class="{ active: (route.query.kind || 'weekly') === 'weekly' }" @click="setRecapKind('weekly')">Weekly</button>
<button :class="{ active: route.query.kind === 'monthly' }" @click="setRecapKind('monthly')">Monthly</button>
</div>
<button class="toolbar-action-primary" @click="recapGenerateOpen = true">
<span class="plus">+</span>
<span>Generate</span>
</button>
</template>
<!-- Source filter (session list only, multi-source) -->
<div v-if="showToolbar && route.name === 'SessionList' && sourceDots.length > 1" class="source-filter-wrap">
<button class="filter-btn" :class="{ active: sourceFilterActive }" @click="toggleSourceFilter">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 3h8M3.5 6h5M5 9h2"/>
</svg>
<span class="filter-label">{{ sourceFilterLabel }}</span>
</button>
<div class="filter-dropdown" :class="{ show: showSourceFilter }">
<div
v-for="src in sourceDots" :key="src.id"
class="fd-row" :class="{ checked: state.sourceFilter === 'all' || state.sourceFilter === src.id }"
@click.stop="setSourceFilter(src.id)"
>
<div class="fd-check">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6l2.5 2.5 4.5-5"/></svg>
</div>
<span class="fd-name">{{ src.id === 'claude' ? 'Claude Code' : 'Codex' }}</span>
</div>
<div class="fd-divider"></div>
<div class="fd-row" :class="{ checked: state.sourceFilter === 'all' }" @click.stop="setSourceFilter('all')">
<div class="fd-check">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 6l2.5 2.5 4.5-5"/></svg>
</div>
<span class="fd-name" style="color: var(--accent-2);">All sources</span>
</div>
</div>
</div>
<div class="toolbar-search" id="search-wrap" v-if="showToolbar">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input
id="search"
type="text"
placeholder="Search…"
autocomplete="off"
:value="state.query"
@input="handleSearch"
/>
<span class="toolbar-search-kbd">/</span>
</div>
<button
v-if="showToolbar"
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
id="sort-toggle"
title="Toggle sort (S)"
>
<span class="label" id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path class="arrow-up" d="M5 6l3-3 3 3"/>
<path class="arrow-down" d="M5 10l3 3 3-3"/>
</svg>
</button>
</div>
<router-view v-slot="{ Component }">
<keep-alive :include="['SessionDetail']">
<component :is="Component" />
</keep-alive>
</router-view>
</main>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
<script setup>
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
FOLDER_SVG,
setRoute,
setView,
setProject,
setProjectSearch
} from '../store.js';
import { formatProjectLabel } from '../utils.js';
const router = useRouter();
const route = useRoute();
// --- Counts ---
const sessionCount = computed(() => state.sessions.length);
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
const totalMemoryCount = computed(() => state.memories.length);
// --- Projects list ---
const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (state.route === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived;
});
let projects = [...new Set(filtered.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)));
// Count per project
const counts = {};
for (const item of filtered) {
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
}
return projects.map(p => ({
slug: p,
label: formatProjectLabel(p),
count: counts[p] || 0
}));
});
// --- Active state helpers ---
function isSessionsActive() {
return state.route === 'sessions' && state.projectFilter === 'all';
}
function isMemoryViewActive(view) {
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
}
function isActivityActive() {
return state.route === 'activity';
}
function isProjectActive(slug) {
return state.projectFilter === slug;
}
// --- Navigation handlers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'activity') {
router.push('/activity');
} else {
router.push('/memory');
}
}
function handleSidebarView(view) {
setView(view);
router.push('/memory');
}
function handleSidebarProject(slug) {
setProject(slug);
if (state.route === 'sessions') router.push('/sessions');
else router.push('/memory');
}
function handleProjectSearch(e) {
setProjectSearch(e.target.value);
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
<defs>
<linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#a78bfa"/>
<stop offset="1" stop-color="#6366f1"/>
</linearGradient>
</defs>
</svg>
<span class="name">Obelisk</span>
</div>
<!-- Library section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<button
class="sidebar-item"
:class="{ active: isSessionsActive() }"
@click="handleSidebarRoute('sessions')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M5 1v4M11 1v4"/>
</svg>
</span>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<!-- Memory parent (non-clickable label) -->
<div class="sidebar-section-title" style="padding-top: 8px;">
<span>Memory</span>
<span class="badge">{{ totalMemoryCount }}</span>
</div>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('active') }"
@click="handleSidebarView('active')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="8" cy="8" r="5.5"/>
<path d="M8 5v3l2 1.5"/>
</svg>
</span>
<span class="label">Active</span>
<span class="badge">{{ activeCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('archived') }"
@click="handleSidebarView('archived')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/>
<path d="M1.5 3.5h13v2h-13z"/>
<path d="M6 8h4"/>
</svg>
</span>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
</div>
<!-- Stats section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button
class="sidebar-item"
:class="{ active: isActivityActive() }"
@click="handleSidebarRoute('usage')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
</svg>
</span>
<span class="label">Activity</span>
</button>
</div>
<!-- Projects section -->
<div class="sidebar-section projects">
<div class="sidebar-section-title">
<span>Projects</span>
</div>
<div class="sidebar-search">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5L14 14"/>
</svg>
<input
type="text"
placeholder="Filter..."
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div class="sidebar-list">
<button
v-for="p in sidebarProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: isProjectActive(p.slug) }"
@click="handleSidebarProject(p.slug)"
>
<span class="icon" v-html="FOLDER_SVG"></span>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<div v-if="!sidebarProjects.length" class="sidebar-empty">
No projects
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
border-right: 1px solid var(--hairline-strong);
background: rgba(0,0,0,0.2);
display: flex; flex-direction: column;
min-height: 0;
}
.sidebar-brand {
display: flex; align-items: center; gap: 8px;
padding: 0 14px; height: 36px;
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-section-title {
padding: 4px 10px 6px;
font-size: 10.5px; color: var(--muted);
font-weight: 500; letter-spacing: 0.04em;
display: flex; justify-content: space-between;
flex-shrink: 0;
}
.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: 50%; 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;
border: none; background: none;
}
.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; }
.sidebar-empty {
padding: 8px 10px;
font-size: 11px;
color: var(--muted-2);
}
</style>
+381
View File
@@ -0,0 +1,381 @@
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { state, toggleSort, setQuery, toggleIncludeMessageBodies } from '../store.js';
import { formatProjectLabel } from '../utils.js';
// --- Route info ---
const route = useRoute();
const isListView = computed(() => {
return route.name === 'SessionList' || route.name === 'MemoryList';
});
const showSearchMsgsToggle = computed(() => {
return route.name === 'SessionList';
});
// --- Breadcrumb computation ---
const breadcrumbs = computed(() => {
const name = route.name;
const crumbs = [];
if (name === 'SessionList') {
crumbs.push({ label: 'Sessions', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'SessionDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: s?.title || route.params.id, terminal: true });
} else if (name === 'SubagentDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: (s?.title || '').slice(0, 30) || route.params.id, to: `/sessions/${route.params.id}` });
crumbs.push({ label: route.params.agentId, terminal: true });
} else if (name === 'MemoryList') {
crumbs.push({ label: 'Memory', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'MemoryDetail') {
crumbs.push({ label: 'Memory', to: '/memory' });
const m = state.memories.find(x => x.id === route.params.id);
const filename = (m?.path || '').split('/').pop();
crumbs.push({ label: filename, terminal: true, filename: true });
} else if (name === 'Activity') {
crumbs.push({ label: 'Activity', terminal: true });
} else if (name === 'Recap') {
crumbs.push({ label: 'Recap', terminal: true });
}
return crumbs;
});
// --- Search ---
const searchInput = ref(null);
let searchTimer = null;
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setQuery(value);
}, 200);
}
// --- Keyboard shortcut: / to focus search ---
function handleKeydown(e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
searchInput.value?.focus();
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(searchTimer);
});
// --- Sort ---
function handleToggleSort() {
toggleSort();
}
function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
</script>
<template>
<div class="toolbar">
<div class="breadcrumb">
<template v-for="(crumb, i) in breadcrumbs" :key="i">
<span v-if="i > 0" class="crumb-sep">/</span>
<router-link
v-if="crumb.to"
class="crumb"
:to="crumb.to"
>
{{ crumb.label }}
</router-link>
<span
v-else
class="crumb terminal"
:class="{ filename: crumb.filename }"
>
{{ crumb.label }}
</span>
</template>
</div>
<div class="toolbar-spacer"></div>
<!-- Search + sort controls (list views only) -->
<template v-if="isListView">
<div class="toolbar-search">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="6.5" cy="6.5" r="4"/>
<path d="M10 10l3.5 3.5"/>
</svg>
<input
ref="searchInput"
type="text"
placeholder="Search..."
@input="handleSearch"
/>
<span class="toolbar-search-kbd">/</span>
</div>
<button
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M2 5.5l6 3.5 6-3.5"/>
</svg>
</button>
<button
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
>
<span class="label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path class="arrow-up" d="M8 3v5M5.5 5.5L8 3l2.5 2.5"/>
<path class="arrow-down" d="M8 8v5M5.5 10.5L8 13l2.5-2.5"/>
</svg>
</button>
</template>
</div>
</template>
<style scoped>
.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);
}
.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;
background: transparent;
cursor: pointer;
}
.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;
border: none;
background: transparent;
}
.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;
}
</style>
@@ -0,0 +1,78 @@
<script setup>
defineProps({
headline: String,
receipts: Array,
stats: Array,
mostSaidPhrase: String,
signoff: String,
idx: { type: Number, default: 5 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card card-closing">
<div class="eyebrow">
<span class="diamond"></span>
<span>The week, carved.</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="closing-body">
<div class="closing-headline">{{ headline }}</div>
<div class="closing-stats" v-if="receipts || stats">
<div v-for="(line, i) in (receipts || stats || [])" :key="i">{{ line }}</div>
</div>
<div class="closing-quote" v-if="mostSaidPhrase">
<span>"{{ mostSaidPhrase }}"</span>
<span class="verb"> most-said phrase</span>
</div>
<div class="closing-signoff">{{ signoff }}</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.card-closing {
background:
radial-gradient(80% 70% at 50% 30%, var(--tg-soft) 0%, transparent 60%),
radial-gradient(60% 50% at 50% 50%, var(--tg-mid) 0%, transparent 70%),
linear-gradient(180deg, rgba(10,11,20,0.6) 0%, rgba(10,11,20,0.95) 100%);
transition: background var(--theme-ease);
}
.closing-body {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 0 40px; gap: 32px;
position: relative; z-index: 1;
}
.closing-headline {
font-family: var(--font-serif); font-size: 72px;
line-height: 1; font-weight: 500; letter-spacing: -0.02em;
color: var(--fg); text-shadow: 0 4px 24px var(--tg);
transition: text-shadow var(--theme-ease);
}
.closing-stats {
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
font-variant-numeric: tabular-nums;
display: flex; flex-direction: column; gap: 4px;
}
.closing-quote {
font-family: var(--font-serif); font-style: italic;
font-size: 19px; color: var(--fg-2); line-height: 1.5; max-width: 360px;
}
.closing-quote .verb {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted); display: block; margin-top: 8px;
}
.closing-signoff {
font-family: var(--font-serif); font-size: 15px;
color: var(--muted); font-style: italic;
}
</style>
@@ -0,0 +1,143 @@
<script setup>
import { computed } from 'vue';
import { CORNER_SEALS } from './seals.js';
const props = defineProps({
archKey: String,
badge: String,
title: String,
claim: String,
subtitle: String,
activity: Array,
footer: String,
idx: { type: Number, default: 1 },
total: { type: Number, default: 5 },
});
const sealSvg = computed(() => CORNER_SEALS[props.archKey] || '');
</script>
<template>
<article class="card card-cover">
<div class="cover-stars">
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="eyebrow">
<span class="diamond"></span>
<span>{{ badge }}</span>
</div>
<div class="cover-seal-corner" v-html="sealSvg"></div>
<div class="cover-body">
<div class="cover-archetype">{{ title }}</div>
<div class="cover-subtitle">{{ claim || subtitle }}</div>
<div class="cover-activity">
<div class="cover-activity-row">
<div
v-for="(val, i) in activity" :key="i"
class="cover-activity-cell"
:class="{ dim: val < 0.4 }"
>
<div v-if="val > 0" class="fill" :style="{ height: val * 100 + '%' }"></div>
</div>
</div>
<div class="cover-activity-labels">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
</div>
</div>
<div class="cover-footer" v-html="footer"></div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.card-cover {
background:
radial-gradient(120% 80% at 50% 100%, var(--tg) 0%, transparent 55%),
radial-gradient(100% 70% at 50% 80%, var(--tg-mid) 0%, transparent 65%),
radial-gradient(80% 50% at 50% 60%, var(--tg-soft) 0%, transparent 65%),
linear-gradient(180deg, rgba(10,11,20,0.4) 0%, rgba(10,11,20,0.85) 70%);
transition: background var(--theme-ease);
}
.cover-stars {
position: absolute; inset: 0; pointer-events: none;
}
.cover-stars span {
position: absolute;
width: 1.5px; height: 1.5px;
background: rgba(255,255,255,0.85);
border-radius: 50%;
box-shadow: 0 0 4px rgba(255,255,255,0.6);
}
.cover-stars span:nth-child(1) { top: 12%; left: 18%; }
.cover-stars span:nth-child(2) { top: 8%; left: 78%; width: 2px; height: 2px; }
.cover-stars span:nth-child(3) { top: 22%; left: 88%; opacity: 0.6; }
.cover-stars span:nth-child(4) { top: 32%; left: 8%; opacity: 0.5; }
.cover-stars span:nth-child(5) { top: 18%; left: 52%; width: 1px; height: 1px; opacity: 0.7; }
.cover-seal-corner {
position: absolute;
top: 22px; right: 24px;
width: 60px; height: 60px; z-index: 3;
}
.cover-seal-corner :deep(svg) {
width: 100%; height: 100%;
filter: drop-shadow(0 0 12px var(--tg));
transition: filter var(--theme-ease);
}
.cover-body {
flex: 1; display: flex; flex-direction: column;
padding: 0 36px; position: relative; z-index: 1;
}
.cover-archetype {
margin-top: auto;
font-family: var(--font-serif);
font-size: 64px; line-height: 1.05; font-weight: 500;
letter-spacing: -0.02em; color: var(--fg);
margin-bottom: 18px;
text-shadow: 0 2px 24px rgba(0,0,0,0.4);
}
.cover-subtitle {
font-family: var(--font-serif); font-style: italic;
font-size: 19px; line-height: 1.5; color: var(--fg-2);
margin-bottom: 36px; max-width: 92%;
}
.cover-activity { margin-bottom: 28px; }
.cover-activity-row {
display: grid; grid-template-columns: repeat(7, 1fr);
gap: 6px; margin-bottom: 8px;
}
.cover-activity-cell {
height: 32px; border-radius: 3px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline);
position: relative; overflow: hidden;
}
.cover-activity-cell .fill {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(to top, var(--tc), var(--tc-2));
box-shadow: 0 0 10px var(--tg);
border-radius: 0 0 2px 2px;
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.cover-activity-cell.dim .fill {
background: linear-gradient(to top, rgba(255,255,255,0.15), rgba(255,255,255,0.06));
box-shadow: none;
}
.cover-activity-labels {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted-2); text-align: center;
}
.cover-footer {
padding-bottom: 28px;
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
display: flex; gap: 14px; font-variant-numeric: tabular-nums;
}
.cover-footer :deep(strong) { color: var(--fg); font-weight: 500; }
.cover-footer :deep(.sep) { color: var(--muted-3); }
</style>
@@ -0,0 +1,94 @@
<script setup>
defineProps({
title: String,
items: Array,
idx: { type: Number, default: 2 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Your thinking path</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="timeline-wrap">
<div class="timeline">
<div v-for="(item, i) in items" :key="i" class="tl-item">
<div class="tl-node"></div>
<div class="tl-day">{{ item.day }}</div>
<div class="tl-prompt">{{ item.prompt }}</div>
<div class="tl-outcome">
<span>{{ item.turn || item.outcome }}</span>
</div>
</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.timeline-wrap {
flex: 1; padding: 0 36px 24px 36px;
overflow-y: auto; position: relative; z-index: 1;
}
.timeline { position: relative; padding-left: 28px; }
.timeline::before {
content: ''; position: absolute;
left: 6px; top: 14px; bottom: 14px; width: 1px;
background: linear-gradient(to bottom,
var(--tg) 0%, var(--tg-mid) 30%,
rgba(255,255,255,0.12) 70%, rgba(255,255,255,0.06) 100%);
transition: background var(--theme-ease);
}
.tl-item { position: relative; padding: 8px 0 10px; }
.tl-item:first-child { padding-top: 4px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-node {
position: absolute; left: -28px; top: 16px;
width: 13px; height: 13px;
}
.tl-item:first-child .tl-node { top: 12px; }
.tl-node::before {
content: ''; position: absolute;
left: 50%; top: 50%;
width: 7px; height: 7px;
background: var(--tc);
transform: translate(-50%, -50%) rotate(45deg);
box-shadow: 0 0 8px var(--tg), 0 0 0 3px rgba(10,11,20,1);
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.tl-day {
display: inline-block;
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
color: var(--tc-2); margin-bottom: 4px; letter-spacing: 0.01em;
transition: color var(--theme-ease);
}
.tl-prompt {
font-family: var(--font-serif); font-style: italic;
font-size: 16px; line-height: 1.35; color: var(--fg); margin-bottom: 6px;
}
.tl-prompt::before { content: '\201C'; color: var(--muted-2); margin-right: 1px; }
.tl-prompt::after { content: '\201D'; color: var(--muted-2); margin-left: 1px; }
.tl-outcome {
display: inline-flex; align-items: baseline; gap: 8px;
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2);
padding: 4px 10px;
border-radius: 4px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--hairline);
border-left: 2px solid var(--tc);
box-shadow: -2px 0 8px -2px var(--tg-mid);
transition: border-left-color var(--theme-ease), box-shadow var(--theme-ease);
}
</style>
@@ -0,0 +1,133 @@
<script setup>
defineProps({
title: String,
voiceLines: Array,
observations: Array,
meter: Object,
quote: Object,
idx: { type: Number, default: 3 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Your vibe this week</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="vibe-content">
<div class="vibe-section">
<div class="section-label">Things you kept saying</div>
<div class="vibe-observations">
<div v-for="(obs, i) in (voiceLines || observations || [])" :key="i" class="vibe-obs">
<div class="vibe-obs-text">{{ obs.text }}</div>
<div class="vibe-obs-meta">
<template v-if="obs.count">×{{ obs.count }} · </template>
{{ obs.label }}
<template v-if="obs.time"> · {{ obs.time }}</template>
</div>
</div>
</div>
</div>
<div class="vibe-section" v-if="meter">
<div class="vibe-meter">
<div class="vibe-meter-track">
<div class="vibe-meter-fill" :style="{ width: meter.value * 100 + '%' }"></div>
</div>
<div class="vibe-meter-row">
<span class="vibe-meter-label">{{ meter.label }}</span>
<span class="vibe-meter-caption">{{ meter.caption }}</span>
</div>
</div>
</div>
<div class="vibe-quote" v-if="quote">
<div class="vibe-quote-text">{{ quote.text }}</div>
<div class="vibe-quote-caption" v-if="quote.caption"> {{ quote.caption }}</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.vibe-content {
flex: 1; padding: 0 36px 32px;
display: flex; flex-direction: column; gap: 22px;
overflow-y: auto; position: relative; z-index: 1;
}
.vibe-section { display: flex; flex-direction: column; gap: 12px; }
.vibe-observations { display: flex; flex-direction: column; gap: 10px; }
.vibe-obs {
display: flex; align-items: baseline; gap: 12px;
padding: 10px 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--hairline);
border-left: 2px solid var(--tg-mid);
border-radius: 4px;
transition: border-left-color var(--theme-ease);
}
.vibe-obs-text {
font-family: var(--font-serif); font-style: italic;
font-size: 18px; line-height: 1.4; color: var(--fg); flex: 1;
}
.vibe-obs-text::before { content: '\201C'; color: var(--muted-2); }
.vibe-obs-text::after { content: '\201D'; color: var(--muted-2); }
.vibe-obs-meta {
font-family: var(--font-mono); font-size: 12px; color: var(--muted);
white-space: nowrap; flex-shrink: 0; font-variant-numeric: tabular-nums;
}
.vibe-correction {
font-family: var(--font-serif); font-size: 14.5px;
line-height: 1.6; color: var(--fg-2);
}
.vibe-correction :deep(strong) { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.vibe-correction :deep(.vs) { color: var(--muted); font-style: italic; margin: 0 6px; }
.vibe-meter { display: flex; flex-direction: column; gap: 8px; }
.vibe-meter-track {
position: relative; height: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline); border-radius: 2px; overflow: hidden;
}
.vibe-meter-fill {
position: absolute; top: 0; left: 0; bottom: 0;
background: linear-gradient(to right, var(--tc), var(--tc-2));
box-shadow: 0 0 12px var(--tg); border-radius: 1px;
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.vibe-meter-row {
display: flex; align-items: baseline; justify-content: space-between;
font-family: var(--font-mono); font-size: 11.5px;
}
.vibe-meter-label {
color: var(--muted); font-style: italic;
font-family: var(--font-serif); font-size: 14px;
}
.vibe-meter-caption {
color: var(--tc-2); font-weight: 600;
transition: color var(--theme-ease);
}
.vibe-quote {
margin-top: auto; padding: 18px 0 0;
border-top: 1px solid var(--hairline);
}
.vibe-quote-text {
font-family: var(--font-serif); font-size: 22px;
line-height: 1.4; color: var(--fg); font-weight: 500;
letter-spacing: -0.01em; margin-bottom: 8px;
}
.vibe-quote-caption {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted);
}
</style>
@@ -0,0 +1,99 @@
<script setup>
defineProps({
title: String,
deck: String,
summary: String,
stats: String,
items: Array,
verdict: String,
idx: { type: Number, default: 4 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Workflows</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="card-deck-text" v-if="deck || summary">{{ deck || summary }}</div>
<div class="wf-content">
<div class="wf-stats" v-if="stats">{{ stats }}</div>
<div class="wf-list">
<div v-for="(item, i) in items" :key="i" class="wf-item">
<div class="wf-item-name">{{ item.name }}</div>
<div class="wf-item-reaction">{{ item.reaction || item.outcome }}</div>
</div>
</div>
<div class="wf-verdict" v-if="verdict">
<div class="wf-verdict-label">Verdict </div>
<div class="wf-verdict-text">{{ verdict }}</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.wf-content {
flex: 1; padding: 0 36px 32px;
display: flex; flex-direction: column; gap: 18px;
overflow-y: auto; position: relative; z-index: 1;
}
.wf-stats {
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
font-variant-numeric: tabular-nums; display: flex; gap: 14px;
}
.wf-stats :deep(strong) { color: var(--fg); font-weight: 500; }
.wf-stats :deep(.sep) { color: var(--muted-3); }
.wf-list {
display: flex; flex-direction: column; gap: 1px;
background: var(--hairline); border: 1px solid var(--hairline);
border-radius: 6px; overflow: hidden;
}
.wf-item {
padding: 14px 16px; background: rgba(10,11,20,0.4);
display: flex; flex-direction: column; gap: 6px;
}
.wf-item-name {
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
color: var(--fg); letter-spacing: -0.005em;
}
.wf-item-reaction {
font-family: var(--font-serif); font-style: italic;
font-size: 16px; color: var(--fg-2); line-height: 1.4;
}
.wf-item-reaction::before { content: '\201C'; color: var(--muted-2); }
.wf-item-reaction::after { content: '\201D'; color: var(--muted-2); }
.wf-verdict {
margin-top: auto; padding: 16px 18px;
border: 1px solid var(--hairline-strong); border-radius: 6px;
background: rgba(255,255,255,0.025);
display: flex; flex-direction: column; gap: 6px;
position: relative; overflow: hidden;
}
.wf-verdict::before {
content: ''; position: absolute;
left: 0; top: 0; bottom: 0; width: 2px;
background: var(--tc); box-shadow: 0 0 8px var(--tg);
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.wf-verdict-label {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted);
}
.wf-verdict-text {
font-family: var(--font-serif); font-size: 22px; font-weight: 500;
color: var(--fg); letter-spacing: -0.01em;
}
</style>
@@ -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'];
@@ -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);
}
@@ -0,0 +1,19 @@
export const MINI_SEALS = {
architect: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#a78bfa" stroke-width="4" stroke-opacity="0.7"/><polygon points="55,32 50,42 60,42" fill="#c4b5fd"/><polygon points="50,42 60,42 58,72 52,72" fill="#a78bfa"/></svg>`,
debugger: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fbbf24" stroke-width="4" stroke-opacity="0.7"/><path d="M 55 34 A 21 21 0 1 1 34 55 A 16 16 0 1 0 55 39 A 11 11 0 1 1 44 55" stroke="#fde68a" stroke-width="3.5" fill="none" stroke-linecap="round"/></svg>`,
shipper: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#f472b6" stroke-width="4" stroke-opacity="0.7"/><rect x="36" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.45"/><rect x="50" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.85"/><rect x="64" y="48" width="13" height="13" rx="1.5" fill="#fda4af"/></svg>`,
curator: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#67e8f9" stroke-width="4" stroke-opacity="0.7"/><rect x="34" y="46" width="42" height="5" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="38" y="55" width="34" height="5" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="34" y="64" width="42" height="5" rx="1" fill="#22d3ee" opacity="0.55"/></svg>`,
director: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fcd34d" stroke-width="4" stroke-opacity="0.7"/><g stroke="#fde68a" stroke-width="3" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="36"/><line x1="55" y1="55" x2="72" y2="44"/><line x1="55" y1="55" x2="72" y2="66"/><line x1="55" y1="55" x2="55" y2="74"/><line x1="55" y1="55" x2="38" y2="66"/><line x1="55" y1="55" x2="38" y2="44"/></g><circle cx="55" cy="55" r="4" fill="#fcd34d"/></svg>`,
cartographer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#34d399" stroke-width="4" stroke-opacity="0.7"/><g stroke="#34d399" stroke-width="1.5" stroke-opacity="0.4" stroke-dasharray="3 3"><line x1="34" y1="55" x2="76" y2="55"/><line x1="55" y1="34" x2="55" y2="76"/></g><polygon points="55,38 51,55 55,53 59,55" fill="#6ee7b7"/><polygon points="55,38 55,53 59,55" fill="#34d399"/></svg>`,
wanderer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#64748b" stroke-width="4" stroke-opacity="0.85"/><path d="M 38 40 C 44 50, 50 44, 54 50 C 60 60, 50 66, 56 72 C 62 78, 70 66, 76 70" stroke="#94a3b8" stroke-width="2.6" fill="none" stroke-linecap="round" opacity="0.95"/><circle cx="38" cy="40" r="3" fill="#94a3b8"/><circle cx="76" cy="70" r="3" fill="#94a3b8"/></svg>`,
};
export const CORNER_SEALS = {
architect: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-arc" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#a78bfa" stop-opacity="0.5"/><stop offset="100%" stop-color="#a78bfa" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-arc)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#a78bfa" stroke-width="1.4" stroke-opacity="0.85"/><polygon points="55,28 50,38 60,38" fill="#c4b5fd"/><polygon points="50,38 60,38 58,76 52,76" fill="#a78bfa"/><polygon points="55,38 60,38 58,76 55,76" fill="#7c3aed" opacity="0.75"/><rect x="48" y="76" width="14" height="2" rx="0.4" fill="#1e293b"/></svg>`,
debugger: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dbg" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="100%" stop-color="#fbbf24" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dbg)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fbbf24" stroke-width="1.4" stroke-opacity="0.85"/><path d="M 55 27 A 28 28 0 1 1 27 55 A 22 22 0 1 0 55 33 A 16 16 0 1 1 39 55 A 11 11 0 1 0 55 44 A 6 6 0 1 1 49 55 L 55 55" stroke="#fde68a" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="55" cy="55" r="2.5" fill="#fde68a"/></svg>`,
shipper: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-shp" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#f472b6" stop-opacity="0.5"/><stop offset="100%" stop-color="#f472b6" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-shp)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#f472b6" stroke-width="1.4" stroke-opacity="0.85"/><rect x="32" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.35"/><rect x="48" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.65"/><rect x="64" y="48" width="14" height="14" rx="1.5" fill="#fda4af"/><path d="M 32 72 L 78 72 M 73 68 L 78 72 L 73 76" stroke="#fda4af" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`,
curator: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cur" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#67e8f9" stop-opacity="0.45"/><stop offset="100%" stop-color="#67e8f9" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cur)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#67e8f9" stroke-width="1.4" stroke-opacity="0.85"/><rect x="30" y="42" width="50" height="6" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="34" y="52" width="42" height="6" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="30" y="62" width="50" height="6" rx="1" fill="#22d3ee" opacity="0.55"/><rect x="38" y="72" width="34" height="4" rx="1" fill="#0891b2" opacity="0.5"/></svg>`,
director: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dir" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fcd34d" stop-opacity="0.45"/><stop offset="100%" stop-color="#fcd34d" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dir)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fcd34d" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#fde68a" stroke-width="1.1" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="32"/><line x1="55" y1="55" x2="74" y2="42"/><line x1="55" y1="55" x2="74" y2="68"/><line x1="55" y1="55" x2="55" y2="78"/><line x1="55" y1="55" x2="36" y2="68"/><line x1="55" y1="55" x2="36" y2="42"/></g><g fill="#fde68a"><circle cx="55" cy="32" r="2.5"/><circle cx="74" cy="42" r="2.5"/><circle cx="74" cy="68" r="2.5"/><circle cx="55" cy="78" r="2.5"/><circle cx="36" cy="68" r="2.5"/><circle cx="36" cy="42" r="2.5"/></g><circle cx="55" cy="55" r="3.5" fill="#fcd34d"/></svg>`,
cartographer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cart" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#34d399" stop-opacity="0.5"/><stop offset="100%" stop-color="#34d399" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cart)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#34d399" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#34d399" stroke-width="0.5" stroke-opacity="0.4" stroke-dasharray="2 2"><line x1="32" y1="44" x2="78" y2="44"/><line x1="32" y1="55" x2="78" y2="55"/><line x1="32" y1="66" x2="78" y2="66"/><line x1="44" y1="32" x2="44" y2="78"/><line x1="55" y1="32" x2="55" y2="78"/><line x1="66" y1="32" x2="66" y2="78"/></g><polygon points="55,38 52.5,55 55,53 57.5,55" fill="#6ee7b7"/><polygon points="55,38 55,53 57.5,55" fill="#34d399"/><polygon points="55,72 52.5,55 55,57 57.5,55" fill="#34d399" opacity="0.6"/><polygon points="72,55 55,52.5 57,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><polygon points="38,55 55,52.5 53,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><circle cx="55" cy="55" r="2" fill="#0a0b14"/><circle cx="55" cy="55" r="2.4" stroke="#6ee7b7" stroke-width="0.6" fill="none"/></svg>`,
wanderer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-wand" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#64748b" stop-opacity="0.5"/><stop offset="100%" stop-color="#64748b" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-wand)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#64748b" stroke-width="1.5" stroke-opacity="0.9"/><path d="M 36 38 C 42 50, 48 42, 54 50 C 60 60, 50 65, 56 72 C 62 78, 70 64, 76 70" stroke="#94a3b8" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/><circle cx="36" cy="38" r="2.4" fill="#94a3b8"/><circle cx="54" cy="50" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="56" cy="72" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="76" cy="70" r="2.4" fill="#94a3b8"/></svg>`,
};
+408
View File
@@ -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;
}
}
+42
View File
@@ -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');
@@ -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" }
]
}
+90
View File
@@ -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;
+31
View File
@@ -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);
}
+52
View File
@@ -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,
}));
}
+130
View File
@@ -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 = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></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;
}
}
+165
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 => `<mark>${m}</mark>`);
}
export function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/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(/^-/, '');
}
+953
View File
@@ -0,0 +1,953 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { state, navigateToSession } from '../store.js';
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'Activity' });
// --- State ---
const activeTab = ref('daily');
const loading = ref(true);
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
const selectedDayKey = ref(null);
const loadedMonths = ref(0);
const monthBlocks = ref([]);
// Tooltip
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
// --- Constants ---
const DAY_MS = 86400000;
const NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i;
function isNoiseSession(s) {
if (!s.title) return true;
const label = formatProjectLabel(s.project) || '';
return NOISE_PROJECT_RE.test(label);
}
function splitNoise(arr) {
const normal = [], noise = [];
for (const s of arr || []) {
if (isNoiseSession(s)) noise.push(s); else normal.push(s);
}
return { normal, noise, total: normal.length + noise.length };
}
const expandedNoise = reactive({});
function toggleNoise(key) {
expandedNoise[key] = !expandedNoise[key];
}
function localDateStr(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const MONTHS_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
// --- Computed: heatmap grid ---
const heatmapGrid = computed(() => {
const today = new Date();
let startDate = new Date(today.getTime() - 364 * DAY_MS);
startDate.setHours(0, 0, 0, 0);
const daysUntilSunday = (7 - startDate.getDay()) % 7;
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
const dailyMap = {};
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
const values = usageData.daily.map(d => d.tokens).filter(Boolean);
const maxTokens = Math.max(...values, 1);
const cells = [];
for (let i = 0; i < 371; i++) {
const date = new Date(startDate.getTime() + i * DAY_MS);
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;
const gridHeight = 7 * step;
// Month labels
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_SHORT[m] });
lastMonth = m;
}
}
return { cells, monthLabels, gridWidth, gridHeight, cellSize, step };
});
// --- Computed: streaks ---
const currentStreak = computed(() => {
const today = new Date();
const dailyMap = {};
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
let streak = 0;
let startedCounting = false;
for (let i = 0; i <= 365; i++) {
const d = new Date(today.getTime() - i * DAY_MS).toISOString().slice(0, 10);
if (dailyMap[d] && dailyMap[d] > 0) {
startedCounting = true;
streak++;
} else if (startedCounting) {
break;
}
}
return streak;
});
const longestStreak = computed(() => {
const sortedDays = [...usageData.daily]
.filter(d => d.tokens > 0)
.sort((a, b) => a.day.localeCompare(b.day));
let longest = 0;
let streak = 0;
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();
streak = (curr - prev === DAY_MS) ? streak + 1 : 1;
}
if (streak > longest) longest = streak;
}
return longest;
});
// --- Computed: weekly chart ---
const weeklyBars = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
let startDate = new Date(today.getTime() - 364 * DAY_MS);
startDate.setHours(0, 0, 0, 0);
// Align to Monday (ISO week start)
const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon...
const daysUntilMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
startDate = new Date(startDate.getTime() + daysUntilMonday * DAY_MS);
const dailyMap = {};
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
const weeks = [];
for (let w = 0; w < 53; w++) {
const weekStart = new Date(startDate.getTime() + w * 7 * DAY_MS);
if (weekStart > today) break;
let tokens = 0;
for (let d = 0; d < 7; d++) {
const date = new Date(weekStart.getTime() + d * DAY_MS);
if (date > today) break;
const key = date.toISOString().slice(0, 10);
tokens += dailyMap[key] || 0;
}
weeks.push({ weekStart, tokens, weekKey: localDateStr(weekStart) });
}
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 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_SHORT[m] }); lastMonth = m; }
}
const bars = weeks.map((w, i) => {
const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0;
const x = i * (barWidth + barGap);
return { x, y: chartHeight - h, width: barWidth, height: Math.max(h, 0.5), label: `Week of ${w.weekKey}: ${fmtTokens(w.tokens)}` };
});
return { bars, labels, chartWidth, chartHeight, barWidth, barGap };
});
// --- Computed: cumulative chart ---
const cumulativeData = computed(() => {
const sorted = [...usageData.daily].sort((a, b) => a.day.localeCompare(b.day));
if (!sorted.length) return null;
let cumulative = 0;
const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; });
const maxVal = points[points.length - 1].total || 1;
const chartWidth = 700;
const chartHeight = 140;
const xScale = (i) => (i / (points.length - 1)) * chartWidth;
const yScale = (v) => chartHeight - (v / maxVal) * chartHeight;
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`;
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_SHORT[m] }); lastMonth = m; }
}
const dots = points.map((p, i) => ({
cx: xScale(i).toFixed(1),
cy: yScale(p.total).toFixed(1),
label: `${p.day}: ${fmtTokens(p.total)} total`
}));
return { linePath, areaPath, labels, dots, chartWidth, chartHeight };
});
// --- Computed: day sessions ---
const daySessions = computed(() => {
if (!selectedDayKey.value) return null;
const dateKey = selectedDayKey.value;
const dayStart = dateKey + 'T00:00:00';
const dayEnd = dateKey + 'T23:59:59';
const sessions = 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;
});
const classified = sessions.map(s => {
const isNew = s.started_at.slice(0, 10) === dateKey;
let kind = 'continued';
if (isNew) {
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 };
});
return {
dateKey,
dateLabel: fmtTooltipDate(dateKey),
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'),
isEmpty: classified.length === 0
};
});
const daySessionsSplit = computed(() => {
if (!daySessions.value) return null;
return {
...daySessions.value,
newWorkspaces: splitNoise(daySessions.value.newWorkspaces),
newSessions: splitNoise(daySessions.value.newSessions),
continued: splitNoise(daySessions.value.continued),
};
});
const monthBlocksSplit = computed(() =>
monthBlocks.value.map((b, bi) => ({
...b,
bi,
newWorkspaces: splitNoise(b.newWorkspaces),
newSessions: splitNoise(b.newSessions),
continued: splitNoise(b.continued),
}))
);
// --- Methods ---
function switchTab(view) {
activeTab.value = view;
}
function onCellEnter(cell, event) {
tooltip.text = `${fmtTokens(cell.tokens)} tokens on ${fmtTooltipDate(cell.key)}`;
tooltip.show = true;
updateTooltipPos(event);
}
function onCellMove(event) {
updateTooltipPos(event);
}
function onCellLeave() {
tooltip.show = false;
}
function onCellClick(cell) {
selectedDayKey.value = cell.key;
}
function onBarEnter(bar, event) {
tooltip.text = bar.label;
tooltip.show = true;
updateTooltipPos(event);
}
function onDotEnter(dot, event) {
tooltip.text = dot.label;
tooltip.show = true;
updateTooltipPos(event);
}
function updateTooltipPos(event) {
const pad = 12;
let left = event.clientX + pad;
if (left + 200 > window.innerWidth - pad) left = event.clientX - 200 - pad;
tooltip.x = left;
tooltip.y = event.clientY - 28;
}
function goToSession(sessionId) {
navigateToSession(sessionId);
}
function buildMonthBlock(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 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 };
});
return {
header: `${MONTHS_FULL[month]} ${year}`,
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'),
isEmpty: classified.length === 0
};
}
function showNextMonth() {
const today = new Date();
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
monthBlocks.value.push(block);
loadedMonths.value++;
}
function projectLabel(project) {
return formatProjectLabel(project);
}
function newSessionProjectCount(sessions) {
const projects = new Set(sessions.map(s => s.project || '(none)'));
return projects.size;
}
// --- Lifecycle ---
onMounted(async () => {
try {
const data = await window.obelisk.getUsageStats();
usageData.daily = data.daily || [];
usageData.totalTokens = data.totalTokens || 0;
usageData.peakDay = data.peakDay || null;
usageData.longestTurn = data.longestTurn || null;
} catch (e) {
console.error('Failed to load usage stats:', e);
}
loading.value = false;
showNextMonth();
});
</script>
<template>
<div class="usage-wrap" v-if="!loading">
<div class="detail-wide">
<!-- Header with tabs -->
<div class="usage-header">
<span class="usage-title">Token activity</span>
<div class="usage-view-tabs">
<button
class="usage-tab"
:class="{ active: activeTab === 'daily' }"
@click="switchTab('daily')"
>Daily</button>
<button
class="usage-tab"
:class="{ active: activeTab === 'weekly' }"
@click="switchTab('weekly')"
>Weekly</button>
<button
class="usage-tab"
:class="{ active: activeTab === 'cumulative' }"
@click="switchTab('cumulative')"
>Cumulative</button>
</div>
</div>
<!-- Stats bar -->
<div class="usage-stats">
<div class="usage-stat">
<span class="usage-stat-value">{{ fmtTokens(usageData.totalTokens) }}</span>
<span class="usage-stat-label">Lifetime tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">{{ usageData.peakDay ? fmtTokens(usageData.peakDay.tokens) : '—' }}</span>
<span class="usage-stat-label">Peak tokens</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">{{ usageData.longestTurn ? fmtDuration(usageData.longestTurn.turn_duration_ms) : '—' }}</span>
<span class="usage-stat-label">Longest task</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">{{ currentStreak }}d</span>
<span class="usage-stat-label">Current streak</span>
</div>
<div class="usage-stat">
<span class="usage-stat-value">{{ longestStreak }}d</span>
<span class="usage-stat-label">Longest streak</span>
</div>
</div>
<!-- Daily heatmap -->
<div class="heatmap-container" v-show="activeTab === 'daily'">
<svg
class="heatmap"
:width="heatmapGrid.gridWidth"
:height="heatmapGrid.gridHeight + 20"
:viewBox="`0 0 ${heatmapGrid.gridWidth} ${heatmapGrid.gridHeight + 20}`"
>
<rect
v-for="cell in heatmapGrid.cells"
:key="cell.key"
:x="cell.col * heatmapGrid.step"
:y="cell.row * heatmapGrid.step"
:width="heatmapGrid.cellSize"
:height="heatmapGrid.cellSize"
rx="2"
:class="['heatmap-cell', `level-${cell.level}`, { selected: selectedDayKey === cell.key }]"
@mouseenter="onCellEnter(cell, $event)"
@mousemove="onCellMove"
@mouseleave="onCellLeave"
@click="onCellClick(cell)"
/>
<text
v-for="ml in heatmapGrid.monthLabels"
:key="'ml-' + ml.col"
:x="ml.col * heatmapGrid.step"
:y="heatmapGrid.gridHeight + 14"
class="heatmap-month"
>{{ ml.label }}</text>
</svg>
<div class="heatmap-legend">
<span class="heatmap-legend-label">Less</span>
<svg width="70" height="11">
<rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/>
<rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/>
<rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/>
<rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/>
<rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/>
</svg>
<span class="heatmap-legend-label">More</span>
</div>
</div>
<!-- Weekly bar chart -->
<div class="chart-container" v-show="activeTab === 'weekly'">
<svg
class="weekly-chart"
:viewBox="`0 0 ${weeklyBars.chartWidth + 20} ${weeklyBars.chartHeight + 24}`"
preserveAspectRatio="xMidYMid meet"
>
<rect
v-for="(bar, i) in weeklyBars.bars"
:key="'bar-' + i"
:x="bar.x"
:y="bar.y"
:width="bar.width"
:height="bar.height"
rx="2"
class="bar-fill"
@mouseenter="onBarEnter(bar, $event)"
@mousemove="onCellMove"
@mouseleave="onCellLeave"
/>
<text
v-for="(lbl, i) in weeklyBars.labels"
:key="'wlbl-' + i"
:x="lbl.i * (weeklyBars.barWidth + weeklyBars.barGap)"
:y="weeklyBars.chartHeight + 16"
class="heatmap-month"
>{{ lbl.label }}</text>
</svg>
</div>
<!-- Cumulative line chart -->
<div class="chart-container" v-show="activeTab === 'cumulative'">
<template v-if="cumulativeData">
<svg
:viewBox="`0 0 ${cumulativeData.chartWidth} ${cumulativeData.chartHeight + 24}`"
preserveAspectRatio="xMidYMid meet"
class="cumulative-chart"
>
<path :d="cumulativeData.areaPath" class="cumulative-area"/>
<path :d="cumulativeData.linePath" class="cumulative-line"/>
<circle
v-for="(dot, i) in cumulativeData.dots"
:key="'dot-' + i"
:cx="dot.cx"
:cy="dot.cy"
r="6"
class="cumulative-dot"
@mouseenter="onDotEnter(dot, $event)"
@mousemove="onCellMove"
@mouseleave="onCellLeave"
/>
<text
v-for="(lbl, i) in cumulativeData.labels"
:key="'clbl-' + i"
:x="lbl.x"
:y="cumulativeData.chartHeight + 16"
class="heatmap-month"
>{{ lbl.label }}</text>
</svg>
</template>
<div v-else class="empty">No data</div>
</div>
<!-- Day sessions panel (from heatmap click) -->
<div class="day-sessions" v-if="daySessionsSplit">
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> no sessions</template></div>
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
<!-- New workspaces -->
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</span>
<span class="activity-group-title">Created {{ daySessionsSplit.newWorkspaces.total }} new workspace{{ daySessionsSplit.newWorkspaces.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-workspaces'] }" @click="toggleNoise('day-workspaces')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-workspaces']">
<button
v-for="s in daySessionsSplit.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- New sessions -->
<div class="activity-group" v-if="daySessionsSplit.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ daySessionsSplit.newSessions.total }} session{{ daySessionsSplit.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) }} project{{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-sessions'] }" @click="toggleNoise('day-sessions')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-sessions']">
<button
v-for="s in daySessionsSplit.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- Continued sessions -->
<div class="activity-group continued" v-if="daySessionsSplit.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ daySessionsSplit.continued.total }} session{{ daySessionsSplit.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-continued'] }" @click="toggleNoise('day-continued')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-continued']">
<button
v-for="s in daySessionsSplit.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div>
</div>
<!-- Monthly activity blocks -->
<div class="day-sessions" v-if="!selectedDayKey">
<template v-for="(block, bi) in monthBlocksSplit" :key="bi">
<div class="day-sessions-header">{{ block.header }}</div>
<div class="day-activity-timeline" v-if="!block.isEmpty">
<div class="activity-group" v-if="block.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</span>
<span class="activity-group-title">Created {{ block.newWorkspaces.total }} new workspace{{ block.newWorkspaces.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-workspaces`] }" @click="toggleNoise(`m${bi}-workspaces`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-workspaces`]">
<button
v-for="s in block.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group" v-if="block.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ block.newSessions.total }} session{{ block.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) }} project{{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-sessions`] }" @click="toggleNoise(`m${bi}-sessions`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-sessions`]">
<button
v-for="s in block.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group continued" v-if="block.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ block.continued.total }} session{{ block.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-continued`] }" @click="toggleNoise(`m${bi}-continued`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-continued`]">
<button
v-for="s in block.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div>
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
</template>
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
</div>
<!-- Tooltip -->
<div
class="chart-tooltip"
:class="{ show: tooltip.show }"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>{{ tooltip.text }}</div>
</div>
</div>
</template>
<style scoped>
.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-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; cursor: pointer; }
.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); }
.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; cursor: pointer; }
.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; cursor: pointer; }
.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);
transition: opacity 0.1s;
}
.chart-tooltip.show { opacity: 1; }
/* Day sessions panel */
.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; display: inline-flex; align-items: center; gap: 6px; }
.activity-item-meta .src-tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; letter-spacing: 0.02em;
color: var(--muted);
}
.activity-item-meta .src-tag .src-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.activity-item-meta .src-tag.claude .src-dot { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
.activity-item-meta .src-tag.codex .src-dot { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
.activity-item-meta .src-tag.claude { color: #d97757; }
.activity-item-meta .src-tag.codex { color: #10a37f; }
.activity-group.continued .activity-item-name { color: var(--fg-2); }
.noise-fold-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; margin-top: 2px;
border-radius: 5px; background: transparent; border: 0;
cursor: pointer; transition: background 0.08s;
text-align: left; width: 100%; font: inherit;
color: var(--muted); font-size: 11.5px;
font-family: var(--font-mono);
}
.noise-fold-row:hover { background: var(--surface-strong); color: var(--fg-2); }
.noise-fold-row .chev {
width: 9px; height: 9px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.noise-fold-row.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
.activity-item.noise .activity-item-name {
color: var(--fg-2); font-style: italic; font-weight: 400;
}
.activity-item.noise .activity-item-project { color: var(--muted); }
.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); }
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
</style>
+116
View File
@@ -0,0 +1,116 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory, isTextTruncated } from '../data.js';
import { escapeHTML, fmtRelative, renderMarkdown, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'MemoryDetail' });
const props = defineProps({ id: String });
const router = useRouter();
const memory = computed(() => state.memories.find(m => m.id === props.id));
const markdown = ref(null);
const showSource = ref(false);
const loading = ref(false);
onMounted(async () => { await loadContent(); });
watch(() => props.id, async () => { markdown.value = null; showSource.value = false; await loadContent(); });
async function loadContent() {
const m = memory.value;
if (!m) return;
if (m.markdown != null) { markdown.value = m.markdown; return; }
if (m.path) {
loading.value = true;
const content = await loadMemoryMarkdown(m.path);
m.markdown = content;
markdown.value = content;
loading.value = false;
}
}
async function handleArchive() {
const m = memory.value;
if (!m) return;
if (m.archived) await restoreMemory(m.id);
else await archiveMemory(m.id);
router.push('/memory');
}
function goToSession() {
const m = memory.value;
if (m?.session_id) router.push(`/sessions/${m.session_id}`);
}
</script>
<template>
<div class="detail" v-if="memory">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(memory.project) }}</span>
<span v-if="memory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ memory.path }}</div>
<div class="detail-summary">{{ memory.summary }}</div>
<div class="detail-meta">
<button v-if="memory.session_id" class="session-link" @click="goToSession">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" style="width:11px;height:11px;">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span>Source session</span>
</button>
<span class="dot" v-if="memory.session_id"></span>
<span>created {{ fmtRelative(memory.ts) }}</span>
<template v-if="memory.message_start">
<span class="dot"></span>
<span style="font-family:var(--font-mono);font-size:11px;">{{ memory.message_start.slice(0, 8) }} {{ (memory.message_end || '').slice(0, 8) }}</span>
</template>
</div>
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button
class="source-toggle"
:class="{ active: showSource }"
:disabled="markdown == null"
@click="showSource = !showSource"
>{{ showSource ? 'Show rendered' : 'Show source' }}</button>
</div>
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div>
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else class="markdown-msg" v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
<span>Anchors</span><span class="count">{{ memory.anchors.length }}</span>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="anchor-list">
<button
v-for="a in memory.anchors"
:key="a.path + ':' + a.line"
class="anchor-link"
:disabled="a.exists === false"
:title="a.exists === false ? 'File no longer exists' : 'Open in editor'"
>
<span class="anchor-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>
</span>
<span class="anchor-path">{{ a.path }}</span>
<span class="anchor-line" v-if="a.line">:{{ a.line }}</span>
</button>
</div>
<div class="detail-actions">
<button class="btn" @click="router.push('/memory')">Back</button>
<button class="btn" :class="memory.archived ? 'primary' : 'danger'" @click="handleArchive">
{{ memory.archived ? 'Restore' : 'Archive' }}
</button>
</div>
</div>
</template>
+786
View File
@@ -0,0 +1,786 @@
<script setup>
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG, clearUndo } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative, renderMarkdown } from '../utils.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory } from '../data.js';
defineOptions({ name: 'MemoryList' });
const router = useRouter();
const listWrapRef = ref(null);
const undoCountdown = ref(0);
// --- Filtered/sorted memories ---
const visibleMemories = computed(() => {
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);
});
const showProjectPrefix = computed(() => state.projectFilter === 'all');
// --- Detail state ---
const detailMemory = ref(null);
const detailMarkdown = ref(null);
const showSource = ref(false);
const loadingMarkdown = ref(false);
const showDetail = computed(() => detailMemory.value !== null);
// --- Row helpers ---
function dominantRowStatus(m) {
if (m.health === 'broken') return 'broken';
if (m.health === 'partial') return 'partial';
if (m.archived) return 'archived';
return null;
}
function statusGlyphs(status) {
if (!status) return '';
const map = {
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
};
return map[status] || '';
}
function pathHTML(m) {
const full = m.path || '';
const filename = full.split('/').pop() || full;
return highlightPlain(filename, state.query.trim());
}
function relativePath(m) {
const full = m.path || '';
if (!m.project) return full;
const projectDir = '/' + m.project.replace(/^-/, '').replace(/-/g, '/');
if (full.startsWith(projectDir)) {
return full.slice(projectDir.length + 1);
}
return full.split('/').slice(-3).join('/');
}
function summaryHTML(m) {
return highlightPlain(m.summary || '', state.query.trim());
}
function sourceSessionTitle(m) {
if (!m.session_id) return '';
const s = state.sessions.find(x => x.id === m.session_id);
return s?.title || m.session_id.slice(0, 8);
}
function openSourceSession(m) {
if (!m.session_id) return;
if (m.message_start) {
router.push({ path: `/sessions/${m.session_id}`, query: { focus: m.message_start } });
} else {
router.push(`/sessions/${m.session_id}`);
}
}
function timeLabel(m) {
return fmtListTime(m.ts);
}
function projectLabel(m) {
return escapeHTML(formatProjectLabel(m.project));
}
// --- Selection ---
function toggleSelection(id) {
const s = new Set(state.selection);
if (s.has(id)) s.delete(id);
else s.add(id);
state.selection = s;
}
// --- Cursor navigation ---
function moveCursor(direction) {
const items = visibleMemories.value;
if (!items.length) return;
const curIdx = items.findIndex(m => m.id === state.cursorId);
let next;
if (curIdx === -1) {
next = 0;
} else {
next = curIdx + direction;
if (next < 0) next = 0;
if (next >= items.length) next = items.length - 1;
}
state.cursorId = items[next].id;
nextTick(() => ensureVisible());
}
function ensureVisible() {
if (!listWrapRef.value || !state.cursorId) return;
const cursorEl = listWrapRef.value.querySelector(`.row[data-id="${state.cursorId}"]`);
if (!cursorEl) return;
const elRect = cursorEl.getBoundingClientRect();
const wrapRect = listWrapRef.value.getBoundingClientRect();
if (elRect.top < wrapRect.top + 30) {
listWrapRef.value.scrollTop -= (wrapRect.top + 30 - elRect.top);
} else if (elRect.bottom > wrapRect.bottom - 10) {
listWrapRef.value.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
}
}
// --- Open detail ---
async function openDetail(m) {
detailMemory.value = m;
showSource.value = false;
loadingMarkdown.value = true;
detailMarkdown.value = null;
if (m.markdown === null && m.path) {
m.markdown = await loadMemoryMarkdown(m.path);
}
detailMarkdown.value = m.markdown;
loadingMarkdown.value = false;
}
function closeDetail() {
detailMemory.value = null;
detailMarkdown.value = null;
showSource.value = false;
}
function toggleSourceView() {
showSource.value = !showSource.value;
}
// --- Row click ---
function onRowClick(m) {
state.cursorId = m.id;
openDetail(m);
}
// --- Archive/restore with undo ---
const undoSnapshot = ref(null);
let undoTimer = null;
async function doArchive(ids) {
const targets = ids || (state.cursorId ? [state.cursorId] : []);
if (!targets.length) return;
undoSnapshot.value = { action: 'archive', ids: [...targets] };
undoCountdown.value = 5;
for (const id of targets) {
await archiveMemory(id);
}
startUndoTimer();
// Move cursor if needed
if (targets.includes(state.cursorId)) {
const items = visibleMemories.value;
if (items.length) state.cursorId = items[0].id;
else state.cursorId = null;
}
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
closeDetail();
}
}
async function doRestore(ids) {
const targets = ids || (state.cursorId ? [state.cursorId] : []);
if (!targets.length) return;
undoSnapshot.value = { action: 'restore', ids: [...targets] };
undoCountdown.value = 5;
for (const id of targets) {
await restoreMemory(id);
}
startUndoTimer();
if (targets.includes(state.cursorId)) {
const items = visibleMemories.value;
if (items.length) state.cursorId = items[0].id;
else state.cursorId = null;
}
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
closeDetail();
}
}
async function undoAction() {
if (!undoSnapshot.value) return;
const { action, ids } = undoSnapshot.value;
for (const id of ids) {
if (action === 'archive') await restoreMemory(id);
else await archiveMemory(id);
}
undoSnapshot.value = null;
undoCountdown.value = 0;
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
}
function startUndoTimer() {
if (undoTimer) clearInterval(undoTimer);
undoCountdown.value = 5;
undoTimer = setInterval(() => {
undoCountdown.value--;
if (undoCountdown.value <= 0) {
clearInterval(undoTimer);
undoTimer = null;
undoSnapshot.value = null;
}
}, 1000);
}
// --- Detail action ---
function detailArchiveRestore() {
if (!detailMemory.value) return;
if (detailMemory.value.archived) {
doRestore([detailMemory.value.id]);
} else {
doArchive([detailMemory.value.id]);
}
}
// --- Detail markdown rendering ---
const renderedMarkdown = computed(() => {
if (detailMarkdown.value == null) return null;
if (showSource.value) return null; // handled by pre block in template
return renderMarkdown(detailMarkdown.value, { variant: 'body' });
});
// --- Keyboard handler ---
function onKeydown(e) {
// Do not handle if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (showDetail.value) {
if (e.key === 'Escape') { e.preventDefault(); closeDetail(); return; }
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); detailArchiveRestore(); return; }
return;
}
switch (e.key) {
case 'j':
e.preventDefault();
moveCursor(1);
break;
case 'k':
e.preventDefault();
moveCursor(-1);
break;
case 'Enter':
e.preventDefault();
if (state.cursorId) {
const m = visibleMemories.value.find(x => x.id === state.cursorId);
if (m) openDetail(m);
}
break;
case 'x':
e.preventDefault();
if (state.cursorId) toggleSelection(state.cursorId);
break;
case 'd':
case 'D':
e.preventDefault();
if (state.view === 'archived') {
doRestore(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
} else {
doArchive(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
}
break;
case 'z':
if ((e.metaKey || e.ctrlKey) && undoSnapshot.value) {
e.preventDefault();
undoAction();
}
break;
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown);
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
});
</script>
<template>
<!-- Detail panel overlay -->
<div v-if="showDetail" class="detail-wrap">
<div class="detail">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ relativePath(detailMemory) }}</div>
<div class="detail-summary">{{ detailMemory.summary }}</div>
<div class="detail-meta">
<button
v-if="detailMemory.session_id"
class="session-link"
@click="openSourceSession(detailMemory)"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span>{{ sourceSessionTitle(detailMemory) }}</span>
</button>
<span v-if="detailMemory.session_id" class="dot"></span>
<span>{{ fmtRelative(detailMemory.ts) }}</span>
</div>
</div>
<div class="markdown-section">
<div class="markdown-toolbar">
<span class="markdown-toolbar-label">Body</span>
<button
class="source-toggle"
:class="{ active: showSource }"
:disabled="detailMarkdown == null"
@click="toggleSourceView"
>
{{ showSource ? 'Show rendered' : 'Show source' }}
</button>
</div>
<div v-if="loadingMarkdown" class="markdown-loading">Loading...</div>
<div v-else-if="detailMarkdown == null" class="markdown-empty">
File not found or empty.
</div>
<pre v-else-if="showSource" class="markdown-source">{{ detailMarkdown }}</pre>
<div v-else class="markdown-body" v-html="renderedMarkdown"></div>
</div>
<div class="detail-actions">
<button class="btn" @click="closeDetail">
Back<span class="kbd">Esc</span>
</button>
<button
class="btn"
:class="detailMemory.archived ? 'primary' : 'danger'"
@click="detailArchiveRestore"
>
{{ detailMemory.archived ? 'Restore' : 'Archive' }}<span class="kbd">D</span>
</button>
</div>
</div>
</div>
<!-- List panel -->
<div v-else ref="listWrapRef" class="list-wrap">
<div v-if="!visibleMemories.length" class="empty">
No memories{{ state.view === 'archived' ? ' archived' : '' }} here.
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
</div>
<div v-else class="memory-list">
<div
v-for="m in visibleMemories"
:key="m.id"
class="row"
:class="{
cursor: state.cursorId === m.id,
selected: state.selection.has(m.id),
archived: m.archived
}"
:data-id="m.id"
@click="onRowClick(m)"
>
<button
class="row-checkbox"
:class="{ checked: state.selection.has(m.id) }"
aria-label="Select"
@click.stop="toggleSelection(m.id)"
>
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M2.5 6.5l2.5 2.5 4.5-5"/>
</svg>
</button>
<div class="row-body">
<div class="row-path">
<span
v-if="dominantRowStatus(m)"
class="row-status"
:class="dominantRowStatus(m)"
:title="dominantRowStatus(m)"
v-html="statusGlyphs(dominantRowStatus(m))"
></span>
<template v-if="showProjectPrefix">
<span class="project-prefix" v-html="projectLabel(m)"></span>
<span class="project-prefix-sep">/</span>
</template>
<span class="path-text" v-html="pathHTML(m)"></span>
</div>
<div class="row-summary" v-html="summaryHTML(m)"></div>
</div>
<div class="row-right">
<div class="row-meta"><span>{{ timeLabel(m) }}</span></div>
<div class="row-actions">
<button
v-if="m.archived"
class="row-action restore"
@click.stop="doRestore([m.id])"
>
Restore<span class="kbd">D</span>
</button>
<button
v-else
class="row-action danger"
@click.stop="doArchive([m.id])"
>
Archive<span class="kbd">D</span>
</button>
</div>
</div>
</div>
</div>
<!-- Undo toast -->
<Transition name="undo-fade">
<div v-if="undoSnapshot" class="undo-toast" @click="undoAction">
{{ undoSnapshot.action === 'archive' ? 'Archived' : 'Restored' }}
{{ undoSnapshot.ids.length }} memory{{ undoSnapshot.ids.length > 1 ? 'ies' : '' }}.
<button class="undo-btn">Undo ({{ undoCountdown }}s)</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.list-wrap {
flex: 1;
overflow-y: auto;
min-height: 0;
position: relative;
}
.detail-wrap {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.detail {
max-width: 720px;
margin: 0 auto;
padding: 32px 32px 60px;
}
.memory-list {
display: flex;
flex-direction: column;
}
/* Row styles */
.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, 60px);
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 :deep(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 :deep(mark), .row-summary :deep(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;
background: transparent; cursor: pointer;
}
.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); }
/* Empty state */
.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); }
/* Detail panel styles */
.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); display: inline-flex; }
.detail-eyebrow .project-icon :deep(svg) { width: 100%; height: 100%; }
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
.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: rgba(167,139,250,0.25);
text-underline-offset: 3px;
display: inline-flex; align-items: center; gap: 5px;
}
.session-link:hover { background: rgba(167,139,250,0.12); 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; cursor: pointer;
}
.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); }
.source-toggle:disabled { opacity: 0.4; cursor: not-allowed; }
.markdown-loading {
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
}
.markdown-empty {
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
border: 1px dashed var(--hairline); border-radius: 6px;
}
.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;
}
.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);
cursor: pointer;
}
.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;
}
/* Undo toast */
.undo-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--surface-strong);
border: 1px solid var(--hairline-strong);
border-radius: 8px;
padding: 10px 16px;
font-size: var(--text-sm);
color: var(--fg-2);
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
z-index: 100;
cursor: pointer;
}
.undo-btn {
background: var(--accent-soft);
border: 1px solid rgba(167,139,250,0.3);
border-radius: 4px;
padding: 3px 10px;
font-size: var(--text-sm);
color: var(--accent-2);
cursor: pointer;
transition: all 0.1s;
font-family: var(--font-mono);
}
.undo-btn:hover { background: rgba(167,139,250,0.25); border-color: var(--accent); }
.undo-fade-enter-active, .undo-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
.undo-fade-enter-from, .undo-fade-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
</style>
+318
View File
@@ -0,0 +1,318 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import CoverCard from '../components/recap/CoverCard.vue';
import PathCard from '../components/recap/PathCard.vue';
import VibeCard from '../components/recap/VibeCard.vue';
import WorkflowCard from '../components/recap/WorkflowCard.vue';
import ClosingCard from '../components/recap/ClosingCard.vue';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import mockJson from '../mock/recap-2026-W24.json';
defineOptions({ name: 'RecapDetail' });
const route = useRoute();
const recapData = ref(mockJson);
const currentArch = ref(mockJson.persona.archetype);
const currentIdx = ref(0);
const recapFilename = computed(() => String(route.params.id || ''));
const palette = computed(() => PALETTES[currentArch.value] || PALETTES.architect);
const CARD_LABELS = ['Cover', 'Path', 'Vibe', 'Workflow', 'Closing'];
const TOTAL = computed(() => recapData.value.cards.length);
const cover = computed(() => recapData.value.cards[0]);
const path = computed(() => recapData.value.cards[1]);
const vibe = computed(() => recapData.value.cards[2]);
const workflow = computed(() => recapData.value.cards[3]);
const closing = computed(() => recapData.value.cards[4]);
const cssVars = computed(() => ({
'--tc': palette.value.tc,
'--tc-2': palette.value.tc2,
'--tg': palette.value.glow,
'--tg-mid': palette.value.mid,
'--tg-soft': palette.value.soft,
'--tg-edge': palette.value.soft,
}));
async function loadRecap(filename) {
if (!filename || !window.obelisk?.recapRead) return;
const data = await window.obelisk.recapRead(filename);
if (data?.cards?.length) {
recapData.value = data;
currentArch.value = data.persona?.archetype || 'architect';
currentIdx.value = 0;
}
}
let unsubRecap;
onMounted(async () => {
const filename = route.params.id;
if (filename) await loadRecap(filename);
if (window.obelisk?.onRecapUpdated) {
unsubRecap = window.obelisk.onRecapUpdated((fp) => {
if (fp.endsWith(route.params.id)) loadRecap(route.params.id);
});
}
});
onUnmounted(() => { unsubRecap?.(); });
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
async function exportImage() {
await window.obelisk.captureExport({
cardIdx: currentIdx.value,
archetype: currentArch.value,
filename: recapFilename.value,
});
}
async function copyImage() {
await window.obelisk.copyImage({
cardIdx: currentIdx.value,
archetype: currentArch.value,
filename: recapFilename.value,
});
}
function goTo(idx) {
if (idx >= 0 && idx < TOTAL.value) currentIdx.value = idx;
}
function onKeydown(e) {
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goTo(currentIdx.value - 1); }
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goTo(currentIdx.value + 1); }
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
else if (e.key === 'End') { e.preventDefault(); goTo(TOTAL.value - 1); }
else if (e.key === 'p') {
const i = ARCH_KEYS.indexOf(currentArch.value);
currentArch.value = ARCH_KEYS[(i + 1) % ARCH_KEYS.length];
}
}
</script>
<template>
<div class="recap-app" :style="cssVars" @keydown="onKeydown" tabindex="0">
<!-- Stage -->
<div class="stage">
<div class="deck">
<div class="card-slot" :class="{ active: currentIdx === 0, prev: currentIdx > 0 }">
<CoverCard
:arch-key="currentArch"
:badge="cover.badge"
:title="cover.title"
:claim="cover.claim || cover.subtitle"
:subtitle="cover.subtitle"
:activity="cover.activity"
:footer="cover.footer"
:idx="1" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 1, prev: currentIdx > 1 }">
<PathCard
:title="path.title"
:items="path.items"
:idx="2" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 2, prev: currentIdx > 2 }">
<VibeCard
:title="vibe.title"
:voice-lines="vibe.voice_lines || vibe.observations"
:observations="vibe.observations"
:meter="vibe.meter"
:quote="vibe.quote"
:idx="3" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 3, prev: currentIdx > 3 }">
<WorkflowCard
:title="workflow.title"
:deck="workflow.deck || workflow.summary"
:summary="workflow.summary"
:stats="workflow.stats"
:items="workflow.items"
:verdict="workflow.verdict"
:idx="4" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 4, prev: currentIdx > 4 }">
<ClosingCard
:headline="closing.headline"
:receipts="closing.receipts || closing.stats"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase"
:signoff="closing.signoff"
:idx="5" :total="TOTAL"
/>
</div>
</div>
</div>
<!-- Nav -->
<div class="nav">
<button class="nav-arrow" :disabled="currentIdx === 0" @click="goTo(currentIdx - 1)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 4l-4 4 4 4"/>
</svg>
</button>
<div class="nav-dots">
<button
v-for="(label, i) in CARD_LABELS" :key="i"
class="nav-dot" :class="{ active: i === currentIdx }"
@click="goTo(i)"
>
<div class="nav-dot-glyph"></div>
<div class="nav-dot-label">{{ label }}</div>
</button>
</div>
<button class="nav-arrow" :disabled="currentIdx === TOTAL - 1" @click="goTo(currentIdx + 1)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4l4 4-4 4"/>
</svg>
</button>
<div class="nav-actions">
<button class="nav-action" title="Copy image" @click="copyImage">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
<path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5v-7A1.5 1.5 0 0 1 3.5 1h7A1.5 1.5 0 0 1 12 2.5V5"/>
</svg>
</button>
<button class="nav-action" title="Export PNG" @click="exportImage">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v8M5 7l3 3 3-3"/>
<path d="M3 12v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V12"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.recap-app {
--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.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--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;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
--transition-fast: 120ms ease;
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
height: 100%;
display: grid;
grid-template-rows: 1fr 64px;
color: var(--fg);
font: 13px/1.45 var(--font-sans);
-webkit-font-smoothing: antialiased;
background-color: var(--bg);
background-image:
radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.10), transparent 55%),
radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.08), transparent 60%),
radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.10), transparent 60%),
linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%);
position: relative;
outline: none;
}
.recap-app::before {
content: '';
position: absolute; inset: 0;
pointer-events: none; z-index: 0;
opacity: 0.3;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
mix-blend-mode: overlay;
}
/* Stage */
.stage {
position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
padding: 32px 24px; z-index: 1;
}
.deck {
position: relative; width: 100%; max-width: 540px;
height: 100%; perspective: 2000px;
}
.card-slot {
position: absolute; inset: 0;
opacity: 0; transform: translateY(24px) scale(0.97);
pointer-events: none;
transition: opacity var(--transition), transform var(--transition);
}
.card-slot.active {
opacity: 1; transform: translateY(0) scale(1);
pointer-events: auto; z-index: 2;
}
.card-slot.prev {
opacity: 0; transform: translateY(-12px) scale(1.02);
}
/* Nav */
.nav {
display: flex; align-items: center; justify-content: center; gap: 16px;
padding: 0 22px;
border-top: 1px solid var(--hairline);
background: rgba(0,0,0,0.18);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
position: relative; z-index: 1;
}
.nav-arrow {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--hairline-strong); background: var(--surface);
color: var(--fg-2); display: grid; place-items: center;
cursor: pointer; transition: all var(--transition-fast);
}
.nav-arrow:hover:not(:disabled) { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.nav-arrow:disabled { cursor: default; opacity: 0.3; }
.nav-arrow svg { width: 14px; height: 14px; }
.nav-dots { display: flex; gap: 8px; padding: 0 4px; }
.nav-dot {
display: flex; flex-direction: column; align-items: center; gap: 4px;
cursor: pointer; padding: 4px 8px; border-radius: 4px;
background: none; border: none; color: inherit;
transition: background var(--transition-fast);
}
.nav-dot:hover { background: var(--surface); }
.nav-dot-glyph {
width: 24px; height: 3px; border-radius: 2px;
background: var(--muted-3); transition: all var(--transition);
}
.nav-dot.active .nav-dot-glyph {
background: var(--tc); box-shadow: 0 0 8px var(--tg); width: 28px;
transition: background var(--theme-ease), box-shadow var(--theme-ease), width var(--transition);
}
.nav-dot-label {
font-family: var(--font-serif); font-style: italic;
font-size: 11px; color: var(--muted-2);
}
.nav-dot.active .nav-dot-label { color: var(--fg-2); }
.nav-actions {
position: absolute; right: 60px;
display: flex; gap: 6px; align-items: center;
}
.nav-action {
width: 32px; height: 32px; border-radius: 6px;
border: 1px solid var(--hairline-strong); background: var(--surface);
color: var(--fg-2); display: grid; place-items: center;
cursor: pointer; transition: all var(--transition-fast);
}
.nav-action:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.nav-action svg { width: 14px; height: 14px; }
</style>
+134
View File
@@ -0,0 +1,134 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CoverCard from '../components/recap/CoverCard.vue';
import PathCard from '../components/recap/PathCard.vue';
import VibeCard from '../components/recap/VibeCard.vue';
import WorkflowCard from '../components/recap/WorkflowCard.vue';
import ClosingCard from '../components/recap/ClosingCard.vue';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import recapJson from '../mock/recap-2026-W24.json';
const route = useRoute();
const recapData = ref(recapJson);
window.__OBELISK_RECAP_EXPORT_READY__ = false;
const cardIdx = computed(() => parseInt(route.query.card) || 0);
const exportFilename = computed(() => typeof route.query.file === 'string' ? route.query.file : '');
const archKey = computed(() => route.query.arch || recapData.value.persona?.archetype || recapJson.persona.archetype);
const palette = computed(() => PALETTES[archKey.value] || PALETTES.architect);
const total = computed(() => recapData.value.cards?.length || 5);
const cover = computed(() => recapData.value.cards?.[0] || recapJson.cards[0]);
const path = computed(() => recapData.value.cards?.[1] || recapJson.cards[1]);
const vibe = computed(() => recapData.value.cards?.[2] || recapJson.cards[2]);
const workflow = computed(() => recapData.value.cards?.[3] || recapJson.cards[3]);
const closing = computed(() => recapData.value.cards?.[4] || recapJson.cards[4]);
const cssVars = computed(() => ({
'--tc': palette.value.tc,
'--tc-2': palette.value.tc2,
'--tg': palette.value.glow,
'--tg-mid': palette.value.mid,
'--tg-soft': palette.value.soft,
'--tg-edge': palette.value.soft,
}));
function setExportReady(value) {
window.__OBELISK_RECAP_EXPORT_READY__ = value;
}
async function markExportReady() {
await nextTick();
await new Promise(resolve => requestAnimationFrame(() => resolve()));
setExportReady(true);
}
let loadSeq = 0;
async function loadExportRecap(filename) {
const seq = ++loadSeq;
setExportReady(false);
try {
if (filename && window.obelisk?.recapRead) {
const data = await window.obelisk.recapRead(filename);
if (seq === loadSeq && data?.cards?.length) {
recapData.value = data;
} else if (seq === loadSeq) {
recapData.value = recapJson;
}
} else if (seq === loadSeq) {
recapData.value = recapJson;
}
} finally {
if (seq === loadSeq) await markExportReady();
}
}
onMounted(() => loadExportRecap(exportFilename.value));
watch(exportFilename, (filename) => loadExportRecap(filename));
</script>
<template>
<div class="export-wrap" :style="cssVars">
<CoverCard v-if="cardIdx === 0"
:arch-key="archKey" :badge="cover.badge" :title="cover.title"
:claim="cover.claim || cover.subtitle"
:subtitle="cover.subtitle" :activity="cover.activity" :footer="cover.footer"
:idx="1" :total="total"
/>
<PathCard v-else-if="cardIdx === 1"
:title="path.title" :items="path.items"
:idx="2" :total="total"
/>
<VibeCard v-else-if="cardIdx === 2"
:title="vibe.title" :voice-lines="vibe.voice_lines || vibe.observations"
:observations="vibe.observations"
:meter="vibe.meter" :quote="vibe.quote"
:idx="3" :total="total"
/>
<WorkflowCard v-else-if="cardIdx === 3"
:title="workflow.title" :deck="workflow.deck || workflow.summary"
:summary="workflow.summary"
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
:idx="4" :total="total"
/>
<ClosingCard v-else-if="cardIdx === 4"
:headline="closing.headline" :receipts="closing.receipts || closing.stats"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
:idx="5" :total="total"
/>
</div>
</template>
<style scoped>
.export-wrap {
--bg: #0a0b14;
--bg-2: #11131f;
--surface: rgba(255,255,255,0.03);
--surface-strong: rgba(255,255,255,0.06);
--fg: rgba(255,255,255,0.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--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;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
--transition-fast: 120ms ease;
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
width: 540px;
height: 675px;
position: relative;
background: var(--bg);
color: var(--fg);
font: 13px/1.45 var(--font-sans);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
</style>
+514
View File
@@ -0,0 +1,514 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, inject } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import { CORNER_SEALS } from '../components/recap/seals.js';
defineOptions({ name: 'RecapList' });
const router = useRouter();
const route = useRoute();
const recaps = ref([]);
const recapsLoaded = ref(false);
const kind = computed(() => route.query.kind || 'weekly');
const showGenerate = inject('recapGenerateOpen', ref(false));
const filtered = computed(() => recaps.value.filter(r => r.kind === kind.value));
const byYear = computed(() => {
const map = {};
for (const r of filtered.value) {
const y = r.period?.start?.slice(0, 4) || '?';
if (!map[y]) map[y] = [];
map[y].push(r);
}
return Object.entries(map).sort((a, b) => b[0] - a[0]);
});
function glowColor(arch) {
return PALETTES[arch]?.glow || PALETTES.architect.glow;
}
function sealSvg(arch) {
return CORNER_SEALS[arch] || CORNER_SEALS.architect;
}
function formatDateRange(r) {
if (!r.period) return '';
const s = new Date(r.period.start);
const e = new Date(r.period.end);
const mo = s.toLocaleString('en', { month: 'short' });
return `${mo} ${s.getDate()} ${e.getDate()}`;
}
function formatTokens(n) {
if (!n) return '';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'k';
return String(n);
}
function openRecap(filename) {
router.push(`/recap/${encodeURIComponent(filename)}`);
}
const generateOptions = [
{ key: 'this-week', label: 'This week' },
{ key: 'last-week', label: 'Last week' },
{ key: 'this-month', label: 'This month' },
{ key: 'last-month', label: 'Last month' },
];
const CMDS = {
'this-week': '/obelisk recap this week',
'last-week': '/obelisk recap last week',
'this-month': '/obelisk recap this month',
'last-month': '/obelisk recap last month',
};
const generateWindow = ref('this-week');
const generateCmd = computed(() => CMDS[generateWindow.value]);
const cmdCopied = ref(false);
async function copyCmd() {
try {
await navigator.clipboard.writeText(generateCmd.value);
cmdCopied.value = true;
setTimeout(() => { cmdCopied.value = false; }, 1600);
} catch {}
}
async function loadRecaps() {
if (!window.obelisk?.recapList) return;
const files = await window.obelisk.recapList();
const results = [];
for (const f of files) {
const data = await window.obelisk.recapRead(f);
if (data?.cards) results.push({ ...data, _filename: f });
}
recaps.value = results;
recapsLoaded.value = true;
}
let unsub;
onMounted(async () => {
await loadRecaps();
if (window.obelisk?.onRecapUpdated) {
unsub = window.obelisk.onRecapUpdated(() => loadRecaps());
}
});
onUnmounted(() => { unsub?.(); });
</script>
<template>
<div class="recap-list">
<div class="content-wrap">
<div class="content" v-if="filtered.length">
<section v-for="[year, items] in byYear" :key="year" class="tl-section">
<div class="tl-section-head">
<span class="year">{{ year }}</span>
<span class="span">{{ items.length }} {{ items.length === 1 ? 'recap' : 'recaps' }}</span>
</div>
<div class="timeline">
<div
v-for="r in items" :key="r._filename"
class="recap-row"
:style="{ '--node-glow': glowColor(r.persona?.archetype) }"
@click="openRecap(r._filename)"
>
<div class="recap-node" v-html="sealSvg(r.persona?.archetype)"></div>
<div class="recap-card">
<div class="recap-body">
<div class="recap-period">
<span>{{ r.period?.label }}</span>
<span class="dot"></span>
<span>{{ formatDateRange(r) }}</span>
</div>
<div class="recap-archetype">{{ r.persona?.title }}</div>
<div class="recap-subtitle">{{ r.persona?.claim || r.persona?.subtitle }}</div>
<div class="recap-stats">
<span>{{ r.metrics?.sessions || 0 }} sessions</span>
<span class="sep">·</span>
<span>{{ formatTokens(r.metrics?.tokens) }} tokens</span>
</div>
</div>
<div class="recap-right">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4l4 4-4 4"/>
</svg>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="content empty-content" v-else-if="recapsLoaded">
<section class="tl-section">
<div class="tl-section-head">
<span class="year">No {{ kind }} recaps yet</span>
<span class="span">the timeline is waiting</span>
</div>
<div class="empty-timeline">
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row">
<div class="empty-node"></div>
<div class="empty-cta">
<div class="empty-eyebrow">
<span class="diamond"></span>
<span>Nothing carved yet</span>
</div>
<div class="empty-title">A recap is something you carve at the end of a stretch of work.</div>
<div class="empty-body">
Obelisk doesn't generate one for you automatically — it waits until you ask. Run <code>/obelisk recap this week</code> in Claude Code, and the result will land here as the first marker on this line.
</div>
<div class="empty-actions">
<button class="toolbar-action primary" @click="showGenerate = true">
<span class="plus">+</span>
<span>Generate {{ kind }} recap</span>
</button>
</div>
</div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
</div>
</section>
</div>
</div>
<!-- Generate modal -->
<div class="modal-backdrop" v-if="showGenerate" @click.self="showGenerate = false">
<div class="modal">
<div class="modal-head">
<span class="diamond"></span>
<span class="title">Generate a new recap</span>
<button class="modal-close" @click="showGenerate = false">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M3 3l6 6M9 3l-6 6"/>
</svg>
</button>
</div>
<div class="modal-body">
<p>Recaps are generated by Claude Code. Run the command below in your terminal — Obelisk will pick it up automatically.</p>
<div class="modal-options">
<button
v-for="opt in generateOptions" :key="opt.key"
class="modal-option" :class="{ active: generateWindow === opt.key }"
@click="generateWindow = opt.key"
>
<span class="modal-option-radio"></span>
<span class="modal-option-label">{{ opt.label }}</span>
</button>
</div>
<div class="cmd-block">
<code><span class="prompt">$</span> {{ generateCmd }}</code>
<button class="cmd-copy" :class="{ copied: cmdCopied }" @click="copyCmd">
<svg v-if="!cmdCopied" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<rect x="3" y="3" width="9" height="9" rx="1.5"/>
<path d="M5 3V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-1"/>
</svg>
<svg v-else viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8l3 3 7-7"/>
</svg>
</button>
</div>
<div class="modal-hint">Generation takes ~30s. New recaps appear in this list automatically.</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.recap-list {
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--bg: #0a0b14;
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--surface: rgba(255,255,255,0.03);
--surface-strong: rgba(255,255,255,0.06);
--fg: rgba(255,255,255,0.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
flex: 1; display: flex; flex-direction: column; min-height: 0;
}
.content-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.content { max-width: 720px; margin: 0 auto; padding: 32px 32px 80px; }
.tl-section { margin-bottom: 36px; }
.tl-section:last-child { margin-bottom: 0; }
.tl-section-head {
display: flex; align-items: baseline; gap: 12px;
margin-bottom: 20px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline);
}
.tl-section-head .year {
font-family: var(--font-serif); font-size: 22px;
font-weight: 500; color: var(--fg-2); letter-spacing: -0.005em;
}
.tl-section-head .span {
font-family: var(--font-mono); font-size: 12px;
color: var(--muted); letter-spacing: 0.02em;
}
.timeline { position: relative; }
.timeline::before {
content: ''; position: absolute;
left: 32px; top: 32px; bottom: 32px;
width: 1px; margin-left: -0.5px;
background: linear-gradient(to bottom,
rgba(167,139,250,0.55) 0%, rgba(167,139,250,0.35) 8%,
rgba(255,255,255,0.12) 30%, rgba(255,255,255,0.06) 100%);
z-index: 0;
}
.recap-row {
position: relative; display: grid;
grid-template-columns: 64px 1fr;
column-gap: 18px; align-items: center;
padding: 12px 0; cursor: pointer;
transition: transform 0.12s;
}
.recap-row:hover { transform: translateX(2px); }
.recap-node {
width: 64px; height: 64px;
position: relative; z-index: 2;
}
.recap-node::before {
content: ''; position: absolute; inset: -2px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.recap-node :deep(svg) {
width: 100%; height: 100%; display: block;
filter: drop-shadow(0 0 6px var(--node-glow, rgba(167,139,250,0.3)));
transition: filter 0.15s;
}
.recap-row:hover .recap-node :deep(svg) {
filter: drop-shadow(0 0 10px var(--node-glow, rgba(167,139,250,0.5)));
}
.recap-card {
display: grid; grid-template-columns: 1fr auto;
gap: 16px; align-items: center;
padding: 14px 16px;
border: 1px solid var(--hairline); border-radius: 8px;
background: rgba(255,255,255,0.02);
transition: background 0.12s, border-color 0.12s;
}
.recap-row:hover .recap-card {
background: rgba(255,255,255,0.035);
border-color: var(--hairline-strong);
}
.recap-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.recap-period {
font-family: var(--font-mono); font-size: 12px;
color: var(--muted); letter-spacing: 0.02em;
display: flex; align-items: center; gap: 8px;
}
.recap-period .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; }
.recap-archetype {
font-family: var(--font-serif); font-size: 20px;
font-weight: 500; color: var(--fg); letter-spacing: -0.01em;
}
.recap-subtitle {
font-family: var(--font-serif); font-style: italic;
font-size: 14.5px; color: var(--fg-3); line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.recap-stats {
margin-top: 4px; font-family: var(--font-mono);
font-size: 11.5px; color: var(--muted-2);
font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
display: flex; gap: 10px;
}
.recap-stats .sep { color: var(--muted-3); }
.recap-right {
display: flex; align-items: center; flex-shrink: 0;
color: var(--muted-2); transition: color 0.12s;
}
.recap-row:hover .recap-right { color: var(--fg-3); }
.recap-right svg { width: 14px; height: 14px; }
/* Empty state */
.empty-content { padding-top: 32px; }
.empty-timeline { position: relative; padding-top: 8px; }
.empty-timeline::before {
content: ''; position: absolute;
left: 15px; top: 24px; bottom: 24px;
width: 1px; margin-left: -0.5px;
background: repeating-linear-gradient(
to bottom, var(--muted-3) 0px, var(--muted-3) 3px,
transparent 3px, transparent 7px);
opacity: 0.55;
}
.empty-row {
display: grid; grid-template-columns: 30px 1fr;
column-gap: 28px; align-items: center; padding: 14px 0;
}
.empty-node {
width: 30px; height: 30px; position: relative; z-index: 2;
display: grid; place-items: center;
}
.empty-node::before {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.empty-node::after {
content: ''; width: 10px; height: 10px;
border: 1.5px dashed var(--muted-2);
transform: rotate(45deg); border-radius: 1px;
}
.empty-row.placeholder .empty-card {
height: 12px; background: transparent;
border: 1px dashed var(--muted-3); border-radius: 6px; opacity: 0.4;
}
.empty-cta {
padding: 28px 22px;
border: 1px dashed var(--hairline-strong); border-radius: 10px;
background: rgba(255,255,255,0.015);
display: flex; flex-direction: column; gap: 16px;
}
.empty-eyebrow {
font-family: var(--font-mono); font-size: 12px;
letter-spacing: 0.06em; color: var(--muted);
display: flex; align-items: center; gap: 8px;
}
.empty-eyebrow .diamond {
width: 6px; height: 6px; background: var(--muted-2);
transform: rotate(45deg); flex-shrink: 0;
}
.empty-title {
font-family: var(--font-serif); font-size: 26px;
font-weight: 500; color: var(--fg);
letter-spacing: -0.015em; line-height: 1.3; max-width: 460px;
}
.empty-body {
font-family: var(--font-serif); font-style: italic;
font-size: 15px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
}
.empty-body code {
font-family: var(--font-mono); font-style: normal;
font-size: 13px; color: var(--accent-2, #c4b5fd);
background: rgba(167,139,250,0.12); padding: 2px 8px;
border-radius: 3px; letter-spacing: 0;
}
.empty-actions { display: flex; gap: 8px; margin-top: 4px; }
.empty-actions .toolbar-action { height: 30px; padding: 0 14px; }
/* Modal */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(5, 6, 12, 0.65);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
z-index: 500;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal {
width: 100%; max-width: 480px;
background: linear-gradient(165deg, rgba(20,22,38,0.95) 0%, rgba(13,15,28,0.95) 100%);
border: 1px solid var(--hairline-strong); border-radius: 12px;
box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 12px 32px rgba(0,0,0,0.4),
inset 0 1px 0 rgba(255,255,255,0.08);
overflow: hidden;
}
.modal-head {
padding: 18px 22px 12px;
border-bottom: 1px solid var(--hairline);
display: flex; align-items: baseline; gap: 10px;
}
.modal-head .diamond {
width: 6px; height: 6px; background: #a78bfa;
transform: rotate(45deg); box-shadow: 0 0 8px rgba(167,139,250,0.35);
flex-shrink: 0; align-self: center;
}
.modal-head .title {
font-family: var(--font-serif); font-size: 17px;
font-weight: 500; color: var(--fg); flex: 1;
}
.modal-close {
color: var(--muted); width: 24px; height: 24px;
display: grid; place-items: center; border-radius: 4px;
border: none; background: none; cursor: pointer; transition: all 0.1s;
}
.modal-close:hover { color: var(--fg-2); background: var(--surface); }
.modal-close svg { width: 12px; height: 12px; }
.modal-body { padding: 18px 22px 20px; }
.modal-body p {
font-family: var(--font-serif); font-style: italic;
font-size: 13.5px; color: var(--fg-2); line-height: 1.6; margin-bottom: 14px;
}
.modal-options {
display: flex; flex-direction: column; gap: 1px;
background: var(--hairline); border: 1px solid var(--hairline);
border-radius: 6px; overflow: hidden; margin-bottom: 14px;
}
.modal-option {
padding: 10px 14px; background: rgba(0,0,0,0.2);
display: flex; align-items: center; gap: 10px;
cursor: pointer; border: none; color: inherit; width: 100%; text-align: left;
transition: background 0.08s;
}
.modal-option:hover { background: rgba(255,255,255,0.025); }
.modal-option.active { background: rgba(167,139,250,0.12); }
.modal-option-label {
font-family: var(--font-mono); font-size: 12px;
color: var(--fg-2); flex: 1;
}
.modal-option.active .modal-option-label { color: #c4b5fd; }
.modal-option-radio {
width: 12px; height: 12px;
border: 1.5px solid var(--muted-2); border-radius: 50%;
position: relative; flex-shrink: 0; transition: all 0.1s;
}
.modal-option.active .modal-option-radio { border-color: #a78bfa; }
.modal-option.active .modal-option-radio::after {
content: ''; position: absolute; inset: 2px;
background: #a78bfa; border-radius: 50%;
box-shadow: 0 0 6px rgba(167,139,250,0.35);
}
.cmd-block {
position: relative;
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
border-radius: 6px; padding: 14px 50px 14px 16px; margin-bottom: 14px;
}
.cmd-block code {
font-family: var(--font-mono); font-size: 12.5px;
color: var(--fg); letter-spacing: 0.005em; word-break: break-all;
}
.cmd-block code .prompt { color: #c4b5fd; margin-right: 4px; }
.cmd-copy {
position: absolute; top: 50%; right: 8px; transform: translateY(-50%);
width: 32px; height: 32px; display: grid; place-items: center;
color: var(--muted); border-radius: 5px; border: none; background: none;
cursor: pointer; transition: all 0.1s;
}
.cmd-copy:hover { color: var(--fg); background: var(--surface); }
.cmd-copy.copied { color: #c4b5fd; background: rgba(167,139,250,0.12); }
.cmd-copy svg { width: 14px; height: 14px; }
.modal-hint {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted-2); letter-spacing: 0.02em; line-height: 1.5;
}
</style>
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative } from '../utils.js';
defineOptions({ name: 'SessionList' });
const router = useRouter();
const debugEmpty = ref(false);
function onKeydown(e) {
if (e.key === 'm' && !e.metaKey && !e.ctrlKey && e.target.tagName !== 'INPUT') {
debugEmpty.value = !debugEmpty.value;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
const homePath = (typeof process !== 'undefined' && process.env?.HOME) || '~';
const visibleSessions = computed(() => {
const q = state.query.trim().toLowerCase();
return state.sessions
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
.filter(s => state.sourceFilter === 'all' || (s.source || 'claude') === state.sourceFilter)
.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.ended_at || a.started_at || 0).getTime();
const tb = new Date(b.ended_at || b.started_at || 0).getTime();
return state.sortDesc ? tb - ta : ta - tb;
});
});
const showProjectPrefix = computed(() => state.projectFilter === 'all');
const showNoise = ref(false);
function isNoise(s) {
return !s.title;
}
const normalSessions = computed(() => visibleSessions.value.filter(s => !isNoise(s)));
const noiseSessions = computed(() => visibleSessions.value.filter(s => isNoise(s)));
function titleHTML(session) {
return highlightPlain(session.title || '(untitled)', state.query.trim());
}
function projectLabel(session) {
return escapeHTML(formatProjectLabel(session.project));
}
function timeLabel(session) {
const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts);
}
function lastActiveLabel(session) {
const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts);
}
function createdLabel(session) {
const ts = new Date(session.started_at || 0).getTime();
return fmtRelative(ts);
}
function openSession(session) {
router.push({ name: 'SessionDetail', params: { id: session.id } });
}
function obeliskStyle(session) {
const created = new Date(session.started_at || 0).getTime();
const days = Math.max(0, (Date.now() - created) / 86400000);
const height = Math.min(1, Math.log(1 + days) / Math.log(1 + 365));
let color;
if (days < 7) color = '#a855f7';
else if (days < 30) color = '#6366f1';
else if (days < 90) color = '#64748b';
else color = '#475569';
const glow = days < 7 ? `0 0 4px ${color}` : 'none';
const maxHeight = 36; // px, roughly the row height minus padding
return {
height: `${Math.max(4, Math.round(height * maxHeight))}px`,
background: color,
boxShadow: glow,
};
}
</script>
<template>
<div class="session-list-wrap">
<!-- Empty state: no data source / debug toggle -->
<div v-if="state.loaded && (debugEmpty || (!visibleSessions.length && !state.query))" class="empty-content">
<div class="empty-eyebrow">
<span class="diamond"></span>
<span>No data source connected</span>
</div>
<div class="empty-title">Obelisk reads your Claude Code session history.</div>
<div class="empty-body">
We didn't find <code>~/.claude</code> on this machine. If you've already used
Claude Code, point Obelisk at where its data lives in
<button class="inline-link" @click="router.push('/settings')">Settings</button>. If you haven't,
<strong>install Claude Code first</strong> Obelisk has nothing to read until
sessions exist.
</div>
<div class="empty-actions">
<button class="toolbar-action primary" @click="router.push('/settings')">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
</svg>
Choose folder
</button>
</div>
<div class="empty-divider"></div>
<div class="empty-help">
<div class="help-row">
<span class="label">expected</span>
<code>~/.claude</code>
</div>
<div class="help-row">
<span class="label">searched</span>
<code>{{ homePath }}</code>
</div>
</div>
</div>
<!-- Empty state: search returned nothing -->
<div v-else-if="state.loaded && !visibleSessions.length" class="empty">
No sessions here.
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
</div>
<div v-else class="session-list">
<div
v-for="s in normalSessions"
:key="s.id"
class="srow"
:class="{ cursor: state.cursorId === s.id }"
:data-session-id="s.id"
@click="openSession(s)"
>
<div class="srow-obelisk" :style="obeliskStyle(s)"></div>
<div class="srow-body">
<div class="srow-title" v-html="titleHTML(s)"></div>
<div class="srow-meta">
<template v-if="showProjectPrefix">
<span class="project-tag" v-html="projectLabel(s)"></span>
<span class="dot"></span>
</template>
<span>{{ s.message_count || 0 }} msg</span>
</div>
</div>
<div class="srow-right">{{ timeLabel(s) }}</div>
</div>
<!-- Noise fold banner -->
<div v-if="noiseSessions.length && !state.query" class="fold-banner" :class="{ expanded: showNoise }" @click="showNoise = !showNoise">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 2.5l3 3.5-3 3.5"/>
</svg>
<div class="body">
<strong>{{ noiseSessions.length }}</strong> quiet sessions hidden untitled, likely tests or incomplete runs.
</div>
<span v-if="!showNoise" class="reveal-link">Show all</span>
</div>
<!-- Noise sessions (collapsed by default) -->
<div v-if="showNoise && noiseSessions.length" class="noise-group">
<div class="noise-group-head">
{{ noiseSessions.length }} sessions · untitled
</div>
<div
v-for="s in noiseSessions"
:key="s.id"
class="srow noise"
@click="openSession(s)"
>
<div class="srow-body">
<div class="srow-title">(untitled)</div>
<div class="srow-meta">
<template v-if="showProjectPrefix">
<span class="project-tag" v-html="projectLabel(s)"></span>
<span class="dot"></span>
</template>
<span>{{ s.message_count || 0 }} msg</span>
</div>
</div>
<div class="srow-right">{{ timeLabel(s) }}</div>
</div>
<button class="noise-fold-bottom" @click.stop="showNoise = false">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 2.5l3 3.5-3 3.5"/>
</svg>
Collapse
</button>
</div>
</div>
</div>
</template>
<style scoped>
.session-list-wrap {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.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-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-title :deep(mark) {
background: var(--accent-soft);
color: var(--accent-2);
padding: 0 2px;
border-radius: 2px;
}
.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-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;
}
.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);
}
/* Onboarding empty state */
.empty-content {
flex: 1;
display: flex; flex-direction: column; gap: 16px;
max-width: 520px;
margin: 0 auto;
justify-content: center;
padding: 40px;
}
.empty-eyebrow {
display: flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 11px;
color: var(--muted); letter-spacing: 0.04em;
}
.empty-eyebrow .diamond {
width: 6px; height: 6px;
background: var(--accent, #a78bfa); transform: rotate(45deg);
box-shadow: 0 0 6px rgba(167,139,250,0.4); flex-shrink: 0;
}
.empty-title {
font-family: var(--font-serif, Georgia); font-size: 22px;
font-weight: 500; color: var(--fg);
letter-spacing: -0.015em; line-height: 1.2;
}
.empty-body {
font-family: var(--font-serif, Georgia); font-style: italic;
font-size: 14px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
}
.empty-body code {
font-family: var(--font-mono); font-style: normal; font-size: 12.5px;
color: var(--accent-2, #c4b5fd); background: rgba(167,139,250,0.12);
padding: 1px 6px; border-radius: 3px;
}
.empty-body strong { color: var(--fg); font-weight: 600; font-style: normal; }
.empty-body .inline-link {
color: var(--accent-2, #c4b5fd); background: none;
border: none; border-bottom: 1px solid rgba(167,139,250,0.4);
padding: 0 0 1px; font: inherit; cursor: pointer; transition: all 0.12s;
}
.empty-body .inline-link:hover { color: var(--accent, #a78bfa); border-bottom-color: var(--accent); }
.empty-actions { display: flex; gap: 8px; margin-top: 6px; }
.empty-actions .toolbar-action {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 14px; border-radius: 5px;
font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.12s;
}
.empty-actions .toolbar-action.primary {
border: 1px solid rgba(167,139,250,0.35); background: rgba(167,139,250,0.12); color: #c4b5fd;
}
.empty-actions .toolbar-action.primary:hover {
background: rgba(167,139,250,0.18); border-color: #a78bfa; color: var(--fg);
box-shadow: 0 0 12px rgba(167,139,250,0.2);
}
.empty-actions .toolbar-action svg { width: 13px; height: 13px; }
.empty-divider { width: 100%; height: 1px; background: var(--hairline); margin: 6px 0; }
.empty-help {
display: flex; flex-direction: column; gap: 6px;
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
}
.empty-help .help-row { display: flex; align-items: baseline; gap: 8px; }
.empty-help .help-row .label { color: var(--muted-2); letter-spacing: 0.04em; width: 76px; flex-shrink: 0; }
.empty-help code {
font-family: var(--font-mono); color: var(--fg-2);
background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px;
}
/* Noise fold */
.fold-banner {
display: flex; align-items: center; gap: 12px;
padding: 10px 22px;
background: rgba(255,255,255,0.015);
border-top: 1px solid var(--hairline);
border-bottom: 1px solid var(--hairline);
font-size: 12.5px; color: var(--muted);
cursor: pointer; transition: all 0.1s;
}
.fold-banner:hover { background: rgba(255,255,255,0.03); color: var(--fg-2); }
.fold-banner.expanded { color: var(--fg-3); background: rgba(255,255,255,0.02); }
.fold-banner .chev {
width: 10px; height: 10px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.fold-banner.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
.fold-banner .body { flex: 1; }
.fold-banner .body strong {
color: var(--fg-2); font-weight: 500;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono); font-size: 11.5px;
}
.fold-banner .reveal-link {
font-size: 11.5px; color: var(--accent-2);
text-decoration: none; border-bottom: 1px solid rgba(167,139,250,0.4);
padding-bottom: 1px; transition: all 0.12s; flex-shrink: 0;
}
.fold-banner:hover .reveal-link { color: var(--accent); border-bottom-color: var(--accent); }
.noise-group {
border-bottom: 1px solid var(--hairline-strong);
background: rgba(0,0,0,0.15);
}
.noise-group-head {
padding: 6px 22px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
letter-spacing: 0.06em; text-transform: uppercase;
background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--hairline);
}
.srow.noise { padding: 8px 22px 8px 18px; }
.srow.noise .srow-title {
color: var(--muted); font-style: italic;
font-size: 13px; font-weight: 400;
}
.srow.noise .srow-meta { color: var(--muted-2); }
.noise-fold-bottom {
padding: 8px 22px; background: rgba(0,0,0,0.2);
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
cursor: pointer; transition: all 0.1s;
display: flex; align-items: center; gap: 8px;
border-top: 1px solid var(--hairline);
border: none; width: 100%; text-align: left;
}
.noise-fold-bottom:hover { background: rgba(0,0,0,0.3); color: var(--fg-2); }
.noise-fold-bottom .chev {
width: 9px; height: 9px; color: var(--muted-2);
transform: rotate(-90deg);
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
defineOptions({ name: 'Settings' });
const sources = ref([]);
const dbPath = ref('');
const recapPath = ref('');
const autoRefresh = ref(true);
const memoryCount = ref(0);
const rebuilding = ref(false);
const version = ref('0.1.0');
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
if (!window.obelisk?.getSettings) return;
const s = await window.obelisk.getSettings();
sources.value = s.sources || [];
dbPath.value = s.dbPath || '';
recapPath.value = s.recapDir || '~/.obelisk/recap';
autoRefresh.value = s.autoRefresh !== false;
memoryCount.value = s.memoryCount || 0;
}
async function browseSourcePath(source) {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
const key = source.id === 'claude' ? 'claudeDir' : 'codexDir';
await saveSetting(key, result);
await loadSettings();
}
}
async function browseRecapPath() {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
recapPath.value = result;
await saveSetting('recapDir', result);
}
}
async function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value;
await saveSetting('autoRefresh', autoRefresh.value);
}
async function saveSetting(key, value) {
if (window.obelisk?.setSetting) {
await window.obelisk.setSetting(key, value);
}
}
async function commitRecapPath() {
await saveSetting('recapDir', recapPath.value);
}
async function rebuildIndex() {
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
rebuilding.value = true;
await nextTick();
await new Promise(resolve => requestAnimationFrame(resolve));
try {
await window.obelisk.rebuildIndex();
await loadSettings();
} finally {
rebuilding.value = false;
}
}
async function revealDb() {
if (window.obelisk?.revealPath) {
window.obelisk.revealPath(dbPath.value);
}
}
function fmtRelative(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
</script>
<template>
<div class="settings-wrap">
<div class="settings-content">
<!-- Data Sources -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Data Sources</h2>
<p>Where Obelisk reads your agent session history.</p>
</div>
<div
v-for="src in sources" :key="src.id"
class="source-card"
:class="{ error: src.status === 'error', warn: src.status === 'warn' }"
>
<div class="source-card-head">
<div class="source-card-mark" :class="src.id">
<span class="mark-dot"></span>
</div>
<div class="source-card-info">
<div class="source-card-name">
{{ src.name }}
<span class="vendor">by {{ src.vendor }}</span>
</div>
<div class="source-card-status">
<span class="stat-dot" :class="src.status"></span>
<span class="stat-text" :class="src.status">{{ src.statusText }}</span>
<template v-if="src.lastIndexed">
<span class="sep">·</span>
<span>last read <strong>{{ fmtRelative(src.lastIndexed) }}</strong></span>
</template>
<template v-if="src.sessionCount">
<span class="sep">·</span>
<span><strong>{{ src.sessionCount }}</strong> sessions</span>
</template>
</div>
</div>
</div>
<div class="source-card-body">
<div class="path-input">
<input class="path-field" :class="{ error: src.status === 'error' }" type="text" :value="src.path" spellcheck="false" readonly/>
<button class="btn" @click="browseSourcePath(src)">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
</svg>
Browse
</button>
</div>
</div>
</div>
</section>
<!-- Index -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Index location</h2>
<p>SQLite database where Obelisk caches the unified session index.</p>
</div>
<div class="path-input" style="max-width: 480px;">
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
<button class="btn" @click="revealDb">Reveal</button>
</div>
</section>
<!-- Auto-refresh -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Auto-refresh</h2>
<p>Obelisk re-reads when new session files appear.</p>
</div>
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
<span class="toggle-track" :class="{ on: autoRefresh }">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Watch data sources for changes</span>
</label>
</section>
<!-- Recap -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Recap</h2>
<p>Where generated weekly and monthly recap files live.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Recap output directory</div>
<div class="form-label-hint">Watched by Obelisk for new <code>recap-*.json</code> files.</div>
</div>
<div class="form-control">
<div class="path-input">
<input
class="path-field"
type="text"
v-model="recapPath"
spellcheck="false"
@keydown.enter="commitRecapPath"
@blur="commitRecapPath"
/>
<button class="btn" @click="browseRecapPath">Browse</button>
</div>
</div>
</div>
</section>
<!-- About -->
<section class="settings-section last">
<div class="settings-section-head">
<h2>About</h2>
<p>The kind of details you don't usually need.</p>
</div>
<div class="form-row">
<div class="form-label">Version</div>
<div class="form-control version-text">
Obelisk {{ version }}
</div>
</div>
<div class="form-row">
<div class="form-label">Reset</div>
<div class="form-control">
<div class="reset-actions">
<button class="btn" :disabled="rebuilding" @click="rebuildIndex">
{{ rebuilding ? 'Rebuilding' : 'Rebuild index' }}
</button>
</div>
<div class="reset-hint">
Rebuilding re-reads your coding agent session data. It does not delete memories or recaps.
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.settings-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.settings-content { max-width: 720px; margin: 0 auto; padding: 36px 32px 80px; }
.settings-section { margin-bottom: 44px; }
.settings-section.last { margin-bottom: 0; }
.settings-section-head {
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline);
}
.settings-section-head h2 {
font-size: 18px; font-weight: 600;
color: var(--fg); letter-spacing: -0.01em; margin-bottom: 2px;
}
.settings-section-head p {
font-size: 13px; color: var(--muted);
}
/* Source cards */
.source-card {
padding: 18px; border: 1px solid var(--hairline); border-radius: 8px;
background: rgba(0,0,0,0.18); margin-bottom: 12px;
transition: border-color 0.15s;
}
.source-card:hover { border-color: var(--hairline-strong); }
.source-card.error { border-color: rgba(248,113,113,0.25); }
.source-card.warn { border-color: rgba(251,191,36,0.20); }
.source-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.source-card-mark {
width: 28px; height: 28px; border-radius: 6px;
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
display: grid; place-items: center; flex-shrink: 0;
}
.source-card-mark .mark-dot { width: 8px; height: 8px; border-radius: 50%; }
.source-card-mark.claude .mark-dot { background: #d97757; box-shadow: 0 0 6px rgba(217,119,87,0.5); }
.source-card-mark.codex .mark-dot { background: #10a37f; box-shadow: 0 0 6px rgba(16,163,127,0.5); }
.source-card-info { flex: 1; min-width: 0; }
.source-card-name {
font-size: 14px; color: var(--fg); font-weight: 600; letter-spacing: -0.005em;
display: flex; align-items: baseline; gap: 8px;
}
.source-card-name .vendor { font-size: 11.5px; color: var(--muted); font-weight: 400; }
.source-card-status {
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
margin-top: 3px; display: flex; align-items: center; gap: 8px;
}
.source-card-status .stat-dot { width: 6px; height: 6px; border-radius: 50%; position: relative; }
.source-card-status .stat-dot.ok { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,0.5); }
.source-card-status .stat-dot.warn { background: #fbbf24; box-shadow: 0 0 5px rgba(251,191,36,0.5); }
.source-card-status .stat-dot.error { background: #f87171; box-shadow: 0 0 5px rgba(248,113,113,0.5); }
.source-card-status .stat-dot.ok::before {
content: ''; position: absolute; inset: -2.5px; border-radius: 50%;
border: 1px solid #34d399; opacity: 0.5; animation: src-pulse 1.6s ease-out infinite;
}
@keyframes src-pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.8); opacity: 0; } }
.source-card-status .stat-text { color: var(--fg-2); }
.source-card-status .stat-text.ok { color: #34d399; }
.source-card-status .stat-text.warn { color: #fbbf24; }
.source-card-status .stat-text.error { color: #f87171; }
.source-card-status .sep { color: var(--muted-3); }
.source-card-status strong { color: var(--fg-2); font-weight: 500; }
.source-card-body { display: flex; flex-direction: column; gap: 10px; }
.form-row {
display: grid; grid-template-columns: 180px 1fr;
gap: 24px; padding: 14px 0; align-items: start;
}
.form-row + .form-row { border-top: 1px solid var(--hairline); }
.form-label { font-size: 13px; color: var(--fg-2); font-weight: 500; padding-top: 6px; }
.form-label-hint {
font-size: 11.5px; color: var(--muted); margin-top: 4px; font-weight: 400;
}
.form-label-hint code {
font-family: var(--font-mono); font-style: normal; font-size: 10.5px;
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px; color: var(--muted);
}
.form-control { display: flex; flex-direction: column; gap: 8px; }
.path-input { display: flex; gap: 6px; }
.path-field {
flex: 1; height: 28px; padding: 0 10px;
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline-strong);
border-radius: 5px; font-family: var(--font-mono); font-size: 12px;
color: var(--fg); min-width: 0; transition: all 0.12s;
}
.path-field:focus { outline: 0; border-color: var(--accent); background: rgba(0,0,0,0.4); box-shadow: 0 0 0 2px rgba(167,139,250,0.12); }
.path-field.error { border-color: rgba(248,113,113,0.4); }
.path-field.error:focus { border-color: #f87171; box-shadow: 0 0 0 2px rgba(248,113,113,0.12); }
.tz-field { max-width: 240px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px;
border: 1px solid var(--hairline-strong); border-radius: 5px;
background: var(--surface); color: var(--fg-2);
font-size: 12px; font-weight: 500; cursor: pointer;
transition: all 0.12s; white-space: nowrap;
}
.btn:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.subtle { background: transparent; border-color: transparent; color: var(--muted); }
.btn.subtle:hover { background: var(--surface); color: var(--fg-2); }
.btn svg { width: 13px; height: 13px; }
.status-row {
display: flex; align-items: center; gap: 14px;
padding: 8px 12px; background: rgba(0,0,0,0.2);
border: 1px solid var(--hairline); border-radius: 5px;
font-family: var(--font-mono); font-size: 11.5px; flex-wrap: wrap;
}
.status-row.ok { border-color: rgba(52,211,153,0.20); background: rgba(52,211,153,0.04); }
.status-row.warn { border-color: rgba(251,191,36,0.20); background: rgba(251,191,36,0.04); }
.status-row.error { border-color: rgba(248,113,113,0.20); background: rgba(248,113,113,0.04); }
.status-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
position: relative;
}
.status-dot.ok { background: #34d399; box-shadow: 0 0 6px rgba(52,211,153,0.5); }
.status-dot.warn { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,0.5); }
.status-dot.error { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.5); }
.status-dot.ok::before {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; border: 1px solid #34d399; opacity: 0.5;
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.6); opacity: 0; } }
.status-text { color: var(--fg-2); font-weight: 500; }
.status-text.error { color: #f87171; }
.status-meta { display: flex; gap: 6px; color: var(--muted); align-items: center; flex-wrap: wrap; }
.status-meta strong { color: var(--fg-2); font-weight: 500; }
.status-meta .sep { color: var(--muted-2); }
.toggle-label { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.toggle-input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track {
position: relative; width: 30px; height: 16px;
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
border-radius: 8px; transition: all 0.15s;
}
.toggle-track.on { background: rgba(167,139,250,0.12); border-color: rgba(167,139,250,0.5); }
.toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 10px; height: 10px; border-radius: 50%;
background: var(--muted); transition: all 0.15s;
}
.toggle-track.on .toggle-thumb {
left: 16px; background: #c4b5fd;
box-shadow: 0 0 6px rgba(167,139,250,0.5);
}
.toggle-text { font-size: 12.5px; color: var(--fg-2); }
.toggle-text code {
font-family: var(--font-mono); font-size: 11px;
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px;
}
.version-text {
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2); padding-top: 6px;
}
.reset-actions { display: flex; gap: 8px; }
.reset-hint {
font-size: 11.5px; color: var(--muted); margin-top: 6px;
}
</style>
@@ -0,0 +1,142 @@
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { loadSubagentDetail, isTextTruncated, loadFullText } from '../data.js';
import { escapeHTML, fmtClockTime, renderMarkdown } from '../utils.js';
defineOptions({ name: 'SubagentDetail' });
const props = defineProps({ id: String, agentId: String });
const router = useRouter();
const messages = ref([]);
const loading = ref(false);
const parentSession = computed(() => state.sessions.find(s => s.id === props.id));
onMounted(async () => { await load(); });
watch(() => props.agentId, async (n, o) => { if (n && n !== o) { messages.value = []; await load(); } });
async function load() {
if (!props.agentId) return;
loading.value = true;
try {
messages.value = await loadSubagentDetail(props.agentId);
} finally { loading.value = false; }
}
function goBack() {
router.push(`/sessions/${props.id}`);
}
async function handleLoadFull(uuid, el) {
const full = await loadFullText(uuid);
if (full && el) {
const body = el.closest('.msg')?.querySelector('.markdown-msg, .markdown-compact');
if (body) body.outerHTML = renderMarkdown(full, { variant: 'msg' });
el.remove();
}
}
</script>
<template>
<div class="session-detail-wrap" ref="wrapRef">
<div class="detail-wide">
<div class="session-header">
<div class="session-eyebrow">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">Subagent</span>
</div>
<div class="session-title">{{ agentId }}</div>
<div class="session-meta-inline">
<span>{{ messages.length }} messages</span>
</div>
</div>
<div v-if="loading" class="empty">Loading</div>
<div v-else class="timeline">
<div
v-for="(msg, idx) in messages"
:key="msg.uuid"
class="msg"
:class="[msg.type === 'user' ? 'user' : 'assistant']"
:data-uuid="msg.uuid"
>
<!-- Thinking -->
<template v-if="msg.content_type === 'thinking'">
<div class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
</div>
</template>
<!-- Meta -->
<template v-else-if="msg.is_meta">
<div class="msg-meta-collapsed">
<button class="meta-toggle" @click="$event.currentTarget.closest('.msg-meta-collapsed').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<div class="meta-body" v-html="renderMarkdown(msg.text, { variant: 'compact' })"></div>
</div>
</template>
<!-- Normal message -->
<template v-else>
<div class="msg-head">
<span class="role">{{ msg.type === 'user' ? 'Prompt' : 'Assistant' }}</span>
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
</div>
<div v-if="msg._thinking" class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg' })"></div>
</div>
<div v-if="msg.text" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
<div v-else-if="!msg.tool_calls?.length" class="msg-text empty-text">(no text content)</div>
<button
v-if="isTextTruncated(msg.text)"
class="truncated-btn"
@click="handleLoadFull(msg.uuid, $event.currentTarget)"
>Message truncated click to load full text</button>
<!-- Tool calls -->
<div v-if="msg.tool_calls?.length" class="msg-tools">
<div v-for="tc in msg.tool_calls" :key="tc.id" class="msg-tool" :class="{ 'is-error': tc.result?.is_error }">
<button class="toolcall-toggle" @click="$event.currentTarget.closest('.msg-tool').classList.toggle('open')">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getToolArgPreview(tc) }}</span>
<span v-if="tc.result?.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="tc-section">Input</div>
<pre>{{ tc.input_json }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
function getToolArgPreview(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
return j.file_path || j.command || j.path || j.description || JSON.stringify(j).slice(0, 100);
} catch { return (tc.input_json || '').slice(0, 100); }
}
</script>
+107
View File
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
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; }
File diff suppressed because it is too large Load Diff
+163
View File
@@ -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); }
+179
View File
@@ -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; }
+29
View File
@@ -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; }
+158
View File
@@ -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; }
+27
View File
@@ -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}`);
}
+158
View File
@@ -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());
+22
View File
@@ -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"]
}
+8
View File
@@ -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'
@@ -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<Record>`. 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).
@@ -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.
@@ -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.
@@ -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.
+61
View File
@@ -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.
@@ -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.
+48
View File
@@ -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',
},
},
);
+1291
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+30
View File
@@ -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"
}
}
+90
View File
@@ -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<string, unknown>;
// 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<unknown> {
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<string, unknown>): 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<unknown> {
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<unknown> {
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();
}
}
+79
View File
@@ -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 };
+310
View File
@@ -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<string, any>;
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<IndexRecord, Cursor> {
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 };
+349
View File
@@ -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<string, any>;
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<string>();
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>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
function extractMessageIsMeta(record: JsonRecord, text: string | null = extractText(record?.message?.content)): 0 | 1 {
const msg = record?.message || {};
if (record?.isMeta === true || msg.isMeta === true) return 1;
return typeof text === 'string' && COMMAND_ENVELOPE_RE.test(text) ? 1 : 0;
}
function filePath(name: string, input: JsonRecord | null | undefined): string | null {
if (!input) return null;
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
}
function isDir(p: string): boolean { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
function readLines(filePath: string, callback: (line: string) => 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<string, { path: string; count: number; first: number }>();
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,
};
+125
View File
@@ -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<IndexRecord, Cursor>): 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;
}
+131
View File
@@ -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:
// "<mtimeMs>:<linesProcessed>". 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<IndexRecord, Cursor> {
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 };
+220
View File
@@ -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<IndexRecord, Cursor> {
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<string, MessageRecord>();
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<string>();
const callMessageUuids = new Map<string, string>();
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 };
+226
View File
@@ -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<IndexRecord, Cursor>;
}
+117 -64
View File
@@ -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<string, any> {
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<?'; p.push(before); }
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; p.push(source); }
const filterParams: any[] = [];
if (sessionId) { where += ' AND mf.session_id=?'; filterParams.push(sessionId); }
if (project) { where += ' AND s.project LIKE ?'; filterParams.push(project); }
if (after) { where += ' AND m.timestamp>?'; filterParams.push(after); }
if (before) { where += ' AND m.timestamp<?'; filterParams.push(before); }
if (cwd) { where += ' AND m.cwd LIKE ?'; filterParams.push(cwd); }
if (source && source !== 'all') { where += " AND COALESCE(m.source, s.source, 'claude')=?"; filterParams.push(source); }
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
p.push(limit);
const rows = db.prepare(`
const stmt = db.prepare(`
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,m.source as m_source,
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
s.source as s_source,
rank
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
${where} ORDER BY rank LIMIT ?`).all(...p);
return rows.map(r => {
${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);
+49
View File
@@ -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 <file.js>\n node runtime.js --attune <file.js>\n');
process.exitCode = 1;
}
main();
@@ -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,
+21
View File
@@ -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<string, any>;
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;
}
+138
View File
@@ -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<T>(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');
}
+95
View File
@@ -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<T>(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<T>(
db: WriteTxDb,
work: () => T,
transactionOptions: WriteTxOptions = {},
retryOptions: WriteRetryOptions = {},
): T {
return runWithWriteRetry(
() => runWriteTransaction(db, work, transactionOptions),
retryOptions,
);
}
+91
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*"]
}
+21
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 20252026 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.
+29
View File
@@ -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 <your question>
```
## 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.
+7
View File
@@ -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"
}
+16 -7
View File
@@ -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 |
| --- | --- | --- |
+3 -3
View File
@@ -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 <script>`. The `--attune` runtime
Run this script with `runtime.js --attune <script>`. The `--attune` runtime
exposes only `remember()` and `forget()`, not retrieval helpers.
```js
@@ -204,7 +204,7 @@ the exact memory ID in a normal `--query` script first. If one candidate clearly
matches the user's request, that request is approval to archive it; if several
candidates match, ask which one to forget.
Run the mutation with `runtime.mjs --attune <script>`:
Run the mutation with `runtime.js --attune <script>`:
```js
return forget({

Some files were not shown because too many files have changed in this diff Show More