chore: sanitize tests, add CONTEXT.md + ADRs, track tests/docs
This commit is contained in:
+1
-2
@@ -1,9 +1,8 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
plans/
|
plans/
|
||||||
.skillopt-backups
|
.skillopt-backups
|
||||||
tests/
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist-renderer/
|
dist-renderer/
|
||||||
release/
|
release/
|
||||||
docs/
|
.dev.docs
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+82
@@ -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
|
||||||
@@ -18,6 +18,26 @@ const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
|||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
const DAY_MS = 86400000;
|
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) {
|
function localDateStr(d) {
|
||||||
const y = d.getFullYear();
|
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 ---
|
// --- Methods ---
|
||||||
function switchTab(view) {
|
function switchTab(view) {
|
||||||
activeTab.value = view;
|
activeTab.value = view;
|
||||||
@@ -502,61 +542,112 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Day sessions panel (from heatmap click) -->
|
<!-- Day sessions panel (from heatmap click) -->
|
||||||
<div class="day-sessions" v-if="daySessions">
|
<div class="day-sessions" v-if="daySessionsSplit">
|
||||||
<div class="day-sessions-header">{{ daySessions.dateLabel }}<template v-if="daySessions.isEmpty"> — no sessions</template></div>
|
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> — no sessions</template></div>
|
||||||
<div class="day-activity-timeline" v-if="!daySessions.isEmpty">
|
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
|
||||||
<!-- New workspaces -->
|
<!-- 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">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon workspace">★</span>
|
<span class="activity-icon workspace">★</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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in daySessions.newWorkspaces"
|
v-for="s in daySessionsSplit.newWorkspaces.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@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-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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- New sessions -->
|
<!-- 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">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon new">+</span>
|
<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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in daySessions.newSessions"
|
v-for="s in daySessionsSplit.newSessions.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@click="goToSession(s.id)"
|
||||||
>
|
>
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Continued sessions -->
|
<!-- 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">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon continued">↳</span>
|
<span class="activity-icon continued">↳</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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in daySessions.continued"
|
v-for="s in daySessionsSplit.continued.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@click="goToSession(s.id)"
|
||||||
>
|
>
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,58 +655,109 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Monthly activity blocks -->
|
<!-- Monthly activity blocks -->
|
||||||
<div class="day-sessions" v-if="!selectedDayKey">
|
<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-sessions-header">{{ block.header }}</div>
|
||||||
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
<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">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon workspace">★</span>
|
<span class="activity-icon workspace">★</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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in block.newWorkspaces"
|
v-for="s in block.newWorkspaces.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@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-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>
|
</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>
|
</div>
|
||||||
<div class="activity-group" v-if="block.newSessions.length">
|
<div class="activity-group" v-if="block.newSessions.total">
|
||||||
<div class="activity-group-header">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon new">+</span>
|
<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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in block.newSessions"
|
v-for="s in block.newSessions.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@click="goToSession(s.id)"
|
||||||
>
|
>
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
<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>
|
</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>
|
</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">
|
<div class="activity-group-header">
|
||||||
<span class="activity-icon continued">↳</span>
|
<span class="activity-icon continued">↳</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>
|
||||||
<div class="activity-group-items">
|
<div class="activity-group-items">
|
||||||
<button
|
<button
|
||||||
v-for="s in block.continued"
|
v-for="s in block.continued.normal"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="activity-item"
|
class="activity-item"
|
||||||
@click="goToSession(s.id)"
|
@click="goToSession(s.id)"
|
||||||
>
|
>
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
<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>
|
</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>
|
</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 { 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-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: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); }
|
.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 {
|
.show-more-btn {
|
||||||
display: block; width: 100%; margin-top: 20px;
|
display: block; width: 100%; margin-top: 20px;
|
||||||
padding: 8px; border-radius: 4px;
|
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.
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 }]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user