feat(app): add Settings view, configurable claude dir, and recap docs refactor

Introduce a Settings view for configuring the Claude data directory
  (with WSL auto-detection on Windows), sidebar project grouping module,
  and an empty-state onboarding screen for SessionList. Refactor
  recap-patterns.md into per-card reference files under references/recap/
  with separate retrieval and writing guides. Remove the legacy panel.html.
  On the data layer: incremental indexing via changedPaths, per-session
  live-update IPC (obelisk:session-updated), and session dirty-tracking
  in the renderer.
This commit is contained in:
tommy0103
2026-06-15 01:44:32 +08:00
parent 9fc7f202f0
commit b7506ee765
48 changed files with 1950 additions and 1406 deletions
+1
View File
@@ -4,3 +4,4 @@ plans/
tests/
node_modules/
dist-renderer/
release/
+25 -4
View File
@@ -124,7 +124,12 @@ same SQLite data.
- `references/schema.md` — full SQLite schema and API reference
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
- `references/recap-patterns.md` — optional `/obelisk recap` card content contract
- `references/recap/overview.md` — optional `/obelisk recap` card-by-card entrypoint
- `references/recap/pattern1-cover.md` and `references/recap/writing1-cover.md`
- `references/recap/pattern2-thinking.md` and `references/recap/writing2-thinking.md`
- `references/recap/pattern3-vibe.md` and `references/recap/writing3-vibe.md`
- `references/recap/pattern4-workflow.md` and `references/recap/writing4-workflow.md`
- `references/recap/pattern5-closing.md` and `references/recap/writing5-closing.md`
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md`
@@ -133,8 +138,11 @@ is the human/agent explanation of that contract.
The design is progressive disclosure with guardrails: the main skill keeps the
core contract and high-risk pitfalls visible, while longer recipes and the full
schema stay out of the first prompt until the agent needs them.
The optional recap reference is only for the explicit `/obelisk recap` intent;
it is not part of the ordinary retrieval path.
The optional recap references are only for the explicit `/obelisk recap` intent;
they are not part of the ordinary retrieval path. `references/recap/overview.md`
drives a card-by-card loop: read one card's retrieval pattern, gather that
card's evidence, read its writing reference, update the JSON, then continue.
This keeps schema, taste, and query planning from competing in one large prompt.
## What gets indexed
@@ -162,7 +170,20 @@ Full-text search via FTS5 covers message text across every session layer and ran
├── schema.md # Full table schema + advanced API reference
├── query-patterns.md # Copyable retrieval recipes
├── retrieval-semantics.md # Query design frame for retrieval semantics
├── recap-patterns.md # Optional /obelisk recap card content contract
├── recap-patterns.md # Compatibility pointer to references/recap/overview.md
├── recap-writing.md # Compatibility pointer to per-card recap writing docs
├── recap/
│ ├── overview.md
│ ├── pattern1-cover.md
│ ├── writing1-cover.md
│ ├── pattern2-thinking.md
│ ├── writing2-thinking.md
│ ├── pattern3-vibe.md
│ ├── writing3-vibe.md
│ ├── pattern4-workflow.md
│ ├── writing4-workflow.md
│ ├── pattern5-closing.md
│ └── writing5-closing.md
└── pitfalls.md # Scope, FTS, ordering, and compactness traps
```
+8 -4
View File
@@ -79,11 +79,11 @@ output intent, not retrieval architecture.
| Intent | Description | Reference |
|---|---|---|
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap-patterns.md` |
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap/overview.md` |
Routing rules:
1. If the first word is `recap`, read `references/recap-patterns.md` before the
1. If the first word is `recap`, read `references/recap/overview.md` before the
first query. Everything after `recap` is the recap target.
Common app-generated prompts include `/obelisk recap this week`,
`/obelisk recap last week`, `/obelisk recap this month`, and
@@ -91,8 +91,12 @@ Routing rules:
relative to the current date and timezone.
2. `recap` does not create a separate retrieval layer. It still uses
`overview()`, `memories()`, helpers, and `sql()` only when needed.
3. If the first word is not `recap`, do not load
`references/recap-patterns.md`. Continue with Query Routing below. Do not
3. Follow the overview's card-by-card sequence. Each card has its own retrieval
pattern and writing file; retrieve that card's evidence, read that card's
writing file, update the JSON, then move to the next card. Do not preload all
recap references before the current card is written.
4. If the first word is not `recap`, do not load
`references/recap/overview.md`. Continue with Query Routing below. Do not
infer recap from broad requests for weekly/monthly summaries, charts,
rankings, shareable cards, or playlist-style metaphors.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+128 -9
View File
@@ -89,7 +89,7 @@ function extractContentType(content) {
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
}
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<task-notification>|<local-command-caveat>|<local-command-stdout>)/;
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
const msg = record?.message || {};
@@ -141,7 +141,77 @@ function inferProjectPath(project, observedCwds = []) {
return best?.path || legacyProjectPathFromSlug(project);
}
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR } = {}) {
function discoverJsonlFiles({ projectsDir = DEFAULT_PROJECTS_DIR, changedPaths = undefined } = {}) {
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) {
const fp = normalizeChangedPath(projectsDir, changedPath);
if (!fp || !fp.endsWith('.jsonl')) 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 parts[1].slice(0, -6);
if (parts.length >= 3) return parts[1] || 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 = [] } = {}) {
const files = [];
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 = [];
if (!fs.existsSync(projectsDir)) return files;
let projects;
@@ -195,7 +265,26 @@ function indexJsonl(db, fi) {
if (!needed) return;
const ins = {
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
msg: db.prepare('INSERT OR REPLACE 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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
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)
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
`),
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 (?,?,?,?,?)'),
@@ -208,7 +297,7 @@ function indexJsonl(db, fi) {
git_branch: existing?.git_branch || null,
version: existing?.version || null,
title: existing?.title || null,
n: existing?.message_count || 0,
n: skip > 0 ? (existing?.message_count || 0) : 0,
cwds: [],
};
@@ -270,6 +359,7 @@ function indexJsonl(db, fi) {
ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path);
}
ins.idx.run(fi.path, mtime, lineNum);
return { sessionId: fi.sessionId, path: fi.path };
}
function refreshSessionProjectPaths(db) {
@@ -362,6 +452,15 @@ function rebuildFts(db) {
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
}
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);
}
@@ -384,9 +483,10 @@ function buildIndex({
schemaPath = resolveSchemaPath(),
DatabaseImpl = Database,
force = false,
changedPaths = undefined,
} = {}) {
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
const files = discoverJsonlFiles({ projectsDir });
const files = discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths });
const latestSourceMtime = files.reduce((latest, file) => {
try {
return Math.max(latest, fs.statSync(file.path).mtimeMs);
@@ -396,11 +496,29 @@ function buildIndex({
}, 0);
try {
if (force) db.prepare("DELETE FROM index_state WHERE jsonl_path NOT LIKE '__%'").run();
if (force) {
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();
}
const affectedSessionIds = new Set();
if (Array.isArray(changedPaths)) {
for (const changedPath of changedPaths) {
const sessionId = sessionIdFromChangedPath(projectsDir, changedPath);
if (sessionId) affectedSessionIds.add(sessionId);
}
}
for (const file of files) {
db.exec('BEGIN');
try {
indexJsonl(db, file);
const indexed = indexJsonl(db, file);
if (indexed?.sessionId) affectedSessionIds.add(indexed.sessionId);
indexSubagentMeta(db, file);
db.exec('COMMIT');
} catch (error) {
@@ -409,11 +527,12 @@ function buildIndex({
}
}
db.exec('BEGIN');
let ftsRebuilt = false;
try {
indexWorkflows(db, { projectsDir });
refreshSessionProjectPaths(db);
indexHistory(db, { historyPath });
rebuildFts(db);
ftsRebuilt = ensureFtsReady(db, { force });
writeIndexMarker(db, '__last_build__');
writeIndexMarker(db, '__app_heartbeat__');
writeIndexMarker(db, '__app_last_successful_build__');
@@ -424,7 +543,7 @@ function buildIndex({
db.exec('ROLLBACK');
throw error;
}
return { files: files.length, latestSourceMtime };
return { files: files.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt };
} finally {
db.close();
}
+248 -29
View File
@@ -6,42 +6,133 @@ const Database = require('better-sqlite3');
const { writeHeartbeat } = require('./indexer');
const { createIndexerService } = require('./indexer-service');
const { createWorkerBuildIndex } = require('./indexer-worker-client');
const { buildRecapExportQuery } = require('./recap-capture-query');
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
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();
let db;
let indexerService;
let indexerWorker;
function openDb() {
if (!fs.existsSync(DB_PATH)) return null;
function getConfiguredClaudeDir() {
const persisted = loadPersistedSettings();
return persisted.claudeDir || DEFAULT_CLAUDE_DIR;
}
function getPathsForClaudeDir(claudeDir = getConfiguredClaudeDir()) {
return {
claudeDir,
dbPath: path.join(claudeDir, 'obelisk.sqlite'),
projectsDir: path.join(claudeDir, 'projects'),
};
}
function closeDb() {
if (db) db.close();
db = new Database(DB_PATH, { readonly: false });
db = null;
}
function openDb(dbPath = getPathsForClaudeDir().dbPath) {
closeDb();
if (!fs.existsSync(dbPath)) return null;
db = new Database(dbPath, { readonly: false });
db.pragma('journal_mode = WAL');
return db;
}
function notifyIndexUpdated() {
function notifyIndexUpdated(result = {}) {
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');
win.webContents.send('obelisk:index-updated', payload);
for (const sessionId of affectedSessionIds) {
win.webContents.send('obelisk:session-updated', { sessionId });
}
}
}
function startIndexerService() {
function startIndexerService({ buildOnStart = false } = {}) {
const paths = getPathsForClaudeDir();
indexerService = createIndexerService({
buildIndex: async ({ reason }) => {
const result = await indexerWorker.buildIndex({ reason });
openDb();
notifyIndexUpdated();
projectsDir: paths.projectsDir,
buildIndex: async ({ reason, changedPaths }) => {
const result = await indexerWorker.buildIndex({
reason,
changedPaths,
claudeDir: paths.claudeDir,
projectsDir: paths.projectsDir,
dbPath: paths.dbPath,
});
openDb(paths.dbPath);
notifyIndexUpdated(result);
return result;
},
writeHeartbeat,
writeHeartbeat: () => writeHeartbeat({ dbPath: paths.dbPath }),
});
indexerService.start({ buildOnStart: false });
indexerService.start({ buildOnStart });
return indexerService;
}
function startBackgroundResources({ runStartupBuild = false } = {}) {
if (!indexerWorker) indexerWorker = createWorkerBuildIndex();
openDb();
if (!indexerService) {
const service = startIndexerService({ buildOnStart: false });
if (runStartupBuild) service.runBuildNow('startup');
}
if (!obeliskWatcher) startObeliskWatcher();
}
async function stopIndexerServiceAndWait() {
const service = indexerService;
if (!service) return;
service.stop();
if (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');
const shouldOpenDevTools = process.argv.includes('--devtools');
const win = new BrowserWindow({
width: 1200,
height: 800,
@@ -54,6 +145,7 @@ function createWindow() {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
devTools: isDev || shouldOpenDevTools,
},
});
@@ -64,10 +156,11 @@ function createWindow() {
}
});
const isDev = process.argv.includes('--dev');
if (isDev) {
win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
if (shouldOpenDevTools) {
win.webContents.openDevTools();
}
} else {
win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html'));
}
@@ -78,6 +171,7 @@ const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
let obeliskWatcher = null;
function startObeliskWatcher() {
if (obeliskWatcher) return obeliskWatcher;
const chokidar = require('chokidar');
if (!fs.existsSync(OBELISK_DIR)) {
fs.mkdirSync(OBELISK_DIR, { recursive: true });
@@ -94,6 +188,7 @@ function startObeliskWatcher() {
obeliskWatcher.on('add', onObeliskChange);
obeliskWatcher.on('change', onObeliskChange);
obeliskWatcher.on('unlink', onObeliskChange);
return obeliskWatcher;
}
function onObeliskChange(filePath) {
@@ -105,25 +200,23 @@ function onObeliskChange(filePath) {
}
app.whenReady().then(() => {
indexerWorker = createWorkerBuildIndex();
openDb();
startBackgroundResources({ runStartupBuild: true });
createWindow();
startIndexerService().runBuildNow('startup');
startObeliskWatcher();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
if (BrowserWindow.getAllWindows().length === 0) {
startBackgroundResources({ runStartupBuild: true });
createWindow();
}
});
});
app.on('before-quit', () => {
if (indexerService) indexerService.stop();
if (indexerWorker) indexerWorker.stop();
if (obeliskWatcher) obeliskWatcher.close();
void stopBackgroundResources({ stopWorker: true });
});
app.on('window-all-closed', () => {
if (db) db.close();
void stopBackgroundResources({ stopWorker: true });
if (process.platform !== 'darwin') app.quit();
});
@@ -366,7 +459,7 @@ async function createExportCapture(parentWin, query) {
: `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`;
await exportWin.loadURL(url);
await new Promise(r => setTimeout(r, 500));
await waitForExportReady(exportWin.webContents);
const image = await exportWin.webContents.capturePage({
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
@@ -375,10 +468,22 @@ async function createExportCapture(parentWin, query) {
return image;
}
ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
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 = `card=${cardIdx}&arch=${archetype}`;
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
const image = await createExportCapture(win, query);
const { filePath } = await dialog.showSaveDialog(win, {
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
@@ -389,10 +494,10 @@ ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
return filePath;
});
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => {
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype, filename } = {}) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
const query = `card=${cardIdx}&arch=${archetype}`;
const query = buildRecapExportQuery({ cardIdx, archetype, filename });
const image = await createExportCapture(win, query);
clipboard.writeImage(image);
return true;
@@ -415,3 +520,117 @@ ipcMain.handle('recap:read', (_, filename) => {
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, dbPath: dbFile } = getPathsForClaudeDir(persisted.claudeDir || DEFAULT_CLAUDE_DIR);
const recapDir = persisted.recapDir || RECAP_DIR;
const exists = fs.existsSync(claudeDir);
let sessionCount = 0;
let memoryCount = 0;
let lastIndexed = '';
if (db) {
try {
sessionCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0;
memoryCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
const latest = db.prepare('SELECT MAX(started_at) as t FROM sessions').get();
lastIndexed = latest?.t || '';
} catch {}
}
return {
claudeDir,
dbPath: dbFile,
recapDir,
autoRefresh: persisted.autoRefresh !== false,
sessionCount,
memoryCount,
lastIndexed,
status: exists ? 'ok' : 'error',
statusText: exists ? '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') {
await stopIndexerServiceAndWait();
openDb();
if (persisted.autoRefresh !== false) {
startIndexerService({ buildOnStart: true });
}
notifyIndexUpdated();
}
return true;
});
ipcMain.handle('settings:browseFolder', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
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) => {
const { shell } = require('electron');
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);
const shouldRestartWatcher = persisted.autoRefresh !== false;
await stopIndexerServiceAndWait();
closeDb();
try {
const result = await indexerWorker.buildIndex({
reason: 'manual-rebuild',
force: true,
claudeDir: paths.claudeDir,
projectsDir: paths.projectsDir,
dbPath: paths.dbPath,
});
openDb(paths.dbPath);
notifyIndexUpdated(result);
return result;
} finally {
if (shouldRestartWatcher) startIndexerService({ buildOnStart: false });
}
});
+31 -6
View File
@@ -8,26 +8,51 @@
"dev": "electron . --dev",
"dev:renderer": "vite renderer",
"build:renderer": "vite build renderer",
"build": "electron-builder"
"build": "npm run build:renderer && electron-builder",
"pack": "npm run build:renderer && electron-builder --dir",
"dist": "npm run build:renderer && electron-builder --mac --win --linux",
"dist:mac": "npm run build:renderer && electron-builder --mac",
"dist:win": "npm run build:renderer && electron-builder --win",
"dist:linux": "npm run build:renderer && electron-builder --linux"
},
"build": {
"appId": "com.obelisk.app",
"productName": "Obelisk",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release"
},
"mac": {
"target": "dmg",
"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": [
"main.js",
"preload.js",
"recap-capture-query.js",
"indexer.js",
"indexer-service.js",
"indexer-worker.js",
"indexer-worker-client.js",
"dist-renderer/**/*",
"node_modules/better-sqlite3/**/*",
"node_modules/chokidar/**/*",
"node_modules/readdirp/**/*"
"dist-renderer/**/*"
],
"extraResources": [
{
+11 -1
View File
@@ -20,10 +20,15 @@ contextBridge.exposeInMainWorld('obelisk', {
getStats: () => ipcRenderer.invoke('db:getStats'),
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
onIndexUpdated: (callback) => {
const listener = () => callback();
const listener = (_, payload) => callback(payload);
ipcRenderer.on('obelisk:index-updated', listener);
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
},
onSessionUpdated: (callback) => {
const listener = (_, payload) => callback(payload);
ipcRenderer.on('obelisk:session-updated', listener);
return () => ipcRenderer.removeListener('obelisk:session-updated', listener);
},
captureExport: (opts) => ipcRenderer.invoke('capture:export', opts),
copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts),
recapList: () => ipcRenderer.invoke('recap:list'),
@@ -33,4 +38,9 @@ contextBridge.exposeInMainWorld('obelisk', {
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, value) => ipcRenderer.invoke('settings:set', key, value),
revealPath: (p) => ipcRenderer.invoke('settings:revealPath', p),
rebuildIndex: () => ipcRenderer.invoke('settings:rebuildIndex'),
});
+18
View File
@@ -0,0 +1,18 @@
const path = require('path');
function cleanRecapFilename(filename) {
if (!filename) return '';
return path.basename(String(filename));
}
function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = {}) {
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();
}
module.exports = { buildRecapExportQuery, cleanRecapFilename };
+35 -32
View File
@@ -14,6 +14,7 @@ import {
toggleIncludeMessageBodies
} from './store.js';
import { formatProjectLabel } from './utils.js';
import { buildSidebarProjects } from './sidebar-projects.mjs';
const router = useRouter();
const route = useRoute();
@@ -30,38 +31,24 @@ const currentRouteType = computed(() => {
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 sidebarProjects = computed(() => {
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (currentRouteType.value === '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 => formatProjectLabel(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
}));
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 totalProjectCount = computed(() => {
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
return new Set(items.map(i => i.project).filter(Boolean)).size;
return sidebarProjectsForCurrentScope('').length;
});
// --- Toolbar visibility ---
@@ -86,6 +73,8 @@ const windowTitle = computed(() => {
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);
@@ -220,7 +209,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
<div class="sidebar-section-title"><span>Library</span></div>
<button
class="sidebar-item"
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
: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">
@@ -324,6 +313,24 @@ provide('recapGenerateOpen', recapGenerateOpen);
</button>
</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">
@@ -374,6 +381,7 @@ provide('recapGenerateOpen', recapGenerateOpen);
</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>
@@ -433,10 +441,5 @@ provide('recapGenerateOpen', recapGenerateOpen);
</router-view>
</main>
</div>
<div class="statusbar">
<div class="status-left" id="status-left"></div>
<div class="status-right" id="status-right"></div>
</div>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup>
defineProps({
headline: String,
receipts: Array,
stats: Array,
mostSaidPhrase: String,
signoff: String,
@@ -21,8 +22,8 @@ defineProps({
<div class="closing-body">
<div class="closing-headline">{{ headline }}</div>
<div class="closing-stats" v-if="stats">
<div v-for="(line, i) in stats" :key="i">{{ line }}</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">
@@ -6,6 +6,7 @@ const props = defineProps({
archKey: String,
badge: String,
title: String,
claim: String,
subtitle: String,
activity: Array,
footer: String,
@@ -28,7 +29,7 @@ const sealSvg = computed(() => CORNER_SEALS[props.archKey] || '');
<div class="cover-seal-corner" v-html="sealSvg"></div>
<div class="cover-body">
<div class="cover-archetype">{{ title }}</div>
<div class="cover-subtitle">{{ subtitle }}</div>
<div class="cover-subtitle">{{ claim || subtitle }}</div>
<div class="cover-activity">
<div class="cover-activity-row">
@@ -24,7 +24,7 @@ defineProps({
<div class="tl-day">{{ item.day }}</div>
<div class="tl-prompt">{{ item.prompt }}</div>
<div class="tl-outcome">
<span>{{ item.outcome }}</span>
<span>{{ item.turn || item.outcome }}</span>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup>
defineProps({
title: String,
voiceLines: Array,
observations: Array,
meter: Object,
quote: Object,
@@ -23,7 +24,7 @@ defineProps({
<div class="vibe-section">
<div class="section-label">Things you kept saying</div>
<div class="vibe-observations">
<div v-for="(obs, i) in observations" :key="i" class="vibe-obs">
<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>
@@ -1,6 +1,7 @@
<script setup>
defineProps({
title: String,
deck: String,
summary: String,
stats: String,
items: Array,
@@ -19,7 +20,7 @@ defineProps({
<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="summary">{{ summary }}</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>
@@ -27,7 +28,7 @@ defineProps({
<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-outcome">{{ item.outcome }}</div>
<div class="wf-item-reaction">{{ item.reaction || item.outcome }}</div>
</div>
</div>
@@ -67,12 +68,12 @@ defineProps({
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
color: var(--fg); letter-spacing: -0.005em;
}
.wf-item-outcome {
.wf-item-reaction {
font-family: var(--font-serif); font-style: italic;
font-size: 16px; color: var(--fg-2); line-height: 1.4;
}
.wf-item-outcome::before { content: '\201C'; color: var(--muted-2); }
.wf-item-outcome::after { content: '\201D'; color: var(--muted-2); }
.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;
+7 -1
View File
@@ -4,6 +4,7 @@ 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';
@@ -11,7 +12,6 @@ import '../styles/sidebar.css';
import '../styles/toolbar.css';
import '../styles/list.css';
import '../styles/detail.css';
import '../styles/statusbar.css';
const app = createApp(App);
@@ -33,4 +33,10 @@ 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');
+13 -13
View File
@@ -30,7 +30,7 @@
"persona": {
"archetype": "architect",
"title": "The Architect",
"subtitle": "从零设计了一个完整的 memory 系统。",
"claim": "从零设计了一个完整的 memory 系统。",
"tone": "affectionate_teasing"
},
@@ -39,7 +39,7 @@
"type": "cover",
"badge": "Week 24",
"title": "The Architect",
"subtitle": "从零设计了一个完整的 memory 系统。",
"claim": "从零设计了一个完整的 memory 系统。",
"activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
"footer": "12 sessions · 2.4M tokens"
},
@@ -47,17 +47,17 @@
"type": "thinking_path",
"title": "Five questions, five turns.",
"items": [
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki", "outcome": "raw SQLite, no wiki" },
{ "day": "Tue", "prompt": "buildWhere 是什么", "outcome": "unified filter opts, not DSL" },
{ "day": "Wed", "prompt": "failures() 90% 误报", "outcome": "is_error in JSONL" },
{ "day": "Thu", "prompt": "memory 层需要清理机制吗", "outcome": "soft-delete, human-only" },
{ "day": "Fri", "prompt": "热力图不选中默认显示本月", "outcome": "GitHub-style activity timeline" }
{ "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.",
"observations": [
"voice_lines": [
{ "label": "catchphrase", "text": "这太丑了", "count": 4 },
{ "label": "highest praise", "text": "可以" },
{ "label": "late night", "text": "你在干什么", "time": "02:47 AM" }
@@ -75,19 +75,19 @@
{
"type": "workflow",
"title": "Three workflows. Forty-two agents.",
"summary": "你召唤了机器军团。结果各有不同。",
"deck": "你召唤了机器军团。结果各有不同。",
"stats": "3 workflows · 42 agents",
"items": [
{ "name": "hono-plugin-review", "outcome": "完美" },
{ "name": "vue-migration", "outcome": "你这页面完全和之前的不一样…" },
{ "name": "split-render-js", "outcome": "可以" }
{ "name": "hono-plugin-review", "reaction": "完美" },
{ "name": "vue-migration", "reaction": "你这页面完全和之前的不一样…" },
{ "name": "split-render-js", "reaction": "可以" }
],
"verdict": "Mostly tolerated."
},
{
"type": "closing",
"headline": "19 days",
"stats": ["847 messages exchanged", "12 corrections · 47 approvals"],
"receipts": ["847 messages exchanged", "12 corrections · 47 approvals"],
"most_said_phrase": "好的开始做吧",
"signoff": "See you next week."
}
+6
View File
@@ -13,6 +13,7 @@ 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 = [
{
@@ -65,6 +66,11 @@ const routes = [
name: 'RecapExport',
component: RecapExport
},
{
path: '/settings',
name: 'Settings',
component: Settings
},
{
path: '/',
redirect: '/memory'
+31
View File
@@ -0,0 +1,31 @@
export function createSessionLiveState() {
return {
dirtySessions: new Set(),
};
}
export const sessionLiveState = createSessionLiveState();
export function noteSessionUpdated(live, sessionId, currentSessionId = null) {
if (!sessionId) return { reload: false, sessionId: null };
if (sessionId === currentSessionId) {
live.dirtySessions.delete(sessionId);
return { reload: true, sessionId };
}
live.dirtySessions.add(sessionId);
return { reload: false, sessionId };
}
export function clearSessionDirty(sessionId, live = sessionLiveState) {
if (sessionId) live.dirtySessions.delete(sessionId);
}
export function consumeSessionDirty(live, sessionId) {
if (!sessionId || !live.dirtySessions.has(sessionId)) return false;
live.dirtySessions.delete(sessionId);
return true;
}
export function consumeGlobalSessionDirty(sessionId) {
return consumeSessionDirty(sessionLiveState, sessionId);
}
+52
View File
@@ -0,0 +1,52 @@
function countByProject(items) {
const counts = {};
for (const item of items) {
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
}
return counts;
}
function orderedProjectSlugs(projectCounts, projects, formatProjectLabel) {
const seen = new Set();
const ordered = [];
for (const project of projects || []) {
const slug = project?.project;
if (!slug || !projectCounts[slug] || seen.has(slug)) continue;
seen.add(slug);
ordered.push(slug);
}
const missing = Object.keys(projectCounts)
.filter(slug => !seen.has(slug))
.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
return ordered.concat(missing);
}
export function buildSidebarProjects({
routeType,
sessions = [],
memories = [],
projects = [],
view = 'active',
search = '',
formatProjectLabel = slug => slug,
} = {}) {
const items = routeType === 'sessions'
? sessions
: memories.filter(memory => view === 'archived' ? memory.archived : !memory.archived);
const counts = countByProject(items);
const q = search.trim().toLowerCase();
return orderedProjectSlugs(counts, projects, formatProjectLabel)
.filter(slug => {
if (!q) return true;
return formatProjectLabel(slug).toLowerCase().includes(q);
})
.map(slug => ({
slug,
label: formatProjectLabel(slug),
count: counts[slug] || 0,
}));
}
+13 -3
View File
@@ -18,6 +18,13 @@ const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
// --- Constants ---
const DAY_MS = 86400000;
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'];
@@ -111,10 +118,13 @@ const longestStreak = computed(() => {
// --- 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);
const daysUntilSunday = (7 - startDate.getDay()) % 7;
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
// 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;
@@ -130,7 +140,7 @@ const weeklyBars = computed(() => {
const key = date.toISOString().slice(0, 10);
tokens += dailyMap[key] || 0;
}
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
weeks.push({ weekStart, tokens, weekKey: localDateStr(weekStart) });
}
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
+14 -2
View File
@@ -56,7 +56,19 @@ function statusGlyphs(status) {
}
function pathHTML(m) {
return highlightPlain(m.path || '', state.query.trim());
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) {
@@ -301,7 +313,7 @@ onUnmounted(() => {
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ detailMemory.path }}</div>
<div class="detail-path">{{ relativePath(detailMemory) }}</div>
<div class="detail-summary">{{ detailMemory.summary }}</div>
<div class="detail-meta">
<span>{{ fmtRelative(detailMemory.ts) }}</span>
+15 -2
View File
@@ -15,6 +15,7 @@ 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'];
@@ -59,10 +60,18 @@ onUnmounted(() => { unsubRecap?.(); });
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
async function exportImage() {
await window.obelisk.captureExport({ cardIdx: currentIdx.value, archetype: currentArch.value });
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 });
await window.obelisk.copyImage({
cardIdx: currentIdx.value,
archetype: currentArch.value,
filename: recapFilename.value,
});
}
function goTo(idx) {
@@ -91,6 +100,7 @@ function onKeydown(e) {
:arch-key="currentArch"
:badge="cover.badge"
:title="cover.title"
:claim="cover.claim || cover.subtitle"
:subtitle="cover.subtitle"
:activity="cover.activity"
:footer="cover.footer"
@@ -107,6 +117,7 @@ function onKeydown(e) {
<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"
@@ -116,6 +127,7 @@ function onKeydown(e) {
<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"
@@ -126,6 +138,7 @@ function onKeydown(e) {
<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"
+56 -15
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
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';
@@ -10,15 +10,19 @@ 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 archKey = computed(() => route.query.arch || recapJson.persona.archetype);
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 = recapJson.cards[0];
const path = recapJson.cards[1];
const vibe = recapJson.cards[2];
const workflow = recapJson.cards[3];
const closing = recapJson.cards[4];
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,
@@ -28,33 +32,70 @@ const cssVars = computed(() => ({
'--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="5"
:idx="1" :total="total"
/>
<PathCard v-else-if="cardIdx === 1"
:title="path.title" :items="path.items"
:idx="2" :total="5"
:idx="2" :total="total"
/>
<VibeCard v-else-if="cardIdx === 2"
:title="vibe.title" :observations="vibe.observations"
:title="vibe.title" :voice-lines="vibe.voice_lines || vibe.observations"
:observations="vibe.observations"
:meter="vibe.meter" :quote="vibe.quote"
:idx="3" :total="5"
:idx="3" :total="total"
/>
<WorkflowCard v-else-if="cardIdx === 3"
:title="workflow.title" :summary="workflow.summary"
:title="workflow.title" :deck="workflow.deck || workflow.summary"
:summary="workflow.summary"
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
:idx="4" :total="5"
:idx="4" :total="total"
/>
<ClosingCard v-else-if="cardIdx === 4"
:headline="closing.headline" :stats="closing.stats"
:headline="closing.headline" :receipts="closing.receipts || closing.stats"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
:idx="5" :total="5"
:idx="5" :total="total"
/>
</div>
</template>
+8 -8
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, inject } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import { MINI_SEALS } from '../components/recap/seals.js';
import { CORNER_SEALS } from '../components/recap/seals.js';
defineOptions({ name: 'RecapList' });
@@ -27,7 +27,7 @@ function glowColor(arch) {
return PALETTES[arch]?.glow || PALETTES.architect.glow;
}
function sealSvg(arch) {
return MINI_SEALS[arch] || MINI_SEALS.architect;
return CORNER_SEALS[arch] || CORNER_SEALS.architect;
}
function formatDateRange(r) {
if (!r.period) return '';
@@ -116,7 +116,7 @@ onUnmounted(() => { unsub?.(); });
<span>{{ formatDateRange(r) }}</span>
</div>
<div class="recap-archetype">{{ r.persona?.title }}</div>
<div class="recap-subtitle">{{ r.persona?.subtitle }}</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>
@@ -266,7 +266,7 @@ onUnmounted(() => { unsub?.(); });
.timeline { position: relative; }
.timeline::before {
content: ''; position: absolute;
left: 15px; top: 15px; bottom: 15px;
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%,
@@ -276,19 +276,19 @@ onUnmounted(() => { unsub?.(); });
.recap-row {
position: relative; display: grid;
grid-template-columns: 30px 1fr;
column-gap: 28px; align-items: center;
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: 30px; height: 30px;
width: 64px; height: 64px;
position: relative; z-index: 2;
}
.recap-node::before {
content: ''; position: absolute; inset: -3px;
content: ''; position: absolute; inset: -2px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.recap-node :deep(svg) {
+75 -18
View File
@@ -1,8 +1,9 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, watch } from 'vue';
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
import {
escapeHTML,
fmtRelative,
@@ -21,6 +22,9 @@ const session = computed(() => state.sessions.find(s => s.id === props.id));
const messages = ref([]);
const loading = ref(false);
const progressPct = ref(0);
const active = ref(false);
let removeSessionUpdated = null;
let keydownAttached = false;
// DOM refs
const wrapRef = ref(null);
@@ -58,51 +62,91 @@ function handleZoom(e) {
}
}
function attachKeydown() {
if (keydownAttached) return;
window.addEventListener('keydown', handleZoom);
keydownAttached = true;
}
function detachKeydown() {
if (!keydownAttached) return;
window.removeEventListener('keydown', handleZoom);
keydownAttached = false;
}
const HINT_KEY = 'obelisk:font-hint-shown';
const showFontHint = ref(false);
onMounted(async () => {
window.addEventListener('keydown', handleZoom);
active.value = true;
attachKeydown();
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
if (!active.value || !props.id || sessionId !== props.id) return;
clearSessionDirty(props.id);
await loadMessages({ force: true });
}) || null;
if (!localStorage.getItem(HINT_KEY)) {
showFontHint.value = true;
localStorage.setItem(HINT_KEY, '1');
setTimeout(() => { showFontHint.value = false; }, 4000);
}
await loadMessages();
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
});
onActivated(async () => {
window.addEventListener('keydown', handleZoom);
if (messages.value.length === 0 && props.id) {
await loadMessages();
active.value = true;
attachKeydown();
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
await loadMessages({ force: true });
}
});
onDeactivated(() => {
active.value = false;
detachKeydown();
});
onUnmounted(() => {
window.removeEventListener('keydown', handleZoom);
active.value = false;
detachKeydown();
removeSessionUpdated?.();
removeSessionUpdated = null;
});
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
messages.value = [];
await loadMessages();
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
}
});
async function loadMessages() {
async function loadMessages({ force = false } = {}) {
if (!props.id) return;
const wasAtBottom = wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
const prevScrollTop = wrapRef.value?.scrollTop || 0;
loading.value = true;
try {
const s = state.sessions.find(x => x.id === props.id);
if (s && (!s.messages || s.messages.length === 0)) {
if (s && (force || !s.messages || s.messages.length === 0)) {
const loaded = await loadSessionDetail(props.id);
if (loaded) Object.assign(s, loaded);
}
messages.value = s?.messages || [];
const latest = state.sessions.find(x => x.id === props.id);
messages.value = latest?.messages || [];
} finally {
loading.value = false;
}
nextTick(() => {
if (!wrapRef.value) return;
if (wasAtBottom) {
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
} else {
wrapRef.value.scrollTop = prevScrollTop;
}
});
// Focus pending uuid if any
if (state.pendingFocusUuid) {
const targetUuid = state.pendingFocusUuid;
@@ -120,20 +164,25 @@ async function loadMessages() {
// --- Scroll / progress tracking ---
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
let navLock = false;
function onScroll() {
if (navLock) return;
if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
if (!msgs.length) return;
totalMsgs.value = msgs.length;
const wrapTop = wrapRef.value.getBoundingClientRect().top;
let topMsgIdx = 0;
const el = wrapRef.value;
const navHeight = 52;
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
let bottomMsgIdx = 0;
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
else break;
}
currentMsgIdx.value = topMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
currentMsgIdx.value = bottomMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct;
}
@@ -147,8 +196,16 @@ function navTo(target) {
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
else return;
const isClose = Math.abs(idx - currentMsgIdx.value) <= 3;
msgs[idx]?.scrollIntoView({ behavior: isClose ? 'smooth' : 'instant', block: 'start' });
currentMsgIdx.value = idx;
navLock = true;
const navHeight = 52;
const el = wrapRef.value;
const msgEl = msgs[idx];
if (!msgEl) return;
const msgBottom = msgEl.offsetTop + msgEl.offsetHeight;
const scrollTarget = msgBottom - el.clientHeight + navHeight;
el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
setTimeout(() => { navLock = false; }, 50);
}
// --- Toggle helpers ---
+118 -2
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
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';
@@ -7,6 +7,17 @@ import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelativ
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();
@@ -81,10 +92,47 @@ function obeliskStyle(session) {
<template>
<div class="session-list-wrap">
<div v-if="!visibleSessions.length" class="empty">
<!-- Empty state: no data source / debug toggle -->
<div v-if="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="!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 visibleSessions"
@@ -116,6 +164,8 @@ function obeliskStyle(session) {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.srow {
@@ -217,4 +267,70 @@ function obeliskStyle(session) {
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;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
defineOptions({ name: 'Settings' });
const claudePath = ref('');
const dbPath = ref('');
const recapPath = ref('');
const autoRefresh = ref(true);
const status = ref('ok');
const statusText = ref('Connected');
const sessionCount = ref(0);
const memoryCount = ref(0);
const lastIndexed = ref('');
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();
claudePath.value = s.claudeDir || '~/.claude';
dbPath.value = s.dbPath || '';
recapPath.value = s.recapDir || '~/.obelisk/recap';
autoRefresh.value = s.autoRefresh !== false;
sessionCount.value = s.sessionCount || 0;
memoryCount.value = s.memoryCount || 0;
lastIndexed.value = s.lastIndexed || '';
status.value = s.status || 'ok';
statusText.value = s.statusText || 'Connected';
}
async function browsePath() {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
claudePath.value = result;
await saveSetting('claudeDir', 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 resetPath() {
await saveSetting('claudeDir', null);
await loadSettings();
}
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 commitClaudePath() {
await saveSetting('claudeDir', claudePath.value);
await loadSettings();
}
async function commitRecapPath() {
await saveSetting('recapDir', recapPath.value);
}
async function rebuildIndex() {
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
rebuilding.value = true;
statusText.value = 'Rebuilding…';
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 Source -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Data Source</h2>
<p>Where Obelisk reads your Claude Code session history.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Claude Code path</div>
<div class="form-label-hint">Default <code>~/.claude</code> on macOS &amp; Linux.</div>
</div>
<div class="form-control">
<div class="path-input">
<input
class="path-field"
:class="{ error: status === 'error' }"
type="text"
v-model="claudePath"
spellcheck="false"
@keydown.enter="commitClaudePath"
@blur="commitClaudePath"
/>
<button class="btn" @click="browsePath">
<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>
<button class="btn subtle" @click="resetPath" title="Reset to default">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.5 6.5A5 5 0 1 0 12 9.5"/>
<path d="M12.5 2v4.5h-4.5"/>
</svg>
</button>
</div>
<div class="status-row" :class="status">
<span class="status-dot" :class="status"></span>
<span class="status-text">{{ statusText }}</span>
<div class="status-meta" v-if="sessionCount || lastIndexed">
<template v-if="lastIndexed">
<span>last read <strong>{{ fmtRelative(lastIndexed) }}</strong></span>
<span class="sep">·</span>
</template>
<span><strong>{{ sessionCount }}</strong> sessions</span>
<span class="sep">·</span>
<span><strong>{{ memoryCount }}</strong> memories</span>
</div>
</div>
</div>
</div>
<div class="form-row">
<div>
<div class="form-label">Index location</div>
<div class="form-label-hint">SQLite database where Obelisk caches the session index.</div>
</div>
<div class="form-control">
<div class="path-input">
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
<button class="btn" @click="revealDb">Reveal</button>
</div>
</div>
</div>
<div class="form-row">
<div>
<div class="form-label">Auto-refresh</div>
<div class="form-label-hint">Obelisk re-reads when new session files appear.</div>
</div>
<div class="form-control">
<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 <code>.claude</code> for changes</span>
</label>
</div>
</div>
</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 only re-reads your Claude Code 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);
}
.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>
+1 -1
View File
@@ -1165,7 +1165,7 @@
/* Back to top floating button */
/* Message pagination nav */
.msg-nav {
position: fixed; bottom: 40px;
position: fixed; bottom: 16px;
left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px;
padding: 5px 8px;
+2
View File
@@ -15,6 +15,8 @@
.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; }
.sidebar-section-title {
padding: 4px 10px 6px;
font-size: 10.5px; color: var(--muted);
-737
View File
@@ -1,737 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<title>Obelisk — Memory</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Playfair+Display:wght@400;500&display=swap');
:root {
--bg: #f8f9fb;
--surface: #ffffff;
--surface-hover: #f3f4f8;
--surface-active: #eceef4;
--border: #e2e4ea;
--border-hover: #c8ccd6;
--text: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--accent: #6366f1;
--accent-soft: #e0e7ff;
--accent-glow: rgba(99, 102, 241, 0.08);
--purple: #a855f7;
--pink: #ec4899;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-border: #fecaca;
--restore: #059669;
--restore-soft: #ecfdf5;
--restore-border: #a7f3d0;
--serif: 'Playfair Display', Charter, 'Iowan Old Style', Georgia, serif;
--mono: 'IBM Plex Mono', monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--radius: 6px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(30, 41, 59, 0.04);
--shadow: 0 2px 8px rgba(30, 41, 59, 0.06);
--shadow-lg: 0 8px 24px rgba(30, 41, 59, 0.08);
--transition: 160ms ease;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.app {
max-width: 880px;
margin: 0 auto;
padding: 48px 24px 120px;
}
header {
margin-bottom: 48px;
}
.brand {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 8px;
}
.brand-icon {
width: 40px;
height: 40px;
position: relative;
}
.brand-icon::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center top, rgba(168, 85, 247, 0.35), rgba(99, 102, 241, 0.2) 60%, transparent 80%);
border-radius: 50%;
}
.brand-icon::after {
content: '';
position: absolute;
left: 50%;
top: 40%;
width: 10px;
height: 18px;
transform: translateX(-50%);
background: linear-gradient(to right, #475569, #1e293b);
clip-path: polygon(30% 0%, 70% 0%, 80% 100%, 20% 100%);
}
header h1 {
font-family: var(--serif);
font-size: 36px;
font-weight: 400;
color: var(--text);
letter-spacing: 0.02em;
}
header .subtitle {
color: var(--text-muted);
font-size: 14px;
margin-top: 4px;
font-style: italic;
font-family: var(--serif);
}
.stats-bar {
display: flex;
gap: 20px;
margin-top: 20px;
padding: 10px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.stat {
display: flex;
align-items: baseline;
gap: 5px;
}
.stat-value {
font-size: 17px;
font-weight: 500;
color: var(--accent);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.03em;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all var(--transition);
user-select: none;
}
.tab:hover { color: var(--text-secondary); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab .count {
display: inline-block;
margin-left: 5px;
font-size: 10px;
font-weight: 400;
color: var(--text-muted);
background: var(--surface-active);
padding: 1px 6px;
border-radius: 10px;
}
.project-group {
margin-bottom: 28px;
}
.project-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
user-select: none;
margin-bottom: 8px;
}
.project-header:hover .project-name { color: var(--accent); }
.project-header .chevron {
color: var(--text-muted);
font-size: 9px;
transition: transform var(--transition);
width: 12px;
}
.project-header.collapsed .chevron { transform: rotate(-90deg); }
.project-header .project-name {
font-size: 15px;
font-weight: 500;
color: var(--text);
transition: color var(--transition);
}
.project-header .project-count {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.memory-list {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 20px;
}
.memory-item {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 16px;
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition);
box-shadow: var(--shadow-sm);
}
.memory-item:hover {
border-color: var(--border-hover);
box-shadow: var(--shadow);
}
.memory-item.expanded {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow);
}
.memory-item.deleted {
opacity: 0.55;
background: var(--bg);
box-shadow: none;
}
.memory-item.deleted:hover { opacity: 0.8; }
.memory-content {
min-width: 0;
}
.memory-path {
font-size: 12px;
color: var(--accent);
opacity: 0.7;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memory-summary {
font-family: var(--sans);
font-size: 16px;
line-height: 1.7;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.memory-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
display: flex;
gap: 12px;
}
.memory-actions {
display: flex;
align-items: center;
flex-shrink: 0;
padding-top: 2px;
}
.btn {
padding: 7px 14px;
font-size: 12px;
font-family: var(--mono);
font-weight: 500;
border: 1px solid;
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition);
background: transparent;
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
.btn:active { transform: translateY(0); }
.btn-delete {
color: var(--danger);
border-color: var(--danger-border);
background: var(--danger-soft);
}
.btn-delete:hover {
border-color: var(--danger);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.12);
}
.btn-restore {
color: var(--restore);
border-color: var(--restore-border);
background: var(--restore-soft);
}
.btn-restore:hover {
border-color: var(--restore);
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.12);
}
.detail-panel {
grid-column: 1 / -1;
margin-top: 12px;
padding: 16px 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
display: none;
}
.detail-panel.open { display: block; }
.detail-panel .file-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
font-weight: 500;
}
.detail-panel .file-content {
font-size: 14px;
line-height: 1.75;
color: var(--text-secondary);
max-height: 400px;
overflow-y: auto;
padding: 16px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.detail-panel .file-content h1,
.detail-panel .file-content h2,
.detail-panel .file-content h3 {
font-family: var(--sans);
color: var(--text);
margin: 1.2em 0 0.4em;
line-height: 1.3;
}
.detail-panel .file-content h1 { font-size: 18px; }
.detail-panel .file-content h2 { font-size: 16px; }
.detail-panel .file-content h3 { font-size: 14px; }
.detail-panel .file-content h1:first-child,
.detail-panel .file-content h2:first-child { margin-top: 0; }
.detail-panel .file-content p { margin: 0.6em 0; }
.detail-panel .file-content ul,
.detail-panel .file-content ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.detail-panel .file-content li { margin: 0.25em 0; }
.detail-panel .file-content code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-active);
padding: 2px 5px;
border-radius: 3px;
}
.detail-panel .file-content pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
overflow-x: auto;
margin: 0.8em 0;
}
.detail-panel .file-content pre code {
background: none;
padding: 0;
font-size: 12px;
line-height: 1.6;
}
.detail-panel .provenance {
margin-top: 12px;
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.provenance code {
background: var(--surface-active);
padding: 1px 4px;
border-radius: 3px;
font-size: 10px;
}
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%) translateY(80px);
background: var(--text);
border-radius: var(--radius);
padding: 12px 20px;
font-size: 12px;
color: var(--bg);
display: flex;
align-items: center;
gap: 14px;
opacity: 0;
transition: all 300ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
z-index: 100;
box-shadow: var(--shadow-lg);
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
.toast .undo-btn {
color: var(--accent-soft);
cursor: pointer;
font-weight: 500;
text-decoration: underline;
text-underline-offset: 2px;
}
.empty {
text-align: center;
padding: 56px 20px;
color: var(--text-muted);
font-family: var(--serif);
font-size: 17px;
font-style: italic;
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">
<div class="brand-icon"></div>
<h1>Memory</h1>
</div>
<div class="subtitle">Let Claude Code search its own memory.</div>
<div class="stats-bar">
<div class="stat"><span class="stat-value" id="stat-active">0</span><span class="stat-label">active</span></div>
<div class="stat"><span class="stat-value" id="stat-deleted">0</span><span class="stat-label">archived</span></div>
<div class="stat"><span class="stat-value" id="stat-projects">0</span><span class="stat-label">projects</span></div>
</div>
</header>
<div class="tabs">
<div class="tab active" data-tab="active">Active <span class="count" id="tab-active-count">0</span></div>
<div class="tab" data-tab="deleted">Archived <span class="count" id="tab-deleted-count">0</span></div>
</div>
<div id="content"></div>
</div>
<div class="toast" id="toast">
<span id="toast-msg"></span>
<span class="undo-btn" id="toast-undo">Undo</span>
</div>
<script>
const MOCK_MEMORIES = [
{
id: 'mem-1781021027286-51v0qh',
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
project: '-Users-tomiya-Code-quiet-zero',
message_start: 'a1b2c3d4',
message_end: 'e5f6g7h8',
path: '.obelisk/memories/filter-opts-design.md',
summary: 'Unified filter opts design: decided to add project/after/before/limit to all list-returning API functions instead of building a query builder DSL. Agent already knows JS; function composition is the query builder.',
created_at: '2026-06-01T16:03:47.286Z',
deleted_at: null,
deleted_reason: null,
_file_content: '# Filter Opts Design Decision\n\nWe chose to add a consistent `opts` object to every list-returning function rather than building a query builder abstraction.\n\n## Reasoning\n\n- The agent already writes JS — function composition IS the query builder\n- A DSL adds a new mental model the agent has to learn\n- `search()` already had the right pattern; we just propagated it\n\n## Alternatives Considered\n\n- Fluent builder (`.filter().select().take()`) — too much implementation, new abstraction\n- Per-function specific filters — inconsistent, hard to compose\n\n## Constraints\n\n- Must be backward compatible (string → sessionId, number → limit)\n- Must push filters to SQL, not pull-and-filter client-side'
},
{
id: 'mem-1781021100000-x9y2z1',
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
project: '-Users-tomiya-Code-quiet-zero',
message_start: 'i9j0k1l2',
message_end: 'm3n4o5p6',
path: '.obelisk/memories/is-error-design.md',
summary: 'Use structural is_error field from JSONL instead of text pattern matching for failures(). The old ERROR_PATS approach had ~90% false positive rate. Bash exit code pattern kept as fallback but SQLite LIKE has no character classes.',
created_at: '2026-06-02T10:15:00.000Z',
deleted_at: null,
deleted_reason: null,
_file_content: '# is_error Design\n\nReplaced text-based error pattern matching with the structural `is_error` boolean from Claude Code JSONL.\n\n## Problem\n\nThe old `failures()` matched content against patterns like "Error", "failed", "ENOENT". This caught source code containing those words, Agent results discussing errors, etc. ~90% false positive rate.\n\n## Solution\n\nIndex `b.is_error` from tool_result blocks into a new `is_error INTEGER` column on `tool_results`. Query becomes `WHERE is_error = 1`.\n\n## Edge Cases\n\nBash exit code fallback: `content LIKE \'Exit code %\'` for cases where is_error might not be set. Note: SQLite LIKE does not support `[1-9]` character classes.'
},
{
id: 'mem-1781021200000-a3b4c5',
session_id: '2831d8a1-df70-4365-a203-59bbe8e354cb',
project: '-Users-tomiya-Code-quiet-zero',
message_start: 'q7r8s9t0',
message_end: 'u1v2w3x4',
path: '.obelisk/memories/no-wiki-philosophy.md',
summary: 'Core philosophy: raw sessions are already structured data. Do not compile them into wiki pages or markdown summaries as an intermediate entity. Keep the relational structure, let agent query at application layer. Memory layer is selective conclusions with provenance, not comprehensive coverage.',
created_at: '2026-05-30T14:20:00.000Z',
deleted_at: null,
deleted_reason: null,
_file_content: '# No Wiki Philosophy\n\n"若无必要,勿增实体"\n\nRaw session data is already structured: messages, tool calls, tool results, files, subagents, workflows, parent chains. Maintaining these relationships in SQLite is already a powerful structure.\n\nCompiling into markdown pages introduces an unnecessary intermediate entity: information gets flattened, causal chains and reference relationships need post-hoc reconstruction.\n\nThe memory layer is NOT a wiki. It is selective, agent-written conclusions with clear provenance. It does not try to comprehensively cover everything.'
},
{
id: 'mem-1781021300000-d6e7f8',
session_id: 'ca0b1609-984a-4761-a090-a4fc4f25b8b8',
project: '-Users-tomiya-Code-bub',
message_start: 'y5z6a7b8',
message_end: 'c9d0e1f2',
path: '.obelisk/memories/bub-tasktree-decision.md',
summary: 'Dynamic TaskTree architecture: chose three-layer design (main Agent → Original Work → Side Quests) over flat task queue. logos_complete semantics split into plan (generate children) and return (bubble up summary).',
created_at: '2026-05-29T18:30:00.000Z',
deleted_at: null,
deleted_reason: null,
_file_content: '# Dynamic TaskTree Architecture\n\nChose a three-layer architecture for the dynamic task tree:\n1. Main Agent — orchestrator\n2. Original Work — primary task execution\n3. Side Quests — spawned sub-tasks\n\n## Key Decision\n\nSplit `logos_complete` into two distinct semantics:\n- **plan**: generate child nodes (`plan: [...]`)\n- **return**: bubble up summary to parent (`summary: "..."`)\n\nThe old design conflated these — a node with k subtasks called logos_complete k+1 times.'
},
{
id: 'mem-1781021400000-g3h4i5',
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
project: '-Users-tomiya-Code-quiet-zero',
message_start: 'j6k7l8m9',
message_end: 'n0o1p2q3',
path: '.obelisk/memories/stale-memory-example.md',
summary: 'Obsolete: originally planned to add a full query builder with .filter().select() chain syntax. This was rejected in favor of unified filter opts on existing functions.',
created_at: '2026-06-01T12:00:00.000Z',
deleted_at: '2026-06-03T09:00:00.000Z',
deleted_reason: 'Superseded by filter-opts-design memory. The query builder plan was rejected.',
_file_content: '# Query Builder Plan (REJECTED)\n\nThis approach was considered and rejected.\n\nWe originally planned a fluent query builder:\n```js\nsummaries.filter({project: like("quiet-zero")}).limit(10)\n```\n\nRejected because:\n- Adds unnecessary abstraction\n- Agent already writes JS\n- Function composition achieves the same thing'
}
];
let memories = JSON.parse(JSON.stringify(MOCK_MEMORIES));
let currentTab = 'active';
let expandedId = null;
let collapsedProjects = new Set();
let toastTimeout = null;
function getActive() { return memories.filter(m => !m.deleted_at); }
function getDeleted() { return memories.filter(m => m.deleted_at); }
function getProjects(list) {
const groups = {};
for (const m of list) {
const p = m.project || '(no project)';
if (!groups[p]) groups[p] = [];
groups[p].push(m);
}
return Object.entries(groups).sort((a, b) => {
const latestA = Math.max(...a[1].map(m => new Date(m.created_at).getTime()));
const latestB = Math.max(...b[1].map(m => new Date(m.created_at).getTime()));
return latestB - latestA;
});
}
function formatProject(slug) {
if (!slug) return '(no project)';
const parts = slug.replace(/^-/, '').split('-');
return parts.slice(-2).join('/');
}
function formatDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function showToast(msg, undoFn) {
const toast = document.getElementById('toast');
document.getElementById('toast-msg').textContent = msg;
toast.classList.add('show');
document.getElementById('toast-undo').onclick = () => { undoFn(); hideToast(); };
clearTimeout(toastTimeout);
toastTimeout = setTimeout(hideToast, 5000);
}
function hideToast() {
document.getElementById('toast').classList.remove('show');
clearTimeout(toastTimeout);
}
function softDelete(id) {
const mem = memories.find(m => m.id === id);
if (!mem) return;
mem.deleted_at = new Date().toISOString();
mem.deleted_reason = 'Deleted via panel';
expandedId = null;
render();
showToast(`Archived: ${mem.path.split('/').pop()}`, () => restore(id));
}
function restore(id) {
const mem = memories.find(m => m.id === id);
if (!mem) return;
mem.deleted_at = null;
mem.deleted_reason = null;
render();
showToast(`Restored: ${mem.path.split('/').pop()}`, () => softDelete(id));
}
function toggleExpand(id) {
expandedId = expandedId === id ? null : id;
render();
}
function toggleProject(project) {
if (collapsedProjects.has(project)) collapsedProjects.delete(project);
else collapsedProjects.add(project);
render();
}
function render() {
const active = getActive();
const deleted = getDeleted();
const projects = new Set(memories.map(m => m.project).filter(Boolean));
document.getElementById('stat-active').textContent = active.length;
document.getElementById('stat-deleted').textContent = deleted.length;
document.getElementById('stat-projects').textContent = projects.size;
document.getElementById('tab-active-count').textContent = active.length;
document.getElementById('tab-deleted-count').textContent = deleted.length;
const list = currentTab === 'active' ? active : deleted;
const grouped = getProjects(list);
const content = document.getElementById('content');
if (!list.length) {
content.innerHTML = `<div class="empty">${currentTab === 'active' ? 'No active memories yet.' : 'No archived memories.'}</div>`;
return;
}
content.innerHTML = grouped.map(([project, mems]) => {
const isCollapsed = collapsedProjects.has(project);
return `
<div class="project-group">
<div class="project-header ${isCollapsed ? 'collapsed' : ''}" onclick="toggleProject('${project}')">
<span class="chevron">&#9662;</span>
<span class="project-name">${formatProject(project)}</span>
<span class="project-count">${mems.length}</span>
</div>
<div class="memory-list" style="${isCollapsed ? 'display:none' : ''}">
${mems.map(m => renderMemory(m)).join('')}
</div>
</div>
`}).join('');
}
function renderMemory(m) {
const isExpanded = expandedId === m.id;
const isDeleted = !!m.deleted_at;
return `
<div class="memory-item ${isExpanded ? 'expanded' : ''} ${isDeleted ? 'deleted' : ''}" onclick="toggleExpand('${m.id}')">
<div class="memory-content">
<div class="memory-path">${m.path}</div>
<div class="memory-summary">${m.summary}</div>
<div class="memory-meta">
<span>${formatDate(m.created_at)}</span>
${m.deleted_at ? `<span>archived ${formatDate(m.deleted_at)}</span>` : ''}
</div>
</div>
<div class="memory-actions" onclick="event.stopPropagation()">
${isDeleted
? `<button class="btn btn-restore" onclick="restore('${m.id}')">restore</button>`
: `<button class="btn btn-delete" onclick="softDelete('${m.id}')">archive</button>`
}
</div>
${isExpanded ? `
<div class="detail-panel open" onclick="event.stopPropagation()">
<div class="file-label">File Content</div>
<div class="file-content">${marked.parse(m._file_content || '*(file not available)*')}</div>
<div class="provenance">
<span>session <code>${m.session_id?.slice(0, 8) || '—'}</code></span>
<span>messages <code>${m.message_start?.slice(0, 8) || '—'}</code> → <code>${m.message_end?.slice(0, 8) || '—'}</code></span>
<span>id <code>${m.id}</code></span>
</div>
</div>
` : ''}
</div>
`;
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentTab = tab.dataset.tab;
expandedId = null;
render();
});
});
render();
</script>
</body>
</html>
+7 -506
View File
@@ -1,508 +1,9 @@
# Obelisk Recap Patterns
# Obelisk Recap Retrieval Patterns
Use this when Obelisk Intent Routing sends the `recap` intent. In practice,
that means the first word after `/obelisk` is `recap`; everything after it is
the recap target.
Compatibility pointer.
Do not self-trigger this reference from broad weekly/monthly summaries, charts,
rankings, shareable-card language, or playlist-style metaphors. Those are target
details only after the explicit `recap` intent has already selected this file.
This is an optional app handoff pattern. Obelisk stays agent-first: the agent
queries sessions and memories, judges what matters, then fills card content.
The app owns rendering, layout, export, and animation.
## Common Period Targets
Treat the target after `recap` as normal user language. Common app-generated
targets:
- `/obelisk recap this week` -- the current calendar week in the user's timezone.
- `/obelisk recap last week` -- the previous calendar week.
- `/obelisk recap this month` -- the current calendar month.
- `/obelisk recap last month` -- the previous calendar month.
Use the current date and timezone from the runtime/session context when
available. If the target contains extra style language, keep the period meaning
and treat the rest as presentation guidance.
## Contract
- Produce card content, not HTML, CSS, SVG, or layout instructions.
- Keep the first result share-safe. Avoid secrets, tokens, private URLs, long
absolute paths, raw tool outputs, and embarrassing private text unless the
user asked for private analysis.
- Concrete numbers, exact times, quotes, and verdicts need evidence. If you
cannot support a detail, omit it or soften it.
- Memory is prior notes, not ground truth. If a card uses a memory conclusion,
say in prose that it came from a prior memory when answering in chat, and add
`memory_id` evidence when producing JSON.
- Do not propose a durable memory just because you generated a recap. Propose
memory only if the retrieval reveals a reusable cross-session conclusion not
already covered by `memories()`.
## Retrieval Shape
Start with orientation and bounded evidence. Use English memory queries even
when the recap text will be in another language.
```js
const period = {
label: 'Week 24',
after: '2026-06-08T00:00:00+08:00',
before: '2026-06-15T00:00:00+08:00',
timezone: 'Asia/Shanghai',
};
const map = overview({ limit: 8 });
const project = map.current.project?.project;
const scoped = project ? { project } : {};
return {
current: map.current,
current_project: map.current_project,
prior_memories: memories({
...scoped,
query: 'weekly recap project decisions workflows debugging shipping',
limit: 8,
}).map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
session_id: m.session_id,
created_at: m.created_at,
summary: m.summary?.slice(0, 280),
})),
sessions: sessions({
...scoped,
after: period.after,
before: period.before,
limit: 40,
}).map(s => ({
id: s.id,
title: s.title,
project: s.project,
branch: s.git_branch,
started_at: s.started_at,
ended_at: s.ended_at,
message_count: s.message_count,
})),
summaries: summaries({
...scoped,
after: period.after,
before: period.before,
limit: 30,
}).map(s => ({
id: s.id,
session_id: s.session_id,
session_title: s.session_title,
project: s.project,
timestamp: s.timestamp,
snippet: s.content?.slice(0, 260),
})),
workflows: workflows({
...scoped,
after: period.after,
before: period.before,
limit: 20,
}).map(w => ({
run_id: w.run_id,
session_id: w.session_id,
name: w.workflow_name,
status: w.status,
agent_count: w.agent_count,
tokens: w.total_tokens,
timestamp: w.timestamp,
})),
failures: failures({
...scoped,
after: period.after,
before: period.before,
limit: 12,
}).map(f => ({
session_id: f.session_id,
session_title: f.session_title,
tool: f.tool_name,
timestamp: f.timestamp,
snippet: f.content?.slice(0, 180),
})),
};
```
Use SQL for exact aggregate numbers after the helper-first pass has established
the scope. Keep meta rows out of ordinary user-visible counts.
```js
const project = '%quiet-zero%';
const after = '2026-06-08T00:00:00+08:00';
const before = '2026-06-15T00:00:00+08:00';
const metrics = sql(`
SELECT
COUNT(DISTINCT s.id) AS sessions,
COUNT(m.uuid) AS messages,
COALESCE(SUM(COALESCE(m.input_tokens, 0) + COALESCE(m.output_tokens, 0)), 0) AS tokens,
COUNT(DISTINCT substr(m.timestamp, 1, 10)) AS active_days
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE s.project LIKE ?
AND COALESCE(m.is_meta, 0) = 0
AND m.timestamp >= ?
AND m.timestamp < ?
`, project, after, before)[0];
const user_messages = sql(`
SELECT
m.uuid,
m.session_id,
s.title AS session_title,
m.timestamp,
substr(m.text, 1, 500) AS text
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ?
AND m.type = 'user'
AND m.content_type = 'text'
AND COALESCE(m.is_meta, 0) = 0
AND m.timestamp >= ?
AND m.timestamp < ?
ORDER BY m.timestamp
LIMIT 300
`, project, after, before);
return { metrics, user_messages };
```
## Archetypes
Choose one dominant archetype from the evidence. Do not force all cards to match
it; the archetype is the cover persona and tone baseline.
| archetype | essential action | tone baseline |
|---|---|---|
| `architect` | establishes structure, boundaries, and systems from above | matter-of-fact, structural pride |
| `debugger` | loops through symptoms until root cause becomes visible | wry, weary, occasional dark humor |
| `shipper` | keeps pushing forward with dense cadence | energetic, slightly breathless |
| `curator` | collects, organizes, refines, and distills | reflective, low-key clarity |
| `director` | coordinates many threads from the center outward | observant, slight remove |
| `cartographer` | reorganizes known structure, redraws maps, moves boundaries | patient, surveyor-like |
| `wanderer` | crosses projects without one obvious center, leaving traces | gentle, accepting, no apology |
Selection hints:
- Use `architect` when the week is dominated by system design, APIs, schemas,
boundaries, or core concepts.
- Use `debugger` when repeated failures, false positives, regressions, or root
cause hunts dominate.
- Use `shipper` when the evidence shows sustained implementation velocity,
builds, releases, or many completed edits.
- Use `curator` when the work is mostly cleanup, documentation, memory,
organization, or taste/refinement.
- Use `director` when workflows, subagents, review loops, or multi-agent
coordination are the main story.
- Use `cartographer` when the user moves modules, redraws information
architecture, re-scopes boundaries, or turns "this lives here" into "this
belongs there".
- Use `wanderer` when the period spans many unrelated projects and the honest
story is exploratory rather than centered.
## JSON Shape
The schema is deliberately card-content oriented. Keep keys stable, but let
strings carry the style. Card text can use the user's language; field names stay
English.
For weekly recaps, `metrics.active_days` and cover `activity` should contain 7
numbers ordered Monday through Sunday, where `0` means no visible activity and
`1` means active. For monthly recaps, use one value per calendar day in the
period, or omit the field if the evidence is too thin.
```ts
type Recap = {
schema_version: "obelisk.recap.v1";
kind: "weekly" | "monthly";
generated_at: string;
period: {
label: string;
start: string;
end: string;
timezone: string;
};
source: {
project?: string;
session_ids: string[];
memory_ids?: string[];
};
metrics: {
sessions?: number;
messages?: number;
tokens?: number;
active_days?: number[];
streak_days?: number;
workflows?: number;
workflow_agents?: number;
corrections?: number;
};
persona: {
archetype:
| "architect"
| "debugger"
| "shipper"
| "curator"
| "director"
| "cartographer"
| "wanderer";
title: string;
subtitle: string;
tone: string;
};
cards: [
CoverCard,
ThinkingPathCard,
VibeCard,
WorkflowOrToolsCard,
ClosingCard
];
evidence?: Evidence[];
};
```
### Card 1: Cover
```ts
type CoverCard = {
type: "cover";
badge: string;
title: string;
subtitle: string;
activity: number[];
footer: string;
evidence_refs?: string[];
};
```
Use the cover to name the period and the persona. The title can be an archetype
label such as "The Architect"; the subtitle should summarize the period's real
dominant work in one line.
### Card 2: Thinking Path
```ts
type ThinkingPathCard = {
type: "thinking_path";
title: string;
items: Array<{
day: string;
prompt: string;
outcome: string;
evidence_refs?: string[];
}>;
};
```
Pick 3-6 turning points. A `prompt` is the question, friction, or task that
started the path. An `outcome` is the decision, fix, or learned framing.
### Card 3: Vibe
```ts
type VibeCard = {
type: "vibe";
title: string;
observations: Array<{
label: string;
text: string;
count?: number;
time?: string;
evidence_refs?: string[];
}>;
meter?: {
label: string;
value: number;
caption: string;
};
quote?: {
text: string;
caption?: string;
evidence_refs?: string[];
};
};
```
This card can be playful, but it must stay grounded. Repeated phrases should be
counted from visible user messages, not meta messages or tool results.
### Card 4: Workflows Or Tools
```ts
type WorkflowOrToolsCard = {
type: "workflow" | "tool_habits" | "debugging" | "shipping";
title: string;
summary?: string;
stats?: string;
items: Array<{
name: string;
outcome: string;
evidence_refs?: string[];
}>;
verdict: string;
};
```
Choose the card type that best fits the period. Use `workflow` when workflow
runs or subagents are the story; `debugging` when failures and fixes dominate;
`shipping` when completed implementation dominates; `tool_habits` when the
period is mostly about how the user worked.
### Card 5: Closing
```ts
type ClosingCard = {
type: "closing";
headline: string;
stats: string[];
most_said_phrase?: string;
signoff: string;
evidence_refs?: string[];
};
```
Close with one high-signal stat and a short signoff. Avoid turning the closing
card into a second summary.
### Evidence
```ts
type Evidence = {
id: string;
session_id?: string;
message_uuid?: string;
memory_id?: string;
summary?: string;
};
```
Evidence is for local traceability and app inspection. It does not have to be
shown on exported cards. Prefer short summaries over raw snippets.
## Output Rules
- If the user asks for app handoff, return one JSON object and no surrounding
prose.
- If the user asks conversationally, answer with the card content naturally and
include JSON only if useful.
- Keep `cards.length === 5` in the order above.
- Do not include unsupported cards just to fill space. It is better to make a
quieter card than to invent drama.
- Do not include raw SQL, query scripts, or private evidence in the shareable
card text.
## Minimal Example
```json
{
"schema_version": "obelisk.recap.v1",
"kind": "weekly",
"generated_at": "2026-06-13T03:40:00+08:00",
"period": {
"label": "Week 24",
"start": "2026-06-08",
"end": "2026-06-14",
"timezone": "Asia/Shanghai"
},
"source": {
"project": "quiet-zero",
"session_ids": ["sid-a", "sid-b"],
"memory_ids": ["mem-a"]
},
"metrics": {
"sessions": 12,
"messages": 847,
"tokens": 2400000,
"active_days": [1, 1, 1, 0, 1, 1, 1],
"workflows": 3,
"workflow_agents": 42
},
"persona": {
"archetype": "architect",
"title": "The Architect",
"subtitle": "Designed a memory system from raw sessions to durable notes.",
"tone": "matter-of-fact, structural pride"
},
"cards": [
{
"type": "cover",
"badge": "Week 24",
"title": "The Architect",
"subtitle": "Designed a full memory layer without turning sessions into a wiki.",
"activity": [1, 1, 1, 0, 1, 1, 1],
"footer": "12 sessions - 2.4M tokens",
"evidence_refs": ["e1", "e2"]
},
{
"type": "thinking_path",
"title": "Your thinking path",
"items": [
{
"day": "Mon",
"prompt": "Why compile sessions into a wiki?",
"outcome": "Kept raw SQLite as the evidence layer.",
"evidence_refs": ["e1"]
}
]
},
{
"type": "vibe",
"title": "Your vibe this week",
"observations": [
{
"label": "Catchphrase",
"text": "This is too ugly.",
"count": 4,
"evidence_refs": ["e3"]
}
],
"meter": {
"label": "Patience",
"value": 0.8,
"caption": "saint"
}
},
{
"type": "workflow",
"title": "Are you a Workflow Enjoyer?",
"stats": "3 workflows - 42 agents",
"items": [
{
"name": "hono-plugin-review",
"outcome": "perfect",
"evidence_refs": ["e4"]
}
],
"verdict": "Mostly tolerated"
},
{
"type": "closing",
"headline": "19 day streak",
"stats": ["847 messages exchanged"],
"most_said_phrase": "Okay, start doing it.",
"signoff": "See you next week.",
"evidence_refs": ["e5"]
}
],
"evidence": [
{
"id": "e1",
"session_id": "sid-a",
"message_uuid": "msg-a",
"summary": "The user chose raw SQLite as the evidence layer."
}
]
}
```
The `/obelisk recap` flow now starts at `references/recap/overview.md`.
Do not use this as an all-in-one retrieval document. The current flow is
card-by-card: read the overview, then for each card read its `patternN-*.md`,
retrieve that card's evidence, read its `writingN-*.md`, and update the JSON
before moving on.
+8
View File
@@ -0,0 +1,8 @@
# Obelisk Recap Writing
Compatibility pointer.
The `/obelisk recap` writing contract now lives in the per-card writing files
under `references/recap/`, coordinated by `references/recap/overview.md`.
Read the overview first. Then use each per-card writing file immediately after
that card's retrieval pattern, rather than loading one large writing prompt.
+74
View File
@@ -0,0 +1,74 @@
# Obelisk Recap Overview
Use this only when the first word after `/obelisk` is `recap`. Everything after
`recap` is the target period or style hint.
## Highest Priority: Phase Loop
This workflow is sequential. Do not preload all recap files. Do not gather all
evidence first and write all cards at the end.
Follow this loop exactly:
1. Resolve the target period from the user's phrase.
2. Run only a tiny orientation pass such as `overview({ limit: 6 })`.
3. For Card 1, read `pattern1-cover.md`.
4. Retrieve only Card 1 evidence.
5. Read `writing1-cover.md`.
6. Update/write the JSON for Card 1 now.
7. Only after the JSON is updated, move to Card 2 and repeat.
Card order:
| card | retrieve | write |
|---|---|---|
| 1 cover | `pattern1-cover.md` | `writing1-cover.md` |
| 2 thinking | `pattern2-thinking.md` | `writing2-thinking.md` |
| 3 vibe | `pattern3-vibe.md` | `writing3-vibe.md` |
| 4 workflow | `pattern4-workflow.md` | `writing4-workflow.md` |
| 5 closing | `pattern5-closing.md` | `writing5-closing.md` |
The per-card files own retrieval details, JSON field duties, and card-specific
taste. Do not move those concerns back into this file.
## Period Targets
- `this week`, `last week`: calendar week in the user's runtime timezone.
- `this month`, `last month`: calendar month in the user's runtime timezone.
Do not infer timezone from examples, UTC suffixes, or file timestamps when
runtime/session timezone is available.
## Overall Deck Taste
This is a Spotify Wrapped-like set of personal share cards: concise, designed,
slightly playful, and built to make the user's work feel seen.
Do not criticize the user. Do not scold, diagnose, rank their personality, or
turn friction into a performance review.
Use designed English chrome where it feels like card UI: week/month labels,
archetype labels, compact stats, verdict seals, and signoffs. Preserve the
user's own language for prompts, quotes, catchphrases, and reactions. This is
not a translation task.
The deck should feel like a small artifact from someone who noticed the week,
not a report generated from a database.
## Archetypes
Choose one dominant archetype from the period's dominant attention, not from the
current recap-generation session.
| archetype | when it fits | tone baseline |
|---|---|---|
| `architect` | structure, boundaries, schema, systems | matter-of-fact structural pride |
| `debugger` | symptoms, false positives, root-cause loops | wry and bug-comfortable |
| `shipper` | dense implementation cadence | energetic but not breathless |
| `curator` | organization, memory, docs, refinement | reflective and precise |
| `director` | workflows, subagents, orchestration | observant from a slight remove |
| `cartographer` | moving boundaries and redrawing maps | patient and surveyor-like |
| `wanderer` | many projects without one center | gentle, exploratory |
If two fit, pick the one that describes what the user spent more thinking time
on, not what shipped.
+36
View File
@@ -0,0 +1,36 @@
# Card 1 Cover Retrieval
Goal: choose the recap's dominant claim, persona, activity shape, and compact
footer. The cover is not a topic inventory; it is one glanceable claim about
what the period felt like.
Use the period from `overview.md`. Start from `overview({ limit: 6 })`, then
look at in-period sessions, summaries, memories, and any obvious project scope.
If the user asked for a project, keep that scope; otherwise prefer the current
project only when the evidence makes it the clear center.
Prefer helpers first. If you need custom SQL for activity, token/message counts,
or source-session scope, read `references/schema.md` before writing the SQL.
Retrieve:
- dominant claim: one thing that defined the period, supported by raw evidence;
- persona: which archetype best matches the user's attention;
- source sessions and memories used by this cover claim;
- activity: weekly day intensities or monthly day intensities when supported;
- footer: compact public metric such as sessions, messages, or tokens.
Avoid:
- a claim that lists three topics;
- an archetype chosen from the recap-generation session itself;
- footer caveats like excluded projects, exact SQL filters, or long session names;
- making the cover a workflow metric when the week was really about a decision.
Read this card's writing file immediately after the cover evidence is stable:
`references/recap/writing1-cover.md`. Then update the JSON fields
`period`, `source`, `metrics`, `persona`, and the first `cards[]` entry.
Do not read `pattern2-thinking.md` until this JSON update is done.
Stop when the cover has one evidence-backed dominant claim, one chosen persona,
one metric scope, and at least one `evidence` anchor.
+33
View File
@@ -0,0 +1,33 @@
# Card 2 Thinking Retrieval
Goal: find turning points. This card is not a project timeline and not an implementation log. It is the record of what changed in the user's mind.
Retrieve 3-6 turns. A turn needs both sides:
- the user question, friction, doubt, or request that started the turn;
- the later decision, reframing, finding, or constraint that replaced the earlier
state.
Useful searches:
- user questions in the period: "为什么", "是不是", "怎么", "我觉得", "不应该";
- places where the user corrected the direction and then approved a new frame;
- summaries that name decisions, followed by `context()` or `thread()` for the
user's actual words;
- memory records only as hints; raw session evidence must provide the prompt and
turn.
Prefer helpers first. If you need custom SQL for message windows or user-turn
counts, read `references/schema.md` before writing the SQL.
Do not use workflow names, feature names, or agent task labels as prompts when
the user had their own wording. Do not use counts, "5 rounds", "13 agents", or
implementation effort as turns unless that count is the turn itself.
Read this card's writing file immediately after the turns are chosen:
`references/recap/writing2-thinking.md`. Then update the JSON `thinking_path`
card and add evidence for each item.
Do not read `pattern3-vibe.md` until this JSON update is done.
Stop when each item has a source-language prompt label, a short changed-state
prompt, turn, and an `evidence` anchor.
+30
View File
@@ -0,0 +1,30 @@
# Card 3 Vibe Retrieval
Goal: find small human signals in visible user messages. Vibe is not a correction log, not bracketed runtime text, and not a psychological profile.
Look for:
- catchphrases and repeated tiny reactions;
- unusually blunt praise or rejection;
- late-night disbelief, jokes, or rituals;
- one quotable sentence that captures the period's character.
Only count visible user messages. Helper APIs omit meta by default, but custom
SQL for phrase counts must filter user text with `COALESCE(m.is_meta,0)=0` and
`m.content_type='text'`. Do not count tool results, injected command envelopes,
UI labels, or bracketed runtime strings.
Useful retrieval:
- targeted phrase counts after you notice a likely catchphrase;
- `thread(sessionId)` around high-energy moments;
- `search()` for exact phrases, then `context()` for timing;
- a bounded SQL count only after reading `references/schema.md`.
Read this card's writing file immediately after you have the small user signals:
`references/recap/writing3-vibe.md`. Then update the JSON `vibe` card and add
evidence for every quote, count, and timestamp.
Do not read `pattern4-workflow.md` until this JSON update is done.
Stop when every observation is either exact user words or a tiny label backed by
exact user words.
+41
View File
@@ -0,0 +1,41 @@
# Card 4 Workflow Retrieval
Goal: find actual workflow runs and how the user received them. Card 4 is about
orchestration as experienced by the user, not an agent performance table.
Workflow rows have their own `workflows.timestamp`. During this card's retrieval,
call `workflows({ project: projectLike, after, before })` before concluding the
period had zero workflows.
Prefer helpers first. If you need custom SQL for workflow joins, timestamps, or
message reactions, read `references/schema.md` before writing the SQL.
Do not derive workflow counts only from `sessions({ after, before })`: long
sessions can start before the period and still contain workflow runs inside the
period. Do not scope workflow lookup by exact `project_path`; nested cwd values
can belong to the same Claude project slug.
For each candidate workflow:
- get the actual workflow_name from `workflows()` or `workflowTree()`;
- collect run id, timestamp, project, agent count, and compact result for stats
and evidence only;
- search the parent session for the user message immediately following the workflow completion;
- use that user reaction as `items[].reaction`.
Rank rows by the strength of the user reaction, not by agent count, workflow
size, duration, or implementation importance. A small workflow with "完美" is a
better row than a large workflow with no visible response.
Do not use architecture topics, memory-system milestones, app modules, or recap
feature work as workflow rows unless they are actual workflow_name values.
Do not make a row for a workflow with no visible user reaction; keep it only in
`stats`, `metrics`, or `evidence`.
Read this card's writing file immediately after workflow evidence is stable:
`references/recap/writing4-workflow.md`. Then update the JSON `workflow` card,
top-level workflow metrics, and source session ids for workflow evidence.
Do not read `pattern5-closing.md` until this JSON update is done.
Stop when every displayed row has an actual workflow name and a visible user
reaction.
+35
View File
@@ -0,0 +1,35 @@
# Card 5 Closing Retrieval
Goal: close with a small personal receipt. Use the same period and source scope
as the recap, or explicitly record a wider metric in `evidence`.
Retrieve:
- one consistent metric that can stand alone, such as streak, active days,
sessions, messages, or workflows;
- one or two compact receipts;
- most said phrase, only if a real repeated user phrase is supported;
- signoff material from the period's mood, not a second summary.
For phrase counts, count only non-meta visible user text. For streaks and active
days, define whether the scope is all Obelisk data, the current project, or the
selected evidence sessions. Keep the scope consistent with the cover footer
unless the evidence explicitly says otherwise.
Prefer helpers first. If you need custom SQL for phrase counts, active days, or
streaks, read `references/schema.md` before writing the SQL.
Avoid:
- naked numbers without units;
- project report bullets;
- internal session names;
- token audits;
- slogans, advice, or next-action commands.
Read this card's writing file immediately after the closing receipt is chosen:
`references/recap/writing5-closing.md`. Then update the JSON `closing` card and
add evidence for counts and phrases.
This is the final card; save the completed JSON before replying.
Stop when the closing can end the deck without explaining the whole week again.
+83
View File
@@ -0,0 +1,83 @@
# Card 1 Cover Writing
The cover should be readable in one glance: badge, persona, one plain claim,
activity, footer. Before writing, say the claim to the user in a chat bubble.
If it sounds like a topic list or report heading, shrink it.
## Mock taste anchor
```json
{
"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"
}
```
This works because `从零设计了一个完整的 memory 系统。` is one plain claim, in
the user's language, and can be read in one breath. `The Architect` is English
chrome; it gives the card a designed surface without translating the user's
actual work.
## JSON Shape
```ts
type CoverCard = {
type: "cover";
badge: string;
title: string;
claim: string;
activity: number[];
footer: string;
evidence_refs?: string[];
};
```
Field duties:
- `badge`: compact period chrome, such as `Week 24`.
- `title`: persona label, usually `The Architect`, `The Debugger`, etc.
- `claim`: one plain claim; not a topic list, project inventory, colon-led
tagline, or clever English that hides the user's language.
- `activity`: period intensity values from retrieval.
- `footer`: public metric line with no internal filter notes.
After writing, check that `persona.claim` and `cover.claim` tell the same
story, and attach `evidence_refs` to the claim or metric if it is surprisingly
specific.
## First JSON Write
After Card 1, create or update the recap JSON file. Do this before reading
`pattern2-thinking.md`.
Use this top-level shape:
```ts
type Recap = {
schema_version: "obelisk.recap.v1";
kind: "weekly" | "monthly";
generated_at: string;
period: { label: string; start: string; end: string; timezone: string };
source: { project?: string; session_ids: string[]; memory_ids?: string[] };
metrics: {
sessions?: number;
messages?: number;
tokens?: number;
active_days?: number[];
streak_days?: number;
workflows?: number;
workflow_agents?: number;
corrections?: number;
};
persona: { archetype: string; title: string; claim: string; tone: string };
cards: [CoverCard, { type: "thinking_path" }, { type: "vibe" }, { type: "workflow" }, { type: "closing" }];
evidence?: Array<{ id: string; session_id?: string; message_uuid?: string; memory_id?: string; summary?: string }>;
};
```
For app handoff, write JSON under `~/.obelisk/recap/`. Weekly filenames are
`recap-{YYYY}-W{WW}.json`; monthly filenames are `recap-{YYYY}-{MM}.json`.
+49
View File
@@ -0,0 +1,49 @@
# Card 2 Thinking Writing
Thinking Path should feel like a few bends in the user's reasoning, not a
weekly changelog. Before writing, test each row by asking: "what changed here?"
## Mock taste anchor
```json
{
"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" }
]
}
```
The prompts stay close to the user's words. Each turn is a short decision
fragment, not a full explanation.
## JSON Shape
```ts
type ThinkingPathCard = {
type: "thinking_path";
title: string;
items: Array<{
day: string;
prompt: string;
turn: string;
evidence_refs?: string[];
}>;
};
```
Field duties:
- `title`: designed deck line, not `本周路径`, not a research-paper heading.
- `prompt`: user's compact question, friction, or task. Use source language.
- `turn`: short decision fragment, finding, or shift; usually under 10 words.
Compact English fragments are allowed when they work as designed chrome.
After writing, remove any row whose prompt is a workflow name or whose turn
describes implementation rather than changed thinking.
Update the JSON now before reading `pattern3-vibe.md`.
+73
View File
@@ -0,0 +1,73 @@
# Card 3 Vibe Writing
Vibe is affectionate observation. It should make the user recognize themselves
without feeling evaluated. Before writing, remove anything that reads like a
correction audit, behavior label, diagnosis, or complaint ledger.
## Mock taste anchor
```json
{
"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"
}
}
```
The humor comes from exact small lines. `可以` is funnier and truer than
"approval signal".
## JSON Shape
```ts
type VibeCard = {
type: "vibe";
title: string;
voice_lines: Array<{
label: string;
text: string;
count?: number;
time?: string;
evidence_refs?: string[];
}>;
meter?: {
label: string;
value: number;
caption: string;
};
quote?: {
text: string;
caption?: string;
evidence_refs?: string[];
};
};
```
Field duties:
- `title`: light character-study line, not a scorecard.
- `voice_lines[].text`: exact user words; no paraphrase, translation,
ellipsized half-quote, meta text, or correction log.
- `voice_lines[].label`: designed chrome can be English; the quoted user text
stays in source language.
- `meter`: meter is not a diagnosis. Keep the caption one or two words and
affectionate, never punitive.
- `quote.text`: one exact user phrase or sentence.
Do not use `[Request interrupted by user]`, tool output, injected context, or
UI status text as vibe. After writing, verify every `voice_lines[].text` and
`quote.text` can be traced to a non-meta user message.
Update the JSON now before reading `pattern4-workflow.md`.
+68
View File
@@ -0,0 +1,68 @@
# Card 4 Workflow Writing
Workflow is the orchestration card. It should show the strongest few workflow
runs and the user's reaction to them. Before writing, remove any row whose
reaction is not traceable to a visible user reaction.
## Mock taste anchor
```json
{
"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."
}
```
The row reactions are user reactions. The title carries the metric; the verdict
is a small English seal.
## JSON Shape
```ts
type WorkflowCard = {
type: "workflow";
title: string;
deck?: string;
stats?: string;
items: Array<{
name: string;
reaction: string;
evidence_refs?: string[];
}>;
verdict: string;
};
```
Field duties:
- `title`: human story line or compact metric line.
- `deck`: optional second line; do not repeat stats mechanically.
- `stats`: compact count line.
- `items[].name`: actual workflow name, command name, or run-id prefix.
- `items[].reaction`: exact or lightly trimmed user reaction. Preserve source
language. No feature description, implementation summary, agent count,
duration, "framework switch", "modularization", "theming landed", or other
internal progress label.
- `verdict`: compact seal based on the row reactions, often 3-6 words.
Agent counts belong only in `title` or `stats`, never in `items[].reaction`.
These row values are invalid because they are implementation labels, not user
reactions:
- `13 agents, the big build` is invalid.
- `9 agents, framework switch` is invalid.
- `6 agents, modularization` is invalid.
- `theming landed` is invalid.
If no user reaction exists, omit the row rather than write an implementation result.
After writing, check that every row name maps to retrieval evidence and every
reaction can be read as quoted user verdict text.
Update the JSON now before reading `pattern5-closing.md`.
+54
View File
@@ -0,0 +1,54 @@
# Card 5 Closing Writing
Closing is a receipt, not a second summary. Before writing, read the headline
alone. If it does not mean anything without the rest of the card, add the unit
or choose a better line.
## Mock taste anchor
```json
{
"type": "closing",
"headline": "19 days",
"receipts": ["847 messages exchanged", "12 corrections · 47 approvals"],
"most_said_phrase": "好的开始做吧",
"signoff": "See you next week."
}
```
This works because `19 days` has a unit, the receipts feel like a small receipt,
and `See you next week.` is a quiet goodbye instead of a slogan.
## JSON Shape
```ts
type ClosingCard = {
type: "closing";
headline: string;
receipts: string[];
most_said_phrase?: string;
signoff: string;
evidence_refs?: string[];
};
```
Field duties:
- `headline`: compact stat or phrase with its unit; not a naked number.
- `receipts`: at most two `receipts`, compact and personal.
- `most_said_phrase`: complete phrase the user actually said, or omit it.
- `signoff`: short and earned; quiet goodbye, not advice or a brand slogan.
English signoff chrome such as `See you next week.` is allowed.
After writing, remove internal scope notes from visible fields and put them in
`evidence`. The final card should feel like the deck ending, not the report
continuing.
Final save rules:
- The file contains only the JSON object: no Markdown fence, no prose.
- Keep exactly five cards in this order: cover, thinking_path, vibe, workflow,
closing.
- Keep private SQL, raw tool output, secrets, long paths, and source caveats out
of visible card text; put traceability in `evidence`.
- After saving, reply briefly with the saved path and important evidence caveats.
+56 -2
View File
@@ -89,7 +89,8 @@ CREATE VIRTUAL TABLE messages_fts USING fts5(
);
```
Queried via `MATCH` syntax. Rebuilt on each index pass.
Queried via `MATCH` syntax. The table is kept in sync by the `messages_fts_*`
triggers above; rebuild it manually only when repairing FTS state.
### memories_fts
@@ -384,6 +385,31 @@ const msgs = thread('session-uuid');
return { count: msgs.length, first: msgs[0]?.text?.slice(0, 100) };
```
#### `raw(uuid, opts?)`
Windowed access to the original JSONL line for a message. Use this when indexed
text, tool inputs, or tool results were truncated and you need the raw source.
It resolves main-session, subagent, and workflow-agent JSONL paths from the
indexed message metadata.
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | `string` | Message UUID |
| `opts.offset` | `number` | Character offset into the JSONL line (default 0) |
| `opts.limit` | `number` | Max characters to return (default 10000) |
**Returns:** `{ text, totalLength, offset, limit, hasMore }` or `null` if the
source line cannot be found.
```js
const line = raw('message-uuid', { offset: 0, limit: 4000 });
return {
preview: line?.text,
has_more: line?.hasMore,
total: line?.totalLength,
};
```
#### `subagents(opts?)`
All subagent spawns, with message counts. For backward compatibility, passing a string is treated as `sessionId`.
@@ -484,6 +510,34 @@ const last5 = recent(5);
return last5.map(s => ({ title: s.title, project: s.project_path, ended: s.ended_at }));
```
#### `summaries(opts?)`
Session summary rows, newest first. For backward compatibility, passing a
string is treated as `sessionId`, and passing a number is treated as `limit`.
| Param | Type | Description |
|-------|------|-------------|
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.sessions` | `string[]` | Restrict to a set of session IDs |
| `opts.project` | `string` | SQL `LIKE` pattern over `sessions.project` |
| `opts.after` | `string` | ISO 8601 lower bound on summary timestamp |
| `opts.before` | `string` | ISO 8601 upper bound on summary timestamp |
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
| `opts.limit` | `number` | Max results (default 100) |
**Returns:** `Array<summary_row & { session_title, project }>` ordered by
`timestamp` descending.
```js
const rows = summaries({ project: '%quiet-zero%', limit: 5 });
return rows.map(s => ({
session: s.session_title,
source: s.source,
summary: s.content?.slice(0, 240),
timestamp: s.timestamp,
}));
```
#### `overview(opts?)`
Compact orientation map for choosing the next retrieval scope. It is not an
@@ -785,7 +839,7 @@ const wfs = workflows();
for (const wf of wfs.slice(0, 3)) {
const tree = workflowTree(wf.run_id);
wf.agent_details = tree?.agents.map(a => ({
type: a.agent_type, desc: a.description, msgs: a.messages.length,
type: a.agent_type, desc: a.description, msgs: a.messageCount,
}));
}
return wfs.slice(0, 3);
+1 -1
View File
@@ -82,7 +82,7 @@ function extractContentType(content) {
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
}
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<task-notification>|<local-command-caveat>|<local-command-stdout>)/;
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<(?:task-notification|system-reminder)\b|<local-command(?:\b|-))/;
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
const msg = record?.message || {};