feat(app): add weekly recap cards with swipeable story UI and export

Introduce a Spotify-Wrapped-style recap feature: five themed cards
  (Cover, Path, Vibe, Workflow, Closing) rendered per archetype palette,
  with keyboard/swipe navigation and image export via capture IPC. Add
  RecapList, RecapDetail, RecapExport views and recap component library.
  Wire recap:list/read/updated IPC channels through preload, document the
  retrieval-to-card contract in references/recap-patterns.md, and bundle
  dist-renderer for production use.
This commit is contained in:
tommy0103
2026-06-14 03:16:49 +08:00
parent 4eec6b38c9
commit 9fc7f202f0
30 changed files with 4924 additions and 76 deletions
+1
View File
@@ -3,3 +3,4 @@ plans/
.skillopt-backups .skillopt-backups
tests/ tests/
node_modules/ node_modules/
dist-renderer/
+4
View File
@@ -124,6 +124,7 @@ same SQLite data.
- `references/schema.md` — full SQLite schema and API reference - `references/schema.md` — full SQLite schema and API reference
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks - `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval - `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
- `references/recap-patterns.md` — optional `/obelisk recap` card content contract
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps - `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md` The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md`
@@ -132,6 +133,8 @@ is the human/agent explanation of that contract.
The design is progressive disclosure with guardrails: the main skill keeps the 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 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. 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.
## What gets indexed ## What gets indexed
@@ -159,6 +162,7 @@ Full-text search via FTS5 covers message text across every session layer and ran
├── schema.md # Full table schema + advanced API reference ├── schema.md # Full table schema + advanced API reference
├── query-patterns.md # Copyable retrieval recipes ├── query-patterns.md # Copyable retrieval recipes
├── retrieval-semantics.md # Query design frame for retrieval semantics ├── retrieval-semantics.md # Query design frame for retrieval semantics
├── recap-patterns.md # Optional /obelisk recap card content contract
└── pitfalls.md # Scope, FTS, ordering, and compactness traps └── pitfalls.md # Scope, FTS, ordering, and compactness traps
``` ```
+25 -1
View File
@@ -72,12 +72,36 @@ Use `sql()` only as an escalation path for exact joins, aggregations, or schema
questions that helpers cannot express cleanly. Do not use raw SQL as a generic questions that helpers cannot express cleanly. Do not use raw SQL as a generic
fallback for broad retrieval. fallback for broad retrieval.
## Intent Routing
Obelisk supports a small intent prefix layer after `/obelisk`. This is for
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` |
Routing rules:
1. If the first word is `recap`, read `references/recap-patterns.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
`/obelisk recap last month`; interpret these as natural period targets
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
infer recap from broad requests for weekly/monthly summaries, charts,
rankings, shareable cards, or playlist-style metaphors.
## Query Routing ## Query Routing
Before writing a query, classify the task. Progressive disclosure is useful, but Before writing a query, classify the task. Progressive disclosure is useful, but
skipping the relevant reference usually costs extra query rounds. skipping the relevant reference usually costs extra query rounds.
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed. - Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame. - Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join. - Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
- Read `references/pitfalls.md` after an error or when helper fields, FTS syntax, aliases, or row shapes are unclear. - Read `references/pitfalls.md` after an error or when helper fields, FTS syntax, aliases, or row shapes are unclear.
+24 -4
View File
@@ -64,9 +64,27 @@ function createIndexerService({
let running = false; let running = false;
let pending = false; let pending = false;
let lastReason = null; let lastReason = null;
let changedPaths = new Set();
let idlePromise = Promise.resolve(); let idlePromise = Promise.resolve();
const runBuildNow = (reason = 'manual') => { const addChangedPath = (changedPath) => {
if (Array.isArray(changedPath)) {
for (const p of changedPath) addChangedPath(p);
return;
}
const name = changedPath ? String(changedPath) : '';
if (name) changedPaths.add(name);
};
const takeChangedPaths = () => {
if (!changedPaths.size) return undefined;
const paths = [...changedPaths];
changedPaths = new Set();
return paths;
};
const runBuildNow = (reason = 'manual', paths = undefined) => {
addChangedPath(paths);
if (stopped) return idlePromise; if (stopped) return idlePromise;
if (running) { if (running) {
pending = true; pending = true;
@@ -74,8 +92,9 @@ function createIndexerService({
} }
running = true; running = true;
pending = false; pending = false;
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => { idlePromise = (async () => {
await buildIndex({ reason }); await buildIndex({ reason, changedPaths: buildChangedPaths });
writeHeartbeat(); writeHeartbeat();
})() })()
.catch((error) => { .catch((error) => {
@@ -91,8 +110,9 @@ function createIndexerService({
return idlePromise; return idlePromise;
}; };
const scheduleBuild = (reason = 'change') => { const scheduleBuild = (reason = 'change', changedPath = undefined) => {
if (stopped) return; if (stopped) return;
addChangedPath(changedPath);
lastReason = reason; lastReason = reason;
if (running) pending = true; if (running) pending = true;
if (buildTimer) timers.clearTimeout(buildTimer); if (buildTimer) timers.clearTimeout(buildTimer);
@@ -112,7 +132,7 @@ function createIndexerService({
const startWatching = () => { const startWatching = () => {
if (stopped || watcher) return; if (stopped || watcher) return;
watcher = watch(() => scheduleBuild('watch')); watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) { if (!watcher) {
watchRetryTimer = timers.setTimeout(() => { watchRetryTimer = timers.setTimeout(() => {
watchRetryTimer = null; watchRetryTimer = null;
+116 -1
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain } = require('electron'); const { app, BrowserWindow, ipcMain, clipboard, dialog, nativeImage } = require('electron');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
@@ -57,6 +57,13 @@ function createWindow() {
}, },
}); });
// Prevent Electron's built-in zoom so Cmd+=/- reaches the renderer
win.webContents.on('before-input-event', (event, input) => {
if ((input.meta || input.control) && ['+', '=', '-', '0'].includes(input.key)) {
win.webContents.setZoomLevel(0);
}
});
const isDev = process.argv.includes('--dev'); const isDev = process.argv.includes('--dev');
if (isDev) { if (isDev) {
win.loadURL('http://localhost:5173'); win.loadURL('http://localhost:5173');
@@ -66,11 +73,43 @@ function createWindow() {
} }
} }
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
let obeliskWatcher = null;
function startObeliskWatcher() {
const chokidar = require('chokidar');
if (!fs.existsSync(OBELISK_DIR)) {
fs.mkdirSync(OBELISK_DIR, { recursive: true });
}
obeliskWatcher = chokidar.watch(OBELISK_DIR, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
ignored: (p, stats) => {
if (stats?.isDirectory()) return false;
if (!stats) return false;
return !p.endsWith('.md') && !p.endsWith('.json');
},
});
obeliskWatcher.on('add', onObeliskChange);
obeliskWatcher.on('change', onObeliskChange);
obeliskWatcher.on('unlink', onObeliskChange);
}
function onObeliskChange(filePath) {
if (filePath.startsWith(RECAP_DIR)) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('obelisk:recap-updated', filePath);
}
}
}
app.whenReady().then(() => { app.whenReady().then(() => {
indexerWorker = createWorkerBuildIndex(); indexerWorker = createWorkerBuildIndex();
openDb(); openDb();
createWindow(); createWindow();
startIndexerService().runBuildNow('startup'); startIndexerService().runBuildNow('startup');
startObeliskWatcher();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
@@ -80,6 +119,7 @@ app.whenReady().then(() => {
app.on('before-quit', () => { app.on('before-quit', () => {
if (indexerService) indexerService.stop(); if (indexerService) indexerService.stop();
if (indexerWorker) indexerWorker.stop(); if (indexerWorker) indexerWorker.stop();
if (obeliskWatcher) obeliskWatcher.close();
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
@@ -300,3 +340,78 @@ ipcMain.handle('db:getUsageStats', () => {
return { daily, totalTokens, peakDay, longestTurn }; return { daily, totalTokens, peakDay, longestTurn };
}); });
// --- Capture ---
const EXPORT_WIDTH = 540;
const EXPORT_HEIGHT = 675;
async function createExportCapture(parentWin, query) {
const exportWin = new BrowserWindow({
width: EXPORT_WIDTH,
height: EXPORT_HEIGHT,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
offscreen: true,
deviceScaleFactor: 2,
},
});
const isDev = process.argv.includes('--dev');
const url = isDev
? `http://localhost:5173/#/recap-export?${query}`
: `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`;
await exportWin.loadURL(url);
await new Promise(r => setTimeout(r, 500));
const image = await exportWin.webContents.capturePage({
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
});
exportWin.close();
return image;
}
ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const query = `card=${cardIdx}&arch=${archetype}`;
const image = await createExportCapture(win, query);
const { filePath } = await dialog.showSaveDialog(win, {
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
});
if (!filePath) return null;
fs.writeFileSync(filePath, image.toPNG());
return filePath;
});
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
const query = `card=${cardIdx}&arch=${archetype}`;
const image = await createExportCapture(win, query);
clipboard.writeImage(image);
return true;
});
// --- Recap files ---
ipcMain.handle('recap:list', () => {
if (!fs.existsSync(RECAP_DIR)) return [];
return fs.readdirSync(RECAP_DIR)
.filter(f => f.endsWith('.json'))
.sort()
.reverse();
});
ipcMain.handle('recap:read', (_, filename) => {
const filePath = path.join(RECAP_DIR, path.basename(filename));
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch { return null; }
});
+9
View File
@@ -24,4 +24,13 @@ contextBridge.exposeInMainWorld('obelisk', {
ipcRenderer.on('obelisk:index-updated', listener); ipcRenderer.on('obelisk:index-updated', listener);
return () => ipcRenderer.removeListener('obelisk:index-updated', listener); return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
}, },
captureExport: (opts) => ipcRenderer.invoke('capture:export', opts),
copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts),
recapList: () => ipcRenderer.invoke('recap:list'),
recapRead: (filename) => ipcRenderer.invoke('recap:read', filename),
onRecapUpdated: (callback) => {
const listener = (_, filePath) => callback(filePath);
ipcRenderer.on('obelisk:recap-updated', listener);
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
},
}); });
+54 -8
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, watch } from 'vue'; import { computed, watch, ref, provide } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { import {
state, state,
@@ -29,6 +29,7 @@ const currentRouteType = computed(() => {
const name = route.name; const name = route.name;
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions'; if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
if (name === 'Activity') return 'activity'; if (name === 'Activity') return 'activity';
if (name === 'Recap' || name === 'RecapDetail') return 'recap';
return 'memory'; return 'memory';
}); });
@@ -81,6 +82,10 @@ const windowTitle = computed(() => {
let scopeText = ''; let scopeText = '';
if (route.name === 'Activity') { if (route.name === 'Activity') {
scopeText = 'Activity'; scopeText = 'Activity';
} else if (route.name === 'Recap') {
scopeText = 'Recap';
} else if (route.name === 'RecapDetail') {
scopeText = `Recap · ${route.params.id}`;
} else if (route.name?.startsWith('Session')) { } else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') { if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id); const s = state.sessions.find(x => x.id === route.params.id);
@@ -114,6 +119,8 @@ function handleSidebarRoute(routeName) {
router.push('/sessions'); router.push('/sessions');
} else if (routeName === 'activity') { } else if (routeName === 'activity') {
router.push('/activity'); router.push('/activity');
} else if (routeName === 'recap') {
router.push('/recap');
} else { } else {
router.push('/memory'); router.push('/memory');
} }
@@ -130,8 +137,7 @@ function handleClearProject() {
function handleSidebarProject(slug) { function handleSidebarProject(slug) {
setProject(slug); setProject(slug);
// Stay on current list route if (currentRouteType.value === 'sessions') router.push('/sessions');
if (state.route === 'sessions') router.push('/sessions');
else router.push('/memory'); else router.push('/memory');
} }
@@ -160,10 +166,20 @@ function handleToggleSearchMsgs() {
// --- Keep-alive includes --- // --- Keep-alive includes ---
const keepAliveIncludes = ['SessionDetail']; const keepAliveIncludes = ['SessionDetail'];
const isExportRoute = computed(() => route.name === 'RecapExport');
// --- Recap ---
const recapGenerateOpen = ref(false);
function setRecapKind(k) {
router.replace({ path: '/recap', query: { kind: k } });
}
provide('recapGenerateOpen', recapGenerateOpen);
</script> </script>
<template> <template>
<div class="app"> <router-view v-if="isExportRoute" />
<div class="app" v-else>
<div class="titlebar"> <div class="titlebar">
<div class="titlebar-text" id="titlebar-text"> <div class="titlebar-text" id="titlebar-text">
<span class="app-name">{{ windowTitle.appName }}</span> <span class="app-name">{{ windowTitle.appName }}</span>
@@ -216,7 +232,7 @@ const keepAliveIncludes = ['SessionDetail'];
</button> </button>
<button <button
class="sidebar-item" class="sidebar-item"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }" :class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')" @click="handleSidebarView('active')"
> >
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -228,7 +244,7 @@ const keepAliveIncludes = ['SessionDetail'];
</button> </button>
<button <button
class="sidebar-item sub" class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }" :class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')" @click="handleSidebarView('active')"
> >
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -239,7 +255,7 @@ const keepAliveIncludes = ['SessionDetail'];
</button> </button>
<button <button
class="sidebar-item sub" class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }" :class="{ active: currentRouteType === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
@click="handleSidebarView('archived')" @click="handleSidebarView('archived')"
> >
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -264,9 +280,20 @@ const keepAliveIncludes = ['SessionDetail'];
</svg> </svg>
<span class="label">Activity</span> <span class="label">Activity</span>
</button> </button>
<button
class="sidebar-item"
:class="{ active: route.name === 'Recap' }"
@click="handleSidebarRoute('recap')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 2h10v12H3z"/>
<path d="M6 5h4M6 8h4M6 11h2"/>
</svg>
<span class="label">Recap</span>
</button>
</div> </div>
<div class="sidebar-section projects"> <div class="sidebar-section projects" v-if="currentRouteType === 'sessions' || currentRouteType === 'memory'">
<div class="sidebar-section-title"><span>Projects</span></div> <div class="sidebar-section-title"><span>Projects</span></div>
<div class="sidebar-search" v-if="totalProjectCount >= 6"> <div class="sidebar-search" v-if="totalProjectCount >= 6">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"> <svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
@@ -346,9 +373,28 @@ const keepAliveIncludes = ['SessionDetail'];
</span> </span>
</template> </template>
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span> <span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
<span v-if="route.name === 'Recap'" class="crumb terminal">Recap</span>
<router-link v-if="route.name === 'RecapDetail'" class="crumb" to="/recap">Recap</router-link>
<template v-if="route.name === 'RecapDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.id }}</span>
</template>
</template> </template>
</div> </div>
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<!-- Recap toolbar actions -->
<template v-if="route.name === 'Recap'">
<div class="tab-group">
<button :class="{ active: (route.query.kind || 'weekly') === 'weekly' }" @click="setRecapKind('weekly')">Weekly</button>
<button :class="{ active: route.query.kind === 'monthly' }" @click="setRecapKind('monthly')">Monthly</button>
</div>
<button class="toolbar-action-primary" @click="recapGenerateOpen = true">
<span class="plus">+</span>
<span>Generate</span>
</button>
</template>
<div class="toolbar-search" id="search-wrap" v-if="showToolbar"> <div class="toolbar-search" id="search-wrap" v-if="showToolbar">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"> <svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/> <circle cx="7" cy="7" r="5"/>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -46,6 +46,8 @@ const breadcrumbs = computed(() => {
crumbs.push({ label: filename, terminal: true, filename: true }); crumbs.push({ label: filename, terminal: true, filename: true });
} else if (name === 'Activity') { } else if (name === 'Activity') {
crumbs.push({ label: 'Activity', terminal: true }); crumbs.push({ label: 'Activity', terminal: true });
} else if (name === 'Recap') {
crumbs.push({ label: 'Recap', terminal: true });
} }
return crumbs; return crumbs;
@@ -0,0 +1,77 @@
<script setup>
defineProps({
headline: String,
stats: Array,
mostSaidPhrase: String,
signoff: String,
idx: { type: Number, default: 5 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card card-closing">
<div class="eyebrow">
<span class="diamond"></span>
<span>The week, carved.</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="closing-body">
<div class="closing-headline">{{ headline }}</div>
<div class="closing-stats" v-if="stats">
<div v-for="(line, i) in stats" :key="i">{{ line }}</div>
</div>
<div class="closing-quote" v-if="mostSaidPhrase">
<span>"{{ mostSaidPhrase }}"</span>
<span class="verb"> most-said phrase</span>
</div>
<div class="closing-signoff">{{ signoff }}</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.card-closing {
background:
radial-gradient(80% 70% at 50% 30%, var(--tg-soft) 0%, transparent 60%),
radial-gradient(60% 50% at 50% 50%, var(--tg-mid) 0%, transparent 70%),
linear-gradient(180deg, rgba(10,11,20,0.6) 0%, rgba(10,11,20,0.95) 100%);
transition: background var(--theme-ease);
}
.closing-body {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 0 40px; gap: 32px;
position: relative; z-index: 1;
}
.closing-headline {
font-family: var(--font-serif); font-size: 72px;
line-height: 1; font-weight: 500; letter-spacing: -0.02em;
color: var(--fg); text-shadow: 0 4px 24px var(--tg);
transition: text-shadow var(--theme-ease);
}
.closing-stats {
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
font-variant-numeric: tabular-nums;
display: flex; flex-direction: column; gap: 4px;
}
.closing-quote {
font-family: var(--font-serif); font-style: italic;
font-size: 19px; color: var(--fg-2); line-height: 1.5; max-width: 360px;
}
.closing-quote .verb {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted); display: block; margin-top: 8px;
}
.closing-signoff {
font-family: var(--font-serif); font-size: 15px;
color: var(--muted); font-style: italic;
}
</style>
@@ -0,0 +1,142 @@
<script setup>
import { computed } from 'vue';
import { CORNER_SEALS } from './seals.js';
const props = defineProps({
archKey: String,
badge: String,
title: String,
subtitle: String,
activity: Array,
footer: String,
idx: { type: Number, default: 1 },
total: { type: Number, default: 5 },
});
const sealSvg = computed(() => CORNER_SEALS[props.archKey] || '');
</script>
<template>
<article class="card card-cover">
<div class="cover-stars">
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="eyebrow">
<span class="diamond"></span>
<span>{{ badge }}</span>
</div>
<div class="cover-seal-corner" v-html="sealSvg"></div>
<div class="cover-body">
<div class="cover-archetype">{{ title }}</div>
<div class="cover-subtitle">{{ subtitle }}</div>
<div class="cover-activity">
<div class="cover-activity-row">
<div
v-for="(val, i) in activity" :key="i"
class="cover-activity-cell"
:class="{ dim: val < 0.4 }"
>
<div v-if="val > 0" class="fill" :style="{ height: val * 100 + '%' }"></div>
</div>
</div>
<div class="cover-activity-labels">
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
</div>
</div>
<div class="cover-footer" v-html="footer"></div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.card-cover {
background:
radial-gradient(120% 80% at 50% 100%, var(--tg) 0%, transparent 55%),
radial-gradient(100% 70% at 50% 80%, var(--tg-mid) 0%, transparent 65%),
radial-gradient(80% 50% at 50% 60%, var(--tg-soft) 0%, transparent 65%),
linear-gradient(180deg, rgba(10,11,20,0.4) 0%, rgba(10,11,20,0.85) 70%);
transition: background var(--theme-ease);
}
.cover-stars {
position: absolute; inset: 0; pointer-events: none;
}
.cover-stars span {
position: absolute;
width: 1.5px; height: 1.5px;
background: rgba(255,255,255,0.85);
border-radius: 50%;
box-shadow: 0 0 4px rgba(255,255,255,0.6);
}
.cover-stars span:nth-child(1) { top: 12%; left: 18%; }
.cover-stars span:nth-child(2) { top: 8%; left: 78%; width: 2px; height: 2px; }
.cover-stars span:nth-child(3) { top: 22%; left: 88%; opacity: 0.6; }
.cover-stars span:nth-child(4) { top: 32%; left: 8%; opacity: 0.5; }
.cover-stars span:nth-child(5) { top: 18%; left: 52%; width: 1px; height: 1px; opacity: 0.7; }
.cover-seal-corner {
position: absolute;
top: 22px; right: 24px;
width: 60px; height: 60px; z-index: 3;
}
.cover-seal-corner :deep(svg) {
width: 100%; height: 100%;
filter: drop-shadow(0 0 12px var(--tg));
transition: filter var(--theme-ease);
}
.cover-body {
flex: 1; display: flex; flex-direction: column;
padding: 0 36px; position: relative; z-index: 1;
}
.cover-archetype {
margin-top: auto;
font-family: var(--font-serif);
font-size: 64px; line-height: 1.05; font-weight: 500;
letter-spacing: -0.02em; color: var(--fg);
margin-bottom: 18px;
text-shadow: 0 2px 24px rgba(0,0,0,0.4);
}
.cover-subtitle {
font-family: var(--font-serif); font-style: italic;
font-size: 19px; line-height: 1.5; color: var(--fg-2);
margin-bottom: 36px; max-width: 92%;
}
.cover-activity { margin-bottom: 28px; }
.cover-activity-row {
display: grid; grid-template-columns: repeat(7, 1fr);
gap: 6px; margin-bottom: 8px;
}
.cover-activity-cell {
height: 32px; border-radius: 3px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline);
position: relative; overflow: hidden;
}
.cover-activity-cell .fill {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(to top, var(--tc), var(--tc-2));
box-shadow: 0 0 10px var(--tg);
border-radius: 0 0 2px 2px;
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.cover-activity-cell.dim .fill {
background: linear-gradient(to top, rgba(255,255,255,0.15), rgba(255,255,255,0.06));
box-shadow: none;
}
.cover-activity-labels {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted-2); text-align: center;
}
.cover-footer {
padding-bottom: 28px;
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
display: flex; gap: 14px; font-variant-numeric: tabular-nums;
}
.cover-footer :deep(strong) { color: var(--fg); font-weight: 500; }
.cover-footer :deep(.sep) { color: var(--muted-3); }
</style>
@@ -0,0 +1,94 @@
<script setup>
defineProps({
title: String,
items: Array,
idx: { type: Number, default: 2 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Your thinking path</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="timeline-wrap">
<div class="timeline">
<div v-for="(item, i) in items" :key="i" class="tl-item">
<div class="tl-node"></div>
<div class="tl-day">{{ item.day }}</div>
<div class="tl-prompt">{{ item.prompt }}</div>
<div class="tl-outcome">
<span>{{ item.outcome }}</span>
</div>
</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.timeline-wrap {
flex: 1; padding: 0 36px 24px 36px;
overflow-y: auto; position: relative; z-index: 1;
}
.timeline { position: relative; padding-left: 28px; }
.timeline::before {
content: ''; position: absolute;
left: 6px; top: 14px; bottom: 14px; width: 1px;
background: linear-gradient(to bottom,
var(--tg) 0%, var(--tg-mid) 30%,
rgba(255,255,255,0.12) 70%, rgba(255,255,255,0.06) 100%);
transition: background var(--theme-ease);
}
.tl-item { position: relative; padding: 8px 0 10px; }
.tl-item:first-child { padding-top: 4px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-node {
position: absolute; left: -28px; top: 16px;
width: 13px; height: 13px;
}
.tl-item:first-child .tl-node { top: 12px; }
.tl-node::before {
content: ''; position: absolute;
left: 50%; top: 50%;
width: 7px; height: 7px;
background: var(--tc);
transform: translate(-50%, -50%) rotate(45deg);
box-shadow: 0 0 8px var(--tg), 0 0 0 3px rgba(10,11,20,1);
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.tl-day {
display: inline-block;
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
color: var(--tc-2); margin-bottom: 4px; letter-spacing: 0.01em;
transition: color var(--theme-ease);
}
.tl-prompt {
font-family: var(--font-serif); font-style: italic;
font-size: 16px; line-height: 1.35; color: var(--fg); margin-bottom: 6px;
}
.tl-prompt::before { content: '\201C'; color: var(--muted-2); margin-right: 1px; }
.tl-prompt::after { content: '\201D'; color: var(--muted-2); margin-left: 1px; }
.tl-outcome {
display: inline-flex; align-items: baseline; gap: 8px;
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2);
padding: 4px 10px;
border-radius: 4px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--hairline);
border-left: 2px solid var(--tc);
box-shadow: -2px 0 8px -2px var(--tg-mid);
transition: border-left-color var(--theme-ease), box-shadow var(--theme-ease);
}
</style>
@@ -0,0 +1,132 @@
<script setup>
defineProps({
title: String,
observations: Array,
meter: Object,
quote: Object,
idx: { type: Number, default: 3 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Your vibe this week</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="vibe-content">
<div class="vibe-section">
<div class="section-label">Things you kept saying</div>
<div class="vibe-observations">
<div v-for="(obs, i) in observations" :key="i" class="vibe-obs">
<div class="vibe-obs-text">{{ obs.text }}</div>
<div class="vibe-obs-meta">
<template v-if="obs.count">×{{ obs.count }} · </template>
{{ obs.label }}
<template v-if="obs.time"> · {{ obs.time }}</template>
</div>
</div>
</div>
</div>
<div class="vibe-section" v-if="meter">
<div class="vibe-meter">
<div class="vibe-meter-track">
<div class="vibe-meter-fill" :style="{ width: meter.value * 100 + '%' }"></div>
</div>
<div class="vibe-meter-row">
<span class="vibe-meter-label">{{ meter.label }}</span>
<span class="vibe-meter-caption">{{ meter.caption }}</span>
</div>
</div>
</div>
<div class="vibe-quote" v-if="quote">
<div class="vibe-quote-text">{{ quote.text }}</div>
<div class="vibe-quote-caption" v-if="quote.caption"> {{ quote.caption }}</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.vibe-content {
flex: 1; padding: 0 36px 32px;
display: flex; flex-direction: column; gap: 22px;
overflow-y: auto; position: relative; z-index: 1;
}
.vibe-section { display: flex; flex-direction: column; gap: 12px; }
.vibe-observations { display: flex; flex-direction: column; gap: 10px; }
.vibe-obs {
display: flex; align-items: baseline; gap: 12px;
padding: 10px 14px;
background: rgba(255,255,255,0.025);
border: 1px solid var(--hairline);
border-left: 2px solid var(--tg-mid);
border-radius: 4px;
transition: border-left-color var(--theme-ease);
}
.vibe-obs-text {
font-family: var(--font-serif); font-style: italic;
font-size: 18px; line-height: 1.4; color: var(--fg); flex: 1;
}
.vibe-obs-text::before { content: '\201C'; color: var(--muted-2); }
.vibe-obs-text::after { content: '\201D'; color: var(--muted-2); }
.vibe-obs-meta {
font-family: var(--font-mono); font-size: 12px; color: var(--muted);
white-space: nowrap; flex-shrink: 0; font-variant-numeric: tabular-nums;
}
.vibe-correction {
font-family: var(--font-serif); font-size: 14.5px;
line-height: 1.6; color: var(--fg-2);
}
.vibe-correction :deep(strong) { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
.vibe-correction :deep(.vs) { color: var(--muted); font-style: italic; margin: 0 6px; }
.vibe-meter { display: flex; flex-direction: column; gap: 8px; }
.vibe-meter-track {
position: relative; height: 10px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--hairline); border-radius: 2px; overflow: hidden;
}
.vibe-meter-fill {
position: absolute; top: 0; left: 0; bottom: 0;
background: linear-gradient(to right, var(--tc), var(--tc-2));
box-shadow: 0 0 12px var(--tg); border-radius: 1px;
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.vibe-meter-row {
display: flex; align-items: baseline; justify-content: space-between;
font-family: var(--font-mono); font-size: 11.5px;
}
.vibe-meter-label {
color: var(--muted); font-style: italic;
font-family: var(--font-serif); font-size: 14px;
}
.vibe-meter-caption {
color: var(--tc-2); font-weight: 600;
transition: color var(--theme-ease);
}
.vibe-quote {
margin-top: auto; padding: 18px 0 0;
border-top: 1px solid var(--hairline);
}
.vibe-quote-text {
font-family: var(--font-serif); font-size: 22px;
line-height: 1.4; color: var(--fg); font-weight: 500;
letter-spacing: -0.01em; margin-bottom: 8px;
}
.vibe-quote-caption {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted);
}
</style>
@@ -0,0 +1,98 @@
<script setup>
defineProps({
title: String,
summary: String,
stats: String,
items: Array,
verdict: String,
idx: { type: Number, default: 4 },
total: { type: Number, default: 5 },
});
</script>
<template>
<article class="card" data-themed>
<div class="eyebrow">
<span class="diamond"></span>
<span>Workflows</span>
<span class="eyebrow-spacer"></span>
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
</div>
<div class="card-title">{{ title }}</div>
<div class="card-deck-text" v-if="summary">{{ summary }}</div>
<div class="wf-content">
<div class="wf-stats" v-if="stats">{{ stats }}</div>
<div class="wf-list">
<div v-for="(item, i) in items" :key="i" class="wf-item">
<div class="wf-item-name">{{ item.name }}</div>
<div class="wf-item-outcome">{{ item.outcome }}</div>
</div>
</div>
<div class="wf-verdict" v-if="verdict">
<div class="wf-verdict-label">Verdict </div>
<div class="wf-verdict-text">{{ verdict }}</div>
</div>
</div>
</article>
</template>
<style scoped>
@import './card-base.css';
.wf-content {
flex: 1; padding: 0 36px 32px;
display: flex; flex-direction: column; gap: 18px;
overflow-y: auto; position: relative; z-index: 1;
}
.wf-stats {
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
font-variant-numeric: tabular-nums; display: flex; gap: 14px;
}
.wf-stats :deep(strong) { color: var(--fg); font-weight: 500; }
.wf-stats :deep(.sep) { color: var(--muted-3); }
.wf-list {
display: flex; flex-direction: column; gap: 1px;
background: var(--hairline); border: 1px solid var(--hairline);
border-radius: 6px; overflow: hidden;
}
.wf-item {
padding: 14px 16px; background: rgba(10,11,20,0.4);
display: flex; flex-direction: column; gap: 6px;
}
.wf-item-name {
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
color: var(--fg); letter-spacing: -0.005em;
}
.wf-item-outcome {
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-verdict {
margin-top: auto; padding: 16px 18px;
border: 1px solid var(--hairline-strong); border-radius: 6px;
background: rgba(255,255,255,0.025);
display: flex; flex-direction: column; gap: 6px;
position: relative; overflow: hidden;
}
.wf-verdict::before {
content: ''; position: absolute;
left: 0; top: 0; bottom: 0; width: 2px;
background: var(--tc); box-shadow: 0 0 8px var(--tg);
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.wf-verdict-label {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted);
}
.wf-verdict-text {
font-family: var(--font-serif); font-size: 22px; font-weight: 500;
color: var(--fg); letter-spacing: -0.01em;
}
</style>
@@ -0,0 +1,21 @@
export const PALETTES = {
architect: { tc: '#a78bfa', tc2: '#c4b5fd', glow: 'rgba(167,139,250,0.40)', mid: 'rgba(167,139,250,0.22)', soft: 'rgba(167,139,250,0.10)' },
debugger: { tc: '#fbbf24', tc2: '#fde68a', glow: 'rgba(251,191,36,0.40)', mid: 'rgba(251,191,36,0.22)', soft: 'rgba(251,191,36,0.10)' },
shipper: { tc: '#f472b6', tc2: '#fda4af', glow: 'rgba(244,114,182,0.40)', mid: 'rgba(244,114,182,0.22)', soft: 'rgba(244,114,182,0.10)' },
curator: { tc: '#67e8f9', tc2: '#a5f3fc', glow: 'rgba(103,232,249,0.40)', mid: 'rgba(103,232,249,0.22)', soft: 'rgba(103,232,249,0.10)' },
director: { tc: '#fcd34d', tc2: '#fde68a', glow: 'rgba(252,211,77,0.40)', mid: 'rgba(252,211,77,0.22)', soft: 'rgba(252,211,77,0.10)' },
cartographer: { tc: '#34d399', tc2: '#6ee7b7', glow: 'rgba(52,211,153,0.40)', mid: 'rgba(52,211,153,0.22)', soft: 'rgba(52,211,153,0.10)' },
wanderer: { tc: '#64748b', tc2: '#94a3b8', glow: 'rgba(100,116,139,0.45)', mid: 'rgba(100,116,139,0.25)', soft: 'rgba(100,116,139,0.12)' },
};
export const ARCHETYPE_NAMES = {
architect: 'The Architect',
debugger: 'The Debugger',
shipper: 'The Shipper',
curator: 'The Curator',
director: 'The Director',
cartographer: 'The Cartographer',
wanderer: 'The Wanderer',
};
export const ARCH_KEYS = ['architect', 'debugger', 'shipper', 'curator', 'director', 'cartographer', 'wanderer'];
@@ -0,0 +1,70 @@
.card {
position: absolute; inset: 0;
border-radius: 14px;
background: linear-gradient(165deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.015) 100%);
border: 1px solid var(--hairline-strong);
box-shadow:
0 30px 80px rgba(0,0,0,0.5),
0 12px 32px rgba(0,0,0,0.3),
inset 0 1px 0 rgba(255,255,255,0.08);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card[data-themed]::before {
content: '';
position: absolute; pointer-events: none; z-index: 0;
width: 60%; height: 50%; bottom: 0; right: 0;
background: radial-gradient(ellipse at 100% 100%, var(--tg-mid) 0%, transparent 70%);
opacity: 0.6;
transition: background var(--theme-ease);
}
.card[data-themed]::after {
content: '';
position: absolute; pointer-events: none; z-index: 0;
left: 0; right: 0; bottom: 0; height: 1px;
background: linear-gradient(to right, transparent 0%, var(--tg) 50%, transparent 100%);
opacity: 0.6;
transition: background var(--theme-ease);
}
.eyebrow {
display: flex; align-items: center; gap: 10px;
padding: 22px 28px 0;
font-family: var(--font-mono); font-size: 12px;
color: var(--muted); letter-spacing: 0.01em;
position: relative; z-index: 1;
}
.eyebrow .diamond {
width: 6px; height: 6px;
background: var(--tc); transform: rotate(45deg);
box-shadow: 0 0 8px var(--tg); flex-shrink: 0;
transition: background var(--theme-ease), box-shadow var(--theme-ease);
}
.eyebrow-spacer { flex: 1; }
.eyebrow .slot {
color: var(--muted-2); font-variant-numeric: tabular-nums;
}
.card-title {
padding: 18px 36px 6px;
font-family: var(--font-serif); font-size: 30px;
letter-spacing: -0.015em; font-weight: 500;
color: var(--fg); line-height: 1.2;
position: relative; z-index: 1;
}
.card-deck-text {
padding: 0 36px 22px;
font-size: 15px; color: var(--fg-3);
line-height: 1.55; font-style: italic;
font-family: var(--font-serif);
position: relative; z-index: 1;
}
.section-label {
font-family: var(--font-serif); font-style: italic;
font-size: 13px; color: var(--muted);
}
@@ -0,0 +1,19 @@
export const MINI_SEALS = {
architect: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#a78bfa" stroke-width="4" stroke-opacity="0.7"/><polygon points="55,32 50,42 60,42" fill="#c4b5fd"/><polygon points="50,42 60,42 58,72 52,72" fill="#a78bfa"/></svg>`,
debugger: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fbbf24" stroke-width="4" stroke-opacity="0.7"/><path d="M 55 34 A 21 21 0 1 1 34 55 A 16 16 0 1 0 55 39 A 11 11 0 1 1 44 55" stroke="#fde68a" stroke-width="3.5" fill="none" stroke-linecap="round"/></svg>`,
shipper: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#f472b6" stroke-width="4" stroke-opacity="0.7"/><rect x="36" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.45"/><rect x="50" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.85"/><rect x="64" y="48" width="13" height="13" rx="1.5" fill="#fda4af"/></svg>`,
curator: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#67e8f9" stroke-width="4" stroke-opacity="0.7"/><rect x="34" y="46" width="42" height="5" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="38" y="55" width="34" height="5" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="34" y="64" width="42" height="5" rx="1" fill="#22d3ee" opacity="0.55"/></svg>`,
director: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fcd34d" stroke-width="4" stroke-opacity="0.7"/><g stroke="#fde68a" stroke-width="3" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="36"/><line x1="55" y1="55" x2="72" y2="44"/><line x1="55" y1="55" x2="72" y2="66"/><line x1="55" y1="55" x2="55" y2="74"/><line x1="55" y1="55" x2="38" y2="66"/><line x1="55" y1="55" x2="38" y2="44"/></g><circle cx="55" cy="55" r="4" fill="#fcd34d"/></svg>`,
cartographer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#34d399" stroke-width="4" stroke-opacity="0.7"/><g stroke="#34d399" stroke-width="1.5" stroke-opacity="0.4" stroke-dasharray="3 3"><line x1="34" y1="55" x2="76" y2="55"/><line x1="55" y1="34" x2="55" y2="76"/></g><polygon points="55,38 51,55 55,53 59,55" fill="#6ee7b7"/><polygon points="55,38 55,53 59,55" fill="#34d399"/></svg>`,
wanderer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#64748b" stroke-width="4" stroke-opacity="0.85"/><path d="M 38 40 C 44 50, 50 44, 54 50 C 60 60, 50 66, 56 72 C 62 78, 70 66, 76 70" stroke="#94a3b8" stroke-width="2.6" fill="none" stroke-linecap="round" opacity="0.95"/><circle cx="38" cy="40" r="3" fill="#94a3b8"/><circle cx="76" cy="70" r="3" fill="#94a3b8"/></svg>`,
};
export const CORNER_SEALS = {
architect: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-arc" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#a78bfa" stop-opacity="0.5"/><stop offset="100%" stop-color="#a78bfa" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-arc)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#a78bfa" stroke-width="1.4" stroke-opacity="0.85"/><polygon points="55,28 50,38 60,38" fill="#c4b5fd"/><polygon points="50,38 60,38 58,76 52,76" fill="#a78bfa"/><polygon points="55,38 60,38 58,76 55,76" fill="#7c3aed" opacity="0.75"/><rect x="48" y="76" width="14" height="2" rx="0.4" fill="#1e293b"/></svg>`,
debugger: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dbg" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="100%" stop-color="#fbbf24" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dbg)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fbbf24" stroke-width="1.4" stroke-opacity="0.85"/><path d="M 55 27 A 28 28 0 1 1 27 55 A 22 22 0 1 0 55 33 A 16 16 0 1 1 39 55 A 11 11 0 1 0 55 44 A 6 6 0 1 1 49 55 L 55 55" stroke="#fde68a" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="55" cy="55" r="2.5" fill="#fde68a"/></svg>`,
shipper: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-shp" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#f472b6" stop-opacity="0.5"/><stop offset="100%" stop-color="#f472b6" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-shp)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#f472b6" stroke-width="1.4" stroke-opacity="0.85"/><rect x="32" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.35"/><rect x="48" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.65"/><rect x="64" y="48" width="14" height="14" rx="1.5" fill="#fda4af"/><path d="M 32 72 L 78 72 M 73 68 L 78 72 L 73 76" stroke="#fda4af" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`,
curator: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cur" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#67e8f9" stop-opacity="0.45"/><stop offset="100%" stop-color="#67e8f9" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cur)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#67e8f9" stroke-width="1.4" stroke-opacity="0.85"/><rect x="30" y="42" width="50" height="6" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="34" y="52" width="42" height="6" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="30" y="62" width="50" height="6" rx="1" fill="#22d3ee" opacity="0.55"/><rect x="38" y="72" width="34" height="4" rx="1" fill="#0891b2" opacity="0.5"/></svg>`,
director: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dir" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fcd34d" stop-opacity="0.45"/><stop offset="100%" stop-color="#fcd34d" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dir)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fcd34d" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#fde68a" stroke-width="1.1" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="32"/><line x1="55" y1="55" x2="74" y2="42"/><line x1="55" y1="55" x2="74" y2="68"/><line x1="55" y1="55" x2="55" y2="78"/><line x1="55" y1="55" x2="36" y2="68"/><line x1="55" y1="55" x2="36" y2="42"/></g><g fill="#fde68a"><circle cx="55" cy="32" r="2.5"/><circle cx="74" cy="42" r="2.5"/><circle cx="74" cy="68" r="2.5"/><circle cx="55" cy="78" r="2.5"/><circle cx="36" cy="68" r="2.5"/><circle cx="36" cy="42" r="2.5"/></g><circle cx="55" cy="55" r="3.5" fill="#fcd34d"/></svg>`,
cartographer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cart" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#34d399" stop-opacity="0.5"/><stop offset="100%" stop-color="#34d399" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cart)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#34d399" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#34d399" stroke-width="0.5" stroke-opacity="0.4" stroke-dasharray="2 2"><line x1="32" y1="44" x2="78" y2="44"/><line x1="32" y1="55" x2="78" y2="55"/><line x1="32" y1="66" x2="78" y2="66"/><line x1="44" y1="32" x2="44" y2="78"/><line x1="55" y1="32" x2="55" y2="78"/><line x1="66" y1="32" x2="66" y2="78"/></g><polygon points="55,38 52.5,55 55,53 57.5,55" fill="#6ee7b7"/><polygon points="55,38 55,53 57.5,55" fill="#34d399"/><polygon points="55,72 52.5,55 55,57 57.5,55" fill="#34d399" opacity="0.6"/><polygon points="72,55 55,52.5 57,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><polygon points="38,55 55,52.5 53,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><circle cx="55" cy="55" r="2" fill="#0a0b14"/><circle cx="55" cy="55" r="2.4" stroke="#6ee7b7" stroke-width="0.6" fill="none"/></svg>`,
wanderer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-wand" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#64748b" stop-opacity="0.5"/><stop offset="100%" stop-color="#64748b" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-wand)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#64748b" stroke-width="1.5" stroke-opacity="0.9"/><path d="M 36 38 C 42 50, 48 42, 54 50 C 60 60, 50 65, 56 72 C 62 78, 70 64, 76 70" stroke="#94a3b8" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/><circle cx="36" cy="38" r="2.4" fill="#94a3b8"/><circle cx="54" cy="50" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="56" cy="72" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="76" cy="70" r="2.4" fill="#94a3b8"/></svg>`,
};
+14 -3
View File
@@ -133,6 +133,7 @@ export async function loadSessionDetail(sessionId) {
})); }));
// Assemble messages with tool_calls inline // Assemble messages with tool_calls inline
const META_RE = /^\s*<(task-notification|command-name|local-command|system-reminder)/;
const rawAssembled = (messages || []).map(msg => { const rawAssembled = (messages || []).map(msg => {
const assembled = { const assembled = {
uuid: msg.uuid, uuid: msg.uuid,
@@ -140,7 +141,7 @@ export async function loadSessionDetail(sessionId) {
timestamp: msg.timestamp, timestamp: msg.timestamp,
text: msg.text, text: msg.text,
content_type: msg.content_type || null, content_type: msg.content_type || null,
is_meta: msg.is_meta || 0 is_meta: msg.is_meta || (msg.text && META_RE.test(msg.text) ? 1 : 0)
}; };
const calls = callsByMessageUuid[msg.uuid]; const calls = callsByMessageUuid[msg.uuid];
@@ -180,15 +181,25 @@ export async function loadSessionDetail(sessionId) {
continue; continue;
} }
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results) // For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results and skill meta)
if (msg.type === 'assistant' && msg.content_type === 'tool_use') { if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] }; const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking; if (msg._thinking) merged._thinking = msg._thinking;
// If this is a Skill-only message, don't merge with subsequent tool_use — keep it standalone
const isSkillOnly = merged.tool_calls.length === 1 && merged.tool_calls[0].name === 'Skill';
let j = i + 1; let j = i + 1;
while (j < rawAssembled.length) { while (j < rawAssembled.length) {
const next = rawAssembled[j]; const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; } if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') { // Absorb skill.md meta message into the skill tool call
if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) {
merged._skillMd = next.text;
j++;
continue;
}
if (!isSkillOnly && next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls); if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text; if (next.text && !merged.text) merged.text = next.text;
j++; j++;
+101
View File
@@ -0,0 +1,101 @@
{
"schema_version": "obelisk.recap.v1",
"kind": "weekly",
"generated_at": "2026-06-14T03:00:00+08:00",
"period": {
"label": "Week 24",
"start": "2026-06-08",
"end": "2026-06-14",
"timezone": "Asia/Shanghai"
},
"source": {
"project": "-Users-tomiya-Code-quiet-zero",
"session_ids": ["defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "9d259960-8eae-4bae-947c-081420bb5626"],
"memory_ids": ["mem-1781021027286-51v0qh"]
},
"metrics": {
"sessions": 12,
"messages": 847,
"tokens": 2400000,
"active_days": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
"streak_days": 19,
"workflows": 3,
"workflow_agents": 42,
"corrections": 12
},
"persona": {
"archetype": "architect",
"title": "The Architect",
"subtitle": "从零设计了一个完整的 memory 系统。",
"tone": "affectionate_teasing"
},
"cards": [
{
"type": "cover",
"badge": "Week 24",
"title": "The Architect",
"subtitle": "从零设计了一个完整的 memory 系统。",
"activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
"footer": "12 sessions · 2.4M tokens"
},
{
"type": "thinking_path",
"title": "Five questions, five turns.",
"items": [
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki", "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" }
]
},
{
"type": "vibe",
"title": "A short character study.",
"observations": [
{ "label": "catchphrase", "text": "这太丑了", "count": 4 },
{ "label": "highest praise", "text": "可以" },
{ "label": "late night", "text": "你在干什么", "time": "02:47 AM" }
],
"meter": {
"label": "patience",
"value": 0.78,
"caption": "saint"
},
"quote": {
"text": "若无必要,勿增实体。",
"caption": "your most philosophical moment"
}
},
{
"type": "workflow",
"title": "Three workflows. Forty-two agents.",
"summary": "你召唤了机器军团。结果各有不同。",
"stats": "3 workflows · 42 agents",
"items": [
{ "name": "hono-plugin-review", "outcome": "完美" },
{ "name": "vue-migration", "outcome": "你这页面完全和之前的不一样…" },
{ "name": "split-render-js", "outcome": "可以" }
],
"verdict": "Mostly tolerated."
},
{
"type": "closing",
"headline": "19 days",
"stats": ["847 messages exchanged", "12 corrections · 47 approvals"],
"most_said_phrase": "好的开始做吧",
"signoff": "See you next week."
}
],
"evidence": [
{ "id": "ev-1", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-1", "summary": "User said '若无必要,勿增实体' when discussing query builder" },
{ "id": "ev-2", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-2", "summary": "User said '这太丑了' about panel design" },
{ "id": "ev-3", "summary": "12 corrections vs 47 approvals in session messages" }
]
}
+19
View File
@@ -10,6 +10,9 @@ const SubagentDetail = () => import('./views/SubagentDetail.vue');
const MemoryList = () => import('./views/MemoryList.vue'); const MemoryList = () => import('./views/MemoryList.vue');
const MemoryDetail = () => import('./views/MemoryDetail.vue'); const MemoryDetail = () => import('./views/MemoryDetail.vue');
const Activity = () => import('./views/Activity.vue'); const Activity = () => import('./views/Activity.vue');
const Recap = () => import('./views/RecapList.vue');
const RecapDetail = () => import('./views/RecapDetail.vue');
const RecapExport = () => import('./views/RecapExport.vue');
const routes = [ const routes = [
{ {
@@ -46,6 +49,22 @@ const routes = [
name: 'Activity', name: 'Activity',
component: Activity component: Activity
}, },
{
path: '/recap',
name: 'Recap',
component: Recap
},
{
path: '/recap/:id',
name: 'RecapDetail',
component: RecapDetail,
props: true
},
{
path: '/recap-export',
name: 'RecapExport',
component: RecapExport
},
{ {
path: '/', path: '/',
redirect: '/memory' redirect: '/memory'
+1 -1
View File
@@ -84,7 +84,7 @@ function goToSession() {
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div> <div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div>
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div> <div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre> <pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else v-html="renderMarkdown(markdown, { variant: 'body' })"></div> <div v-else class="markdown-msg" v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div> </div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section"> <div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
+305
View File
@@ -0,0 +1,305 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import CoverCard from '../components/recap/CoverCard.vue';
import PathCard from '../components/recap/PathCard.vue';
import VibeCard from '../components/recap/VibeCard.vue';
import WorkflowCard from '../components/recap/WorkflowCard.vue';
import ClosingCard from '../components/recap/ClosingCard.vue';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import mockJson from '../mock/recap-2026-W24.json';
defineOptions({ name: 'RecapDetail' });
const route = useRoute();
const recapData = ref(mockJson);
const currentArch = ref(mockJson.persona.archetype);
const currentIdx = ref(0);
const palette = computed(() => PALETTES[currentArch.value] || PALETTES.architect);
const CARD_LABELS = ['Cover', 'Path', 'Vibe', 'Workflow', 'Closing'];
const TOTAL = computed(() => recapData.value.cards.length);
const cover = computed(() => recapData.value.cards[0]);
const path = computed(() => recapData.value.cards[1]);
const vibe = computed(() => recapData.value.cards[2]);
const workflow = computed(() => recapData.value.cards[3]);
const closing = computed(() => recapData.value.cards[4]);
const cssVars = computed(() => ({
'--tc': palette.value.tc,
'--tc-2': palette.value.tc2,
'--tg': palette.value.glow,
'--tg-mid': palette.value.mid,
'--tg-soft': palette.value.soft,
'--tg-edge': palette.value.soft,
}));
async function loadRecap(filename) {
if (!filename || !window.obelisk?.recapRead) return;
const data = await window.obelisk.recapRead(filename);
if (data?.cards?.length) {
recapData.value = data;
currentArch.value = data.persona?.archetype || 'architect';
currentIdx.value = 0;
}
}
let unsubRecap;
onMounted(async () => {
const filename = route.params.id;
if (filename) await loadRecap(filename);
if (window.obelisk?.onRecapUpdated) {
unsubRecap = window.obelisk.onRecapUpdated((fp) => {
if (fp.endsWith(route.params.id)) loadRecap(route.params.id);
});
}
});
onUnmounted(() => { unsubRecap?.(); });
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
async function exportImage() {
await window.obelisk.captureExport({ cardIdx: currentIdx.value, archetype: currentArch.value });
}
async function copyImage() {
await window.obelisk.copyImage({ cardIdx: currentIdx.value, archetype: currentArch.value });
}
function goTo(idx) {
if (idx >= 0 && idx < TOTAL.value) currentIdx.value = idx;
}
function onKeydown(e) {
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goTo(currentIdx.value - 1); }
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goTo(currentIdx.value + 1); }
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
else if (e.key === 'End') { e.preventDefault(); goTo(TOTAL.value - 1); }
else if (e.key === 'p') {
const i = ARCH_KEYS.indexOf(currentArch.value);
currentArch.value = ARCH_KEYS[(i + 1) % ARCH_KEYS.length];
}
}
</script>
<template>
<div class="recap-app" :style="cssVars" @keydown="onKeydown" tabindex="0">
<!-- Stage -->
<div class="stage">
<div class="deck">
<div class="card-slot" :class="{ active: currentIdx === 0, prev: currentIdx > 0 }">
<CoverCard
:arch-key="currentArch"
:badge="cover.badge"
:title="cover.title"
:subtitle="cover.subtitle"
:activity="cover.activity"
:footer="cover.footer"
:idx="1" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 1, prev: currentIdx > 1 }">
<PathCard
:title="path.title"
:items="path.items"
:idx="2" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 2, prev: currentIdx > 2 }">
<VibeCard
:title="vibe.title"
:observations="vibe.observations"
:meter="vibe.meter"
:quote="vibe.quote"
:idx="3" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 3, prev: currentIdx > 3 }">
<WorkflowCard
:title="workflow.title"
:summary="workflow.summary"
:stats="workflow.stats"
:items="workflow.items"
:verdict="workflow.verdict"
:idx="4" :total="TOTAL"
/>
</div>
<div class="card-slot" :class="{ active: currentIdx === 4, prev: currentIdx > 4 }">
<ClosingCard
:headline="closing.headline"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase"
:signoff="closing.signoff"
:idx="5" :total="TOTAL"
/>
</div>
</div>
</div>
<!-- Nav -->
<div class="nav">
<button class="nav-arrow" :disabled="currentIdx === 0" @click="goTo(currentIdx - 1)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 4l-4 4 4 4"/>
</svg>
</button>
<div class="nav-dots">
<button
v-for="(label, i) in CARD_LABELS" :key="i"
class="nav-dot" :class="{ active: i === currentIdx }"
@click="goTo(i)"
>
<div class="nav-dot-glyph"></div>
<div class="nav-dot-label">{{ label }}</div>
</button>
</div>
<button class="nav-arrow" :disabled="currentIdx === TOTAL - 1" @click="goTo(currentIdx + 1)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4l4 4-4 4"/>
</svg>
</button>
<div class="nav-actions">
<button class="nav-action" title="Copy image" @click="copyImage">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
<path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5v-7A1.5 1.5 0 0 1 3.5 1h7A1.5 1.5 0 0 1 12 2.5V5"/>
</svg>
</button>
<button class="nav-action" title="Export PNG" @click="exportImage">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v8M5 7l3 3 3-3"/>
<path d="M3 12v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V12"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.recap-app {
--bg: #0a0b14;
--bg-2: #11131f;
--surface: rgba(255,255,255,0.03);
--surface-strong: rgba(255,255,255,0.06);
--surface-hi: rgba(255,255,255,0.09);
--fg: rgba(255,255,255,0.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
--transition-fast: 120ms ease;
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
height: 100%;
display: grid;
grid-template-rows: 1fr 64px;
color: var(--fg);
font: 13px/1.45 var(--font-sans);
-webkit-font-smoothing: antialiased;
background-color: var(--bg);
background-image:
radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.10), transparent 55%),
radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.08), transparent 60%),
radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.10), transparent 60%),
linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%);
position: relative;
outline: none;
}
.recap-app::before {
content: '';
position: absolute; inset: 0;
pointer-events: none; z-index: 0;
opacity: 0.3;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
mix-blend-mode: overlay;
}
/* Stage */
.stage {
position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
padding: 32px 24px; z-index: 1;
}
.deck {
position: relative; width: 100%; max-width: 540px;
height: 100%; perspective: 2000px;
}
.card-slot {
position: absolute; inset: 0;
opacity: 0; transform: translateY(24px) scale(0.97);
pointer-events: none;
transition: opacity var(--transition), transform var(--transition);
}
.card-slot.active {
opacity: 1; transform: translateY(0) scale(1);
pointer-events: auto; z-index: 2;
}
.card-slot.prev {
opacity: 0; transform: translateY(-12px) scale(1.02);
}
/* Nav */
.nav {
display: flex; align-items: center; justify-content: center; gap: 16px;
padding: 0 22px;
border-top: 1px solid var(--hairline);
background: rgba(0,0,0,0.18);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
position: relative; z-index: 1;
}
.nav-arrow {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--hairline-strong); background: var(--surface);
color: var(--fg-2); display: grid; place-items: center;
cursor: pointer; transition: all var(--transition-fast);
}
.nav-arrow:hover:not(:disabled) { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.nav-arrow:disabled { cursor: default; opacity: 0.3; }
.nav-arrow svg { width: 14px; height: 14px; }
.nav-dots { display: flex; gap: 8px; padding: 0 4px; }
.nav-dot {
display: flex; flex-direction: column; align-items: center; gap: 4px;
cursor: pointer; padding: 4px 8px; border-radius: 4px;
background: none; border: none; color: inherit;
transition: background var(--transition-fast);
}
.nav-dot:hover { background: var(--surface); }
.nav-dot-glyph {
width: 24px; height: 3px; border-radius: 2px;
background: var(--muted-3); transition: all var(--transition);
}
.nav-dot.active .nav-dot-glyph {
background: var(--tc); box-shadow: 0 0 8px var(--tg); width: 28px;
transition: background var(--theme-ease), box-shadow var(--theme-ease), width var(--transition);
}
.nav-dot-label {
font-family: var(--font-serif); font-style: italic;
font-size: 11px; color: var(--muted-2);
}
.nav-dot.active .nav-dot-label { color: var(--fg-2); }
.nav-actions {
position: absolute; right: 60px;
display: flex; gap: 6px; align-items: center;
}
.nav-action {
width: 32px; height: 32px; border-radius: 6px;
border: 1px solid var(--hairline-strong); background: var(--surface);
color: var(--fg-2); display: grid; place-items: center;
cursor: pointer; transition: all var(--transition-fast);
}
.nav-action:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.nav-action svg { width: 14px; height: 14px; }
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import CoverCard from '../components/recap/CoverCard.vue';
import PathCard from '../components/recap/PathCard.vue';
import VibeCard from '../components/recap/VibeCard.vue';
import WorkflowCard from '../components/recap/WorkflowCard.vue';
import ClosingCard from '../components/recap/ClosingCard.vue';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import recapJson from '../mock/recap-2026-W24.json';
const route = useRoute();
const cardIdx = computed(() => parseInt(route.query.card) || 0);
const archKey = computed(() => route.query.arch || recapJson.persona.archetype);
const palette = computed(() => PALETTES[archKey.value] || PALETTES.architect);
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 cssVars = computed(() => ({
'--tc': palette.value.tc,
'--tc-2': palette.value.tc2,
'--tg': palette.value.glow,
'--tg-mid': palette.value.mid,
'--tg-soft': palette.value.soft,
'--tg-edge': palette.value.soft,
}));
</script>
<template>
<div class="export-wrap" :style="cssVars">
<CoverCard v-if="cardIdx === 0"
:arch-key="archKey" :badge="cover.badge" :title="cover.title"
:subtitle="cover.subtitle" :activity="cover.activity" :footer="cover.footer"
:idx="1" :total="5"
/>
<PathCard v-else-if="cardIdx === 1"
:title="path.title" :items="path.items"
:idx="2" :total="5"
/>
<VibeCard v-else-if="cardIdx === 2"
:title="vibe.title" :observations="vibe.observations"
:meter="vibe.meter" :quote="vibe.quote"
:idx="3" :total="5"
/>
<WorkflowCard v-else-if="cardIdx === 3"
:title="workflow.title" :summary="workflow.summary"
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
:idx="4" :total="5"
/>
<ClosingCard v-else-if="cardIdx === 4"
:headline="closing.headline" :stats="closing.stats"
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
:idx="5" :total="5"
/>
</div>
</template>
<style scoped>
.export-wrap {
--bg: #0a0b14;
--bg-2: #11131f;
--surface: rgba(255,255,255,0.03);
--surface-strong: rgba(255,255,255,0.06);
--fg: rgba(255,255,255,0.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
--transition-fast: 120ms ease;
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
width: 540px;
height: 675px;
position: relative;
background: var(--bg);
color: var(--fg);
font: 13px/1.45 var(--font-sans);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
</style>
+512
View File
@@ -0,0 +1,512 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, inject } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import { MINI_SEALS } from '../components/recap/seals.js';
defineOptions({ name: 'RecapList' });
const router = useRouter();
const route = useRoute();
const recaps = ref([]);
const kind = computed(() => route.query.kind || 'weekly');
const showGenerate = inject('recapGenerateOpen', ref(false));
const filtered = computed(() => recaps.value.filter(r => r.kind === kind.value));
const byYear = computed(() => {
const map = {};
for (const r of filtered.value) {
const y = r.period?.start?.slice(0, 4) || '?';
if (!map[y]) map[y] = [];
map[y].push(r);
}
return Object.entries(map).sort((a, b) => b[0] - a[0]);
});
function glowColor(arch) {
return PALETTES[arch]?.glow || PALETTES.architect.glow;
}
function sealSvg(arch) {
return MINI_SEALS[arch] || MINI_SEALS.architect;
}
function formatDateRange(r) {
if (!r.period) return '';
const s = new Date(r.period.start);
const e = new Date(r.period.end);
const mo = s.toLocaleString('en', { month: 'short' });
return `${mo} ${s.getDate()} ${e.getDate()}`;
}
function formatTokens(n) {
if (!n) return '';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'k';
return String(n);
}
function openRecap(filename) {
router.push(`/recap/${encodeURIComponent(filename)}`);
}
const generateOptions = [
{ key: 'this-week', label: 'This week' },
{ key: 'last-week', label: 'Last week' },
{ key: 'this-month', label: 'This month' },
{ key: 'last-month', label: 'Last month' },
];
const CMDS = {
'this-week': '/obelisk recap this week',
'last-week': '/obelisk recap last week',
'this-month': '/obelisk recap this month',
'last-month': '/obelisk recap last month',
};
const generateWindow = ref('this-week');
const generateCmd = computed(() => CMDS[generateWindow.value]);
const cmdCopied = ref(false);
async function copyCmd() {
try {
await navigator.clipboard.writeText(generateCmd.value);
cmdCopied.value = true;
setTimeout(() => { cmdCopied.value = false; }, 1600);
} catch {}
}
async function loadRecaps() {
if (!window.obelisk?.recapList) return;
const files = await window.obelisk.recapList();
const results = [];
for (const f of files) {
const data = await window.obelisk.recapRead(f);
if (data?.cards) results.push({ ...data, _filename: f });
}
recaps.value = results;
}
let unsub;
onMounted(async () => {
await loadRecaps();
if (window.obelisk?.onRecapUpdated) {
unsub = window.obelisk.onRecapUpdated(() => loadRecaps());
}
});
onUnmounted(() => { unsub?.(); });
</script>
<template>
<div class="recap-list">
<div class="content-wrap">
<div class="content" v-if="filtered.length">
<section v-for="[year, items] in byYear" :key="year" class="tl-section">
<div class="tl-section-head">
<span class="year">{{ year }}</span>
<span class="span">{{ items.length }} {{ items.length === 1 ? 'recap' : 'recaps' }}</span>
</div>
<div class="timeline">
<div
v-for="r in items" :key="r._filename"
class="recap-row"
:style="{ '--node-glow': glowColor(r.persona?.archetype) }"
@click="openRecap(r._filename)"
>
<div class="recap-node" v-html="sealSvg(r.persona?.archetype)"></div>
<div class="recap-card">
<div class="recap-body">
<div class="recap-period">
<span>{{ r.period?.label }}</span>
<span class="dot"></span>
<span>{{ formatDateRange(r) }}</span>
</div>
<div class="recap-archetype">{{ r.persona?.title }}</div>
<div class="recap-subtitle">{{ r.persona?.subtitle }}</div>
<div class="recap-stats">
<span>{{ r.metrics?.sessions || 0 }} sessions</span>
<span class="sep">·</span>
<span>{{ formatTokens(r.metrics?.tokens) }} tokens</span>
</div>
</div>
<div class="recap-right">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4l4 4-4 4"/>
</svg>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="content empty-content" v-else>
<section class="tl-section">
<div class="tl-section-head">
<span class="year">No {{ kind }} recaps yet</span>
<span class="span">the timeline is waiting</span>
</div>
<div class="empty-timeline">
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row">
<div class="empty-node"></div>
<div class="empty-cta">
<div class="empty-eyebrow">
<span class="diamond"></span>
<span>Nothing carved yet</span>
</div>
<div class="empty-title">A recap is something you carve at the end of a stretch of work.</div>
<div class="empty-body">
Obelisk doesn't generate one for you automatically — it waits until you ask. Run <code>/obelisk recap this week</code> in Claude Code, and the result will land here as the first marker on this line.
</div>
<div class="empty-actions">
<button class="toolbar-action primary" @click="showGenerate = true">
<span class="plus">+</span>
<span>Generate {{ kind }} recap</span>
</button>
</div>
</div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
<div class="empty-row placeholder">
<div class="empty-node"></div>
<div class="empty-card"></div>
</div>
</div>
</section>
</div>
</div>
<!-- Generate modal -->
<div class="modal-backdrop" v-if="showGenerate" @click.self="showGenerate = false">
<div class="modal">
<div class="modal-head">
<span class="diamond"></span>
<span class="title">Generate a new recap</span>
<button class="modal-close" @click="showGenerate = false">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<path d="M3 3l6 6M9 3l-6 6"/>
</svg>
</button>
</div>
<div class="modal-body">
<p>Recaps are generated by Claude Code. Run the command below in your terminal — Obelisk will pick it up automatically.</p>
<div class="modal-options">
<button
v-for="opt in generateOptions" :key="opt.key"
class="modal-option" :class="{ active: generateWindow === opt.key }"
@click="generateWindow = opt.key"
>
<span class="modal-option-radio"></span>
<span class="modal-option-label">{{ opt.label }}</span>
</button>
</div>
<div class="cmd-block">
<code><span class="prompt">$</span> {{ generateCmd }}</code>
<button class="cmd-copy" :class="{ copied: cmdCopied }" @click="copyCmd">
<svg v-if="!cmdCopied" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<rect x="3" y="3" width="9" height="9" rx="1.5"/>
<path d="M5 3V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-1"/>
</svg>
<svg v-else viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8l3 3 7-7"/>
</svg>
</button>
</div>
<div class="modal-hint">Generation takes ~30s. New recaps appear in this list automatically.</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.recap-list {
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
--bg: #0a0b14;
--hairline: rgba(255,255,255,0.05);
--hairline-strong: rgba(255,255,255,0.10);
--hairline-vivid: rgba(255,255,255,0.16);
--surface: rgba(255,255,255,0.03);
--surface-strong: rgba(255,255,255,0.06);
--fg: rgba(255,255,255,0.94);
--fg-2: rgba(255,255,255,0.74);
--fg-3: rgba(255,255,255,0.55);
--muted: rgba(255,255,255,0.48);
--muted-2: rgba(255,255,255,0.28);
--muted-3: rgba(255,255,255,0.16);
flex: 1; display: flex; flex-direction: column; min-height: 0;
}
.content-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.content { max-width: 720px; margin: 0 auto; padding: 32px 32px 80px; }
.tl-section { margin-bottom: 36px; }
.tl-section:last-child { margin-bottom: 0; }
.tl-section-head {
display: flex; align-items: baseline; gap: 12px;
margin-bottom: 20px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline);
}
.tl-section-head .year {
font-family: var(--font-serif); font-size: 22px;
font-weight: 500; color: var(--fg-2); letter-spacing: -0.005em;
}
.tl-section-head .span {
font-family: var(--font-mono); font-size: 12px;
color: var(--muted); letter-spacing: 0.02em;
}
.timeline { position: relative; }
.timeline::before {
content: ''; position: absolute;
left: 15px; top: 15px; bottom: 15px;
width: 1px; margin-left: -0.5px;
background: linear-gradient(to bottom,
rgba(167,139,250,0.55) 0%, rgba(167,139,250,0.35) 8%,
rgba(255,255,255,0.12) 30%, rgba(255,255,255,0.06) 100%);
z-index: 0;
}
.recap-row {
position: relative; display: grid;
grid-template-columns: 30px 1fr;
column-gap: 28px; align-items: center;
padding: 12px 0; cursor: pointer;
transition: transform 0.12s;
}
.recap-row:hover { transform: translateX(2px); }
.recap-node {
width: 30px; height: 30px;
position: relative; z-index: 2;
}
.recap-node::before {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.recap-node :deep(svg) {
width: 100%; height: 100%; display: block;
filter: drop-shadow(0 0 6px var(--node-glow, rgba(167,139,250,0.3)));
transition: filter 0.15s;
}
.recap-row:hover .recap-node :deep(svg) {
filter: drop-shadow(0 0 10px var(--node-glow, rgba(167,139,250,0.5)));
}
.recap-card {
display: grid; grid-template-columns: 1fr auto;
gap: 16px; align-items: center;
padding: 14px 16px;
border: 1px solid var(--hairline); border-radius: 8px;
background: rgba(255,255,255,0.02);
transition: background 0.12s, border-color 0.12s;
}
.recap-row:hover .recap-card {
background: rgba(255,255,255,0.035);
border-color: var(--hairline-strong);
}
.recap-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.recap-period {
font-family: var(--font-mono); font-size: 12px;
color: var(--muted); letter-spacing: 0.02em;
display: flex; align-items: center; gap: 8px;
}
.recap-period .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; }
.recap-archetype {
font-family: var(--font-serif); font-size: 20px;
font-weight: 500; color: var(--fg); letter-spacing: -0.01em;
}
.recap-subtitle {
font-family: var(--font-serif); font-style: italic;
font-size: 14.5px; color: var(--fg-3); line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.recap-stats {
margin-top: 4px; font-family: var(--font-mono);
font-size: 11.5px; color: var(--muted-2);
font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
display: flex; gap: 10px;
}
.recap-stats .sep { color: var(--muted-3); }
.recap-right {
display: flex; align-items: center; flex-shrink: 0;
color: var(--muted-2); transition: color 0.12s;
}
.recap-row:hover .recap-right { color: var(--fg-3); }
.recap-right svg { width: 14px; height: 14px; }
/* Empty state */
.empty-content { padding-top: 32px; }
.empty-timeline { position: relative; padding-top: 8px; }
.empty-timeline::before {
content: ''; position: absolute;
left: 15px; top: 24px; bottom: 24px;
width: 1px; margin-left: -0.5px;
background: repeating-linear-gradient(
to bottom, var(--muted-3) 0px, var(--muted-3) 3px,
transparent 3px, transparent 7px);
opacity: 0.55;
}
.empty-row {
display: grid; grid-template-columns: 30px 1fr;
column-gap: 28px; align-items: center; padding: 14px 0;
}
.empty-node {
width: 30px; height: 30px; position: relative; z-index: 2;
display: grid; place-items: center;
}
.empty-node::before {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.empty-node::after {
content: ''; width: 10px; height: 10px;
border: 1.5px dashed var(--muted-2);
transform: rotate(45deg); border-radius: 1px;
}
.empty-row.placeholder .empty-card {
height: 12px; background: transparent;
border: 1px dashed var(--muted-3); border-radius: 6px; opacity: 0.4;
}
.empty-cta {
padding: 28px 22px;
border: 1px dashed var(--hairline-strong); border-radius: 10px;
background: rgba(255,255,255,0.015);
display: flex; flex-direction: column; gap: 16px;
}
.empty-eyebrow {
font-family: var(--font-mono); font-size: 12px;
letter-spacing: 0.06em; color: var(--muted);
display: flex; align-items: center; gap: 8px;
}
.empty-eyebrow .diamond {
width: 6px; height: 6px; background: var(--muted-2);
transform: rotate(45deg); flex-shrink: 0;
}
.empty-title {
font-family: var(--font-serif); font-size: 26px;
font-weight: 500; color: var(--fg);
letter-spacing: -0.015em; line-height: 1.3; max-width: 460px;
}
.empty-body {
font-family: var(--font-serif); font-style: italic;
font-size: 15px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
}
.empty-body code {
font-family: var(--font-mono); font-style: normal;
font-size: 13px; color: var(--accent-2, #c4b5fd);
background: rgba(167,139,250,0.12); padding: 2px 8px;
border-radius: 3px; letter-spacing: 0;
}
.empty-actions { display: flex; gap: 8px; margin-top: 4px; }
.empty-actions .toolbar-action { height: 30px; padding: 0 14px; }
/* Modal */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(5, 6, 12, 0.65);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
z-index: 500;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal {
width: 100%; max-width: 480px;
background: linear-gradient(165deg, rgba(20,22,38,0.95) 0%, rgba(13,15,28,0.95) 100%);
border: 1px solid var(--hairline-strong); border-radius: 12px;
box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 12px 32px rgba(0,0,0,0.4),
inset 0 1px 0 rgba(255,255,255,0.08);
overflow: hidden;
}
.modal-head {
padding: 18px 22px 12px;
border-bottom: 1px solid var(--hairline);
display: flex; align-items: baseline; gap: 10px;
}
.modal-head .diamond {
width: 6px; height: 6px; background: #a78bfa;
transform: rotate(45deg); box-shadow: 0 0 8px rgba(167,139,250,0.35);
flex-shrink: 0; align-self: center;
}
.modal-head .title {
font-family: var(--font-serif); font-size: 17px;
font-weight: 500; color: var(--fg); flex: 1;
}
.modal-close {
color: var(--muted); width: 24px; height: 24px;
display: grid; place-items: center; border-radius: 4px;
border: none; background: none; cursor: pointer; transition: all 0.1s;
}
.modal-close:hover { color: var(--fg-2); background: var(--surface); }
.modal-close svg { width: 12px; height: 12px; }
.modal-body { padding: 18px 22px 20px; }
.modal-body p {
font-family: var(--font-serif); font-style: italic;
font-size: 13.5px; color: var(--fg-2); line-height: 1.6; margin-bottom: 14px;
}
.modal-options {
display: flex; flex-direction: column; gap: 1px;
background: var(--hairline); border: 1px solid var(--hairline);
border-radius: 6px; overflow: hidden; margin-bottom: 14px;
}
.modal-option {
padding: 10px 14px; background: rgba(0,0,0,0.2);
display: flex; align-items: center; gap: 10px;
cursor: pointer; border: none; color: inherit; width: 100%; text-align: left;
transition: background 0.08s;
}
.modal-option:hover { background: rgba(255,255,255,0.025); }
.modal-option.active { background: rgba(167,139,250,0.12); }
.modal-option-label {
font-family: var(--font-mono); font-size: 12px;
color: var(--fg-2); flex: 1;
}
.modal-option.active .modal-option-label { color: #c4b5fd; }
.modal-option-radio {
width: 12px; height: 12px;
border: 1.5px solid var(--muted-2); border-radius: 50%;
position: relative; flex-shrink: 0; transition: all 0.1s;
}
.modal-option.active .modal-option-radio { border-color: #a78bfa; }
.modal-option.active .modal-option-radio::after {
content: ''; position: absolute; inset: 2px;
background: #a78bfa; border-radius: 50%;
box-shadow: 0 0 6px rgba(167,139,250,0.35);
}
.cmd-block {
position: relative;
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
border-radius: 6px; padding: 14px 50px 14px 16px; margin-bottom: 14px;
}
.cmd-block code {
font-family: var(--font-mono); font-size: 12.5px;
color: var(--fg); letter-spacing: 0.005em; word-break: break-all;
}
.cmd-block code .prompt { color: #c4b5fd; margin-right: 4px; }
.cmd-copy {
position: absolute; top: 50%; right: 8px; transform: translateY(-50%);
width: 32px; height: 32px; display: grid; place-items: center;
color: var(--muted); border-radius: 5px; border: none; background: none;
cursor: pointer; transition: all 0.1s;
}
.cmd-copy:hover { color: var(--fg); background: var(--surface); }
.cmd-copy.copied { color: #c4b5fd; background: rgba(167,139,250,0.12); }
.cmd-copy svg { width: 14px; height: 14px; }
.modal-hint {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted-2); letter-spacing: 0.02em; line-height: 1.5;
}
</style>
+454 -22
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, onActivated, watch } from 'vue'; import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js'; import { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js'; import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
@@ -21,23 +21,67 @@ const session = computed(() => state.sessions.find(s => s.id === props.id));
const messages = ref([]); const messages = ref([]);
const loading = ref(false); const loading = ref(false);
const progressPct = ref(0); const progressPct = ref(0);
const showBackToTop = ref(false);
// DOM refs // DOM refs
const wrapRef = ref(null); const wrapRef = ref(null);
const detailRef = ref(null); const detailRef = ref(null);
// --- Load session on mount or when id changes --- // --- Load session on mount or when id changes ---
const FONT_SIZE_KEY = 'obelisk:session-font-size';
const FONT_SIZES = [12, 13, 14, 15, 16, 18];
const fontSizeIdx = ref(FONT_SIZES.indexOf(parseInt(localStorage.getItem(FONT_SIZE_KEY)) || 14));
if (fontSizeIdx.value < 0) fontSizeIdx.value = 2;
const fontSize = computed(() => FONT_SIZES[fontSizeIdx.value] + 'px');
function adjustFont(delta) {
const next = fontSizeIdx.value + delta;
if (next >= 0 && next < FONT_SIZES.length) {
fontSizeIdx.value = next;
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[next]);
}
}
function handleZoom(e) {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === '=' || e.key === '+') {
e.preventDefault();
if (fontSizeIdx.value < FONT_SIZES.length - 1) fontSizeIdx.value++;
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
} else if (e.key === '-') {
e.preventDefault();
if (fontSizeIdx.value > 0) fontSizeIdx.value--;
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
} else if (e.key === '0') {
e.preventDefault();
fontSizeIdx.value = 2;
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
}
}
const HINT_KEY = 'obelisk:font-hint-shown';
const showFontHint = ref(false);
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', handleZoom);
if (!localStorage.getItem(HINT_KEY)) {
showFontHint.value = true;
localStorage.setItem(HINT_KEY, '1');
setTimeout(() => { showFontHint.value = false; }, 4000);
}
await loadMessages(); await loadMessages();
}); });
onActivated(async () => { onActivated(async () => {
window.addEventListener('keydown', handleZoom);
if (messages.value.length === 0 && props.id) { if (messages.value.length === 0 && props.id) {
await loadMessages(); await loadMessages();
} }
}); });
onUnmounted(() => {
window.removeEventListener('keydown', handleZoom);
});
watch(() => props.id, async (newId, oldId) => { watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) { if (newId && newId !== oldId) {
messages.value = []; messages.value = [];
@@ -74,23 +118,37 @@ async function loadMessages() {
} }
// --- Scroll / progress tracking --- // --- Scroll / progress tracking ---
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
function onScroll() { function onScroll() {
if (!wrapRef.value || !detailRef.value) return; if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card'); const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
if (!msgs.length) return; if (!msgs.length) return;
totalMsgs.value = msgs.length;
const wrapTop = wrapRef.value.getBoundingClientRect().top; const wrapTop = wrapRef.value.getBoundingClientRect().top;
let topMsgIdx = 0; let topMsgIdx = 0;
for (let i = 0; i < msgs.length; i++) { for (let i = 0; i < msgs.length; i++) {
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i; if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
else break; else break;
} }
currentMsgIdx.value = topMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100); const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct; progressPct.value = pct;
showBackToTop.value = wrapRef.value.scrollTop > 300;
} }
function scrollToTop() { function navTo(target) {
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' }); if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
if (!msgs.length) return;
let idx;
if (target === 'first') idx = 0;
else if (target === 'last') idx = msgs.length - 1;
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' });
} }
// --- Toggle helpers --- // --- Toggle helpers ---
@@ -147,11 +205,301 @@ function getArgPreview(tc) {
if (j.file_path) return j.file_path; if (j.file_path) return j.file_path;
if (j.command) return j.command; if (j.command) return j.command;
if (j.path) return j.path; if (j.path) return j.path;
if (j.query) return j.query;
if (j.description) return j.description; if (j.description) return j.description;
return JSON.stringify(j).slice(0, 100); if (j.pattern) return j.pattern;
} catch { if (j.url) return j.url;
return (tc.input_json || '').slice(0, 100); if (j.name) return j.name;
if (j.title) return j.title;
for (const k of Object.keys(j)) {
if (typeof j[k] === 'string' && j[k].length < 90) return j[k];
} }
return JSON.stringify(j).slice(0, 90);
} catch {
return (tc.input_json || '').slice(0, 90);
}
}
function formatToolInput(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
return JSON.stringify(j, null, 2);
} catch {
return tc.input_json || '';
}
}
function escapeH(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const TOOL_ICONS = {
Bash: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>',
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>',
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
};
function getToolIcon(name) {
return TOOL_ICONS[name] || '';
}
function renderPrettyTool(tc) {
let args;
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
const result = tc.result || {};
const isError = !!result.is_error;
const out = result.content || '';
if (tc.name === 'Read') {
const path = args.file_path || args.path || '?';
if (!out) return '<div style="color:var(--muted);font-size:11px;font-style:italic;">No content returned.</div>';
return renderFileContent(out);
}
if (tc.name === 'Write') {
const path = args.file_path || args.path || '?';
const header = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tool-action-label">Writing</span>
<span class="file-ref">${escapeH(path)}</span>
</div>`;
let content = '';
if (args.content) {
const lines = args.content.split('\n');
const gutter = lines.map((_, i) => i + 1).join('\n');
content = `<div class="file-content">
<div class="file-content-head"><span class="label">New file</span><span class="meta">${lines.length} lines</span></div>
<div class="file-content-body collapsed"><div class="gutter">${gutter}</div><div class="code">${escapeH(args.content)}</div></div>
</div>`;
}
const chip = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
return header + content + chip;
}
if (tc.name === 'Edit') {
let diff = '';
if (args.old_string && args.new_string) diff = renderDiff(args.old_string, args.new_string);
const chip = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
return diff + chip;
}
if (tc.name === 'Bash') {
const desc = args.description ? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeH(args.description)}</div>` : '';
return desc + renderTerminal(args.command || '', out, isError);
}
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
(out ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(out, isError)}</div>` : '');
}
function renderFileContent(text) {
let lines = text.split('\n');
// Detect if content already has line numbers (e.g. " 1\tcode" from cat -n / Read tool)
const hasLineNums = lines.length > 1 && lines.slice(0, 5).every(l => /^\s*\d+\t/.test(l) || l === '');
let gutter;
if (hasLineNums) {
const parsed = lines.map(l => {
const m = l.match(/^\s*(\d+)\t(.*)$/);
return m ? { num: m[1], code: m[2] } : { num: '', code: l };
});
gutter = parsed.map(p => p.num).join('\n');
lines = parsed.map(p => p.code);
} else {
gutter = lines.map((_, i) => i + 1).join('\n');
}
const total = lines.length;
const collapsed = total > 12;
return `<div class="file-content">
<div class="file-content-head"><span class="label">File contents</span><span class="meta">${total} lines</span></div>
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(lines.join('\n'))}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
function renderDiff(oldStr, newStr) {
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
let prefix = 0;
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
let suffix = 0;
while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++;
const result = [];
for (let i = 0; i < prefix; i++) result.push({ kind: 'context', text: oldLines[i], oldNo: i + 1, newNo: i + 1 });
for (let i = prefix; i < oldLines.length - suffix; i++) result.push({ kind: 'del', text: oldLines[i], oldNo: i + 1, newNo: null });
for (let i = prefix; i < newLines.length - suffix; i++) result.push({ kind: 'add', text: newLines[i], oldNo: null, newNo: i + 1 });
for (let i = 0; i < suffix; i++) {
result.push({ kind: 'context', text: oldLines[oldLines.length - suffix + i], oldNo: oldLines.length - suffix + i + 1, newNo: newLines.length - suffix + i + 1 });
}
const adds = result.filter(d => d.kind === 'add').length;
const dels = result.filter(d => d.kind === 'del').length;
const rows = result.map(line => {
const oldN = line.oldNo == null ? ' ' : String(line.oldNo);
const newN = line.newNo == null ? ' ' : String(line.newNo);
return `<div class="diff-gutter ${line.kind}">${oldN.padStart(3)} ${newN.padStart(3)}</div><div class="diff-line ${line.kind}"> ${escapeH(line.text)}</div>`;
}).join('');
return `<div class="diff-view">
<div class="diff-view-head"><span class="label">Diff</span><div class="stats"><span class="stat-add">+${adds}</span><span class="stat-del">${dels}</span></div></div>
<div class="diff-body">${rows}</div>
</div>`;
}
function renderTerminal(command, output, isError) {
let formatted = escapeH(output);
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
return `<div class="terminal-view">
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeH(command)}</span></div>
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
</div>`;
}
function renderFieldGrid(obj) {
const entries = Object.entries(obj);
if (!entries.length) return '';
const rows = entries.map(([k, v]) => {
return `<div class="field-key">${escapeH(k)}</div><div class="field-val">${renderValue(v)}</div>`;
}).join('');
return `<div class="field-grid">${rows}</div>`;
}
function renderValue(v) {
if (v === null || v === undefined) return '<span class="literal-null">null</span>';
if (typeof v === 'boolean') return `<span class="literal-bool">${v}</span>`;
if (typeof v === 'number') return `<span class="literal-num">${v}</span>`;
if (typeof v === 'string') {
if (/^https?:\/\//.test(v)) return `<span class="literal-string">${escapeH(v)}</span>`;
if (v.length > 120) {
return `<span class="lit-string-long" onclick="this.classList.toggle('open')">"${escapeH(v.slice(0, 120))}<span class="long-rest">${escapeH(v.slice(120))}</span>"<button class="more-btn">+${v.length - 120}</button></span>`;
}
return `<span class="literal-string">"${escapeH(v)}"</span>`;
}
if (Array.isArray(v)) {
if (v.length === 0) return '<span class="literal-null">[]</span>';
if (v.length <= 4 && v.every(x => typeof x !== 'object')) return `<span class="literal-string">[${v.map(x => renderValue(x)).join(', ')}]</span>`;
return `<span class="literal-null">Array(${v.length})</span>`;
}
if (typeof v === 'object') {
const keys = Object.keys(v);
return `<span class="literal-null">Object(${keys.length})</span>`;
}
return `<span>${escapeH(String(v))}</span>`;
}
function renderOutput(out, isError) {
if (!out) return '<div style="padding:8px;color:var(--muted-2);font-style:italic;font-size:11px;">No output.</div>';
let parsed = null;
try { parsed = JSON.parse(out); } catch {}
if (parsed !== null && typeof parsed === 'object') {
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => x && typeof x === 'object' && !Array.isArray(x))) {
return renderAutoTable(parsed);
}
if (Array.isArray(parsed)) {
return renderFieldGrid(Object.fromEntries(parsed.map((x, i) => [i, x])));
}
return renderObjectOutput(parsed);
}
if (out.includes('\n')) {
const lines = out.split('\n');
const total = lines.length;
const collapsed = total > 10;
const gutter = lines.map((_, i) => i + 1).join('\n');
return `<div class="file-content">
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(out)}</div></div>
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
</div>`;
}
return `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
}
function renderObjectOutput(obj) {
const hero = extractHero(obj);
let rest = obj;
if (hero) {
rest = { ...obj };
if (hero.titleKey) delete rest[hero.titleKey];
if (hero.urlKey) delete rest[hero.urlKey];
if (hero.idKey) delete rest[hero.idKey];
}
let html = '';
if (hero) {
html += `<div style="margin-bottom:10px;padding:8px 12px;border-left:2px solid var(--accent-soft);background:rgba(167,139,250,0.04);border-radius:0 5px 5px 0;">`;
if (hero.titleKey) html += `<div style="font-size:14px;font-weight:600;color:var(--fg);margin-bottom:2px;">${escapeH(obj[hero.titleKey])}</div>`;
const sub = [];
if (hero.idKey) sub.push(escapeH(obj[hero.idKey]));
if (hero.urlKey) sub.push(escapeH(obj[hero.urlKey]));
if (sub.length) html += `<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);">${sub.join(' · ')}</div>`;
html += '</div>';
}
if (Object.keys(rest).length) html += renderFieldGrid(rest);
return html;
}
function extractHero(obj) {
if (!obj || typeof obj !== 'object') return null;
const titleKey = ['title', 'name', 'summary'].find(k => typeof obj[k] === 'string');
const urlKey = ['url', 'permalink', 'href', 'link'].find(k => typeof obj[k] === 'string' && /^https?:/.test(obj[k]));
const idKey = ['id', 'identifier', 'uuid', 'key'].find(k => typeof obj[k] === 'string');
if (!titleKey && !urlKey && !idKey) return null;
return { titleKey, urlKey, idKey };
}
function renderAutoTable(rows) {
const sample = rows.slice(0, 5);
const allKeys = new Set();
for (const row of sample) Object.keys(row).forEach(k => allKeys.add(k));
const cols = Array.from(allKeys);
const head = cols.map(c => `<th>${escapeH(c)}</th>`).join('');
const body = rows.slice(0, 50).map(row =>
`<tr>${cols.map(c => {
const v = row[c];
if (v == null) return '<td><span class="literal-null">—</span></td>';
if (typeof v === 'string' && v.length > 60) return `<td title="${escapeH(v)}">${escapeH(v.slice(0, 60))}…</td>`;
if (typeof v === 'object') return `<td>${renderValue(v)}</td>`;
return `<td>${escapeH(String(v))}</td>`;
}).join('')}</tr>`
).join('');
return `<div class="auto-table-wrap">
<div class="auto-table-head"><span class="h-label">Result</span><span class="h-meta">${rows.length} items · ${cols.length} columns</span></div>
<div class="auto-table-scroll"><table class="auto-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>
</div>`;
}
function toggleRaw(event) {
const body = event.target.closest('.toolcall-body');
if (!body) return;
const pretty = body.querySelector('.toolcall-pretty');
const raw = body.querySelector('.toolcall-raw');
const btn = body.querySelector('.raw-toggle');
if (!pretty || !raw) return;
const showing = raw.classList.toggle('show');
pretty.classList.toggle('hidden', showing);
btn?.classList.toggle('active', showing);
}
function getSkillMd(skillMsgIdx) {
const msg = messages.value[skillMsgIdx];
if (msg?._skillMd) return msg._skillMd;
// Fallback: search next few messages
for (let i = skillMsgIdx + 1; i < Math.min(skillMsgIdx + 3, messages.value.length); i++) {
const m = messages.value[i];
if (m.is_meta === 1 && m.text && m.text.includes('Base directory for this skill')) {
return m.text;
}
}
return null;
}
function toggleSkillMd(event) {
const card = event.target.closest('.skill-card');
if (card) card.classList.toggle('skill-md-open');
} }
function getToolCallParsedInput(tc) { function getToolCallParsedInput(tc) {
@@ -164,7 +512,7 @@ function getToolCallParsedInput(tc) {
</script> </script>
<template> <template>
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll"> <div class="detail-wrap" ref="wrapRef" @scroll="onScroll" :style="{ '--text-base': fontSize, '--text-md': fontSize }">
<div class="detail" ref="detailRef"> <div class="detail" ref="detailRef">
<!-- Progress bar --> <!-- Progress bar -->
<div class="session-progress"> <div class="session-progress">
@@ -275,19 +623,28 @@ function getToolCallParsedInput(tc) {
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }"> <div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
<button class="toolcall-toggle" @click="toggleToolCall"> <button class="toolcall-toggle" @click="toggleToolCall">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span> <span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getArgPreview(tc) }}</span> <span class="tool-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span> <span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button> </button>
<div class="toolcall-body"> <div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
</div>
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
<div class="toolcall-raw">
<div class="tc-section">Input</div> <div class="tc-section">Input</div>
<pre>{{ tc.input_json || '' }}</pre> <pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result"> <template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div> <div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre> <pre>{{ tc.result.content || '(empty)' }}</pre>
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
</div> </div>
</div> </div>
@@ -295,6 +652,29 @@ function getToolCallParsedInput(tc) {
</template> </template>
</template> </template>
<!-- Skill card (standalone, like workflow) -->
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text">
<div class="skill-card" :data-uuid="msg.uuid">
<div class="skill-card-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
</div>
<div class="skill-card-body">
<div class="skill-card-header">
<span class="skill-card-badge">Skill</span>
<span class="skill-card-name">{{ getToolCallParsedInput(msg.tool_calls[0]).skill || '?' }}</span>
</div>
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
<div v-if="getSkillMd(idx)" class="skill-card-md">
<button class="skill-md-toggle" @click="toggleSkillMd">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span>SKILL.md</span>
</button>
<div class="skill-md-body" v-html="renderMarkdown(getSkillMd(idx), { variant: 'compact' })"></div>
</div>
</div>
</div>
</template>
<!-- Standalone thinking message --> <!-- Standalone thinking message -->
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'"> <template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
<div class="msg assistant" :data-uuid="msg.uuid"> <div class="msg assistant" :data-uuid="msg.uuid">
@@ -347,8 +727,16 @@ function getToolCallParsedInput(tc) {
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools"> <div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
<template v-for="tc in msg.tool_calls" :key="tc.id"> <template v-for="tc in msg.tool_calls" :key="tc.id">
<!-- Skill loaded — agent equipped a capability -->
<template v-if="tc.name === 'Skill'">
<div class="skill-badge">
<span class="skill-label">skill</span>
<span class="skill-name">{{ getToolCallParsedInput(tc).skill || '?' }}</span>
</div>
</template>
<!-- Agent/Task tool call (subagent) --> <!-- Agent/Task tool call (subagent) -->
<template v-if="tc.name === 'Agent' || tc.name === 'Task'"> <template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
<div class="msg-tool agent-call"> <div class="msg-tool agent-call">
<button class="toolcall-toggle" @click="toggleToolCall"> <button class="toolcall-toggle" @click="toggleToolCall">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
@@ -361,7 +749,7 @@ function getToolCallParsedInput(tc) {
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')" @click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
>View conversation &rarr;</button> >View conversation &rarr;</button>
</button> </button>
<div class="toolcall-body"> <div class="toolcall-body" style="padding:10px 12px;">
<template v-if="getToolCallParsedInput(tc).prompt"> <template v-if="getToolCallParsedInput(tc).prompt">
<div class="tc-section">Prompt</div> <div class="tc-section">Prompt</div>
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div> <div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
@@ -388,7 +776,7 @@ function getToolCallParsedInput(tc) {
>{{ tc.workflow.status }}</span> >{{ tc.workflow.status }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span> <span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button> </button>
<div class="toolcall-body"> <div class="toolcall-body" style="padding:10px 12px;">
<template v-if="tc.workflow?.agents?.length"> <template v-if="tc.workflow?.agents?.length">
<div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div> <div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div>
<div class="workflow-agent-list"> <div class="workflow-agent-list">
@@ -427,19 +815,28 @@ function getToolCallParsedInput(tc) {
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }"> <div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
<button class="toolcall-toggle" @click="toggleToolCall"> <button class="toolcall-toggle" @click="toggleToolCall">
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg> <svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
<span class="tool-name">{{ tc.name }}</span> <span class="tool-name">{{ tc.name }}</span>
<span class="tool-arg">{{ getArgPreview(tc) }}</span> <span class="tool-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span> <span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button> </button>
<div class="toolcall-body"> <div class="toolcall-body">
<div class="toolcall-body-strip">
<span class="strip-label">{{ tc.name }}</span>
<span class="spacer"></span>
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
</div>
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
<div class="toolcall-raw">
<div class="tc-section">Input</div> <div class="tc-section">Input</div>
<pre>{{ tc.input_json || '' }}</pre> <pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result"> <template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div> <div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre> <pre>{{ tc.result.content || '(empty)' }}</pre>
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
</template> </template>
@@ -460,16 +857,30 @@ function getToolCallParsedInput(tc) {
</template> </template>
</div> </div>
</template> </template>
</div>
<!-- Back to top button --> <!-- Pagination nav -->
<button <div class="msg-nav" v-if="totalMsgs > 0">
class="back-to-top" <button class="msg-nav-btn" @click="navTo('first')" :disabled="currentMsgIdx === 0" title="First">
:class="{ show: showBackToTop }" <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v8M7 8l4-4v8z"/></svg>
@click="scrollToTop" </button>
> <button class="msg-nav-btn" @click="navTo('prev')" :disabled="currentMsgIdx === 0" title="Previous">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
</button>
<span class="msg-nav-pos"><span class="msg-nav-current">{{ currentMsgIdx + 1 }}</span> / {{ totalMsgs }}</span>
<button class="msg-nav-btn" @click="navTo('next')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Next">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>
</button>
<button class="msg-nav-btn" @click="navTo('last')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Last">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v8M9 8l-4-4v8z"/></svg>
</button> </button>
</div> </div>
<Transition name="toast">
<div v-if="showFontHint" class="font-toast">
+/- to adjust font size
</div>
</Transition>
</div> </div>
</template> </template>
@@ -478,5 +889,26 @@ function getToolCallParsedInput(tc) {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
position: relative;
} }
.font-toast {
position: fixed;
bottom: 48px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid var(--hairline-strong);
backdrop-filter: blur(12px);
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-2);
pointer-events: none;
z-index: 100;
}
.toast-enter-active { transition: opacity 0.3s, transform 0.3s; }
.toast-leave-active { transition: opacity 0.6s, transform 0.6s; }
.toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(8px); }
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(-4px); }
</style> </style>
+341 -23
View File
@@ -320,7 +320,14 @@
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; } .markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; } .markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
.markdown-msg { font-size: var(--text-base); line-height: 1.6; color: var(--fg); word-wrap: break-word; } .markdown-msg {
font-size: var(--text-base);
line-height: 1.75;
color: var(--fg);
word-wrap: break-word;
font-family: 'Helvetica Neue', 'Inter', -apple-system, system-ui, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
letter-spacing: 0.005em;
}
.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 { .markdown-msg h1, .markdown-msg h2, .markdown-msg h3 {
font-weight: 600; letter-spacing: -0.01em; font-weight: 600; letter-spacing: -0.01em;
margin: 1em 0 0.4em; line-height: 1.3; margin: 1em 0 0.4em; line-height: 1.3;
@@ -513,6 +520,12 @@
transition: transform 0.15s; flex-shrink: 0; transition: transform 0.15s; flex-shrink: 0;
} }
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); } .msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
.toolcall-toggle .tool-icon {
width: 14px; height: 14px; color: var(--accent-2); flex-shrink: 0;
display: inline-flex; align-items: center;
}
.toolcall-toggle .tool-icon svg { width: 14px; height: 14px; }
.msg-tool.is-error .toolcall-toggle .tool-icon { color: var(--danger); }
.toolcall-toggle .tool-name { .toolcall-toggle .tool-name {
font-family: var(--font-mono); font-size: 11px; font-family: var(--font-mono); font-size: 11px;
color: var(--accent-2); font-weight: 600; flex-shrink: 0; color: var(--accent-2); font-weight: 600; flex-shrink: 0;
@@ -530,23 +543,313 @@
letter-spacing: 0.04em; font-weight: 500; letter-spacing: 0.04em; font-weight: 500;
} }
.toolcall-body { .toolcall-body {
display: none; border-top: 1px solid var(--hairline); display: none;
background: rgba(0,0,0,0.35); border-top: 1px solid var(--hairline);
padding: 10px 12px; max-height: 300px; overflow: auto; background: rgba(0,0,0,0.32);
} }
.msg-tool.open .toolcall-body { display: block; } .msg-tool.open .toolcall-body { display: block; }
.toolcall-body-strip {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--hairline);
background: rgba(0,0,0,0.18);
}
.toolcall-body-strip .strip-label {
font-family: var(--font-mono); font-size: 10px;
color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase;
}
.toolcall-body-strip .spacer { flex: 1; }
.raw-toggle {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 7px; border-radius: 3px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
border: 1px solid var(--hairline); cursor: pointer; transition: all 0.1s;
}
.raw-toggle:hover { color: var(--fg-2); border-color: var(--hairline-strong); background: var(--surface-strong); }
.raw-toggle.active { color: var(--accent-2); border-color: var(--accent-soft); background: var(--accent-soft); }
.toolcall-pretty { padding: 10px 12px; }
.toolcall-pretty.hidden { display: none; }
.toolcall-body .tc-section { .toolcall-body .tc-section {
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); font-size: 10px; color: var(--muted);
letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.05em; text-transform: uppercase;
margin: 4px 0 4px; font-weight: 500; margin: 0 0 5px; font-weight: 500;
} }
.toolcall-body .tc-section:first-child { margin-top: 0; }
.toolcall-body pre { .toolcall-raw {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5; display: none; padding: 12px 14px; max-height: 400px; overflow: auto;
}
.toolcall-raw.show { display: block; }
.toolcall-raw .tc-section {
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
letter-spacing: 0.05em; text-transform: uppercase;
margin: 0 0 5px; font-weight: 500;
}
.toolcall-raw .tc-section + pre { margin-bottom: 12px; }
.toolcall-raw pre {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word; color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
margin-bottom: 8px;
} }
/* File reference chip */
.file-ref {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; border-radius: 4px;
background: rgba(255,255,255,0.04); border: 1px solid var(--hairline);
font-family: var(--font-mono); font-size: 11.5px; color: var(--fg);
}
.file-ref .file-line { color: var(--muted); margin-left: 2px; }
/* File content viewer */
.file-content {
border: 1px solid var(--hairline); border-radius: 5px;
background: rgba(0,0,0,0.4); overflow: hidden;
}
.file-content-head {
display: flex; align-items: center; gap: 10px;
padding: 6px 10px; background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--hairline);
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
}
.file-content-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
.file-content-head .meta { margin-left: auto; }
.file-content-body {
display: grid; grid-template-columns: max-content 1fr;
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
max-height: 320px; overflow: auto;
}
.file-content-body.collapsed { max-height: 180px; }
.file-content-body .gutter {
padding: 6px 10px 6px 12px; color: var(--muted-2); user-select: none;
text-align: right; background: rgba(255,255,255,0.015);
border-right: 1px solid var(--hairline); white-space: pre;
}
.file-content-body .code { padding: 6px 12px; color: var(--fg-2); white-space: pre; overflow-x: auto; }
.file-content-expand {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 6px; border-top: 1px solid var(--hairline);
background: rgba(255,255,255,0.02);
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
width: 100%; cursor: pointer; transition: all 0.1s; border: none;
}
.file-content-expand:hover { color: var(--fg-2); background: var(--surface-strong); }
/* Diff view */
.diff-view {
border: 1px solid var(--hairline); border-radius: 5px;
background: rgba(0,0,0,0.4); overflow: hidden;
}
.diff-view-head {
display: flex; align-items: center; gap: 10px;
padding: 6px 10px; background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--hairline);
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
}
.diff-view-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
.diff-view-head .stats { margin-left: auto; display: flex; gap: 8px; }
.diff-view-head .stat-add { color: rgba(165,180,252,0.85); }
.diff-view-head .stat-del { color: rgba(249,168,212,0.7); }
.diff-body {
display: grid; grid-template-columns: max-content 1fr;
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
max-height: 380px; overflow: auto;
}
.diff-body .diff-gutter {
padding: 0 10px 0 12px; color: var(--muted-2); user-select: none;
text-align: right; background: rgba(255,255,255,0.015);
border-right: 1px solid var(--hairline); white-space: pre;
}
.diff-body .diff-line { padding: 0 12px; white-space: pre; }
.diff-body .diff-line.add { background: rgba(99,102,241,0.06); color: rgba(165,180,252,0.85); }
.diff-body .diff-line.del { background: rgba(236,72,153,0.06); color: rgba(249,168,212,0.6); text-decoration: line-through; text-decoration-color: rgba(249,168,212,0.25); }
.diff-body .diff-line.context { color: var(--fg-2); }
.diff-body .diff-gutter.add { background: rgba(99,102,241,0.04); color: rgba(99,102,241,0.55); }
.diff-body .diff-gutter.del { background: rgba(236,72,153,0.04); color: rgba(236,72,153,0.45); }
/* Terminal view */
.terminal-view {
border: 1px solid var(--hairline); border-radius: 5px;
background: #07090f; overflow: hidden;
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
}
.terminal-prompt-line {
display: flex; gap: 8px; padding: 8px 12px;
background: rgba(255,255,255,0.03);
}
.terminal-prompt-line .prompt-marker { color: #4ade80; font-weight: 600; user-select: none; flex-shrink: 0; }
.terminal-prompt-line .prompt-cmd { color: var(--fg); white-space: pre-wrap; word-break: break-all; }
.terminal-divider {
height: 1px;
background: rgba(255,255,255,0.06);
}
.terminal-output {
padding: 8px 12px; color: rgba(255,255,255,0.68);
white-space: pre-wrap; word-wrap: break-word;
max-height: 300px; overflow: auto;
border-left: 2px solid rgba(255,255,255,0.06);
margin-left: 10px;
}
.terminal-output.is-error {
color: #fca5a5;
border-left-color: rgba(248,113,113,0.3);
}
/* Field grid (generic fallback) */
.field-grid {
display: grid; grid-template-columns: max-content 1fr;
gap: 4px 14px; font-family: var(--font-mono); font-size: 11.5px;
align-items: start;
}
.field-grid .field-key { color: var(--muted); font-weight: 500; padding-top: 1px; }
.field-grid .field-val { color: var(--fg-2); word-break: break-word; min-width: 0; }
.field-grid .field-val .literal-string { color: var(--accent-2); }
.field-grid .field-val .literal-num { color: #fcd34d; }
.field-grid .field-val .literal-bool { color: #4ade80; }
.field-grid .field-val .literal-null { color: var(--muted); font-style: italic; }
/* Long string expand */
.lit-string-long { display: inline; color: var(--accent-2); cursor: pointer; }
.lit-string-long .long-rest { display: none; }
.lit-string-long.open .long-rest { display: inline; }
.lit-string-long.open .more-btn { display: none; }
.lit-string-long .more-btn {
display: inline-block; margin-left: 6px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
padding: 0 5px; border: 1px solid var(--hairline-strong);
border-radius: 3px; vertical-align: middle; cursor: pointer;
transition: all 0.1s;
}
.lit-string-long .more-btn:hover { color: var(--fg-2); border-color: var(--hairline); background: var(--surface-strong); }
/* Body section label */
.body-label {
font-family: var(--font-mono); font-size: 9.5px;
color: var(--muted); letter-spacing: 0.08em;
text-transform: uppercase; font-weight: 600;
margin-bottom: 6px;
}
/* Result chip */
.result-chip {
display: inline-flex; align-items: center; gap: 6px;
margin-top: 10px; padding: 5px 10px; border-radius: 4px;
background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.18);
font-size: 11.5px; color: var(--fg-2);
}
.result-chip.error { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.25); }
/* Tool action label */
.tool-action-label {
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
letter-spacing: 0.05em; text-transform: uppercase;
margin-bottom: 6px;
}
/* Auto-table for list-of-objects output */
.auto-table-wrap {
border: 1px solid var(--hairline); border-radius: 5px;
background: rgba(0,0,0,0.32); overflow: hidden;
}
.auto-table-head {
padding: 6px 10px; background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--hairline);
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
display: flex; align-items: center; gap: 8px;
}
.auto-table-head .h-label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
.auto-table-head .h-meta { margin-left: auto; }
.auto-table-scroll { max-height: 360px; overflow: auto; }
.auto-table {
width: 100%; border-collapse: collapse;
font-family: var(--font-mono); font-size: 11.5px;
}
.auto-table th, .auto-table td {
padding: 6px 10px; text-align: left;
border-bottom: 1px solid var(--hairline);
vertical-align: top; line-height: 1.45;
}
.auto-table th {
background: rgba(255,255,255,0.025);
font-weight: 500; font-size: 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.04em;
position: sticky; top: 0; white-space: nowrap;
}
.auto-table tr:last-child td { border-bottom: 0; }
.auto-table tr:hover td { background: rgba(255,255,255,0.015); }
/* Skill card (standalone timeline item) */
.skill-card {
display: flex; align-items: flex-start; gap: 12px;
padding: 12px 14px;
border-radius: 8px;
background: rgba(6, 182, 212, 0.04);
border: 1px solid rgba(6, 182, 212, 0.15);
border-left: 3px solid rgba(6, 182, 212, 0.5);
}
.skill-card-icon {
width: 28px; height: 28px; flex-shrink: 0;
display: grid; place-items: center;
border-radius: 6px;
background: rgba(6, 182, 212, 0.1);
color: #67e8f9;
}
.skill-card-icon svg { width: 14px; height: 14px; }
.skill-card-body { flex: 1; min-width: 0; }
.skill-card-header {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.skill-card-badge {
font-family: var(--font-mono); font-size: 10px;
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
color: #67e8f9;
padding: 1px 6px; border-radius: 3px;
background: rgba(6, 182, 212, 0.15);
}
.skill-card-name {
font-size: 14px; font-weight: 500; color: var(--fg);
}
.skill-card-args {
font-size: 12px; color: var(--fg-3); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.skill-card-md { margin-top: 8px; }
.skill-md-toggle {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); cursor: pointer;
border: none; background: none; padding: 2px 0;
transition: color 0.1s;
}
.skill-md-toggle:hover { color: var(--fg-2); }
.skill-md-toggle .chevron {
width: 8px; height: 8px; transition: transform 0.15s;
}
.skill-card.skill-md-open .skill-md-toggle .chevron { transform: rotate(90deg); }
.skill-md-body {
display: none;
margin-top: 8px; padding: 12px;
max-height: 400px; overflow-y: auto;
border: 1px solid var(--hairline); border-radius: 5px;
background: rgba(0,0,0,0.3);
}
.skill-card.skill-md-open .skill-md-body { display: block; }
/* Skill badge (inside assistant bubble, for mixed messages) */
.skill-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px;
border-radius: 4px;
background: var(--surface);
border: 1px solid var(--hairline);
font-family: var(--font-mono); font-size: 11px;
}
.skill-badge .skill-label { color: var(--muted); }
.skill-badge .skill-name { color: var(--accent-2); font-weight: 500; }
.tc-subagent { .tc-subagent {
margin-top: 8px; padding: 8px 10px; margin-top: 8px; padding: 8px 10px;
border: 1px solid var(--workflow-soft); border: 1px solid var(--workflow-soft);
@@ -860,16 +1163,31 @@
.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; } .back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
/* Back to top floating button */ /* Back to top floating button */
.back-to-top { /* Message pagination nav */
position: sticky; bottom: 20px; float: right; .msg-nav {
margin-right: 8px; margin-top: -40px; position: fixed; bottom: 40px;
width: 32px; height: 32px; border-radius: 50%; left: 50%; transform: translateX(-50%);
background: var(--surface-strong); border: 1px solid var(--hairline-strong); display: flex; align-items: center; gap: 4px;
color: var(--muted); cursor: pointer; padding: 5px 8px;
display: grid; place-items: center; border-radius: 8px;
opacity: 0; transition: opacity 0.15s, background 0.1s; background: rgba(10, 11, 20, 0.85);
pointer-events: none; z-index: 10; border: 1px solid var(--hairline-strong);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
z-index: 10;
} }
.back-to-top.show { opacity: 1; pointer-events: auto; } .msg-nav-btn {
.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); } width: 28px; height: 28px;
.back-to-top svg { width: 14px; height: 14px; } display: grid; place-items: center;
border-radius: 5px; border: none; background: none;
color: var(--muted); cursor: pointer;
transition: all 0.1s;
}
.msg-nav-btn:hover:not(:disabled) { color: var(--fg); background: var(--surface-strong); }
.msg-nav-btn:disabled { opacity: 0.25; cursor: default; }
.msg-nav-btn svg { width: 13px; height: 13px; }
.msg-nav-pos {
font-family: var(--font-mono); font-size: 11px;
color: var(--muted); padding: 0 8px;
font-variant-numeric: tabular-nums;
}
.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; }
+30
View File
@@ -79,3 +79,33 @@
.sort-group.desc .arrow-down { opacity: 1; } .sort-group.desc .arrow-down { opacity: 1; }
.sort-group.asc .arrow-up { opacity: 1; } .sort-group.asc .arrow-up { opacity: 1; }
.sort-group.asc .arrow-down { opacity: 0.25; } .sort-group.asc .arrow-down { opacity: 0.25; }
.tab-group {
display: inline-flex;
border: 1px solid var(--hairline-strong); border-radius: 5px;
overflow: hidden; height: 26px;
}
.tab-group button {
padding: 0 12px; font-size: 12px; color: var(--muted);
border: none; background: none; cursor: pointer;
border-right: 1px solid var(--hairline-strong);
display: inline-flex; align-items: center; transition: all 0.1s;
font-family: inherit;
}
.tab-group button:last-child { border-right: 0; }
.tab-group button:hover { background: var(--surface); color: var(--fg-2); }
.tab-group button.active { background: var(--accent-soft); color: var(--accent-2); }
.toolbar-action-primary {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 12px;
border: 1px solid rgba(167,139,250,0.35); border-radius: 5px;
background: var(--accent-soft); color: var(--accent-2);
font-size: 12px; font-weight: 500; cursor: pointer;
transition: all 0.12s;
}
.toolbar-action-primary:hover {
background: rgba(167,139,250,0.18); border-color: var(--accent);
color: var(--fg); box-shadow: 0 0 12px rgba(167,139,250,0.20);
}
.toolbar-action-primary .plus { font-size: 14px; line-height: 1; opacity: 0.8; font-weight: 400; }
+508
View File
@@ -0,0 +1,508 @@
# Obelisk Recap 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.
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."
}
]
}
```
-4
View File
@@ -511,10 +511,6 @@ function createAttuneApi(db) {
const created_at = new Date().toISOString(); const created_at = new Date().toISOString();
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run( db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run(
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at); id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at);
db.prepare(`
INSERT INTO memories_fts(rowid, id, path, summary)
SELECT rowid, id, path, summary FROM memories WHERE id = ?
`).run(id);
return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at }; return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at };
}; };
+28
View File
@@ -34,6 +34,20 @@ CREATE TABLE IF NOT EXISTS summaries (
source TEXT, content TEXT); source TEXT, content TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid); uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, uuid, session_id, text)
VALUES (new.rowid, new.uuid, new.session_id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
INSERT INTO messages_fts(rowid, uuid, session_id, text)
VALUES (new.rowid, new.uuid, new.session_id, new.text);
END;
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id); CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp); CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
@@ -52,6 +66,20 @@ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, path, summary, id UNINDEXED, path, summary,
content=memories, content_rowid=rowid, content=memories, content_rowid=rowid,
tokenize='unicode61 remove_diacritics 1'); tokenize='unicode61 remove_diacritics 1');
CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, id, path, summary)
VALUES (new.rowid, new.id, new.path, new.summary);
END;
CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
END;
CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
INSERT INTO memories_fts(rowid, id, path, summary)
VALUES (new.rowid, new.id, new.path, new.summary);
END;
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project); CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at); CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);