@gcunharodrigues/wrxn 0.21.0 → 0.21.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.21.0",
3
+ "version": "0.21.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"
@@ -17,7 +17,7 @@ user-invocable: true
17
17
  - **Event log** — `.wrxn/events/*.jsonl`, the per-session, secret-redacted **user prompts** emit-event.cjs appends.
18
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
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).
20
+ A prompt that appears in **both** arms is de-duplicated — by `(session, whitespace-normalized text, timestamp within a tight window)` — so a single turn surfaces once even when its two arms stamp it ms apart or differ by whitespace. If the transcript dir is **missing, unreadable, or wholesale-drifted** (present but every line an unknown type, so it yields no usable turn), the search **degrades loudly to events-only** and says so in the output (it never crashes).
21
21
 
22
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
23
 
@@ -51,8 +51,8 @@ When the operator references an earlier moment ("like we discussed", "the decisi
51
51
 
52
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
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.
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. Scoping only *narrows* the rows it does **not** relabel them: the `this session` label tracks the genuinely-live session (`CLAUDE_SESSION_ID`), so scoping to a **past** session shows its real id, not `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`). A datetime **without an explicit zone** is read as **UTC** (matching the record stamps), not machine-local time. An undatable hit is excluded.
56
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
57
 
58
58
  ```bash
@@ -71,9 +71,9 @@ Hits are **most-recent-first**, one per line:
71
71
  ```
72
72
 
73
73
  - `role` is `user` (event log or a user transcript turn) or `assistant` (a transcript turn).
74
- - The session column collapses to `this session` for hits from the active session.
74
+ - The session column collapses to `this session` for hits from the genuinely-live session (`CLAUDE_SESSION_ID`), independent of any `--session` scope.
75
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).
76
+ - If the transcript arm is unavailable (missing, unreadable, or wholesale-drifted), a trailing `chat-search: transcript arm unavailable — showing event-log results only.` line is appended (loud degrade).
77
77
 
78
78
  ## Boundaries
79
79
 
@@ -38,10 +38,12 @@ function snippetFor(text, matchesLine) {
38
38
  }
39
39
 
40
40
  // ── render: one hit → "timestamp · session (or 'this session') · role · snippet" ──
41
- // The session column collapses to "this session" when the hit is from the caller's active session
42
- // (opts.session), so a result set reads as scrollback relative to where the operator stands now.
41
+ // The session column collapses to "this session" when the hit is from the caller's genuinely-LIVE session
42
+ // (opts.activeSession — the CLI wires it from CLAUDE_SESSION_ID), so a result set reads as scrollback relative
43
+ // to where the operator stands now. This is DECOUPLED from the --session SCOPE filter (opts.session): scoping
44
+ // to a PAST session narrows the rows but never relabels them "this session" (#98).
43
45
  function renderHit(hit, opts) {
44
- const active = opts && opts.session;
46
+ const active = opts && opts.activeSession;
45
47
  const sessionLabel = active && hit.session === active ? 'this session' : hit.session;
46
48
  return `${hit.ts} · ${sessionLabel} · ${hit.role} · ${hit.snippet}`;
47
49
  }
@@ -89,7 +91,8 @@ const INJECTED_STRIP_MAX = 65536;
89
91
  // Strip hook-injected framework-context blocks from one text part BEFORE matching. Two phases: (1) every
90
92
  // well-delimited <tag>…</tag> block anywhere; (2) a part-LEADING unclosed <tag>… (transcript truncated
91
93
  // 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).
