feat: index Codex sessions alongside Claude Code with source tagging
Add `source` column to sessions and messages ('claude' | 'codex').
Discover and parse Codex JSONL files from ~/.codex/sessions/, mapping
Codex thread/item structures to the same schema (messages, tool_calls,
tool_results, subagents). Move DB to ~/.obelisk/ with legacy migration.
Add rebuild-to-temp-then-swap for safe full rebuilds. On the app side:
source filter toggle, collapsible untitled session fold, configurable
codexDir in Settings, and a dev script. Update SKILL.md and query
helpers to expose source fields and accept source filter opt.
This commit is contained in:
@@ -615,6 +615,10 @@ function getToolCallParsedInput(tc) {
|
||||
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="project-path">{{ session.project_path || '' }}</span>
|
||||
<span class="via">
|
||||
<span class="via-dot" :class="session.source || 'claude'"></span>
|
||||
via {{ (session.source || 'claude') === 'codex' ? 'Codex' : 'Claude Code' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="session-title">{{ session.title || '(untitled)' }}</div>
|
||||
<div class="session-meta-inline">
|
||||
|
||||
@@ -23,6 +23,7 @@ const visibleSessions = computed(() => {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
return state.sessions
|
||||
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||
.filter(s => state.sourceFilter === 'all' || (s.source || 'claude') === state.sourceFilter)
|
||||
.map(s => {
|
||||
if (!q) return { ...s, messageHit: null };
|
||||
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||
@@ -40,6 +41,14 @@ const visibleSessions = computed(() => {
|
||||
});
|
||||
|
||||
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||
const showNoise = ref(false);
|
||||
|
||||
function isNoise(s) {
|
||||
return !s.title;
|
||||
}
|
||||
|
||||
const normalSessions = computed(() => visibleSessions.value.filter(s => !isNoise(s)));
|
||||
const noiseSessions = computed(() => visibleSessions.value.filter(s => isNoise(s)));
|
||||
|
||||
function titleHTML(session) {
|
||||
return highlightPlain(session.title || '(untitled)', state.query.trim());
|
||||
@@ -135,7 +144,7 @@ function obeliskStyle(session) {
|
||||
|
||||
<div v-else class="session-list">
|
||||
<div
|
||||
v-for="s in visibleSessions"
|
||||
v-for="s in normalSessions"
|
||||
:key="s.id"
|
||||
class="srow"
|
||||
:class="{ cursor: state.cursorId === s.id }"
|
||||
@@ -155,6 +164,48 @@ function obeliskStyle(session) {
|
||||
</div>
|
||||
<div class="srow-right">{{ timeLabel(s) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Noise fold banner -->
|
||||
<div v-if="noiseSessions.length && !state.query" class="fold-banner" :class="{ expanded: showNoise }" @click="showNoise = !showNoise">
|
||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<path d="M4 2.5l3 3.5-3 3.5"/>
|
||||
</svg>
|
||||
<div class="body">
|
||||
<strong>{{ noiseSessions.length }}</strong> quiet sessions hidden — untitled, likely tests or incomplete runs.
|
||||
</div>
|
||||
<span v-if="!showNoise" class="reveal-link">Show all</span>
|
||||
</div>
|
||||
|
||||
<!-- Noise sessions (collapsed by default) -->
|
||||
<div v-if="showNoise && noiseSessions.length" class="noise-group">
|
||||
<div class="noise-group-head">
|
||||
{{ noiseSessions.length }} sessions · untitled
|
||||
</div>
|
||||
<div
|
||||
v-for="s in noiseSessions"
|
||||
:key="s.id"
|
||||
class="srow noise"
|
||||
@click="openSession(s)"
|
||||
>
|
||||
<div class="srow-body">
|
||||
<div class="srow-title">(untitled)</div>
|
||||
<div class="srow-meta">
|
||||
<template v-if="showProjectPrefix">
|
||||
<span class="project-tag" v-html="projectLabel(s)"></span>
|
||||
<span class="dot"></span>
|
||||
</template>
|
||||
<span>{{ s.message_count || 0 }} msg</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="srow-right">{{ timeLabel(s) }}</div>
|
||||
</div>
|
||||
<button class="noise-fold-bottom" @click.stop="showNoise = false">
|
||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<path d="M4 2.5l3 3.5-3 3.5"/>
|
||||
</svg>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -333,4 +384,65 @@ function obeliskStyle(session) {
|
||||
font-family: var(--font-mono); color: var(--fg-2);
|
||||
background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Noise fold */
|
||||
.fold-banner {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 22px;
|
||||
background: rgba(255,255,255,0.015);
|
||||
border-top: 1px solid var(--hairline);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
font-size: 12.5px; color: var(--muted);
|
||||
cursor: pointer; transition: all 0.1s;
|
||||
}
|
||||
.fold-banner:hover { background: rgba(255,255,255,0.03); color: var(--fg-2); }
|
||||
.fold-banner.expanded { color: var(--fg-3); background: rgba(255,255,255,0.02); }
|
||||
.fold-banner .chev {
|
||||
width: 10px; height: 10px; color: var(--muted-2);
|
||||
transition: transform 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.fold-banner.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
|
||||
.fold-banner .body { flex: 1; }
|
||||
.fold-banner .body strong {
|
||||
color: var(--fg-2); font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
}
|
||||
.fold-banner .reveal-link {
|
||||
font-size: 11.5px; color: var(--accent-2);
|
||||
text-decoration: none; border-bottom: 1px solid rgba(167,139,250,0.4);
|
||||
padding-bottom: 1px; transition: all 0.12s; flex-shrink: 0;
|
||||
}
|
||||
.fold-banner:hover .reveal-link { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.noise-group {
|
||||
border-bottom: 1px solid var(--hairline-strong);
|
||||
background: rgba(0,0,0,0.15);
|
||||
}
|
||||
.noise-group-head {
|
||||
padding: 6px 22px;
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
background: rgba(0,0,0,0.1); border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.srow.noise { padding: 8px 22px 8px 18px; }
|
||||
.srow.noise .srow-title {
|
||||
color: var(--muted); font-style: italic;
|
||||
font-size: 13px; font-weight: 400;
|
||||
}
|
||||
.srow.noise .srow-meta { color: var(--muted-2); }
|
||||
|
||||
.noise-fold-bottom {
|
||||
padding: 8px 22px; background: rgba(0,0,0,0.2);
|
||||
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
|
||||
cursor: pointer; transition: all 0.1s;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
border: none; width: 100%; text-align: left;
|
||||
}
|
||||
.noise-fold-bottom:hover { background: rgba(0,0,0,0.3); color: var(--fg-2); }
|
||||
.noise-fold-bottom .chev {
|
||||
width: 9px; height: 9px; color: var(--muted-2);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
|
||||
defineOptions({ name: 'Settings' });
|
||||
|
||||
const claudePath = ref('');
|
||||
const sources = ref([]);
|
||||
const dbPath = ref('');
|
||||
const recapPath = ref('');
|
||||
const autoRefresh = ref(true);
|
||||
const status = ref('ok');
|
||||
const statusText = ref('Connected');
|
||||
const sessionCount = ref(0);
|
||||
const memoryCount = ref(0);
|
||||
const lastIndexed = ref('');
|
||||
const rebuilding = ref(false);
|
||||
const version = ref('0.1.0');
|
||||
|
||||
@@ -22,23 +18,19 @@ onMounted(async () => {
|
||||
async function loadSettings() {
|
||||
if (!window.obelisk?.getSettings) return;
|
||||
const s = await window.obelisk.getSettings();
|
||||
claudePath.value = s.claudeDir || '~/.claude';
|
||||
sources.value = s.sources || [];
|
||||
dbPath.value = s.dbPath || '';
|
||||
recapPath.value = s.recapDir || '~/.obelisk/recap';
|
||||
autoRefresh.value = s.autoRefresh !== false;
|
||||
sessionCount.value = s.sessionCount || 0;
|
||||
memoryCount.value = s.memoryCount || 0;
|
||||
lastIndexed.value = s.lastIndexed || '';
|
||||
status.value = s.status || 'ok';
|
||||
statusText.value = s.statusText || 'Connected';
|
||||
}
|
||||
|
||||
async function browsePath() {
|
||||
async function browseSourcePath(source) {
|
||||
if (!window.obelisk?.browseFolder) return;
|
||||
const result = await window.obelisk.browseFolder();
|
||||
if (result) {
|
||||
claudePath.value = result;
|
||||
await saveSetting('claudeDir', result);
|
||||
const key = source.id === 'claude' ? 'claudeDir' : 'codexDir';
|
||||
await saveSetting(key, result);
|
||||
await loadSettings();
|
||||
}
|
||||
}
|
||||
@@ -52,11 +44,6 @@ async function browseRecapPath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPath() {
|
||||
await saveSetting('claudeDir', null);
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
async function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value;
|
||||
await saveSetting('autoRefresh', autoRefresh.value);
|
||||
@@ -68,11 +55,6 @@ async function saveSetting(key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
async function commitClaudePath() {
|
||||
await saveSetting('claudeDir', claudePath.value);
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
async function commitRecapPath() {
|
||||
await saveSetting('recapDir', recapPath.value);
|
||||
}
|
||||
@@ -80,7 +62,8 @@ async function commitRecapPath() {
|
||||
async function rebuildIndex() {
|
||||
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
|
||||
rebuilding.value = true;
|
||||
statusText.value = 'Rebuilding…';
|
||||
await nextTick();
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
try {
|
||||
await window.obelisk.rebuildIndex();
|
||||
await loadSettings();
|
||||
@@ -111,86 +94,79 @@ function fmtRelative(iso) {
|
||||
<div class="settings-wrap">
|
||||
<div class="settings-content">
|
||||
|
||||
<!-- Data Source -->
|
||||
<!-- Data Sources -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Data Source</h2>
|
||||
<p>Where Obelisk reads your Claude Code session history.</p>
|
||||
<h2>Data Sources</h2>
|
||||
<p>Where Obelisk reads your agent session history.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Claude Code path</div>
|
||||
<div class="form-label-hint">Default <code>~/.claude</code> on macOS & Linux.</div>
|
||||
<div
|
||||
v-for="src in sources" :key="src.id"
|
||||
class="source-card"
|
||||
:class="{ error: src.status === 'error', warn: src.status === 'warn' }"
|
||||
>
|
||||
<div class="source-card-head">
|
||||
<div class="source-card-mark" :class="src.id">
|
||||
<span class="mark-dot"></span>
|
||||
</div>
|
||||
<div class="source-card-info">
|
||||
<div class="source-card-name">
|
||||
{{ src.name }}
|
||||
<span class="vendor">by {{ src.vendor }}</span>
|
||||
</div>
|
||||
<div class="source-card-status">
|
||||
<span class="stat-dot" :class="src.status"></span>
|
||||
<span class="stat-text" :class="src.status">{{ src.statusText }}</span>
|
||||
<template v-if="src.lastIndexed">
|
||||
<span class="sep">·</span>
|
||||
<span>last read <strong>{{ fmtRelative(src.lastIndexed) }}</strong></span>
|
||||
</template>
|
||||
<template v-if="src.sessionCount">
|
||||
<span class="sep">·</span>
|
||||
<span><strong>{{ src.sessionCount }}</strong> sessions</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="source-card-body">
|
||||
<div class="path-input">
|
||||
<input
|
||||
class="path-field"
|
||||
:class="{ error: status === 'error' }"
|
||||
type="text"
|
||||
v-model="claudePath"
|
||||
spellcheck="false"
|
||||
@keydown.enter="commitClaudePath"
|
||||
@blur="commitClaudePath"
|
||||
/>
|
||||
<button class="btn" @click="browsePath">
|
||||
<input class="path-field" :class="{ error: src.status === 'error' }" type="text" :value="src.path" spellcheck="false" readonly/>
|
||||
<button class="btn" @click="browseSourcePath(src)">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
|
||||
</svg>
|
||||
Browse…
|
||||
</button>
|
||||
<button class="btn subtle" @click="resetPath" title="Reset to default">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.5 6.5A5 5 0 1 0 12 9.5"/>
|
||||
<path d="M12.5 2v4.5h-4.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-row" :class="status">
|
||||
<span class="status-dot" :class="status"></span>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<div class="status-meta" v-if="sessionCount || lastIndexed">
|
||||
<template v-if="lastIndexed">
|
||||
<span>last read <strong>{{ fmtRelative(lastIndexed) }}</strong></span>
|
||||
<span class="sep">·</span>
|
||||
</template>
|
||||
<span><strong>{{ sessionCount }}</strong> sessions</span>
|
||||
<span class="sep">·</span>
|
||||
<span><strong>{{ memoryCount }}</strong> memories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Index location</div>
|
||||
<div class="form-label-hint">SQLite database where Obelisk caches the session index.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="path-input">
|
||||
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
|
||||
<button class="btn" @click="revealDb">Reveal</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Index -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Index location</h2>
|
||||
<p>SQLite database where Obelisk caches the unified session index.</p>
|
||||
</div>
|
||||
<div class="path-input" style="max-width: 480px;">
|
||||
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
|
||||
<button class="btn" @click="revealDb">Reveal</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<div class="form-label">Auto-refresh</div>
|
||||
<div class="form-label-hint">Obelisk re-reads when new session files appear.</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
|
||||
<span class="toggle-track" :class="{ on: autoRefresh }">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-text">Watch <code>.claude</code> for changes</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Auto-refresh -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section-head">
|
||||
<h2>Auto-refresh</h2>
|
||||
<p>Obelisk re-reads when new session files appear.</p>
|
||||
</div>
|
||||
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
|
||||
<span class="toggle-track" :class="{ on: autoRefresh }">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-text">Watch data sources for changes</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Recap -->
|
||||
@@ -241,7 +217,7 @@ function fmtRelative(iso) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="reset-hint">
|
||||
Rebuilding only re-reads your Claude Code data. It does not delete memories or recaps.
|
||||
Rebuilding re-reads your coding agent session data. It does not delete memories or recaps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,6 +245,51 @@ function fmtRelative(iso) {
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
|
||||
/* Source cards */
|
||||
.source-card {
|
||||
padding: 18px; border: 1px solid var(--hairline); border-radius: 8px;
|
||||
background: rgba(0,0,0,0.18); margin-bottom: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.source-card:hover { border-color: var(--hairline-strong); }
|
||||
.source-card.error { border-color: rgba(248,113,113,0.25); }
|
||||
.source-card.warn { border-color: rgba(251,191,36,0.20); }
|
||||
.source-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.source-card-mark {
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
|
||||
display: grid; place-items: center; flex-shrink: 0;
|
||||
}
|
||||
.source-card-mark .mark-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.source-card-mark.claude .mark-dot { background: #d97757; box-shadow: 0 0 6px rgba(217,119,87,0.5); }
|
||||
.source-card-mark.codex .mark-dot { background: #10a37f; box-shadow: 0 0 6px rgba(16,163,127,0.5); }
|
||||
.source-card-info { flex: 1; min-width: 0; }
|
||||
.source-card-name {
|
||||
font-size: 14px; color: var(--fg); font-weight: 600; letter-spacing: -0.005em;
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
}
|
||||
.source-card-name .vendor { font-size: 11.5px; color: var(--muted); font-weight: 400; }
|
||||
.source-card-status {
|
||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||
margin-top: 3px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.source-card-status .stat-dot { width: 6px; height: 6px; border-radius: 50%; position: relative; }
|
||||
.source-card-status .stat-dot.ok { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,0.5); }
|
||||
.source-card-status .stat-dot.warn { background: #fbbf24; box-shadow: 0 0 5px rgba(251,191,36,0.5); }
|
||||
.source-card-status .stat-dot.error { background: #f87171; box-shadow: 0 0 5px rgba(248,113,113,0.5); }
|
||||
.source-card-status .stat-dot.ok::before {
|
||||
content: ''; position: absolute; inset: -2.5px; border-radius: 50%;
|
||||
border: 1px solid #34d399; opacity: 0.5; animation: src-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes src-pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.8); opacity: 0; } }
|
||||
.source-card-status .stat-text { color: var(--fg-2); }
|
||||
.source-card-status .stat-text.ok { color: #34d399; }
|
||||
.source-card-status .stat-text.warn { color: #fbbf24; }
|
||||
.source-card-status .stat-text.error { color: #f87171; }
|
||||
.source-card-status .sep { color: var(--muted-3); }
|
||||
.source-card-status strong { color: var(--fg-2); font-weight: 500; }
|
||||
.source-card-body { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.form-row {
|
||||
display: grid; grid-template-columns: 180px 1fr;
|
||||
gap: 24px; padding: 14px 0; align-items: start;
|
||||
|
||||
Reference in New Issue
Block a user