Skip to content

feat(modules): add HistoryModule for full uncompressed message history - #151

Merged
antra-tess merged 8 commits into
mainfrom
feat/history-module
Sep 17, 2026
Merged

antra-tess merged 8 commits into
mainfrom
feat/history-module

Conversation

@antra-tess

Copy link
Copy Markdown
Collaborator

Summary

Adds HistoryModule, exposing three read-only agent tools backed by context-manager's new native chronicle secondary-index queries (queryMessagesByTime / queryMessagesByChannel / queryMessagesByTimeAndChannel / getChannelMessageCounts / getChannelTokenStats, requires @animalabs/context-manager >= 0.6.0 with the companion history-index PR):

  • stats — per-channel message counts (all-time) + token totals (range-scoped), for orienting before pulling raw content.
  • extract — paginated raw messages for a time range and/or channel, rendered as readable text by default (format:"raw" for unmodified content blocks).
  • search — substring/regex match over a narrowed candidate window, with an explicit truncated signal when the caller's filter was too broad for maxScan — never silently drops matches past the cap.

These queries are O(log n + k) against chronicle's native field indexes, not a full-store scan, so the module stays responsive against multi-million-message production stores (Mythos/Sol scale). Kept deliberately read-only (no side effects, no message mutation), same posture as HealthModule — bind(contextManager) after AgentFramework.create(), mirroring HealthModule.bind() since ModuleContext itself exposes no store/context-manager reference.

Degrades gracefully (a clean ToolResult error, not a crash) against an older chronicle build that lacks the native index-query capability.

Dependencies

Needs both companion PRs to exercise the native path end-to-end:

Not wired into any resident's recipe — module enablement is host-app-level config (see forking-knowledge-miner's per-module recipe gate pattern), left for a separate follow-up.

