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:
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 (0001–0006)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
});
|
||||
Generated
+7368
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Memory list and detail rendering, extracted from render.js.
|
||||
|
||||
import { state, FOLDER_SVG } from './state.js';
|
||||
import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js';
|
||||
import registry from './registry.js';
|
||||
|
||||
// --- Utilities (local copies to avoid importing render.js) ---
|
||||
|
||||
function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Shared registry to break circular dependencies between modules.
|
||||
const registry = {};
|
||||
export default registry;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>`;
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Utility functions extracted from render.js
|
||||
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state).
|
||||
|
||||
import { state } from './state.js';
|
||||
|
||||
// --- Time / formatting ---
|
||||
|
||||
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
export function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
|
||||
export function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
export function fmtClockTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fmtSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
// --- HTML / Markdown ---
|
||||
|
||||
export function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
export function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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>`;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>`,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Utility functions ported from the vanilla JS utils.js.
|
||||
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
|
||||
|
||||
import { state } from './store.js';
|
||||
|
||||
// --- Time / formatting ---
|
||||
|
||||
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
export function isSameDay(a, b) {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
export function fmtListTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
if (isSameDay(d, now)) return hhmm;
|
||||
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||
}
|
||||
|
||||
export function fmtRelative(ts) {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60000, hr = 3600000, day = 86400000;
|
||||
if (diff < 0) return 'in the future';
|
||||
if (diff < min) return 'just now';
|
||||
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||
}
|
||||
|
||||
export function fmtClockTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fmtSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
// --- HTML / Markdown ---
|
||||
|
||||
export function escapeHTML(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function highlightPlain(text, query) {
|
||||
if (!query) return escapeHTML(text);
|
||||
const safe = escapeHTML(text);
|
||||
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return safe.replace(new RegExp(q, 'gi'), m => `<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(/^-/, '');
|
||||
}
|
||||
@@ -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">★</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">↳</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">★</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">↳</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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); }
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1291
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Executable
+21
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025–2026 tommy0103
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user