94
+ // FAIL-OPEN: any fault returns the input unchanged. Logic-identical to memory-synth.cjs's stripInjectedContext
95
+ // (#62) — same strip semantics, not a byte-for-byte copy (and the 64KB strip cap is the inherited #62 tradeoff).
93
96
  function stripInjectedContext(text) {
94
97
  try {
95
98
  let out = String(text || '');
@@ -223,7 +226,12 @@ function parseSince(raw) {
223
226
  d.setUTCHours(0, 0, 0, 0);
224
227
  return d.getTime();
225
228
  }
226
- const t = Date.parse(s);
229
+ // N3 (#99): record stamps are UTC, but Date.parse reads an ISO datetime WITH a time component and NO zone
230
+ // designator ("2026-06-26T12:00:00") as MACHINE-LOCAL time — silently shifting the floor by the host offset.
231
+ // Normalize it to UTC by appending Z. A date-only form (no 'T') is already UTC under Date.parse, and an
232
+ // explicit zone (trailing Z or a ±HH[:MM] offset) is honored as written — both pass through untouched.
233
+ const normalized = /[tT]/.test(s) && !/[zZ]$/.test(s) && !/[+-]\d{2}(:?\d{2})?$/.test(s) ? `${s}Z` : s;
234
+ const t = Date.parse(normalized);
227
235
  if (Number.isNaN(t)) {
228
236
  throw inputError(`chat-search: --since value "${raw}" is not a date — use "today" or an ISO-8601 date (e.g. 2026-06-26)`);
229
237
  }
@@ -314,6 +322,23 @@ function compileUserRegex(pattern) {
314
322
  }
315
323
  }
316
324
 
325
+ // ── cross-arm dedup identity (#97) ─────────────────────────────────────────────
326
+ // The SAME prompt reaches both arms stamped by DIFFERENT processes: emit-event.cjs writes the event ts at
327
+ // hook-fire, the harness writes the transcript timestamp when it persists the turn — they differ by ms — and
328
+ // the transcript text can differ from the event text by whitespace (hygiene strip, soft-wrap). So the dedup
329
+ // identity is (session, whitespace-normalized text, ts within a TIGHT ±window), NOT an exact triple. The
330
+ // window is 2s: a single turn's two stamps land within ~1s in practice, while two genuinely-distinct prompts
331
+ // are seconds-to-minutes apart AND (almost always) differ in text — so the window only ever collapses what is
332
+ // really one turn. The normalized text is the primary discriminator; the window only absorbs the writer
333
+ // clock-skew on an otherwise-identical turn (two distinct prompts inside the window keep their distinct text,
334
+ // so they never merge; the same text outside the window stays two distinct moments).
335
+ const DEDUP_WINDOW_MS = 2000;
336
+ // Normalize a message's text for the dedup key: trim the ends and collapse every interior whitespace run
337
+ // (spaces, tabs, newlines) to a single space, so an event/transcript pair that differs only in spacing keys alike.
338
+ function normalizeForDedup(text) {
339
+ return String(text).trim().replace(/\s+/g, ' ');
340
+ }
341
+
317
342
  // ── the engine seam ───────────────────────────────────────────────────────────
318
343
  // searchConversationalLog(query, opts, roots): scan BOTH arms across the given roots' sessions and return
319
344
  // the turns that match `query` — a case-insensitive substring by default, or (opts.regex) the compiled
@@ -325,7 +350,7 @@ function searchConversationalLog(query, opts, roots) {
325
350
  const q = String(query == null ? '' : query);
326
351
  const needle = q.toLowerCase();
327
352
  const hits = [];
328
- const seen = new Set(); // a prompt present in BOTH arms (same session+ts+text) must surface only once.
353
+ const seen = new Map(); // dedup index (#97): `${session}${normText}` [{ tsMs, ts }] already surfaced; a near-stamp match collapses cross-arm duplicates.
329
354
  let degraded = false; // set when the transcript arm could not be consulted → loud events-only degrade (#84).
330
355
 
331
356
  // ── slice-3 filters (#85): each is an opts field applied per-record inside consider(), so it composes
@@ -344,9 +369,21 @@ function searchConversationalLog(query, opts, roots) {
344
369
  if (sessionScope != null && session !== sessionScope) return; // --session: exact-match a single session
345
370
  if (typeof text !== 'string' || !lineMatches(text)) return; // --regex pattern, else case-insensitive substring
346
371
  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);
372
+ // Cross-arm dedup (#97): same session + whitespace-normalized text + a ts within the tight window = the
373
+ // same turn → surface once. NUL-joined so session and text can't bleed. An undatable or exactly-equal
374
+ // stamp falls back to ts-string equality, so an unparseable ts still collapses its exact twin.
375
+ const key = `${session}${normalizeForDedup(text)}`;
376
+ const tsMs = Date.parse(ts);
377
+ const prior = seen.get(key);
378
+ if (prior) {
379
+ const dup = prior.some(
380
+ (e) => (Number.isFinite(tsMs) && Number.isFinite(e.tsMs) && Math.abs(e.tsMs - tsMs) <= DEDUP_WINDOW_MS) || e.ts === ts,
381
+ );
382
+ if (dup) return; // a near-identical turn from the other arm (or an exact twin) already surfaced
383
+ prior.push({ tsMs, ts });
384
+ } else {
385
+ seen.set(key, [{ tsMs, ts }]);
386
+ }
350
387
  hits.push({ ts, session, role, snippet: snippetFor(text, lineMatches) });
351
388
  }
352
389
 
@@ -388,6 +425,7 @@ function searchConversationalLog(query, opts, roots) {
388
425
  if (!tfiles) {
389
426
  degraded = true;
390
427
  } else {
428
+ let recognizedTurns = 0; // user/assistant turns this dir yielded; zero across a non-empty dir = wholesale drift (#99 F3)
391
429
  for (const file of tfiles) {
392
430
  let lines;
393
431
  try {
@@ -404,12 +442,17 @@ function searchConversationalLog(query, opts, roots) {
404
442
  continue; // skip a malformed transcript line, never crash the scan
405
443
  }
406
444
  if (!rec || (rec.type !== 'user' && rec.type !== 'assistant')) continue; // only user/assistant turns; unknown line types (summary/system/…) skipped
445
+ recognizedTurns++; // a recognized turn (matchable or not) → this dir is NOT wholesale-drifted (#99 F3)
407
446
  // hygiene pipeline: flatten → strip injected framework context (a block holding the term is not a
408
447
  // hit) → redact secrets (raw chat can echo a credential; scrub BEFORE it can surface in a snippet).
409
448
  const text = redactSecrets(stripInjectedContext(transcriptText(rec)));
410
449
  consider(rec.timestamp, rec.sessionId, rec.type, text);
411
450
  }
412
451
  }
452
+ // F3 (#99): a present, readable dir whose EVERY line is an unknown type yielded zero usable turns — the
453
+ // arm is effectively unavailable → loud events-only degrade. Per-RECORD drift (some good turns) stays
454
+ // silent, and a present-but-empty dir ([]) is reachable-but-nothing and stays silent, both as before.
455
+ if (tfiles.length > 0 && recognizedTurns === 0) degraded = true;
413
456
  }
414
457
  }
415
458
 
@@ -446,13 +489,16 @@ function findInstallRoot(start) {
446
489
  return null;
447
490
  }
448
491
 
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.
492
+ // Value of a --name <value> flag, or undefined when the flag is ABSENT (optional). A PRESENT flag whose value
493
+ // is missing or is itself a --flag fails LOUD (N2, #99) parity with `--session ""`, never a silent
494
+ // scope-widening drop: so `--session --regex` can't swallow --regex as the id and a trailing `--since` can't
495
+ // vanish. The throw is userFacing, so main()'s catch prints one clean line and exits non-zero.
451
496
  function flag(name) {
452
497
  const i = process.argv.indexOf(`--${name}`);
453
498
  if (i < 0) return undefined;
454
499
  const val = process.argv[i + 1];
455
- return val == null || val.startsWith('--') ? undefined : val;
500
+ if (val == null || val.startsWith('--')) throw inputError(`chat-search: --${name} requires a value`);
501
+ return val;
456
502
  }
457
503
 
458
504
  // Presence of a boolean --name flag (e.g. --regex), which carries no value.
@@ -476,34 +522,39 @@ function main() {
476
522
  process.stdout.write('Usage: node .wrxn/chat-search.cjs <search-term...> [--root <dir>] [--session <id>] [--since <when>] [--regex]\n');
477
523
  process.exit(2);
478
524
  }
479
- const root = flag('root') || findInstallRoot();
480
- if (!root) {
481
- process.stderr.write('chat-search: cannot resolve the install root run inside a wrxn install or pass --root <dir>\n');
482
- process.exit(2);
483
- }
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;
525
+ // Flag parsing AND the engine call live inside ONE user-facing try: a value-flag with a missing/--prefixed
526
+ // value (flag(), N2 #99) and a bad regex / unparseable --since (the engine) both throw a clean userFacing
527
+ // error the catch prints that one line and exits non-zero (never a Node stack or absolute path).
494
528
  try {
495
- result = searchConversationalLog(terms.join(' '), opts, root);
529
+ const root = flag('root') || findInstallRoot();
530
+ if (!root) {
531
+ process.stderr.write('chat-search: cannot resolve the install root — run inside a wrxn install or pass --root <dir>\n');
532
+ process.exit(2);
533
+ }
534
+ // Wire the slice-3 flags (#85) into engine opts.
535
+ const opts = {};
536
+ const session = flag('session');
537
+ if (session !== undefined) opts.session = session;
538
+ const since = flag('since');
539
+ if (since !== undefined) opts.since = since;
540
+ if (hasFlag('regex')) opts.regex = true;
541
+ // The "this session" label tracks the genuinely-LIVE session (Claude Code exports CLAUDE_SESSION_ID), NOT
542
+ // the --session SCOPE filter — so scoping to a PAST session never relabels its rows "this session" (#98).
543
+ const activeSession = process.env.CLAUDE_SESSION_ID;
544
+ if (activeSession) opts.activeSession = activeSession;
545
+
546
+ const result = searchConversationalLog(terms.join(' '), opts, root);
547
+ process.stdout.write(result.rendered + '\n');
548
+ process.exit(0);
496
549
  } catch (err) {
497
550
  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).
551
+ // invalid flag input (missing flag value, bad/catastrophic regex, unparseable --since): fail LOUD with
552
+ // the one clean line — never a Node stack or path — and exit non-zero (usage error).
500
553
  process.stderr.write(err.message + '\n');
501
554
  process.exit(2);
502
555
  }
503
556
  throw err; // an unexpected fault → the entrypoint wrap turns it into a clean path-free diagnostic
504
557
  }
505
- process.stdout.write(result.rendered + '\n');
506
- process.exit(0);
507
558
  }
508
559
 
509
560
  // Belt-and-suspenders fail-loud (mirrors emit-event.cjs's entrypoint wrap): the per-file read and root