chore: sanitize tests, add CONTEXT.md + ADRs, track tests/docs

This commit is contained in:
tommy0103
2026-07-08 16:11:12 +08:00
parent d5d5df46fa
commit 1a34245618
19 changed files with 3880 additions and 31 deletions
+1 -2
View File
@@ -1,9 +1,8 @@
.DS_Store
plans/
.skillopt-backups
tests/
node_modules/
dist-renderer/
release/
docs/
.dev.docs
.claude/
+82
View File
@@ -0,0 +1,82 @@
# Obelisk
Obelisk is explicit memory infrastructure for coding agents: it indexes local
Claude Code and Codex transcripts into a queryable SQLite evidence layer, and a
CodeAct runtime lets an agent write a small query, run it, and answer from real
session history. This glossary pins the terms that are specific to Obelisk; it is
not a spec.
## Runtime interface
**Runtime interface**:
The public contract, expressed as four verbs — `build`, `search(text)`,
`query(code)`, `attune(code)`. Skill, CLI, and MCP are transports over this same
shape; none of them add their own retrieval surface.
_Avoid_: API, tool surface
**CodeAct**:
The interaction style where an agent writes JavaScript that runs inside the
`query(code)` sandbox and returns JSON, rather than calling many fine-grained
tools. This is Obelisk's core design choice.
_Avoid_: tool-calling, function-calling
**Helper**:
A convenience accessor available only inside the `query(code)` sandbox
(`overview`, `search`, `context`, `sql`, `memories`, …). Helpers are never
promoted to an external tool surface.
## Indexing
**Parse core**:
The pure `jsonl -> records` transform. Given a transcript file and a start line,
it yields normalized index records. It does not open, own, or write to a
database, and is shared verbatim by every indexing mode. This is the layer that
must never be duplicated.
_Avoid_: parser, ingest
**Record**:
One normalized row destined for the index (session, message, tool call, tool
result, summary, subagent, workflow, …), emitted by the parse core before any
persistence happens.
**Persist layer**:
The thin, binding-specific writer that consumes records from the parse core and
writes them into SQLite inside a transaction. Two persist layers exist and differ
only in binding: `node:sqlite` (skill/CLI) and `better-sqlite3` (app).
_Avoid_: writer, sink, DAO
**Daemon indexing mode**:
Continuous incremental indexing driven by a long-lived process (the desktop app,
later a CLI daemon) that watches transcript directories and keeps the index fresh
as files change.
_Avoid_: watcher mode, live indexing
**Passive pull mode**:
On-demand incremental indexing performed by the skill when there is no active
daemon: an invocation of the runtime brings the index up to date, then answers.
_Avoid_: lazy indexing, on-read indexing
**index_state**:
The bookkeeping table shared by both indexing modes. It records, per transcript
path, the last-seen `mtime` and `lines_processed` (enabling resume-from-line
incremental indexing), plus heartbeat/last-build markers used for daemon
arbitration.
**Daemon arbitration**:
The mechanism by which the passive pull mode detects a fresh daemon (via
`__app_heartbeat__` and `__app_last_successful_build__` markers in `index_state`)
and skips its own indexing. Because a fresh daemon owns writes, the two persist
layers never write concurrently.
## Memory
**Queryable session memory**:
The evidence layer — real sessions, messages, tool calls, subagents, workflows —
that an agent queries on demand. Obelisk deliberately does this instead of
implicit/ambient memory.
_Avoid_: implicit memory, ambient memory, auto-recall
**Approved durable memory**:
Human-approved conclusions persisted as markdown plus a registry record, via
`attune(code)` calling `remember()`/`forget()`. Auditable and revocable.
_Avoid_: long-term memory, vector memory
+202 -29
View File
@@ -18,6 +18,26 @@ const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
// --- Constants ---
const DAY_MS = 86400000;
const NOISE_PROJECT_RE = /^(od-conn-test|[0-9a-f]{6,})/i;
function isNoiseSession(s) {
if (!s.title) return true;
const label = formatProjectLabel(s.project) || '';
return NOISE_PROJECT_RE.test(label);
}
function splitNoise(arr) {
const normal = [], noise = [];
for (const s of arr || []) {
if (isNoiseSession(s)) noise.push(s); else normal.push(s);
}
return { normal, noise, total: normal.length + noise.length };
}
const expandedNoise = reactive({});
function toggleNoise(key) {
expandedNoise[key] = !expandedNoise[key];
}
function localDateStr(d) {
const y = d.getFullYear();
@@ -235,6 +255,26 @@ const daySessions = computed(() => {
};
});
const daySessionsSplit = computed(() => {
if (!daySessions.value) return null;
return {
...daySessions.value,
newWorkspaces: splitNoise(daySessions.value.newWorkspaces),
newSessions: splitNoise(daySessions.value.newSessions),
continued: splitNoise(daySessions.value.continued),
};
});
const monthBlocksSplit = computed(() =>
monthBlocks.value.map((b, bi) => ({
...b,
bi,
newWorkspaces: splitNoise(b.newWorkspaces),
newSessions: splitNoise(b.newSessions),
continued: splitNoise(b.continued),
}))
);
// --- Methods ---
function switchTab(view) {
activeTab.value = view;
@@ -502,61 +542,112 @@ onMounted(async () => {
</div>
<!-- Day sessions panel (from heatmap click) -->
<div class="day-sessions" v-if="daySessions">
<div class="day-sessions-header">{{ daySessions.dateLabel }}<template v-if="daySessions.isEmpty"> no sessions</template></div>
<div class="day-activity-timeline" v-if="!daySessions.isEmpty">
<div class="day-sessions" v-if="daySessionsSplit">
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> no sessions</template></div>
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
<!-- New workspaces -->
<div class="activity-group" v-if="daySessions.newWorkspaces.length">
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</span>
<span class="activity-group-title">Created {{ daySessions.newWorkspaces.length }} new workspace{{ daySessions.newWorkspaces.length > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Created {{ daySessionsSplit.newWorkspaces.total }} new workspace{{ daySessionsSplit.newWorkspaces.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessions.newWorkspaces"
v-for="s in daySessionsSplit.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-workspaces'] }" @click="toggleNoise('day-workspaces')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-workspaces']">
<button
v-for="s in daySessionsSplit.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- New sessions -->
<div class="activity-group" v-if="daySessions.newSessions.length">
<div class="activity-group" v-if="daySessionsSplit.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ daySessions.newSessions.length }} session{{ daySessions.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(daySessions.newSessions) }} project{{ newSessionProjectCount(daySessions.newSessions) > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Started {{ daySessionsSplit.newSessions.total }} session{{ daySessionsSplit.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) }} project{{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessions.newSessions"
v-for="s in daySessionsSplit.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-sessions'] }" @click="toggleNoise('day-sessions')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-sessions']">
<button
v-for="s in daySessionsSplit.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- Continued sessions -->
<div class="activity-group continued" v-if="daySessions.continued.length">
<div class="activity-group continued" v-if="daySessionsSplit.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ daySessions.continued.length }} session{{ daySessions.continued.length > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Continued {{ daySessionsSplit.continued.total }} session{{ daySessionsSplit.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessions.continued"
v-for="s in daySessionsSplit.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-continued'] }" @click="toggleNoise('day-continued')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-continued']">
<button
v-for="s in daySessionsSplit.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div>
@@ -564,58 +655,109 @@ onMounted(async () => {
<!-- Monthly activity blocks -->
<div class="day-sessions" v-if="!selectedDayKey">
<template v-for="(block, bi) in monthBlocks" :key="bi">
<template v-for="(block, bi) in monthBlocksSplit" :key="bi">
<div class="day-sessions-header">{{ block.header }}</div>
<div class="day-activity-timeline" v-if="!block.isEmpty">
<div class="activity-group" v-if="block.newWorkspaces.length">
<div class="activity-group" v-if="block.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</span>
<span class="activity-group-title">Created {{ block.newWorkspaces.length }} new workspace{{ block.newWorkspaces.length > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Created {{ block.newWorkspaces.total }} new workspace{{ block.newWorkspaces.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.newWorkspaces"
v-for="s in block.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-workspaces`] }" @click="toggleNoise(`m${bi}-workspaces`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-workspaces`]">
<button
v-for="s in block.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group" v-if="block.newSessions.length">
<div class="activity-group" v-if="block.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ block.newSessions.length }} session{{ block.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(block.newSessions) }} project{{ newSessionProjectCount(block.newSessions) > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Started {{ block.newSessions.total }} session{{ block.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) }} project{{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.newSessions"
v-for="s in block.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-sessions`] }" @click="toggleNoise(`m${bi}-sessions`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-sessions`]">
<button
v-for="s in block.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group continued" v-if="block.continued.length">
<div class="activity-group continued" v-if="block.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ block.continued.length }} session{{ block.continued.length > 1 ? 's' : '' }}</span>
<span class="activity-group-title">Continued {{ block.continued.total }} session{{ block.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.continued"
v-for="s in block.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-continued`] }" @click="toggleNoise(`m${bi}-continued`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-continued`]">
<button
v-for="s in block.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div>
@@ -763,10 +905,41 @@ onMounted(async () => {
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
.activity-item-meta .src-tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; letter-spacing: 0.02em;
color: var(--muted);
}
.activity-item-meta .src-tag .src-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.activity-item-meta .src-tag.claude .src-dot { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
.activity-item-meta .src-tag.codex .src-dot { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
.activity-item-meta .src-tag.claude { color: #d97757; }
.activity-item-meta .src-tag.codex { color: #10a37f; }
.activity-group.continued .activity-item-name { color: var(--fg-2); }
.noise-fold-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; margin-top: 2px;
border-radius: 5px; background: transparent; border: 0;
cursor: pointer; transition: background 0.08s;
text-align: left; width: 100%; font: inherit;
color: var(--muted); font-size: 11.5px;
font-family: var(--font-mono);
}
.noise-fold-row:hover { background: var(--surface-strong); color: var(--fg-2); }
.noise-fold-row .chev {
width: 9px; height: 9px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.noise-fold-row.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
.activity-item.noise .activity-item-name {
color: var(--fg-2); font-style: italic; font-weight: 400;
}
.activity-item.noise .activity-item-project { color: var(--muted); }
.show-more-btn {
display: block; width: 100%; margin-top: 20px;
padding: 8px; border-radius: 4px;
@@ -0,0 +1,31 @@
# Indexing splits into a shared parse core and binding-specific persist layers
**Context.** Obelisk had two divergent full indexers — `scripts/indexer.mjs`
(791 lines, `node:sqlite`, for the skill/runtime) and `app/indexer.js` (1170
lines, `better-sqlite3`, for the Electron app) — both parsing the same Claude and
Codex JSONL into the same schema. Keeping two implementations contradicts the
"single source of truth / infra" goal, but forcing one SQLite binding is also
bad: `node:sqlite` is zero-native-dep and ideal for a clone-and-run skill
artifact, while `better-sqlite3` is the battle-tested choice inside Electron.
**Decision.** Unify at the logic layer, not the binding layer. Extract a pure
**parse core** (`jsonl -> records`) with no database dependency, and keep two
thin **persist layers** (`node:sqlite` for skill/CLI, `better-sqlite3` for the
app) that consume the same records. The parse core is a **streaming iterator**
(`parseJsonl(file, fromLine)` yielding records), not a batched `records[]`, to
preserve the existing memory-friendly line-by-line indexing and the
`lines_processed` resume-from-line semantics recorded in `index_state`.
**Two indexing modes** share this parse core and differ only in trigger:
**daemon indexing mode** (app/CLI watches and keeps the index fresh) and
**passive pull mode** (skill indexes on invocation when no daemon is active).
They never write concurrently because the passive mode detects a fresh daemon via
heartbeat markers in `index_state` (**daemon arbitration**) and skips its own
indexing.
**Consequences.** Golden tests anchor on the parse core: feed a fixture JSONL,
assert the yielded record sequence — independent of binding. The refactor's real
work is disentangling parse from persist inside the current indexers, where
prepared-statement writes are today interleaved into the parse loop. The app's
richer incremental-discovery logic must be folded *into* the shared parse core,
not dropped.
@@ -0,0 +1,26 @@
# The runtime contract is two-tier, with api-reference.md authoritative
**Context.** Before the TypeScript migration and module extraction, we need to
pin what "the contract" is so refactoring cannot silently change observable
behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry
surface; agents actually depend on the *return shapes* of the sandbox helpers
(`search`, `overview`, `memories`, …), which are already documented in
`references/api-reference.md` and relied on by every example in
`references/query-patterns.md`. Current behavior is good and there is no reason to
change it during migration.
**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden
tests):** the four-verb CLI I/O envelope (file/args → pretty JSON on stdout,
`{error, stack}` error envelope, exit codes) and the sandbox contract (`sql()`
read-only enforcement, `attune` exposing only `remember`/`forget`, the set of
globals/helpers available inside `query`/`attune`). **Tier 2 (locked to
api-reference.md):** each helper's documented return shape — not frozen forever,
but never allowed to drift silently; contract tests assert the live shape matches
`references/api-reference.md`, so changing a helper forces a doc change plus a
deliberate version bump. `references/api-reference.md` is therefore promoted from
description to authoritative contract, and Phase 1 becomes "make it authoritative
and enforce it," not "write a new contract doc."
**Consequences.** Behavior is preserved across the TS/module refactor by
construction: the golden and contract tests fail if any observable shape moves.
The cost is that helper shapes can no longer be reshaped casually mid-migration.
@@ -0,0 +1,22 @@
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript
**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill
runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while
the skill artifact must install with **zero build step** on the user's machine
(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra
its checkable contracts, but raises how the compiled output is shipped and which
module format it targets.
**Decision.** Author all of Core in TypeScript and compile it ahead-of-time to
**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled*
ESM JS, so installing the skill never runs a build. Rather than have Core
dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it
can `import` the same compiled Core. TypeScript source is the single source of
truth; the build step lives in the main repo (`build:skill`), never on the user's
machine.
**Consequences.** A one-time ESM migration of the Electron main process (Phase 5),
in exchange for no dual-build maintenance and a single module format across skill,
CLI, and app. The shipped skill artifact contains compiled JS, not TS. The
renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only
adds root tooling (package.json, tsconfig, ESLint); it does not touch the app.
@@ -0,0 +1,22 @@
# The skill artifact ships readable compiled JS, deliberately not bundled
**Context.** Obelisk reads a user's entire local Claude Code and Codex history,
so auditability is the foundation of trust: before a user lets the skill loose on
their data, they must be able to read what it does. The obvious way to shrink a
clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`,
but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The
"don't drag the whole repo into the user's skills dir" concern is real but
separate — it is solved by shipping *only Core*, not by bundling.
**Decision.** The skill artifact ships **readable, non-bundled, non-minified**
compiled JavaScript emitted straight from `tsc` (module structure and comments
preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and
`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and
`tests/`, which is what keeps it small. Bundling into one file is deliberately
rejected: it trades auditability for marginal size, the wrong trade for a
history-reading tool. The public TS source in the main repo allows cross-checking.
**Consequences.** The installed skill is a few readable files rather than one
blob; a future contributor may be tempted to "optimize" by bundling — this ADR
records that the un-bundled form is intentional. Small artifact size comes from
scoping the artifact to Core, handled by `build:skill`, not from a bundler.
+261
View File
@@ -0,0 +1,261 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
const { createIndexerService } = require('../app/indexer-service.js');
function manualTimers() {
const timers = new Set();
return {
setTimeout(fn) {
timers.add(fn);
return fn;
},
clearTimeout(fn) {
timers.delete(fn);
},
flush() {
const pending = [...timers];
timers.clear();
for (const fn of pending) fn();
},
};
}
test('indexer service debounces repeated build requests', async () => {
const timers = manualTimers();
const calls = [];
const service = createIndexerService({
buildIndex: async ({ reason }) => calls.push(reason),
watchProjects: () => null,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
});
service.scheduleBuild('first');
service.scheduleBuild('second');
service.scheduleBuild('third');
timers.flush();
await service.idle();
assert.deepEqual(calls, ['third']);
});
test('indexer service runs one pending build after an in-flight build finishes', async () => {
const timers = manualTimers();
const calls = [];
let finishFirst;
const service = createIndexerService({
buildIndex: async ({ reason }) => {
calls.push(reason);
if (reason === 'first') await new Promise(resolve => { finishFirst = resolve; });
},
watchProjects: () => null,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
});
const first = service.runBuildNow('first');
service.scheduleBuild('second');
timers.flush();
assert.deepEqual(calls, ['first']);
finishFirst();
await first;
await service.idle();
assert.deepEqual(calls, ['first', 'pending']);
});
test('indexer service waits for a stability window before building', async () => {
const timers = manualTimers();
const calls = [];
const service = createIndexerService({
buildIndex: async ({ reason }) => calls.push(reason),
watchProjects: () => null,
writeHeartbeat: () => {},
timers,
stabilityMs: 500,
});
service.scheduleBuild('jsonl-change');
timers.flush();
await service.idle();
assert.deepEqual(calls, []);
timers.flush();
await service.idle();
assert.deepEqual(calls, ['jsonl-change']);
});
test('indexer service retries watcher setup when the projects directory is missing', () => {
const timers = manualTimers();
let attempts = 0;
const service = createIndexerService({
buildIndex: async () => {},
watchProjects: () => {
attempts++;
return attempts === 1 ? null : { close() {} };
},
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
});
service.start({ buildOnStart: false });
assert.equal(attempts, 1);
timers.flush();
assert.equal(attempts, 2);
timers.flush();
assert.equal(attempts, 2);
});
test('indexer service watches Claude JSON files through chokidar', async () => {
const projectsDir = mkdtempSync(join(tmpdir(), 'obelisk-chokidar-projects-'));
const timers = manualTimers();
const calls = [];
let watchArgs = null;
const handlers = {};
const watcher = {
on(event, handler) {
handlers[event] = handler;
return watcher;
},
closeCalled: false,
close() {
watcher.closeCalled = true;
},
};
const chokidar = {
watch(paths, options) {
watchArgs = { paths, options };
return watcher;
},
};
const service = createIndexerService({
projectsDir,
buildIndex: async ({ reason }) => calls.push(reason),
chokidar,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
debounceMs: 0,
});
try {
service.start({ buildOnStart: false });
assert.equal(watchArgs.paths, projectsDir);
assert.equal(watchArgs.options.cwd, projectsDir);
assert.equal(watchArgs.options.ignoreInitial, true);
assert.ok(watchArgs.options.awaitWriteFinish);
handlers.change('session.jsonl');
timers.flush();
await service.idle();
assert.deepEqual(calls, ['watch']);
} finally {
service.stop();
}
assert.equal(watcher.closeCalled, true);
});
test('indexer service passes changed JSONL paths to the build worker', async () => {
const projectsDir = mkdtempSync(join(tmpdir(), 'obelisk-changed-paths-'));
const timers = manualTimers();
const calls = [];
const handlers = {};
const watcher = {
on(event, handler) {
handlers[event] = handler;
return watcher;
},
close() {},
};
const chokidar = {
watch() {
return watcher;
},
};
const service = createIndexerService({
projectsDir,
buildIndex: async (args) => calls.push(args),
chokidar,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
debounceMs: 0,
});
service.start({ buildOnStart: false });
handlers.change('project-a/session-1.jsonl');
handlers.add('project-a/session-2.json');
timers.flush();
await service.idle();
assert.equal(calls.length, 1);
assert.equal(calls[0].reason, 'watch');
assert.deepEqual(calls[0].changedPaths, [
'project-a/session-1.jsonl',
'project-a/session-2.json',
]);
});
test('indexer service watches Claude projects and Codex sessions for app-side indexing', async () => {
const claudeProjectsDir = mkdtempSync(join(tmpdir(), 'obelisk-watch-claude-'));
const codexSessionsDir = mkdtempSync(join(tmpdir(), 'obelisk-watch-codex-sessions-'));
const timers = manualTimers();
const calls = [];
const watchers = [];
const watchArgs = [];
const chokidar = {
watch(paths, options) {
const handlers = {};
const watcher = {
handlers,
on(event, handler) {
handlers[event] = handler;
return watcher;
},
close() {},
};
watchers.push(watcher);
watchArgs.push({ paths, options });
return watcher;
},
};
const service = createIndexerService({
projectsDir: claudeProjectsDir,
watchDirs: [claudeProjectsDir, codexSessionsDir],
buildIndex: async (args) => calls.push(args),
chokidar,
writeHeartbeat: () => {},
timers,
stabilityMs: 0,
debounceMs: 0,
});
service.start({ buildOnStart: false });
assert.deepEqual(watchArgs.map(arg => arg.paths), [claudeProjectsDir, codexSessionsDir]);
assert.deepEqual(watchArgs.map(arg => arg.options.cwd), [claudeProjectsDir, codexSessionsDir]);
watchers[1].handlers.change('2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl');
timers.flush();
await service.idle();
assert.equal(calls.length, 1);
assert.deepEqual(calls[0].changedPaths, [
'2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl',
]);
});
+74
View File
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { createWorkerBuildIndex } = require('../app/indexer-worker-client.js');
test('worker build client resolves build results from a worker thread', async () => {
const instances = [];
class MockWorker {
constructor(workerPath) {
this.workerPath = workerPath;
this.handlers = {};
this.messages = [];
this.terminated = false;
instances.push(this);
}
on(event, handler) {
this.handlers[event] = handler;
return this;
}
postMessage(message) {
this.messages.push(message);
queueMicrotask(() => {
this.handlers.message({
id: message.id,
result: { files: 1, reason: message.args.reason },
});
});
}
terminate() {
this.terminated = true;
}
}
const client = createWorkerBuildIndex({ WorkerImpl: MockWorker, workerPath: '/tmp/indexer-worker.js' });
const result = await client.buildIndex({ reason: 'watch' });
assert.equal(instances.length, 1);
assert.equal(instances[0].workerPath, '/tmp/indexer-worker.js');
assert.deepEqual(result, { files: 1, reason: 'watch' });
client.stop();
assert.equal(instances[0].terminated, true);
});
test('worker build client rejects pending builds when worker exits cleanly', async () => {
class MockWorker {
constructor() {
this.handlers = {};
}
on(event, handler) {
this.handlers[event] = handler;
return this;
}
postMessage() {
queueMicrotask(() => {
this.handlers.exit(0);
});
}
}
const client = createWorkerBuildIndex({ WorkerImpl: MockWorker, workerPath: '/tmp/indexer-worker.js' });
await assert.rejects(
client.buildIndex({ reason: 'startup' }),
/Indexer worker exited with code 0/,
);
});
+741
View File
@@ -0,0 +1,741 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { appendFileSync, mkdtempSync, mkdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
const { buildIndex } = require('../app/indexer.js');
const { DatabaseSync } = require('node:sqlite');
class TestDatabase {
constructor(dbPath) {
this.db = new DatabaseSync(dbPath);
}
pragma(statement) {
this.db.exec(`PRAGMA ${statement}`);
}
exec(sql) {
return this.db.exec(sql);
}
prepare(sql) {
return this.db.prepare(sql);
}
close() {
return this.db.close();
}
}
test('app indexer builds the Obelisk database from Claude JSONL and records app heartbeat', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-app-1';
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-app-1',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: { role: 'user', content: [{ type: 'text', text: 'hello from app indexer' }] },
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
const firstBuild = buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-app-1').text, 'hello from app indexer');
assert.deepEqual(firstBuild.affectedSessionIds, [sessionId]);
assert.equal(firstBuild.ftsRebuilt, true);
assert.equal(db.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'hello'").get().uuid, 'msg-app-1');
assert.equal(db.prepare("SELECT jsonl_path FROM index_state WHERE jsonl_path='__app_heartbeat__'").get().jsonl_path, '__app_heartbeat__');
assert.equal(db.prepare("SELECT jsonl_path FROM index_state WHERE jsonl_path='__app_last_successful_build__'").get().jsonl_path, '__app_last_successful_build__');
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId).project_path, '/tmp/obelisk-app');
db.close();
appendFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-app-2',
type: 'assistant',
timestamp: '2026-06-13T10:01:00Z',
message: { role: 'assistant', content: [{ type: 'text', text: 'companion view updated quickly' }] },
}),
'',
].join('\n'));
const nextMtime = new Date(Date.now() + 2000);
utimesSync(jsonlPath, nextMtime, nextMtime);
const secondBuild = buildIndex({
claudeDir,
dbPath,
DatabaseImpl: TestDatabase,
changedPaths: [`-tmp-obelisk-app/${sessionId}.jsonl`],
});
assert.deepEqual(secondBuild.affectedSessionIds, [sessionId]);
assert.equal(secondBuild.ftsRebuilt, false);
const db2 = new TestDatabase(dbPath);
assert.equal(db2.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'companion'").get().uuid, 'msg-app-2');
assert.equal(db2.prepare('SELECT message_count FROM sessions WHERE id=?').get(sessionId).message_count, 2);
db2.close();
});
test('force rebuild ignores stale JSONL index_state rows after session tables were cleared', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-force-1';
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-force-1',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should not trust stale state' }] },
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const broken = new TestDatabase(dbPath);
assert.equal(broken.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path = ?').get(jsonlPath).c, 1);
broken.prepare('DELETE FROM messages').run();
broken.prepare('DELETE FROM sessions').run();
broken.close();
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase, force: true });
const db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions').get().c, 1);
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-1').text, 'force rebuild should not trust stale state');
db.close();
});
test('force rebuild bypasses stale message FTS delete triggers', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-fts-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-force-fts-1';
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-force-fts-1',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should bulk clear without old fts deletes' }] },
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const trapped = new TestDatabase(dbPath);
trapped.exec('DROP TRIGGER messages_fts_ad');
trapped.exec(`
CREATE TRIGGER messages_fts_ad AFTER DELETE ON messages BEGIN
SELECT RAISE(FAIL, 'message FTS delete trigger fired during force rebuild');
END;
`);
trapped.close();
assert.doesNotThrow(() => {
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase, force: true });
});
const db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-fts-1').text, 'force rebuild should bulk clear without old fts deletes');
assert.equal(db.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'bulk'").get().uuid, 'msg-force-fts-1');
const trigger = db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='messages_fts_ad'").get();
assert.match(trigger.sql, /INSERT INTO messages_fts/);
db.close();
});
test('force rebuild into a new database preserves existing memories', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-preserve-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-force-preserve-1';
writeFileSync(join(projectDir, `${sessionId}.jsonl`), [
JSON.stringify({
uuid: 'msg-force-preserve-1',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should preserve memories' }] },
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const tempDbPath = join(home, '.obelisk', 'obelisk.rebuild.tmp');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const sourceDb = new TestDatabase(dbPath);
sourceDb.prepare(`
INSERT INTO memories (id,session_id,project,message_start,message_end,path,anchors,summary,created_at,deleted_at,deleted_reason)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
'mem-preserve-1',
sessionId,
'-tmp-obelisk-app',
'msg-force-preserve-1',
'msg-force-preserve-1',
'/tmp/obelisk-app/notes.md',
'[]',
'Decision: preserve memories when rebuilding the session index.',
'2026-06-13T10:05:00Z',
null,
null,
);
sourceDb.close();
buildIndex({
claudeDir,
dbPath: tempDbPath,
DatabaseImpl: TestDatabase,
force: true,
preserveDbPath: dbPath,
});
const db = new TestDatabase(tempDbPath);
const memory = db.prepare('SELECT id, summary FROM memories WHERE id=?').get('mem-preserve-1');
assert.equal(memory.summary, 'Decision: preserve memories when rebuilding the session index.');
assert.equal(db.prepare("SELECT id FROM memories_fts WHERE memories_fts MATCH 'preserve'").get().id, 'mem-preserve-1');
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-preserve-1').text, 'force rebuild should preserve memories');
db.close();
});
test('app indexer reports changed workflow JSON as an affected session', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-workflow-'));
const claudeDir = join(home, '.claude');
const project = '-tmp-obelisk-app';
const sessionId = 'session-workflow-1';
const projectDir = join(claudeDir, 'projects', project);
const workflowDir = join(projectDir, sessionId, 'workflows');
mkdirSync(workflowDir, { recursive: true });
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-workflow-1',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
cwd: '/tmp/obelisk-app',
message: { role: 'user', content: [{ type: 'text', text: 'start workflow' }] },
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const workflowPath = join(workflowDir, 'run-1.json');
writeFileSync(workflowPath, JSON.stringify({
runId: 'run-1',
timestamp: '2026-06-13T10:01:00Z',
workflowName: 'review',
}));
const result = buildIndex({
claudeDir,
dbPath,
DatabaseImpl: TestDatabase,
changedPaths: [`${project}/${sessionId}/workflows/run-1.json`],
});
assert.deepEqual(result.affectedSessionIds, [sessionId]);
});
test('app indexer marks UI-fallback control messages as meta at ingest time', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-meta-'));
const claudeDir = join(home, '.claude');
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
mkdirSync(projectDir, { recursive: true });
const sessionId = 'session-meta-1';
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
uuid: 'msg-meta-system-reminder',
type: 'user',
timestamp: '2026-06-13T10:00:00Z',
message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>Keep answers concise</system-reminder>' }] },
}),
JSON.stringify({
uuid: 'msg-meta-local-command',
type: 'user',
timestamp: '2026-06-13T10:01:00Z',
message: { role: 'user', content: [{ type: 'text', text: '<local-command>git status</local-command>' }] },
}),
JSON.stringify({
uuid: 'msg-meta-normal',
type: 'user',
timestamp: '2026-06-13T10:02:00Z',
message: { role: 'user', content: [{ type: 'text', text: 'normal user request' }] },
}),
'',
].join('\n'));
const dbPath = join(claudeDir, 'obelisk.sqlite');
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-system-reminder').is_meta, 1);
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-local-command').is_meta, 1);
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-normal').is_meta, 0);
db.close();
});
test('app indexer loads Codex root sessions into the shared schema', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: codexId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
originator: 'Codex Desktop',
source: 'vscode',
thread_source: 'user',
git: { branch: 'feat/codex' },
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'codex user asks for indexing', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:01.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: 'codex assistant replies' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:02.000Z',
type: 'response_item',
payload: { type: 'function_call', call_id: 'call_codex_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:03.000Z',
type: 'response_item',
payload: { type: 'function_call_output', call_id: 'call_codex_1', output: '/tmp/obelisk-app' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:04.000Z',
type: 'event_msg',
payload: {
type: 'task_complete',
completed_at: 1781454004,
duration_ms: 4321,
turn_id: 'turn-1',
last_agent_message: 'codex assistant replies',
},
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const result = buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(`codex:${codexId}`);
assert.equal(session.source, 'codex');
assert.equal(session.project, '-tmp-obelisk-app');
assert.equal(session.project_path, '/tmp/obelisk-app');
assert.equal(session.git_branch, 'feat/codex');
assert.equal(session.version, '0.135.0-alpha.1');
assert.equal(session.message_count, 3);
assert.deepEqual(result.affectedSessionIds, [`codex:${codexId}`]);
const messages = db.prepare('SELECT role, text, source, content_type, turn_duration_ms FROM messages WHERE session_id=? ORDER BY timestamp, uuid').all(`codex:${codexId}`);
assert.deepEqual(messages.map(m => [m.role, m.text, m.source, m.content_type]), [
['user', 'codex user asks for indexing', 'codex', 'text'],
['assistant', 'codex assistant replies', 'codex', 'text'],
['assistant', null, 'codex', 'tool_use'],
]);
assert.equal(messages[1].turn_duration_ms, 4321);
const tool = db.prepare('SELECT * FROM tool_calls WHERE id=?').get('codex:call_codex_1');
assert.equal(tool.session_id, `codex:${codexId}`);
assert.equal(tool.name, 'exec_command');
assert.equal(tool.message_uuid, `codex:${codexId}:000004`);
const toolResult = db.prepare('SELECT message_uuid, content FROM tool_results WHERE tool_use_id=?').get('codex:call_codex_1');
assert.equal(toolResult.message_uuid, `codex:${codexId}:000004`);
assert.equal(toolResult.content, '/tmp/obelisk-app');
db.close();
});
test('app indexer accepts Codex changed paths relative to the sessions directory', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-sessions-change-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
const filename = `rollout-2026-06-15T00-19-59-${codexId}.jsonl`;
writeFileSync(join(codexSessionDir, filename), [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: codexId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
thread_source: 'user',
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'sessions-relative codex change', images: [], local_images: [], text_elements: [] },
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const result = buildIndex({
claudeDir,
codexDir,
dbPath,
DatabaseImpl: TestDatabase,
changedPaths: [join('2026', '06', '15', filename)],
});
const db = new TestDatabase(dbPath);
assert.deepEqual(result.affectedSessionIds, [`codex:${codexId}`]);
assert.equal(
db.prepare('SELECT text FROM messages WHERE session_id=?').get(`codex:${codexId}`).text,
'sessions-relative codex change',
);
db.close();
});
test('app indexer uses Codex response_item messages only when no visible event message exists', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-response-message-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: codexId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
source: 'vscode',
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'developer context should not be indexed' }],
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'assistant fallback message' }],
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:02.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: 'assistant event message' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:03.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
phase: 'final_answer',
content: [{ type: 'output_text', text: 'assistant event message' }],
},
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
const rows = db.prepare('SELECT text FROM messages WHERE session_id=? ORDER BY timestamp, uuid').all(`codex:${codexId}`);
assert.deepEqual(rows.map(row => row.text), [
'assistant fallback message',
'assistant event message',
]);
db.close();
});
test('app indexer skips Codex guardian review threads', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-guardian-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T18:12:00.000Z',
type: 'session_meta',
payload: {
id: guardianId,
timestamp: '2026-06-14T18:12:00.000Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
thread_source: 'subagent',
source: { subagent: { other: 'guardian' } },
},
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:01.000Z',
type: 'turn_context',
payload: { cwd: '/tmp/obelisk-app', model: 'codex-auto-review' },
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:02.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'approval guardian prompt', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:03.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: '{"outcome":"allow"}' },
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
const result = buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
assert.deepEqual(result.affectedSessionIds, []);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(`codex:${guardianId}`).c, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id=?').get(`codex:${guardianId}`).c, 0);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?').get(`codex:${guardianId}`).c, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'approval'").get().c, 0);
db.close();
});
test('app indexer removes stale Codex guardian rows when the JSONL was already indexed', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-guardian-stale-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
const guardianSessionId = `codex:${guardianId}`;
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
timestamp: '2026-06-14T18:12:00.000Z',
type: 'session_meta',
payload: {
id: guardianId,
timestamp: '2026-06-14T18:12:00.000Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
thread_source: 'subagent',
source: { subagent: { other: 'guardian' } },
},
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:01.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'stale approval guardian prompt', images: [], local_images: [], text_elements: [] },
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
buildIndex({ claudeDir, codexDir: join(home, 'empty-codex'), dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
db.prepare('INSERT INTO sessions (id,jsonl_path,source,message_count) VALUES (?,?,?,?)').run(guardianSessionId, jsonlPath, 'codex', 1);
db.prepare('INSERT INTO messages (uuid,session_id,type,timestamp,role,text,content_type,source) VALUES (?,?,?,?,?,?,?,?)')
.run(`${guardianSessionId}:000002`, guardianSessionId, 'user', '2026-06-14T18:12:01.000Z', 'user', 'stale approval guardian prompt', 'text', 'codex');
db.prepare('INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)')
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'exec_command', '{}', null);
db.prepare('INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)')
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'ok', null, 0);
db.prepare('INSERT INTO subagents (agent_id,session_id) VALUES (?,?)').run(guardianSessionId, guardianSessionId);
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)')
.run(jsonlPath, statSync(jsonlPath).mtimeMs, 2);
db.close();
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
const cleanedDb = new TestDatabase(dbPath);
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(guardianSessionId).c, 0);
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id=?').get(guardianSessionId).c, 0);
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM tool_calls WHERE session_id=?').get(guardianSessionId).c, 0);
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM tool_results WHERE session_id=?').get(guardianSessionId).c, 0);
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?').get(guardianSessionId).c, 0);
assert.equal(cleanedDb.prepare("SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'stale'").get().c, 0);
cleanedDb.close();
});
test('app indexer maps Codex subagent threads onto parent sessions', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-subagent-'));
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(codexSessionDir, { recursive: true });
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${parentId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: parentId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
source: 'vscode',
git: { branch: 'feat/codex' },
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'event_msg',
payload: {
type: 'collab_agent_spawn_end',
call_id: 'call_spawn_1',
sender_thread_id: parentId,
new_thread_id: childId,
new_agent_nickname: 'Plato',
new_agent_role: 'worker',
model: 'gpt-5.5',
reasoning_effort: 'xhigh',
prompt: 'inspect app-side codex indexing',
status: 'pending_init',
},
}),
'',
].join('\n'));
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T01-41-42-${childId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T17:41:42.924Z',
type: 'session_meta',
payload: {
id: childId,
timestamp: '2026-06-14T17:41:42.924Z',
cwd: '/tmp/obelisk-app',
cli_version: '0.135.0-alpha.1',
source: {
subagent: {
thread_spawn: {
parent_thread_id: parentId,
depth: 1,
agent_nickname: 'Plato',
agent_role: 'worker',
},
},
},
agent_nickname: 'Plato',
agent_role: 'worker',
},
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:43.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'subagent prompt', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:44.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: 'subagent answer' },
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:45.000Z',
type: 'response_item',
payload: { type: 'function_call', call_id: 'call_child_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:46.000Z',
type: 'response_item',
payload: { type: 'function_call_output', call_id: 'call_child_1', output: '/tmp/obelisk-app' },
}),
'',
].join('\n'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
const db = new TestDatabase(dbPath);
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(`codex:${childId}`).c, 0);
const subagent = db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(`codex:${childId}`);
assert.equal(subagent.session_id, `codex:${parentId}`);
assert.equal(subagent.parent_tool_use_id, 'codex:call_spawn_1');
assert.equal(subagent.agent_type, 'worker');
assert.equal(subagent.description, 'Plato');
const spawnMessage = db.prepare('SELECT * FROM messages WHERE uuid=?').get(`codex:${parentId}:000002`);
assert.equal(spawnMessage.session_id, `codex:${parentId}`);
assert.equal(spawnMessage.content_type, 'tool_use');
assert.equal(spawnMessage.source, 'codex');
assert.equal(db.prepare('SELECT name, message_uuid FROM tool_calls WHERE id=?').get('codex:call_spawn_1').message_uuid, spawnMessage.uuid);
const childMessages = db.prepare('SELECT session_id, agent_id, is_sidechain, source, text FROM messages WHERE agent_id=? ORDER BY timestamp, uuid').all(`codex:${childId}`);
assert.deepEqual(childMessages.map(m => [m.session_id, m.agent_id, m.is_sidechain, m.source, m.text]), [
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent prompt'],
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent answer'],
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', null],
]);
const childResult = db.prepare(`
SELECT tr.content FROM tool_results tr
JOIN messages m ON m.uuid = tr.message_uuid
WHERE m.agent_id = ?
`).get(`codex:${childId}`);
assert.equal(childResult.content, '/tmp/obelisk-app');
db.close();
});
+984
View File
@@ -0,0 +1,984 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Module from 'node:module';
const require = createRequire(import.meta.url);
async function loadMainForWindowFlags(flags) {
const originalArgv = process.argv;
const originalHome = process.env.HOME;
const originalLoad = Module._load;
const mainPath = require.resolve('../app/main.js');
const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`);
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
delete require.cache[mainPath];
process.env.HOME = home;
process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags];
const windows = [];
class FakeDatabase {
pragma() {}
close() {}
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor(options) {
this.options = options;
this.loadedURL = null;
this.loadedFile = null;
this.devToolsOpened = false;
this.webContents = {
on() {},
setZoomLevel() {},
openDevTools: () => { this.devToolsOpened = true; },
send() {},
};
windows.push(this);
}
loadFile(filePath) { this.loadedFile = filePath; }
loadURL(url) { this.loadedURL = url; return Promise.resolve(); }
close() {}
static getAllWindows() { return windows; }
static fromWebContents() { return null; }
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: { handle() {} },
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() {},
stop() {},
idle: async () => {},
runBuildNow() { return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async () => ({ files: 0, affectedSessionIds: [] }),
stop() {},
}),
};
}
if (request === 'chokidar') {
return { watch: () => ({ on() { return this; }, close() {} }) };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
return windows;
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.argv = originalArgv;
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
}
test('dev mode does not open DevTools unless explicitly requested', async () => {
const packagedWindows = await loadMainForWindowFlags([]);
assert.equal(packagedWindows.length, 1);
assert.equal(packagedWindows[0].loadedURL, null);
assert.equal(packagedWindows[0].options.webPreferences.devTools, false);
assert.equal(packagedWindows[0].devToolsOpened, false);
const devWindows = await loadMainForWindowFlags(['--dev']);
assert.equal(devWindows.length, 1);
assert.equal(devWindows[0].loadedURL, 'http://localhost:5173');
assert.equal(devWindows[0].options.webPreferences.devTools, true);
assert.equal(devWindows[0].devToolsOpened, false);
const devtoolsWindows = await loadMainForWindowFlags(['--dev', '--devtools']);
assert.equal(devtoolsWindows.length, 1);
assert.equal(devtoolsWindows[0].loadedURL, 'http://localhost:5173');
assert.equal(devtoolsWindows[0].devToolsOpened, true);
});
test('main process watches Codex sessions directory instead of Codex root', async () => {
const originalLoad = Module._load;
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`);
const claudeDir = join(home, '.claude');
const codexDir = join(home, '.codex');
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
mkdirSync(join(codexDir, 'sessions'), { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
process.env.HOME = home;
const serviceOptions = [];
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeDatabase {
pragma() {}
close() {}
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() { return []; }
static fromWebContents() { return null; }
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: { handle() {} },
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: (options) => {
serviceOptions.push(options);
return {
start() {},
stop() {},
idle: async () => {},
runBuildNow() { return Promise.resolve(); },
};
},
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async () => ({ files: 0, affectedSessionIds: [] }),
stop() {},
}),
};
}
if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) };
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
assert.equal(serviceOptions.length, 1);
assert.deepEqual(serviceOptions[0].watchDirs, [
join(claudeDir, 'projects'),
join(codexDir, 'sessions'),
]);
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('session IPC hides Codex rows by default and supports explicit source opt-in', async () => {
const originalLoad = Module._load;
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-source-filter-${Date.now()}`);
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
process.env.HOME = home;
const ipcHandlers = new Map();
const queries = [];
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeDatabase {
pragma() {}
close() {}
prepare(sql) {
return {
all: (...params) => {
queries.push({ sql, params });
return [];
},
get: (...params) => {
queries.push({ sql, params });
return null;
},
run: () => ({}),
};
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() { return []; }
static fromWebContents() { return null; }
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() {},
stop() {},
idle: async () => {},
runBuildNow() { return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async () => ({ files: 0, affectedSessionIds: [] }),
stop() {},
}),
};
}
if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) };
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
ipcHandlers.get('db:getSessions')(null, {});
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
ipcHandlers.get('db:getProjects')(null, {});
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
ipcHandlers.get('db:getSessions')(null, { includeCodex: true });
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = \?/);
assert.ok(queries.at(-1).params.includes('codex'));
await ipcHandlers.get('settings:get')();
assert.ok(
queries.some(q => /COUNT\(\*\) as c FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
);
assert.ok(
queries.some(q => /MAX\(started_at\) as t FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
);
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('main process migrates an existing app database before source-filtered IPC queries', async () => {
const originalLoad = Module._load;
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);
const obeliskDir = join(home, '.obelisk');
mkdirSync(obeliskDir, { recursive: true });
process.env.HOME = home;
const { DatabaseSync } = require('node:sqlite');
const dbPath = join(obeliskDir, 'obelisk.sqlite');
const legacy = new DatabaseSync(dbPath);
legacy.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
message_count INTEGER DEFAULT 0, jsonl_path TEXT
);
CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, model TEXT,
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
input_tokens INTEGER, output_tokens INTEGER,
cwd TEXT, skill TEXT, turn_duration_ms INTEGER
);
CREATE TABLE memories (
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
message_start TEXT, message_end TEXT,
path TEXT, anchors TEXT, summary TEXT, created_at TEXT,
deleted_at TEXT, deleted_reason TEXT
);
INSERT INTO sessions (id, title, project, started_at, message_count)
VALUES ('legacy-session', 'Legacy session', 'quiet-zero', '2026-06-10T10:00:00Z', 1);
`);
legacy.close();
const ipcHandlers = new Map();
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() { return []; }
static fromWebContents() { return null; }
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') {
return class SqliteCompatDatabase {
constructor(dbFile) {
this.db = new DatabaseSync(dbFile);
}
pragma(statement) {
this.db.exec(`PRAGMA ${statement}`);
}
exec(sql) {
return this.db.exec(sql);
}
close() {
return this.db.close();
}
prepare(sql) {
const stmt = this.db.prepare(sql);
return {
all: (...params) => stmt.all(...params),
get: (...params) => stmt.get(...params),
run: (...params) => stmt.run(...params),
};
}
};
}
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() {},
stop() {},
idle: async () => {},
runBuildNow() { return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async () => ({ files: 0, affectedSessionIds: [] }),
stop() {},
}),
};
}
if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) };
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
const sessions = ipcHandlers.get('db:getSessions')(null, {});
assert.equal(sessions[0].id, 'legacy-session');
assert.equal(sessions[0].source, 'claude');
assert.deepEqual(ipcHandlers.get('db:getStats')(null, {}), {
sessions: 1,
memories: 0,
memoriesArchived: 0,
});
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('closing the last macOS window releases background resources until activation', async () => {
const originalLoad = Module._load;
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-window-${Date.now()}`);
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
process.env.HOME = home;
Object.defineProperty(process, 'platform', { value: 'darwin' });
const appHandlers = new Map();
const serviceEvents = [];
const workers = [];
const watchers = [];
const windows = [];
let quitCalled = false;
class FakeDatabase {
pragma() {}
close() { serviceEvents.push('db-close'); }
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
windows.push(this);
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() { return windows; }
static fromWebContents() { return null; }
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on(event, handler) { appHandlers.set(event, handler); },
quit() { quitCalled = true; },
},
BrowserWindow: FakeBrowserWindow,
ipcMain: { handle() {} },
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() { serviceEvents.push('service-start'); },
stop() { serviceEvents.push('service-stop'); },
idle: async () => { serviceEvents.push('service-idle'); },
runBuildNow() { serviceEvents.push('service-build'); return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => {
const worker = { stop() { serviceEvents.push('worker-stop'); } };
workers.push(worker);
return worker;
},
};
}
if (request === 'chokidar') {
return {
watch: () => {
const watcher = { on() { return this; }, close() { serviceEvents.push('watcher-close'); } };
watchers.push(watcher);
return watcher;
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
assert.equal(windows.length, 1);
assert.equal(workers.length, 1);
assert.equal(watchers.length, 1);
windows.length = 0;
appHandlers.get('window-all-closed')();
await new Promise(resolve => setImmediate(resolve));
assert.equal(quitCalled, false);
assert.ok(serviceEvents.includes('service-stop'));
assert.ok(serviceEvents.includes('worker-stop'));
assert.ok(serviceEvents.includes('watcher-close'));
assert.ok(serviceEvents.includes('db-close'));
appHandlers.get('activate')();
await new Promise(resolve => setImmediate(resolve));
assert.equal(windows.length, 1);
assert.equal(workers.length, 2);
assert.equal(watchers.length, 2);
assert.equal(serviceEvents.filter(e => e === 'service-start').length, 2);
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform);
rmSync(home, { recursive: true, force: true });
}
});
test('settings rebuild reopens the database from the configured Claude path', async () => {
const home = join(tmpdir(), `obelisk-main-settings-${Date.now()}`);
const defaultClaudeDir = join(home, '.claude');
const customClaudeDir = join(home, 'custom-claude');
const customCodexDir = join(home, 'custom-codex');
mkdirSync(defaultClaudeDir, { recursive: true });
mkdirSync(customClaudeDir, { recursive: true });
mkdirSync(customCodexDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(defaultClaudeDir, 'obelisk.sqlite'), '');
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
claudeDir: customClaudeDir,
codexDir: customCodexDir,
}));
const originalHome = process.env.HOME;
process.env.HOME = home;
const ipcHandlers = new Map();
const openedDbPaths = [];
const buildCalls = [];
const serviceEvents = [];
const originalLoad = Module._load;
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeDatabase {
constructor(dbPath) {
openedDbPaths.push(dbPath);
}
pragma() {}
close() {}
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = {
on() {},
setZoomLevel() {},
openDevTools() {},
send() {},
};
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() {
return [];
}
static fromWebContents() {
return null;
}
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() { serviceEvents.push('start'); },
stop() { serviceEvents.push('stop'); },
idle: async () => { serviceEvents.push('idle'); },
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async (args) => {
serviceEvents.push('build');
buildCalls.push(args);
writeFileSync(args.dbPath, 'rebuilt temp db');
return { files: 2, affectedSessionIds: ['session-1', 'session-2'] };
},
stop() { return Promise.resolve(); },
}),
};
}
if (request === 'chokidar') {
return { watch: () => ({ on() { return this; }, close() {} }) };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
const rebuild = ipcHandlers.get('settings:rebuildIndex');
assert.equal(typeof rebuild, 'function');
await rebuild();
assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir);
assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects'));
assert.equal(buildCalls.at(-1).codexDir, customCodexDir);
assert.notEqual(buildCalls.at(-1).dbPath, join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(buildCalls.at(-1).preserveDbPath, join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(openedDbPaths.at(-1), join(home, '.obelisk', 'obelisk.sqlite'));
assert.equal(
require('node:fs').readFileSync(join(home, '.obelisk', 'obelisk.sqlite'), 'utf8'),
'rebuilt temp db',
);
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('settings rebuild keeps the existing database after a worker failure', async () => {
const home = join(tmpdir(), `obelisk-main-settings-rebuild-failure-${Date.now()}`);
const customClaudeDir = join(home, 'custom-claude');
const customCodexDir = join(home, 'custom-codex');
mkdirSync(customClaudeDir, { recursive: true });
mkdirSync(customCodexDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
claudeDir: customClaudeDir,
codexDir: customCodexDir,
}));
const originalHome = process.env.HOME;
process.env.HOME = home;
const ipcHandlers = new Map();
const openedDbPaths = [];
const closedDbPaths = [];
const serviceEvents = [];
const originalLoad = Module._load;
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeDatabase {
constructor(dbPath) {
this.dbPath = dbPath;
openedDbPaths.push(dbPath);
}
pragma() {}
close() { closedDbPaths.push(this.dbPath); }
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = {
on() {},
setZoomLevel() {},
openDevTools() {},
send() {},
};
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() {
return [];
}
static fromWebContents() {
return null;
}
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() { serviceEvents.push('start'); },
stop() { serviceEvents.push('stop'); },
idle: async () => { serviceEvents.push('idle'); },
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
return {
createWorkerBuildIndex: () => ({
buildIndex: async () => {
serviceEvents.push('build');
throw new Error('worker exploded');
},
stop() {},
}),
};
}
if (request === 'chokidar') {
return { watch: () => ({ on() { return this; }, close() {} }) };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
const rebuild = ipcHandlers.get('settings:rebuildIndex');
const openCountBeforeRebuild = openedDbPaths.length;
await assert.rejects(() => rebuild(), /worker exploded/);
const expectedDbPath = join(home, '.obelisk', 'obelisk.sqlite');
assert.equal(openedDbPaths.at(-1), expectedDbPath);
assert.equal(openedDbPaths.length, openCountBeforeRebuild);
assert.equal(closedDbPaths.includes(expectedDbPath), false);
assert.equal(
require('node:fs').readFileSync(expectedDbPath, 'utf8'),
'legacy custom db',
);
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
assert.ok(serviceEvents.lastIndexOf('start') > serviceEvents.indexOf('build'));
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('settings rebuild cancels an in-flight background build instead of waiting for it', async () => {
const home = join(tmpdir(), `obelisk-main-settings-rebuild-cancel-${Date.now()}`);
const customClaudeDir = join(home, 'custom-claude');
const customCodexDir = join(home, 'custom-codex');
mkdirSync(customClaudeDir, { recursive: true });
mkdirSync(customCodexDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
claudeDir: customClaudeDir,
codexDir: customCodexDir,
}));
const originalHome = process.env.HOME;
process.env.HOME = home;
const ipcHandlers = new Map();
const serviceEvents = [];
const originalLoad = Module._load;
const mainPath = require.resolve('../app/main.js');
delete require.cache[mainPath];
class FakeDatabase {
pragma() {}
close() {}
prepare() {
return { get: () => null, all: () => [], run: () => ({}) };
}
}
class FakeBrowserWindow {
constructor() {
this.webContents = {
on() {},
setZoomLevel() {},
openDevTools() {},
send() {},
};
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() {
return [];
}
static fromWebContents() {
return null;
}
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
whenReady: () => Promise.resolve(),
on() {},
quit() {},
},
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
clipboard: {},
dialog: {},
nativeImage: {},
};
}
if (request === 'better-sqlite3') return FakeDatabase;
if (request === './indexer') return { writeHeartbeat() {} };
if (request === './indexer-service') {
return {
createIndexerService: () => ({
start() { serviceEvents.push('start'); },
stop() { serviceEvents.push('stop'); },
idle: async () => new Promise(() => {}),
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
}),
};
}
if (request === './indexer-worker-client') {
let buildIndexCalls = 0;
return {
createWorkerBuildIndex: () => ({
buildIndex: async (args) => {
serviceEvents.push(`build-${++buildIndexCalls}`);
writeFileSync(args.dbPath, 'rebuilt temp db');
return { files: 2, affectedSessionIds: [] };
},
stop() {
serviceEvents.push('worker-stop');
return Promise.resolve();
},
}),
};
}
if (request === 'chokidar') {
return { watch: () => ({ on() { return this; }, close() {} }) };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
require('../app/main.js');
await new Promise(resolve => setImmediate(resolve));
const rebuild = ipcHandlers.get('settings:rebuildIndex');
const outcome = await Promise.race([
rebuild().then(() => 'done'),
new Promise(resolve => setTimeout(() => resolve('timeout'), 20)),
]);
assert.equal(outcome, 'done');
assert.ok(serviceEvents.indexOf('worker-stop') > serviceEvents.indexOf('stop'));
assert.ok(serviceEvents.some(event => event.startsWith('build-')));
} finally {
Module._load = originalLoad;
delete require.cache[mainPath];
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
+125
View File
@@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { extractContentType, extractMessageIsMeta } from '../scripts/db.mjs';
async function readExecutableSchema() {
return readFile(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
}
async function readSchemaReference() {
return readFile(new URL('../references/schema.md', import.meta.url), 'utf8');
}
async function readApiReference() {
return readFile(new URL('../references/api-reference.md', import.meta.url), 'utf8');
}
async function readSkill() {
return readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
}
test('db module loads the executable schema from scripts/schema.sql', async () => {
const source = await readFile(new URL('../scripts/db.mjs', import.meta.url), 'utf8');
assert.match(source, /schema\.sql/);
assert.doesNotMatch(source, /CREATE TABLE IF NOT EXISTS sessions/);
});
test('memories schema indexes common recall filters', async () => {
const source = await readExecutableSchema();
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_project ON memories\(project\)/);
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_session ON memories\(session_id\)/);
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_created ON memories\(created_at\)/);
assert.match(source, /CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories/);
});
test('messages schema stores the raw content block type', async () => {
const source = await readExecutableSchema();
assert.match(source, /content_type TEXT/);
assert.match(source, /is_meta INTEGER DEFAULT 0/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages/);
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/);
});
test('schema reference stays focused on raw SQL structure', async () => {
const ref = await readSchemaReference();
assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference');
assert.match(ref, /Raw SQL Quick Reference/i);
assert.match(ref, /references\/api-reference\.md/);
assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/);
assert.match(ref, /tool_calls.*does not have timestamps/i);
assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/);
assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/);
assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/);
});
test('api reference documents query helpers and current return fields', async () => {
const ref = await readApiReference();
assert.match(ref, /## Query API Reference/);
assert.match(ref, /#### `summaries\(opts\?\)`/);
assert.match(ref, /summary rows/i);
assert.match(ref, /session_title/);
assert.match(ref, /opts\.branch/);
assert.match(ref, /#### `raw\(uuid, opts\?\)`/);
assert.match(ref, /original JSONL line/i);
assert.match(ref, /opts\.offset/);
assert.match(ref, /totalLength/);
assert.match(ref, /hasMore/);
assert.match(ref, /messageCount/);
assert.doesNotMatch(ref, /a\.messages\.length/);
assert.doesNotMatch(ref, /Rebuilt on each index pass/);
});
test('skill routes agents to the right reference document', async () => {
const skill = await readSkill();
assert.match(skill, /Reference Map/);
assert.match(skill, /references\/schema\.md.*raw SQL/i);
assert.match(skill, /references\/api-reference\.md.*helper/i);
assert.match(skill, /references\/query-patterns\.md.*synthesis/i);
assert.match(skill, /references\/pitfalls\.md.*error/i);
});
test('extractContentType maps Claude content blocks to the message evidence type', () => {
assert.equal(extractContentType('hello'), 'text');
assert.equal(extractContentType([{ type: 'text', text: 'hello' }]), 'text');
assert.equal(extractContentType([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]), 'text');
assert.equal(extractContentType([{ type: 'thinking', thinking: 'hidden reasoning' }]), 'thinking');
assert.equal(extractContentType([{ type: 'tool_use', id: 'tool-1', name: 'Read', input: {} }]), 'tool_use');
assert.equal(extractContentType([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]), 'tool_result');
assert.equal(extractContentType([{ type: 'text', text: 'reply' }, { type: 'thinking', thinking: 'hmm' }]), 'unknown');
assert.equal(extractContentType([{ type: 'text', text: 'reply' }, { type: 'tool_result', content: 'ok' }]), 'unknown');
assert.equal(extractContentType(null), 'unknown');
});
test('extractMessageIsMeta marks injected and command-envelope messages', () => {
assert.equal(extractMessageIsMeta({ isMeta: true, message: { content: 'caveat' } }, 'caveat'), 1);
assert.equal(extractMessageIsMeta({ message: { isMeta: true, content: 'caveat' } }, 'caveat'), 1);
assert.equal(extractMessageIsMeta(
{ message: { content: [{ type: 'text', text: '<command-name>/exit</command-name>' }] } },
'<command-name>/exit</command-name>',
), 1);
assert.equal(extractMessageIsMeta(
{ message: { content: [{ type: 'text', text: '<system-reminder>Keep answers concise</system-reminder>' }] } },
'<system-reminder>Keep answers concise</system-reminder>',
), 1);
assert.equal(extractMessageIsMeta(
{ message: { content: [{ type: 'text', text: '<local-command>git status</local-command>' }] } },
'<local-command>git status</local-command>',
), 1);
assert.equal(extractMessageIsMeta(
{ message: { content: [{ type: 'text', text: 'quoted <command-name>/exit</command-name>' }] } },
'quoted <command-name>/exit</command-name>',
), 0);
assert.equal(extractMessageIsMeta({ message: { content: 'normal user request' } }, 'normal user request'), 0);
});
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { inferProjectPath, refreshSessionProjectPaths, shouldSkipBuild } from '../scripts/indexer.mjs';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
test('inferProjectPath preserves hyphens from observed cwd', () => {
assert.equal(
inferProjectPath('-Users-dev-Code-quiet-zero', ['/Users/dev/Code/quiet-zero']),
'/Users/dev/Code/quiet-zero',
);
assert.equal(
inferProjectPath('-Users-dev-Code-research-widget-svc', ['/Users/dev/Code/research/widget-svc']),
'/Users/dev/Code/research/widget-svc',
);
});
test('inferProjectPath falls back to legacy slug decoding without cwd evidence', () => {
assert.equal(
inferProjectPath('-Users-dev-Code-quiet-zero', []),
'/Users/dev/Code/quiet/zero',
);
});
test('refreshSessionProjectPaths repairs indexed sessions from message cwd', () => {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY, project TEXT, project_path TEXT
);
CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT, cwd TEXT
);
`);
db.prepare('INSERT INTO sessions (id, project, project_path) VALUES (?, ?, ?)').run(
'sid-1',
'-Users-dev-Code-quiet-zero',
'/Users/dev/Code/quiet/zero',
);
db.prepare('INSERT INTO messages (uuid, session_id, timestamp, cwd) VALUES (?, ?, ?, ?)').run(
'msg-1',
'sid-1',
'2026-06-10T10:00:00Z',
'/Users/dev/Code/quiet-zero',
);
refreshSessionProjectPaths(db);
assert.equal(
db.prepare('SELECT project_path FROM sessions WHERE id=?').get('sid-1').project_path,
'/Users/dev/Code/quiet-zero',
);
db.close();
});
test('shouldSkipBuild requires both fresh app heartbeat and successful app build', () => {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER
);
`);
db.prepare('INSERT INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(
'__app_heartbeat__',
100000,
);
assert.equal(shouldSkipBuild(db, { now: 110000 }).skip, false);
db.prepare('INSERT INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(
'__app_last_successful_build__',
100000,
);
assert.deepEqual(
shouldSkipBuild(db, { now: 110000 }),
{ skip: true, reason: 'app_successful_build' },
);
assert.equal(shouldSkipBuild(db, { now: 200000 }).skip, false);
db.close();
});
+297
View File
@@ -0,0 +1,297 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createQueryApi, createAttuneApi } from '../scripts/query.mjs';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
function memoryDb({ projectPath = '/tmp/quiet-zero-test' } = {}) {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
const insertSession = db.prepare(`
INSERT INTO sessions (id, title, project, project_path, started_at, ended_at, git_branch, message_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertSession.run('sid-1', 'Older quiet-zero session', 'quiet-zero', projectPath, '2026-06-09T10:00:00Z', '2026-06-09T11:00:00Z', 'main', 12);
insertSession.run('sid-2', 'Memory layer session', 'quiet-zero', projectPath, '2026-06-10T10:00:00Z', '2026-06-10T11:00:00Z', 'codex/memory-layer', 23);
insertSession.run('sid-3', 'Other project session', 'other-project', '/tmp/other-project', '2026-06-11T10:00:00Z', '2026-06-11T11:00:00Z', 'main', 5);
const insert = db.prepare(`
INSERT INTO memories (id, session_id, project, path, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
insert.run('mem-1', 'sid-1', 'quiet-zero', '.obelisk/memories/parallel-agents.md', 'Decision: use parallel agents for independent review facets.', '2026-06-09T12:00:00Z');
insert.run('mem-2', 'sid-2', 'quiet-zero', '.obelisk/memories/sqlite-memory.md', 'Decision: store markdown memory records in SQLite.', '2026-06-10T12:00:00Z');
insert.run('mem-3', 'sid-3', 'other-project', '.obelisk/memories/parallel-agents.md', 'Other project note about parallel agents.', '2026-06-11T12:00:00Z');
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
return db;
}
function searchDb() {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT, project TEXT, started_at TEXT,
source TEXT DEFAULT 'claude'
);
CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT,
timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, source TEXT DEFAULT 'claude'
);
CREATE VIRTUAL TABLE messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text,
content=messages, content_rowid=rowid
);
`);
db.prepare(`
INSERT INTO sessions (id, title, project, started_at)
VALUES (?, ?, ?, ?)
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
const insert = db.prepare(`
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1);
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0);
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1);
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0);
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
return db;
}
test('search exposes content_type on hits and temporal context', () => {
const db = searchDb();
const api = createQueryApi(db);
const rows = api.search('needle', { limit: 1 });
assert.equal(rows[0].message.uuid, 'msg-text');
assert.equal(rows[0].message.content_type, 'text');
assert.equal(rows[0].message.is_meta, 0);
assert.equal(rows[0].context[0].uuid, 'msg-thinking');
assert.equal(rows[0].context[0].content_type, 'thinking');
assert.equal(rows[0].context[0].is_meta, 0);
db.close();
});
test('search and thread omit meta messages by default and expose them on request', () => {
const db = searchDb();
const api = createQueryApi(db);
assert.deepEqual(api.search('injected', { limit: 5 }), []);
const withMeta = api.search('injected', { includeMeta: true, limit: 5 });
assert.equal(withMeta[0].message.uuid, 'msg-meta');
assert.equal(withMeta[0].message.is_meta, 1);
assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']);
assert.deepEqual(
api.thread('sid-search', { includeMeta: true }).map(m => m.uuid),
['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'],
);
db.close();
});
test('memories follows list-helper scalar opts and filters by query within scope', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.deepEqual(api.memories('sid-1').map(m => m.id), ['mem-1']);
assert.deepEqual(api.memories(1).map(m => m.id), ['mem-3']);
assert.deepEqual(
api.memories({ project: '%quiet-zero%', query: 'parallel agents', limit: 5 }).map(m => m.id),
['mem-1'],
);
db.close();
});
test('memories requires English query terms', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.throws(
() => api.memories({ query: '记忆层', limit: 5 }),
/memories\(\) query must use English terms/,
);
db.close();
});
test('memories uses FTS recall with safe English tokenization and rank', () => {
const db = memoryDb();
const api = createQueryApi(db);
const rows = api.memories({ project: '%quiet-zero%', query: 'sqlite-memory', limit: 5 });
assert.deepEqual(rows.map(m => m.id), ['mem-2']);
assert.equal(typeof rows[0].rank, 'number');
db.close();
});
test('memories does not broaden punctuation-only FTS queries into full recall', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.deepEqual(api.memories({ project: '%quiet-zero%', query: '---', limit: 5 }), []);
db.close();
});
test('overview returns a compact current-project map with bounded sessions', () => {
const db = memoryDb({ projectPath: process.cwd() });
const api = createQueryApi(db);
const view = api.overview({ limit: 1, projectLimit: 5 });
assert.equal(view.current.cwd, process.cwd());
assert.equal(view.current.project.project, 'quiet-zero');
assert.equal(view.current.project.source, 'cwd_project_path');
assert.equal(view.current.project.confidence, 'exact');
assert.equal('session' in view.current, false);
assert.equal(view.current_project.session_total, 2);
assert.deepEqual(view.current_project.sessions.map(s => s.id), ['sid-2']);
assert.equal(view.current_project.memory_total, 2);
assert.deepEqual(view.current_project.memories.map(m => m.id), ['mem-2', 'mem-1']);
assert.equal(view.totals.projects, 2);
assert.equal(view.totals.sessions, 3);
assert.equal(view.totals.memories, 3);
assert.ok(view.projects.some(p => p.project === 'quiet-zero' && p.session_count === 2 && p.memory_count === 2));
db.close();
});
test('query api is read-only and does not expose attune helpers', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.equal(api.remember, undefined);
assert.equal(api.forget, undefined);
assert.equal(typeof api.overview, 'function');
assert.deepEqual(api.sql('SELECT id FROM memories ORDER BY id').map(r => r.id), ['mem-1', 'mem-2', 'mem-3']);
assert.throws(
() => api.sql("INSERT INTO memories (id, path, summary) VALUES ('mem-x', '/tmp/x.md', 'x')"),
/sql\(\) only supports read-only SELECT\/WITH queries/,
);
db.close();
});
test('attune api exposes only memory mutation helpers', () => {
const db = memoryDb();
const api = createAttuneApi(db);
assert.deepEqual(Object.keys(api).sort(), ['forget', 'remember']);
assert.equal(api.search, undefined);
assert.equal(api.sql, undefined);
assert.equal(typeof api.remember, 'function');
assert.equal(typeof api.forget, 'function');
db.close();
});
test('remember stores absolute project-relative memory path', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'decision.md');
writeFileSync(memoryPath, '# Decision\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
const result = api.remember({
path: '.obelisk/memories/decision.md',
session_id: 'sid-1',
summary: 'Decision: store normalized memory paths.',
});
assert.equal(result.path, memoryPath);
assert.equal(db.prepare('SELECT path FROM memories WHERE id=?').get(result.id).path, memoryPath);
db.close();
});
test('remember updates FTS recall for the registered memory immediately', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'query-plan.md');
writeFileSync(memoryPath, '# Query Plan\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
const registered = api.remember({
path: '.obelisk/memories/query-plan.md',
session_id: 'sid-2',
summary: 'Decision: use faceted query plans for synthesis recall.',
});
const rows = createQueryApi(db).memories({
project: '%quiet-zero%',
query: 'faceted query plans',
limit: 5,
});
assert.deepEqual(rows.map(m => m.id), [registered.id]);
assert.equal(typeof rows[0].rank, 'number');
db.close();
});
test('forget soft-deletes memory records from active recall', () => {
const db = memoryDb();
const api = createAttuneApi(db);
const result = api.forget({ id: 'mem-1', reason: 'Outdated project guidance.' });
assert.equal(result.id, 'mem-1');
assert.equal(result.deleted_reason, 'Outdated project guidance.');
assert.match(result.deleted_at, /^\d{4}-\d{2}-\d{2}T/);
const row = db.prepare('SELECT deleted_at, deleted_reason FROM memories WHERE id=?').get('mem-1');
assert.equal(row.deleted_at, result.deleted_at);
assert.equal(row.deleted_reason, 'Outdated project guidance.');
assert.deepEqual(createQueryApi(db).memories({ project: '%quiet-zero%', limit: 10 }).map(m => m.id), ['mem-2']);
db.close();
});
test('remember requires English summaries', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'decision.md');
writeFileSync(memoryPath, '# Decision\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
assert.throws(
() => api.remember({
path: '.obelisk/memories/decision.md',
session_id: 'sid-1',
summary: '决策:记忆摘要必须使用英文。',
}),
/remember\(\) summary must be written in English/,
);
db.close();
});
test('remember rejects missing memory files', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
assert.throws(
() => api.remember({
path: '.obelisk/memories/missing.md',
session_id: 'sid-1',
summary: 'Decision: this should not be registered.',
}),
/remember\(\) memory file does not exist/,
);
db.close();
});
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { buildRecapExportQuery, cleanRecapFilename } = require('../app/recap-capture-query.js');
test('recap export query includes the selected recap filename', () => {
const query = buildRecapExportQuery({
cardIdx: 3,
archetype: 'shipper',
filename: 'recap-2026-06.json',
});
assert.equal(query, 'card=3&arch=shipper&file=recap-2026-06.json');
});
test('recap export query strips paths before passing filename to renderer', () => {
assert.equal(cleanRecapFilename('/tmp/recap-2026-W24.json'), 'recap-2026-W24.json');
assert.equal(
buildRecapExportQuery({
cardIdx: '2',
archetype: 'architect',
filename: '/Users/dev/.obelisk/recap/recap-2026-W24.json',
}),
'card=2&arch=architect&file=recap-2026-W24.json',
);
});
+356
View File
@@ -0,0 +1,356 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
async function read(path) {
return readFile(new URL(`../${path}`, import.meta.url), 'utf8');
}
const cards = [
['1', 'cover'],
['2', 'thinking'],
['3', 'vibe'],
['4', 'workflow'],
['5', 'closing'],
];
test('skill routes only the explicit recap intent to the split recap overview', async () => {
const skill = await read('SKILL.md');
assert.match(skill, /## Intent Routing/);
assert.match(skill, /references\/recap\/overview\.md/);
assert.match(skill, /first word is `recap`/i);
assert.match(skill, /Everything after `recap` is the recap target/);
assert.match(skill, /`\/obelisk recap this week`/);
assert.match(skill, /`\/obelisk recap this month`/);
assert.match(skill, /`\/obelisk recap last week`/);
assert.match(skill, /`\/obelisk recap last month`/);
assert.match(skill, /do not load\s+`references\/recap\/overview\.md`/);
assert.doesNotMatch(skill, /NetEase-style chart/);
assert.doesNotMatch(skill, /playful personal progress recap/);
});
test('README lists the recap folder without making recap the core retrieval path', async () => {
const readme = await read('README.md');
assert.match(readme, /references\/recap\/overview\.md/);
for (const [n, name] of cards) {
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`));
assert.match(readme, new RegExp(`references/recap/writing${n}-${name}\\.md`));
}
assert.match(readme, /optional .*\/obelisk recap/i);
assert.match(readme, /explicit `\/obelisk recap` intent/);
assert.match(readme, /card-by-card/i);
});
test('old recap references are thin redirects to the split docs', async () => {
const retrieval = await read('references/recap-patterns.md');
const writing = await read('references/recap-writing.md');
assert.match(retrieval, /compatibility/i);
assert.match(retrieval, /references\/recap\/overview\.md/);
assert.match(retrieval, /Do not use this as an all-in-one/i);
assert.match(writing, /compatibility/i);
assert.match(writing, /references\/recap\/overview\.md/);
assert.match(writing, /per-card writing/i);
assert.ok(retrieval.length < 1200);
assert.ok(writing.length < 1200);
});
test('recap overview defines the card-by-card retrieval and writing loop', async () => {
const ref = await read('references/recap/overview.md');
assert.match(ref, /Highest Priority: Phase Loop/i);
assert.match(ref, /Spotify Wrapped-like/i);
assert.match(ref, /share cards/i);
assert.match(ref, /make the user's work feel seen/i);
assert.match(ref, /Do not criticize/i);
assert.match(ref, /Do not preload all recap files/i);
assert.match(ref, /Do not gather all\s+evidence first/i);
assert.match(ref, /Update\/write the JSON for Card 1 now/i);
assert.match(ref, /Only after the JSON is updated, move to Card 2/i);
assert.match(ref, /pattern1-cover\.md[\s\S]*writing1-cover\.md/);
assert.match(ref, /pattern2-thinking\.md[\s\S]*writing2-thinking\.md/);
assert.match(ref, /pattern3-vibe\.md[\s\S]*writing3-vibe\.md/);
assert.match(ref, /pattern4-workflow\.md[\s\S]*writing4-workflow\.md/);
assert.match(ref, /pattern5-closing\.md[\s\S]*writing5-closing\.md/);
});
test('recap overview stays narrow and leaves card details to per-card files', async () => {
const ref = await read('references/recap/overview.md');
assert.ok(ref.split('\n').length < 90);
assert.match(ref, /The per-card files own retrieval details/i);
assert.doesNotMatch(ref, /schema_version/);
assert.doesNotMatch(ref, /obelisk\.recap\.v1/);
assert.doesNotMatch(ref, /~\/\.obelisk\/recap\//);
assert.doesNotMatch(ref, /references\/schema\.md/);
assert.doesNotMatch(ref, /## JSON Shape/);
for (const archetype of [
'architect',
'debugger',
'shipper',
'curator',
'director',
'cartographer',
'wanderer',
]) {
assert.match(ref, new RegExp(`\\b${archetype}\\b`));
}
});
test('each recap card has a separate retrieval pattern and writing reference', async () => {
for (const [n, name] of cards) {
const pattern = await read(`references/recap/pattern${n}-${name}.md`);
const writing = await read(`references/recap/writing${n}-${name}.md`);
assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`));
assert.match(pattern, /Read this card's writing file immediately after/i);
assert.match(pattern, /update the JSON/i);
assert.match(pattern, /evidence/i);
if (n !== '5') assert.match(pattern, /Do not read `pattern/);
assert.doesNotMatch(pattern, /## JSON Shape/);
assert.match(writing, new RegExp(`# Card ${n} .* Writing`));
assert.match(writing, /Mock taste anchor/i);
assert.match(writing, /## JSON Shape/);
assert.match(writing, /Before writing/i);
assert.match(writing, /After writing/i);
assert.match(writing, /evidence_refs/);
if (n !== '1' && n !== '5') assert.match(writing, /Update the JSON now before reading/i);
}
});
test('cover and closing writing own JSON initialization and final save rules', async () => {
const cover = await read('references/recap/writing1-cover.md');
const closing = await read('references/recap/writing5-closing.md');
assert.match(cover, /First JSON Write/i);
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
assert.match(cover, /~\/\.obelisk\/recap\//);
assert.match(cover, /recap-\{YYYY\}-W\{WW\}\.json/);
assert.match(cover, /recap-\{YYYY\}-\{MM\}\.json/);
assert.match(closing, /Final save rules/i);
assert.match(closing, /file contains only the JSON object/i);
assert.match(closing, /Keep exactly five cards/i);
});
test('cover card retrieval and writing choose one dominant human claim', async () => {
const pattern = await read('references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md');
assert.match(pattern, /dominant claim/i);
assert.match(pattern, /persona/i);
assert.match(pattern, /activity/i);
assert.match(pattern, /footer/i);
assert.match(pattern, /not a topic inventory/i);
assert.match(writing, /从零设计了一个完整的 memory 系统。/);
assert.match(writing, /one plain claim/i);
assert.match(writing, /one breath/i);
assert.match(writing, /not a topic list/i);
assert.match(writing, /The Architect/);
});
test('cover card schema uses claim instead of subtitle', async () => {
const pattern = await read('references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md');
const component = await read('app/renderer/src/components/recap/CoverCard.vue');
const detail = await read('app/renderer/src/views/RecapDetail.vue');
const list = await read('app/renderer/src/views/RecapList.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(pattern, /claim that lists three topics/i);
assert.doesNotMatch(pattern, /subtitle/);
assert.match(writing, /claim: string/);
assert.match(writing, /persona: \{ archetype: string; title: string; claim: string; tone: string \}/);
assert.match(writing, /cover\.claim/);
assert.match(writing, /persona\.claim/);
assert.doesNotMatch(writing, /cover\.subtitle/);
assert.doesNotMatch(writing, /persona\.subtitle/);
assert.match(component, /claim:\s*String/);
assert.match(component, /claim\s*\|\|\s*subtitle/);
assert.match(detail, /cover\.claim\s*\|\|\s*cover\.subtitle/);
assert.match(list, /persona\?\.claim\s*\|\|\s*r\.persona\?\.subtitle/);
assert.match(mock, /"claim": "从零设计了一个完整的 memory 系统。"/);
});
test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
const pattern = await read('references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md');
assert.match(pattern, /turning points/i);
assert.match(pattern, /user question/i);
assert.match(pattern, /friction/i);
assert.match(pattern, /what changed in the user's mind/i);
assert.match(pattern, /not a project timeline/i);
assert.match(pattern, /not an implementation log/i);
assert.match(writing, /Five questions, five turns\./);
assert.match(writing, /raw SQLite, no wiki/);
assert.match(writing, /unified filter opts, not DSL/);
assert.match(writing, /is_error in JSONL/);
assert.match(writing, /soft-delete, human-only/);
assert.match(writing, /GitHub-style activity timeline/);
assert.match(writing, /turn.*short decision fragment/i);
});
test('thinking path schema uses turn instead of outcome', async () => {
const pattern = await read('references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md');
const component = await read('app/renderer/src/components/recap/PathCard.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(pattern, /prompt, turn, and an `evidence` anchor/i);
assert.doesNotMatch(pattern, /outcome/);
assert.match(writing, /turn: string/);
assert.match(writing, /"turn": "raw SQLite, no wiki"/);
assert.match(writing, /`turn`: short decision fragment/i);
assert.doesNotMatch(writing, /outcome: string/);
assert.match(component, /item\.turn\s*\|\|\s*item\.outcome/);
assert.match(mock, /"turn": "raw SQLite, no wiki"/);
});
test('vibe card retrieval finds small user voice, not a correction audit', async () => {
const pattern = await read('references/recap/pattern3-vibe.md');
const writing = await read('references/recap/writing3-vibe.md');
assert.match(pattern, /catchphrases/i);
assert.match(pattern, /visible user messages/i);
assert.match(pattern, /COALESCE\(m\.is_meta,0\)=0/);
assert.match(pattern, /not a correction log/i);
assert.match(pattern, /not bracketed runtime/i);
assert.match(writing, /A short character study\./);
assert.match(writing, /这太丑了/);
assert.match(writing, /可以/);
assert.match(writing, /你在干什么/);
assert.match(writing, /voice_lines\[\]\.text.*exact user words/i);
assert.match(writing, /meter is not a diagnosis/i);
assert.match(writing, /Do not use `\[Request interrupted by user\]`/);
});
test('vibe card schema uses voice_lines instead of observations', async () => {
const writing = await read('references/recap/writing3-vibe.md');
const component = await read('app/renderer/src/components/recap/VibeCard.vue');
const detail = await read('app/renderer/src/views/RecapDetail.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(writing, /voice_lines: Array/);
assert.match(writing, /"voice_lines": \[/);
assert.match(writing, /voice_lines\[\]\.label/);
assert.doesNotMatch(writing, /observations: Array/);
assert.match(component, /voiceLines:\s*Array/);
assert.match(component, /voiceLines\s*\|\|\s*observations/);
assert.match(detail, /vibe\.voice_lines\s*\|\|\s*vibe\.observations/);
assert.match(mock, /"voice_lines": \[/);
});
test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
const pattern = await read('references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md');
assert.match(pattern, /workflows\.timestamp/);
assert.match(pattern, /workflows\(\{ project: .* after, before/i);
assert.match(pattern, /Do not derive workflow counts only from `sessions\(\{ after, before \}\)`/i);
assert.match(pattern, /Do not scope workflow lookup by exact `project_path`/i);
assert.match(pattern, /user message immediately following/i);
assert.match(pattern, /rank rows by the strength of the user reaction/i);
assert.match(pattern, /not by agent count/i);
assert.match(pattern, /actual workflow_name/i);
assert.match(writing, /Three workflows\. Forty-two agents\./);
assert.match(writing, /hono-plugin-review/);
assert.match(writing, /vue-migration/);
assert.match(writing, /split-render-js/);
assert.match(writing, /完美/);
assert.match(writing, /你这页面完全和之前的不一样/);
assert.match(writing, /Mostly tolerated\./);
assert.match(writing, /reaction.*user reaction/i);
assert.match(writing, /Agent counts belong only in `title` or `stats`/i);
assert.match(writing, /`13 agents, the big build`[\s\S]*invalid/i);
assert.doesNotMatch(writing, /tiny factual verdict/i);
assert.match(writing, /omit the row rather than write an implementation result/i);
});
test('workflow card uses reaction instead of outcome for row copy', async () => {
const pattern = await read('references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md');
const component = await read('app/renderer/src/components/recap/WorkflowCard.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(pattern, /items\[\]\.reaction/i);
assert.match(writing, /reaction: string/);
assert.match(writing, /"reaction": "完美"/);
assert.match(writing, /items\[\]\.reaction/);
assert.doesNotMatch(writing, /items\[\]\.outcome/);
assert.doesNotMatch(writing, /"outcome": "完美"/);
assert.match(component, /item\.reaction\s*\|\|\s*item\.outcome/);
assert.match(mock, /"reaction": "完美"/);
});
test('workflow card schema uses deck instead of summary for the visible line', async () => {
const writing = await read('references/recap/writing4-workflow.md');
const component = await read('app/renderer/src/components/recap/WorkflowCard.vue');
const detail = await read('app/renderer/src/views/RecapDetail.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(writing, /deck\?: string/);
assert.match(writing, /"deck": "你召唤了机器军团。结果各有不同。"/);
assert.match(writing, /`deck`: optional second line/i);
assert.doesNotMatch(writing, /summary\?: string/);
assert.match(component, /deck:\s*String/);
assert.match(component, /deck\s*\|\|\s*summary/);
assert.match(detail, /workflow\.deck\s*\|\|\s*workflow\.summary/);
assert.match(mock, /"deck": "你召唤了机器军团。结果各有不同。"/);
});
test('closing card retrieval and writing keep a small personal receipt', async () => {
const pattern = await read('references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md');
assert.match(pattern, /same period and source scope/i);
assert.match(pattern, /streak/i);
assert.match(pattern, /most said phrase/i);
assert.match(pattern, /consistent metric/i);
assert.match(pattern, /not a second summary/i);
assert.match(writing, /19 days/);
assert.match(writing, /847 messages exchanged/);
assert.match(writing, /好的开始做吧/);
assert.match(writing, /See you next week\./);
assert.match(writing, /not a naked number/i);
assert.match(writing, /quiet goodbye/i);
assert.match(writing, /at most two `receipts`/i);
});
test('closing card schema uses receipts instead of stats', async () => {
const pattern = await read('references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md');
const component = await read('app/renderer/src/components/recap/ClosingCard.vue');
const detail = await read('app/renderer/src/views/RecapDetail.vue');
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
assert.match(pattern, /one or two compact receipts/i);
assert.doesNotMatch(pattern, /receipt stats/);
assert.match(writing, /receipts: string\[\]/);
assert.match(writing, /"receipts": \["847 messages exchanged", "12 corrections · 47 approvals"\]/);
assert.match(writing, /`receipts`: at most two/i);
assert.doesNotMatch(writing, /stats: string\[\]/);
assert.match(component, /receipts:\s*Array/);
assert.match(component, /receipts\s*\|\|\s*stats/);
assert.match(detail, /closing\.receipts\s*\|\|\s*closing\.stats/);
assert.match(mock, /"receipts": \["847 messages exchanged", "12 corrections · 47 approvals"\]/);
});
test('split recap writing keeps mixed-language rhythm and plain speech', async () => {
const overview = await read('references/recap/overview.md');
const writingDocs = await Promise.all(
cards.map(([n, name]) => read(`references/recap/writing${n}-${name}.md`)),
);
const combined = [overview, ...writingDocs].join('\n');
assert.match(combined, /not a translation task/i);
assert.match(combined, /English chrome/i);
assert.match(combined, /source language/i);
assert.match(combined, /chat bubble/i);
assert.match(combined, /If it sounds like a topic list or report heading/i);
assert.match(combined, /one plain claim/i);
assert.match(combined, /exact user words/i);
});
+461
View File
@@ -0,0 +1,461 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, { home }) {
return spawnSync(process.execPath, ['scripts/runtime.mjs', ...args], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
}
function tempHome() {
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
mkdirSync(join(home, '.claude'), { recursive: true });
return home;
}
test('runtime query scripts cannot call attune helpers', () => {
const home = tempHome();
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, 'return { rememberType: typeof remember, forgetType: typeof forget, overviewType: typeof overview };');
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(result.stdout), {
rememberType: 'undefined',
forgetType: 'undefined',
overviewType: 'function',
});
});
test('runtime attune scripts expose only memory mutation helpers', () => {
const home = tempHome();
const memoryPath = join(home, 'memory.md');
const scriptPath = join(home, 'attune.mjs');
writeFileSync(memoryPath, '# Memory\n');
writeFileSync(scriptPath, `
return {
rememberType: typeof remember,
forgetType: typeof forget,
searchType: typeof search,
sqlType: typeof sql,
overviewType: typeof overview,
result: remember({
path: ${JSON.stringify(memoryPath)},
project: 'runtime-test',
summary: 'Decision: runtime remember exposes only memory registration.'
})
};
`);
const result = runRuntime(['--attune', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = JSON.parse(result.stdout);
assert.equal(payload.rememberType, 'function');
assert.equal(payload.forgetType, 'function');
assert.equal(payload.searchType, 'undefined');
assert.equal(payload.sqlType, 'undefined');
assert.equal(payload.overviewType, 'undefined');
assert.equal(payload.result.path, memoryPath);
});
test('runtime rejects removed remember mode', () => {
const home = tempHome();
const scriptPath = join(home, 'remember.mjs');
writeFileSync(scriptPath, 'return { ok: true };');
const result = runRuntime(['--remember', scriptPath], { home });
assert.notEqual(result.status, 0);
assert.equal(result.stdout, '');
assert.match(result.stderr, /--attune <file\.js>/);
});
test('runtime migrates old memories schema before recall', () => {
const home = tempHome();
const dbPath = join(home, '.claude', 'obelisk.sqlite');
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE memories (
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
message_start TEXT, message_end TEXT,
path TEXT, summary TEXT, created_at TEXT
);
INSERT INTO memories (id, project, path, summary, created_at)
VALUES ('mem-old', 'legacy-project', '/tmp/old.md', 'Decision: keep legacy memory rows readable.', '2026-06-10T12:00:00Z');
`);
db.close();
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, `
return memories({ project: "%legacy-project%", query: "legacy memory", limit: 5 })
.map(m => ({ id: m.id, rankType: typeof m.rank }));
`);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(result.stdout), [{ id: 'mem-old', rankType: 'number' }]);
});
test('runtime indexes Codex root sessions into the shared query helpers', () => {
const home = tempHome();
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
mkdirSync(codexSessionDir, { recursive: true });
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: codexId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-runtime',
cli_version: '0.135.0-alpha.1',
source: 'vscode',
git: { branch: 'feat/codex' },
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'codex user asks for runtime indexing', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'developer replay should stay out of visible search' }],
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:02.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: 'codex assistant replies from runtime' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:03.000Z',
type: 'response_item',
payload: { type: 'function_call', call_id: 'call_codex_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:04.000Z',
type: 'response_item',
payload: { type: 'function_call_output', call_id: 'call_codex_1', output: '/tmp/obelisk-runtime' },
}),
'',
].join('\n'));
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, `
const sid = ${JSON.stringify(`codex:${codexId}`)};
return {
sessions: sessions({ source: 'codex', limit: 5 }).map(s => ({
id: s.id,
source: s.source,
project: s.project,
project_path: s.project_path,
git_branch: s.git_branch,
version: s.version,
message_count: s.message_count
})),
messages: thread(sid).map(m => ({ role: m.role, text: m.text, source: m.source, content_type: m.content_type })),
search: search('runtime indexing', { source: 'codex', limit: 5 }).map(h => ({
uuid: h.message.uuid,
message_source: h.message.source,
session_source: h.session.source
})),
developerReplay: search('developer replay', { source: 'codex', limit: 5 }).length,
rawHasEventLine: raw(${JSON.stringify(`codex:${codexId}:000002`)}, { limit: 1000 })?.text.includes('codex user asks for runtime indexing') || false,
tool: sql('SELECT id, message_uuid, session_id, name FROM tool_calls WHERE id=?', 'codex:call_codex_1')[0],
toolResult: sql('SELECT tool_use_id, message_uuid, session_id, content FROM tool_results WHERE tool_use_id=?', 'codex:call_codex_1')[0],
overviewSources: overview({ limit: 5 }).totals.sources
};
`);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = JSON.parse(result.stdout);
assert.deepEqual(payload.sessions, [{
id: `codex:${codexId}`,
source: 'codex',
project: '-tmp-obelisk-runtime',
project_path: '/tmp/obelisk-runtime',
git_branch: 'feat/codex',
version: '0.135.0-alpha.1',
message_count: 3,
}]);
assert.deepEqual(payload.messages.map(m => [m.role, m.text, m.source, m.content_type]), [
['user', 'codex user asks for runtime indexing', 'codex', 'text'],
['assistant', 'codex assistant replies from runtime', 'codex', 'text'],
['assistant', null, 'codex', 'tool_use'],
]);
assert.equal(payload.search[0].message_source, 'codex');
assert.equal(payload.search[0].session_source, 'codex');
assert.equal(payload.developerReplay, 0);
assert.equal(payload.rawHasEventLine, true);
assert.equal(payload.tool.session_id, `codex:${codexId}`);
assert.equal(payload.tool.message_uuid, `codex:${codexId}:000005`);
assert.equal(payload.toolResult.message_uuid, `codex:${codexId}:000005`);
assert.equal(payload.toolResult.content, '/tmp/obelisk-runtime');
assert.ok(payload.overviewSources.some(s => s.source === 'codex' && s.session_count === 1));
});
test('runtime skips Codex guardian review threads', () => {
const home = tempHome();
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
mkdirSync(codexSessionDir, { recursive: true });
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T18:12:00.000Z',
type: 'session_meta',
payload: {
id: guardianId,
timestamp: '2026-06-14T18:12:00.000Z',
cwd: '/tmp/obelisk-runtime',
cli_version: '0.135.0-alpha.1',
thread_source: 'subagent',
source: { subagent: { other: 'guardian' } },
},
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:01.000Z',
type: 'turn_context',
payload: { cwd: '/tmp/obelisk-runtime', model: 'codex-auto-review' },
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:02.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'approval guardian prompt', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:03.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: '{"outcome":"allow"}' },
}),
'',
].join('\n'));
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, `
const sid = ${JSON.stringify(`codex:${guardianId}`)};
return {
sessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
searchCount: search('approval', { source: 'codex', limit: 5 }).length,
sessionRows: sql('SELECT COUNT(*) AS c FROM sessions WHERE id=?', sid)[0].c,
messageRows: sql('SELECT COUNT(*) AS c FROM messages WHERE session_id=?', sid)[0].c,
subagentRows: sql('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?', sid)[0].c
};
`);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(result.stdout), {
sessions: [],
searchCount: 0,
sessionRows: 0,
messageRows: 0,
subagentRows: 0,
});
});
test('runtime removes stale Codex guardian rows when the JSONL was already indexed', () => {
const home = tempHome();
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
mkdirSync(codexSessionDir, { recursive: true });
const initScriptPath = join(home, 'init.mjs');
writeFileSync(initScriptPath, 'return sessions({ source: "codex", limit: 5 }).length;');
assert.equal(runRuntime(['--query', initScriptPath], { home }).status, 0);
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
const guardianSessionId = `codex:${guardianId}`;
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`);
writeFileSync(jsonlPath, [
JSON.stringify({
timestamp: '2026-06-14T18:12:00.000Z',
type: 'session_meta',
payload: {
id: guardianId,
timestamp: '2026-06-14T18:12:00.000Z',
cwd: '/tmp/obelisk-runtime',
cli_version: '0.135.0-alpha.1',
thread_source: 'subagent',
source: { subagent: { other: 'guardian' } },
},
}),
JSON.stringify({
timestamp: '2026-06-14T18:12:01.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'stale approval guardian prompt', images: [], local_images: [], text_elements: [] },
}),
'',
].join('\n'));
const db = new DatabaseSync(join(home, '.obelisk', 'obelisk.sqlite'));
db.prepare('INSERT INTO sessions (id,jsonl_path,source,message_count) VALUES (?,?,?,?)').run(guardianSessionId, jsonlPath, 'codex', 1);
db.prepare('INSERT INTO messages (uuid,session_id,type,timestamp,role,text,content_type,source) VALUES (?,?,?,?,?,?,?,?)')
.run(`${guardianSessionId}:000002`, guardianSessionId, 'user', '2026-06-14T18:12:01.000Z', 'user', 'stale approval guardian prompt', 'text', 'codex');
db.prepare('INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)')
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'exec_command', '{}', null);
db.prepare('INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)')
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'ok', null, 0);
db.prepare('INSERT INTO subagents (agent_id,session_id) VALUES (?,?)').run(guardianSessionId, guardianSessionId);
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)')
.run(jsonlPath, statSync(jsonlPath).mtimeMs, 2);
db.prepare("UPDATE index_state SET mtime=? WHERE jsonl_path='__last_build__'").run(Date.now() - 31000);
db.close();
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, `
const sid = ${JSON.stringify(guardianSessionId)};
return {
sessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
searchCount: search('stale', { source: 'codex', limit: 5 }).length,
sessionRows: sql('SELECT COUNT(*) AS c FROM sessions WHERE id=?', sid)[0].c,
messageRows: sql('SELECT COUNT(*) AS c FROM messages WHERE session_id=?', sid)[0].c,
toolRows: sql('SELECT COUNT(*) AS c FROM tool_calls WHERE session_id=?', sid)[0].c,
resultRows: sql('SELECT COUNT(*) AS c FROM tool_results WHERE session_id=?', sid)[0].c,
subagentRows: sql('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?', sid)[0].c
};
`);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(result.stdout), {
sessions: [],
searchCount: 0,
sessionRows: 0,
messageRows: 0,
toolRows: 0,
resultRows: 0,
subagentRows: 0,
});
});
test('runtime maps Codex child threads onto subagents', () => {
const home = tempHome();
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
mkdirSync(codexSessionDir, { recursive: true });
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${parentId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T16:19:59.842Z',
type: 'session_meta',
payload: {
id: parentId,
timestamp: '2026-06-14T16:19:59.842Z',
cwd: '/tmp/obelisk-runtime',
cli_version: '0.135.0-alpha.1',
source: 'vscode',
},
}),
JSON.stringify({
timestamp: '2026-06-14T16:20:00.000Z',
type: 'event_msg',
payload: {
type: 'collab_agent_spawn_end',
call_id: 'call_spawn_1',
sender_thread_id: parentId,
new_thread_id: childId,
new_agent_nickname: 'Plato',
new_agent_role: 'worker',
prompt: 'inspect skill-side codex indexing',
},
}),
'',
].join('\n'));
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T01-41-42-${childId}.jsonl`), [
JSON.stringify({
timestamp: '2026-06-14T17:41:42.924Z',
type: 'session_meta',
payload: {
id: childId,
timestamp: '2026-06-14T17:41:42.924Z',
cwd: '/tmp/obelisk-runtime',
cli_version: '0.135.0-alpha.1',
source: {
subagent: {
thread_spawn: {
parent_thread_id: parentId,
agent_nickname: 'Plato',
agent_role: 'worker',
},
},
},
agent_nickname: 'Plato',
agent_role: 'worker',
},
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:43.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'subagent prompt', images: [], local_images: [], text_elements: [] },
}),
JSON.stringify({
timestamp: '2026-06-14T17:41:44.000Z',
type: 'event_msg',
payload: { type: 'agent_message', phase: 'final_answer', message: 'subagent answer' },
}),
'',
].join('\n'));
const scriptPath = join(home, 'query.mjs');
writeFileSync(scriptPath, `
return {
parentSessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
subagents: subagents({ source: 'codex', limit: 5 }).map(sa => ({
agent_id: sa.agent_id,
session_id: sa.session_id,
parent_tool_use_id: sa.parent_tool_use_id,
agent_type: sa.agent_type,
description: sa.description,
messageCount: sa.messageCount
})),
childMessages: sql(
'SELECT session_id, agent_id, is_sidechain, source, text FROM messages WHERE agent_id=? ORDER BY timestamp, uuid',
${JSON.stringify(`codex:${childId}`)}
)
};
`);
const result = runRuntime(['--query', scriptPath], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = JSON.parse(result.stdout);
assert.deepEqual(payload.parentSessions, [`codex:${parentId}`]);
assert.deepEqual(payload.subagents, [{
agent_id: `codex:${childId}`,
session_id: `codex:${parentId}`,
parent_tool_use_id: 'codex:call_spawn_1',
agent_type: 'worker',
description: 'Plato',
messageCount: 2,
}]);
assert.deepEqual(payload.childMessages.map(m => [m.session_id, m.agent_id, m.is_sidechain, m.source, m.text]), [
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent prompt'],
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent answer'],
]);
});
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createSessionLiveState,
consumeSessionDirty,
noteSessionUpdated,
} from '../app/renderer/src/session-live.mjs';
test('session live state marks non-visible updated sessions as dirty', () => {
const live = createSessionLiveState();
const action = noteSessionUpdated(live, 'session-2', 'session-1');
assert.deepEqual(action, { reload: false, sessionId: 'session-2' });
assert.equal(consumeSessionDirty(live, 'session-2'), true);
assert.equal(consumeSessionDirty(live, 'session-2'), false);
});
test('session live state reloads the visible session without leaving it dirty', () => {
const live = createSessionLiveState();
const action = noteSessionUpdated(live, 'session-1', 'session-1');
assert.deepEqual(action, { reload: true, sessionId: 'session-1' });
assert.equal(consumeSessionDirty(live, 'session-1'), false);
});
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildSidebarProjects } from '../app/renderer/src/sidebar-projects.mjs';
test('session sidebar projects follow database recency order instead of label order', () => {
const sessions = [
{ project: '-Users-dev-Code-sample-cli-' },
{ project: '-Users-dev-Code-quiet-zero' },
{ project: '-Users-dev-Code-quiet-zero' },
{ project: '-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444' },
];
const projects = [
{ project: '-Users-dev-Code-quiet-zero' },
{ project: '-Users-dev-Code-sample-cli-' },
{ project: '-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444' },
];
const labels = {
'-Users-dev-Code-sample-cli-': 'sample-cli+',
'-Users-dev-Code-quiet-zero': 'quiet-zero',
'-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444': '00000000-1111-2...',
};
const result = buildSidebarProjects({
routeType: 'sessions',
sessions,
projects,
formatProjectLabel: slug => labels[slug] || slug,
});
assert.deepEqual(result.map(project => project.label), [
'quiet-zero',
'sample-cli+',
'00000000-1111-2...',
]);
assert.equal(result[0].count, 2);
});
test('memory sidebar projects filter by archive state and label search', () => {
const result = buildSidebarProjects({
routeType: 'memory',
view: 'active',
search: 'quiet',
memories: [
{ project: 'quiet-zero', archived: false },
{ project: 'quiet-zero', archived: true },
{ project: 'sample-lib', archived: false },
],
projects: [
{ project: 'sample-lib' },
{ project: 'quiet-zero' },
],
formatProjectLabel: slug => slug,
});
assert.deepEqual(result, [{ slug: 'quiet-zero', label: 'quiet-zero', count: 1 }]);
});