feat(history): author filter, extract around id, wholeWord, newest-first search - #174
antra-tess wants to merge 3 commits into
Conversation
…rst search
Resident (Fable) diary-work feedback on the history tools:
- author / excludeAuthor on search + extract. Incoming MCPL messages are all
participant "user"; the author lives in metadata.author, so the filter
matches author name/id, or participant for the agent's own turns. Exact
match, never substring. Results carry `author`.
- extract({aroundId, before, after, allChannels}): the conversation around a
message id from search, so a search hit no longer needs its timestamp
copied by hand into extract.
- search wholeWord (Unicode-aware; "mission" no longer hits "uncommissioned"),
order:"newest", and scannedThrough + hint whenever the scan stops early.
Found on a real store (not by the stub): a time-only native query's
matchedCount is the PAGE size, and channel queries come back in append
order, not timestamp order. New edgeWindow() picks the n messages nearest a
range edge in true timestamp order: native reverse when there is no channel,
a widening time window when there is one. Plain extract's matchedCount had
the same page-size problem: it now reports hasMore, and an exact
matchedCount only when a channel is given. The test stubs now mimic both
real contracts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed head 6ca10f0, and as a trial merge onto main@67991cf (CM 0.11.0). Second read by Opus 5.5 in a separate session; finding 2 and the sharper form of notes 3 and 4 are its catches, each re-run here on a real store.
Verdict: CHANGES REQUESTED, two findings of one class. The three claims about the native queries are true (checked in message-store.js, not the stub), and the ordinary cases on a real Chronicle store with backfilled timestamps come back right. The new contract the PR adds, "stop early, hand back scannedThrough, continue with from/to", is honest only when the scanned pool is in timestamp order, and there are two paths where it isn't.
Blocking
-
extractwithauthorand achannelIdresumes by timestamp over an append-ordered scan. The filtered scan pages the channel window withqueryMessagesByTimeAndChannel({ offset: scanned }), which the store returns in ordinal order.scannedThroughislastScanned.timestampand the hint says continue withfrom:scannedThrough. Under catch-up backfill, messages past positionscannedcan carry older timestamps, and the continuation never reaches them.Real store,
#diary, all by antra:a1..a5appended at 60..20 min ago, thenb1(55 min) andb2(25 min) appended late.extract({author:"antra", channelId:"diary", maxScan:5}) → a1 a2 a3 a4 a5, truncated, scannedThrough = t(a5) extract({author:"antra", channelId:"diary", from: scannedThrough}) → a5 (b1, b2 unreachable) extract({author:"antra", maxScan:5}) // no channel: time-ordered → a1 b1 a2 a3 a4, scannedThrough = t(a4) extract({author:"antra", from: scannedThrough}) → a4 b2 a5 (complete)The no-channel path is fine because a time-only query is timestamp-ordered. The PR's test for this path (
extract author: maxScan bound reports truncated + scannedThrough) has no channel and no backfill, so it can't see it; a test with both would go red on the current code (the probe above shows the failure; I didn't write the test). Fix: resume by raw window position (returnscannedas something likewindowOffsetand accept it back), which keeps the cheap paging and doesn't depend on (2). Paging through time slices instead would inherit (2) until that's fixed. ThepageFullhint has the same problem ("continue with from:scannedThrough and offset:0"). -
edgeWindow's overshoot fallback is wrong for any late-appended old message, not only "same-burst backfill". When the grown window holds more thanfetchCap(max(4n, n+1000)), or thecoveredbreak takes the whole range, the code takes the window's ordinal tail. That is the timestamp tail only if nothing in the tail was appended late. A density jump across one ×4 step gets there without any burst: a channel that was busy last week and quiet since.Real store, channel
d:dense0..dense1999stamped 3 days ago one second apart, thenq1..q5at 55..15 min ago, then one message appended last and stamped 4 days ago (OLD-backfill). One 30-day-old message in another channel, so the first step isn'tcovered.search({channelId:"d", order:"newest", maxScan:10}) → window = q5 q4 q3 q2 q1 dense1999 dense1998 dense1997 dense1996 OLD-backfill truncated:true, scannedThrough = 4.00 days ago extract({aroundId: q1, before:5, after:2}) → OLD-backfill dense1996 dense1997 dense1998 dense1999 q1 q2 q3The window contains a four-day-old message and drops
dense1995; theto:continuation then skipsdense0..dense1995, 1996 messages never scanned.aroundIdpresents the four-day-old message as part of the conversation aroundq1.aroundIdis the likeliest place to hit this: with the defaultbefore=10,fetchCapis about 1010, so all it takes is under 11 messages in one window step and over 1010 in the next. Fix: on overshoot, bisect the width between the previous step and the current one. Channel counts are exact, so the bisection lands in[n, fetchCap]. Keep the ordinal fallback only for a single millisecond holding more thanfetchCap. The doc comment's "correct for everything but same-burst backfill" goes with it.Two routes to
idx === -1inhandleExtractAroundfollow from this: the fallback plus backfill can push the anchor out of both sides' ordinal slices, and more thanAROUND_TIE_CAPsame-millisecond ties with a low-sequence anchor can too. Both throw "Message X is not in channel …", which is the wrong message. The comment "only reachable when an explicit channelId excludes the anchor" is overconfident. Reasoned from the code, not probed.
Notes
- Author id keys never match if the id has an uppercase letter.
normalizeAuthorSpeclowercases the spec;a.idis compared as stored. Discord ids are numeric, so nothing there; any platform with letter ids fails on the id half of "name or id". Folda.idtoo. wholeWordtreats combining marks as boundaries.WORD_CHAR_REis[\p{L}\p{N}_]; U+0301 and U+093E both test false. Socafematches inside NFDcafé, and Indic-script words match mid-word because every vowel sign reads as a boundary. Add\p{M}. Cyrillic works, which is what the test covers.- Newest-side channel window is capped at
Date.now(); the no-channel path isn't. When growth breaks on count,wTo = hi = Date.now(). Twenty messages in the last five minutes plus one stampednow+5s: channel search returnsc19..c15, no-channel returnsfuture c19..c16. Ato:continuation walks backward, so it never reaches the skewed one. Small, but Eidoverse has skew (ew#201). KeeptoMsopen on the newest side. author: "user"matches every MCPL-ingested message, since the stored participant is a key. Harmless; the description says the participant key is for "your own turns".- Cost: each channel count is a full time×channel intersection in the store (
queryByTimeAndChannelrebuilds it per call).edgeWindowdoes up to ~13 of them,aroundIdroughly 25 per call, and the author-filtered channel scan one per 1000-row page (50 for a fullmaxScan). Not correctness; antra's call whether CM should grow a cursor. - The no-channel
extractdropsmatchedCount. That's a removed response field, filed under "fixed"; resident scripts parse these. I found no consumer in connectome-host or AFsrc, so minor, but a changed/breaking fragment would be more honest than fixed.
Verified
- Store contract, read in
@animalabs/context-manager0.11.0message-store.js: time-onlyqueryByTimereturnsmatchedCount = page sizeand supportsreverse; time+channel returns an exactmatchedCountregardless oflimit(so thelimit: 0counting inedgeWindowis sound) and pages in ordinal order; channel-only is exact too. Both test stubs mirror this. - Real-store runs (
JsStore+MessageStore, timestamps edited after append the way CM's own tests do), belowfetchCap:order:"newest"in a channel returns the timestamp tail, not the append tail;aroundIdreturns timestamp neighbours across a backfill (r2 r90 r3 r91 r4) and interleaves channels withallChannels;author/excludeAuthorexact,@/<@id>forms, participant match for own turns; plainextractreportshasMoreand drops the page-sizematchedCountwithout a channel, keeps the exact one with; anchor-outside-channel and mixed-mode inputs error cleanly. A 3000-message burst plus five spaced messages and one backfill inside the burst comes back right for bothsearchandaroundId, because the first ten-minute window step covers it without overshoot. edgeWindowterminates:lo/hiare finite (store-wide earliest or 0, and now), width grows ×4 from ten minutes,coveredis checked beforecount.tscclean on the trial merge;history-module+history-module-ux75/75 on head and on the merge. No overlap with #173 (git merge-treeclean).package.jsonon the branch still says^0.10.0for context-manager; main is^0.11.0. The merge takes main's, which is what the code was verified against.
…windows
- extract author/excludeAuthor: resume a truncated filtered scan by window
position (`resume: {windowOffset, offset}`), not by timestamp — channel
windows page in append order, so a timestamp resume skipped late-appended
backfill. Fixes the pageFull hint too. scannedThrough dropped from extract.
- edgeWindow: on overshoot of fetchCap, bisect the far bound (exact counts)
instead of taking the ordinal tail; a single over-full millisecond is
resolved by sequence, which is exact. Newest side stays open when `to` is.
- aroundId: honest error for the >AROUND_TIE_CAP tie case.
- author ids folded to lower case; participant is a key only for messages
without author metadata; wholeWord treats \p{M} as word characters.
- Changelog: matchedCount removal moved to a breaking fragment.
- New real-store tests (JsStore + MessageStore, timestamps edited after
append) reproducing both review findings and notes 3–6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changes to the history tools, driven by a resident's (Fable's) feedback from diary work:
What's added
author/excludeAuthoronsearchandextract. Every incoming MCPL channel message is stored as participantuser; the real author is only inmetadata.author. The filter matches the author's name or id (case-insensitive, exact, never substring;@nameand<@id>also accepted), or the stored participant for the agent's own turns. Results now includeauthor.extractwith an author filter scans in-process up tomaxScan. It reports an exactmatchedCountonly when it read the whole window; otherwise it returnsmatchedCountAtLeast,truncated, andscannedThrough.extract({aroundId, before, after, allChannels}): the conversation around a message id returned bysearch, in the anchor's own channel by default. The anchor is markedanchor: true.search:wholeWord(Unicode-aware: "mission" no longer matches inside "uncommissioned").order: "newest".limitormaxScan):scannedThrough, plus a hint naming thefrom/tovalue to continue with.What's fixed (found on a real store; the old stub hid it)
queryMessagesByTime/queryMessagesByTimeAndChannelwithout a channel) reports the page size as itsmatchedCount, not a total. Channel-scoped queries return messages in append order, and Discord catch-up appends old-timestamped messages late. The newedgeWindow()returns the n messages nearest a range edge in true timestamp order: the nativereverseoption when there's no channel, and a widening time window when there is one. Newest-first search andaroundIdboth use it.extractwithout a channel was reporting that page-sizedmatchedCountas a total. It now reportshasMore, and keepsmatchedCountonly when a channel is given (a response-shape change).Known limits
The author filter depends on what each connector puts in
author. In Eidoverse,id=name= the in-world name (no stable id), and narration is credited to the world, not to the person who acted. The same person can also appear under several names across platforms (antra_tessera/Antra). Callers can pass a list for now.Testing
test/history-module-ux.test.ts: 26 new tests, including catch-up backfill ordering and page-size counts. The existing history tests were updated for the new stub method and the limit+1 probe.npm test: 950 tests, 946 pass, 0 fail (4 not run).aroundIdreturns the true neighbours in the channel, andwholeWord/excludeAuthorbehave as intended.🤖 Generated with Claude Code