@gcunharodrigues/wrxn 0.19.0 → 0.20.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wrxn": "bin/wrxn.cjs"
|
|
@@ -49,6 +49,91 @@ function findInstallRoot(startDir) {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ── skip-log (#104) ──────────────────────────────────────────────────────────────
|
|
53
|
+
// The #45 `wx` claim blocks a re-spawn SILENTLY, so a benign dedup skip is invisible in the synth log.
|
|
54
|
+
// When a fire skips because the marker is already present, append ONE row to .wrxn/continuity/.synth.log so
|
|
55
|
+
// the dedup is diagnosable. The row matches memory-synth.cjs's writeSynthLog shape EXACTLY — tab-separated
|
|
56
|
+
// six fields: ISO timestamp, session id, task `handoff`, engine `-`, `attempts=0`, outcome `skip`. The
|
|
57
|
+
// session id is sanitized via the same idiom as memory-synth's sanitizeLogField (strip C0/C1 control chars —
|
|
58
|
+
// which INCLUDES tab \x09 and newline \x0a — then length-cap) so a hostile id can't forge extra rows. `skip`
|
|
59
|
+
// is a NON-failure token (the #51 staleness guard treats only no-engine/error as rot), so a dedup never
|
|
60
|
+
// false-warns. Self-contained — node stdlib only, NO kernel import; sanitizeLogField is duplicated by design
|
|
61
|
+
// (every install-only hook is standalone, exactly as safeId is replicated across the hooks).
|
|
62
|
+
const SYNTH_LOG = '.synth.log';
|
|
63
|
+
const LOG_FIELD_MAX = 64;
|
|
64
|
+
|
|
65
|
+
function sanitizeLogField(v) {
|
|
66
|
+
// eslint-disable-next-line no-control-regex
|
|
67
|
+
return String(v == null ? '' : v).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, LOG_FIELD_MAX);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── content-versioned claim (#105) ───────────────────────────────────────────────
|
|
71
|
+
// Slice 1 (#104) released the `.spawned-<sid>` marker on SessionStart so a resumed session's later end could
|
|
72
|
+
// re-synth. When that release is MISSED (e.g. a multi-terminal continuation whose new process never released
|
|
73
|
+
// the claim), the existence-only marker freezes the baton with no recovery. Slice 2 makes the dedup
|
|
74
|
+
// CONTENT-AWARE: stamp the marker with the session transcript's byte size (+ a timestamp) at claim time, then
|
|
75
|
+
// on a marker-present end re-arm the synth when the transcript has GROWN materially since the stamp — a
|
|
76
|
+
// self-heal that needs no SessionStart release. Any fault (absent/unreadable transcript path, stat fault,
|
|
77
|
+
// unparseable marker) falls back to slice 1's existence-only claim and is fully fail-open (never throws,
|
|
78
|
+
// never double-fires on a fault). Self-contained — node stdlib only, NO kernel import.
|
|
79
|
+
//
|
|
80
|
+
// GROWTH_THRESHOLD — the byte growth that counts as "did real work since the stamp". A same-end double-fire
|
|
81
|
+
// appends NOTHING to the transcript (~0 bytes); a resumed session that did real work appends KB–MB of JSONL
|
|
82
|
+
// (each transcript turn — uuid/parentUuid/sessionId/timestamp/message — is hundreds of bytes to several KB).
|
|
83
|
+
// 1 KiB sits comfortably above zero / trailing-byte jitter and well below a single real exchange, so it
|
|
84
|
+
// cleanly separates "no new work" (skip) from "genuine continuation" (re-arm). Conservative by design: the
|
|
85
|
+
// re-arm fires only on a POSITIVE growth signal, so any uncertainty defaults to skip (never double-fire).
|
|
86
|
+
const GROWTH_THRESHOLD = 1024;
|
|
87
|
+
|
|
88
|
+
// The transcript byte size from the SessionEnd payload's transcript_path, or null on ANY fault (absent path,
|
|
89
|
+
// ENOENT, EACCES, …). null = "size unknown" → the caller falls back to the existence-only claim (fail-open).
|
|
90
|
+
function transcriptSize(transcriptPath) {
|
|
91
|
+
try {
|
|
92
|
+
if (!transcriptPath) return null;
|
|
93
|
+
return fs.statSync(transcriptPath).size;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The marker payload stamped at claim/re-arm time: the transcript size + an ISO timestamp. A null size
|
|
100
|
+
// (unreadable transcript) is stamped as-is, so a later read yields no baseline → existence-only fallback.
|
|
101
|
+
function stampContent(size) {
|
|
102
|
+
return JSON.stringify({ size: typeof size === 'number' ? size : null, at: new Date().toISOString() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The size stamped into an existing marker, or null when there is no usable baseline — a #104 zero-byte
|
|
106
|
+
// marker, unparseable JSON, or a null/non-number size. null → the caller falls back to existence-only (skip),
|
|
107
|
+
// so a corrupt/legacy marker can never trigger a re-arm (any uncertainty defaults to skip, never double-fire).
|
|
108
|
+
function readStampedSize(markerPath) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = fs.readFileSync(markerPath, 'utf8');
|
|
111
|
+
if (!raw.trim()) return null;
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
return typeof parsed.size === 'number' ? parsed.size : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Append exactly one tab-separated `skip` row for a marker-present dedup. Best-effort + FAIL-OPEN: a logging
|
|
120
|
+
// fault is swallowed so it can NEVER affect the dedup (the diagnosability log must not become a failure mode).
|
|
121
|
+
function appendSkipLog(dir, sessionId) {
|
|
122
|
+
try {
|
|
123
|
+
const line = [
|
|
124
|
+
new Date().toISOString(),
|
|
125
|
+
sanitizeLogField(sessionId) || '-', // untrusted stash value — strip control chars, cap length.
|
|
126
|
+
'handoff',
|
|
127
|
+
'-',
|
|
128
|
+
'attempts=0',
|
|
129
|
+
'skip',
|
|
130
|
+
].join('\t');
|
|
131
|
+
fs.appendFileSync(path.join(dir, SYNTH_LOG), line + '\n');
|
|
132
|
+
} catch {
|
|
133
|
+
/* the skip log is best-effort — a write fault must never affect the dedup */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
52
137
|
/**
|
|
53
138
|
* The testable core. Given the SessionEnd payload, the install root, the env (for the recursion guard),
|
|
54
139
|
* and an injectable `spawn`, it stashes the payload + writes the pending markers and launches the synth
|
|
@@ -68,14 +153,52 @@ function run({ payload, root, env = process.env, spawn: spawnFn = spawn }) {
|
|
|
68
153
|
// Once-per-session claim (#45): the harness fires SessionEnd more than once per session, so an
|
|
69
154
|
// unguarded hook launches N synths that race on one shared `.pending`. CLAIM the session ATOMICALLY
|
|
70
155
|
// with an exclusive create (`wx` throws EEXIST if the marker exists) BEFORE staging markers / spawning:
|
|
71
|
-
// the FIRST fire creates the marker and proceeds;
|
|
72
|
-
//
|
|
73
|
-
// processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must
|
|
74
|
-
// is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A
|
|
75
|
-
// cannot be deduped → preserve today's spawn-every-time behavior for that path.
|
|
156
|
+
// the FIRST fire creates+stamps the marker and proceeds; a later fire for the same session throws EEXIST,
|
|
157
|
+
// handled below by the content-aware dedup (#105: re-arm if the transcript grew, else skip). Race-safe
|
|
158
|
+
// across the separate hook processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must
|
|
159
|
+
// OUTLIVE the synth, so it is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A
|
|
160
|
+
// missing/empty session_id cannot be deduped → preserve today's spawn-every-time behavior for that path.
|
|
76
161
|
const sid = payload && payload.session_id;
|
|
77
162
|
if (sid) {
|
|
78
|
-
|
|
163
|
+
const markerPath = path.join(dir, `.spawned-${safeId(sid)}`);
|
|
164
|
+
const currentSize = transcriptSize(payload && payload.transcript_path); // null on any fault → fallback
|
|
165
|
+
try {
|
|
166
|
+
// Atomic exclusive claim (wx is TOCTOU-free across the separate hook processes, #45) — and STAMP it
|
|
167
|
+
// with the transcript byte size + a timestamp (#105), so a later marker-present end can tell a genuine
|
|
168
|
+
// continuation (transcript grew) from a same-end duplicate. Write to the just-claimed fd so the create
|
|
169
|
+
// and the stamp are one operation on the file we exclusively own.
|
|
170
|
+
const fd = fs.openSync(markerPath, 'wx');
|
|
171
|
+
try {
|
|
172
|
+
fs.writeSync(fd, stampContent(currentSize));
|
|
173
|
+
} finally {
|
|
174
|
+
fs.closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err && err.code === 'EEXIST') {
|
|
178
|
+
// The session is already claimed — a same-instance double-fire (#45) OR a resume's later end before
|
|
179
|
+
// SessionStart released the marker. Content-aware dedup (#105): re-arm ONLY on a positive growth
|
|
180
|
+
// signal — if the transcript grew past the threshold since the stamp, this is a genuine continuation
|
|
181
|
+
// (a missed release), so RE-STAMP to the new size and fall through to spawn (the baton un-freezes).
|
|
182
|
+
// Otherwise — no material growth, OR an indeterminate baseline/size from a fault — it is a same-end
|
|
183
|
+
// duplicate: log ONE benign `skip` row (#104) and no-op. Any uncertainty defaults to skip, so a
|
|
184
|
+
// stat/read fault can never double-fire the synth.
|
|
185
|
+
const stampedSize = readStampedSize(markerPath);
|
|
186
|
+
if (stampedSize != null && currentSize != null && currentSize - stampedSize > GROWTH_THRESHOLD) {
|
|
187
|
+
try {
|
|
188
|
+
fs.writeFileSync(markerPath, stampContent(currentSize)); // re-stamp the new baseline
|
|
189
|
+
} catch {
|
|
190
|
+
/* re-stamp is best-effort: a write fault must not block the re-arm — still spawn (PRD: the rare
|
|
191
|
+
double-spawn is bounded by the in-flight .pending markers + the synth's once-per-session guard) */
|
|
192
|
+
}
|
|
193
|
+
// fall through (do NOT return) → stash the pending markers + spawn the synth.
|
|
194
|
+
} else {
|
|
195
|
+
appendSkipLog(dir, sid);
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
throw err; // any OTHER claim fault → fail-open via the outer catch (not a dedup → no skip row)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
79
202
|
}
|
|
80
203
|
|
|
81
204
|
// Stash the payload as .pending (the synth reads it for transcript_path) and raise the handoff gate.
|
|
@@ -93,6 +93,38 @@ function stampStartHead(root, sessionId, opts = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ── re-arm the SessionEnd synth on resume (#104) ─────────────────────────────────
|
|
97
|
+
// The SessionEnd spawn hook (memory-synth-spawn.cjs) dedups via a PERSISTENT per-session marker
|
|
98
|
+
// .wrxn/continuity/.spawned-<sid>. But SessionEnd fires once per PROCESS instance, not per session id — a
|
|
99
|
+
// resume reopens the SAME id in a NEW process, so the resumed session's later (content-rich) end hits the
|
|
100
|
+
// existing marker, is swallowed fail-open, and never re-synths: the baton FREEZES at the first end. The
|
|
101
|
+
// fix: SessionStart releases (deletes) the marker on EVERY start, so the next SessionEnd for this id is free
|
|
102
|
+
// to synth again. A fresh id (startup / clear) has no marker → no-op. The spawn hook's WITHIN-instance `wx`
|
|
103
|
+
// claim (the same-process #45 guard) is untouched — only this cross-process persistent marker is released
|
|
104
|
+
// between sessions. The marker name is keyed by safeId, byte-identical to the spawn hook's writer, so the
|
|
105
|
+
// release deletes EXACTLY what that hook wrote.
|
|
106
|
+
|
|
107
|
+
const CONTINUITY_DIR_REL = ['.wrxn', 'continuity'];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Release (delete) the session's once-per-session synth claim marker at
|
|
111
|
+
* <root>/.wrxn/continuity/.spawned-<safeId(sessionId)>, re-arming the SessionEnd synth for a resumed
|
|
112
|
+
* session id (#104). Returns true when a marker was removed; false on any fail-open path (no root/session,
|
|
113
|
+
* absent marker, unwritable / any fault). NEVER throws — orientation must never block on the release.
|
|
114
|
+
* @param {string} root install root
|
|
115
|
+
* @param {string} sessionId the session id (marker key)
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function releaseSpawnClaim(root, sessionId) {
|
|
119
|
+
try {
|
|
120
|
+
if (!root || !sessionId) return false;
|
|
121
|
+
fs.unlinkSync(path.join(root, ...CONTINUITY_DIR_REL, `.spawned-${safeId(sessionId)}`));
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false; // absent marker (no-op) / unwritable / any delete fault → fail-open, never blocks orientation
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
96
128
|
// Walk up from CLAUDE_PROJECT_DIR (or cwd) to the install root carrying wrxn.install.json.
|
|
97
129
|
function findInstallRoot(startDir) {
|
|
98
130
|
let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -348,6 +380,15 @@ function main() {
|
|
|
348
380
|
/* never block orientation on the baseline stamp */
|
|
349
381
|
}
|
|
350
382
|
|
|
383
|
+
// #104: release the SessionEnd synth's once-per-session claim so a RESUMED session (same id, new process)
|
|
384
|
+
// re-synths its later, content-rich end instead of freezing the baton at the first end. A fresh id
|
|
385
|
+
// (startup / clear) has no marker → no-op. Fail-open: any fault is swallowed and orientation proceeds.
|
|
386
|
+
try {
|
|
387
|
+
if (event && event.session_id) releaseSpawnClaim(root, event.session_id);
|
|
388
|
+
} catch {
|
|
389
|
+
/* never block orientation on the claim release */
|
|
390
|
+
}
|
|
391
|
+
|
|
351
392
|
// Hold for an in-flight SessionEnd synth (auto-memory-03) so a back-to-back /clear resumes on the
|
|
352
393
|
// FRESH baton, bounded by the crash safety-cap. Fail-open: any fault here must not block orientation.
|
|
353
394
|
try {
|
|
@@ -399,4 +440,4 @@ if (require.main === module) {
|
|
|
399
440
|
}
|
|
400
441
|
}
|
|
401
442
|
|
|
402
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|
|
443
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, releaseSpawnClaim, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: chat-search
|
|
3
|
-
description: On-demand retrieval over the Conversational log — grep an exact past moment (timestamp · session · role · snippet)
|
|
3
|
+
description: On-demand retrieval over the Conversational log — grep an exact past moment (timestamp · session · role · snippet) across both arms: the event log AND the harness transcript (user and assistant turns). Operator- and agent-invocable; never an automatic hook.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -10,9 +10,22 @@ user-invocable: true
|
|
|
10
10
|
|
|
11
11
|
> Scrollback, not memory. The Conversational log is ephemeral and pruned (~90d). Durable, portable memory remains the committed wiki — use the `memory` skill for that.
|
|
12
12
|
|
|
13
|
-
## Scope
|
|
13
|
+
## Scope — both arms of the Conversational log
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
`chat-search` reads **both arms** and merges them into one recency-first result:
|
|
16
|
+
|
|
17
|
+
- **Event log** — `.wrxn/events/*.jsonl`, the per-session, secret-redacted **user prompts** emit-event.cjs appends.
|
|
18
|
+
- **Harness transcript** — `~/.claude/projects/<slug>/*.jsonl`, the only source of **assistant** turns and full user/assistant **message content**. The `<slug>` is the project's absolute path with every non-alphanumeric character replaced by `-` (how the harness names the dir). The transcript arm is **hygiene-cleaned** before matching (see below).
|
|
19
|
+
|
|
20
|
+
A prompt that appears in **both** arms is de-duplicated — by `(session, timestamp, text)` — so it surfaces once. If the transcript dir is **missing or unreadable**, the search **degrades loudly to events-only** and says so in the output (it never crashes).
|
|
21
|
+
|
|
22
|
+
Three optional flags refine a search — `--session` (scope to one session), `--since` (a time floor), and `--regex` (pattern match instead of substring). They compose with each other and with both arms. See **Scoping & match flags** below.
|
|
23
|
+
|
|
24
|
+
### Transcript hygiene (so a hit is real conversation, not framework noise)
|
|
25
|
+
|
|
26
|
+
- **Injected context is stripped** before matching: `<wrxn-orientation>`, `<synapse-rules>`, `<recall-surface>`, `<reference-candidate>`, and `<system-reminder>` blocks the hooks inject each turn are removed, so a term that lives **only inside** an injected block is **not** a hit.
|
|
27
|
+
- **Secrets are redacted** (the same canonical secret-shape set the memory adapters use): a credential echoed into chat never appears in a snippet.
|
|
28
|
+
- Only `text` content is searched — `thinking` / `tool_use` / `tool_result` blocks and unknown line `type`s (`summary`, `system`, …) are dropped.
|
|
16
29
|
|
|
17
30
|
## Invocation
|
|
18
31
|
|
|
@@ -25,15 +38,30 @@ This slice reads **one arm** of the Conversational log: the **event log** at `.w
|
|
|
25
38
|
### Engine (what the skill runs)
|
|
26
39
|
|
|
27
40
|
```bash
|
|
28
|
-
node .wrxn/chat-search.cjs <search-term...> [--root <dir>]
|
|
41
|
+
node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]
|
|
29
42
|
```
|
|
30
43
|
|
|
31
|
-
The engine resolves the install root by walking up to the `wrxn.install.json` receipt, so it runs from anywhere inside an install. Pass `--root <dir>` to override (mainly for tests). It prints the rendered result and exits 0 — a nothing-found result is a normal outcome, never an error exit.
|
|
44
|
+
The engine resolves the install root by walking up to the `wrxn.install.json` receipt, so it runs from anywhere inside an install. Pass `--root <dir>` to override (mainly for tests). Flags follow the search term(s). It prints the rendered result and exits 0 — a nothing-found result is a normal outcome, never an error exit. **Invalid flag input** (a malformed/catastrophic `--regex`, an unparseable `--since`, an unsafe `--session`) fails **loud**: one clear line on stderr and a non-zero exit — never a stack trace or a hang.
|
|
32
45
|
|
|
33
46
|
### Agent (self-invocation)
|
|
34
47
|
|
|
35
48
|
When the operator references an earlier moment ("like we discussed", "the decision from this morning") and you need the **exact** wording rather than a guess, run the engine and ground on what was actually said. Reach for it the same way you reach for `recon_find` — deliberately, when a past moment is needed.
|
|
36
49
|
|
|
50
|
+
## Scoping & match flags
|
|
51
|
+
|
|
52
|
+
All three are optional, compose with each other, and apply across **both arms** (so scoping/filtering also covers assistant turns), preserving recency order and cross-arm dedup.
|
|
53
|
+
|
|
54
|
+
- **`--session <id>`** — scope results to a single session (exact match on the session id). The id is the harness/event session id (letters, digits, `-`, `_`); a malformed id is rejected. Scoped rows render as `this session`.
|
|
55
|
+
- **`--since <when>`** — keep only hits at or after a timestamp floor. `<when>` is either `today` (from 00:00 **UTC** of the current day — record stamps are UTC) or an ISO-8601 date/datetime (e.g. `2026-06-26` or `2026-06-26T12:00:00Z`). An undatable hit is excluded.
|
|
56
|
+
- **`--regex`** — match the search term as a **regular expression** instead of a case-insensitive substring. Regex mode is **case-sensitive** (the universal regex default; the substring default stays case-insensitive).
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node .wrxn/chat-search.cjs "gate.*decision" --regex --since today
|
|
60
|
+
node .wrxn/chat-search.cjs baton echo --session 9dc65f19-65fb-43cb-81fa-0340353f1cc5
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **`--regex` safety (ReDoS).** The pattern is user-supplied and runs over whole transcripts, so it is bounded at compile (before any input is matched): it is **length-capped** (≤ 200 chars) and runs through a **nesting-aware static screen** that rejects a quantified group whose body repeats or alternates through **any** nesting depth — both flat shapes (`(a+)+`, `(a|a)+`, `(?:a*)*`) and nested ones a flat check misses (`((a)+)+`, `((\w)+)+$`, `((a+))+`, `(a(b+)c)+`) — plus **backreferences** (`\1`…`\9`). A rejected, malformed, or unparseable value fails loud. The screen is deliberately conservative (it also refuses the rare-but-safe `(foo|bar)+` — drop the outer quantifier), but does not over-reject ordinary grouping: `(foo|bar)`, `(ab)+`, `([a+])+`, `(?:ab)+`, `a{1,5}`, `\d{4}-\d{2}-\d{2}` are all fine. **Residual:** a static screen models structure, not match semantics, so under the no-timeout / `fs`-`os`-`path`-only constraint it refuses the known catastrophic shapes rather than proving a pattern safe; an exotic construct outside those shapes is not modelled.
|
|
64
|
+
|
|
37
65
|
## Output
|
|
38
66
|
|
|
39
67
|
Hits are **most-recent-first**, one per line:
|
|
@@ -42,17 +70,18 @@ Hits are **most-recent-first**, one per line:
|
|
|
42
70
|
<timestamp> · <session (or "this session")> · <role> · <snippet (±1 line context)>
|
|
43
71
|
```
|
|
44
72
|
|
|
45
|
-
- `role` is `user`
|
|
73
|
+
- `role` is `user` (event log or a user transcript turn) or `assistant` (a transcript turn).
|
|
46
74
|
- The session column collapses to `this session` for hits from the active session.
|
|
47
75
|
- No match → an explicit `chat-search: nothing found for "<term>" ...` line (never silence, never a crash).
|
|
76
|
+
- If the transcript arm is unavailable, a trailing `chat-search: transcript arm unavailable — showing event-log results only.` line is appended (loud degrade).
|
|
48
77
|
|
|
49
78
|
## Boundaries
|
|
50
79
|
|
|
51
|
-
- **Pure in-process grep.** No Brain, no embeddings, no recon/serve/daemon, no network (ADR 0008).
|
|
52
|
-
- **Read-only.** It never writes a wiki page, never mutates the event log.
|
|
80
|
+
- **Pure in-process grep.** No Brain, no embeddings, no recon/serve/daemon, no network (ADR 0008). Node stdlib only (`fs` + `os` + `path`).
|
|
81
|
+
- **Read-only.** It never writes a wiki page, never mutates the event log or the transcript.
|
|
53
82
|
- **Never an automatic hook.** Invoked only by the operator or the agent, deliberately (ADR 0002).
|
|
54
|
-
- Default scope is **this project's sessions** — every `.jsonl` under `.wrxn/events
|
|
83
|
+
- Default scope is **this project's sessions** — every `.jsonl` under `.wrxn/events/` plus this project's harness transcript under `~/.claude/projects/<slug>/`. The slug is **path-bounded** (it can never escape `~/.claude/projects`). Cross-project search is out of scope.
|
|
55
84
|
|
|
56
85
|
## Source
|
|
57
86
|
|
|
58
|
-
WRXN Kernel
|
|
87
|
+
WRXN Kernel issues #83 (slice 1 — event-log arm) + #84 (slice 2 — harness-transcript arm + hygiene) + #85 (slice 3 — scoping + match flags), under PRD #82. Engine: `.wrxn/chat-search.cjs`. Grounded by `docs/adr/0008-chat-search-ondemand-grep-outside-brain.md` and the `CONTEXT.md` terms **Conversational log** / **chat-search**.
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
// WRXN chat-search engine — on-demand retrieval over the Conversational log (kernel #83, slice
|
|
4
|
+
// WRXN chat-search engine — on-demand retrieval over the Conversational log (kernel #83 slice 1, #84 slice 2).
|
|
5
5
|
// Self-contained: this ships INTO an install and MUST NOT import the kernel lib (node stdlib only).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// It scans BOTH arms of the Conversational log:
|
|
8
|
+
// · the EVENT-LOG arm — <root>/.wrxn/events/*.jsonl, the per-session, pre-redacted user-prompt records
|
|
9
|
+
// emit-event.cjs appends (slice 1);
|
|
10
|
+
// · the HARNESS-TRANSCRIPT arm — ~/.claude/projects/<slug>/*.jsonl, the only source of ASSISTANT turns
|
|
11
|
+
// and full user/assistant message content (slice 2 / #84). Its output is injected-context-stripped +
|
|
12
|
+
// secret-redacted, and a prompt present in both arms is de-duplicated.
|
|
13
|
+
// Pure in-process fs scan: no Brain, no embeddings, no recon/serve dependency, no network (ADR 0008). It
|
|
14
|
+
// degrades LOUDLY to events-only if the transcript dir is absent/unreadable. Never wired as a per-prompt
|
|
15
|
+
// hook — it is invoked deliberately, by the operator or the agent (ADR 0002 boundary).
|
|
11
16
|
|
|
12
17
|
const fs = require('fs');
|
|
18
|
+
const os = require('os'); // home-dir resolution only (the transcript arm lives under ~/.claude/projects) — NOT network/daemon/embedding.
|
|
13
19
|
const path = require('path');
|
|
14
20
|
|
|
15
21
|
// Only prompt records carry text in the event arm, so only they can match. role is derived from kind:
|
|
@@ -18,12 +24,13 @@ function roleFromKind(kind) {
|
|
|
18
24
|
return kind === 'prompt' ? 'user' : String(kind || '');
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
// A snippet is the matching line plus ±1 line of context within the message text
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
27
|
+
// A snippet is the matching line plus ±1 line of context within the message text. `matchesLine` is the
|
|
28
|
+
// active matcher (case-insensitive substring by default, or the compiled --regex), so the snippet finds the
|
|
29
|
+
// SAME line the scan matched on. A single-line prompt collapses to just that line. A match that only spans a
|
|
30
|
+
// line boundary has no single matching line → fall back to the trimmed whole text (defensive; rare).
|
|
31
|
+
function snippetFor(text, matchesLine) {
|
|
25
32
|
const lines = String(text).split('\n');
|
|
26
|
-
const i = lines.findIndex((l) => l
|
|
33
|
+
const i = lines.findIndex((l) => matchesLine(l));
|
|
27
34
|
if (i === -1) return String(text).trim();
|
|
28
35
|
const from = Math.max(0, i - 1);
|
|
29
36
|
const to = Math.min(lines.length - 1, i + 1);
|
|
@@ -64,16 +71,287 @@ function listEventFiles(dir) {
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
// ── injected-context strip (REUSE-by-replication of memory-synth's #62 sentinel-strip) ──────────
|
|
75
|
+
// REUSE > ADAPT > CREATE: this is COPIED (mirror, not import) from memory-synth.cjs's stripInjectedContext
|
|
76
|
+
// (the slice-1 self-containment convention — the engine replicates findInstallRoot rather than importing,
|
|
77
|
+
// and importing memory-synth would transitively pull in https + child_process, breaching the ADR-0008
|
|
78
|
+
// no-network/no-daemon boundary this engine's dependency test pins). Hook-injected framework-context
|
|
79
|
+
// sentinels — SessionStart's <wrxn-orientation> (which embeds the prior baton), UserPromptSubmit's
|
|
80
|
+
// <synapse-rules>/<recall-surface>/<reference-candidate>, and <system-reminder> harness notes — land
|
|
81
|
+
// verbatim in the transcript. They are framework noise, not conversation, so a hit inside one is NOT a real
|
|
82
|
+
// hit: strip them BEFORE matching. (#62, #84)
|
|
83
|
+
const INJECTED_SENTINELS = ['wrxn-orientation', 'synapse-rules', 'recall-surface', 'reference-candidate', 'system-reminder'];
|
|
84
|
+
// ReDoS bound (#62): the closed-block strip is ~quadratic on a pathological part (many opens, no closes). A
|
|
85
|
+
// real injected block is a few KB; this cap sits far above that yet below the multi-second regex range, so
|
|
86
|
+
// the strip stays effectively linear even when the engine scans whole transcripts.
|
|
87
|
+
const INJECTED_STRIP_MAX = 65536;
|
|
88
|
+
|
|
89
|
+
// Strip hook-injected framework-context blocks from one text part BEFORE matching. Two phases: (1) every
|
|
90
|
+
// well-delimited <tag>…</tag> block anywhere; (2) a part-LEADING unclosed <tag>… (transcript truncated
|
|
91
|
+
// mid-block) to end-of-part — anchored so a sentinel merely MENTIONED mid-prose keeps its tail. PURE and
|
|
92
|
+
// FAIL-OPEN: any fault returns the input unchanged. Byte-faithful copy of memory-synth.cjs (#62).
|
|
93
|
+
function stripInjectedContext(text) {
|
|
94
|
+
try {
|
|
95
|
+
let out = String(text || '');
|
|
96
|
+
if (out.length > INJECTED_STRIP_MAX) return out; // far larger than any real injected block → ordinary content, nothing part-leading to strip.
|
|
97
|
+
for (const tag of INJECTED_SENTINELS) {
|
|
98
|
+
out = out.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, 'g'), '');
|
|
99
|
+
}
|
|
100
|
+
for (const tag of INJECTED_SENTINELS) {
|
|
101
|
+
out = out.replace(new RegExp(`^\\s*<${tag}>[\\s\\S]*$`), '');
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
} catch {
|
|
105
|
+
return String(text || ''); // a filter fault never breaks the scan
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── secret redaction (REUSE-by-replication of the #39 canonical set) ────────────────────────────
|
|
110
|
+
// The transcript arm derives output from RAW chat, which can echo a credential the operator/agent pasted.
|
|
111
|
+
// Scrub it before any snippet leaves the engine (events are already pre-redacted upstream). The array body
|
|
112
|
+
// below is the ONE canonical secret-shape set, kept BYTE-IDENTICAL to its dream/sync/harvest/memory-synth/
|
|
113
|
+
// sidecar siblings. THIS copy is ENROLLED in adapter-drift-guard.test.cjs's #39 CANON_SITES, so the build
|
|
114
|
+
// fails if it ever drifts from the others — copied, not imported, for the same self-containment reason as
|
|
115
|
+
// stripInjectedContext above. (#39, #84)
|
|
116
|
+
const SECRET_PATTERNS = [
|
|
117
|
+
/AKIA[0-9A-Z]{16}/, // AWS access key id
|
|
118
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/, // GitHub token (ghp_/gho_/ghu_/ghs_/ghr_); {20,} covers the 36-char + CI forms
|
|
119
|
+
/github_pat_[A-Za-z0-9_]{22,}/, // GitHub fine-grained PAT
|
|
120
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack token
|
|
121
|
+
/sk-[A-Za-z0-9]{20,}/, // OpenAI-style secret key
|
|
122
|
+
/sk-proj-[A-Za-z0-9_-]{20,}/, // OpenAI project-scoped key (underscore form sk-… misses)
|
|
123
|
+
/AIza[0-9A-Za-z._-]{10,}/, // Google / Gemini API key
|
|
124
|
+
/sk_(?:live|test)_[A-Za-z0-9]{20,}/, // Stripe live/test secret key
|
|
125
|
+
/npm_[A-Za-z0-9]{20,}/, // npm publish / automation token
|
|
126
|
+
/\bey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{3,}\.[A-Za-z0-9_-]{3,}\b/, // JWT (incl. Bearer payloads); the eyJ… header gates it
|
|
127
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/, // PEM block (FULL — must precede the header fallback so redaction eats the body)
|
|
128
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM header (fallback: a lone/truncated header with no END)
|
|
129
|
+
/Bearer\s+[A-Za-z0-9._~+/=-]{20,}/, // opaque Bearer token (non-JWT)
|
|
130
|
+
/\b[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)\b\s*[:=]\s*\S+/i, // KEY/TOKEN/SECRET/PASSWORD = value
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// Redaction form: every shape made global so EVERY occurrence is scrubbed (each clone preserves its own
|
|
134
|
+
// flags and only ADDS g, so detection and redaction never diverge). Mirrors memory-synth.cjs (#39).
|
|
135
|
+
const REDACTIONS = SECRET_PATTERNS.map((re) => new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
|
|
136
|
+
|
|
137
|
+
// Redact common secret shapes from `text`, each match → `[REDACTED]`. PURE; ordinary prose is preserved.
|
|
138
|
+
function redactSecrets(text) {
|
|
139
|
+
let out = String(text || '');
|
|
140
|
+
for (const re of REDACTIONS) out = out.replace(re, '[REDACTED]');
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── harness-transcript arm (#84) ──────────────────────────────────────────────
|
|
145
|
+
// The transcript lives OUTSIDE the install, under ~/.claude/projects/<slug>/*.jsonl, where the harness
|
|
146
|
+
// derives <slug> from the project's absolute path. The base is injectable (opts.transcriptsHome) so the
|
|
147
|
+
// engine is testable without touching the operator's real ~/.claude.
|
|
148
|
+
function defaultTranscriptsHome() {
|
|
149
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Map an install root to its harness transcript dir <home>/<slug>. The slug mirrors how Claude Code names
|
|
153
|
+
// ~/.claude/projects/<slug>: every NON-alphanumeric char of the absolute root path → '-' (verified against
|
|
154
|
+
// the live dir names). That replacement IS the path-traversal defense — no '/', '\' or '..' can survive it,
|
|
155
|
+
// so a crafted root cannot escape the base. The containment check is belt-and-suspenders and ALSO refuses a
|
|
156
|
+
// degenerate empty slug that would otherwise resolve to the base itself and read EVERY project's transcripts
|
|
157
|
+
// (a cross-project leak). A refusal returns null → the caller degrades loudly to events-only. #84.
|
|
158
|
+
function resolveTranscriptDir(root, home) {
|
|
159
|
+
if (!root || !home) return null;
|
|
160
|
+
const base = path.resolve(String(home));
|
|
161
|
+
const slug = String(root).replace(/[^A-Za-z0-9]/g, '-');
|
|
162
|
+
const dir = path.resolve(base, slug);
|
|
163
|
+
if (dir === base || !dir.startsWith(base + path.sep)) return null; // must sit STRICTLY under the base — never the base, never outside it.
|
|
164
|
+
return dir;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Every <sid>.jsonl transcript in a project's harness dir. Throws on an absent/unreadable dir — the caller
|
|
168
|
+
// catches it to DEGRADE LOUDLY to events-only (so it can tell "arm unavailable" from "arm present, empty").
|
|
169
|
+
function listTranscriptFiles(dir) {
|
|
170
|
+
return fs
|
|
171
|
+
.readdirSync(dir)
|
|
172
|
+
.filter((n) => n.endsWith('.jsonl'))
|
|
173
|
+
.map((n) => path.join(dir, n));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Flatten a transcript record's message.content to plain searchable text. Content is a STRING (a plain
|
|
177
|
+
// user/assistant message) OR a text-block ARRAY (only the `text` blocks carry conversation — `thinking` /
|
|
178
|
+
// `tool_use` / `tool_result` blocks are framework noise, not message content, and are dropped). Any other
|
|
179
|
+
// shape → '' (nothing matchable). #84.
|
|
180
|
+
function transcriptText(rec) {
|
|
181
|
+
const m = (rec && rec.message) || {};
|
|
182
|
+
const c = m.content;
|
|
183
|
+
if (typeof c === 'string') return c;
|
|
184
|
+
if (Array.isArray(c)) {
|
|
185
|
+
return c
|
|
186
|
+
.filter((p) => p && typeof p === 'object' && p.type === 'text' && typeof p.text === 'string')
|
|
187
|
+
.map((p) => p.text)
|
|
188
|
+
.join('\n');
|
|
189
|
+
}
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── slice-3 flags (#85): validation + parsing ──────────────────────────────────
|
|
194
|
+
// A user-facing input error. The `userFacing` flag lets the CLI print this message cleanly (one line, no
|
|
195
|
+
// Node stack/path) and exit non-zero — the AC's "invalid flag input fails LOUD, never a crash". Bad DATA
|
|
196
|
+
// stays fail-soft (skipped, never thrown); only bad INPUT (a flag value) fails loud.
|
|
197
|
+
function inputError(msg) {
|
|
198
|
+
const e = new Error(msg);
|
|
199
|
+
e.userFacing = true;
|
|
200
|
+
return e;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Validate a --session id. Scoping is a pure exact-match on each record's session field — the value is NEVER
|
|
204
|
+
// used to build a path — so traversal/widening are structurally impossible; this charset guard makes that
|
|
205
|
+
// explicit (real session ids are the harness UUIDs + event sids: alnum, hyphen, underscore) and fails LOUD
|
|
206
|
+
// on a malformed id rather than silently matching nothing.
|
|
207
|
+
const SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
208
|
+
function validateSession(raw) {
|
|
209
|
+
const s = String(raw);
|
|
210
|
+
if (!SESSION_ID.test(s)) {
|
|
211
|
+
throw inputError(`chat-search: --session value "${raw}" is not a valid session id (expected letters, digits, '-' or '_')`);
|
|
212
|
+
}
|
|
213
|
+
return s;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Parse a --since value into a threshold epoch-ms; a hit survives when its timestamp is >= the threshold.
|
|
217
|
+
// Accepts the keyword `today` (start of the current UTC day — record stamps are UTC `…Z`) or an ISO-8601
|
|
218
|
+
// date/datetime (via Date.parse). An unparseable value fails LOUD (the AC's invalid-input handling).
|
|
219
|
+
function parseSince(raw) {
|
|
220
|
+
const s = String(raw).trim();
|
|
221
|
+
if (s.toLowerCase() === 'today') {
|
|
222
|
+
const d = new Date();
|
|
223
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
224
|
+
return d.getTime();
|
|
225
|
+
}
|
|
226
|
+
const t = Date.parse(s);
|
|
227
|
+
if (Number.isNaN(t)) {
|
|
228
|
+
throw inputError(`chat-search: --since value "${raw}" is not a date — use "today" or an ISO-8601 date (e.g. 2026-06-26)`);
|
|
229
|
+
}
|
|
230
|
+
return t;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── --regex ReDoS bound ──────────────────────────────────────────────────────
|
|
234
|
+
// --regex compiles a USER-supplied pattern and runs it over whole transcripts → classic ReDoS surface. Node
|
|
235
|
+
// has no native regex timeout and ADR-0008 forbids a worker/child_process to bound runtime, so the defense is
|
|
236
|
+
// STATIC and applied at COMPILE (before any input is matched):
|
|
237
|
+
// (1) PATTERN-LENGTH CAP — a legitimate search pattern is short ("(deploy|ship).*gate", "\\bbaton\\b"); 200
|
|
238
|
+
// chars is generous for real use yet bounds how much quantifier nesting an adversarial pattern can pack.
|
|
239
|
+
// (2) NESTING-AWARE CATASTROPHIC-SHAPE SCREEN (isCatastrophicRegex) — reject a *quantified group* whose
|
|
240
|
+
// body, through ANY nesting depth, repeats or alternates (a quantifier + * { ? or an alternation |).
|
|
241
|
+
// This is the catastrophic-backtracking (star-height ≥ 2 / overlapping-alternation-under-a-quantifier)
|
|
242
|
+
// family — both FLAT (a+)+, (a|a)+, (?:a*)* AND the NESTED forms a flat regex screen misses because it
|
|
243
|
+
// cannot cross an inner paren: ((a)+)+ (the OWASP example), ((a+))+, ((\w)+)+$, (a(b+)c)+, (a{1,5})+.
|
|
244
|
+
// Plus backreferences (\1..\9), which can backtrack catastrophically — refused outright.
|
|
245
|
+
// It is deliberately CONSERVATIVE: it also rejects the rare-but-safe outer-quantified alternation (foo|bar)+
|
|
246
|
+
// (drop the outer quantifier to search it). It does NOT over-reject ordinary grouping — (foo|bar), (ab)+,
|
|
247
|
+
// ([a+])+, (?:ab)+, a{1,5}, \d{4}-\d{2}-\d{2} all pass.
|
|
248
|
+
// RESIDUAL (stated honestly): a STATIC screen cannot be airtight under the no-timeout/ADR-0008 constraint —
|
|
249
|
+
// it models group/quantifier structure, not match semantics, so it cannot prove a pattern safe, only refuse
|
|
250
|
+
// the known-dangerous shapes. The catastrophic families that actually occur (flat + nested star-height-2,
|
|
251
|
+
// overlapping alternation under a quantifier, backreferences — incl. every reviewed probe) are refused at
|
|
252
|
+
// compile before any input is matched; an exotic construct outside these structural shapes is not modelled.
|
|
253
|
+
const REGEX_PATTERN_MAX = 200;
|
|
254
|
+
|
|
255
|
+
// Linear (O(pattern length)) structural scan: does `p` contain a quantified group whose body repeats or
|
|
256
|
+
// alternates at any nesting depth, or a backreference? Tracks one frame per open group; `complex` = the
|
|
257
|
+
// group's body holds a quantifier/alternation (bubbled up from descendants), so a quantifier on the group's
|
|
258
|
+
// `)` makes it catastrophic. Skips escapes (\w, \(, …) and char-class interiors ([a+]) where metachars are
|
|
259
|
+
// literal, and the `?` of a group prefix ((?:, (?=, (?<…), which is not a quantifier.
|
|
260
|
+
function isCatastrophicRegex(p) {
|
|
261
|
+
const stack = [];
|
|
262
|
+
for (let i = 0; i < p.length; i++) {
|
|
263
|
+
const c = p[i];
|
|
264
|
+
if (c === '\\') {
|
|
265
|
+
const n = p[i + 1];
|
|
266
|
+
if (n >= '1' && n <= '9') return true; // backreference → refuse
|
|
267
|
+
i++; // skip the escaped atom — never structural
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (c === '[') { // character class: every metachar inside is a literal
|
|
271
|
+
i++;
|
|
272
|
+
if (p[i] === '^') i++;
|
|
273
|
+
if (p[i] === ']') i++; // a leading ] is a literal member
|
|
274
|
+
while (i < p.length && p[i] !== ']') {
|
|
275
|
+
if (p[i] === '\\') i++;
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (c === '(') {
|
|
281
|
+
stack.push({ complex: false });
|
|
282
|
+
if (p[i + 1] === '?') i++; // group prefix ((?:, (?=, (?<…) — its ? is not a quantifier
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (c === ')') {
|
|
286
|
+
const f = stack.pop() || { complex: false };
|
|
287
|
+
const q = p[i + 1];
|
|
288
|
+
if ((q === '+' || q === '*' || q === '?' || q === '{') && f.complex) return true; // quantified group, repeating body
|
|
289
|
+
if (stack.length) stack[stack.length - 1].complex = stack[stack.length - 1].complex || f.complex; // bubble up any depth
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if ((c === '|' || c === '+' || c === '*' || c === '?' || c === '{') && stack.length) {
|
|
293
|
+
stack[stack.length - 1].complex = true; // a quantifier/alternation token in the current group's body
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Compile a user-supplied --regex pattern. Case-sensitive, no g/y flag, so .test() is stateless across the
|
|
300
|
+
// thousands of calls the scan makes. A too-long / catastrophic / malformed pattern fails LOUD (the AC's
|
|
301
|
+
// invalid-input handling) — the operator sees one clean line, never a stack or a multi-second hang.
|
|
302
|
+
function compileUserRegex(pattern) {
|
|
303
|
+
const p = String(pattern);
|
|
304
|
+
if (p.length > REGEX_PATTERN_MAX) {
|
|
305
|
+
throw inputError(`chat-search: --regex pattern too long (max ${REGEX_PATTERN_MAX} chars)`);
|
|
306
|
+
}
|
|
307
|
+
if (isCatastrophicRegex(p)) {
|
|
308
|
+
throw inputError('chat-search: --regex pattern rejected — a quantified group whose body repeats or alternates (or a backreference) risks catastrophic backtracking (ReDoS); simplify the pattern');
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return new RegExp(p);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
throw inputError(`chat-search: --regex pattern is not a valid regular expression (${err.message})`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
67
317
|
// ── the engine seam ───────────────────────────────────────────────────────────
|
|
68
|
-
// searchConversationalLog(query, opts, roots): scan
|
|
69
|
-
//
|
|
70
|
-
//
|
|
318
|
+
// searchConversationalLog(query, opts, roots): scan BOTH arms across the given roots' sessions and return
|
|
319
|
+
// the turns that match `query` — a case-insensitive substring by default, or (opts.regex) the compiled
|
|
320
|
+
// pattern. opts also carries the slice-3 filters: opts.session scopes to one session, opts.since is a
|
|
321
|
+
// timestamp floor; both are applied per-record so they compose with the arms, recency, and dedup. A bad
|
|
322
|
+
// flag value (invalid/catastrophic regex, unparseable --since, unsafe --session) throws a user-facing
|
|
323
|
+
// error (fail loud). Each hit is a structured record { ts, session, role, snippet }.
|
|
71
324
|
function searchConversationalLog(query, opts, roots) {
|
|
72
325
|
const q = String(query == null ? '' : query);
|
|
73
326
|
const needle = q.toLowerCase();
|
|
74
327
|
const hits = [];
|
|
328
|
+
const seen = new Set(); // a prompt present in BOTH arms (same session+ts+text) must surface only once.
|
|
329
|
+
let degraded = false; // set when the transcript arm could not be consulted → loud events-only degrade (#84).
|
|
330
|
+
|
|
331
|
+
// ── slice-3 filters (#85): each is an opts field applied per-record inside consider(), so it composes
|
|
332
|
+
// automatically with BOTH arms, the recency sort, and the cross-arm dedup. --session scopes to one session. ──
|
|
333
|
+
const sessionScope = opts && opts.session != null ? validateSession(opts.session) : null;
|
|
334
|
+
const sinceThreshold = opts && opts.since != null ? parseSince(opts.since) : null; // --since: epoch-ms floor
|
|
335
|
+
const useRegex = !!(opts && opts.regex);
|
|
336
|
+
const re = useRegex ? compileUserRegex(q) : null; // --regex: compile once (throws loud on a bad/dangerous pattern)
|
|
337
|
+
// The one matcher used for BOTH the whole-text scan gate and the per-line snippet — substring or regex.
|
|
338
|
+
const lineMatches = useRegex ? (s) => re.test(String(s)) : (s) => String(s).toLowerCase().includes(needle);
|
|
339
|
+
|
|
340
|
+
// Push a hit when `text` contains the needle and this (session, timestamp, text) triple is new. `text` is
|
|
341
|
+
// the FULL message text already made surface-safe by its arm (events are pre-redacted; transcript turns are
|
|
342
|
+
// injected-stripped + secret-redacted before they reach here), so the snippet it yields is safe to render.
|
|
343
|
+
function consider(ts, session, role, text) {
|
|
344
|
+
if (sessionScope != null && session !== sessionScope) return; // --session: exact-match a single session
|
|
345
|
+
if (typeof text !== 'string' || !lineMatches(text)) return; // --regex pattern, else case-insensitive substring
|
|
346
|
+
if (sinceThreshold != null && !(Date.parse(ts) >= sinceThreshold)) return; // --since: drop hits before the floor (an undatable ts → NaN → dropped)
|
|
347
|
+
const key = `${session}${ts}${text}`; // the AC dedup key — NUL-joined so the parts can't bleed.
|
|
348
|
+
if (seen.has(key)) return;
|
|
349
|
+
seen.add(key);
|
|
350
|
+
hits.push({ ts, session, role, snippet: snippetFor(text, lineMatches) });
|
|
351
|
+
}
|
|
75
352
|
|
|
76
353
|
for (const root of normalizeRoots(roots)) {
|
|
354
|
+
// ── event-log arm (slice 1): pre-redacted user prompts under <root>/.wrxn/events ──
|
|
77
355
|
for (const file of listEventFiles(eventsDirOf(root))) {
|
|
78
356
|
let lines;
|
|
79
357
|
try {
|
|
@@ -90,8 +368,47 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
90
368
|
continue; // skip a malformed line, never crash the scan
|
|
91
369
|
}
|
|
92
370
|
if (!rec || rec.kind !== 'prompt' || typeof rec.text !== 'string') continue; // ONLY prompt records match — a non-prompt (e.g. tool) record is skipped even if it carries a stray text field (#87)
|
|
93
|
-
|
|
94
|
-
|
|
371
|
+
consider(rec.ts, rec.sid, roleFromKind(rec.kind), rec.text); // events are pre-redacted upstream — pass through as-is.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── harness-transcript arm (#84): assistant turns + full user/assistant message content ──
|
|
376
|
+
const home = (opts && opts.transcriptsHome) || defaultTranscriptsHome();
|
|
377
|
+
const tdir = resolveTranscriptDir(root, home);
|
|
378
|
+
let tfiles = null;
|
|
379
|
+
if (tdir) {
|
|
380
|
+
try {
|
|
381
|
+
tfiles = listTranscriptFiles(tdir); // a present dir yields an array (possibly empty); absent/unreadable throws.
|
|
382
|
+
} catch {
|
|
383
|
+
/* absent / unreadable transcript dir → tfiles stays null → degrade below */
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// tfiles null ⇒ the arm could NOT be consulted (dir refused by the slug guard, or absent/unreadable) →
|
|
387
|
+
// loud events-only degrade. A present-but-empty dir yields [] (arm reachable, simply nothing) → NO degrade.
|
|
388
|
+
if (!tfiles) {
|
|
389
|
+
degraded = true;
|
|
390
|
+
} else {
|
|
391
|
+
for (const file of tfiles) {
|
|
392
|
+
let lines;
|
|
393
|
+
try {
|
|
394
|
+
lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
395
|
+
} catch {
|
|
396
|
+
continue; // an unreadable transcript entry → skip it, keep scanning (never crash)
|
|
397
|
+
}
|
|
398
|
+
for (const line of lines) {
|
|
399
|
+
if (!line.trim()) continue;
|
|
400
|
+
let rec;
|
|
401
|
+
try {
|
|
402
|
+
rec = JSON.parse(line);
|
|
403
|
+
} catch {
|
|
404
|
+
continue; // skip a malformed transcript line, never crash the scan
|
|
405
|
+
}
|
|
406
|
+
if (!rec || (rec.type !== 'user' && rec.type !== 'assistant')) continue; // only user/assistant turns; unknown line types (summary/system/…) skipped
|
|
407
|
+
// hygiene pipeline: flatten → strip injected framework context (a block holding the term is not a
|
|
408
|
+
// hit) → redact secrets (raw chat can echo a credential; scrub BEFORE it can surface in a snippet).
|
|
409
|
+
const text = redactSecrets(stripInjectedContext(transcriptText(rec)));
|
|
410
|
+
consider(rec.timestamp, rec.sessionId, rec.type, text);
|
|
411
|
+
}
|
|
95
412
|
}
|
|
96
413
|
}
|
|
97
414
|
}
|
|
@@ -100,14 +417,19 @@ function searchConversationalLog(query, opts, roots) {
|
|
|
100
417
|
hits.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
|
|
101
418
|
|
|
102
419
|
const found = hits.length > 0;
|
|
103
|
-
|
|
420
|
+
// Loud degrade (#84): when the transcript arm could not be consulted, say so on its own line so the
|
|
421
|
+
// operator knows results are EVENTS-ONLY (no assistant turns, no full message content) — never silent.
|
|
422
|
+
const head = found
|
|
104
423
|
? hits.map((h) => renderHit(h, opts)).join('\n')
|
|
105
|
-
: `chat-search: nothing found for "${q}" in the conversational log
|
|
424
|
+
: `chat-search: nothing found for "${q}" in the conversational log.`;
|
|
425
|
+
const rendered = degraded
|
|
426
|
+
? `${head}\nchat-search: transcript arm unavailable — showing event-log results only.`
|
|
427
|
+
: head;
|
|
106
428
|
|
|
107
|
-
return { query: q, total: hits.length, found, hits, rendered };
|
|
429
|
+
return { query: q, total: hits.length, found, degraded, hits, rendered };
|
|
108
430
|
}
|
|
109
431
|
|
|
110
|
-
// ── CLI: node .wrxn/chat-search.cjs <term...> [--root <dir>]
|
|
432
|
+
// ── CLI: node .wrxn/chat-search.cjs <term...> [--root <dir>] [--session <id>] [--since <when>] [--regex] ──
|
|
111
433
|
// The operator-invocable surface (/chat-search <term>). Resolves the install root by walking up to the
|
|
112
434
|
// wrxn.install.json receipt (mirrors wiki.cjs), or honors a --root override (tests). Prints the rendered
|
|
113
435
|
// result and exits 0 — a nothing-found result is a normal outcome, never an error exit.
|
|
@@ -124,9 +446,18 @@ function findInstallRoot(start) {
|
|
|
124
446
|
return null;
|
|
125
447
|
}
|
|
126
448
|
|
|
449
|
+
// Value of a --name <value> flag. A following token that is itself a --flag (or a missing value) is NOT
|
|
450
|
+
// consumed as the value — so `--session --regex` can't silently swallow --regex as the session id.
|
|
127
451
|
function flag(name) {
|
|
128
452
|
const i = process.argv.indexOf(`--${name}`);
|
|
129
|
-
|
|
453
|
+
if (i < 0) return undefined;
|
|
454
|
+
const val = process.argv[i + 1];
|
|
455
|
+
return val == null || val.startsWith('--') ? undefined : val;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Presence of a boolean --name flag (e.g. --regex), which carries no value.
|
|
459
|
+
function hasFlag(name) {
|
|
460
|
+
return process.argv.includes(`--${name}`);
|
|
130
461
|
}
|
|
131
462
|
|
|
132
463
|
// Positional search terms: argv from index 2 up to the first --flag.
|
|
@@ -142,7 +473,7 @@ function positionals() {
|
|
|
142
473
|
function main() {
|
|
143
474
|
const terms = positionals();
|
|
144
475
|
if (terms.length === 0) {
|
|
145
|
-
process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>]\n');
|
|
476
|
+
process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]\n');
|
|
146
477
|
process.exit(2);
|
|
147
478
|
}
|
|
148
479
|
const root = flag('root') || findInstallRoot();
|
|
@@ -150,9 +481,27 @@ function main() {
|
|
|
150
481
|
process.stderr.write('chat-search: cannot resolve the install root — run inside a wrxn install or pass --root <dir>\n');
|
|
151
482
|
process.exit(2);
|
|
152
483
|
}
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
const
|
|
484
|
+
// Wire the slice-3 flags (#85) into engine opts. The engine validates them (it throws a clean, user-facing
|
|
485
|
+
// error on a bad regex / unparseable --since); the CLI catch below prints that one line and exits non-zero.
|
|
486
|
+
const opts = {};
|
|
487
|
+
const session = flag('session');
|
|
488
|
+
if (session !== undefined) opts.session = session;
|
|
489
|
+
const since = flag('since');
|
|
490
|
+
if (since !== undefined) opts.since = since;
|
|
491
|
+
if (hasFlag('regex')) opts.regex = true;
|
|
492
|
+
|
|
493
|
+
let result;
|
|
494
|
+
try {
|
|
495
|
+
result = searchConversationalLog(terms.join(' '), opts, root);
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (err && err.userFacing) {
|
|
498
|
+
// invalid flag input (bad/catastrophic regex, unparseable --since): fail LOUD with the one clean line
|
|
499
|
+
// the engine already built — never a Node stack or path — and exit non-zero (usage error).
|
|
500
|
+
process.stderr.write(err.message + '\n');
|
|
501
|
+
process.exit(2);
|
|
502
|
+
}
|
|
503
|
+
throw err; // an unexpected fault → the entrypoint wrap turns it into a clean path-free diagnostic
|
|
504
|
+
}
|
|
156
505
|
process.stdout.write(result.rendered + '\n');
|
|
157
506
|
process.exit(0);
|
|
158
507
|
}
|
|
@@ -170,4 +519,4 @@ if (require.main === module) {
|
|
|
170
519
|
}
|
|
171
520
|
}
|
|
172
521
|
|
|
173
|
-
module.exports = { searchConversationalLog, renderHit };
|
|
522
|
+
module.exports = { searchConversationalLog, renderHit, resolveTranscriptDir };
|