session-grep 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luke Otwell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # session-grep
2
+
3
+ Grep across local AI coding-session transcripts (Claude Code, Codex) with **bounded
4
+ message context** — built for agents answering questions about past sessions.
5
+
6
+ Session history is a knowledge base — decisions, incidents, rules, dead ends — but
7
+ the transcripts are hostile to search: conversational text is under 2% of bytes; the
8
+ rest is tool output, thinking blocks, and base64. Raw grep returns whole JSONL records
9
+ (10-100KB each); loading transcripts wholesale blows up context windows. session-grep
10
+ parses records into messages, matches against conversation (not tool echoes), and
11
+ returns ranked hits with a hard output budget.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npx skills add lhotwll217/session-grep # as an agent skill
17
+ npx session-grep --query "why did you" --since 7d # as a CLI, no install
18
+ npm i -g session-grep # or global
19
+ npx session-grep --self-test # verify: 22 built-in assertions
20
+ ```
21
+
22
+ Needs Node ≥ 20 and ripgrep. The skill is the [skills/session-grep/](skills/session-grep/)
23
+ folder (SKILL.md + script + adapters) — installable via `npx skills add`, or copy it into
24
+ any skills directory; the self-test travels with it. Session formats are pluggable: one
25
+ adapter file per tool in `adapters/`, drop in a new one to support another harness.
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ session-grep --query "task_started" --before 2 --after 2 # exact term, bounded context
31
+ session-grep --query "sidebar poll triage membership" --any # multi-word: rarity-ranked, per-word hit counts
32
+ session-grep --overview # one-line digest per session
33
+ session-grep --skim 269a # one session's conversation, sampled to budget
34
+ ```
35
+
36
+ Searches `~/.claude/projects` and `~/.codex/sessions` by default; `--root DIR` points
37
+ anywhere. Full flags and agent guidance: [skills/session-grep/SKILL.md](skills/session-grep/SKILL.md).
38
+
39
+ ## Benchmark
40
+
41
+ `eval/` is a promptfoo benchmark: an agent equipped with session-grep vs a naive-grep
42
+ control, rubric-graded questions over real session history, measured in cost,
43
+ correctness, tool calls, and time. The harness ships; our transcripts and cases stay
44
+ local (documented in [eval/README.md](eval/README.md)). Our result — 29 questions
45
+ over 24MB of real sessions, haiku subject:
46
+
47
+ | | session-grep | naive control |
48
+ |---|---|---|
49
+ | correct | **79%** (23/29) | 45% (13/29) |
50
+ | cost | **$1.25** (0.41×) | $3.02 |
51
+ | tool calls | **130** (3.3× fewer) | 423 |
52
+ | time per question | **25s** (2.2× faster) | 54s |
53
+
54
+ Cheaper on 26/29 questions; $0.054 vs $0.233 per correct answer. Target gate:
55
+ ≤0.5× cost at ≥ control correctness (`node eval/compare.mjs --gate`).
56
+
57
+ To create your own eval on your own sessions (and tailor the tool with the
58
+ improvement loop), see [eval/README.md](eval/README.md) and
59
+ [eval/AUTORESEARCH.md](eval/AUTORESEARCH.md).
60
+
61
+ ## Origin
62
+
63
+ Ported from [owner-operator](https://github.com/lhotwll217/owner-operator)'s
64
+ `sessions-grep` skill; benchmarked on that project's own development sessions.
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // npm bin entry. The canonical skill lives in skills/session-grep/ — the layout
3
+ // `npx skills add` installs — and this wrapper just runs it.
4
+ import '../skills/session-grep/session-grep.mjs';
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "session-grep",
3
+ "version": "0.1.0",
4
+ "description": "Grep AI coding-session transcripts (Claude Code, Codex) with bounded message context — built for agents. Includes rarity-ranked multi-word search, session overviews, sampled spines, and a built-in self-test.",
5
+ "type": "module",
6
+ "bin": {
7
+ "session-grep": "bin/session-grep.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "skills/"
12
+ ],
13
+ "scripts": {
14
+ "eval": "promptfoo eval -c eval/promptfooconfig.yaml --no-cache",
15
+ "eval:smoke": "promptfoo eval -c eval/promptfooconfig.yaml --no-cache --filter-providers 'haiku' --filter-pattern 'roadmap-rule|interactive-launch|ripgrep-shim'",
16
+ "eval:view": "promptfoo view",
17
+ "loop": "node eval/loop.mjs",
18
+ "compare": "node eval/compare.mjs",
19
+ "test": "node --test eval/test/*.test.mjs",
20
+ "self-test": "node skills/session-grep/session-grep.mjs --self-test",
21
+ "prepublishOnly": "node skills/session-grep/session-grep.mjs --self-test"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/lhotwll217/session-grep.git"
26
+ },
27
+ "keywords": [
28
+ "claude-code",
29
+ "codex",
30
+ "sessions",
31
+ "transcripts",
32
+ "grep",
33
+ "agent-tools",
34
+ "skill",
35
+ "promptfoo"
36
+ ],
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=20"
40
+ }
41
+ }
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: session-grep
3
+ description: >-
4
+ Literal or regex grep across local AI session transcripts with bounded message context. Use when the user asks to search exact words, punctuation, hashtags/patterns, phrases like "why did you", or wants messages before/after a hit. This is for targeted drill-in, not broad topic discovery.
5
+ ---
6
+
7
+ # session-grep
8
+
9
+ Searches local AI CLI session files with exact literal matching or opt-in regex matching and returns only bounded
10
+ message context around each hit. Use this when BM25 search is too fuzzy, cannot search
11
+ punctuation/common phrases, or when you need a simple pattern like hashtags.
12
+
13
+ ## Onboarding (first use)
14
+
15
+ The folders searched by default live in the `SESSION_ROOTS` variable at the top of
16
+ the script. On first use, check it matches where this machine's sessions
17
+ actually live, and edit it if not — ask the user which tools' history they want
18
+ searchable. Known session homes:
19
+
20
+ | tool | home | format |
21
+ |---|---|---|
22
+ | Claude Code | `~/.claude/projects` | jsonl (supported) |
23
+ | Codex CLI | `~/.codex/sessions`, `~/.codex/archived_sessions` | jsonl (supported) |
24
+ | Cursor | `~/Library/Application Support/Cursor/User/workspaceStorage` (macOS), `~/.config/Cursor/...` (linux) | sqlite (not yet parseable) |
25
+ | Gemini CLI | `~/.gemini/tmp` | json (not yet parseable) |
26
+ | opencode | `~/.local/share/opencode/storage` | split json (not yet parseable) |
27
+
28
+ Quick existence check: `ls -d ~/.claude/projects ~/.codex/sessions 2>/dev/null`.
29
+ Any directory of session `*.jsonl` files can be added to `SESSION_ROOTS`; `--root DIR`
30
+ overrides per call without editing anything.
31
+
32
+ Format support lives in the `adapters/` folder next to the script — one file per
33
+ tool, each exporting `{name, detect(file), message(record, opts)}`. Supporting a
34
+ new JSONL-based tool means dropping one file in that folder (and adding a
35
+ `--self-test` fixture); non-JSONL formats also need a reader change in the script.
36
+
37
+ ## When to use
38
+
39
+ - "grep sessions for ..."
40
+ - "search exact phrase ..."
41
+ - "find where I asked why did you ..."
42
+ - punctuation searches like `?`
43
+ - any request for messages before/after a specific text hit
44
+
45
+ ## Retrieval principle
46
+
47
+ When no stronger filtering criteria is given, treat **recency as the default heuristic for
48
+ relevance**. Search newest-first and prefer a recent window (`--since today`, `--since 7d`,
49
+ or another explicit date) before expanding all-time. Only broaden when recent results are
50
+ missing or insufficient.
51
+
52
+ ## How to use
53
+
54
+ The script lives NEXT TO THIS FILE (in the repo: `skills/session-grep/session-grep.mjs`; as an
55
+ installed skill it sits in this skill's directory). Invoke it by its path relative to
56
+ this SKILL.md — shown below as `session-grep.mjs`:
57
+
58
+ ```bash
59
+ node session-grep.mjs --query "why did you" --since 7d --limit 12 --before 2 --after 2
60
+ node session-grep.mjs --query "sidebar poll triage membership" --any # multi-word: any-word match, rarity-ranked
61
+ node session-grep.mjs --overview # digest of every session
62
+ node session-grep.mjs --skim 269a --max-chars 12000 # one session's conversation, sampled
63
+ node session-grep.mjs --regex --query "#[A-Za-z0-9_][A-Za-z0-9_-]*" --since 7d --limit 20
64
+ ```
65
+
66
+ For broad questions (summarize a session, what was X about) start with `--overview`,
67
+ then `--skim SESSION_ID`, then targeted `--query` for specifics. For fact questions:
68
+ multi-word literal phrases almost never occur verbatim — use `--any` (matches any word,
69
+ hits ranked by word rarity, per-word hit counts reported) or a single rare term.
70
+ Every hit is a pointer: to read around a promising hit, use `--session <id> --at <idx>`
71
+ from its header instead of re-searching with wider context.
72
+
73
+ Common flags:
74
+
75
+ - `--query TEXT` literal query, or a JavaScript regex pattern when `--regex` is set
76
+ - `--any` match ANY query word; hits ranked by summed word rarity (IDF); reports per-word hit counts so you learn which words are low-signal
77
+ - `--regex` treat `--query` as a JavaScript regular expression; useful for hashtags and lightweight patterns
78
+ - `--overview` no query needed: one compact digest per session (id, dates, message counts, opening prompt)
79
+ - `--skim ID_PREFIX` no query needed: one session's user/assistant conversation, head/tail kept, middle sampled to the output budget
80
+ - `--session ID_PREFIX --at INDEX` drill into a hit's pointer: every hit prints `id=` and `idx=` — this returns the exact messages around that index (±5 by default, `--before/--after` to widen) without re-running the search
81
+ - `--limit N` max matching messages, default 20; use a high number for "all"
82
+ - `--before N` messages before each hit, default 1
83
+ - `--after N` messages after each hit, default 1
84
+ - `--role user|assistant|all` filter matching messages, default `all`
85
+ - `--source claude|codex|all` filter sources, default `all`
86
+ - `--since today|Nd|YYYY-MM-DD` filter by message/session timestamp
87
+ - `--sort newest|oldest|file` output order, default `newest`
88
+ - `--root DIR` search this directory of `*.jsonl` transcripts instead of the default live stores (repeatable)
89
+ - `--max-chars N` output budget, default 8000 — excess hits are omitted with a notice, never dumped
90
+ - `--include-tools` also match inside tool_result blocks (excluded by default: they are file/command echoes, ~45% of bytes, and mostly restate the conversation)
91
+ - `--case-sensitive` exact case match, useful for all-caps searches
92
+ - `--json` machine-readable output (compact, same truncation and budget as text)
93
+ - `--self-test` verify the tool against a built-in synthetic corpus (20 assertions, no dependencies) — run this after copying the skill anywhere
94
+
95
+ ## Output rules
96
+
97
+ Summarize the hits; do not paste long transcript blocks. Give source, id/path, timestamp,
98
+ and the compact context needed to understand what happened around the match.
@@ -0,0 +1,19 @@
1
+ // Shared helpers for adapters. Files starting with _ are not loaded as adapters.
2
+
3
+ // Flatten a message's content blocks to text. opts.includeTools: when false (the
4
+ // default), tool_result blocks are excluded — they are file/command echoes, ~45% of
5
+ // corpus bytes, and mostly restate what the conversation already says.
6
+ export function contentToText(content, opts = {}) {
7
+ if (typeof content === 'string') return content;
8
+ if (!Array.isArray(content)) return '';
9
+ const chunks = [];
10
+ for (const item of content) {
11
+ if (typeof item === 'string') chunks.push(item);
12
+ else if (item && typeof item === 'object' && (item.type !== 'tool_result' || opts.includeTools)) {
13
+ for (const key of ['text', 'output_text', 'input_text', 'content']) {
14
+ if (typeof item[key] === 'string') chunks.push(item[key]);
15
+ }
16
+ }
17
+ }
18
+ return chunks.join('\n');
19
+ }
@@ -0,0 +1,15 @@
1
+ // Claude Code sessions: ~/.claude/projects/<project-slug>/<session-id>.jsonl,
2
+ // one JSON record per line; messages under .message.content as typed blocks.
3
+ import { contentToText } from './_shared.mjs';
4
+
5
+ export default {
6
+ name: 'claude',
7
+ fallback: true, // claims any file no other adapter detects
8
+ detect: () => true,
9
+ message(obj, opts) {
10
+ if ((obj.type === 'user' || obj.type === 'assistant') && obj.message && typeof obj.message === 'object') {
11
+ return { role: obj.message.role || obj.type, text: contentToText(obj.message.content, opts), timestamp: obj.timestamp };
12
+ }
13
+ return null;
14
+ },
15
+ };
@@ -0,0 +1,17 @@
1
+ // Codex CLI sessions: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl, one
2
+ // {type, payload} record per line; messages are response_item/message payloads.
3
+ // Boilerplate records (AGENTS.md preamble, IDE context, aborted turns) are skipped.
4
+ import { contentToText } from './_shared.mjs';
5
+
6
+ export default {
7
+ name: 'codex',
8
+ detect: (file) => file.includes('/.codex/') || /\/codex\//.test(file),
9
+ message(obj, opts) {
10
+ if (obj.type !== 'response_item' || !obj.payload || obj.payload.type !== 'message') return null;
11
+ const role = obj.payload.role || 'unknown';
12
+ if (!['user', 'assistant'].includes(role)) return null;
13
+ const text = contentToText(obj.payload.content, opts);
14
+ if (text.startsWith('# AGENTS.md instructions') || text.startsWith('# Context from my IDE setup:') || text.startsWith('<turn_aborted>') || text.slice(0, 5000).includes('<environment_context>')) return null;
15
+ return { role, text, timestamp: obj.timestamp };
16
+ },
17
+ };
@@ -0,0 +1,555 @@
1
+ #!/usr/bin/env node
2
+ // session-grep — literal/regex grep across AI coding-session transcripts (Claude Code,
3
+ // Codex) returning bounded MESSAGE context around each hit, not raw JSONL lines.
4
+ // Ported from owner-operator's sessions-grep skill; standalone here so it can be shared
5
+ // and continuously eval-tuned (see eval/).
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ const args = process.argv.slice(2);
12
+ const opts = { limit: 20, before: 1, after: 1, role: 'all', source: 'all', sort: 'newest', json: false, regex: false, roots: [], maxChars: 8000 };
13
+ for (let i = 0; i < args.length; i++) {
14
+ const a = args[i];
15
+ if (a === '--query') opts.query = args[++i];
16
+ else if (a === '--limit') opts.limit = Number(args[++i]);
17
+ else if (a === '--before') { opts.before = Number(args[++i]); opts.beforeSet = true; }
18
+ else if (a === '--after') { opts.after = Number(args[++i]); opts.afterSet = true; }
19
+ else if (a === '--role') opts.role = args[++i];
20
+ else if (a === '--source') opts.source = args[++i];
21
+ else if (a === '--since') opts.since = args[++i];
22
+ else if (a === '--sort') opts.sort = args[++i];
23
+ else if (a === '--root') opts.roots.push(args[++i]);
24
+ else if (a === '--max-chars') { opts.maxChars = Number(args[++i]); opts.maxCharsSet = true; }
25
+ else if (a === '--overview') opts.overview = true;
26
+ else if (a === '--skim') opts.skim = args[++i];
27
+ else if (a === '--session') opts.session = args[++i];
28
+ else if (a === '--at') opts.at = Number(args[++i]);
29
+ else if (a === '--self-test') opts.selfTest = true;
30
+ else if (a === '--include-tools') opts.includeTools = true;
31
+ else if (a === '--any') opts.any = true;
32
+ else if (a === '--regex') opts.regex = true;
33
+ else if (a === '--case-sensitive') opts.caseSensitive = true;
34
+ else if (a === '--json') opts.json = true;
35
+ else if (a === '--help' || a === '-h') usage(0);
36
+ else usage(1, `Unknown arg: ${a}`);
37
+ }
38
+
39
+ // ─── FORMAT ADAPTERS ────────────────────────────────────────────────────────
40
+ // Loaded from the adapters/ folder next to this script — one file per session
41
+ // format, each exporting {name, detect(file), message(record, opts), fallback?}.
42
+ // Supporting a new JSONL-based tool = dropping one file in that folder (plus a
43
+ // --self-test fixture below). `--source` values and dispatch derive from what's
44
+ // loaded. Non-JSONL formats (Cursor's sqlite, opencode's split JSON) also need a
45
+ // reader change here; see SKILL.md "Onboarding" for the format map.
46
+ import { fileURLToPath, pathToFileURL } from 'node:url';
47
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
48
+ const ADAPTERS = {};
49
+ {
50
+ const dir = path.join(scriptDir, 'adapters');
51
+ const loaded = [];
52
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.mjs') && !f.startsWith('_')).sort()) {
53
+ const mod = await import(pathToFileURL(path.join(dir, f)).href);
54
+ if (mod.default?.name && mod.default.detect && mod.default.message) loaded.push(mod.default);
55
+ }
56
+ loaded.sort((a, b) => (a.fallback ? 1 : 0) - (b.fallback ? 1 : 0)); // fallbacks last
57
+ for (const a of loaded) ADAPTERS[a.name] = a;
58
+ }
59
+ // ────────────────────────────────────────────────────────────────────────────
60
+
61
+ if (opts.selfTest) {
62
+ process.exit(await selfTest());
63
+ }
64
+ if (opts.at != null && !opts.session) usage(1, '--at requires --session ID_PREFIX');
65
+ if (!opts.query && !opts.overview && !opts.skim && !(opts.session && opts.at != null)) usage(1, 'Missing --query (or use --overview / --skim ID / --session ID --at INDEX)');
66
+ if (!Number.isFinite(opts.limit) || opts.limit < 1) usage(1, '--limit must be >= 1');
67
+ if (!Number.isFinite(opts.maxChars) || opts.maxChars < 500) usage(1, '--max-chars must be >= 500');
68
+ if (!Number.isFinite(opts.before) || opts.before < 0) usage(1, '--before must be >= 0');
69
+ if (!Number.isFinite(opts.after) || opts.after < 0) usage(1, '--after must be >= 0');
70
+ if (!['all', 'user', 'assistant'].includes(opts.role)) usage(1, '--role must be all, user, or assistant');
71
+ if (opts.source !== 'all' && !ADAPTERS[opts.source]) usage(1, `--source must be all or one of: ${Object.keys(ADAPTERS).join(', ')}`);
72
+ if (!['newest', 'oldest', 'file'].includes(opts.sort)) usage(1, '--sort must be newest, oldest, or file');
73
+ const sinceTime = opts.since ? parseSince(opts.since) : null;
74
+ if (opts.since && sinceTime == null) usage(1, '--since must be today, Nd, or YYYY-MM-DD');
75
+ if (opts.any && opts.regex) usage(1, '--any and --regex cannot be combined');
76
+ const queryRegex = opts.regex ? compileRegex(opts.query, opts.caseSensitive) : null;
77
+
78
+ // --any: multi-word phrases rarely occur verbatim in transcripts, so match ANY word
79
+ // and rank by how many distinct words a message hits. Low-signal words are dropped
80
+ // from the word set so common glue doesn't dominate the ranking.
81
+ const STOPWORDS = new Set(['the', 'and', 'was', 'were', 'did', 'does', 'you', 'your', 'why', 'how', 'what', 'when', 'where', 'which', 'who', 'for', 'that', 'this', 'with', 'from', 'have', 'has', 'had', 'are', 'not', 'but', 'about', 'into', 'out', 'our', 'they', 'them', 'then', 'than', 'its', 'get', 'got', 'can', 'could', 'would', 'should', 'ever', 'any', 'all', 'some', 'there']);
82
+ let anyWords = null;
83
+ if (opts.any) {
84
+ const raw = opts.query.split(/\s+/).filter(Boolean);
85
+ const strong = raw.filter((w) => w.length >= 3 && !STOPWORDS.has(w.toLowerCase()));
86
+ // Dedupe: repeated words must not double-count df or score.
87
+ anyWords = [...new Set((strong.length ? strong : raw).map((w) => (opts.caseSensitive ? w : w.toLowerCase())))];
88
+ if (!anyWords.length) usage(1, '--any needs at least one query word');
89
+ }
90
+
91
+ const home = os.homedir();
92
+
93
+ // ─── SESSION_ROOTS — the folders this skill searches by default ─────────────
94
+ // EDIT THIS on first use (or the agent running the skill should — see the
95
+ // "Onboarding" section of SKILL.md, which lists known session homes per tool).
96
+ // Any directory containing session *.jsonl files works. --root DIR overrides
97
+ // per call without touching this.
98
+ const SESSION_ROOTS = [
99
+ path.join(home, '.claude/projects'), // Claude Code
100
+ path.join(home, '.codex/sessions'), // Codex CLI
101
+ path.join(home, '.codex/archived_sessions'), // Codex CLI (archived)
102
+ ];
103
+ // ────────────────────────────────────────────────────────────────────────────
104
+
105
+ const roots = (opts.roots.length ? opts.roots : SESSION_ROOTS).filter((dir) => fs.existsSync(dir));
106
+ if (!roots.length) usage(1, 'No session roots found to search — edit SESSION_ROOTS at the top of this file (see SKILL.md "Onboarding") or pass --root DIR');
107
+
108
+ // Browse modes answer "which session?" and "what happened in it?" in one call each —
109
+ // whole-thread questions shouldn't cost 20 grep probes. A skim substitutes for many
110
+ // probe calls, so it gets a roomier default budget.
111
+ if (opts.skim && !opts.maxCharsSet) opts.maxChars = 16000;
112
+ if (opts.overview || opts.skim) {
113
+ browse();
114
+ process.exit(0);
115
+ }
116
+
117
+ // Window mode: consume a hit's pointer. Every search hit prints `id=... idx=...`;
118
+ // `--session ID --at IDX` returns the exact messages around that index — drill-in
119
+ // without re-running the search. Context defaults widen to ±5 here (that's the point).
120
+ if (opts.session && opts.at != null) {
121
+ if (!Number.isFinite(opts.at) || opts.at < 0) usage(1, '--at must be a message index >= 0 (from a hit\'s idx= field)');
122
+ const file = allSessionFiles().find((f) => sessionId(f).startsWith(opts.session));
123
+ if (!file) usage(1, `No session file matching id prefix "${opts.session}" under: ${roots.join(', ')}`);
124
+ const messages = parseMessages(fs.readFileSync(file, 'utf8'), sourceOf(file));
125
+ if (opts.at >= messages.length) {
126
+ usage(1, `--at ${opts.at} out of range: session ${sessionId(file)} has ${messages.length} messages (0..${messages.length - 1}). Note: indexes depend on --include-tools — drill in with the same setting the search used.`);
127
+ }
128
+ const b = opts.beforeSet ? opts.before : 5;
129
+ const a = opts.afterSet ? opts.after : 5;
130
+ const from = Math.max(0, opts.at - b);
131
+ const to = Math.min(messages.length - 1, opts.at + a);
132
+ console.log(`window id=${sessionId(file)} messages ${from}..${to} of ${messages.length} path=${file}`);
133
+ let size = 0;
134
+ for (let i = from; i <= to; i++) {
135
+ const m = messages[i];
136
+ const line = `[${i}]${i === opts.at ? '*' : ' '} ${m.role}${m.timestamp ? ' ' + String(m.timestamp).slice(0, 16) : ''}: ${truncate(m.text, i === opts.at ? 600 : 300)}`;
137
+ size += line.length;
138
+ if (size > opts.maxChars) { console.log(`... window truncated by --max-chars at [${i}]`); break; }
139
+ console.log(line);
140
+ }
141
+ process.exit(0);
142
+ }
143
+
144
+ const rg = spawnSync('rg', [
145
+ ...(opts.caseSensitive ? [] : ['-i']),
146
+ ...(opts.regex ? [] : ['--fixed-strings']),
147
+ '--files-with-matches',
148
+ '--glob',
149
+ '*.jsonl',
150
+ ...(anyWords ? anyWords.flatMap((w) => ['-e', w]) : [opts.query]),
151
+ ...roots,
152
+ ], { encoding: 'utf8' });
153
+
154
+ if (rg.error) {
155
+ usage(1, `ripgrep (rg) is required but could not be run (${rg.error.code ?? rg.error.message}). Install it, e.g. \`brew install ripgrep\`.`);
156
+ }
157
+ let files;
158
+ if (rg.status === 2 && opts.regex) {
159
+ // A JS-valid regex that ripgrep's engine rejects (lookaround, backrefs) must not
160
+ // die at the prefilter — fall back to scanning every session file with the JS matcher.
161
+ files = allSessionFiles();
162
+ } else if (rg.status === 2) {
163
+ const detail = rg.stderr.trim() ? `\n${rg.stderr.trim()}` : '';
164
+ usage(1, `Invalid query for ripgrep.${detail}`);
165
+ } else {
166
+ files = rg.status === 0 ? rg.stdout.trim().split('\n').filter(Boolean) : [];
167
+ }
168
+ const matches = [];
169
+ const q = opts.caseSensitive ? opts.query : opts.query.toLowerCase();
170
+ // --any rarity stats: document frequency per word across scanned messages. Rare words
171
+ // are the signal; the ranking weights them (IDF) and the output reports the counts so
172
+ // the caller learns which of its words are low-signal.
173
+ const wordDf = anyWords ? Object.fromEntries(anyWords.map((w) => [w, 0])) : null;
174
+ let messagesScanned = 0;
175
+
176
+ for (const file of files) {
177
+ const source = sourceOf(file);
178
+ if (opts.source !== 'all' && source !== opts.source) continue;
179
+ let raw;
180
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { continue; }
181
+ const messages = parseMessages(raw, source);
182
+ for (let i = 0; i < messages.length; i++) {
183
+ const msg = messages[i];
184
+ messagesScanned++;
185
+ const haystack = opts.caseSensitive ? msg.text : msg.text.toLowerCase();
186
+ let hitWords = null;
187
+ if (anyWords) {
188
+ hitWords = anyWords.filter((w) => haystack.includes(w));
189
+ for (const w of hitWords) wordDf[w]++;
190
+ if (!hitWords.length) continue;
191
+ }
192
+ if (opts.role !== 'all' && msg.role !== opts.role) continue;
193
+ if (!anyWords && (opts.regex ? !queryRegex.test(msg.text) : !haystack.includes(q))) continue;
194
+ const time = timeOf(msg.timestamp) ?? timeOf(messages[0]?.timestamp) ?? fs.statSync(file).mtimeMs;
195
+ if (sinceTime != null && time < sinceTime) continue;
196
+ matches.push({
197
+ source,
198
+ id: sessionId(file),
199
+ path: file,
200
+ index: i,
201
+ timestamp: msg.timestamp,
202
+ time,
203
+ ...(anyWords ? { matchedWords: hitWords } : {}),
204
+ before: messages.slice(Math.max(0, i - opts.before), i),
205
+ match: msg,
206
+ after: messages.slice(i + 1, i + 1 + opts.after),
207
+ });
208
+ }
209
+ }
210
+
211
+ // With --any, rank by summed word rarity (IDF): a hit on one rare identifier beats a
212
+ // hit on three ubiquitous words. Recency breaks ties.
213
+ if (anyWords) {
214
+ const idf = (w) => Math.log((messagesScanned + 1) / (wordDf[w] + 1));
215
+ for (const m of matches) m.score = round3(m.matchedWords.reduce((t, w) => t + idf(w), 0));
216
+ matches.sort((a, b) => b.score - a.score || (opts.sort === 'oldest' ? a.time - b.time : b.time - a.time));
217
+ } else if (opts.sort === 'newest') matches.sort((a, b) => b.time - a.time);
218
+ else if (opts.sort === 'oldest') matches.sort((a, b) => a.time - b.time);
219
+ const limited = matches.slice(0, opts.limit);
220
+
221
+ // Zero hits should steer the next query, not dead-end the agent: multi-word literal
222
+ // phrases almost never occur verbatim in transcripts — say so and point at --any.
223
+ const hint = !limited.length
224
+ ? (!opts.any && opts.query.trim().split(/\s+/).length > 1 && !opts.regex
225
+ ? 'no hits: multi-word phrases rarely occur verbatim in transcripts — retry with --any (matches any word, ranked by words matched), or grep ONE rare term (an identifier, error string, or filename)'
226
+ : opts.any
227
+ ? 'no hits for any query word: try different, rarer words (identifiers, error strings, filenames), or loosen --since/--role filters'
228
+ : 'no hits: try a rarer single term, or --any with several candidate words')
229
+ : null;
230
+
231
+ // Per-word hit counts teach the caller which of its words are low-signal: a word
232
+ // matching thousands of messages contributes nothing — drop it next query.
233
+ const wordStats = anyWords
234
+ ? anyWords.map((w) => `${w}=${wordDf[w]}`).join(' ')
235
+ : null;
236
+
237
+ // Output is budgeted (--max-chars, default 8k): a bad query can't flood the caller's
238
+ // context. Hits are selected in rank order until the budget runs out (an oversized
239
+ // FIRST hit is trimmed to fit rather than blowing the budget), and the header reports
240
+ // the true emitted count.
241
+ const OMIT = (n) => `... ${n} more matching messages omitted by the ${opts.maxChars}-char output budget — narrow with --role/--since${opts.any ? '/rarer words' : ''}, or raise --max-chars`;
242
+
243
+ const HEADER_ALLOWANCE = 300;
244
+ function selectWithinBudget(renderLen, trimContext) {
245
+ const emitted = [];
246
+ let size = HEADER_ALLOWANCE;
247
+ for (const m of limited) {
248
+ let entry = m;
249
+ let len = renderLen(entry);
250
+ if (size + len > opts.maxChars) {
251
+ if (emitted.length) break;
252
+ entry = trimContext(entry); // always emit at least the match itself, contextless
253
+ len = renderLen(entry);
254
+ if (size + len > opts.maxChars) break;
255
+ }
256
+ size += len;
257
+ emitted.push(entry);
258
+ }
259
+ return emitted;
260
+ }
261
+
262
+ if (opts.json) {
263
+ const slim = (msg) => ({ role: msg.role, text: truncate(msg.text, 300), timestamp: msg.timestamp });
264
+ const toEntry = (m) => ({ source: m.source, id: m.id, index: m.index, timestamp: m.timestamp, ...(anyWords ? { matchedWords: m.matchedWords, score: m.score } : {}), path: m.path, before: m.before.map(slim), match: slim(m.match), after: m.after.map(slim) });
265
+ const emitted = selectWithinBudget(
266
+ (m) => JSON.stringify(toEntry(m)).length,
267
+ (m) => ({ ...m, before: [], after: [] }),
268
+ ).map(toEntry);
269
+ const omitted = limited.length - emitted.length;
270
+ console.log(JSON.stringify({ query: opts.query, regex: opts.regex, any: !!opts.any, ...(anyWords ? { wordHits: wordDf, messagesScanned } : {}), rawFilesWithHits: files.length, totalMatches: matches.length, shown: emitted.length, ...(omitted ? { omittedByBudget: omitted, note: OMIT(omitted) } : {}), ...(hint ? { hint } : {}), matches: emitted }));
271
+ } else {
272
+ const renderLines = (m) => [
273
+ `${m.source} id=${m.id} idx=${m.index} ts=${m.timestamp ?? ''}${anyWords ? ` matched=[${m.matchedWords.join(',')}] score=${m.score}` : ''}`,
274
+ `path=${m.path}`,
275
+ ...m.before.map((b) => ` before ${b.role}: ${truncate(b.text, 180)}`),
276
+ ` MATCH ${m.match.role}: ${truncate(m.match.text, 300)}`,
277
+ ...m.after.map((a) => ` after ${a.role}: ${truncate(a.text, 180)}`),
278
+ ];
279
+ const emitted = selectWithinBudget(
280
+ (m) => renderLines(m).reduce((t, l) => t + l.length + 1, 6),
281
+ (m) => ({ ...m, before: [], after: [] }),
282
+ );
283
+ const omitted = limited.length - emitted.length;
284
+ console.log(`query=${JSON.stringify(opts.query)}${opts.regex ? ' regex=true' : ''}${opts.any ? ` any=true` : ''} raw_files_with_hits=${files.length} total_message_matches=${matches.length} shown=${emitted.length} sort=${opts.sort}${opts.since ? ` since=${opts.since}` : ''}${opts.caseSensitive ? ' case_sensitive=true' : ''}`);
285
+ if (wordStats) console.log(`word_hits: ${wordStats} (of ${messagesScanned} messages in matched files; high-count words are low-signal — prefer the rare ones)`);
286
+ if (hint) console.log(`hint: ${hint}`);
287
+ emitted.forEach((m, idx) => {
288
+ const [head, ...rest] = renderLines(m);
289
+ console.log(`\n[${idx + 1}] ${head}`);
290
+ for (const l of rest) console.log(l);
291
+ });
292
+ if (omitted) console.log(`\n${OMIT(omitted)}`);
293
+ }
294
+
295
+ function sourceOf(file) {
296
+ for (const [name, adapter] of Object.entries(ADAPTERS)) {
297
+ if (adapter.detect(file)) return name;
298
+ }
299
+ }
300
+
301
+ function parseMessages(raw, source) {
302
+ const out = [];
303
+ for (const line of raw.split('\n')) {
304
+ if (!line.trim()) continue;
305
+ let obj;
306
+ try { obj = JSON.parse(line); } catch { continue; }
307
+ const msg = ADAPTERS[source].message(obj, { includeTools: opts.includeTools });
308
+ if (!msg || !msg.text.trim()) continue;
309
+ out.push(msg);
310
+ }
311
+ return out;
312
+ }
313
+
314
+ function sessionId(file) {
315
+ return path.basename(file, '.jsonl');
316
+ }
317
+
318
+ function round3(x) {
319
+ return Math.round(x * 1000) / 1000;
320
+ }
321
+
322
+ function allSessionFiles() {
323
+ const out = [];
324
+ for (const root of roots) {
325
+ for (const entry of fs.readdirSync(root, { recursive: true })) {
326
+ const p = path.join(root, String(entry));
327
+ if (p.endsWith('.jsonl') && fs.statSync(p).isFile()) out.push(p);
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ // --overview: one compact digest per session (id, dates, message counts, opening user
334
+ // prompt) so the caller can pick the right session in a single cheap call.
335
+ // --skim ID: the conversational spine of one session — user + assistant text only,
336
+ // head/tail preserved and the middle sampled evenly to fit the output budget. Indexes
337
+ // are printed so specifics can be drilled with a targeted --query afterwards.
338
+ function browse() {
339
+ const files = allSessionFiles();
340
+ if (opts.skim) {
341
+ const file = files.find((f) => sessionId(f).startsWith(opts.skim));
342
+ if (!file) usage(1, `No session file matching id prefix "${opts.skim}" under: ${roots.join(', ')}`);
343
+ const messages = parseMessages(fs.readFileSync(file, 'utf8'), sourceOf(file));
344
+ const lines = messages.map((m, i) => `[${i}] ${m.role}${m.timestamp ? ' ' + String(m.timestamp).slice(0, 16) : ''}: ${truncate(m.text, 200)}`);
345
+ console.log(`skim id=${sessionId(file)} messages=${messages.length} path=${file}`);
346
+ const budget = opts.maxChars - 200;
347
+ const total = lines.reduce((t, l) => t + l.length + 1, 0);
348
+ if (total <= budget) {
349
+ for (const l of lines) console.log(l);
350
+ return;
351
+ }
352
+ const avg = total / lines.length;
353
+ // Budget is authoritative — no minimum floor (codex review: keep>=20 blew small
354
+ // budgets). Head/tail sizes scale down with the budget; middle picks are CENTERED
355
+ // in their strides so low sample counts don't cluster at the start of the middle.
356
+ const keep = Math.max(3, Math.floor(budget / avg));
357
+ const edge = Math.min(10, Math.floor(keep / 3), Math.floor(lines.length / 2));
358
+ const head = Math.max(1, edge);
359
+ const tail = Math.min(Math.max(1, edge), lines.length - head);
360
+ const middleKeep = Math.max(0, keep - head - tail);
361
+ const middle = lines.length - head - tail;
362
+ const stride = middleKeep > 0 ? middle / middleKeep : Infinity;
363
+ const chosen = new Set();
364
+ for (let i = 0; i < head; i++) chosen.add(i);
365
+ for (let i = 0; i < middleKeep; i++) chosen.add(head + Math.min(middle - 1, Math.floor((i + 0.5) * stride)));
366
+ for (let i = lines.length - tail; i < lines.length; i++) chosen.add(i);
367
+ let skipped = 0;
368
+ for (let i = 0; i < lines.length; i++) {
369
+ if (chosen.has(i)) {
370
+ if (skipped) console.log(` ... ${skipped} messages sampled out (drill in with --query on anything above/below) ...`);
371
+ skipped = 0;
372
+ console.log(lines[i]);
373
+ } else {
374
+ skipped++;
375
+ }
376
+ }
377
+ if (skipped) console.log(` ... ${skipped} messages sampled out ...`);
378
+ return;
379
+ }
380
+
381
+ // --overview
382
+ const digests = [];
383
+ for (const file of files) {
384
+ const source = sourceOf(file);
385
+ let raw;
386
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { continue; }
387
+ const messages = parseMessages(raw, source);
388
+ if (!messages.length) continue;
389
+ const first = messages.find((m) => m.role === 'user') ?? messages[0];
390
+ const times = messages.map((m) => timeOf(m.timestamp)).filter((t) => t != null);
391
+ digests.push({
392
+ id: sessionId(file),
393
+ source,
394
+ path: file,
395
+ from: times.length ? new Date(Math.min(...times)).toISOString().slice(0, 16) : '?',
396
+ to: times.length ? new Date(Math.max(...times)).toISOString().slice(0, 16) : '?',
397
+ user: messages.filter((m) => m.role === 'user').length,
398
+ assistant: messages.filter((m) => m.role === 'assistant').length,
399
+ mb: (raw.length / 1e6).toFixed(1),
400
+ opening: truncate(first.text, 220),
401
+ lastTime: times.length ? Math.max(...times) : 0,
402
+ });
403
+ }
404
+ digests.sort((a, b) => b.lastTime - a.lastTime);
405
+ console.log(`sessions=${digests.length} (newest first) — drill in with --skim ID or --query`);
406
+ let size = 0;
407
+ for (const d of digests) {
408
+ const block = `\nid=${d.id} source=${d.source} ${d.from} -> ${d.to} msgs=${d.user}u/${d.assistant}a size=${d.mb}MB\n opening: ${d.opening}`;
409
+ if (size + block.length > opts.maxChars) {
410
+ console.log(`\n... remaining sessions omitted by --max-chars budget`);
411
+ break;
412
+ }
413
+ size += block.length;
414
+ console.log(block);
415
+ }
416
+ }
417
+
418
+ function truncate(s, n) {
419
+ const oneLine = s.replace(/\s+/g, ' ').trim();
420
+ return oneLine.length > n ? `${oneLine.slice(0, n)}...` : oneLine;
421
+ }
422
+
423
+ function timeOf(value) {
424
+ if (!value) return null;
425
+ const t = Date.parse(value);
426
+ return Number.isFinite(t) ? t : null;
427
+ }
428
+
429
+ function parseSince(value) {
430
+ const now = new Date();
431
+ if (value === 'today') return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
432
+ const days = value.match(/^(\d+)d$/);
433
+ if (days) return now.getTime() - Number(days[1]) * 24 * 60 * 60 * 1000;
434
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return Date.parse(`${value}T00:00:00`);
435
+ return null;
436
+ }
437
+
438
+ function compileRegex(pattern, caseSensitive) {
439
+ try {
440
+ return new RegExp(pattern, caseSensitive ? 'u' : 'iu');
441
+ } catch (error) {
442
+ usage(1, `Invalid JavaScript regex: ${error.message}`);
443
+ }
444
+ }
445
+
446
+ function usage(code, msg) {
447
+ if (msg) console.error(msg);
448
+ console.error('Usage: session-grep.mjs --query TEXT [--any] [--regex] [--limit N] [--before N] [--after N] [--role user|assistant|all] [--source claude|codex|all] [--since today|Nd|YYYY-MM-DD] [--sort newest|oldest|file] [--root DIR ...] [--max-chars N] [--include-tools] [--case-sensitive] [--json] | --overview | --skim ID | --session ID --at INDEX | --self-test');
449
+ process.exit(code);
450
+ }
451
+
452
+ // ── self-test ───────────────────────────────────────────────────────────────
453
+ // The skill carries its own verification: builds a synthetic corpus in a temp dir,
454
+ // runs this script against it, and asserts every advertised behavior. Zero deps —
455
+ // works wherever the skill is copied. `node session-grep.mjs --self-test`
456
+ async function selfTest() {
457
+ const { execFileSync } = await import('node:child_process');
458
+ const self = process.argv[1];
459
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-grep-selftest-'));
460
+ const proj = path.join(dir, 'proj');
461
+ fs.mkdirSync(proj, { recursive: true });
462
+ const line = (role, content, ts) => JSON.stringify({ type: role, timestamp: ts, message: { role, content } }) + '\n';
463
+ const text = (t) => [{ type: 'text', text: t }];
464
+
465
+ // Session A: 30 messages; a rare identifier late; a tool_result echo; common words everywhere.
466
+ let a = '';
467
+ for (let i = 0; i < 12; i++) a += line(i % 2 ? 'assistant' : 'user', text(`common sidebar chatter number ${i} about the project`), `2026-06-01T10:${String(i).padStart(2, '0')}:00Z`);
468
+ a += line('assistant', text('the flumoxide bug came from spawnSync returning ENOENT'), '2026-06-01T10:20:00Z');
469
+ a += line('user', [{ type: 'tool_result', content: 'TOOLNOISE flumoxide echoed inside tool output ZEBRAECHO' }], '2026-06-01T10:21:00Z');
470
+ for (let i = 0; i < 12; i++) a += line(i % 2 ? 'assistant' : 'user', text(`more sidebar discussion segment ${i} winding down`), `2026-06-01T11:${String(i).padStart(2, '0')}:00Z`);
471
+ a += line('user', text('final closing message of session alpha'), '2026-06-01T12:00:00Z');
472
+ fs.writeFileSync(path.join(proj, 'aaaa1111.jsonl'), a);
473
+ // Session B: small, distinct.
474
+ fs.writeFileSync(path.join(proj, 'bbbb2222.jsonl'),
475
+ line('user', text('opening question about quixotic deployment'), '2026-06-05T09:00:00Z') +
476
+ line('assistant', text('quixotic deployment answered with lookahead syntax note'), '2026-06-05T09:01:00Z'));
477
+ // Session C: codex format (exercises the adapter registry + path detection).
478
+ fs.mkdirSync(path.join(dir, 'codex'), { recursive: true });
479
+ const codexLine = (role, t, ts) => JSON.stringify({ type: 'response_item', timestamp: ts, payload: { type: 'message', role, content: [{ type: 'output_text', text: t }] } }) + '\n';
480
+ fs.writeFileSync(path.join(dir, 'codex', 'rollout-cccc.jsonl'),
481
+ codexLine('assistant', 'zorptastic reply straight from the codex adapter', '2026-06-07T08:00:00Z'));
482
+
483
+ const run = (args) => execFileSync(process.execPath, [self, ...args, '--root', dir], { encoding: 'utf8' });
484
+ let n = 0;
485
+ const failures = [];
486
+ const check = (name, cond) => { n++; if (!cond) failures.push(name); };
487
+
488
+ try {
489
+ // literal + context + truthful shown count
490
+ const lit = JSON.parse(run(['--query', 'flumoxide', '--json']));
491
+ check('literal finds text block', lit.matches.some((m) => m.match.text.includes('spawnSync')));
492
+ check('shown equals matches length', lit.shown === lit.matches.length);
493
+ check('tool_result excluded by default', !lit.matches.some((m) => m.match.text.includes('TOOLNOISE')));
494
+ const withTools = JSON.parse(run(['--query', 'ZEBRAECHO', '--json', '--include-tools']));
495
+ check('--include-tools matches tool output', withTools.totalMatches === 1);
496
+ const withoutTools = JSON.parse(run(['--query', 'ZEBRAECHO', '--json']));
497
+ check('tool-only needle invisible by default', withoutTools.totalMatches === 0);
498
+
499
+ // --any: rarity ranking + dedupe
500
+ const any = JSON.parse(run(['--query', 'sidebar flumoxide sidebar', '--any', '--json']));
501
+ check('any dedupes words', Object.keys(any.wordHits).length === 2);
502
+ check('rare word ranks first', any.matches[0].matchedWords.includes('flumoxide'));
503
+ check('word df counted', any.wordHits.sidebar > any.wordHits.flumoxide);
504
+
505
+ // budget enforcement + omission notice
506
+ const tiny = run(['--query', 'sidebar', '--limit', '30', '--max-chars', '600']);
507
+ check('budget respected (<=600+slack)', tiny.length <= 900);
508
+ check('omission notice present', tiny.includes('omitted by the 600-char output budget'));
509
+ const tinyShown = Number(tiny.match(/shown=(\d+)/)[1]);
510
+ check('header shown = emitted blocks', (tiny.match(/\n\[\d+\]/g) || []).length === tinyShown);
511
+
512
+ // zero-hit hint
513
+ const miss = run(['--query', 'totally absent phrase here']);
514
+ check('multi-word miss hints --any', miss.includes('retry with --any'));
515
+
516
+ // regex incl. JS-only syntax (lookahead) falling back past rg
517
+ const la = JSON.parse(run(['--regex', '--query', 'quixotic(?= deployment)', '--json']));
518
+ check('JS-only regex still matches via fallback', la.totalMatches === 2);
519
+
520
+ // overview + spine
521
+ const ov = run(['--overview']);
522
+ check('overview lists both sessions', ov.includes('aaaa1111') && ov.includes('bbbb2222'));
523
+ const spine = run(['--skim', 'aaaa1111', '--max-chars', '900']);
524
+ check('skim within budget (+slack)', spine.length <= 1400);
525
+ check('skim keeps head', spine.includes('number 0'));
526
+ check('skim keeps tail', spine.includes('session alpha'));
527
+
528
+ // role filter still works
529
+ const role = JSON.parse(run(['--query', 'sidebar', '--role', 'user', '--json']));
530
+ check('role filter', role.matches.every((m) => m.match.role === 'user'));
531
+
532
+ // adapter registry: codex format parsed, source detected from path, --source filters
533
+ const cx = JSON.parse(run(['--query', 'zorptastic', '--json']));
534
+ check('codex adapter parses', cx.totalMatches === 1 && cx.matches[0].source === 'codex');
535
+ const cxOnly = JSON.parse(run(['--query', 'zorptastic', '--source', 'claude', '--json']));
536
+ check('--source filters by adapter', cxOnly.totalMatches === 0);
537
+
538
+ // pointer drill-in: consume a hit's id+idx via --session/--at
539
+ const hit = JSON.parse(run(['--query', 'flumoxide', '--json'])).matches[0];
540
+ const win = run(['--session', hit.id.slice(0, 6), '--at', String(hit.index)]);
541
+ check('window centers on the hit', win.includes(`[${hit.index}]*`) && win.includes('flumoxide'));
542
+ check('window includes neighbors', win.includes(`[${hit.index - 1}] `) && win.includes(`[${hit.index + 1}] `));
543
+ } catch (error) {
544
+ failures.push(`crashed: ${error.message}`);
545
+ } finally {
546
+ fs.rmSync(dir, { recursive: true, force: true });
547
+ }
548
+
549
+ if (failures.length) {
550
+ console.error(`self-test: ${failures.length}/${n} FAILED:\n - ${failures.join('\n - ')}`);
551
+ return 1;
552
+ }
553
+ console.log(`self-test: ok — ${n} assertions passed`);
554
+ return 0;
555
+ }