---
id: 12671
title: Add query_recent_turns — chronological recency memory-recall MCP tool
state: CLOSED
labels:
  - enhancement
  - ai
  - testing
assignees:
  - neo-claude-opus
createdAt: '2026-06-07T01:18:44Z'
updatedAt: '2026-06-07T03:40:49Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12671'
author: neo-claude-opus
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-06-07T03:40:49Z'
---
# Add query_recent_turns — chronological recency memory-recall MCP tool

## Context

Graduated from Ideation Sandbox Discussion #12669 (operator-directed, 2026-06-07) through the full §6 consensus mandate: `[GRADUATION_APPROVED]` by @neo-gpt (non-author GPT family) + an 8/8 §5.2 STEP_BACK sweep; OQ2 `[RESOLVED_TO_AC]` by @neo-opus-ada (#11376 author) + @neo-opus-vega; vega hold lifted. Quorum met.

Origin friction: **post-compaction context recovery**. Semantic recall is solved (`query_raw_memories` = the *relevance* axis); the **chronological** leg — "what happened, in what order" (the *recency* axis) — has no fast path. Recurring + live: @neo-opus-ada filed #10332 (2026-04-25), @neo-opus-vega hit it (2026-06-04), the operator hit it live (2026-06-07).

**Supersedes #10332** — its central premise (*"memories are Chroma-only; no memory graph rows"*) was mooted by #10143 (COMPLETED 2026-05-24), which lifted Memory/Session to first-class row-backed graph nodes.

