Skip to content

feat(recall): let callers supply the temporal window instead of parsing it - #3678

Merged
nicoloboschi merged 4 commits into
mainfrom
feat/recall-explicit-temporal-window
Aug 21, 2026
Merged

feat(recall): let callers supply the temporal window instead of parsing it#3678
nicoloboschi merged 4 commits into
mainfrom
feat/recall-explicit-temporal-window

Conversation

@nicoloboschi

@nicoloboschi nicoloboschi commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

Recall works out the temporal arm's window by parsing dates out of the query text. A caller that already knows the range it means — a date picker, an agent that resolved "last quarter" itself, a scheduled job over a fixed period — had no way to say so. It had to phrase the range in English and hope the parser agreed.

Fix

temporal_window: {start, end} on RecallRequest. When set it is used verbatim and the extraction is skipped entirely.

{ "query": "what did we decide about pricing",
  "temporal_window": { "start": "2023-04-01T00:00:00Z", "end": "2023-06-30T23:59:59Z" } }

Skipping the extraction is half the point. It is pure CPU deliberately serialised through a single worker, and costs up to ~1.3s on document-sized query text — which is exactly what consolidation and reflect recall with.

It ranks, it does not filter

The obvious reading of a date range on a search API is "restrict results to this period", and that is not what this does. The temporal arm is one of four retrieval arms: it surfaces memories whose own dates (mentioned_at / occurred_start / occurred_end) fall in the window so fusion ranks them higher. The semantic, keyword and graph arms are untouched, so memories dated outside the window are still returned.

Since that is the thing a caller is most likely to get wrong, every description says so explicitly and in the same terms — the model docstring, the OpenAPI field, both MCP recall tools, both wrapper clients, the control-plane type, and the docs page. Restricting results to a period would be a different feature, and is not in this PR.

Two smaller semantics, both settled at parse time rather than deep in a query:

  • Bounds are inclusive, and a naive datetime is read as UTC — the temporal arm coerces naive values to UTC anyway, so doing it at the boundary means both ends are unambiguous before they reach any SQL.
  • A window that ends before it starts is rejected with a 422 rather than silently matching nothing.

Interaction with enable_temporal_retrieval

A supplied window does not override it. That per-bank flag gates the arm itself and stays the single switch for it, so a caller cannot re-enable an arm a bank deliberately turned off. Covered by a test.

query_timestamp is unaffected — it still anchors recency scoring, so it remains useful alongside a window.

Control plane

The Recall Analyzer gets a Time window row — two datetime inputs, a Clear button, and a hint that states what the window actually does, since "date range on a search form" reads as a filter otherwise.

The two rules live in lib/temporal-window.ts rather than the component so they are testable: a window needs both ends (one alone is an incomplete range, not a half-open filter), and a reversed range is withheld and flagged inline with the Recall button disabled, rather than sent for the API to 422.

Comparing the raw datetime-local strings is exact — they are already YYYY-MM-DDTHH:mm, which sorts chronologically — so there is no Date parsing and no local-timezone reinterpretation between the input and the request. The value is sent with no offset, which the API reads as UTC, matching what query_timestamp already does from this same form; the hint says so. Strings added to all ten locales.

Surfaces updated

Dataplane (RecallRequest → handler → recall_asyncretrieve_all_fact_types_parallel), both MCP recall tool definitions, regenerated OpenAPI + Go/Rust/Python/TypeScript clients, both hand-written wrappers (TS temporalWindow, Python temporal_window), the control-plane proxy route and lib/api.ts, and the docs (api/recall.mdx, mcp-server.md, regenerated docs skill).

The Rust struct literals in hindsight-cli (2) and the client doctest (1) had to name the new field — progenitor generates RecallRequest at build time, so a struct literal that omits it stops compiling. Both crates verified with cargo check.

Test

  • tests/test_recall_temporal_window.py (9): the supplied window reaches the store verbatim; it beats what the query text would have extracted; the extraction path is unchanged without it; enable_temporal_retrieval=false still wins; reversed/equal bounds; naive→UTC; and two HTTP-level tests for handler forwarding and the 422.
  • Wrapper parity regressions on both sides, mirroring the existing min_scores pair: hindsight-clients/python/tests/test_recall_temporal_window.py, plus two cases in typescript/tests/main_operations.test.ts.
  • Control plane: npx vitest run (144, incl. 5 new for the window rules), npm run i18n:check, npx tsc --noEmit (5 errors = the pre-existing stale-SDK baseline, none in the changed files), plus a visual check of the rendered row.
  • ./scripts/hooks/lint.sh, uv run ty check, ./scripts/hooks/check-unused.sh, cargo check (cli + rust client), npx tsc --noEmit (TS client), npx jest -t temporal.

…ng it

Recall derives the temporal arm's window by parsing dates out of the query
text. A caller that already knows the range it means — a date picker, an agent
that resolved "last quarter" itself — had no way to say so, and had to phrase
it in English and hope dateparser agreed.

Add `temporal_window: {start, end}` to RecallRequest. When set it is used
verbatim and the extraction is skipped entirely, which is also the point: that
work is pure CPU serialised through a single worker and costs up to ~1.3s on
document-sized query text, which is exactly what consolidation and reflect
recall with.

