+
{{ s.title || '(untitled)' }}
- {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+ {{ s.source === 'codex' ? 'Codex' : 'Claude' }} · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+
+
+
+ {{ block.newSessions.noise.length }} hidden — likely test or throwaway runs
+
+
+
+ {{ s.title || '(untitled)' }}
+ {{ s.source === 'codex' ? 'Codex' : 'Claude' }} · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+
+
+
-
+
{{ s.title || '(untitled)' }}
- {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+ {{ s.source === 'codex' ? 'Codex' : 'Claude' }} · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+
+
+
+ {{ block.continued.noise.length }} hidden — likely test or throwaway runs
+
+
+
+ {{ s.title || '(untitled)' }}
+ {{ s.source === 'codex' ? 'Codex' : 'Claude' }} · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg
+
+
+
@@ -763,10 +905,41 @@ onMounted(async () => {
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
-.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
+.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
+.activity-item-meta .src-tag {
+ display: inline-flex; align-items: center; gap: 4px;
+ font-size: 10px; letter-spacing: 0.02em;
+ color: var(--muted);
+}
+.activity-item-meta .src-tag .src-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
+.activity-item-meta .src-tag.claude .src-dot { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
+.activity-item-meta .src-tag.codex .src-dot { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
+.activity-item-meta .src-tag.claude { color: #d97757; }
+.activity-item-meta .src-tag.codex { color: #10a37f; }
.activity-group.continued .activity-item-name { color: var(--fg-2); }
+.noise-fold-row {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 12px; margin-top: 2px;
+ border-radius: 5px; background: transparent; border: 0;
+ cursor: pointer; transition: background 0.08s;
+ text-align: left; width: 100%; font: inherit;
+ color: var(--muted); font-size: 11.5px;
+ font-family: var(--font-mono);
+}
+.noise-fold-row:hover { background: var(--surface-strong); color: var(--fg-2); }
+.noise-fold-row .chev {
+ width: 9px; height: 9px; color: var(--muted-2);
+ transition: transform 0.15s; flex-shrink: 0;
+}
+.noise-fold-row.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
+
+.activity-item.noise .activity-item-name {
+ color: var(--fg-2); font-style: italic; font-weight: 400;
+}
+.activity-item.noise .activity-item-project { color: var(--muted); }
+
.show-more-btn {
display: block; width: 100%; margin-top: 20px;
padding: 8px; border-radius: 4px;
diff --git a/docs/adr/0001-parse-core-and-persist-layers.md b/docs/adr/0001-parse-core-and-persist-layers.md
new file mode 100644
index 0000000..aabc2cc
--- /dev/null
+++ b/docs/adr/0001-parse-core-and-persist-layers.md
@@ -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.
diff --git a/docs/adr/0002-two-tier-runtime-contract.md b/docs/adr/0002-two-tier-runtime-contract.md
new file mode 100644
index 0000000..fc589f0
--- /dev/null
+++ b/docs/adr/0002-two-tier-runtime-contract.md
@@ -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.
diff --git a/docs/adr/0003-core-typescript-esm-precompiled.md b/docs/adr/0003-core-typescript-esm-precompiled.md
new file mode 100644
index 0000000..059278c
--- /dev/null
+++ b/docs/adr/0003-core-typescript-esm-precompiled.md
@@ -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.
diff --git a/docs/adr/0004-skill-artifact-readable-not-bundled.md b/docs/adr/0004-skill-artifact-readable-not-bundled.md
new file mode 100644
index 0000000..f9c0233
--- /dev/null
+++ b/docs/adr/0004-skill-artifact-readable-not-bundled.md
@@ -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.
diff --git a/tests/app-indexer-service.test.mjs b/tests/app-indexer-service.test.mjs
new file mode 100644
index 0000000..53d9020
--- /dev/null
+++ b/tests/app-indexer-service.test.mjs
@@ -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',
+ ]);
+});
diff --git a/tests/app-indexer-worker-client.test.mjs b/tests/app-indexer-worker-client.test.mjs
new file mode 100644
index 0000000..9f12d02
--- /dev/null
+++ b/tests/app-indexer-worker-client.test.mjs
@@ -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/,
+ );
+});
diff --git a/tests/app-indexer.test.mjs b/tests/app-indexer.test.mjs
new file mode 100644
index 0000000..4e196e5
--- /dev/null
+++ b/tests/app-indexer.test.mjs
@@ -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: '
Keep answers concise ' }] },
+ }),
+ JSON.stringify({
+ uuid: 'msg-meta-local-command',
+ type: 'user',
+ timestamp: '2026-06-13T10:01:00Z',
+ message: { role: 'user', content: [{ type: 'text', text: '
git status ' }] },
+ }),
+ 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();
+});
diff --git a/tests/app-main-settings.test.mjs b/tests/app-main-settings.test.mjs
new file mode 100644
index 0000000..3f329a3
--- /dev/null
+++ b/tests/app-main-settings.test.mjs
@@ -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 });
+ }
+});
diff --git a/tests/db-schema.test.mjs b/tests/db-schema.test.mjs
new file mode 100644
index 0000000..e331001
--- /dev/null
+++ b/tests/db-schema.test.mjs
@@ -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: '
/exit ' }] } },
+ '
/exit ',
+ ), 1);
+ assert.equal(extractMessageIsMeta(
+ { message: { content: [{ type: 'text', text: '
Keep answers concise ' }] } },
+ '
Keep answers concise ',
+ ), 1);
+ assert.equal(extractMessageIsMeta(
+ { message: { content: [{ type: 'text', text: '
git status ' }] } },
+ '
git status ',
+ ), 1);
+ assert.equal(extractMessageIsMeta(
+ { message: { content: [{ type: 'text', text: 'quoted
/exit ' }] } },
+ 'quoted
/exit ',
+ ), 0);
+ assert.equal(extractMessageIsMeta({ message: { content: 'normal user request' } }, 'normal user request'), 0);
+});
diff --git a/tests/indexer.test.mjs b/tests/indexer.test.mjs
new file mode 100644
index 0000000..d308283
--- /dev/null
+++ b/tests/indexer.test.mjs
@@ -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();
+});
diff --git a/tests/query.test.mjs b/tests/query.test.mjs
new file mode 100644
index 0000000..d29aafa
--- /dev/null
+++ b/tests/query.test.mjs
@@ -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', '
/exit ', '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();
+});
diff --git a/tests/recap-capture-query.test.mjs b/tests/recap-capture-query.test.mjs
new file mode 100644
index 0000000..c13821f
--- /dev/null
+++ b/tests/recap-capture-query.test.mjs
@@ -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',
+ );
+});
diff --git a/tests/recap-patterns.test.mjs b/tests/recap-patterns.test.mjs
new file mode 100644
index 0000000..a983839
--- /dev/null
+++ b/tests/recap-patterns.test.mjs
@@ -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);
+});
diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs
new file mode 100644
index 0000000..a6c3083
--- /dev/null
+++ b/tests/runtime.test.mjs
@@ -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
/);
+});
+
+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'],
+ ]);
+});
diff --git a/tests/session-live.test.mjs b/tests/session-live.test.mjs
new file mode 100644
index 0000000..052468d
--- /dev/null
+++ b/tests/session-live.test.mjs
@@ -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);
+});
diff --git a/tests/sidebar-projects.test.mjs b/tests/sidebar-projects.test.mjs
new file mode 100644
index 0000000..69ab670
--- /dev/null
+++ b/tests/sidebar-projects.test.mjs
@@ -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 }]);
+});