feat(history): query messages by time/channel via native chronicle index - #99
Conversation
Registers two native chronicle secondary field indexes at MessageStore construction (/timestamp numeric, /metadata/external/channelId string) and adds query methods built on top: - queryByTime / queryByChannel / queryByTimeAndChannel — O(log n + k) ordinal lookups via the native index, content fetched only for the matched page via point lookups, never a full-slot materialization. queryByTimeAndChannel intersects the full uncapped ordinal sets from both native queries before paginating, since a native single-filter page can't be post-filtered by the other criterion without paginating against the wrong universe. - getChannelCounts — native distinct-value counts, O(index size), zero content decoding. - getChannelTokenStats — token totals by channel; no native token index exists (tokens aren't a field on stored messages and stamping one would mean rewriting all historical messages), so this reuses the existing calibrated token estimator via a small incremental per-ordinal cache. Query/registration calls are capability-detected (older chronicle builds without this native index throw a clear, specific error rather than silently falling back to something misleading) and self-heal once on a `null` result (chronicle's "no such index — unregistered, wrong kind, or poisoned" signal, distinct from an empty match array): a single re-register + retry, then a distinct "unavailable" error if still null, so a transiently-poisoned index (e.g. from a cross-branch write elsewhere in the store) doesn't silently read as "no messages". Corresponding ContextManager thin wrappers added in the same style as the existing getMessageWindow/queryMessages. Requires the companion chronicle native field-index PR (anima-research/chronicle#17). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI status note"Changelog entry present": fixed (fragment added in 1ddb3d8). "Build & Test" (15 test failures across all 4 matrix jobs): expected for now, not a defect — this PR's new tests correctly follow this repo's own established convention (see This will go green on its own once chronicle#17 merges and publishes and this PR's 🤖 Generated with Claude Code |
Chronicle 0.4.0 published (anima-research/chronicle#17) — ships the native secondary field-index capability this PR's queryByTime/ queryByChannel/etc. depend on. Unblocks CI: the 15 tests that were failing against the old published 0.3.0 (which lacks the native capability) now run for real instead of hitting the graceful- degradation error path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Recommendation: hold merge until the two P2 cache-correctness findings below are fixed and regression-tested. The native query paths and their self-healing behavior work in the tested cases, but Reviewed head:
Validation:
— Reviewed with OpenAI Codex. |
Addresses 3 review findings from Codex/GPT-5.6 Sol on PR #99 (anima-research/context-manager, review at ac4861e), all in getChannelTokenStats' per-ordinal cache: - [P2] tokenStatsCache was keyed only by ordinal, so it survived a branch switch untouched even though ordinals are branch-relative. A diverged branch reusing the same ordinal for a different message would silently serve the other branch's cached channel/token data. Fixed by tracking the cache's warmed branch (tokenStatsCacheBranch) and wiping the cache wholesale on any detected change, mirroring this file's existing lookupIndex/rebuildIndex branch-detection pattern rather than inventing a new one. - [P2] Cached entries stored the CALIBRATED token estimate, so a setTokenCalibration() call (routine during autobiographical strategy operation) left already-cached entries frozen at their old calibration while newly-cached entries used the new one. Fixed by caching the RAW (calibration-independent) estimate instead, mirroring the existing estimateBlockTokensRaw/estimateBlockTokens split, and applying the current calibration multiplier at read time — no invalidation needed when calibration changes. - [P3] The TS cast on metadata.external didn't validate the runtime value, so a non-string channelId (null, a number, ...) could leak into byChannel as its own bucket, disagreeing with the native String-kind field index (which chronicle 0.4.0 correctly excludes such values from). Fixed with a small extractChannelId helper that normalizes non-string values to undefined. Adds 3 regression tests in test/message-store-history-index.test.ts reproducing each reviewer repro exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
All 3 findings fixed in f33c2a0. 1. [P2] Branch-scoping: added `tokenStatsCacheBranch`, checked against `store.currentBranch().name` at the top of `getChannelTokenStats` — mirrors this file's own existing `lookupIndex`/`rebuildIndex` branch-change-detection pattern rather than inventing a new one. Any detected branch change wholesale-clears `tokenStatsCache` before it's touched (a stale-branch entry isn't just outdated, it can name a completely different message at the same ordinal, so a partial/keyed invalidation wasn't enough — full clear on branch change is correct here). New regression test reproduces your exact repro: shared `base` message, diverge on `main`/`side` at the same ordinal, warm cache pre-switch, assert post-switch stats reflect the current branch's message. 2. [P2] Calibration invalidation: the cache now stores the RAW (calibration-independent) per-message estimate via the existing `estimateBlockTokensRaw` — mirroring this file's own raw/calibrated split for the block-level cache — instead of the calibrated one. `tokenCalibration` is applied at READ time on every `getChannelTokenStats` call, so a `setTokenCalibration()` call is reflected on the very next call with no invalidation step needed at all (simpler than clearing on calibration change). New regression: warm cache at calibration 1, call `setTokenCalibration(2)`, assert totals double on the next call. 3. [P3] Non-string channelId leakage: added a private `extractChannelId` helper that runtime-validates (`typeof === 'string'`) instead of just type-asserting, normalizing `null`/numbers/etc. to `undefined` — matching chronicle's native String-kind field index, which silently excludes non-string values. New test: one message with `channelId: 'a'`, one with `channelId: null` — asserts `getChannelCounts()` and `getChannelTokenStats().byChannel` now agree. Verification: `npx tsc --noEmit` clean, `npm run build` clean, `npm test` 779/779 pass (776 prior + 3 new regressions), 0 failures. 🤖 Generated with Claude Code |
|
Recommendation: hold merge for the remaining P2 branch-identity issue below. The original three reproduction probes now pass. The normal branch-switch, calibration-refresh, and non-string-channel cases are fixed, but branch-name reuse still serves another branch's statistics. The new rounding discrepancy is lower priority. Re-reviewed head:
Verification:
— Re-reviewed with OpenAI Codex. |
…fix multi-block rounding order Addresses 2 further review findings from Codex/GPT-5.6 Sol on PR #99 (anima-research/context-manager, re-review at f33c2a0): - [P2] tokenStatsCacheBranch compared store.currentBranch().name, but chronicle branch names are reusable (delete a non-current branch, create a different branch under the same name) while branch ids are not. A delete+recreate-under-the-same-name sequence with no intervening stats call left the name-based guard blind to the branch swap, serving the deleted branch's stale cached data. Renamed the tracked field to tokenStatsCacheBranchId and compare store.currentBranch().id instead — mirroring the reasoning chronicle's own native field-index fix already uses for branch scoping. - [P3] The cache summed all of a message's raw per-block estimates and applied Math.round(sum * calibration) once, but the live estimateTokens() path sums INDIVIDUALLY-rounded per-block calibrated estimates instead. round(sum(raw) * f) != sum(round(raw * f)) in general for a multi-block message at a fractional calibration factor, so cached stats could disagree with a live estimateTokens() call on the same message. Changed the cache to store rawBlockEstimates (per-block, not pre-summed) and replay the same round-then-sum order at read time. Adds 2 more regression tests in test/message-store-history-index.test.ts reproducing each reviewer repro exactly (branch delete+recreate under the same name; two single-character blocks at calibration 0.6). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Both fixed in 9f43ed4. 1. [P2] Branch id, not name: `tokenStatsCacheBranch` (a `.name` string) is now `tokenStatsCacheBranchId`, compared and stamped from `store.currentBranch().id` — the immutable, never-reused identifier, unlike name which a delete+recreate can legitimately reuse. New regression reproduces your exact repro: warm cache on a branch, delete it, recreate a DIFFERENT branch under the same name, assert stats reflect the new branch's data, not the deleted one's. 2. [P3] Rounding order: the cache now stores `rawBlockEstimates: number[]` per top-level content block (not pre-summed for the whole message) and replays the identical per-block round-then-sum `estimateTokens` already uses, at read time in `getChannelTokenStats`. New regression: two 1-char text blocks, cache warmed at calibration 1, recalibrated to 0.6, asserts the cached path exactly equals a live `estimateTokens()` call (2, matching round-then-sum, not the old sum-then-round-once result of 1). Verification: `npx tsc --noEmit` clean, `npm run build` clean, `npm test` 781/781 pass, 0 failures. 🤖 Generated with Claude Code |
|
Recommendation: merge. The remaining branch-identity and rounding findings are resolved in I reviewed the latest diff and replayed all five probes from the two review rounds. All pass:
No new actionable findings in the fixes. The explicitly documented stale-cache behavior after edits/removals within a branch remains a limitation; this review does not claim that those statistics are refreshed after such mutations. Validation on the reviewed head:
— Re-reviewed with OpenAI Codex. |
…ronicle to ^0.4.0 context-manager 0.9.0 published (anima-research/context-manager#99) — ships the queryMessagesByTime/queryMessagesByChannel/etc. this module's HistoryModule depends on. Also bumps this package's own direct chronicle dependency to ^0.4.0: leaving it at ^0.3.0 while context-manager pulled in 0.4.0 produced two separate installed chronicle copies (npm can't dedupe across an unsatisfied range), which made TypeScript see two structurally-identical-but-distinct JsStore types and fail to compile anywhere this package's own code touches a JsStore. Bumping both to ^0.4.0 lets npm dedupe to one copy. Unblocks CI: tsc --noEmit was failing outright against the old published context-manager (missing exports); now clean, and the full suite passes (677/681, 4 pre-existing unrelated skips) against the real published dependency chain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Registers two native chronicle secondary field indexes at
MessageStoreconstruction (/timestampnumeric,/metadata/external/channelIdstring, requires anima-research/chronicle#17) and adds query methods on top:queryByTime/queryByChannel/queryByTimeAndChannel— O(log n + k) ordinal lookups via the native index; content is fetched only for the matched page via point lookups, never a full-slot materialization.queryByTimeAndChannelintersects the full, uncapped ordinal sets from both native queries before paginating — a native single-filter page can't be correctly post-filtered by the other criterion without paginating against the wrong universe.getChannelCounts— native distinct-value counts, O(index size), zero content decoding.getChannelTokenStats— token totals by channel. No native token index exists (tokens aren't a field on stored messages, and stamping one would mean rewriting every historical message), so this reuses the existing calibrated token estimator via a small incremental per-ordinal cache.Corresponding
ContextManagerthin wrappers added in the same style as the existinggetMessageWindow/queryMessages.Reliability: every native call is capability-detected (an older chronicle build without this index throws a clear, specific error rather than silently degrading to something misleading) and self-heals once on a
nullresult (chronicle's "no such index — unregistered, wrong kind, or poisoned" signal, distinct from a genuine empty match array): one re-register + retry, then a distinct "unavailable" error if stillnull— so a transiently-poisoned index elsewhere in the store never silently reads back as "no messages".Built to back agent-facing "search/stats/extract over full uncompressed history" tools (companion
agent-frameworkPR: anima-research/agent-framework#151) that need to stay fast against multi-GB production stores (Mythos/Sol scale) without a full scan.A note on the base
Local
mainhere had diverged substantially fromorigin/main(98 commits apart in each direction — both sides independently touchedcontext-manager.ts/message-store.tsas part of two different kv-unified integration attempts). This PR's single commit was rebased cleanly (pure 3-way merge, no manual conflict resolution needed — the change is 100% additive) directly onto currentorigin/main, then re-verified there: typecheck clean, full suite 776/776 passing againstorigin/main's actual code, not the diverged local branch.Test plan
npx tsc --noEmitclean againstorigin/maintest/message-store-history-index.test.ts: 23 tests covering time-range boundary inclusivity, channel exact-match, time+channel intersection correctness, channel counts, token-stat totals against a manual sum, channelId-less messages, and the null-return self-heal/fail-closed paths)🤖 Generated with Claude Code