Test plan

  • npm run typecheck clean (verified against the two companion branches' local builds)
  • New test/history-module.test.ts: 17 tests covering all three tools (stats shape, extract date/channel filtering + format + limit capping, search substring/regex/invalid-regex/maxScan truncation, unbound-module and capability-absent error paths)
  • Full suite: 647 tests, 634 pass, 0 fail, 4 pre-existing unrelated skips
  • Rebased cleanly onto current origin/main (no file overlap with this repo's other in-flight local work)

🤖 Generated with Claude Code

Exposes three read-only agent tools backed by context-manager's new
native chronicle secondary-index queries (queryMessagesByTime /
queryMessagesByChannel / queryMessagesByTimeAndChannel /
getChannelMessageCounts / getChannelTokenStats, @animalabs/context-manager
>= 0.6.0):

- `stats`   — per-channel message counts (all-time) + token totals
              (range-scoped), for orienting before pulling raw content.
- `extract` — paginated raw messages for a time range and/or channel,
              rendered as readable text by default or raw content
              blocks on request.
- `search`  — substring/regex match over a narrowed candidate window,
              with an explicit truncation signal when the caller's
              filter was too broad for maxScan rather than silently
              missing matches past the cap.

These queries are O(log n + k) against chronicle's native field
indexes, not a full-store scan, so the module stays fast against
multi-million-message production stores. Kept deliberately read-only
(no side effects), same posture as HealthModule — bind(contextManager)
after AgentFramework.create(), mirroring HealthModule.bind().

Requires the companion chronicle (native secondary field index) and
context-manager (query wrappers) changes to actually exercise the
native path; degrades to a clear tool error (not a crash) against an
older chronicle build lacking the capability.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

CI status note

"Changelog entry present": fixed (fragment added in 9ba878c).

"Build & Test" (tsc --noEmit fails outright): expected, not a defect — this is a compile-time consequence of stacking on an unpublished dependency. src/modules/history/index.ts calls ContextManager.queryMessagesByTimeAndChannel/getChannelMessageCounts/getChannelTokenStats and imports the ChannelCount/ChannelTokenStats types, none of which exist in the currently-published @animalabs/context-manager (^0.8.0) — they only exist on the companion context-manager PR (feat/chronicle-history-index, not merged/published), which itself depends on anima-research/chronicle#17. Unlike a runtime capability gap, a missing exported type/method can't be gated at runtime — tsc will only pass once context-manager publishes with these exports and this PR's dependency range is bumped to admit that version.

Verified locally against real builds of both companion branches (chronicle#17's native module + context-manager's feat/chronicle-history-index branch): npm run typecheck clean, 647/651 tests pass (4 pre-existing unrelated skips).

Suggested merge order: chronicle#17 → publish → context-manager feat/chronicle-history-index (bump its chronicle dep range) → publish → this PR (bump its context-manager dep range).

🤖 Generated with Claude Code

…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>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Bumped both `@animalabs/context-manager` (^0.8.0 → ^0.9.0, now published: anima-research/context-manager#99) and this package's own direct `@animalabs/chronicle` (^0.3.0 → ^0.4.0) in 9485f16.

The chronicle bump was necessary too, not just cosmetic: leaving this package's own direct chronicle dependency at ^0.3.0 while context-manager pulled in 0.4.0 left npm with two separate installed chronicle copies (an unsatisfied range can't be deduped), and TypeScript saw two structurally-identical-but-distinct `JsStore` types — failing to compile anywhere this package's own code (`framework.ts`, `recovery/offline-branch.ts`, tests) touches a `JsStore`. Bumping both to `^0.4.0` lets npm dedupe to one shared copy.

CI is now fully green: all 4 platform/Node-version jobs pass, changelog check passes.

🤖 Generated with Claude Code

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Recommendation: hold merge until the three findings below are fixed and regression-tested. The published dependency chain builds and the existing tests pass, but real-store probes expose a channel-schema mismatch, a pagination cap bypass, and an unbounded synchronous regex path.

Reviewed head: 9485f16d2fb00a0984035a6014f2025fa3afc51d.

  1. [P1] Match the channel metadata actually persisted by agent-framework — src/modules/history/index.ts:255–260 and 359–365.

    The module and its backing context-manager index assume metadata.external.channelId. The framework's normal MCPL channel-ingestion path writes metadata.channelId, alongside top-level serverId and messageId (framework.ts:4884–4890); it does not synthesize metadata.external.

    Reproduced using a real framework/store and handleMcplChannelIncoming: ingest one message on discord:g:c, then bind HistoryModule to that agent's context manager. The stored message count is 1, but stats returns messageCountsAllTime: [] and an empty channel breakdown; extract({channelId:'discord:g:c'}) returns zero messages, and channel-filtered search returns no matches with truncated:false. These are successful but false empty answers for normal framework history. Align the indexed channel schema and result projection with persisted framework data, including existing records. Add an integration test through the actual ingestion path; the current stub fixture writes only the assumed external shape.

  2. [P1] Bound regex evaluation outside the host event loop — src/modules/history/index.ts:426–427.

    An arbitrary, syntactically valid regex is executed synchronously on the framework's event-loop thread. maxScan bounds the number of messages, but a single RegExp.exec can perform catastrophic backtracking and block turns, health responses, maintenance, and ordinary timer-based tool timeouts. Catching regex-construction errors does not bound matching time.

    Reproduced in an isolated child process against the real module: one message containing 32 a characters followed by !, then search({query:'(a+)+$', regex:true, maxScan:1}). The child reached regex evaluation and had to be terminated after 3 seconds. An otherwise identical control using ^a+$ completed normally. Use a linear-time regex engine or a worker with an enforceable execution deadline; a promise timeout around synchronous matching cannot interrupt it.

  3. [P2] Validate non-negative integer pagination before crossing N-API — src/modules/history/index.ts:250–251 and 283–284.

    Math.min(value, maximum) enforces only an upper bound. The tool schemas permit negative numbers, and those values reach the native unsigned pagination arguments. With no filters, extract({limit:-1}) against a 250-message real store returns all 250 messages, exceeding the advertised hard cap of 200. Likewise search({query:'m',maxScan:-2}) passes native limit:-1, fetches the entire candidate set, and produces a 248-message candidate pool after the negative JS slice. On a large resident store this defeats the bounded-read design.

    Validate limit, offset, and maxScan as finite integers in their intended ranges before dispatch, and reflect the bounds in the schemas. Reject negative inputs or clamp them consistently; do not rely on the native unsigned conversion. Add real-native tests for these inputs, since the stub's Array.slice has different behavior.

Validation:

  • Isolated build passed with published @animalabs/context-manager 0.9.0, Chronicle 0.4.0, and Membrane 0.5.85.
  • Full npm test run: 764 tests, 760 passed, zero failed, four skipped.
  • HistoryModule suite run directly without --test-force-exit: 17 passed.
  • Three additional real-store/child-process probes reproduced the findings above.
  • git diff --check passed; all GitHub checks were green at final refresh; review checkout is clean.

— Reviewed with OpenAI Codex.

antra-tess and others added 3 commits September 17, 2026 08:59
Two review findings on #151's HistoryModule (head 9485f16):

- [P1] search's regex:true path ran the caller's pattern via a synchronous
  RegExp.exec() on the main thread — a catastrophically-backtracking
  pattern (e.g. (a+)+$) blocks the framework's single-threaded event loop
  for every agent, not just the calling search. A Promise.race/setTimeout
  can't interrupt this (nothing on the same thread can preempt synchronous
  JS), so regex matching now runs on a one-shot node:worker_threads worker
  (src/modules/history/search-regex-worker.ts) with a hard wall-clock
  deadline; a stuck worker is forcibly terminate()'d and the call surfaces
  as a clean tool error, never a silently-empty result. Follows the
  existing gate-script.ts/gate-script-worker.ts precedent for sandboxing
  script-shaped execution in this repo, simplified to a one-shot
  request/response (handleSearch was already async, so no need for
  gate-script's Atomics.wait synchronous-emulation machinery). Plain
  substring search is unaffected (linear, in-process, no ReDoS risk) — only
  regex mode pays the worker-spawn cost.

- [P2] extract/search capped limit/offset/maxScan with bare
  Math.min(value, max), which only enforces an upper bound —
  Math.min(-1, 200) is -1. A negative limit/offset/maxScan reached
  context-manager's native pagination calls unbounded (repro:
  extract({limit:-1}) against a 250-message store returned all 250).
  Replaced with clampCount(), which rejects any non-finite/non-integer/
  negative value as a clean tool error before any native call is made.

Adds regression coverage: the reviewer's ReDoS repro (asserts the call
returns in bounded time with a clear failure, not a hung test or an empty
success), a control regex to prove the worker path still matches correctly,
and 18 negative/non-integer/NaN/Infinity cases across extract's
limit/offset and search's limit/maxScan, including the reviewer's exact
250-message/248-candidate repro shapes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ation

Two review findings on the HistoryModule PR:

- ReDoS: an arbitrary caller-supplied regex was matched synchronously
  on the framework's event loop. A catastrophically-backtracking
  pattern (e.g. (a+)+$ against 32 'a's) blocks not just this tool
  call but every agent's turns, health checks, and timers, since
  Node is single-threaded and a Promise timeout cannot interrupt an
  in-flight synchronous RegExp.exec. Fixed by moving regex-mode
  matching (only regex mode — substring search is inherently linear
  and stays in-process) to a one-shot worker thread
  (search-regex-worker.ts) with a hard wall-clock deadline; the
  worker is always terminate()'d in a finally block whether it
  finished, errored, or had to be killed mid-backtrack. Follows this
  repo's existing gate-script.ts/gate-script-worker.ts precedent for
  the same class of problem (bounding caller-influenced execution)
  rather than inventing a new mechanism.

- Negative pagination: Math.min(value, max) only enforces an upper
  bound, so limit:-1/maxScan:-2 reached native chronicle calls
  unbounded, defeating the advertised hard caps (verified: a
  250-message store returned all 250 on limit:-1). Fixed with
  clampCount(), which rejects any limit/offset/maxScan that isn't a
  finite non-negative integer before any context-manager call.

Also: getChannelId() now checks metadata.channelId before
metadata.external.channelId, matching context-manager 0.9.1's
dual-schema channel index (bumped below) — a channel-filtered result
found via the newer schema no longer displays channelId: null.

Bumps @animalabs/context-manager to ^0.9.1 (anima-research/context-manager
now indexes and merges both channel-id metadata shapes — 0.9.0 only
indexed metadata.external.channelId, which real agent-framework MCPL
ingestion never writes, so HistoryModule silently found nothing for
real framework history; see that repo's 0.9.1 changelog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

All three findings fixed.

1. [P1] Channel schema mismatch — fixed upstream, not in this repo. Confirmed: this was a context-manager bug (its native channel index only covered `metadata.external.channelId`, but agent-framework's real `handleMcplChannelIncoming` ingestion writes `metadata.channelId` directly). Fixed and published as `@animalabs/context-manager@0.9.1` (indexes and merges BOTH schemas now — see that repo's 0.9.1 changelog), which this PR now depends on (`^0.9.1`, bumped in 465c80b). Also fixed `getChannelId()` here to check the same two schemas in the same order, so a channel-filtered result found via the newer schema doesn't display `channelId: null`.

2. [P1] ReDoS via unbounded synchronous regex — fixed in a55b788. Regex-mode search (only regex mode — substring search is inherently linear, stays in-process, common case pays no overhead) now runs in a one-shot worker thread (`search-regex-worker.ts`) with a hard wall-clock deadline; the worker is always `terminate()`'d in a `finally` block whether it finished, errored, or had to be killed mid-backtrack. Confirmed your Promise-timeout observation was correct and didn't attempt that approach — went straight to real OS-level concurrency. Followed this repo's own existing precedent for the same class of problem (`src/gate/gate-script.ts`/`gate-script-worker.ts`, used for agent-authored `gate.js`) rather than inventing a new mechanism or adding a new dependency (checked — nothing in this monorepo already pulls in re2/an equivalent). New test reproduces your exact repro (32×'a' + '!', `(a+)+$`, `maxScan:1`) and asserts a bounded return time with a clean `isError` failure, not a hang or silent empty success, plus a control case proving the worker path still matches correctly.

3. [P2] Negative pagination bypass — fixed in a55b788. New `clampCount()` rejects any `limit`/`offset`/`maxScan` that isn't a finite, non-negative integer, checked before any context-manager call — verified against your exact repro shapes (250-message store, 248-candidate pool) with an assertion that zero native calls are made before rejection.

Verification: `npx tsc --noEmit` clean, full `npm test` 788/792 pass, 0 failures, 4 pre-existing unrelated skips.

🤖 Generated with Claude Code

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Recommendation: hold merge for the P2 channel-counting regression in the newly selected context-manager dependency. All three original review probes now pass: normal MCPL channel queries work, negative pagination is rejected, and the pathological regex returns a clean timeout from the worker. Two smaller pagination edge cases are also listed below.

Re-reviewed head: 465c80b1a4fad8ce4500515520a7ef0b7f497253, using published context-manager 0.9.1.

  1. [P2] Deduplicate messages that carry both channel metadata fields when reporting counts — handleStats at src/modules/history/index.ts:241.

    Context-manager 0.9.1 correctly unions the two indexes for extraction, but its getChannelCounts simply sums their counts. The assumption that the schemas are disjoint is not enforced: handleMcplChannelIncoming preserves incoming metadata.external and adds top-level metadata.channelId, so an incoming event with legacy channel metadata becomes a valid dual-schema record through the framework's own ingestion path.

    Reproduced with one incoming MCPL event whose channel is discord:g:c and whose metadata is {external:{channelId:'discord:g:c'}}. The store contains one message; channel-filtered extraction returns one message and token stats count one message, but stats.messageCountsAllTime reports two messages for that channel. This makes the orientation tool contradict its own other results. Fix the overlap accounting upstream and require the corrected dependency (or otherwise report unique messages here). Add this real-ingestion overlap case alongside the single-schema regression.

  2. [P3] Honor the accepted zero search limit in both matching paths — substring loop at src/modules/history/index.ts:367–374 and worker loop at search-regex-worker.ts:62–64.

    clampCount explicitly accepts zero, but both loops append a match before checking the limit. With one matching message, search({query:'needle',limit:0}) returns one match, and regex:true does the same. Return zero matches without starting the scan, or reject zero consistently and document a positive minimum.

  3. [P3] Bound offsets to the native pagination range — src/modules/history/index.ts:276.

    Offset validation uses Number.MAX_SAFE_INTEGER, but the time-only/unfiltered native query accepts a u32 offset. Values beyond that range still wrap on conversion. Against a one-message real store, extract({offset:4294967296,limit:1}) succeeds and returns the first message instead of an empty page or validation error. Reject offsets outside the native range or handle an out-of-range offset before crossing N-API.

Verification:

  • Isolated build passed with context-manager 0.9.1 / Chronicle 0.4.0.
  • Full suite with --test-concurrency=4: 820 tests, 816 passed, zero failures, four skipped.
  • HistoryModule suite run directly: 37 passed.
  • All three original review probes pass. The slow regex returns success:false, isError:true, and the 2000 ms timeout error instead of hanging; a normal regex succeeds.
  • A regex smoke test through Bun's source-code entry path also passed.
  • The three additional probes above reproduce the remaining issues.
  • git diff --check passes; all GitHub checks are green; head rechecked and review checkout clean.

— Re-reviewed with OpenAI Codex.

antra-tess and others added 2 commits September 17, 2026 09:30
Two more small review findings on #151's HistoryModule (head 465c80b):

- [P3] search's limit:0 wasn't honored in either matching loop (the
  in-process substring loop in index.ts, and search-regex-worker.ts's own
  independent copy of the same loop): both pushed a match onto the results
  array BEFORE checking matches.length >= limit, so with limit:0 (a value
  clampCount already accepts — it's >= 0) and at least one matching
  candidate, both paths still returned exactly 1 match instead of 0. Moved
  the limit check to before any per-candidate work in both loops.

- [P3] extract's offset validation ceiling was Number.MAX_SAFE_INTEGER, but
  the native chronicle call it reaches takes a u32 offset (max
  4294967295). A value between the u32 max and MAX_SAFE_INTEGER passed
  clampCount unchanged, then wrapped/truncated crossing the N-API boundary
  — repro: extract({offset: 4294967296, limit: 1}) against a real
  1-message store "succeeded" and returned page 0 instead of an empty page
  or a validation error. Added NATIVE_OFFSET_MAX (0xFFFFFFFF) as the
  offset ceiling, clamped the same way limit already is (consistent
  clamp-not-reject behavior).

Adds regression coverage: limit:0 in both substring and regex mode
(asserts zero matches, not one), and offset:4294967296 asserted clamped to
4294967295 before it ever reaches context-manager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

All three findings fixed.

1. [P2] getChannelCounts double-counting — fixed upstream. Context-manager's dual-schema fix in 0.9.1 correctly unioned ordinals for extraction/token-stats, but `getChannelCounts` was summing the two native indexes' counts, double-counting a message that legitimately carries both `metadata.channelId` AND `metadata.external.channelId` (a real shape — MCPL ingestion preserves incoming `metadata.external` while also adding its own top-level `metadata.channelId`). Fixed and published as `@animalabs/context-manager@0.9.2`: `getChannelCounts` now reuses the already-correct `queryChannelOrdinals` union (same logic `queryByChannel` relies on) per distinct channel value instead of summing native counts. This PR now depends on `^0.9.2` (bumped in 57ec676).

2. [P3] `search`'s `limit:0` not honored — fixed in b0058ef. Both matching loops (in-process substring, and the worker's independent copy) were appending a match before checking the limit. Moved the check to before any per-candidate work in both places. New tests: `limit:0` against a real matching candidate returns zero matches, for both substring and regex/worker paths.

3. [P3] Offset beyond native u32 range wrapped instead of erroring — fixed in b0058ef. Added `NATIVE_OFFSET_MAX = 0xffffffff`, `extract`'s offset `clampCount` call now bounds to that instead of `Number.MAX_SAFE_INTEGER`. New test reproduces your exact repro (`offset: 4294967296`) and asserts the value actually reaching context-manager is clamped to `4294967295`, not the raw wrap-prone input.

Verification: `npx tsc --noEmit` clean, full `npm test` 823/827 pass, 0 failures, 4 pre-existing unrelated skips.

🤖 Generated with Claude Code

@antra-tess

Copy link
Copy Markdown
Collaborator Author

Recommendation: merge. All remaining findings are resolved at 57ec6765a14a5af4ab07929970956371fbf84ede, using published @animalabs/context-manager 0.9.2. This supersedes my previous hold-merge recommendation.

I reviewed the pagination changes and the dependency's updated channel-count implementation, then replayed all six review probes. They all pass:

  • Normal MCPL channel metadata is found by channel-filtered history queries.
  • A message carrying both supported channel fields is counted once, consistent with extraction and token statistics.
  • Negative pagination inputs are rejected before native dispatch.
  • limit: 0 returns no matches in both substring and regex modes.
  • An offset beyond the native unsigned range no longer wraps to the first page.
  • The pathological regex produces a clean worker timeout; the control regex still works.

No new actionable findings in these fixes.

Validation on the reviewed head:

  • Isolated TypeScript build: passed.
  • Full test suite with --test-concurrency=4: 824 tests, 820 passed, zero failed, four skipped.
  • HistoryModule suite run directly without --test-force-exit: 40 passed.
  • Independent review probes: six passed, including real framework ingestion and a child-process regex check.
  • git diff --check: passed.
  • All GitHub build/test and changelog checks: green.
  • Review checkout clean; head rechecked before posting.

— Re-reviewed with OpenAI Codex.

@antra-tess
antra-tess merged commit 47b8b7d into main Sep 17, 2026
5 checks passed
antra-tess added a commit that referenced this pull request Sep 17, 2026
…at/operator-surgery-log

Conflicts in src/framework.ts were option-bag and return-shape unions:
updateAgentRuntimeSettings opts gain both allowInfeasible and requester/note;
nudge/unstick keep the quiesced hint AND the operator-log record; the
scheduler busy test keeps the surgery hold with main's quiesce comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant