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
tests/
node_modules/
dist-renderer/
+4
View File
@@ -124,6 +124,7 @@ same SQLite data.
- `references/schema.md` — full SQLite schema and API reference
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
- `references/recap-patterns.md` — optional `/obelisk recap` card content contract
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
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
core contract and high-risk pitfalls visible, while longer recipes and the full
schema stay out of the first prompt until the agent needs them.
The optional recap reference is only for the explicit `/obelisk recap` intent;
it is not part of the ordinary retrieval path.
## 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
├── query-patterns.md # Copyable retrieval recipes
├── 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
```
+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
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
Before writing a query, classify the task. Progressive disclosure is useful, but
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/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.
+24 -4
View File
@@ -64,9 +64,27 @@ function createIndexerService({
let running = false;
let pending = false;
let lastReason = null;
let changedPaths = new Set();
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 (running) {
pending = true;
@@ -74,8 +92,9 @@ function createIndexerService({
}
running = true;
pending = false;
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => {
await buildIndex({ reason });
await buildIndex({ reason, changedPaths: buildChangedPaths });
writeHeartbeat();
})()
.catch((error) => {
@@ -91,8 +110,9 @@ function createIndexerService({
return idlePromise;
};
const scheduleBuild = (reason = 'change') => {
const scheduleBuild = (reason = 'change', changedPath = undefined) => {
if (stopped) return;
addChangedPath(changedPath);
lastReason = reason;
if (running) pending = true;
if (buildTimer) timers.clearTimeout(buildTimer);
@@ -112,7 +132,7 @@ function createIndexerService({
const startWatching = () => {
if (stopped || watcher) return;
watcher = watch(() => scheduleBuild('watch'));
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
if (!watcher) {
watchRetryTimer = timers.setTimeout(() => {
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 os = require('os');
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');
if (isDev) {
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(() => {
indexerWorker = createWorkerBuildIndex();
openDb();
createWindow();
startIndexerService().runBuildNow('startup');
startObeliskWatcher();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
@@ -80,6 +119,7 @@ app.whenReady().then(() => {
app.on('before-quit', () => {
if (indexerService) indexerService.stop();
if (indexerWorker) indexerWorker.stop();
if (obeliskWatcher) obeliskWatcher.close();
});
app.on('window-all-closed', () => {
@@ -300,3 +340,78 @@ ipcMain.handle('db:getUsageStats', () => {
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);
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>
import { computed, watch } from 'vue';
import { computed, watch, ref, provide } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
@@ -29,6 +29,7 @@ const currentRouteType = computed(() => {
const name = route.name;
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
if (name === 'Activity') return 'activity';
if (name === 'Recap' || name === 'RecapDetail') return 'recap';
return 'memory';
});
@@ -81,6 +82,10 @@ const windowTitle = computed(() => {
let scopeText = '';
if (route.name === 'Activity') {
scopeText = 'Activity';
} else if (route.name === 'Recap') {
scopeText = 'Recap';
} else if (route.name === 'RecapDetail') {
scopeText = `Recap · ${route.params.id}`;
} else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id);
@@ -114,6 +119,8 @@ function handleSidebarRoute(routeName) {
router.push('/sessions');
} else if (routeName === 'activity') {
router.push('/activity');
} else if (routeName === 'recap') {
router.push('/recap');
} else {
router.push('/memory');
}
@@ -130,8 +137,7 @@ function handleClearProject() {
function handleSidebarProject(slug) {
setProject(slug);
// Stay on current list route
if (state.route === 'sessions') router.push('/sessions');
if (currentRouteType.value === 'sessions') router.push('/sessions');
else router.push('/memory');
}
@@ -160,10 +166,20 @@ function handleToggleSearchMsgs() {
// --- Keep-alive includes ---
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>
<template>
<div class="app">
<router-view v-if="isExportRoute" />
<div class="app" v-else>
<div class="titlebar">
<div class="titlebar-text" id="titlebar-text">
<span class="app-name">{{ windowTitle.appName }}</span>
@@ -216,7 +232,7 @@ const keepAliveIncludes = ['SessionDetail'];
</button>
<button
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')"
>
<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
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')"
>
<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
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')"
>
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -264,9 +280,20 @@ const keepAliveIncludes = ['SessionDetail'];
</svg>
<span class="label">Activity</span>
</button>
<button
class="sidebar-item"
:class="{ active: route.name === 'Recap' }"
@click="handleSidebarRoute('recap')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 2h10v12H3z"/>
<path d="M6 5h4M6 8h4M6 11h2"/>
</svg>
<span class="label">Recap</span>
</button>
</div>
<div class="sidebar-section projects">
<div class="sidebar-section projects" v-if="currentRouteType === 'sessions' || currentRouteType === 'memory'">
<div class="sidebar-section-title"><span>Projects</span></div>
<div class="sidebar-search" v-if="totalProjectCount >= 6">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
@@ -346,9 +373,28 @@ const keepAliveIncludes = ['SessionDetail'];
</span>
</template>
<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>
</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">
<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"/>
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 });
} else if (name === 'Activity') {
crumbs.push({ label: 'Activity', terminal: true });
} else if (name === 'Recap') {
crumbs.push({ label: 'Recap', terminal: true });
}
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
const META_RE = /^\s*<(task-notification|command-name|local-command|system-reminder)/;
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
@@ -140,7 +141,7 @@ export async function loadSessionDetail(sessionId) {
timestamp: msg.timestamp,
text: msg.text,
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];
@@ -180,15 +181,25 @@ export async function loadSessionDetail(sessionId) {
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') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
// If this is a Skill-only message, don't merge with subsequent tool_use — keep it standalone
const isSkillOnly = merged.tool_calls.length === 1 && merged.tool_calls[0].name === 'Skill';
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
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.text && !merged.text) merged.text = next.text;
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 MemoryDetail = () => import('./views/MemoryDetail.vue');
const Activity = () => import('./views/Activity.vue');
const Recap = () => import('./views/RecapList.vue');
const RecapDetail = () => import('./views/RecapDetail.vue');
const RecapExport = () => import('./views/RecapExport.vue');
const routes = [
{
@@ -46,6 +49,22 @@ const routes = [
name: 'Activity',
component: Activity
},
{
path: '/recap',
name: 'Recap',
component: Recap
},
{
path: '/recap/:id',
name: 'RecapDetail',
component: RecapDetail,
props: true
},
{
path: '/recap-export',
name: 'RecapExport',
component: RecapExport
},
{
path: '/',
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-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
<div v-else class="markdown-msg" v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
+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>
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 { state, FOLDER_SVG } from '../store.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 loading = ref(false);
const progressPct = ref(0);
const showBackToTop = ref(false);
// DOM refs
const wrapRef = ref(null);
const detailRef = ref(null);
// --- 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 () => {
window.addEventListener('keydown', handleZoom);
if (!localStorage.getItem(HINT_KEY)) {
showFontHint.value = true;
localStorage.setItem(HINT_KEY, '1');
setTimeout(() => { showFontHint.value = false; }, 4000);
}
await loadMessages();
});
onActivated(async () => {
window.addEventListener('keydown', handleZoom);
if (messages.value.length === 0 && props.id) {
await loadMessages();
}
});
onUnmounted(() => {
window.removeEventListener('keydown', handleZoom);
});
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
messages.value = [];
@@ -74,23 +118,37 @@ async function loadMessages() {
}
// --- Scroll / progress tracking ---
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
function onScroll() {
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;
totalMsgs.value = msgs.length;
const wrapTop = wrapRef.value.getBoundingClientRect().top;
let topMsgIdx = 0;
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
else break;
}
currentMsgIdx.value = topMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct;
showBackToTop.value = wrapRef.value.scrollTop > 300;
}
function scrollToTop() {
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
function navTo(target) {
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 ---
@@ -147,11 +205,301 @@ function getArgPreview(tc) {
if (j.file_path) return j.file_path;
if (j.command) return j.command;
if (j.path) return j.path;
if (j.query) return j.query;
if (j.description) return j.description;
return JSON.stringify(j).slice(0, 100);
} catch {
return (tc.input_json || '').slice(0, 100);
if (j.pattern) return j.pattern;
if (j.url) return j.url;
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) {
@@ -164,7 +512,7 @@ function getToolCallParsedInput(tc) {
</script>
<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">
<!-- Progress bar -->
<div class="session-progress">
@@ -275,19 +623,28 @@ function getToolCallParsedInput(tc) {
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
<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>
<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-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<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>
<pre>{{ tc.input_json || '' }}</pre>
<pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
@@ -295,6 +652,29 @@ function getToolCallParsedInput(tc) {
</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 -->
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
<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">
<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) -->
<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">
<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>
@@ -361,7 +749,7 @@ function getToolCallParsedInput(tc) {
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
>View conversation &rarr;</button>
</button>
<div class="toolcall-body">
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="getToolCallParsedInput(tc).prompt">
<div class="tc-section">Prompt</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>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<div class="toolcall-body">
<div class="toolcall-body" style="padding:10px 12px;">
<template v-if="tc.workflow?.agents?.length">
<div class="tc-section">Agents &middot; {{ tc.workflow.agents.length }}</div>
<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 }">
<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>
<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-arg">{{ getArgPreview(tc) }}</span>
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
</button>
<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>
<pre>{{ tc.input_json || '' }}</pre>
<pre>{{ formatToolInput(tc) }}</pre>
<template v-if="tc.result">
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
<pre>{{ tc.result.content || '(empty)' }}</pre>
</template>
</div>
</div>
</div>
</template>
</template>
@@ -460,16 +857,30 @@ function getToolCallParsedInput(tc) {
</template>
</div>
</template>
</div>
<!-- Back to top button -->
<button
class="back-to-top"
:class="{ show: showBackToTop }"
@click="scrollToTop"
>
<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>
<!-- Pagination nav -->
<div class="msg-nav" v-if="totalMsgs > 0">
<button class="msg-nav-btn" @click="navTo('first')" :disabled="currentMsgIdx === 0" title="First">
<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>
</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="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>
</div>
<Transition name="toast">
<div v-if="showFontHint" class="font-toast">
+/- to adjust font size
</div>
</Transition>
</div>
</template>
@@ -478,5 +889,26 @@ function getToolCallParsedInput(tc) {
flex: 1;
overflow-y: auto;
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>
+341 -23
View File
@@ -320,7 +320,14 @@
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
.markdown-msg { font-size: var(--text-base); line-height: 1.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 {
font-weight: 600; letter-spacing: -0.01em;
margin: 1em 0 0.4em; line-height: 1.3;
@@ -513,6 +520,12 @@
transition: transform 0.15s; flex-shrink: 0;
}
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
.toolcall-toggle .tool-icon {
width: 14px; height: 14px; color: var(--accent-2); flex-shrink: 0;
display: inline-flex; align-items: center;
}
.toolcall-toggle .tool-icon svg { width: 14px; height: 14px; }
.msg-tool.is-error .toolcall-toggle .tool-icon { color: var(--danger); }
.toolcall-toggle .tool-name {
font-family: var(--font-mono); font-size: 11px;
color: var(--accent-2); font-weight: 600; flex-shrink: 0;
@@ -530,23 +543,313 @@
letter-spacing: 0.04em; font-weight: 500;
}
.toolcall-body {
display: none; border-top: 1px solid var(--hairline);
background: rgba(0,0,0,0.35);
padding: 10px 12px; max-height: 300px; overflow: auto;
display: none;
border-top: 1px solid var(--hairline);
background: rgba(0,0,0,0.32);
}
.msg-tool.open .toolcall-body { display: block; }
.toolcall-body-strip {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--hairline);
background: rgba(0,0,0,0.18);
}
.toolcall-body-strip .strip-label {
font-family: var(--font-mono); font-size: 10px;
color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase;
}
.toolcall-body-strip .spacer { flex: 1; }
.raw-toggle {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 7px; border-radius: 3px;
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
border: 1px solid var(--hairline); cursor: pointer; transition: all 0.1s;
}
.raw-toggle:hover { color: var(--fg-2); border-color: var(--hairline-strong); background: var(--surface-strong); }
.raw-toggle.active { color: var(--accent-2); border-color: var(--accent-soft); background: var(--accent-soft); }
.toolcall-pretty { padding: 10px 12px; }
.toolcall-pretty.hidden { display: none; }
.toolcall-body .tc-section {
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
letter-spacing: 0.04em; text-transform: uppercase;
margin: 4px 0 4px; font-weight: 500;
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-body .tc-section:first-child { margin-top: 0; }
.toolcall-body pre {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5;
.toolcall-raw {
display: none; padding: 12px 14px; max-height: 400px; overflow: auto;
}
.toolcall-raw.show { display: block; }
.toolcall-raw .tc-section {
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
letter-spacing: 0.05em; text-transform: uppercase;
margin: 0 0 5px; font-weight: 500;
}
.toolcall-raw .tc-section + pre { margin-bottom: 12px; }
.toolcall-raw pre {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
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 {
margin-top: 8px; padding: 8px 10px;
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 top floating button */
.back-to-top {
position: sticky; bottom: 20px; float: right;
margin-right: 8px; margin-top: -40px;
width: 32px; height: 32px; border-radius: 50%;
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
color: var(--muted); cursor: pointer;
display: grid; place-items: center;
opacity: 0; transition: opacity 0.15s, background 0.1s;
pointer-events: none; z-index: 10;
/* Message pagination nav */
.msg-nav {
position: fixed; bottom: 40px;
left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px;
padding: 5px 8px;
border-radius: 8px;
background: rgba(10, 11, 20, 0.85);
border: 1px solid var(--hairline-strong);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
z-index: 10;
}
.back-to-top.show { opacity: 1; pointer-events: auto; }
.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
.back-to-top svg { width: 14px; height: 14px; }
.msg-nav-btn {
width: 28px; height: 28px;
display: grid; place-items: center;
border-radius: 5px; border: none; background: none;
color: var(--muted); cursor: pointer;
transition: all 0.1s;
}
.msg-nav-btn:hover:not(:disabled) { color: var(--fg); background: var(--surface-strong); }
.msg-nav-btn:disabled { opacity: 0.25; cursor: default; }
.msg-nav-btn svg { width: 13px; height: 13px; }
.msg-nav-pos {
font-family: var(--font-mono); font-size: 11px;
color: var(--muted); padding: 0 8px;
font-variant-numeric: tabular-nums;
}
.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; }
+30
View File
@@ -79,3 +79,33 @@
.sort-group.desc .arrow-down { opacity: 1; }
.sort-group.asc .arrow-up { opacity: 1; }
.sort-group.asc .arrow-down { opacity: 0.25; }
.tab-group {
display: inline-flex;
border: 1px solid var(--hairline-strong); border-radius: 5px;
overflow: hidden; height: 26px;
}
.tab-group button {
padding: 0 12px; font-size: 12px; color: var(--muted);
border: none; background: none; cursor: pointer;
border-right: 1px solid var(--hairline-strong);
display: inline-flex; align-items: center; transition: all 0.1s;
font-family: inherit;
}
.tab-group button:last-child { border-right: 0; }
.tab-group button:hover { background: var(--surface); color: var(--fg-2); }
.tab-group button.active { background: var(--accent-soft); color: var(--accent-2); }
.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();
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);
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 };
};
+28
View File
@@ -34,6 +34,20 @@ CREATE TABLE IF NOT EXISTS summaries (
source TEXT, content TEXT);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
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_agent ON messages(agent_id);
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,
content=memories, content_rowid=rowid,
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_session ON memories(session_id);
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);