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 +21 -0
- package/README.md +68 -0
- package/bin/session-grep.mjs +4 -0
- package/package.json +41 -0
- package/skills/session-grep/SKILL.md +98 -0
- package/skills/session-grep/adapters/_shared.mjs +19 -0
- package/skills/session-grep/adapters/claude.mjs +15 -0
- package/skills/session-grep/adapters/codex.mjs +17 -0
- package/skills/session-grep/session-grep.mjs +555 -0
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
|
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
|
+
}
|