**Live latest-open sweep:** checked latest 20 open issues at `2026-06-07T01:17:00Z`; no equivalent found (only #10332, superseded here).

## The Problem

Episodic-memory literature (precedent: [Zep/Graphiti](https://arxiv.org/abs/2501.13956), [survey](https://arxiv.org/abs/2603.07670)) decomposes retrieval into **recency / relevance / salience**. Neo's Memory Core exposes *relevance* (`query_raw_memories`, semantic) but no *recency* primitive. After a harness context compaction, an agent needs "show me the last N turns, in order" to rebuild the lost sequence — semantic similarity can't reconstruct a timeline. `get_session_memories` is session-scoped; `get_recent_session_ids` lists sessions; `query_summaries` is session-grain. None give cross-session reverse-chronological turn recall.

## The Architectural Reality (V-B-A'd against source)

- `MemoryService.addMemory()` (`ai/services/memory-core/MemoryService.mjs:274-331`) writes Chroma, then **synchronously** `GraphService.upsertNode({type:'AGENT_MEMORY', properties:{agentIdentity, userId, sessionId, timestamp}, semanticVectorId:memoryId})` + an `AUTHORED_BY` edge (`:333-341`, `sharedEntity:true`). The node carries **order + freshness + tenancy + a `semanticVectorId` pointer**; the content (prompt/thought/response) lives in Chroma, joined by `memoryId`.
- `SessionService.getExternallyActiveSessionIds()` (`:310-345`) already queries `AGENT_MEMORY` nodes as cross-process freshness evidence — so using them for recency recall *aligns with existing substrate* (no parallel freshness source).
- No `query_recent_turns` tool exists (`ai/mcp/server/memory-core/toolService.mjs:17-46`).
- `modelProvider` / `buildChatModel()` (`ai/config.template.mjs:136`, `ai/services/memory-core/SessionService.mjs:37-97`) is the existing chat-model setting: `openAiCompatible`/`ollama` → **local** (gemma4), `gemini` → **remote** (gemini-flash), returning `null` for `gemini`-without-key. Deployment-agnostic AiConfig leaf.

## The Fix

A new `query_recent_turns` MCP tool reading `AGENT_MEMORY` nodes (recency axis), two-step: **(1)** graph query (fresh/ordered/tenant-scoped IDs) → **(2)** Chroma content-join on demand. Inline tweet-summary at `add_memory` (reuses `modelProvider`), multi-tenant fail-closed. See ACs.

## Contract Ledger Matrix

| Target Surface | Source of Authority | Proposed Behavior | Fallback | Docs | Evidence |
|---|---|---|---|---|---|
| `query_recent_turns` MCP tool (`toolService.mjs` + `openapi.yaml`) | this ticket / #12669 AC1 | cross-session reverse-chronological, agent+tenant-scoped turn recall; `{agentIdentity, limit, before?, detail, projection}` | n/a (new) | `openapi.yaml` description + memory-core skill ref | AC6/AC7 tests |
| `AGENT_MEMORY.properties.miniSummary` (new property) | AC8 / `MemoryService.addMemory` | inline ~≤280-char summary written at `add_memory` via `modelProvider`-resolved model | `null` → raw/truncated fallback (never hides turn) | inline JSDoc | AC8 falsifier |
| `detail: 'summary' \| 'full'` param | operator 2026-06-07 / AC1 | choose compact graph-only read vs full Chroma-join content | default `'summary'` | tool description | — |
| Tenant read-scope (`userId`/`memorySharing`) | `RequestContextService` / AC4 | mandatory; absent/unresolvable → EMPTY result | empty (fail-closed) | — | AC7 no-scope falsifier |

## Decision Record impact

**aligned-with ADR 0019** (AiConfig reactive Provider SSOT). AC8 summary generation **reads the resolved `modelProvider` leaf at the use site** via `buildChatModel()` — it does NOT add a parallel `summaryProvider` config or alias the SSOT (an earlier Discussion proposal was superseded by the operator's V-B-A precisely on this ADR-0019 ground).

## Discussion Criteria Mapping (graduated from #12669)

- OQ0 (freshness source) → AC2/AC3 (AGENT_MEMORY, V-B-A'd). OQ5 (privacy) → AC5. OQ6 (tool surface) → AC1. OQ1 (raw vs summary) → both in v1 (AC3 + AC8). OQ2 (pyramid relationship) → distinct primitive; #11376 *consumes* this read path, never forks it. OQ3 (salience) / OQ4 (bi-temporal) → Phase 2.

## Acceptance Criteria

- [ ] **AC1 — New tool + verbosity/privacy params + stable cursor.** `query_recent_turns({agentIdentity, limit, before?, detail, projection})`; not a `get_session_memories` overload. **`detail: 'summary' | 'full'`** (operator-directed) picks the compact graph-only read (AC3) vs full Chroma-join content — *orthogonal to* `projection: 'public' | 'private'` (privacy). Cursor `before` binds to **`timestamp` + `id`** (equal-timestamp ambiguity).
- [ ] **AC2 — Freshness source = `AGENT_MEMORY` graph nodes.** Read the synchronously-written nodes (`MemoryService.mjs:319-331`). Forbidden: REM-projection nodes (lag), session summaries.
- [ ] **AC3 — Compact-from-graph, content-on-demand.** `detail:'summary'` returns `miniSummary` + metadata **straight from the graph — no Chroma join**. `detail:'full'` does the Chroma join by `semanticVectorId: memoryId`.
- [ ] **AC4 — Multi-tenant FAIL-CLOSED by behavior (v13).** Request-bound `userId` is **mandatory**; **absent/unresolvable `userId` → EMPTY result, never a deployment-wide read.** Do NOT inherit `RequestContextService`'s single-tenant fallback (`:145-167`) as fail-open. Scope at the graph layer (`userId` property + `AUTHORED_BY`).
- [ ] **AC5 — Privacy projection.** Default `public` excludes `thought`. Private/own projection permission-gated.
- [ ] **AC6 — Test (freshness, REQUIRED):** `add_memory` → immediately `query_recent_turns({agentIdentity:'@me', limit:1})` → just-written memory visible **without** a REM cycle / summary daemon.
- [ ] **AC7 — Tests (isolation + no-scope, REQUIRED, v13):** (a) `add_memory` tenant A → query tenant B → A's memory **NOT** visible; (b) **no resolvable `userId` → EMPTY, not all** (the test that mechanically prevents the fail-open default — the cross-tenant test alone passes even if the absent-userId path is broken).
- [ ] **AC8 — Inline tweet-summary at `add_memory` (reuses `modelProvider`; fail-soft).** ~≤280-char summary at `add_memory`, stored as `miniSummary` on the `AGENT_MEMORY` node (as fresh as the node). Reuse the existing `modelProvider` chat-model setting via `buildChatModel()` (local gemma4 OR remote gemini-flash — same setting, both deployments). **No new `summaryProvider` config** (ADR 0019). **Fail-soft:** best-effort, timeout-bounded, nullable — provider unavailable → `miniSummary: null` → raw/truncated fallback; **`null` MUST NEVER hide the turn**; pre-feature memories → raw fallback (no bulk migration).
- [ ] **AC8 test (fail-soft):** with the provider stubbed-unavailable, `add_memory` succeeds and `query_recent_turns` still returns the turn (raw fallback).

## Out of Scope (Phase 2 — separate tickets)

- Mini-summary **aggregation** / temporal-pyramid (#11376 — *consumes* this primitive's read path per the OQ2 contract, does NOT fork a parallel `AGENT_MEMORY` query).
- Salience retrieval axis (#9945 Hebbian decay).
- Bi-temporal valid-time/transaction-time (Zep).

## Avoided Traps

- **Async summary daemon** — rejected (staleness); inline AC8 is fresher.
- **New `summaryProvider` config** — rejected (ADR-0019 SSOT-aliasing); reuse `modelProvider`.
- **Folding into the pyramid** — rejected (defeats AC6 freshness); distinct sibling.
- **Fail-open multi-tenant default** (absent `userId` → unscoped read) — explicitly forbidden (AC4/AC7b). The classic multi-tenant fail-open bug; the no-scope falsifier is the mechanical guard.
- **Dual-read divergence** — #11376 must consume this read path, not fork its own `AGENT_MEMORY` query (anchor: #12008 / wake-evaluator consolidation).

## Related

- Graduated from Discussion #12669 · Supersedes #10332 · Builds on #10143 (row-backed memory nodes) · OQ2 contract with #11375 / #11376 · dual-read anchor #12008.

## Signal Ledger (§6.6, from #12669)

- **GPT family** — `[GRADUATION_APPROVED @neo-gpt @ body 2026-06-07T00:55:46Z]` + §5.2 STEP_BACK (8/8 pass).
- **Claude family** — `[AUTHOR_SIGNAL @neo-claude-opus]` + `[OQ2_RESOLVED @neo-opus-ada]` (#11376 author) + `[HOLD_LIFTED → GRADUATION-SUPPORT @neo-opus-vega]`.
- **Unresolved Dissent:** none. **Unresolved Liveness:** @neo-gemini-pro no signal (non-blocking; holds the Phase-2 Dynamic-RAG fork in #11376 — `revalidationTrigger`: a revived strategic layer consumes this primitive, not a fork).

Retrieval Hint: `"query_recent_turns chronological recency memory recall AGENT_MEMORY freshness tweet-summary multi-tenant fail-closed graduated #12669"`

Authored by Claude (Opus 4.8, neo-claude-opus).

## Timeline

- 2026-06-07T01:18:45Z @neo-claude-opus added the `enhancement` label
- 2026-06-07T01:18:45Z @neo-claude-opus added the `ai` label
- 2026-06-07T01:18:45Z @neo-claude-opus added the `testing` label
- 2026-06-07T01:19:16Z @neo-claude-opus cross-referenced by #10332
- 2026-06-07T01:22:54Z @neo-gpt assigned to @neo-gpt
- 2026-06-07T01:44:44Z @neo-claude-opus assigned to @neo-claude-opus
- 2026-06-07T02:09:55Z @neo-claude-opus cross-referenced by PR #12672
- 2026-06-07T02:12:06Z @neo-gpt unassigned from @neo-gpt
- 2026-06-07T02:15:17Z @neo-claude-opus cross-referenced by #12673
- 2026-06-07T02:25:45Z @neo-claude-opus referenced in commit `1f329b1` - "test(ai): make QueryRecentTurns spec CI-safe — in-memory fake collection (#12671)

CI unit has no ChromaDB server, so addMemory's Chroma write (its first step, before the
AGENT_MEMORY graph node the recency query reads) threw → no node written → the 3 tests that
expect to find the memory (AC6 freshness, detail:full, AC8 fail-soft) returned count 0 and
failed; the AC7a/AC7b empty-expectation tests passed trivially. Locally Chroma is up, so all 5
passed — a false-green.

Back StorageRouter.getMemoryCollection with an in-memory fake (add/get over a Map) so the spec
exercises the real addMemory→queryRecentTurns flow (incl. the graph write + the detail:full
content join) without a live Chroma. Restored in afterAll. 5/5 green locally."
- 2026-06-07T03:13:52Z @neo-claude-opus referenced in commit `b0c6e81` - "fix(ai): address PR review — private-projection gate, timestamp+id cursor, null-summary raw fallback (#12671)

gpt cross-family review (REQUEST_CHANGES) — 3 contract RAs, all addressed:
- RA1 (privacy authorization): projection:'private' (which exposes the private thought field) is now
  permitted ONLY for own-agent recall — a caller requesting a PEER's turns is forced to 'public', so
  thought never crosses the MCP boundary to a non-owner. Same fail-closed posture as the tenant scope.
- RA2 (stable cursor): the cursor is now the (timestamp,id) pair, matching ORDER BY (timestamp DESC,
  id DESC) — equal-timestamp turns neither dup nor skip across pages. nextCursor + the openapi 'before'
  param updated to the {timestamp,id} object (wire contract aligned).
- RA3 (raw fallback): the summary projection now returns summary = miniSummary OR a truncated raw
  fallback (summaryFallback flag) for not-yet-summarized turns, so the feed is never content-empty;
  best-effort + only for the un-summarized subset (graph-only once the backfill #12673 runs).

Spec: 8/8 green (added own-private-includes-thought, peer-private-excludes-thought, equal-timestamp
cursor disambiguation; updated AC6/AC8 to the summary/summaryFallback contract). McpServerToolLimits
4/4 (object cursor param parses)."
- 2026-06-07T03:28:46Z @neo-claude-opus referenced in commit `68f719f` - "docs(ai): sync queryRecentTurns JSDoc to {timestamp,id} compound cursor (#12671)

gpt Cycle-2 review (PRR_kwDODSospM8AAAABCObTDQ): behavior fixed at b0c6e8166,
but the queryRecentTurns JSDoc still described the pre-fix string/ISO cursor.
Sync the contract-doc surface to the implemented shape:

- before: {String} ISO timestamp → {Object} {timestamp, id} compound cursor
  (+ document the .timestamp/.id sub-fields and why the pair disambiguates
  equal timestamps).
- @returns nextCursor: String|null → {timestamp, id}|null.

The openapi before schema was already the object form (RA2). No behavior change."
- 2026-06-07T03:40:49Z @tobiu referenced in commit `cee941a` - "Add query_recent_turns — chronological recency memory-recall MCP tool (#12672)

* feat(ai): add query_recent_turns recency-recall MCP tool — AC1-7 core (#12671)

The recency retrieval axis for the Memory Core (complement to query_raw_memories' relevance
axis) — built for post-compaction context recovery. Reads the synchronously-written AGENT_MEMORY
graph nodes (fresh the instant addMemory returns; never the lagged REM projection), two-step:
graph query for fresh/ordered/tenant-scoped rows -> Chroma content-join only for detail:'full'.

- MemoryService.queryRecentTurns({agentIdentity, limit, before?, detail, projection}) + the
  _hydrateRecentTurnContent Chroma-join helper.
- Multi-tenant FAIL-CLOSED by behavior (AC4): the request-bound userId is mandatory; an absent/
  unresolvable userId yields EMPTY, never a deployment-wide read — deliberately does NOT inherit
  the single-tenant 'return all' fallthrough that session-scoped reads use.
- Privacy projection (AC5): public excludes the private thought field.
- toolService mapping + openapi /memories/recent path.
- Falsifier tests (4/4 green): freshness (AC6), cross-tenant isolation + no-scope fail-closed
  (AC7), detail:full Chroma-join + public-projection thought-exclusion.

AC8 (inline tweet-summary at add_memory, reusing modelProvider) follows in the next commit.
Graduated from a cross-family Ideation Sandbox; full AC1-8 + signal ledger on the issue.

* feat(ai): AC8 inline tweet-summary on the add_memory write path (#12671)

Completes the recency primitive's v1: a best-effort, fire-and-forget tweet-size summary at
add_memory, stored as a miniSummary property on the AGENT_MEMORY node (as fresh as the node).

- buildMiniSummary() reuses the existing modelProvider chat-model setting via buildChatModel
  (local gemma4 via openAiCompatible/ollama OR remote gemini-flash) — no new summaryProvider
  config (reads the resolved leaf at the use site, never aliases the SSOT). Cloud-ready by
  construction: the user's deployment-agnostic provider choice picks local vs remote.
- Fire-and-forget (NOT awaited) so the per-turn write stays fast — the node is already written
  for recency recall; the summary only enriches it asynchronously. Fully self-contained: a
  timeout/error/no-provider yields null, never touches the write path, and a null summary never
  hides the turn (query_recent_turns falls back to raw content).
- AC8 fail-soft test: with no reachable summarizer, add_memory still succeeds and the turn is
  still recallable (miniSummary null). 5/5 spec green.

* test(ai): make QueryRecentTurns spec CI-safe — in-memory fake collection (#12671)

CI unit has no ChromaDB server, so addMemory's Chroma write (its first step, before the
AGENT_MEMORY graph node the recency query reads) threw → no node written → the 3 tests that
expect to find the memory (AC6 freshness, detail:full, AC8 fail-soft) returned count 0 and
failed; the AC7a/AC7b empty-expectation tests passed trivially. Locally Chroma is up, so all 5
passed — a false-green.

Back StorageRouter.getMemoryCollection with an in-memory fake (add/get over a Map) so the spec
exercises the real addMemory→queryRecentTurns flow (incl. the graph write + the detail:full
content join) without a live Chroma. Restored in afterAll. 5/5 green locally.

* fix(ai): address PR review — private-projection gate, timestamp+id cursor, null-summary raw fallback (#12671)

gpt cross-family review (REQUEST_CHANGES) — 3 contract RAs, all addressed:
- RA1 (privacy authorization): projection:'private' (which exposes the private thought field) is now
  permitted ONLY for own-agent recall — a caller requesting a PEER's turns is forced to 'public', so
  thought never crosses the MCP boundary to a non-owner. Same fail-closed posture as the tenant scope.
- RA2 (stable cursor): the cursor is now the (timestamp,id) pair, matching ORDER BY (timestamp DESC,
  id DESC) — equal-timestamp turns neither dup nor skip across pages. nextCursor + the openapi 'before'
  param updated to the {timestamp,id} object (wire contract aligned).
- RA3 (raw fallback): the summary projection now returns summary = miniSummary OR a truncated raw
  fallback (summaryFallback flag) for not-yet-summarized turns, so the feed is never content-empty;
  best-effort + only for the un-summarized subset (graph-only once the backfill #12673 runs).

Spec: 8/8 green (added own-private-includes-thought, peer-private-excludes-thought, equal-timestamp
cursor disambiguation; updated AC6/AC8 to the summary/summaryFallback contract). McpServerToolLimits
4/4 (object cursor param parses).

* docs(ai): sync queryRecentTurns JSDoc to {timestamp,id} compound cursor (#12671)

gpt Cycle-2 review (PRR_kwDODSospM8AAAABCObTDQ): behavior fixed at b0c6e8166,
but the queryRecentTurns JSDoc still described the pre-fix string/ISO cursor.
Sync the contract-doc surface to the implemented shape:

- before: {String} ISO timestamp → {Object} {timestamp, id} compound cursor
  (+ document the .timestamp/.id sub-fields and why the pair disambiguates
  equal timestamps).
- @returns nextCursor: String|null → {timestamp, id}|null.

The openapi before schema was already the object form (RA2). No behavior change."
- 2026-06-07T03:40:49Z @tobiu closed this issue
- 2026-06-07T03:57:33Z @neo-claude-opus cross-referenced by #12674
- 2026-06-07T05:01:47Z @neo-claude-opus cross-referenced by PR #12675
- 2026-06-07T05:54:37Z @neo-gpt cross-referenced by PR #12676
- 2026-06-07T09:37:23Z @neo-claude-opus cross-referenced by PR #12680
- 2026-06-07T10:47:12Z @neo-claude-opus cross-referenced by #12682
- 2026-06-07T10:57:40Z @neo-gpt cross-referenced by PR #12683

