v0.41.24.0 fix(conversation-parser): threshold gates + bold-paren-time pattern — 20,167 Circleback messages unblocked (closes #1533) (#1543)

* fix(conversation-parser): threshold-gated fallback + acceptance floor (closes #1533)

`gbrain conversation-parser scan` reported `phase: no_match` on meeting
pages where 175 of 226 lines (77.8%) were valid `imessage-slack` format.
The 36 reformatted Circleback meetings could not flow through the
conversation facts pipeline.

Root cause: `scorePattern` only scans the first 10 non-blank lines. A
meeting page's `## Summary` + blockquote + `## Transcript` preamble takes
all 10 head slots, so every pattern scored 0 and the orchestrator
short-circuited to `no_match` without ever seeing the transcript.

Fix: two-tier scoring with threshold gates.

1. Fast path unchanged: chat-only pages match on line 1, scoring 1.0,
   skipping the fallback entirely.
2. Full-body fallback fires when `top.score < SCORING_HEAD_TRIGGER_THRESHOLD`
   (0.3). NOT `=== 0` — Codex P1 #1 caught the bug class where a stray
   head match (blockquote that accidentally matches an unrelated
   pattern at 0.1) would suppress the fallback. 0.3 leaves the fast
   path untouched while triggering on any preamble-dominated page.
3. Minimum acceptance floor `SCORING_MIN_ACCEPTANCE` (0.05) prevents
   essay false positives: a 300-line essay with one stray
   `**Name** (date time):` line scores ~0.003 — without the floor it
   would flip to `regex_match` with `messages.length = 1`. Closes
   Codex P1 #2.

DRY refactor: extract `getNonBlankLines` + `scoreFromLines` so the
quick_reject + regex loop lives in one place. New exported
`scorePatternFull` for direct unit testing. Fallback pre-splits the
body ONCE per pass to avoid 12 redundant splits.

Plan + decisions + Codex consult absorption at:
~/.claude/plans/system-instruction-you-are-working-starry-frost.md

Tests: 10 new cases in test/conversation-parser/parse.test.ts (87
pass). Highlights:
- #1533 IRON-RULE regression pin (meeting page → regex_match,
  imessage-slack, 20 messages)
- Stray-head-match guard (Codex P1 #1: irc-classic 0.1 in head does
  not suppress fallback; imessage-slack wins on full body)
- Essay false-positive guard (Codex P1 #2: 1/301 score below
  acceptance floor stays no_match)
- 300-line preamble + 50 chat lines hits fallback
- Cap test reshaped (Codex P2 #6): pins behavior not constant value

Once landed and a brain has `cycle.conversation_facts_backfill.enabled
= true` (opt-in), the 36 Circleback meetings flow through the fact
extractor automatically. Operators on the manual path run
`gbrain extract-conversation-facts <source>` directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(conversation-parser): add bold-paren-time built-in pattern (closes user-facing half of #1533)

Per /codex follow-up D-FOLLOWUP-1.B: the threshold-gated fallback fix
(8d7a18ac) closed the bug CLASS — but the user's actual 112 Circleback
meeting files at ~/git/brain/meetings/ use a transcript shape that no
existing built-in pattern matches:

  **Participant 2** (00:00): Companies that we...      ← (HH:MM)
  **Participant 1** (00:00:00): We found the...        ← (HH:MM:SS)

Without a pattern that matches this shape, even the fallback re-scoring
all 12 candidates against the full body returned zero matches → no_match.

Add `bold-paren-time` as the 13th built-in. Two sub-shapes covered via
non-capturing optional seconds group; capture indexes stay identical.
date_source: frontmatter so the page's `date:` provides the day anchor.

Time semantics: Circleback timestamps are elapsed-time-from-meeting-
start, not wall-clock. Parser treats them as wall-clock 24h on the
frontmatter date, so every message lands on the same day at HH:MM. The
fact extractor only cares about speaker + content, so this is honest
enough; precise per-line wall-clock would need an elapsed_time flag on
PatternEntry (v0.42+ scope).

Declaration position: after imessage-slack and telegram-bracket so on
the rare tie those more-specific patterns win. The regex requires `\)`
immediately after the time group, so imessage-slack's
`(2024-03-15 9:00 AM)` shape falls through correctly.

EMPIRICAL RESULT against all Circleback meetings in ~/git/brain/meetings:
- 367 total files with `source: circleback`
- Pre-fix:  0 parsed (no pattern matched the shape)
- Post-fix: 113 parsed (112 via bold-paren-time, 1 via telegram-bracket)
- 20,167 messages flow through to the fact extractor (was 0)
- 254 remain no_match (notes-only meetings without inline transcripts —
  transcripts in those cases live in separate files referenced via
  blockquote, not in the meeting body)

Smoke-tested manually against 3 representative files:
- 2026-03-19-yc-partner-strategy-ai-leverage-review.md → 225 messages
- 2026-01-15-ro-khanna-c4.md → 294 messages
- 2026-04-01-narrative-arc-equity-regrets.md → 9 messages (HH:MM:SS variant)

Tests: 54 unit cases pass (88 across full parser suite). 3 new cases in
parse.test.ts pin the contract: (HH:MM) shape matches, (HH:MM:SS) shape
matches, imessage-slack still wins on full-datetime overlap, and meeting
page with preamble + bold-paren-time transcript hits the threshold
fallback correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.21.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: bump conversation-parser entry to v0.41.21.0 (13 patterns + threshold gates)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy): scrub OpenClaw fork name from new pattern's source_doc + CLAUDE.md

CI verify failed on check:privacy — the new bold-paren-time pattern
added in 80208f21 referenced the private OpenClaw fork name in
builtins.ts:125 (comment) and builtins.ts:178 (source_doc), and the
CLAUDE.md doc-sync commit 1710020a leaked it once more.

CLAUDE.md privacy rule (line 550): the private fork name is banned
in CHANGELOG.md, README.md, docs/, skills/, PR titles + bodies,
commit messages, and comments in checked-in code. Canonical
replacement: "your OpenClaw" or "OpenClaw reference deployment".

This commit rewrites all three sites. Source pipeline attribution
stays accurate ("OpenClaw meeting-ingestion pipeline reformat of
Circleback transcripts") without naming the specific private fork.

bun run verify: 28/28 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version 0.41.21.0 → 0.41.24.0

Queue reservation by user — 0.41.22.0 / 0.41.23.0 slots left for
sibling worktrees. Bumps VERSION, package.json, CHANGELOG header,
and the CLAUDE.md entry's version tag in lockstep. llms-full.txt
regenerated.

bun run verify: 28/28 green. Parser tests: 92/92.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-27 08:33:50 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48e1000306
commit 726dfff02c
8 changed files with 582 additions and 32 deletions
+138
View File
@@ -2,6 +2,70 @@
All notable changes to GBrain will be documented in this file.
## [0.41.24.0] - 2026-05-27
**Your reformatted meeting transcripts now actually parse.**
If you reformat Circleback meetings into a per-line shape like
`**Garry Tan** (12:34): hello`, gbrain used to look at them and say
`no_match` — even when 78% of the page was valid chat. Your 36 reformatted
meetings sat there inert and the fact extractor never saw a single
message. v0.41.24.0 makes them flow through.
Two problems were stacked. First, the parser only looked at the first
10 lines of a page to figure out which chat format it was. Meeting
pages start with `## Summary`, a blockquote, a `## Transcript` heading
— ~10 lines of prose before the actual chat. None of those lines
matched any chat regex, so the parser gave up. Second, even if the
parser had kept looking, none of the 12 built-in patterns recognized
the time-only shape Circleback exports use: `**Speaker** (HH:MM):` or
`**Speaker** (HH:MM:SS):`. Both shapes were invisible.
The fix tightens the detector AND teaches it the missing shape. The
detector now falls back to scanning the full body when the head pass
returns a weak score, AND it refuses to accept a final score below 5%
(so an essay with one stray chat-shape line stays `no_match` instead
of false-positive parsing as a 1-message conversation). The new
`bold-paren-time` built-in covers the two Circleback variants. After
the fix, 113 of 367 Circleback meeting pages on the canonical brain
parse correctly (was 0), and **20,167 messages** flow through to the
fact extractor for the first time. The other 254 are notes-only
meetings that link to a separate transcript file — those legitimately
have nothing to parse.
**How to use it:** `gbrain upgrade`. Then either enable the cycle phase
that runs in the background (`gbrain config set
cycle.conversation_facts_backfill.enabled true`) or fire it manually
(`gbrain extract-conversation-facts <source>`). The parser fix
unblocks the pipeline; the trigger is yours.
**The detector numbers that matter** (parse.ts):
| Setting | Value | What it does |
|---|---|---|
| `SCORING_HEAD_LINES` | 10 | Lines scanned in the fast path (unchanged) |
| `SCORING_HEAD_TRIGGER_THRESHOLD` | 0.3 | Below this, fall back to full-body scoring |
| `SCORING_MIN_ACCEPTANCE` | 0.05 | Final score floor — below this, return `no_match` |
The 0.3 trigger threshold (instead of "fall back only on score === 0")
closes a silent bug class: a blockquote that accidentally matches an
unrelated pattern at 0.1 used to suppress the fallback entirely. Now
it doesn't.
**Things to watch:**
- The new pattern treats Circleback's `(00:00)` / `(00:00:00)` as
wall-clock time on the page's frontmatter date. Real Circleback
timestamps are elapsed-time-from-meeting-start, so every message in
a meeting lands on the same day starting at 00:00 + offset. The
fact extractor cares about speaker + content, not precise
timestamps, so this is honest enough. Proper per-line wall-clock
reconstruction would need an `elapsed_time: true` flag on
PatternEntry — filed as v0.42+ scope.
- `imessage-slack` still wins on full-datetime overlaps. The new
pattern's regex requires `\)` immediately after the time group, so
`(2024-03-15 9:00 AM)` shapes correctly fall through to the more
specific pattern.
## [0.41.23.0] - 2026-05-26
**You can now see how every extractor in your brain is doing — how
@@ -577,6 +641,80 @@ doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists.
### Itemized changes
#### Added
- `bold-paren-time` built-in pattern in
`src/core/conversation-parser/builtins.ts` recognizes
`**Speaker** (HH:MM): text` and `**Speaker** (HH:MM:SS): text`.
Verified against 367 Circleback meeting files at `~/git/brain/meetings/`:
113 now parse (was 0), 20,167 messages flowing through.
- `scorePatternFull(body, entry)` exported from
`src/core/conversation-parser/parse.ts` for callers that need
full-body scoring of a single pattern.
#### Changed
- `parseConversation()` falls back to full-body re-scoring when the
head pass's top score is below `SCORING_HEAD_TRIGGER_THRESHOLD`
(0.3) instead of only when it is exactly 0. The pre-fix shape left
a real bug class open where a stray head match suppressed the
fallback.
- `parseConversation()` returns `no_match` when the final winner's
score is below `SCORING_MIN_ACCEPTANCE` (0.05) to prevent
essay-false-positive parsing.
- `scorePattern()` is now a thin wrapper over a private
`scoreFromLines` core that holds the quick_reject + regex loop in
one place. `scorePattern` behavior + contract + signature unchanged.
#### Tests
- 11 new unit cases in `test/conversation-parser/parse.test.ts`:
the #1533 IRON-RULE regression pin (meeting page →
`regex_match`), honest unmatched-count after fallback, pure-prose
stays `no_match`, 300-line preamble + 50 chat lines hits fallback,
`scorePatternFull` direct boundaries, stray-head-match
miscategorization guard, essay false-positive acceptance-floor
guard, `bold-paren-time` matches HH:MM, `bold-paren-time` matches
HH:MM:SS, `imessage-slack` still wins on full-datetime overlap,
meeting page with preamble + `bold-paren-time` transcript hits
fallback.
- Existing "caps at SCORING_HEAD_LINES (10)" test reshaped to pin
behavior (10 match + 1 non-match scores 1.0; 9 non-match + 1 match
+ 100 after scores 0.1) instead of importing the constant.
#### Closed
- Closes #1533 — both the bug class (trigger threshold + acceptance
floor) and the user-facing case (113 Circleback meetings × 20,167
messages).
## To take advantage of v0.41.24.0
`gbrain upgrade` should do this automatically. Then to actually flow
your reformatted Circleback meetings through the fact extractor:
1. **One-shot manual extraction (immediate):**
```bash
gbrain extract-conversation-facts <source>
```
Replace `<source>` with the source id holding your meeting pages.
Use `gbrain sources list` to find it.
2. **OR enable the cycle phase (continuous, opt-in):**
```bash
gbrain config set cycle.conversation_facts_backfill.enabled true
```
The dream cycle picks them up on the next tick.
3. **Verify the parser sees them:**
```bash
gbrain conversation-parser scan <slug> --json
```
Expected output for a Circleback meeting:
`"phase": "regex_match"`, `"matched_pattern_id":
"bold-paren-time"`, `"message_count"` in the dozens to hundreds.
4. **If parser still reports `no_match`** on a page you expected to
parse, your meeting may use a transcript shape outside the 13
built-in patterns. Run `gbrain conversation-parser list-builtins`
to see what shapes are recognized, file an issue with a sample
page, or wait for v0.42+'s user-declared `simple_pattern`
support.
- `atomsExistingForHashes(engine, sourceId, hashes[])` exported from
`src/core/cycle/extract-atoms.ts` — one batched SQL roundtrip that
returns the set of `content_hash16` values already extracted as atoms
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
0.41.23.0
0.41.24.0
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -140,5 +140,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.23.0"
"version": "0.41.24.0"
}
+59
View File
@@ -119,6 +119,65 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
source_doc: 'PR #1461 (closed); preserved verbatim with Co-Authored-By',
},
{
// v0.41.18+ (D-FOLLOWUP-1.B closes the user-facing half of #1533):
// matches the shape Circleback meeting exports use after an
// OpenClaw meeting-ingestion pipeline reformats them. Two
// sub-shapes in the wild (verified across a 367-file corpus):
// **Participant 2** (00:00): Companies that we have... ← (HH:MM)
// **Participant 1** (00:00:00): We found the apostrophes... ← (HH:MM:SS)
//
// The time group is elapsed time from meeting start, NOT
// wall-clock. Parser treats it as wall-clock 24h on the
// frontmatter date — speaker + text are captured correctly, but
// every message lands on the same day starting at 00:00 + offset
// minutes. The downstream fact extractor only cares about
// speaker + content, so this is honest-enough; precise per-line
// wall-clock timestamps would require a new `elapsed_time:
// true` flag on PatternEntry (v0.42+).
//
// Declaration position is AFTER imessage-slack + telegram-
// bracket so on the rare tie those more-specific patterns win.
// The regex deliberately requires `\)` immediately after the
// time so `(2024-03-15 9:00 AM)` and `(9:00 AM)` shapes fall
// through to imessage-slack instead of false-matching here.
// The seconds segment is a non-capturing optional group so
// capture indexes stay identical across both sub-shapes.
id: 'bold-paren-time',
origin: 'builtin',
regex: /^\*\*(.+?)\*\*\s+\((\d{1,2}):(\d{2})(?::\d{2})?\)\s*:\s*(.*)$/,
captures: {
speaker_group: 1,
hour_group: 2,
minute_group: 3,
text_group: 4,
},
date_source: 'frontmatter',
time_format: '24h',
timezone_policy: 'utc_assumed_with_warn',
multi_line: false,
quick_reject: /^\*\*/,
test_positive: [
'**Garry Tan** (00:00): hello world',
'**Participant 2** (02:22): response here',
'**Alex Graveley** (15:09): Thats exactly right.',
'**Participant 1** (00:00:00): hello world with seconds',
'**Participant 2** (01:23:45): mid-meeting line',
],
test_negative: [
// imessage-slack shape (full date+time) MUST fall through to imessage-slack:
'**Alice Example** (2024-03-15 9:00 AM): iMessage shape',
// telegram-bracket shape MUST fall through to telegram-bracket:
'**[18:37] \u{1f464} G T:** telegram bracket',
// No bold markers:
'Alice (00:00): missing the bold',
// Bold but no parens:
'**Alice** hello world',
],
source_doc:
'OpenClaw meeting-ingestion pipeline reformat of Circleback transcripts (see your OpenClaw skills/meeting-ingestion/SKILL.md)',
},
{
id: 'telegram-text-export',
origin: 'builtin',
+115 -22
View File
@@ -48,6 +48,33 @@ export type { ParseConversationOpts, ParseResult, MatchedMessage } from './types
*/
const SCORING_HEAD_LINES = 10;
/**
* Head-pass score below which `parseConversation` falls back to a
* full-body re-score (v0.41.18+ fix for #1533 + Codex P1 #1).
*
* Why a threshold instead of `=== 0`: meeting pages start with
* `## Summary` + blockquotes + `## Transcript` headings. A
* blockquote like `> [12:00] Foo` can accidentally match an
* unrelated pattern's regex (irc-classic at score 0.1) which would
* suppress the fallback even when 175 of 226 lines further down are
* valid imessage-slack. 0.3 = "fewer than 3 of 10 head lines
* matched"; chat-only pages still score 1.0 and skip the fallback
* entirely, so the fast path is preserved.
*/
const SCORING_HEAD_TRIGGER_THRESHOLD = 0.3;
/**
* Minimum final winner score required to accept `regex_match`
* (v0.41.18+ Codex P1 #2). A 500-line essay with one stray
* `**Name** (date time):` line scores ~1/500 = 0.002 for
* imessage-slack, which without this floor would flip to
* `regex_match` with `messages.length = 1` — a false positive that
* silently corrupts downstream fact extraction. 0.05 = "at least 5%
* of non-blank lines anchored a message"; real transcript pages
* typically score 0.5+ and sail through, accidental anchors do not.
*/
const SCORING_MIN_ACCEPTANCE = 0.05;
/**
* Tie-breaker priority: lower index wins on score tie. Mirrors
* BUILTIN_PATTERNS declaration order. User-declared patterns get
@@ -334,6 +361,41 @@ export function applyPattern(
return out;
}
/**
* Split a body into non-blank trimmed lines. `headCap` (when set)
* limits the result to the first N lines — used by the head-pass
* scorer; omit for full-body scoring.
*/
function getNonBlankLines(body: string, headCap?: number): string[] {
if (!body) return [];
const all = body
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
return headCap !== undefined ? all.slice(0, headCap) : all;
}
/**
* Core scorer over a pre-split line array. Both `scorePattern` (head
* window) and `scorePatternFull` (whole body) delegate here so the
* quick_reject + regex loop lives in one place. Reused by
* `parseConversation`'s fallback path which pre-splits ONCE and
* passes the array to all 12 candidates (saves 11 redundant body
* splits per fallback pass).
*/
function scoreFromLines(
lines: readonly string[],
entry: PatternEntry,
): number {
if (lines.length === 0) return 0;
let anchored = 0;
for (const line of lines) {
if (entry.quick_reject && !entry.quick_reject.test(line)) continue;
if (entry.regex.test(line)) anchored++;
}
return anchored / lines.length;
}
/**
* Score how well a pattern matches the first N lines of a body (D18).
* Returns 0..1 ratio of matched lines. Higher = more confident.
@@ -344,19 +406,22 @@ export function applyPattern(
* Exported for tests.
*/
export function scorePattern(body: string, entry: PatternEntry): number {
if (!body) return 0;
const lines = body
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0)
.slice(0, SCORING_HEAD_LINES);
if (lines.length === 0) return 0;
let anchored = 0;
for (const line of lines) {
if (entry.quick_reject && !entry.quick_reject.test(line)) continue;
if (entry.regex.test(line)) anchored++;
}
return anchored / lines.length;
return scoreFromLines(getNonBlankLines(body, SCORING_HEAD_LINES), entry);
}
/**
* Score how well a pattern matches the FULL body, no head cap
* (v0.41.18+ Codex P1 #1 — full-body fallback when head pass falls
* below `SCORING_HEAD_TRIGGER_THRESHOLD`).
*
* Cost-aware: in `parseConversation`'s fallback path we pre-split
* ONCE and route through `scoreFromLines` directly, NOT through this
* wrapper, to avoid 12 redundant body splits per pass. This wrapper
* exists for direct unit testing and for any future caller that
* needs full-body scoring of a single pattern.
*/
export function scorePatternFull(body: string, entry: PatternEntry): number {
return scoreFromLines(getNonBlankLines(body), entry);
}
/**
@@ -392,23 +457,51 @@ export function parseConversation(
// declared priority order (built-in declaration order; user patterns
// lose every tie).
type Scored = { entry: PatternEntry; score: number; priority: number };
const scored: Scored[] = candidates.map((entry) => ({
const sortScored = (arr: Scored[]) =>
arr.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.priority - b.priority;
});
let scored: Scored[] = candidates.map((entry) => ({
entry,
score: scorePattern(body, entry),
priority: priorityOf(entry.id),
}));
// Sort: score desc, then priority asc.
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.priority - b.priority;
});
sortScored(scored);
// REGRESSION (closes #1533 + Codex P1 #1): meeting pages have
// ## Summary + blockquote + ## Transcript ahead of the chat. The
// pre-fix "trigger fallback only when score === 0" shape left a
// real bug class open — a stray head match (e.g. a blockquote
// that accidentally matches an unrelated pattern at 0.1)
// suppressed the fallback even when 175 of 226 lines further down
// were valid imessage-slack. Threshold 0.3 means "fewer than 3 of
// 10 head lines matched"; chat-only pages still score 1.0 and skip
// the fallback. Re-score every candidate against the full body,
// pre-splitting ONCE to avoid 12 redundant body splits.
if (scored[0].score < SCORING_HEAD_TRIGGER_THRESHOLD) {
const allLines = getNonBlankLines(body);
scored = candidates.map((entry) => ({
entry,
score: scoreFromLines(allLines, entry),
priority: priorityOf(entry.id),
}));
sortScored(scored);
// NOTE: patterns_scored stays as scored.length (= candidate
// count, typically 12) even when the fallback runs — the
// diagnostic reports "candidates considered" not "scoring
// attempts" (Codex P2 #7).
}
const top = scored[0];
const patternsScored = scored.length;
// If even the top scorer matches zero lines, no regex won. Caller
// may invoke LLM fallback (T4); for now return no_match.
if (top.score === 0) {
// Minimum acceptance floor (closes Codex P1 #2): an essay with
// one stray `**Name** (date time):` line scores ~1/300 ≈ 0.003 —
// below the 5% floor we stay no_match instead of returning a
// 1-message false positive. Real transcript pages typically score
// 0.5+ and sail through.
if (top.score < SCORING_MIN_ACCEPTANCE) {
return {
messages: [],
phase: 'no_match',
+266 -6
View File
@@ -22,6 +22,7 @@ import {
deriveDateContext,
applyPattern,
scorePattern,
scorePatternFull,
} from '../../src/core/conversation-parser/parse.ts';
import { BUILTIN_PATTERNS } from '../../src/core/conversation-parser/builtins.ts';
import type { Page } from '../../src/core/types.ts';
@@ -336,13 +337,272 @@ describe('scorePattern — boundary', () => {
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
expect(scorePattern('\n\n \n', tg)).toBe(0);
});
test('caps at SCORING_HEAD_LINES (10) lines', () => {
// 100 telegram lines → still scores 1.0 because only first 10 sampled.
const body = Array.from(
{ length: 100 },
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
).join('\n');
// T5 reshape (Codex P2 #6): pins BEHAVIOR not the constant value.
// The prior test ("100 matching lines score 1.0") would pass with
// head=10 or head=1000 — it didn't prove anything about the cap.
test('head cap ignores lines past line 10 (10 match + 1 non-match scores 1.0)', () => {
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
const matching = Array.from(
{ length: 10 },
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
);
const body = [...matching, 'plain text outside the head window'].join('\n');
// First 10 lines all match → 10/10. Line 11 was ignored.
expect(scorePattern(body, tg)).toBe(1);
});
test('head cap stops at line 10 (9 non-match + 1 match at line 10 + 100 match after scores 0.1)', () => {
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
const nonMatches = Array.from({ length: 9 }, (_, i) => `non-matching prose line ${i}`);
const matchingLate = Array.from(
{ length: 100 },
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
);
// Line 10 (index 9 in the matching array) IS a match; lines 11-109 are too
// but are past the head cap and don't count.
const body = [...nonMatches, matchingLate[0], ...matchingLate.slice(1)].join('\n');
// Head sees 9 non-matches + 1 match = 1/10 = 0.1. Pre-fix: same result.
// Post-fix: same result (this test pins head-cap behavior, not the new
// fallback path — that's tested separately below).
expect(scorePattern(body, tg)).toBe(0.1);
});
});
// ---------------------------------------------------------------------------
// scorePatternFull — direct unit tests (v0.41.18+ T3 #5)
// ---------------------------------------------------------------------------
describe('scorePatternFull — full-body scoring (v0.41.18+ Codex P1 #1)', () => {
test('empty body scores 0', () => {
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
expect(scorePatternFull('', im)).toBe(0);
});
test('preamble + 20 matching lines scores 20/(preamble + 20)', () => {
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
const preamble = ['## Summary', 'Three sentences.', '> Source: ref', '## Transcript'];
const matches = Array.from(
{ length: 20 },
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): message ${i}`,
);
const body = [...preamble, ...matches].join('\n');
// 24 total non-blank, 20 match → 20/24 ≈ 0.833
expect(scorePatternFull(body, im)).toBeCloseTo(20 / 24, 5);
});
test('preamble-only-no-match scores 0', () => {
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
const body = '## Summary\nProse paragraph.\n> Blockquote\n## Heading';
expect(scorePatternFull(body, im)).toBe(0);
});
});
// ---------------------------------------------------------------------------
// bold-paren-time pattern (v0.41.18+ D-FOLLOWUP-1.B; closes user-facing
// half of #1533 — the 112 Circleback meeting files at
// ~/git/brain/meetings/*.md with `source: circleback` frontmatter)
// ---------------------------------------------------------------------------
describe('bold-paren-time pattern (Circleback meeting transcripts)', () => {
test('matches **Speaker** (HH:MM): text with frontmatter date', () => {
const body = [
'**Garry Tan** (00:00): Hey, can you hear me?',
'**Participant 2** (02:22): Yeah, just joined.',
'**Garry Tan** (15:09): That makes sense.',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-paren-time');
expect(r.messages).toHaveLength(3);
expect(r.messages[0]).toEqual({
speaker: 'Garry Tan',
timestamp: '2026-03-19T00:00:00Z',
text: 'Hey, can you hear me?',
});
expect(r.messages[2]).toEqual({
speaker: 'Garry Tan',
timestamp: '2026-03-19T15:09:00Z',
text: 'That makes sense.',
});
});
test('matches **Speaker** (HH:MM:SS): text shape (Circleback seconds variant)', () => {
const body = [
'**Participant 1** (00:00:00): opening line',
'**Participant 2** (00:00:19): quick reply',
'**Participant 1** (01:23:45): later in the meeting',
].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-04-01' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-paren-time');
expect(r.messages).toHaveLength(3);
// Seconds segment is non-capturing; minute_group still captures the
// minutes component. Time-format is wall-clock 24h on frontmatter date.
expect(r.messages[0].timestamp).toBe('2026-04-01T00:00:00Z');
expect(r.messages[1].timestamp).toBe('2026-04-01T00:00:00Z');
expect(r.messages[2].timestamp).toBe('2026-04-01T01:23:00Z');
});
test('imessage-slack shape still wins over bold-paren-time on overlap', () => {
// Both patterns start with `**` and have parens. The imessage-
// slack regex requires a full date+time inside; bold-paren-time
// requires just `(HH:MM)`. The dates-with-AM/PM shape MUST fall
// through to imessage-slack, not bold-paren-time.
const body = '**Alice Example** (2024-03-15 9:00 AM): hello world';
const r = parseConversation(body);
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('imessage-slack');
expect(r.messages).toHaveLength(1);
expect(r.messages[0].text).toBe('hello world');
});
test('meeting page with preamble + bold-paren-time transcript hits fallback', () => {
// Real Circleback shape: ## Summary + blockquote + ## Transcript
// before the bold-paren-time chat. Same fallback gate that
// closes #1533 must work for this pattern too.
const preamble = [
'## Summary',
'Meeting covered Q1 roadmap discussion.',
'> Source: circleback meeting #7411053',
'## Topics Discussed',
'- Roadmap',
'- Hiring',
'## Transcript',
];
const transcript = Array.from(
{ length: 20 },
(_, i) => `**Participant 2** (${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i * 11) % 60).padStart(2, '0')}): transcript line ${i}`,
);
const body = [...preamble, ...transcript].join('\n');
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('bold-paren-time');
expect(r.messages).toHaveLength(20);
});
});
// ---------------------------------------------------------------------------
// parseConversation — full-body fallback (v0.41.18+ #1533 + Codex P1 #1, #2, #8)
// ---------------------------------------------------------------------------
describe('parseConversation — full-body fallback', () => {
// T3 #1: IRON-RULE regression pin for #1533. Pre-fix this returns
// no_match because head 10 sees only preamble.
test('#1533: meeting page with ## Summary + blockquote + ## Transcript before chat hits fallback', () => {
const preamble = [
'## Summary',
'This meeting covered Q1 roadmap discussion.',
'Three engineers participated in the call.',
'Action items were captured during the conversation.',
'> Source: [meeting recording](https://example.com/rec/123)',
'## Topics Discussed',
'- Product roadmap for Q1',
'- Engineering team allocation',
'- Customer feedback synthesis',
'## Transcript',
];
const transcript = Array.from(
{ length: 20 },
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): line ${i}`,
);
const body = [...preamble, ...transcript].join('\n');
const r = parseConversation(body);
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('imessage-slack');
expect(r.messages).toHaveLength(20);
});
// T3 #2: diagnostic now reports total_non_blank - matched, not total.
test('#1533: unmatched_line_count subtracts matched messages after fallback', () => {
const preamble = [
'## Summary',
'Prose A.',
'Prose B.',
'> Blockquote',
'## Transcript',
];
const transcript = Array.from(
{ length: 20 },
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): line ${i}`,
);
const body = [...preamble, ...transcript].join('\n');
const r = parseConversation(body, { diagnostic: true });
expect(r.phase).toBe('regex_match');
expect(r.unmatched_line_count).toBe(5); // 25 total non-blank - 20 messages = 5
});
// T3 #3: a 50-line essay with no chat shape stays no_match.
test('pure-prose 50-line essay stays no_match (fallback found nothing to anchor)', () => {
const body = Array.from(
{ length: 50 },
(_, i) => `This is the ${i + 1}th paragraph of a pure-prose article.`,
).join('\n');
const r = parseConversation(body);
expect(r.phase).toBe('no_match');
expect(r.messages).toHaveLength(0);
});
// T3 #4: proves "full-body" not just "wider window" — 300-line preamble
// far exceeds any reasonable head-bump alternative.
test('300-line preamble + 50 chat lines hits fallback (any preamble length)', () => {
const preamble = Array.from(
{ length: 300 },
(_, i) => `Preamble paragraph ${i + 1} with prose content here.`,
);
const transcript = Array.from(
{ length: 50 },
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): chat line ${i}`,
);
const body = [...preamble, ...transcript].join('\n');
const r = parseConversation(body);
expect(r.phase).toBe('regex_match');
expect(r.matched_pattern_id).toBe('imessage-slack');
expect(r.messages).toHaveLength(50);
});
// T3 #6 (Codex P1 #1 + #8): stray-head-match doesn't suppress fallback.
// Pre-fix: irc-classic 0.1 in head → no fallback → irc-classic wins with 1
// message. Post-fix: 0.1 < 0.3 trigger → fallback re-scores → imessage-slack
// wins (50/60 ≈ 0.83 vs irc-classic 1/60 ≈ 0.017).
test('Codex P1 #1: stray irc-classic match in head does not suppress fallback', () => {
const preamble = [
'## Meeting Notes',
'<presenter> Garry Tan opening remarks', // stray irc-classic match
'- agenda item 1',
'- agenda item 2',
'- agenda item 3',
'- agenda item 4',
'- agenda item 5',
'- agenda item 6',
'- agenda item 7',
'## Transcript',
];
const transcript = Array.from(
{ length: 50 },
(_, i) => `**Garry Tan** (2024-01-29 12:00 PM): real transcript line ${i}`,
);
const body = [...preamble, ...transcript].join('\n');
const r = parseConversation(body);
expect(r.phase).toBe('regex_match');
// The critical assertion: imessage-slack wins, NOT irc-classic.
expect(r.matched_pattern_id).toBe('imessage-slack');
expect(r.messages).toHaveLength(50);
});
// T3 #7 (Codex P1 #2): essay with one stray chat-shape line stays
// no_match. 1/301 ≈ 0.003, below SCORING_MIN_ACCEPTANCE (0.05).
test('Codex P1 #2: 300-line essay with one stray chat line stays no_match (acceptance floor)', () => {
const prose = Array.from(
{ length: 150 },
(_, i) => `Essay paragraph ${i + 1} of pure prose with no chat shape.`,
);
const strayChatLine = '**Author Name** (2024-01-01 9:00 AM): stray quoted snippet';
const morePros = Array.from(
{ length: 150 },
(_, i) => `Essay continuation paragraph ${i + 151}.`,
);
const body = [...prose, strayChatLine, ...morePros].join('\n');
const r = parseConversation(body);
// Pre-fix: regex_match with messages.length === 1.
// Post-fix: no_match because 1/301 < 0.05 acceptance floor.
expect(r.phase).toBe('no_match');
expect(r.messages).toHaveLength(0);
});
});