Naming and wording carry weight here, because the obvious reading of a date
range on a search API is "restrict results to this period" and that is not
what this does. The temporal arm is one of four retrieval arms: it surfaces
memories whose own dates fall in the window so fusion ranks them higher, and
the other three arms are untouched, so memories outside the window are still
returned. Every description — model docstring, OpenAPI field, MCP tool, both
wrappers, control plane, docs — says so explicitly.

It does not override `enable_temporal_retrieval`. That per-bank flag gates the
arm itself and stays the single switch for it, so a supplied window cannot
re-enable an arm a bank turned off.

Bounds are inclusive and naive datetimes are read as UTC at parse time, so
both ends are unambiguous before they reach a query; a reversed window is
rejected at the boundary rather than silently returning nothing.

The Rust struct literals in the CLI and the client's doctest have to name the
new field or progenitor's generated RecallRequest stops compiling.

@r266-tech r266-tech left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public MemoryEngineInterface.recall_async contract in hindsight-api-slim/hindsight_api/engine/interface.py still lacks the new temporal_window parameter, while concrete MemoryEngine.recall_async accepts and forwards it. Extensions obtained through ExtensionContext are typed against this interface, so extension callers cannot use the explicit-window feature through the documented API without a type error, and third-party implementations are left out of contract parity.

Please add temporal_window with the matching type and default to MemoryEngineInterface.recall_async, document its semantics, and add an extension-facing type check or test that passes the field through the interface.

This comment targets PR #3678 at head 6e603e1091eac5b2a4fc72230f0db0881ed0b787, matching snapshot snapshot_6b4649b0b24142557b937a8c1f73cc5cc18ed37ea6fa059aba71335013dec64f. The head-binding check passed. git diff --check exited 0. The requested focused tests were not authority-approved and were not independently executed in this inspection.

@koriyoshi2041

Copy link
Copy Markdown
Contributor

The red checks split into one diff-owned failure and two unrelated failures:

  • check-cli-coverage is actionable: recall_memories.temporal_window is in the generated OpenAPI but is neither exposed in main.rs nor listed in hindsight-cli/.openapi-coverage.toml. Please either add the CLI surface or record an intentional skip with a reason.
  • test-api (1/3) failed only test_bank_stats_cache_distributed.py::TestDistributedBankStatsCache::test_result_is_written_and_served_from_table (2,214 other tests passed); that cache assertion is outside this diff.
  • test-openclaw-integration died during npm ci with a registry ECONNRESET while fetching TypeScript, before integration tests ran.

This is for head 6e603e1091eac5b2a4fc72230f0db0881ed0b787.

The recall UI could not reach the window it now proxies. Adds a Time window
row to the Recall Analyzer: two datetime inputs, a Clear button, and a hint
that states what the window actually does — ranks memories dated in the range
higher, does not hide the ones outside it — since "date range on a search
form" reads as a filter otherwise.

The two rules live in lib/temporal-window.ts rather than the component so they
are testable: a window needs both ends (one alone is an incomplete range, not
a half-open filter), and a reversed range is withheld and flagged inline with
the Recall button disabled, instead of being sent for the API to reject with a
422.

Comparing the raw `datetime-local` strings is exact — they are already
YYYY-MM-DDTHH:mm, which sorts chronologically — so there is no Date parsing
and no local-timezone reinterpretation between the input and the request. The
value is sent with no offset, which the API reads as UTC, matching what
query_timestamp already does from this same form; the hint says so.

Strings added to all ten locales.
…e button

Disabling the Recall button left the Enter-key handler on the query input
calling runSearch() directly. With a reversed range that ran the search anyway
and silently dropped the window, which is the failure the inline warning
exists to prevent. Guard in runSearch so every entry point agrees, and toast
the same message rather than doing nothing visible.
…ow-end

check-cli-coverage caught that recall_memories gained a request-body field the
CLI neither exposes nor exempts. The exemption list is for genuinely complex
nested bodies — min_scores' four calibrated floats, tag_groups' boolean tree —
and two datetimes is not that, so expose it rather than write it off.

Flattened into two flags and recorded as such in the coverage manifest, the
same shape `include` already uses.

Both ends are required: one alone is an incomplete range, not a half-open
filter, and running a recall without the window the caller asked for is worse
than refusing. A reversed range is rejected before the request rather than
sent for the API to 422, and a datetime with no offset is read as UTC, which
is how the API reads one.
@nicoloboschi
nicoloboschi merged commit 3de41af into main Aug 21, 2026
108 checks passed
@nicoloboschi
nicoloboschi deleted the feat/recall-explicit-temporal-window branch August 21, 2026 11:02
@koriyoshi2041

Copy link
Copy Markdown
Contributor

Confirmed the CLI coverage gap from my earlier triage is fixed at cb1eb2f0: memory recall --help now exposes both --window-start and --window-end, and the exact-head uv run cli-coverage-check passes with all 89 operations and 116 request parameters covered. git diff --check 13b7fdcb..cb1eb2f0 is also clean. The separate MemoryEngineInterface.recall_async parity point raised in review remains outside this CLI follow-up.

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.

3 participants