ai-lens 0.8.118 → 0.8.120
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/.commithash +1 -1
- package/CHANGELOG.md +9 -0
- package/client/capture.js +305 -72
- package/client/memory-match.js +47 -8
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
bf6f7d9
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
|
|
4
4
|
|
|
5
|
+
## 0.8.120 — 2026-07-03
|
|
6
|
+
- fix: session events are no longer misattributed to an unrelated repository when a tool touches a file outside the project — project refinement now only follows genuine nested sub-repos, so the `projects` filter and per-project stats stay correct
|
|
7
|
+
- feat(codex): Codex sessions now report per-call token usage, matching Claude Code / Cursor granularity
|
|
8
|
+
- feat(codex): Codex sessions now capture per-message assistant text for dialogue and search
|
|
9
|
+
|
|
10
|
+
## 0.8.119 — 2026-07-02
|
|
11
|
+
- feat: team-memory injection now fires only on genuinely relevant prompts — the trigger matches distinctive terms of the memory title instead of an absolute score, cutting noise ~20x while keeping real matches
|
|
12
|
+
- feat: the full team memory pool is delivered to the session cache (previously most team-tier memories could never surface), server-gated to clients on this version and newer
|
|
13
|
+
|
|
5
14
|
## 0.8.118 — 2026-07-01
|
|
6
15
|
- fix(team-memory): injected memory now actually reaches the model on Claude Code (via `additionalContext`, not a user-only `systemMessage` banner) — before, relevant memory showed on screen but the model never received it. Silent by design; no screen clutter
|
|
7
16
|
|
package/client/capture.js
CHANGED
|
@@ -297,45 +297,20 @@ function maybeCleanStaleTranscriptOffsets() {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
300
|
+
* Read the NEW bytes appended to a JSONL transcript since the last call for
|
|
301
|
+
* this path, returning the complete newline-terminated lines in that delta.
|
|
302
|
+
* The intricate byte-offset / rotation / partial-line handling lives HERE, in
|
|
303
|
+
* one place, shared by the Claude Code (assistant-line) and Codex (token_count)
|
|
304
|
+
* token extractors so the two can't silently diverge.
|
|
303
305
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* Returns { calls, commitOffset }:
|
|
311
|
-
* - `calls` is Array<{usage, model, timestamp, uuid}>:
|
|
312
|
-
* • `usage` has Anthropic-named keys (input_tokens, output_tokens,
|
|
313
|
-
* cache_read_input_tokens, cache_creation_input_tokens) for
|
|
314
|
-
* buildTokenUsageRaw.
|
|
315
|
-
* • `model` is the model from that specific assistant line.
|
|
316
|
-
* • `timestamp` is the assistant line's own ISO timestamp if present, else
|
|
317
|
-
* null (caller falls back to the Stop event's timestamp).
|
|
318
|
-
* • `uuid` is the assistant line's `uuid` field (Claude Code transcripts
|
|
319
|
-
* always include one). Used by the caller to derive a stable event_id
|
|
320
|
-
* so concurrent Stop hook invocations can't double-emit a single API call.
|
|
321
|
-
* - `commitOffset` is a no-arg function the caller MUST invoke ONLY after
|
|
322
|
-
* successfully writing every TokenUsage event derived from `calls` into
|
|
323
|
-
* the spool. If spool writes fail (or the caller never calls it), the
|
|
324
|
-
* transcript cursor stays put and the next Stop re-reads the same delta —
|
|
325
|
-
* the server-side ON CONFLICT on the deterministic event_id then dedups
|
|
326
|
-
* whichever rows did land. This is what makes the pipeline crash-safe:
|
|
327
|
-
* we never mark transcript bytes "consumed" until we know the rows they
|
|
328
|
-
* produced are durably queued.
|
|
329
|
-
*
|
|
330
|
-
* Returns an empty `calls` array (and a no-op `commitOffset`) if nothing new
|
|
331
|
-
* in the delta or no real assistant lines are found.
|
|
332
|
-
*
|
|
333
|
-
* Partial-line handling: a Stop hook can fire while Claude Code is mid-write,
|
|
334
|
-
* so the delta may end before a trailing '\n'. We detect this, ignore the
|
|
335
|
-
* partial tail, and persist the offset at the byte right after the LAST
|
|
336
|
-
* complete '\n' so the next Stop re-reads the partial line once it's finished.
|
|
306
|
+
* Returns { lines, commitOffset }:
|
|
307
|
+
* - `lines` — raw JSONL lines in the processable region (empty on nothing-new
|
|
308
|
+
* or any I/O failure).
|
|
309
|
+
* - `commitOffset` — a no-arg fn the caller MUST invoke ONLY after it has
|
|
310
|
+
* durably consumed every line (advancing the cursor eagerly would drop rows
|
|
311
|
+
* if a later spool write fails).
|
|
337
312
|
*/
|
|
338
|
-
function
|
|
313
|
+
function readNewTranscriptDelta(transcriptPath) {
|
|
339
314
|
// Run the rate-limited stale-offset cleanup at most once per 24h. Wrapped in
|
|
340
315
|
// try/catch so a cleanup failure never prevents the token read.
|
|
341
316
|
try { maybeCleanStaleTranscriptOffsets(); } catch { /* best-effort */ }
|
|
@@ -345,7 +320,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
345
320
|
const noopCommit = () => {};
|
|
346
321
|
|
|
347
322
|
if (!transcriptPath || typeof transcriptPath !== 'string') {
|
|
348
|
-
return {
|
|
323
|
+
return { lines: [], commitOffset: noopCommit };
|
|
349
324
|
}
|
|
350
325
|
|
|
351
326
|
const offsetFile = join(TRANSCRIPT_OFFSETS_DIR, encodeURIComponent(transcriptPath));
|
|
@@ -358,7 +333,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
358
333
|
currentMtime = st.mtimeMs;
|
|
359
334
|
} catch (err) {
|
|
360
335
|
captureLog({ msg: 'transcript-offset-error', stage: 'stat', path: transcriptPath, error: err?.message });
|
|
361
|
-
return {
|
|
336
|
+
return { lines: [], commitOffset: noopCommit };
|
|
362
337
|
}
|
|
363
338
|
|
|
364
339
|
const savedState = readTranscriptOffsetState(offsetFile);
|
|
@@ -377,7 +352,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
377
352
|
|
|
378
353
|
// Nothing new — just a stat, super cheap.
|
|
379
354
|
if (currentSize === startOffset) {
|
|
380
|
-
return {
|
|
355
|
+
return { lines: [], commitOffset: noopCommit };
|
|
381
356
|
}
|
|
382
357
|
|
|
383
358
|
let buffer = null;
|
|
@@ -395,13 +370,13 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
395
370
|
// Read failed before we got any bytes — leave the cursor unchanged so the
|
|
396
371
|
// next Stop retries. (Old behavior advanced past the unread bytes; that
|
|
397
372
|
// silently dropped data on transient I/O errors.)
|
|
398
|
-
return {
|
|
373
|
+
return { lines: [], commitOffset: noopCommit };
|
|
399
374
|
}
|
|
400
375
|
|
|
401
376
|
if (!buffer || buffer.length === 0) {
|
|
402
377
|
// Defensive: shouldn't happen because of the currentSize === startOffset
|
|
403
378
|
// early return above. Don't advance the cursor.
|
|
404
|
-
return {
|
|
379
|
+
return { lines: [], commitOffset: noopCommit };
|
|
405
380
|
}
|
|
406
381
|
|
|
407
382
|
// Find the LAST complete newline. CRITICAL: search the BYTE buffer, not a
|
|
@@ -416,7 +391,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
416
391
|
if (lastNewlineIndex === -1) {
|
|
417
392
|
// The entire chunk is a partial trailing line — leave the cursor unchanged
|
|
418
393
|
// so the next Stop re-reads the line once it's finished.
|
|
419
|
-
return {
|
|
394
|
+
return { lines: [], commitOffset: noopCommit };
|
|
420
395
|
}
|
|
421
396
|
|
|
422
397
|
// Process only the bytes up to (and including) the last '\n'. The new
|
|
@@ -428,12 +403,36 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
428
403
|
const nextState = { offset: newOffset, size: currentSize, mtime_ms: currentMtime };
|
|
429
404
|
const commitOffset = () => writeTranscriptOffsetState(offsetFile, nextState);
|
|
430
405
|
|
|
406
|
+
return { lines: processable.split('\n'), commitOffset };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Incrementally read only the NEW bytes appended to a Claude Code transcript
|
|
411
|
+
* since the last time this function was called for that transcript, and
|
|
412
|
+
* return ONE entry per real (non-synthetic) assistant API call in the delta.
|
|
413
|
+
*
|
|
414
|
+
* Each entry corresponds to a single Anthropic API call: a single assistant
|
|
415
|
+
* line in the JSONL with its own usage record. A single user->agent turn can
|
|
416
|
+
* produce many of these (tool-use loops), and the caller is expected to emit
|
|
417
|
+
* one unified TokenUsage event per entry — symmetric with Cursor's per-call
|
|
418
|
+
* AgentResponse rows and Codex's per-call TokenUsage rows.
|
|
419
|
+
*
|
|
420
|
+
* Returns { calls, commitOffset } (see readNewTranscriptDelta for the
|
|
421
|
+
* commit-offset crash-safety contract): `calls` is Array<{usage, model,
|
|
422
|
+
* timestamp, uuid}> with Anthropic-named usage keys for buildTokenUsageRaw,
|
|
423
|
+
* the model + ISO timestamp from that specific assistant line, and the line's
|
|
424
|
+
* `uuid` (used by the caller to derive a stable event_id so concurrent Stop
|
|
425
|
+
* hook invocations can't double-emit a single API call).
|
|
426
|
+
*/
|
|
427
|
+
function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
428
|
+
const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
|
|
429
|
+
if (!lines.length) return { calls: [], commitOffset };
|
|
430
|
+
|
|
431
431
|
// Iterate lines in the processable region. Defensive parse-tolerance is
|
|
432
432
|
// still useful for the rare case where a single line in the middle is
|
|
433
433
|
// malformed (we skip it rather than dropping the whole delta).
|
|
434
434
|
const calls = [];
|
|
435
435
|
try {
|
|
436
|
-
const lines = processable.split('\n');
|
|
437
436
|
for (const rawLine of lines) {
|
|
438
437
|
const line = rawLine && rawLine.trim();
|
|
439
438
|
if (!line) continue;
|
|
@@ -493,7 +492,7 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
493
492
|
// to spool in this path, so it's safe to commit immediately (no risk of
|
|
494
493
|
// losing events).
|
|
495
494
|
commitOffset();
|
|
496
|
-
return { calls: [], commitOffset:
|
|
495
|
+
return { calls: [], commitOffset: () => {} };
|
|
497
496
|
}
|
|
498
497
|
|
|
499
498
|
// IMPORTANT: do NOT persist the offset here. The caller is responsible for
|
|
@@ -504,6 +503,173 @@ function extractNewClaudeApiCallsFromTranscript(transcriptPath) {
|
|
|
504
503
|
return { calls, commitOffset };
|
|
505
504
|
}
|
|
506
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Codex analogue of extractNewClaudeApiCallsFromTranscript: read the NEW bytes
|
|
508
|
+
* appended to a Codex rollout transcript (`rollout-*.jsonl`) since the last
|
|
509
|
+
* call and, in a SINGLE delta pass, return both the per-call token usage
|
|
510
|
+
* (`calls`) and the assistant's dialogue text (`messages`).
|
|
511
|
+
*
|
|
512
|
+
* A single pass is mandatory: `readNewTranscriptDelta` advances a per-path byte
|
|
513
|
+
* cursor that is committed only after the caller spools everything, so a second
|
|
514
|
+
* independent read would re-consume the same bytes. Claude's extractor folds
|
|
515
|
+
* text into each `call` (usage + content share one assistant line); Codex keeps
|
|
516
|
+
* them separate because usage and text live in DIFFERENT interleaved records.
|
|
517
|
+
*
|
|
518
|
+
* TOKEN USAGE (`calls`) — Codex records per-call usage as `event_msg`/
|
|
519
|
+
* `token_count` records whose `payload.info.last_token_usage` is the DELTA for
|
|
520
|
+
* that single call (the sibling `total_token_usage` is the running cumulative
|
|
521
|
+
* sum). The rollout is OpenAI-shaped, so field names differ from Claude's and
|
|
522
|
+
* we normalize them to the Anthropic convention that buildTokenUsageRaw + the
|
|
523
|
+
* dashboard SQL expect:
|
|
524
|
+
* - Codex `cached_input_tokens` ⊆ `input_tokens` (a subset), so we split them
|
|
525
|
+
* into non-overlapping `input_tokens` (fresh) + `cache_read_input_tokens`
|
|
526
|
+
* (cached), matching Claude where `input_tokens` excludes the cache read.
|
|
527
|
+
* - Codex has no prompt-cache-creation notion → `cache_creation_input_tokens`
|
|
528
|
+
* is always 0.
|
|
529
|
+
* - `output_tokens` already includes `reasoning_output_tokens`, mirroring
|
|
530
|
+
* Claude's output convention, so it passes through unchanged.
|
|
531
|
+
* Records whose call delta is all-zero (input+output == 0, e.g. the residual
|
|
532
|
+
* token_count emitted around a compaction) are skipped so we never spool an
|
|
533
|
+
* empty TokenUsage row.
|
|
534
|
+
*
|
|
535
|
+
* ASSISTANT TEXT (`messages`, ANL-1239) — the assistant's visible dialogue
|
|
536
|
+
* lives in `response_item`/`message` records with `role === 'assistant'`; we
|
|
537
|
+
* concatenate their `content[]` `output_text` blocks. The dedup key is
|
|
538
|
+
* `${timestamp}:${payload.id | sha(text)}`: current Codex writes a durable
|
|
539
|
+
* `msg_…` id on every assistant message (hard version cutoff ~2026-06-26; older
|
|
540
|
+
* rollouts have none), so we prefer it and fall back to a text fingerprint —
|
|
541
|
+
* mirroring how the token path synthesizes a stable key from records that lack
|
|
542
|
+
* a per-record uuid. The
|
|
543
|
+
* caller emits these as AssistantText events, metric-neutral server-side
|
|
544
|
+
* (ASSISTANT_CONTENT_TYPES). NOTE: there is deliberately no AssistantThinking
|
|
545
|
+
* analogue (unlike Claude). Current Codex emits no plaintext reasoning: the
|
|
546
|
+
* full chain-of-thought lives only in `response_item`/`reasoning`
|
|
547
|
+
* `encrypted_content`, and the short plaintext `summary` those records once
|
|
548
|
+
* carried was dropped ~2026-04 (verified on real rollouts: 91% of Feb reasoning
|
|
549
|
+
* records had a summary, 0% from April on). Since AI Lens captures live/current
|
|
550
|
+
* Codex, no recoverable thinking text exists.
|
|
551
|
+
*
|
|
552
|
+
* The model isn't on the token_count / message record; it lives on the nearest
|
|
553
|
+
* preceding `turn_context` record. We track the last-seen turn_context model
|
|
554
|
+
* within the delta and fall back to `fallbackModel` (the Stop event's model)
|
|
555
|
+
* when the governing turn_context landed in an earlier delta.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} transcriptPath Path to the Codex rollout JSONL.
|
|
558
|
+
* @param {string|null} fallbackModel Model from the Stop event, used when no
|
|
559
|
+
* turn_context precedes a record in this
|
|
560
|
+
* delta.
|
|
561
|
+
* @returns {{calls: Array<{usage, model, timestamp, uuid}>,
|
|
562
|
+
* messages: Array<{text, model, timestamp, uuid}>,
|
|
563
|
+
* commitOffset: function}}
|
|
564
|
+
*/
|
|
565
|
+
function extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel) {
|
|
566
|
+
const { lines, commitOffset } = readNewTranscriptDelta(transcriptPath);
|
|
567
|
+
if (!lines.length) return { calls: [], messages: [], commitOffset };
|
|
568
|
+
|
|
569
|
+
const calls = [];
|
|
570
|
+
const messages = [];
|
|
571
|
+
let currentModel = fallbackModel || null;
|
|
572
|
+
try {
|
|
573
|
+
for (const rawLine of lines) {
|
|
574
|
+
const line = rawLine && rawLine.trim();
|
|
575
|
+
if (!line) continue;
|
|
576
|
+
let parsed;
|
|
577
|
+
try {
|
|
578
|
+
parsed = JSON.parse(line);
|
|
579
|
+
} catch {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
// A turn_context record carries the model for the records that follow it.
|
|
583
|
+
if (parsed?.type === 'turn_context' && typeof parsed.payload?.model === 'string' && parsed.payload.model) {
|
|
584
|
+
currentModel = parsed.payload.model;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const payload = parsed?.payload;
|
|
588
|
+
if (!payload) continue;
|
|
589
|
+
const ts = typeof parsed.timestamp === 'string' ? parsed.timestamp : null;
|
|
590
|
+
|
|
591
|
+
// Assistant dialogue text — response_item/message with role 'assistant'.
|
|
592
|
+
if (parsed.type === 'response_item' && payload.type === 'message' && payload.role === 'assistant') {
|
|
593
|
+
let text = '';
|
|
594
|
+
if (Array.isArray(payload.content)) {
|
|
595
|
+
for (const block of payload.content) {
|
|
596
|
+
if (!block || typeof block !== 'object') continue;
|
|
597
|
+
// Assistant content is 'output_text'; accept 'text' too, defensively.
|
|
598
|
+
if ((block.type === 'output_text' || block.type === 'text') && typeof block.text === 'string' && block.text) {
|
|
599
|
+
text += (text ? '\n' : '') + block.text;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (text) {
|
|
604
|
+
// Stable per-message dedup key: `${timestamp}:${id | sha(text)}`.
|
|
605
|
+
// Current Codex writes a durable `msg_…` id on every assistant
|
|
606
|
+
// message (verified: 100% from the 2026-06-26 build onward; older
|
|
607
|
+
// rollouts, ≤ mid-May in local data, carry none — a hard version
|
|
608
|
+
// cutoff, not a gradual mix). Live capture only ever sees current
|
|
609
|
+
// rollouts, so the id is normally present; the text-hash fallback is
|
|
610
|
+
// defensive insurance for a pre-cutoff / downgraded client. Either
|
|
611
|
+
// way the key is unique per record and identical across two Stop
|
|
612
|
+
// hooks reading the same bytes (main() re-hashes source_uuid into the
|
|
613
|
+
// event_id). Prefer the durable id so the key stays human-traceable.
|
|
614
|
+
const idPart = typeof payload.id === 'string' && payload.id
|
|
615
|
+
? payload.id
|
|
616
|
+
: createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
617
|
+
const uuid = `${ts || ''}:${idPart}`;
|
|
618
|
+
messages.push({
|
|
619
|
+
text,
|
|
620
|
+
model: currentModel,
|
|
621
|
+
timestamp: ts,
|
|
622
|
+
uuid,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (payload.type !== 'token_count') continue;
|
|
629
|
+
const info = payload.info;
|
|
630
|
+
const last = info?.last_token_usage;
|
|
631
|
+
if (!last || typeof last !== 'object') continue;
|
|
632
|
+
|
|
633
|
+
const rawInput = toNumberOrNull(last.input_tokens) ?? 0;
|
|
634
|
+
const cached = toNumberOrNull(last.cached_input_tokens) ?? 0;
|
|
635
|
+
const output = toNumberOrNull(last.output_tokens) ?? 0;
|
|
636
|
+
// Skip all-zero deltas (residual token_count around a compaction).
|
|
637
|
+
if (rawInput + output <= 0) continue;
|
|
638
|
+
|
|
639
|
+
// cached_input_tokens is a SUBSET of input_tokens — split into
|
|
640
|
+
// non-overlapping fresh-input + cache-read to match Claude's convention.
|
|
641
|
+
const cacheRead = Math.min(cached, rawInput);
|
|
642
|
+
const freshInput = Math.max(0, rawInput - cacheRead);
|
|
643
|
+
|
|
644
|
+
// Stable per-call fingerprint for the dedup event_id. The token_count
|
|
645
|
+
// record has no per-record uuid, so we key on the record timestamp + the
|
|
646
|
+
// cumulative total (monotonic across calls) — unique per token_count
|
|
647
|
+
// record and identical across two concurrent Stop hooks reading the same
|
|
648
|
+
// bytes.
|
|
649
|
+
const cum = toNumberOrNull(info?.total_token_usage?.total_tokens);
|
|
650
|
+
const uuid = `${ts || ''}:${cum != null ? cum : ''}`;
|
|
651
|
+
|
|
652
|
+
calls.push({
|
|
653
|
+
usage: {
|
|
654
|
+
input_tokens: freshInput,
|
|
655
|
+
output_tokens: output,
|
|
656
|
+
cache_read_input_tokens: cacheRead,
|
|
657
|
+
cache_creation_input_tokens: 0,
|
|
658
|
+
},
|
|
659
|
+
model: currentModel,
|
|
660
|
+
timestamp: ts,
|
|
661
|
+
uuid: uuid === ':' ? null : uuid,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
captureLog({ msg: 'transcript-offset-error', stage: 'parse-codex', path: transcriptPath, error: err?.message });
|
|
666
|
+
commitOffset();
|
|
667
|
+
return { calls: [], messages: [], commitOffset: () => {} };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return { calls, messages, commitOffset };
|
|
671
|
+
}
|
|
672
|
+
|
|
507
673
|
// =============================================================================
|
|
508
674
|
// Source Detection
|
|
509
675
|
// =============================================================================
|
|
@@ -687,7 +853,14 @@ function refineProjectPath(event, projectPath, sessionId) {
|
|
|
687
853
|
const filePath = extractFilePath(toolInput);
|
|
688
854
|
if (!filePath) return projectPath;
|
|
689
855
|
const gitRoot = findGitRoot(filePath);
|
|
690
|
-
|
|
856
|
+
// Refine ONLY downward — into the current project itself or a real sub-repo
|
|
857
|
+
// of it (nested repos like etl-pipelines/bk-reports-automation/). Never flip
|
|
858
|
+
// to an unrelated repo just because its path string happens to be longer:
|
|
859
|
+
// a session running in one project that touches a file in a sibling/foreign
|
|
860
|
+
// repo would otherwise get misattributed there (and mis-filtered against the
|
|
861
|
+
// `projects` config). `pathContains` handles the nesting check plus Windows
|
|
862
|
+
// backslash/drive-letter/case normalization for free.
|
|
863
|
+
if (gitRoot && (!projectPath || pathContains(projectPath, gitRoot))) {
|
|
691
864
|
if (sessionId) cacheSessionPath(sessionId, gitRoot);
|
|
692
865
|
return gitRoot;
|
|
693
866
|
}
|
|
@@ -1369,7 +1542,7 @@ function normalizeCodex(event) {
|
|
|
1369
1542
|
projectPath = refineProjectPath({ tool_input: codexToolInput(event), input: codexToolInput(event) }, projectPath, sessionId);
|
|
1370
1543
|
}
|
|
1371
1544
|
|
|
1372
|
-
|
|
1545
|
+
const primary = {
|
|
1373
1546
|
event_id: null,
|
|
1374
1547
|
source: 'codex',
|
|
1375
1548
|
session_id: sessionId,
|
|
@@ -1378,7 +1551,59 @@ function normalizeCodex(event) {
|
|
|
1378
1551
|
timestamp,
|
|
1379
1552
|
data,
|
|
1380
1553
|
raw: event,
|
|
1381
|
-
}
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
// On Stop (end of a Codex agent turn), read the rollout delta once and emit
|
|
1557
|
+
// one TokenUsage event per real API call PLUS one AssistantText event per
|
|
1558
|
+
// assistant message (ANL-1239) — giving Codex the same row-per-call
|
|
1559
|
+
// granularity as Claude Code and Cursor. The rollout path arrives as
|
|
1560
|
+
// `transcript_path` (the same field detectSource keys on for `rollout-*`).
|
|
1561
|
+
if (hookType === 'Stop') {
|
|
1562
|
+
const transcriptPath = event.transcript_path || null;
|
|
1563
|
+
const fallbackModel = typeof event.model === 'string' ? event.model : null;
|
|
1564
|
+
const { calls, messages, commitOffset } = extractNewCodexRecordsFromTranscript(transcriptPath, fallbackModel);
|
|
1565
|
+
const tokenEvents = calls.map(call => ({
|
|
1566
|
+
event_id: null, // assigned in main() from a stable hash of call.uuid
|
|
1567
|
+
source: 'codex',
|
|
1568
|
+
session_id: sessionId,
|
|
1569
|
+
type: 'TokenUsage',
|
|
1570
|
+
project_path: projectPath,
|
|
1571
|
+
timestamp: call.timestamp || timestamp,
|
|
1572
|
+
data: {
|
|
1573
|
+
model: call.model,
|
|
1574
|
+
input_tokens: call.usage.input_tokens,
|
|
1575
|
+
output_tokens: call.usage.output_tokens,
|
|
1576
|
+
},
|
|
1577
|
+
raw: buildTokenUsageRaw({ source_uuid: call.uuid }, call.usage, call.model),
|
|
1578
|
+
}));
|
|
1579
|
+
// Assistant dialogue text (ANL-1239). Stored for dialogue/search;
|
|
1580
|
+
// metric-neutral server-side (ASSISTANT_CONTENT_TYPES). data.text is
|
|
1581
|
+
// truncated for display; raw.text keeps the full text (redacted
|
|
1582
|
+
// server-side). Codex has NO plaintext thinking, so there is no
|
|
1583
|
+
// AssistantThinking counterpart (unlike the Claude Code Stop path).
|
|
1584
|
+
const contentEvents = messages.map(msg => ({
|
|
1585
|
+
event_id: null, // assigned in main() from a stable hash of msg.uuid
|
|
1586
|
+
source: 'codex',
|
|
1587
|
+
session_id: sessionId,
|
|
1588
|
+
type: 'AssistantText',
|
|
1589
|
+
project_path: projectPath,
|
|
1590
|
+
timestamp: msg.timestamp || timestamp,
|
|
1591
|
+
data: { text: truncate(msg.text, TRUNCATION_LIMITS.agentResponse), model: msg.model },
|
|
1592
|
+
raw: { source_uuid: msg.uuid, model: msg.model, text: msg.text },
|
|
1593
|
+
}));
|
|
1594
|
+
const result = [primary, ...tokenEvents, ...contentEvents];
|
|
1595
|
+
// Attach the commit callback as a non-enumerable property so it survives
|
|
1596
|
+
// through normalizeEvent()/writeToSpool without leaking into iteration or
|
|
1597
|
+
// length assertions — mirrors the Claude Code Stop path.
|
|
1598
|
+
Object.defineProperty(result, 'commitTranscriptOffset', {
|
|
1599
|
+
value: commitOffset,
|
|
1600
|
+
enumerable: false,
|
|
1601
|
+
writable: false,
|
|
1602
|
+
});
|
|
1603
|
+
return result;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return [primary];
|
|
1382
1607
|
}
|
|
1383
1608
|
|
|
1384
1609
|
// =============================================================================
|
|
@@ -1735,21 +1960,23 @@ export function maybeEmitSessionStartMemoryIndex(primary, { now = Date.now() } =
|
|
|
1735
1960
|
// empty cache ⇒ this is a no-op) and relevance (local BM25 threshold).
|
|
1736
1961
|
// =============================================================================
|
|
1737
1962
|
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
//
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
1963
|
+
// Inject decision rule — REPLACED the absolute raw-BM25 floor (was 1.5, then 3.0).
|
|
1964
|
+
// A 20k-prompt historical replay (analytics, 90d, board [0102]) showed the raw sum
|
|
1965
|
+
// cannot be thresholded: it grows with prompt length, so long unrelated prompts
|
|
1966
|
+
// (pasted logs, slash-command expansions) hit 4–23 while true matches are defined
|
|
1967
|
+
// by WHICH terms hit. The shipped rule uses the two per-candidate signals from
|
|
1968
|
+
// scoreCandidates (client/memory-match.js):
|
|
1969
|
+
// dth — distinctive title hits (unique query terms landing in the TITLE
|
|
1970
|
+
// that are rare in the pool, df ≤ max(1, ⌊0.3·N⌋));
|
|
1971
|
+
// coverage — raw score / the query's saturation ceiling (length-normalized).
|
|
1972
|
+
// `dth >= 2 && coverage >= 0.12` measured on the replay golden set
|
|
1973
|
+
// (test/client/memory-match-golden.test.js): recall 65% on verified positive
|
|
1974
|
+
// pairs, 4.5% fire-rate on the 20k noise set (~7 injects/day team-wide) — vs 100%
|
|
1975
|
+
// noise fire-rate for the old raw>=3 on a full pool. Precision over recall BY
|
|
1976
|
+
// DESIGN: silence beats a context-rotting near-miss. Tune ONLY against the golden
|
|
1977
|
+
// harness + PostHog memory_matched (top_dth/top_coverage) data.
|
|
1978
|
+
const MEMORY_MATCH_MIN_DTH = 2;
|
|
1979
|
+
const MEMORY_MATCH_MIN_COVERAGE = 0.12;
|
|
1753
1980
|
// Budget: never inject more than this many memories per prompt (repo OR team-general
|
|
1754
1981
|
// alike) — keeps a prompt light even on a broad match.
|
|
1755
1982
|
const MEMORY_INJECT_BUDGET = 2;
|
|
@@ -1888,16 +2115,17 @@ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } =
|
|
|
1888
2115
|
// Score with the local BM25-lite scorer over the whole pool.
|
|
1889
2116
|
const scored = scoreCandidates(promptText, entries);
|
|
1890
2117
|
const byId = new Map(entries.map(e => [e.id, e]));
|
|
1891
|
-
const
|
|
2118
|
+
const signalOf = new Map(scored.map(s => [s.id, s]));
|
|
1892
2119
|
|
|
1893
2120
|
// Relevance-gated selection (UNIFORM across phases): inject ONLY candidates —
|
|
1894
|
-
// repo OR team-general — that clear
|
|
1895
|
-
// were not already shown this session.
|
|
1896
|
-
// low-signal prompt ("давай делай",
|
|
1897
|
-
//
|
|
2121
|
+
// repo OR team-general — that clear the dth+coverage decision rule (see the
|
|
2122
|
+
// constants block) for THIS prompt and were not already shown this session.
|
|
2123
|
+
// NO unconditional team-general dump: a low-signal prompt ("давай делай",
|
|
2124
|
+
// "continue") has no distinctive title hits → injects NOTHING. Ranking among
|
|
2125
|
+
// qualifiers stays by raw score (scoreCandidates sort order). `promptIndex`
|
|
1898
2126
|
// still advances via commitCount() for telemetry, but no longer gates selection.
|
|
1899
2127
|
const picked = scored
|
|
1900
|
-
.filter(s => s.
|
|
2128
|
+
.filter(s => s.dth >= MEMORY_MATCH_MIN_DTH && s.coverage >= MEMORY_MATCH_MIN_COVERAGE && !shown[s.id])
|
|
1901
2129
|
.map(s => byId.get(s.id))
|
|
1902
2130
|
.filter(Boolean)
|
|
1903
2131
|
.slice(0, MEMORY_INJECT_BUDGET);
|
|
@@ -1925,12 +2153,17 @@ export function maybeEmitUserPromptMemoryInject(primary, { now = Date.now() } =
|
|
|
1925
2153
|
commitCount();
|
|
1926
2154
|
|
|
1927
2155
|
const matchedIds = picked.map(e => e.id);
|
|
1928
|
-
|
|
2156
|
+
// top_* = max over the picked set. top_score keeps its raw-BM25 semantics
|
|
2157
|
+
// (telemetry continuity); top_dth/top_coverage carry the decision signals so
|
|
2158
|
+
// the live PostHog stream can validate/re-tune the rule against real traffic.
|
|
2159
|
+
const agg = (key) => matchedIds.reduce((m, id) => Math.max(m, signalOf.get(id)?.[key] || 0), 0);
|
|
1929
2160
|
return {
|
|
1930
2161
|
wrote: true,
|
|
1931
2162
|
match: {
|
|
1932
2163
|
matched_ids: matchedIds,
|
|
1933
|
-
top_score:
|
|
2164
|
+
top_score: agg('score'),
|
|
2165
|
+
top_dth: agg('dth'),
|
|
2166
|
+
top_coverage: agg('coverage'),
|
|
1934
2167
|
prompt_index: promptIndex,
|
|
1935
2168
|
n_matched: matchedIds.length,
|
|
1936
2169
|
had_team_general: hadTeamGeneral,
|
|
@@ -2134,7 +2367,7 @@ async function main() {
|
|
|
2134
2367
|
: ev.type === 'AssistantThinking' ? 'assistantthinking'
|
|
2135
2368
|
: 'tokenusage';
|
|
2136
2369
|
if (sourceUuid) {
|
|
2137
|
-
ev.event_id = deterministicEventId(
|
|
2370
|
+
ev.event_id = deterministicEventId(`${ev.source}:${kind}:${sourceUuid}`);
|
|
2138
2371
|
} else {
|
|
2139
2372
|
// Fallback: stdin hash + per-event index. Should never be needed because
|
|
2140
2373
|
// Claude Code transcripts always include uuid per record.
|
package/client/memory-match.js
CHANGED
|
@@ -11,10 +11,26 @@
|
|
|
11
11
|
* this must have ZERO runtime deps (no `natural`, no tokenizer package). ~1 file,
|
|
12
12
|
* pure functions, deterministic — fully unit-testable without I/O.
|
|
13
13
|
*
|
|
14
|
-
* The corpus is the SMALL primed pool (
|
|
15
|
-
*
|
|
16
|
-
* would otherwise get idf ≤ 0 and
|
|
17
|
-
* prompt). See IDF_FLOOR.
|
|
14
|
+
* The corpus is the SMALL primed pool (up to ~100 candidates for 0.8.119+ clients,
|
|
15
|
+
* legacy cap ~30), so this is BM25 with a tiny-corpus caveat baked in: idf is
|
|
16
|
+
* FLOORED (a term present in every candidate would otherwise get idf ≤ 0 and
|
|
17
|
+
* vanish, even when it's the whole point of the prompt). See IDF_FLOOR.
|
|
18
|
+
*
|
|
19
|
+
* DECISION SIGNALS (post-replay redesign, board [0102]): a 20k-prompt historical
|
|
20
|
+
* replay showed the RAW BM25 sum cannot be thresholded — it grows with prompt
|
|
21
|
+
* length (long pasted logs hit 4–23 on unrelated memories), while true matches
|
|
22
|
+
* are distinguished by WHICH terms hit, not how many points accumulate. So each
|
|
23
|
+
* scored candidate also carries:
|
|
24
|
+
* - `dth` (distinctive title hits) — how many unique query terms land in the
|
|
25
|
+
* candidate's TITLE while being RARE in the pool (df ≤ max(1, ⌊0.3·N⌋)).
|
|
26
|
+
* df-based on purpose: an absolute idf cutoff is unreachable for pools of
|
|
27
|
+
* N ≤ 3 (a title term always has df ≥ 1 → idf ≤ 1.204), silently disabling
|
|
28
|
+
* injection for small teams.
|
|
29
|
+
* - `coverage` — rawScore / the query's saturation ceiling (every unique term
|
|
30
|
+
* matched at tf→∞ on an average-length doc). Normalizes away prompt length.
|
|
31
|
+
* The inject decision in capture.js is `dth >= 2 && coverage >= 0.12`, measured
|
|
32
|
+
* on the replay golden set (test/client/memory-match-golden.test.js): recall 65%
|
|
33
|
+
* on verified positives, 4.5% fire-rate on the noise set (vs 100% for raw>=3).
|
|
18
34
|
*
|
|
19
35
|
* Bilingual by design: memory titles + our MEMORY.md entries are Russian; prompts
|
|
20
36
|
* are mixed RU/EN. Tokenization is Unicode-letter/number aware so Cyrillic and
|
|
@@ -36,6 +52,12 @@ const WEIGHT_TAGS = 2;
|
|
|
36
52
|
const WEIGHT_PATHS = 2;
|
|
37
53
|
const WEIGHT_PREVIEW = 1;
|
|
38
54
|
|
|
55
|
+
// A query term is "distinctive" for dth-counting when its pool document frequency
|
|
56
|
+
// is at most this fraction of the pool (floored at 1 so N=3 pools still work).
|
|
57
|
+
// 0.3 reproduces the replay-measured cutoff (df ≤ 20 at N = 67) and stays
|
|
58
|
+
// reachable at any pool size — see the header note on why NOT an idf cutoff.
|
|
59
|
+
const DISTINCTIVE_DF_RATIO = 0.3;
|
|
60
|
+
|
|
39
61
|
// Bilingual stopwords (EN + RU) — high-frequency function words that carry no
|
|
40
62
|
// topic signal. Kept deliberately small; over-stopping hurts recall more than a
|
|
41
63
|
// few function words hurt precision on this tiny corpus.
|
|
@@ -104,8 +126,11 @@ export function candidateBag(cand) {
|
|
|
104
126
|
*
|
|
105
127
|
* @param {string} promptText the user's current prompt (already truncated upstream)
|
|
106
128
|
* @param {Array<object>} candidates primed light-index entries ({id,title,tags,repo_rel_paths,preview,…})
|
|
107
|
-
* @returns {Array<{id:string,score:number}>} candidates
|
|
108
|
-
* Stable tie-break by id so ordering
|
|
129
|
+
* @returns {Array<{id:string,score:number,dth:number,coverage:number}>} candidates
|
|
130
|
+
* with score > 0, sorted desc by score. Stable tie-break by id so ordering
|
|
131
|
+
* is deterministic across engines. `dth`/`coverage` are the inject-decision
|
|
132
|
+
* signals (see header); `score` stays the raw BM25 sum used for RANKING
|
|
133
|
+
* and telemetry (`top_score` semantics unchanged).
|
|
109
134
|
*/
|
|
110
135
|
export function scoreCandidates(promptText, candidates) {
|
|
111
136
|
const queryTerms = tokenize(promptText);
|
|
@@ -114,18 +139,21 @@ export function scoreCandidates(promptText, candidates) {
|
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
// Build per-candidate term-frequency maps + doc lengths from the weighted bags.
|
|
142
|
+
// titleTerms is kept separately for dth: a hit only counts as "title" when the
|
|
143
|
+
// term appears in the title field itself, not merely anywhere in the bag.
|
|
117
144
|
const docs = [];
|
|
118
145
|
for (const cand of candidates) {
|
|
119
146
|
if (!cand || cand.id == null) continue;
|
|
120
147
|
const bag = candidateBag(cand);
|
|
121
148
|
const tf = new Map();
|
|
122
149
|
for (const t of bag) tf.set(t, (tf.get(t) || 0) + 1);
|
|
123
|
-
docs.push({ id: cand.id, tf, length: bag.length });
|
|
150
|
+
docs.push({ id: cand.id, tf, length: bag.length, titleTerms: new Set(tokenize(cand.title)) });
|
|
124
151
|
}
|
|
125
152
|
if (docs.length === 0) return [];
|
|
126
153
|
|
|
127
154
|
const N = docs.length;
|
|
128
155
|
const avgdl = docs.reduce((s, d) => s + d.length, 0) / N || 1;
|
|
156
|
+
const distinctiveDfCap = Math.max(1, Math.floor(DISTINCTIVE_DF_RATIO * N));
|
|
129
157
|
|
|
130
158
|
// Document frequency per UNIQUE query term (over the pool = the corpus).
|
|
131
159
|
const uniqueQueryTerms = [...new Set(queryTerms)];
|
|
@@ -144,17 +172,28 @@ export function scoreCandidates(promptText, candidates) {
|
|
|
144
172
|
idf.set(term, Math.max(raw, IDF_FLOOR));
|
|
145
173
|
}
|
|
146
174
|
|
|
175
|
+
// Query saturation ceiling for coverage: what the score would be if EVERY unique
|
|
176
|
+
// query term matched at tf→∞ on an average-length doc (den → f + K1 ⇒ factor
|
|
177
|
+
// (K1+1)/(1+K1)·f/(f+…) → (K1+1)/(1+K1) at f=1 normalization). Constant per
|
|
178
|
+
// query, so coverage is comparable across prompts of any length.
|
|
179
|
+
let sumIdfSat = 0;
|
|
180
|
+
for (const term of uniqueQueryTerms) sumIdfSat += idf.get(term) * (K1 + 1) / (1 + K1);
|
|
181
|
+
|
|
147
182
|
const results = [];
|
|
148
183
|
for (const d of docs) {
|
|
149
184
|
let score = 0;
|
|
185
|
+
let dth = 0;
|
|
150
186
|
for (const term of uniqueQueryTerms) {
|
|
151
187
|
const f = d.tf.get(term);
|
|
152
188
|
if (!f) continue;
|
|
189
|
+
if (d.titleTerms.has(term) && df.get(term) <= distinctiveDfCap) dth++;
|
|
153
190
|
const num = f * (K1 + 1);
|
|
154
191
|
const den = f + K1 * (1 - B + B * (d.length / avgdl));
|
|
155
192
|
score += idf.get(term) * (num / den);
|
|
156
193
|
}
|
|
157
|
-
if (score > 0)
|
|
194
|
+
if (score > 0) {
|
|
195
|
+
results.push({ id: d.id, score, dth, coverage: sumIdfSat > 0 ? score / sumIdfSat : 0 });
|
|
196
|
+
}
|
|
158
197
|
}
|
|
159
198
|
|
|
160
199
|
results.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|