diff --git a/.github/workflows/prcheck.yml b/.github/workflows/prcheck.yml
index feb4521b9..adcafaa4d 100644
--- a/.github/workflows/prcheck.yml
+++ b/.github/workflows/prcheck.yml
@@ -121,3 +121,8 @@ jobs:
test/main/presenter/memoryNativeMigration.test.ts
test/main/presenter/agentRuntimePresenter/tapeService.test.ts
test/main/presenter/memoryRetrieval.eval.test.ts
+
+ - name: Validate memory performance bounds
+ env:
+ DEEPCHAT_REQUIRE_NATIVE_SQLITE: '1'
+ run: pnpm run test:main:memory-perf
diff --git a/docs/architecture/agent-memory-performance-and-scale/plan.md b/docs/architecture/agent-memory-performance-and-scale/plan.md
new file mode 100644
index 000000000..53c6c00a0
--- /dev/null
+++ b/docs/architecture/agent-memory-performance-and-scale/plan.md
@@ -0,0 +1,398 @@
+# Agent Memory Performance and Scale — Implementation Plan
+
+> This document describes the implemented architecture. Requirements and acceptance criteria are defined in
+> [spec.md](./spec.md), and implementation evidence is tracked in [tasks.md](./tasks.md).
+
+## Delivery Sequence
+
+The work was delivered in dependency order so that every optimization retained the existing correctness
+gates:
+
+1. Close architectural decisions, establish characterization tests, and add production-path counters.
+2. Bound keyword recall and separate logical vector readiness from resource lifetime.
+3. Add a rebuildable Tape ingestion projection.
+4. Batch candidate decisions and embedding persistence.
+5. Bound working-memory refresh and maintenance work.
+6. Bound startup resources, management pages, content, and audit growth.
+7. Run the full scale matrix and record as-built evidence.
+
+Authoritative revalidation, semantic revision checks, read epochs, destructive operation generations,
+provider deadlines, and vector leases remain active in every phase.
+
+## Recall Hot Path
+
+### FTS Policy and Derived-State Isolation
+
+A single FTS policy module owns the JavaScript row evaluator, SQL recallability predicate, policy version,
+and deterministic agent-scope encoder. Explicit mirror maintenance, filtered backfill, status transitions,
+and differential tests consume that policy rather than duplicating exclusions.
+
+Authoritative memory DML follows this transaction shape:
+
+1. Apply the authoritative table mutation.
+2. Advance the FTS mutation generation and mark the mirror dirty.
+3. Enter a nested savepoint and apply the minimal mirror mutation.
+4. On mirror success, update the clean generation.
+5. On mirror failure, roll back only the savepoint and commit the authoritative mutation with dirty metadata.
+
+Updates that do not affect content or recallability avoid FTS work. Bulk agent deletion removes that agent's
+mirror rows while authoritative rows still exist, then deletes the authoritative rows. Structural failures
+schedule a cooled lazy rebuild; transient busy/locked failures degrade only the current request.
+
+FTS v4 includes content and a deterministic scope token. MATCH constrains both columns, while BM25 assigns
+zero weight to scope. unicode61 is a permanent LIKE-only capability state because maintaining an unused
+mirror would create write amplification without a reader.
+
+### Deterministic Keyword Selection
+
+Every keyword candidate receives this priority tuple:
+
+```ts
+type RecallTermPriority = [kindRank: number, negativeLength: number, position: number]
+```
+
+`code/path=0`, `cjk=1`, and `ascii=2`. Length is measured in Unicode code points. The selector takes at most
+eight terms by priority and restores original occurrence order before building the query. If any useful term
+has at least three code points, shorter code tokens are dropped; an all-short query remains eligible for one
+LIKE fallback.
+
+### Bounded Search Strategy
+
+`MemoryRepositoryPort.search` returns internal rows plus the selected strategy:
+
+```ts
+interface MemoryKeywordSearchResult {
+ rows: AgentMemoryRow[]
+ strategy: 'fts-only' | 'like-fallback'
+}
+```
+
+The FTS statement has two bounded branches:
+
+- A MATCH/BM25 branch orders and limits its lexical candidate window first.
+- An importance branch reads a fixed window from the recall-importance partial index and probes the same
+ MATCH expression.
+
+The branches union and deduplicate at most twice the caller limit. The fallback is one bounded agent-scoped
+LIKE statement. Both paths feed the existing scoring and fusion logic, then one `listByIds` call replaces
+snapshots with current authoritative rows.
+
+### Vector Readiness and Mutation Barrier
+
+Logical readiness and native resource lifetime are independent:
+
+```ts
+interface VectorReadyCertificate {
+ agentId: string
+ providerId: string
+ modelId: string
+ dimension: number
+ configGeneration: number
+ storeGeneration: number
+}
+```
+
+The vector manager provides `withVectorMutation(agentId, operation)`. Coverage verification, bulk upsert,
+sidecar deletion, reconciliation, destructive reindex, and SQLite-ready transitions use this barrier.
+Ordinary vector queries use a generation lease but do not occupy the mutation barrier.
+
+The verifier acquires the barrier before reading SQLite embedded IDs, compares paged SQLite and DuckDB ID
+sets in both directions, removes sidecar extras, and rebuilds when an authoritative embedded row lacks a
+vector. It signs the certificate only if the read epoch, embedding identity, and store generation are stable
+before and after the scan.
+
+LRU close/reopen changes only the lease epoch. Configuration identity change stops new leases, drains old
+leases, clears the logical certificate, and rebuilds. A query with no certificate remains FTS-only and may
+schedule asynchronous verification.
+
+## Tape Ingestion Projection
+
+### Derived Schema
+
+The projection uses idempotent derived tables rather than a global migration:
+
+```sql
+CREATE TABLE deepchat_memory_ingestion_projection (
+ session_id TEXT NOT NULL,
+ message_id TEXT NOT NULL,
+ order_seq INTEGER NOT NULL,
+ entry_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ status TEXT NOT NULL,
+ had_tool_use INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (session_id, message_id)
+);
+
+CREATE INDEX idx_memory_ingestion_projection_range
+ ON deepchat_memory_ingestion_projection(session_id, order_seq, message_id);
+
+CREATE TABLE deepchat_memory_ingestion_projection_meta (
+ session_id TEXT PRIMARY KEY,
+ projection_version INTEGER NOT NULL,
+ max_entry_id INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+```
+
+Projection version starts at 1. Tape retains complete revisions and remains authoritative.
+
+### Reducer Semantics
+
+`DeepChatTapeEntriesTable.append` invokes the reducer at the lowest persistence layer so every caller shares
+the same behavior.
+
+- Message revisions use the existing rank and entry-ID tie-break.
+- Only effective sent/error messages produce projection rows.
+- Final tool-call facts may arrive before their message. They advance metadata; the final message derives
+ `had_tool_use` from shared effective semantics.
+- Only explicit `success` and `error` tool statuses rank as terminal. Missing, loading, pending, and unknown
+ statuses cannot become final tool-use evidence.
+- Tool results and pending tool interactions advance metadata without setting final tool use.
+- Retraction and mutations whose equivalence cannot be proven invalidate session metadata.
+- Session delete, clear, fork, truncate, and rewind remove or invalidate the corresponding projection state.
+
+Tape append and reducer execution share one transaction. Before advancing metadata, the reducer compares the
+current session metadata with that session's previous maximum entry ID. Reducer failure invalidates the
+session metadata while preserving the authoritative append; invalidation failure rolls back the append.
+
+### One-Statement Current Range
+
+`readCurrentMemoryIngestionRange` uses one CTE to read the Tape maximum, projection metadata, and requested
+range. Its tagged result is one of:
+
+```ts
+type MemoryIngestionRangeResult =
+ | { source: 'projection'; current: true; rows: ProjectionRow[] }
+ | { source: 'rebuilt'; current: true; rows: ProjectionRow[] }
+ | { source: 'fallback'; current: false; rows: EffectiveTapeRow[] }
+```
+
+A stale projection reads Tape once, invokes the single `buildEffectiveTapeView` implementation, and replaces
+projection and metadata transactionally. The already-materialized view supplies the current extraction range.
+If replacement fails, extraction consumes the authoritative view but all cursor commit boundaries are null.
+
+Read and replacement failures enter a bounded per-session cooldown for 30 seconds. During that interval,
+subsequent extraction attempts stop before another full Tape materialization or provider call. The passive
+retry state holds at most 256 sessions, requires no timers, clears after a successful current read or replace,
+and is removed when the session is initialized, cleared, or destroyed.
+
+Chunk construction records the final fragment for each `orderSeq`. No chunk may commit a sequence until that
+fragment succeeds. Projection and full-view ordering use SQLite-compatible UTF-8/BINARY comparison.
+
+## Candidate and Decision Batching
+
+### Normalization and Retrieval
+
+Candidate parsing is followed immediately by Unicode-safe content validation and stable deduplication using
+`(kind, normalizeForProvenanceV2(content))`. No recall or provider work occurs for discarded candidates.
+
+`retrieveForDecisions` performs:
+
+1. One bounded lexical query per candidate.
+2. One batch query embedding for unresolved candidates.
+3. All vector queries under one store lease.
+4. One authoritative `listByIds` for every candidate and vector ID.
+5. A maximum of three live neighbors per candidate.
+
+Pinned decision-head state stores IDs only. Pinned rows join the same authoritative materialization and must
+pass the live predicate before entering a prompt.
+
+The query-vector snapshot is identity-bound:
+
+```ts
+interface MemoryDecisionQueryVectorSnapshot {
+ vector: number[]
+ providerId: string
+ modelId: string
+ dimension: number
+ configGeneration: number
+}
+```
+
+### Partitioning and Parsing
+
+The pure partitioner caps each batch at four candidates, three neighbor excerpts per candidate, and 12,000
+estimated input tokens. At most two batches are sent. It drops the lowest-priority neighbor excerpt before
+using the initial `ADD` fallback for an otherwise unplaceable candidate.
+
+The response is a JSON array keyed by original candidate index:
+
+```ts
+interface BatchMemoryDecisionResult {
+ candidateIndex: number
+ decision: 'ADD' | 'UPDATE' | 'SUPERSEDE' | 'NOOP' | 'CHALLENGE'
+ targetIndex: number | null
+ mergedContent: string | null
+}
+```
+
+A shared JSON-fence parser extracts the array. The first duplicate index wins. Initial missing, invalid, or
+provider-failed items use candidate-local `ADD` fallback. Retry uses a stricter policy: only an explicit valid
+`ADD` may create a row; every other missing, invalid, stale, or failed result becomes `concurrent-update`.
+
+### Fencing and Revision Retry
+
+Each provider partition checks its operation fence before admission and after completion. Final application
+re-resolves provenance, permanent-forget state, current owner revision, liveness, and status at the
+transaction boundary. Candidate application stays serial in original order.
+
+Only the first four revision conflicts are retried. Retry refreshes provenance and neighbors, reuses the
+candidate embedding only if its identity snapshot remains current, and sends one additional decision batch.
+The upper bounds remain five provider calls in steady state and six under contention.
+
+## Bulk Embedding Persistence
+
+One supervisor promise per agent owns `{ dirty, running }` state and continues in 50-row chunks until the
+backlog is empty. Concurrent triggers share the same promise, including merged follow-up cycles.
+
+Each chunk follows this fixed transaction flow:
+
+1. Snapshot IDs, expected revisions, content, and embedding identity.
+2. Request one provider embedding batch.
+3. Perform one authoritative `listByIds` revalidation.
+4. Enter `withVectorMutation` and one vector lease.
+5. Execute one DuckDB bulk delete and one parameterized bulk insert in one transaction.
+6. Execute one revision-aware SQLite success update and, if needed, one error update.
+7. Remove vectors for SQLite CAS misses before releasing the mutation scope.
+
+Provider-wide failure leaves retryable rows pending. Malformed individual vectors enter the error batch.
+Configuration, clear, forget, and content-edit races cannot mark old vectors ready.
+
+## Working Memory and Maintenance
+
+### Working Dirty Coalescing
+
+The working-memory service owns dirty-agent state, trailing timers, and per-agent refresh singleflight.
+Mutation finalization calls `markDirty(agentId)`; the 100 ms timer refreshes once and skips an unchanged blob.
+A read encountering dirty state cancels the timer and synchronously flushes before final injection assembly.
+Dispose cancels timers; working memory remains rebuildable.
+
+### Archive and Cognitive Queries
+
+`archiveEligibleBatch` converts the decay threshold into an age inequality over
+`COALESCE(last_accessed, created_at)`, uses its expression index, and updates at most 256 IDs through a CTE and
+`UPDATE ... RETURNING`. The health count uses the same algebra.
+
+Reflection and persona call `getCognitiveMaintenanceInput`, which returns eligible count, importance after
+watermark, maximum creation time, and bounded top rows. SQL performs aggregate, ordering, and limiting.
+
+### Provider Budget, Fairness, and Concurrency
+
+`MaintenanceBudget` owns total calls, input tokens, and non-transferable step quotas:
+
+| Step | Call quota |
+| --- | ---: |
+| Challenge | 4 |
+| Merge | 2 |
+| Reflection | 1 |
+| Persona | 1 |
+
+The total token budget is 24,000 estimated input tokens. Reservation occurs before gateway admission and is
+not refunded on failure.
+
+Heavy steps run in the listed order under a process-wide fair semaphore of two. An agent's complete heavy
+pass is singleflight. Cheap maintenance runs outside the semaphore.
+
+Conflict challenger selection uses its persisted fairness index. Every selected challenger is stamped after
+an attempt. Integrity repair selects bounded anomaly classes and uses set-based transitions, while semantic
+sibling-group resolution uses a constant number of statements regardless of sibling count.
+
+## Resources and Data Capacity
+
+### Startup Warmup and Store LRU
+
+Startup first asks the agent repository for managed, enabled, memory-enabled candidates. Memory storage then
+uses the recent-activity expression index to rank only those candidates and returns at most eight.
+
+Embedding connection warmup uses a process-lifetime success set, in-flight map, and five-minute failure map
+keyed by `provider:model`.
+
+The vector manager maintains a soft cap of eight open stores, a 15-minute idle TTL, and an unref'ed 60-second
+sweep. Candidate selection occurs outside locks; the agent lock rechecks lease count, open-in-flight state,
+and identity. All-busy pressure may exceed the cap temporarily, and lease release immediately retries
+convergence.
+
+### Management Pagination
+
+The additive route is:
+
+```ts
+interface MemoryPageInput {
+ agentId: string
+ cursor?: string
+ limit?: number
+}
+
+interface MemoryPageOutput {
+ items: MemoryItemDto[]
+ nextCursor: string | null
+}
+```
+
+The shared contract owns canonical base64url encode/decode for `{v:1,createdAt,id}`. The repository applies
+the management visibility predicate, uses `(created_at DESC, id DESC)` keyset ordering, and reads one extra
+row to determine whether to emit a cursor. Direct repository calls cap their limit as well. After cursor
+validation, the route returns an empty page for a non-DeepChat Agent without calling the presenter.
+
+The renderer tracks loaded page count and request generation. Refresh replays the same page depth into a
+temporary result and atomically replaces visible rows. Search retains the server route and its own request
+generation. Active-only server search hides the unrelated page action, while local archived search keeps Load
+more available to extend its loaded window. Update events are coalesced with a 100 ms trailing timer.
+
+### Content and Audit Retention
+
+Shared Unicode scalar helpers define code-point length and safe truncation. Route schemas, domain services,
+memory tools, extraction parsing, merged content, excerpts, and chunking use those helpers.
+
+Operational audit cleanup uses an exact partial index and allowlist. A cheap pass deletes at most 500 rows
+beyond the newest 10,000 per agent. User-semantic, persona, unknown, malformed, and legacy causal rows never
+enter the cleanup candidate set.
+
+## Performance Validation
+
+The independent `*.perf.ts` configuration runs single-worker, non-parallel scale fixtures with a 120-second
+timeout. The production observer is optional and defaults to undefined; enabled tests receive real counters
+from repository, projection, provider gateway, DuckDB store, and vector manager execution points.
+
+The matrix separates scenarios rather than forming a Cartesian product:
+
+- Memory recall at 1,000, 10,000, and 50,000 rows.
+- Tape current-range and full-view baselines at 10,000 and 100,000 entries.
+- One hundred agents sharing one embedding model.
+- A 101-row drain proving 50/50/1 chunks.
+- Eight decision candidates with three neighbors.
+- Fifty-thousand-row maintenance query plans and a 1,000-sibling transition.
+
+Median and nearest-rank p95 are reported. Recall uses 11 paired samples and alternates FTS/LIKE execution order
+to reduce cache-order bias. Its hard gate asserts `fts-only`, preserves statement and materialization caps, and
+requires the 10,000-to-50,000-row FTS median growth factor to be no more than 65% of the legacy LIKE growth
+factor. The 50,000-row point ratio and absolute latency are report-only. The Tape relative ratio remains a hard
+gate. The native CI job rebuilds the Node ABI dependency before running the suite.
+
+## Compatibility and Rollback
+
+- FTS and projection may be dropped and rebuilt; their failure paths preserve authoritative data.
+- Projection metadata is independent of the global schema migration sequence.
+- Bulk DuckDB writes preserve the existing table format.
+- Batch decision behavior can be disabled without removing semantic revision checks.
+- LRU closes only reopenable stores and does not delete sidecar files.
+- `memory.page` is additive; the deprecated list remains temporarily compatible.
+- Audit retention is limited to proven operational rows and is protected by exact allowlist tests.
+- No runtime dependency, GitHub issue, or DuckDB format change is introduced.
+
+## Validation Commands
+
+```bash
+mise exec -- pnpm run typecheck
+mise exec -- pnpm run test:main -- --run
+mise exec -- pnpm run test:renderer -- --run
+mise exec -- pnpm run test:main:memory-perf
+mise exec -- pnpm run format
+mise exec -- pnpm run i18n
+mise exec -- pnpm run lint
+mise exec -- pnpm run format:check
+```
+
+Native SQLite, FTS, migration, and scale validation run in the `memory-native-validation` CI job. Local
+development does not switch the shared dependency tree between Electron and Node ABIs.
diff --git a/docs/architecture/agent-memory-performance-and-scale/spec.md b/docs/architecture/agent-memory-performance-and-scale/spec.md
new file mode 100644
index 000000000..5bbc13738
--- /dev/null
+++ b/docs/architecture/agent-memory-performance-and-scale/spec.md
@@ -0,0 +1,365 @@
+# Agent Memory Performance and Scale — Specification
+
+> Status: **implemented**
+>
+> Classification: **architecture**
+>
+> Local performance gate: **passed**
+>
+> Post-commit native CI gate: **pending**
+
+This document defines the requirements and acceptance criteria for predictable Agent Memory work at
+large per-agent and multi-agent scales. The implemented architecture is described in
+[plan.md](./plan.md), and implementation evidence is tracked in [tasks.md](./tasks.md).
+
+## Purpose
+
+Agent Memory stores authoritative semantic state in SQLite, derives its extraction input from the
+conversation Tape, and uses per-agent DuckDB sidecars for vector search. The implementation already
+protects semantic operations with authoritative revalidation, revision compare-and-swap, read epochs,
+destructive operation generations, vector-store leases, provider deadlines, and fail-closed privacy
+gates. Those correctness mechanisms are baseline invariants, not optional performance costs.
+
+The remaining scale risks were algorithmic amplification and unbounded resource growth in the Electron
+main process: non-indexed keyword scans, repeated stale-vector scans, full-session effective Tape views,
+per-candidate provider calls, per-row embedding persistence, unbounded maintenance work, and missing
+store, list, content, and audit caps.
+
+The target operating envelope is predictable work with 10,000–50,000 memories per agent, 100,000 Tape
+entries per session, and 100 configured agents. The goal is not constant latency on every machine; it is
+bounded local work, provider work, materialization, and native resource use.
+
+## Goals
+
+- Keep pre-first-token SQLite work index-backed and bounded by candidate limits.
+- Make extraction cost proportional to the unconsumed Tape range instead of complete history.
+- Bound an eight-candidate extraction to five steady-state provider calls and six under contention.
+- Persist embedding batches with batch-level DuckDB and SQLite statements rather than per-row loops.
+- Give working memory and maintenance explicit debounce, row, call, token, and concurrency budgets.
+- Bound startup warmup, open DuckDB stores, management pages, submitted content, and operational audit
+ growth.
+- Maintain a deterministic scale suite that enforces complexity, cross-size recall growth, and stable relative
+ work reductions without using shared-runner absolute latency as a hard gate.
+
+## Non-Goals
+
+- Changing retrieval scoring, reciprocal-rank fusion, default weights, top-K, or similarity thresholds.
+- Introducing an external search or vector service, or adding a runtime dependency.
+- Combining per-agent DuckDB files or changing the sidecar file format.
+- Weakening authoritative final revalidation, semantic revision checks, destructive cancellation,
+ vector leases, provider deadlines, or fail-closed privacy behavior.
+- Splitting lifecycle state from embedding state.
+- Replacing the Memory Settings information architecture; pagination only bounds browsing while
+ `memory.search` remains the full-corpus search path.
+- Implementing memory export. A future export path needs independent pagination that includes historical,
+ superseded, conflicted, persona, and archived rows; it must not reuse the management-page contract.
+- Optimizing real provider network latency. This architecture limits request count, concurrency, queueing,
+ and local work.
+
+## Architectural Invariants
+
+1. SQLite `agent_memory` remains authoritative. FTS, Tape projection, and DuckDB vectors are rebuildable
+ derived state.
+2. Tape remains the evidence source of truth. Projection failure may reduce performance but cannot corrupt
+ or replace Tape.
+3. Every provider continuation rechecks its operation fence after admission and completion. Destructive
+ clear, agent deletion, configuration replacement, and disposal prevent older work from committing.
+4. Semantic writes use revision-aware conditional transitions. A stale decision cannot overwrite newer
+ content or silently fall through to `ADD`.
+5. Recall performs one final agent-scoped authoritative materialization before returning rows.
+6. Vector operations run under manager-owned generation leases. Derived-data generation and resource lease
+ epoch remain separate concepts.
+7. Provider deadlines include rate-limit admission, and every model or embedding request uses the shared
+ provider gateway.
+8. Performance optimizations fail open only where correctness permits it: FTS may fall back to bounded LIKE,
+ projection may fall back to the authoritative Tape view without advancing a cursor, and vector failure
+ may fall back to lexical recall.
+9. Privacy and stale-write checks remain fail closed.
+10. No scale optimization may change the DuckDB format or consume a global SQLite schema migration version
+ for rebuildable derived state.
+
+## Requirements
+
+### Conditional Keyword Recall and FTS v4
+
+- FTS metadata uses schema version 4 and records a separate policy version. A predicate, scope encoding, or
+ tokenizer change invalidates and rebuilds the derived index.
+- FTS contains only recallable rows: `superseded_by IS NULL`, not archived or conflicted, and not persona or
+ working memory.
+- Authoritative DML and the FTS dirty generation share one outer transaction. Explicit mirror maintenance
+ runs inside a nested savepoint; mirror failure rolls back only the savepoint, commits the authoritative
+ mutation, and leaves the derived generation dirty.
+- Filtered rebuild indexes only recallable rows. It never indexes the complete table and relies on query-time
+ filtering afterward.
+- Keyword selection is deterministic: code/path terms precede CJK terms, which precede ASCII terms; within a
+ class, longer Unicode-code-point terms precede shorter terms, with first occurrence as the final tie-break.
+ At most eight terms are retained, then restored to original query order.
+- Corpus term-statistics SQL and unbounded keyword-stat caches are absent. BM25 supplies frequency weighting.
+- When trigram FTS is available and every selected term is at least three Unicode code points, recall executes
+ only a bounded BM25 branch and a bounded importance/created-at supplement using the same MATCH expression.
+- When FTS is unavailable, unicode61 is active, or all useful terms are short, recall executes at most one
+ agent-scoped bounded LIKE query.
+- A recall uses exactly one keyword strategy: `fts-only` or `like-fallback`; it never combines FTS and LIKE.
+- MATCH includes a deterministic agent-scope token and content. Scope has zero BM25 weight. Scope policy v2
+ uses a fixed 24-bit token to reduce trigram posting intersections, while final `agent_id` revalidation
+ prevents cross-agent results even if tokens collide.
+- Runtime structural failure marks the mirror dirty and enables a cooled lazy rebuild. Transient busy/locked
+ errors affect only the current request. unicode61 stays LIKE-only and does not maintain an unused mirror.
+- Incremental import excludes derived FTS metadata and invalidates the target metadata after importing
+ authoritative rows. Reopen performs a filtered rebuild.
+- `clearByAgent` removes only that agent's mirror rows and cannot disable FTS for other agents.
+- Both lexical and importance branches cap their candidate windows before materialization. A common term
+ cannot materialize every match for an agent.
+
+### Vector Readiness Certificate
+
+- Ordinary recall never calls `hasStaleEmbeddings`.
+- A ready certificate binds agent, provider/model, dimension, configuration generation, and logical
+ derived-data generation. Closing and reopening an LRU resource changes only the lease epoch and does not
+ invalidate the certificate.
+- Startup warm, destructive rebuild, reset, and embedding identity change perform complete verification.
+- Verification runs under a per-agent vector-mutation barrier. It pages through current embedded SQLite IDs,
+ compares them bidirectionally with DuckDB IDs, removes sidecar extras, and triggers destructive reindex if
+ SQLite IDs are missing vectors.
+- A certificate is installed only when read epoch, embedding identity, and logical store generation remain
+ stable across the complete scan.
+- Embedding drain, reconciliation, sidecar mutation, SQLite-ready transition, and certificate verification
+ share the mutation barrier. Verification cannot delete a vector written by an in-flight drain before its
+ SQLite transition completes.
+- A normal embedding drain does not create a missing certificate. A valid certificate remains valid after a
+ successful revision-aware dual write.
+- Authoritative vector materialization additionally requires an embedded row with the current model
+ fingerprint and dimension. Pending rows remain lexically recallable.
+
+### Tape Ingestion Projection
+
+- `deepchat_memory_ingestion_projection` and its metadata are rebuildable derived tables with projection
+ version 1 and idempotent creation.
+- Each effective message stores session ID, message ID, order sequence, current lineage entry ID, role,
+ content, final status, and effective tool-use state.
+- The reducer is injected into the lowest-level Tape table. Every append, including anchors, events, tool
+ facts, and unrelated rows, advances or invalidates session metadata.
+- Tape append and projection update share one SQLite transaction. Reducer failure rolls back its savepoint,
+ invalidates the session metadata, and still permits the authoritative Tape append. If invalidation also
+ fails, the Tape append rolls back.
+- Metadata compares against the previous maximum entry ID for the same session, never a global
+ `entry_id - 1` assumption.
+- Message revisions use the shared effective-view rank and entry-ID tie-break. Only sent/error messages are
+ projected.
+- A final tool fact before the sent/error message is a valid runtime order. Only explicit `success` and
+ `error` tool statuses are terminal; missing, pending, loading, or unknown statuses cannot set final tool
+ use. The final message reconstructs equivalent `had_tool_use`. Retraction and mutations whose equivalence
+ cannot be proven mark the projection stale.
+- Session delete, clear, fork, truncate, and rewind clean or invalidate projection state transactionally.
+- Stale metadata triggers one full authoritative Tape read and one `buildEffectiveTapeView` rebuild, followed
+ by transactional projection replacement. The already-built view is reused for the current extraction.
+- Rebuild failure uses the authoritative full view for that extraction but sets every cursor commit boundary
+ to null.
+- A read or rebuild failure places the session in a 30-second passive retry cooldown. During cooldown,
+ extraction skips before another full Tape read or provider call. The failure cache holds at most 256
+ sessions, clears on success and session lifecycle reset, and never schedules an independent timer.
+- Normal extraction reads the requested `(session_id, order_seq)` range in one SQLite statement and does not
+ call `getBySession()`.
+- Range ordering matches SQLite BINARY semantics: `orderSeq ASC, messageId ASC`. Equal-order fragments form
+ one cursor commit group, and no chunk commits that sequence before its final fragment succeeds.
+- Reading the last 20 messages of a 100,000-entry Tape materializes only the bounded range.
+
+### Candidate and Decision Batching
+
+- Parsed candidates are normalized and stably deduplicated by `(kind, normalized content)` before recall or
+ provider work. First occurrence wins.
+- Candidate content is limited to 2,000 Unicode code points. Oversized automatic candidates are rejected with
+ a content-free aggregate audit reason and are never silently truncated.
+- Decision retrieval performs one bounded keyword query per candidate, one batch query embedding for all
+ unresolved candidates, all vector queries inside one lease, and one authoritative `listByIds` for the
+ union. Each candidate retains at most three neighbors.
+- A decision partition contains at most four candidates, three 400-code-point neighbor excerpts per
+ candidate, and 12,000 estimated input tokens. At most two partitions are admitted.
+- Budget reduction drops the lowest-priority neighbor excerpt first and never truncates the candidate.
+ Candidates that still cannot fit use the initial safe `ADD` fallback without adding a third decision call.
+- Batch results align by candidate index. The first duplicate index wins. A missing or invalid initial item
+ falls back only that candidate to `ADD`; a batch provider failure falls back only that batch.
+- Model-generated merged content above 2,000 Unicode code points is invalid.
+- Candidate application is serial and preserves original order.
+- Every provider admission and response checks the operation fence. A stale fence stops remaining partitions
+ and prevents later external disclosure or writes.
+- Final application re-resolves provenance, permanent-forget audit state, owner revision, liveness, pinned
+ neighbors, and status inside the transaction boundary.
+- The query-vector snapshot binds provider/model, dimension, and configuration generation. Retry never uses a
+ vector produced by a different embedding identity.
+- Only the first four revision conflicts retry. Retry refreshes provenance, lexical/vector neighbors, and
+ authoritative rows while reusing only an identity-compatible candidate embedding, and performs at most one
+ additional decision provider call.
+- During retry, only an explicit valid `ADD` may create a row. Missing, invalid, stale, or failed retry results
+ return `concurrent-update`.
+- Provider calls are bounded to five in steady state and six under contention.
+
+### Bulk Embedding Persistence
+
+- A drain processes at most 50 pending rows per batch and continues through the same per-agent supervisor
+ until the backlog is empty.
+- Pending snapshots include ID and expected revision.
+- Each batch performs one provider embedding call and one authoritative `listByIds` revalidation.
+- One vector-mutation scope and lease contain one DuckDB transaction with one bulk delete and one
+ parameterized multi-values insert.
+- SQLite performs at most one revision-aware success update and one revision-aware error update per batch.
+ Ready transitions require agent, ID, expected revision, pending state, and liveness.
+- CAS misses cannot mark an old vector ready; their sidecar orphans are deleted in the same mutation scope or
+ left to bounded reconciliation.
+- A provider-wide failure leaves retryable rows pending and does not emit per-row pending-to-pending writes.
+ Only malformed individual vectors enter the error batch.
+- Concurrent callers share one supervisor promise covering merged follow-up cycles. The implementation does
+ not create an unbounded promise chain or fire-and-forget a hidden final cycle.
+
+### Working Memory and Bounded Maintenance
+
+- A domain mutation marks working memory dirty and starts a 100 ms trailing debounce. Twenty synchronous
+ mutations for one agent trigger at most one asynchronous refresh.
+- A read or injection encountering dirty working memory cancels the timer and synchronously flushes before
+ persona/working assembly, while preserving the initial epoch gate and final no-await gate.
+- Archive work uses a set-based `UPDATE ... RETURNING` and processes at most 256 rows per pass.
+- Eligibility uses current-time age algebra over `COALESCE(last_accessed, created_at)` and importance; it does
+ not depend on `pow()`, stale materialized decay, or a lifetime `access_count === 0` veto.
+- Reflection and persona use one aggregate plus indexed top-N repository query rather than full-agent
+ materialization and JavaScript sorting.
+- A maintenance budget permits eight calls and 24,000 estimated input tokens per pass. Step quotas are
+ challenge 4, merge 2, reflection 1, and persona 1; quotas are not borrowed.
+- Reserving a call consumes it even if gateway admission or the provider fails.
+- Heavy steps run challenge, merge, reflection, then persona. Heavy work uses a process-wide fair semaphore
+ of two agents and per-agent whole-pass singleflight. Cheap maintenance does not occupy the semaphore.
+- Challenge selection uses bounded indexed fairness ordering by
+ `COALESCE(last_consolidated_at, 0), created_at, id`. Every selected challenger is stamped after an attempt,
+ including normalization, size, budget, gateway, and provider failures.
+- Conflict integrity repair selects only actual anomalies, scans at most 256 rows, and uses a constant number
+ of set-based statements. Sibling-group size does not increase statement count.
+- Maintenance prompt construction performs incremental token preflight before concatenating strings.
+- No-change passes do not perform whole-agent decay refresh, consolidation stamps, or other unbounded writes.
+
+### Startup, Native Stores, and Resource Bounds
+
+- Startup obtains enabled, managed, memory-enabled agents first, then performs candidate-scoped indexed latest
+ activity lookup and collects at most eight agents.
+- Embedding connection warmup is keyed by `provider:model`, with a process-lifetime success set, in-flight
+ singleflight map, and five-minute failure cooldown.
+- Per-agent vector stores have a soft cap of eight and an idle TTL of 15 minutes. An unref'ed 60-second sweep
+ enforces both the cap and TTL.
+- Eviction selects a candidate outside locks and rechecks lease count, open-in-flight state, and identity
+ inside the agent lock. It never closes normal admission merely to test evictability.
+- Busy stores may temporarily exceed the soft cap; lease release triggers immediate convergence.
+- Expected lease competition waits or retries and does not clear the logical certificate or mark embeddings
+ as failed.
+- With 100 agents sharing one embedding model, startup performs at most one provider warmup and opens at most
+ eight sidecars.
+
+### Pagination, Content, and Audit Bounds
+
+- `memory.page` is an additive typed route with default and maximum limit 100.
+- Management visibility matches the legacy list: superseded, conflicted, persona, and working rows are
+ excluded; archived rows remain visible.
+- Ordering is `created_at DESC, id DESC`. The repository reads `limit + 1` and emits a cursor only when another
+ page exists.
+- The opaque cursor is canonical base64url JSON `{v:1,createdAt,id}`. Invalid encoding, version, shape, unsafe
+ timestamp, or empty ID produces route validation failure rather than page-one fallback.
+- After cursor validation, a non-DeepChat Agent receives an empty page and never reaches the memory
+ presenter.
+- `memory.list` remains wire-compatible for one compatibility window, is deprecated, and has an architecture
+ guard against new production callers.
+- Renderer pagination supports replace, append, load-more, ID deduplication, and request-generation rejection
+ of stale responses. Agent changes reset pages. Update bursts are coalesced, and refresh atomically reloads
+ the previously loaded page depth.
+- Dirty editors preserve their local draft even when the refreshed window omits the row. Clean editors close
+ only after the complete refreshed loaded window omits the row.
+- Non-empty active-memory search continues to use server-side `memory.search` and hides the unrelated
+ management-page Load more action. Archived search remains local to loaded management pages, so Load more
+ stays available when archived rows are included.
+- User add, manual edit, and memory-tool content is limited to 12,000 Unicode code points. Automatic extraction
+ and model-generated merged memory are limited to 2,000. Validation exists at route/tool and domain layers.
+- Existing oversized rows are not migrated or truncated and remain readable and recallable.
+- Operational audit cleanup applies only to `memory/maintenance_llm`, `memory/reflect`, `memory/repair`,
+ `memory/conflict_repair`, and `memory/extract`.
+- Per agent, cleanup retains the newest 10,000 allowlisted events and deletes at most 500 older events per
+ cheap pass.
+- `memory/add`, `memory/manual_edit`, `memory/archive`, `memory/forget`, `memory/restore`,
+ `memory/challenge_resolved`, every `persona/*` event, and unknown, malformed, or legacy causal rows are
+ retained permanently.
+- Cleanup classification never uses a blanket `memory_ref_id IS NULL` rule and cannot change
+ `hasForgetEvent` results.
+
+### Performance Evidence and CI Gate
+
+- `test:main:memory-perf` uses an independent Vitest configuration and does not run in ordinary `test:main`.
+- The deterministic matrix covers memory sizes 1,000, 10,000, and 50,000; Tape sizes 10,000 and 100,000; 100
+ agents sharing one model; a 101-row embedding drain proving 50/50/1 batching; and eight candidates with
+ three neighbors each.
+- Production-path observers record SQLite statements, repository calls, materialized rows, provider calls,
+ DuckDB statements, open stores, active leases, and queue/cache high-water marks. Production defaults to no
+ observer and has no global observer state.
+- CI hard-asserts statement, materialization, provider, store, lease, queue, and cache bounds.
+- The recall scale gate uses 11 paired samples with alternating execution order. It requires the FTS median
+ growth factor from 10,000 to 50,000 rows to be no more than 65% of the legacy LIKE growth factor and asserts
+ that every indexed sample remains on the `fts-only` strategy.
+- The 50,000-row FTS/LIKE point ratio is reported but is not a shared-runner gate. The 100,000-entry Tape tail
+ median remains required to be no more than 20% of the full-view baseline.
+- Absolute targets of 50 ms p95 for 50,000-row recall and 25 ms p95 for a 100,000-entry Tape tail are reported,
+ not enforced on shared runners.
+- The native CI job rebuilds the Node ABI binding, sets `DEEPCHAT_REQUIRE_NATIVE_SQLITE=1`, and treats a missing
+ native binding or skipped performance suite as failure.
+
+## Compatibility and Failure Semantics
+
+- No third-party dependency or DuckDB format change is introduced.
+- FTS and projection can be dropped and rebuilt. Their failure falls back to bounded LIKE or the authoritative
+ Tape path without corrupting authoritative state.
+- Bulk vector persistence does not change the sidecar schema.
+- `memory.page` is additive, and `memory.list` remains temporarily wire-compatible.
+- Operational audit deletion is intentionally irreversible but applies only to the exact allowlist and is
+ protected by retention and forget-event parity tests.
+- Existing recall scoring and quality semantics remain unchanged.
+- Local package commands use `mise exec -- pnpm`; CI continues to use its configured pnpm toolchain directly.
+- No GitHub issue is created or linked.
+- Open design questions: none.
+
+## As-Built Performance Evidence
+
+The reference run used Apple M4 Pro/arm64, macOS 26.5.2, Node 24.14.1, and
+`DEEPCHAT_REQUIRE_NATIVE_SQLITE=1`. Recall wall-clock values and point ratios are reference data. Complexity
+bounds, the recall cross-size growth advantage, and the Tape relative ratio are the portable gates.
+
+| Scenario | Size | New median/p95 | Legacy median/p95 | New/legacy |
+| --- | ---: | ---: | ---: | ---: |
+| Safe-trigram FTS / bounded LIKE | 1k | 0.699 / 0.731 ms | 0.165 / 0.181 ms | 422.8% |
+| Safe-trigram FTS / bounded LIKE | 10k | 1.075 / 1.123 ms | 1.313 / 1.489 ms | 81.9% |
+| Safe-trigram FTS / bounded LIKE | 50k | 3.546 / 3.645 ms | 8.428 / 8.567 ms | **42.1%** |
+| Tape current-range / full effective view | 10k | 0.058 / 0.060 ms | 12.888 / 14.853 ms | 0.45% |
+| Tape current-range / full effective view | 100k | 0.052 / 0.068 ms | 192.222 / 195.931 ms | **0.03%** |
+
+An Ubuntu 22.04 shared runner observed a 95.0% recall point ratio at 50,000 rows while the FTS and LIKE
+10,000-to-50,000-row growth factors were 2.67 and 5.45 respectively. The architecture therefore gates the
+portable scaling advantage rather than an architecture-sensitive point ratio.
+
+The 1,000-row FTS fixed cost and the 50,000-row FTS/LIKE point ratio are intentionally not shared-runner gates.
+The portable recall gate compares the 10,000-to-50,000-row growth factors, while the Tape gate compares the
+100,000-entry range and full-view medians. The reference p95 values are below both report-only targets.
+
+Production-path complexity evidence also confirms:
+
+- Safe-trigram recall uses one SQLite query and materializes at most 40 results.
+- A current Tape range uses one query and materializes 20 rows.
+- A 101-row embedding backlog drains in 50/50/1 provider and persistence batches.
+- Eight candidates with three neighbors use triage, extraction, and two decision calls when vector embedding
+ is not required.
+- One hundred shared-model agents prewarm at most eight stores and one provider connection.
+- Common-term recall remains agent-scoped across 100 agents.
+- Maintenance uses the intended indexes at 50,000 rows, and a 1,000-sibling conflict transition uses one
+ set-based statement.
+
+Targeted main/native tests, Memory renderer tests, the reference performance suite, and type checking pass.
+The complete main and renderer suites still contain unrelated pre-existing failures; no correctness,
+complexity, or resource assertion was weakened or skipped to hide them. Post-change native performance
+validation and the post-commit native CI gate remain pending.
+
+## Acceptance
+
+The architecture is accepted when all requirements above remain represented in production code and
+regression tests, the growth, relative Tape, and complexity performance gates pass, authoritative data remains
+safe under derived-state failure, and the post-commit native CI gate completes successfully.
diff --git a/docs/architecture/agent-memory-performance-and-scale/tasks.md b/docs/architecture/agent-memory-performance-and-scale/tasks.md
new file mode 100644
index 000000000..5b9df65e2
--- /dev/null
+++ b/docs/architecture/agent-memory-performance-and-scale/tasks.md
@@ -0,0 +1,193 @@
+# Agent Memory Performance and Scale — Tasks
+
+> The requirements are defined in [spec.md](./spec.md), and the implemented design is described in
+> [plan.md](./plan.md). Phases are ordered by architectural dependency and contain no external document gate.
+
+## Phase: Characterization and Performance Baseline
+
+- [x] Lock the keyword strategy, retry fallback and cap, Tape table-level projection, certificate/resource
+ generations, bounded maintenance writes, export boundary, and exact audit allowlist.
+- [x] Verify lazy provenance re-key collision recovery by rereading the v2 owner and comparing kind plus
+ normalized content.
+- [x] Cover equivalent and non-equivalent concurrent provenance owners.
+- [x] Characterize recall IDs/order/source, effective Tape ranges, decision outcomes, embedding status,
+ working blobs, archive behavior, maintenance, and startup warmup.
+- [x] Add an independent `*.perf.ts` Vitest configuration and `test:main:memory-perf` command.
+- [x] Add deterministic 1k/10k/50k memory, 10k/100k Tape, and 100-agent fixtures.
+- [x] Add optional, no-op-by-default production observers for SQLite, repository, materialized rows, provider,
+ DuckDB, store, lease, queue, and cache metrics.
+- [x] Record the safe-trigram/LIKE, full Tape view, 101-row drain, and eight-candidate baselines.
+- [x] Keep benchmark fixtures independent of real providers and user data.
+- [x] Replace test-side counter reporting with counters emitted at production execution points.
+
+Gate: the baseline reproduces per-agent scans, full Tape materialization, call amplification, and resource
+growth deterministically.
+
+## Phase: Recall Hot Path and Vector Readiness
+
+- [x] Upgrade FTS metadata to v4 with a policy version, deterministic agent scope, explicit savepoint-isolated
+ mirror maintenance, and filtered rebuild.
+- [x] Ensure FTS failure never rolls back authoritative memory DML and degrades to exactly one LIKE fallback.
+- [x] Remove corpus keyword statistics and unbounded keyword caches.
+- [x] Implement the deterministic code/path, CJK, and ASCII selector with an eight-term cap.
+- [x] Implement bounded BM25 and same-MATCH importance branches.
+- [x] Use LIKE only for unavailable FTS, unicode61, or an all-short useful query.
+- [x] Bind vector readiness to embedding identity, configuration generation, and logical store generation.
+- [x] Remove ordinary recall calls to `hasStaleEmbeddings`.
+- [x] Keep one authoritative `listByIds` materialization for recall results.
+- [x] Serialize coverage verification, vector mutation, reconciliation, and SQLite-ready transitions through a
+ per-agent mutation barrier.
+- [x] Sign readiness only after stable-epoch bidirectional SQLite/DuckDB coverage verification.
+- [x] Make incremental import invalidate derived FTS metadata and keep agent-scoped clear isolated.
+- [x] Bound common-term candidate materialization before sorting.
+- [x] Cover trigram, unicode61, short CJK/code, build/runtime failure, liveness transitions, configuration
+ generation, scope isolation, and retrieval evaluation parity.
+- [x] Run the 1k/10k/50k recall matrix and record the large-scale point ratio.
+
+Gate: safe-trigram recall performs no LIKE, corpus statistics, or stale-vector existence scan.
+
+## Phase: Tape Ingestion Projection
+
+- [x] Add projection and metadata tables, projection version 1, range index, and idempotent schema creation.
+- [x] Implement message revision, retraction, tool, and deletion semantics with shared effective-view ranking.
+- [x] Run Tape append and projection update in one transaction; invalidate metadata before accepting a degraded
+ authoritative append.
+- [x] Treat a final tool fact before its sent/error message as a valid runtime order.
+- [x] Clean or invalidate projection state for delete, clear, fork, truncate, and rewind.
+- [x] Add metadata validation, transactional full rebuild, and authoritative fallback.
+- [x] Add one-statement current-range reads keyed by session and order sequence.
+- [x] Prevent any fallback extraction from advancing the persistent cursor.
+- [x] Commit an equal-order sequence only after its final fragment succeeds.
+- [x] Verify parity for replacement, retraction, pending state, tool deduplication, lineage, edit/retry, restart,
+ and stale metadata.
+- [x] Run the 100k Tape tail matrix and satisfy the full-view relative ratio.
+
+Gate: normal extraction does not call Tape `getBySession()` and materializes only the requested range.
+
+## Phase: Candidate Decisions and Embedding Batches
+
+- [x] Stably deduplicate candidates by normalized `(kind, content)` and enforce the 2,000-code-point limit
+ before recall or provider work.
+- [x] Add batched decision retrieval with one query-embedding batch, one vector lease, and one authoritative
+ row materialization.
+- [x] Add a deterministic four-candidate, three-neighbor, 12,000-token partitioner.
+- [x] Add the indexed batch prompt/result parser, initial candidate-local `ADD` fallback, and budget fallback
+ audit.
+- [x] Apply candidates serially in original order with revision-aware semantic transitions.
+- [x] Fence every provider admission and response and stop later partitions after destructive invalidation.
+- [x] Revalidate provenance, permanent-forget state, liveness, revision, pinned rows, and embedding identity at
+ final apply.
+- [x] Retry only the first four conflicts, reuse only identity-compatible embeddings, and never use implicit
+ retry `ADD`.
+- [x] Enforce provider limits of five in steady state and six under contention.
+- [x] Perform DuckDB bulk delete and bulk insert in one transaction.
+- [x] Perform at most one revision-aware SQLite success update and one error update per embedding batch.
+- [x] Replace promise chaining with one per-agent drain supervisor that processes 50-row chunks to exhaustion.
+- [x] Preserve pending rows on provider-wide failure and avoid empty pending-to-pending writes.
+- [x] Cover partial parsing, missing/duplicate indexes, token overflow, concurrent forget/clear/configuration
+ change, sidecar orphan cleanup, and 101-row drain behavior.
+
+Gate: eight candidates and a 50-row embedding batch remain inside fixed provider and statement limits.
+
+## Phase: Working Memory and Maintenance Bounds
+
+- [x] Add 100 ms trailing working-memory debounce and per-agent refresh singleflight.
+- [x] Synchronously flush dirty working memory on read while preserving read-epoch finalization.
+- [x] Add a 256-row `archiveEligibleBatch` using indexed current-time age algebra and
+ `UPDATE ... RETURNING`.
+- [x] Remove the lifetime zero-access archive veto and align lifecycle preview, health, UI copy, and tests.
+- [x] Replace full-corpus reflection/persona reads with aggregate plus indexed top-N input.
+- [x] Add a shared maintenance budget with 4/2/1/1 call quotas and 24,000 estimated input tokens.
+- [x] Run heavy steps in challenge, merge, reflection, persona order without quota borrowing.
+- [x] Add persistent challenger fairness and stamp every attempted challenger outcome.
+- [x] Limit heavy maintenance to two agents with process-wide fairness and whole-pass per-agent singleflight.
+- [x] Keep gateway admission failures inside the consumed budget.
+- [x] Replace conflict sibling loops and broad integrity repair with bounded set-based transitions.
+- [x] Verify maintenance query plans use their intended indexes without temporary ORDER BY storage.
+- [x] Cover mutation coalescing, read flush, archive limits, recent access, cognitive top-N, provider budget,
+ conflict fairness, and three-agent concurrency.
+
+Gate: a no-change pass performs no unbounded row writes or provider calls.
+
+## Phase: Resource, Pagination, Content, and Audit Bounds
+
+- [x] Filter managed, enabled, memory-enabled agent candidates before indexed latest-activity ranking.
+- [x] Limit startup prewarm to eight recent agents.
+- [x] Key embedding connection warmup by `provider:model` with successful, in-flight, and five-minute failure
+ caches.
+- [x] Add lease-safe store LRU with a soft cap of eight, a 15-minute idle TTL, and immediate convergence after
+ lease release.
+- [x] Keep expected LRU competition from clearing logical readiness or creating embedding errors.
+- [x] Verify 100 shared-model agents use at most one warm embedding call and eight open sidecars.
+- [x] Add `memory.page`, a canonical versioned opaque cursor, and default/maximum page size 100.
+- [x] Add indexed keyset pagination, loaded-depth renderer refresh, stale-response rejection, and server search.
+- [x] Keep dirty page-two editors intact across refresh.
+- [x] Deprecate the legacy list and prevent new production callers through an architecture guard.
+- [x] Enforce 12,000-code-point manual and 2,000-code-point automatic content limits across shared, domain,
+ tool, and model-generated boundaries.
+- [x] Retain existing oversized rows without migration or truncation.
+- [x] Add exact operational-audit retention: newest 10,000 retained and at most 500 deleted per cheap pass.
+- [x] Prove user-semantic, persona, unknown, malformed, and legacy causal rows are never pruned.
+- [x] Prove cleanup leaves `hasForgetEvent` unchanged.
+- [x] Cover cursor tampering/ties/end, repository direct-call bounds, renderer load-more/search/refresh, Unicode
+ boundaries, and retention idempotence.
+
+Gate: long-lived native resources, management responses, submitted content, and operational audit growth have
+explicit limits.
+
+## Phase: Scale Evidence and Documentation
+
+- [x] Run the complete performance matrix and record median, p95, and production complexity counters.
+- [x] Add alternating 11-sample paired measurement, assert `fts-only`, and enforce the portable
+ 10k-to-50k growth-factor gate.
+- [ ] Verify the portable recall growth gate in post-change native CI.
+- [x] Verify 100k Tape current-range median is no more than 20% of the full-view baseline.
+- [x] Record whether the report-only recall and Tape p95 targets pass in the reference environment.
+- [x] Record the implemented architecture, compatibility guarantees, failure modes, and benchmark evidence in
+ this self-contained document set.
+- [x] Verify no unresolved clarification marker remains.
+
+Gate: the implementation and its scale evidence agree with [spec.md](./spec.md).
+
+## Validation Status
+
+- [x] `mise exec -- pnpm run typecheck`
+- [ ] `mise exec -- pnpm run test:main -- --run` — memory-focused tests pass; the complete suite still has four
+ unrelated pre-existing failures in Cron Jobs, a debug mock session, and agent-session rebudget integration.
+- [ ] `mise exec -- pnpm run test:renderer -- --run` — MemoryListView 25/25 and MemorySettings 11/11 pass; the
+ complete suite still has four unrelated Skills/Pinia mock initialization failures.
+- [ ] `mise exec -- pnpm run test:main:memory-perf` — non-native collection passes with seven tests and four
+ expected native skips; the local binding is intentionally not rebuilt, so native execution is delegated to
+ CI.
+- [x] `mise exec -- pnpm run format`
+- [x] `mise exec -- pnpm run i18n`
+- [x] `mise exec -- pnpm run lint`
+- [x] `mise exec -- pnpm run format:check`
+- [ ] GitHub Actions `memory-native-validation` post-commit gate.
+
+## Hardening Review Completion
+
+- [x] Serialize vector verification and mutation, prevent LRU admission errors, and make incremental import
+ rebuild FTS safely.
+- [x] Consolidate FTS policy and scope, bound common-term queries, and add indexed archive ranges.
+- [x] Preserve projection currency for valid tool-before-message order and select only conflict anomalies.
+- [x] Add a bounded passive projection-rebuild cooldown without weakening fallback cursor safety.
+- [x] Treat only explicit success/error tool facts as terminal effective Tape evidence.
+- [x] Guard management paging for non-DeepChat Agents and align Load more with active versus archived search.
+- [x] Address review maintainability findings for shared limits, query-plan assertions, and mirrored tests.
+- [x] Make startup activity work proportional to eligible agent count rather than memory-row count.
+- [x] Restore server search, coalesce update events, refresh loaded pages atomically, and correct lifecycle UI
+ semantics.
+- [x] Use Unicode code points for content and excerpts, share one drain supervisor promise, version the exact
+ audit-retention index, and remove duplicate/dead abstractions.
+- [x] Run targeted main/native/static tests, Memory renderer tests, the scale suite, type checking, linting,
+ formatting, and translation checks without weakening assertions.
+
+## Definition of Done
+
+- Every recall, Tape, provider, embedding, maintenance, store, page, content, and audit path has an explicit
+ complexity or capacity bound.
+- FTS, projection, and DuckDB failure preserve authoritative SQLite and Tape state.
+- Correctness, privacy, revision, lease, cancellation, and deadline invariants remain intact.
+- Automated and benchmark evidence covers every requirement.
+- No unresolved clarification, external document dependency, or GitHub issue side effect remains.
diff --git a/docs/architecture/agent-memory-system/spec.md b/docs/architecture/agent-memory-system/spec.md
index b2004e1a5..0f83fff85 100644
--- a/docs/architecture/agent-memory-system/spec.md
+++ b/docs/architecture/agent-memory-system/spec.md
@@ -54,6 +54,10 @@ These hold across every module and are the system's core invariants.
12. **Destructive generations are separate from read epochs.** Ordinary semantic mutations advance the
per-agent read epoch; clear, agent deletion, and dispose invalidate a destructive operation generation
and abort stale provider continuations before they can write rows, cursors, audits, events, or vectors.
+13. **Scale is contractually bounded.** Recall chooses either indexed FTS or one LIKE fallback; extraction
+ batches candidate recall/decisions; embedding persists in bulk; maintenance has row/call/token/concurrency
+ budgets; startup, vector-store residency, management pages, content, and operational audit history all
+ have explicit caps.
---
@@ -119,11 +123,14 @@ flowchart TD
| Services | `memoryPresenter/services/conflictService.ts` | Conflict aggregate guard, listing/resolution, integrity repair, and maintenance scheduling through narrow ports |
| Services | `memoryPresenter/services/managementService.ts` | List/get/lifecycle/health/delete/clear/status delegation and management-facing row projection |
| Infra | `memoryPresenter/infra/providerGateway.ts` | Agent-aware RateLimit admission, purpose deadlines, destructive abort, and bounded unsettled provider work |
-| Infra | `memoryPresenter/infra/vectorStoreManager.ts` | DuckDB sidecar infrastructure: scoped generation leases, identity/readiness, recoverable reset, and parallel close/drain orchestration |
+| Infra | `memoryPresenter/infra/vectorStoreManager.ts` | DuckDB sidecar infrastructure: scoped generation leases, readiness certificates, recoverable reset, lease-safe LRU/TTL eviction, and parallel close/drain orchestration |
+| Infra | `src/main/lib/asyncSemaphore.ts` | Fair process-wide admission for heavy per-agent maintenance |
| Infra | `memoryPresenter/infra/embeddingPipeline.ts` | Pending embedding drain, reindex/backfill, embedding/vector warmups, dimension cooldowns, and `isReindexing` |
| Infra | `memoryPresenter/infra/memoryVectorStore.ts` | `MemoryVectorStore` — per-agent DuckDB sidecar (HNSW/cosine, identity gate, transactional upsert, disk reclaim) |
| Core | `memoryPresenter/core/candidates.ts` | Pure memory candidate normalization |
| Core | `memoryPresenter/core/decision.ts` | The Mem0-style decision ring prompt + tolerant parser (`ADD/UPDATE/SUPERSEDE/NOOP/CHALLENGE`) |
+| Core | `memoryPresenter/core/batchDecision.ts` | Pure 4-candidate/12k-token decision partitioner and indexed batch-result parser |
+| Core | `memoryPresenter/core/maintenanceBudget.ts` | Shared per-pass call/token accounting with fixed challenge/merge/reflection/persona quotas |
| Core | `memoryPresenter/core/extraction.ts` | Triage + extraction prompts and parsers; reflection/persona prompts; persona small-step (Levenshtein) guard |
| Core | `memoryPresenter/core/injectionPort.ts` | `sanitizeForInjection` + the token-budgeted Context Assembler + the injection manifest |
| Core | `memoryPresenter/core/lifecycle.ts` | Lifecycle diagnostics and archive/freshness thresholds |
@@ -133,6 +140,7 @@ flowchart TD
| Storage | `sqlitePresenter/tables/agentMemory.ts` | `agent_memory` table + `agent_memory_fts` FTS5 + keyword search |
| Storage | `sqlitePresenter/tables/agentMemoryAudit.ts` | `agent_memory_audit` content-free maintenance ledger |
| Storage | `sqlitePresenter/tables/deepchatTapeSearchProjection.ts` | `deepchat_tape_search_projection` (+ meta + FTS) evidence projection |
+| Storage | `sqlitePresenter/tables/deepchatMemoryIngestionProjection.ts` | Versioned effective-message range projection used only by memory extraction |
| Runtime seam | `agentRuntimePresenter/index.ts` | `appendMemoryInjection`, `enqueueSessionExtraction`, span builder, cursor, memory anchors |
| Runtime | `agentRuntimePresenter/tapeService.ts` | `search()` / `getContext()` / `ensureSearchProjection()` |
| Tools | `toolPresenter/agentTools/agentMemoryTools.ts` | `memory_remember` / `memory_recall` / `memory_forget` |
@@ -156,7 +164,7 @@ entrypoints/contracts and may not import `services`, `infra`, or the facade entr
## 5. Data model and storage responsibilities
-Memory is split across five stores. Each has one job; none is authoritative for more than its job.
+Memory is split across six stores. Each has one job; none is authoritative for more than its job.
| Store | Holds | Rebuildable? |
| --- | --- | --- |
@@ -164,6 +172,7 @@ Memory is split across five stores. Each has one job; none is authoritative for
| SQLite `agent_memory_fts` | Keyword recall (BM25), external-content mirror of `agent_memory` | Yes — rebuilt idempotently; degrades to `LIKE` |
| SQLite `agent_memory_audit` | Maintenance/user provenance ledger (ids/action/model; `scheduler`/`user` actors + optional `session_id`; drives the cooldown) | No — but content-free |
| SQLite `deepchat_tape_search_projection` | Searchable evidence projection of the effective tape (summary + refs + FTS) | Yes — rebuilt from raw tape entries |
+| SQLite `deepchat_memory_ingestion_projection` | Effective final messages keyed by session/message with order, lineage entry, and tool-use bit for bounded extraction ranges | Yes — rebuilt from raw tape entries |
| DuckDB sidecar (one `.duckdb` per agent) | `memory_vector` (HNSW/cosine) + `embedding_meta` (provider/model/dim identity) | Yes — re-embedded from `agent_memory` |
The raw tape (`deepchat_tape_entries`) remains the ultimate evidence source of truth and also stores the
@@ -282,11 +291,11 @@ Compaction-triggered extraction keeps its old behavior.
```mermaid
flowchart TD
T["turn / resume done or compaction"] --> EQ["enqueueSessionExtraction
(per-session serial chain, epoch-guarded)"]
- EQ --> CUR["read cursor + buildEffectiveTapeView window (from,to]
build message-aligned chunks + exact lineage"]
+ EQ --> CUR["read cursor + validate/rebuild ingestion projection
range-read effective messages (from,to]"]
CUR --> TRI{"triage gate
(cheap KEEP/SKIP, fail-open)"}
TRI -- SKIP --> ADV0["advance cursor, no write"]
TRI -- KEEP --> EX["extraction → JSON candidates (≤8)"]
- EX --> CW["coordinateWrite per candidate"]
+ EX --> CW["stable dedupe + batched neighbor recall/decisions"]
CW --> DUP{provenance hit?}
DUP -- hit --> REV["handleProvenanceHit
(restore / suppress / decision)"]
DUP -- miss --> NB["retrieveForDecision top-10 neighbors
(keyword any + no inline prune)"]
@@ -301,7 +310,7 @@ flowchart TD
SUP --> PEND
REV --> PEND
PEND --> ADV["cursor advances MAX + memory/extract anchor (when created>0)"]
- PEND --> EMB["background per-agent embedding queue
(batched · fair · transactional)"]
+ PEND --> EMB["single per-agent embedding drain
(50 rows · bulk DuckDB/SQLite)"]
EMB --> DUCK["DuckDB memory_vector + status=embedded"]
```
@@ -322,6 +331,13 @@ reported as a discriminated `MemoryCandidateParseResult` (`{ ok: false, reason }
degraded to `[]`, so the caller can retry the span instead of consuming it; a successful parse returns
`{ ok: true, candidates }` (an empty array is a valid success).
+Before recall or another provider call, normalized candidates are stably deduplicated by
+`(kind, provenance-v2-normalized content)` and automatic content is capped at **2,000 characters**. An
+oversized item becomes a content-free `candidate-too-large` audit outcome rather than being truncated.
+Candidate neighbor retrieval performs one bounded keyword query per item, one batch query-embedding call,
+one vector lease for all vector queries, and one authoritative `listByIds`; each item keeps at most three
+neighbors.
+
**Candidate normalization.** Every write entry point (`coordinateWrite`, `directAddMemory`, and
`writeMemoriesSync`) normalizes candidates before provenance-key generation or storage. A valid category
takes precedence over legacy `kind`: `task_outcome` becomes `episodic`, all other valid categories become
@@ -338,19 +354,23 @@ and persona run on the scheduler with no active chat session, so the active chat
unavailable to them. The cost saving comes from the triage gate avoiding the larger extraction call, plus the
*option* to point extraction at a cheaper model.
-**Decision ring (`coordinateWrite`).** Each candidate:
+**Decision ring (`coordinateWrite`).** Candidates are applied serially in original order, but model work is
+batched: at most four candidates per prompt, three 400-character neighbor excerpts each, 12,000 estimated
+input tokens, and two initial decision calls. The partitioner drops lowest-priority excerpts before using a
+safe initial `ADD` budget fallback; it never truncates candidate content or opens a third initial batch.
+Each candidate then follows this state machine:
1. Trim; empty → no-op. Teardown guard → no-op.
2. Compute the provenance key and check for an existing row. A pure scheduler-archived row is restored to
`pending_embedding`; a user/runtime-forgotten row is suppressed; an archived superseded conflict loser is
suppressed; an active duplicate returns `noop(duplicate)`. A non-archived superseded row does not revive
by provenance alone when a model path is available — its live chain head is fed into the decision ring.
-3. Otherwise retrieve up to **10** decision neighbors through `retrieveForDecision`, which uses the
- agent-facing keyword query in `any` mode and disables inline vector pruning. This keeps FTS-only agents
- capable of semantic write decisions without requiring vectors. On the first attempt, a decision-model
- failure or invalid target index still degrades to `ADD`; a revision/liveness conflict instead performs one
- complete fresh provenance/recall/prompt attempt. During that retry only an explicit valid `ADD` may insert;
- another conflict, provider failure, or parse failure returns `noop(concurrent-update)`.
+3. Otherwise use its three-item batch-retrieved neighbor set. This keeps FTS-only agents capable of semantic
+ write decisions without requiring vectors. On the first attempt, a missing/invalid result or provider
+ failure degrades only that candidate to `ADD`. Revision/liveness conflicts are collected in original
+ order; only the first four receive one fresh provenance/recall pass and one shared retry decision call,
+ reusing their first query embeddings. During retry only an explicit valid `ADD` may insert; another
+ conflict, provider failure, missing result, or parse failure returns `noop(concurrent-update)`.
4. Apply: `ADD` inserts with the candidate category. `UPDATE` uses one conditional SQL statement plus the
confidence update in one transaction, atomically checking agent/id/revision/liveness/conflict while
replacing content/category/provenance, resetting embedding metadata, and incrementing
@@ -367,14 +387,19 @@ The write outcome is a discriminated union `MemoryWriteOutcome` (`created` / `up
tool and the user-facing `memory.add` route (when an extraction model is configured; otherwise the user-add
path is a pure dedupe-add).
-**Two-phase persistence.** Phase 1 is a synchronous SQLite insert as `pending_embedding`. Phase 2 is an
-asynchronous, per-agent, fair, batched embedding drain: `listPendingEmbedding` filters by `agent_id` in SQL
-(no cross-agent starvation), one batched `getEmbeddings` call, and a single transactional upsert into DuckDB
-under the per-agent lock. A transient embedding-service failure re-marks rows `pending_embedding`;
-a dimension mismatch or vector-store write failure after a successful embed marks the row `error`. The drain
-does not leave `error` rows permanently stranded: when no normal pending work exists and the cooldown has
-elapsed, it requeues up to the bounded retry batch and tries again. A manual per-agent reindex resets
-embedding state and rebuilds from SQLite.
+**Two-phase persistence.** Phase 1 is a synchronous SQLite insert as `pending_embedding`. Phase 2 is one
+per-agent `{dirty,running}` drain loop over at most 50 rows: one batched `getEmbeddings`, one authoritative
+`listByIds`, one DuckDB transaction containing a bulk delete and multi-values insert, and at most one
+revision-aware SQLite success update plus one error update. Content/config/clear races therefore cannot mark
+an old vector ready. A whole-provider failure leaves rows pending without per-row no-op writes; malformed
+individual vectors enter the error batch. Cooldown-bounded retry and manual per-agent reindex remain available.
+
+The effective-message ingestion projection is maintained at the lowest Tape append boundary, so every
+message/tool/anchor/event append advances or invalidates the session watermark. Reads compare projection
+version and session max entry ID; a mismatch rebuilds transactionally through the single
+`buildEffectiveTapeView` implementation. Rebuild failure uses that full view for the current extraction and
+does not falsely advance the cursor. The steady path materializes only the requested `(orderSeq, messageId)`
+range while preserving the final message entry ID as lineage.
**Cursor.** `memory_cursor_order_seq` (on `deepchat_sessions`) is written `SET = MAX(existing, floor(x), 0)`,
so a late/stale extraction can never roll it back. It advances only when `extractAndStore` returns `ok: true`
@@ -392,19 +417,23 @@ and trims to `topK`. `searchMemories()` is the only caller allowed to pass a sea
retrieval `topK` and still record access only for recall/injection hits.
- **Keyword path.** `agent_memory_fts` (FTS5, BM25-ranked) with the tokenizer chosen at runtime — `trigram`
- for CJK/substring matching, else `unicode61`. The query is tokenized on whitespace; each term is quoted
- independently (so user text can never inject an FTS5 operator) and the terms are joined with `AND`, so a
- multi-word query matches rows containing **all** terms in any order rather than only an exact phrase (a
- single-term query is unchanged). `LIKE` runs the same per-term `AND` and is unioned in, so the result is
- never a subset of the legacy `LIKE` behavior; if FTS5 is unavailable the path is pure `LIKE`. Persona,
- working, archived, conflicted, and superseded rows are excluded. The FTS index records its tokenizer and
- version in `agent_memory_fts_meta`; if the runtime tokenizer choice changes (e.g. content starts containing
- CJK), the index is dropped and rebuilt so a stale tokenizer can never silently degrade keyword recall.
+ for CJK/substring matching, else `unicode61`. Query terms are selected deterministically by kind
+ (code/path → CJK → ASCII), Unicode code-point length, and original position, with a cap of eight. When the
+ tokenizer is trigram and every selected term is at least three code points, the path runs BM25 and one
+ importance/recency supplement using the exact same MATCH. Otherwise it runs exactly one bounded per-agent
+ LIKE query. FTS and LIKE never run together, and no corpus-frequency stats/cache is consulted. FTS v3
+ contains only recallable rows. Authoritative writes mark the derived generation dirty, then maintain the
+ mirror inside a nested savepoint; failure rolls back only the savepoint and forces one bounded LIKE until
+ filtered rebuild. Persona, working, archived, conflicted, and superseded rows remain outside the index.
- **Vector path.** Only when an embedding model is configured. The query is embedded, DuckDB returns nearest
neighbors by cosine distance, distances are converted to similarity and filtered by `similarityThreshold`,
and the same row-class exclusions as the keyword path (persona, working, archived, conflicted, superseded)
- are applied to the matches. If any embedded row's identity is stale (model/dim/fingerprint mismatch), the
- turn answers **FTS-only** and a non-destructive background re-embed is queued — rows are never deleted.
+ are applied to the matches. A readiness certificate binds agent, provider/model, dimension, config
+ generation, and logical-store
+ generation. A missing certificate makes that turn **FTS-only** and schedules non-destructive warmup; ordinary
+ recall never performs a stale-existence scan. SQLite authoritative revalidation additionally requires vector
+ rows to remain `embedded` with the current fingerprint/dimension, so edited pending rows cannot surface via
+ old sidecar vectors. Rows are never deleted merely because readiness is absent.
Query embeddings are tracked per `agentId::embeddingFingerprint` group and then by full normalized query:
identical concurrent queries share one provider call, two distinct fresh queries may run concurrently, and
a third distinct query degrades that turn to FTS-only. The 800 ms soft timeout and 30 s stale replacement
@@ -447,9 +476,9 @@ Important memories stretch their half-life, so they survive longer before becomi
and forgetting are deliberately two different scores: a memory can rank low for recall yet still be retained.
`category` is not a second scoring axis: it is ignored by retrieval, decay, RRF, and rerank logic. Its only
ranking/retention effect is indirect, through the deterministic importance floor applied at write time.
-Decay refresh uses the same formula whether computed in SQL or in the JavaScript fallback. When SQLite's
-math functions are available, the maintenance pass updates `decay_score` in one statement and does not touch
-`last_consolidated_at`; otherwise it falls back to the same calculation inside one repository transaction.
+Archive eligibility uses the same half-life formula but converts the threshold into an age boundary inside
+the bounded archive query. Ordinary maintenance therefore does not rewrite materialized decay scores across
+the full Agent corpus.
---
@@ -461,9 +490,9 @@ Memory schedules its **own** maintenance — it does not rely on the repo-wide s
flowchart TD
TM["event-driven arms:
60s after start one-shot batch
5min idle debounce after each write
config-change arm on enable / model-available"] --> CP["runConsolidationPass(agent)"]
CP --> CD{"6h LLM cooldown
seeded from agent_memory_audit"}
- CD -- within --> CHEAP["cheap maintenance only:
decay refresh + archiveStale + working sync"]
- CD -- past --> LLMJOB["mergeNearDuplicates + runChallengeResolutionPass
+ maybeReflect(kind=reflection)
+ maybeEvolvePersona(draft, default-off)"]
- LLMJOB --> POST["refreshDecayScores + archiveStale + working sync"]
+ CD -- within --> CHEAP["cheap bounded maintenance:
archive ≤256 + repair + audit prune ≤500"]
+ CD -- past --> LLMJOB["global semaphore ≤2
challenge → merge → reflection → persona
shared 8-call / 24k-token budget"]
+ LLMJOB --> POST["bounded archive + working flush"]
POST --> AUD["agent_memory_audit
(provenance-only) + status=completed"]
AUD --> EV["memory.updated → UI refresh
(post-audit emit gated on touched;
archiveStale emits its own when archived>0)"]
```
@@ -484,13 +513,20 @@ value is absent, so the cooldown survives restarts. Within the cooldown only che
no audit row). A missing-model pass is recorded `skipped` and does **not** advance the cooldown (it retries
next trigger).
+**Heavy maintenance budget.** At most two Agents execute heavy maintenance concurrently through a fair
+process-wide semaphore; the complete same-Agent pass remains singleflight. Each admitted pass runs
+challenge → merge → reflection → persona under one shared budget of **8 calls / 24k estimated input tokens**,
+with non-borrowable per-step call quotas **4 / 2 / 1 / 1**. Reservation happens before gateway admission, so
+provider failures cannot create an unbounded retry loop. Cheap repair/archive/audit cleanup does not occupy a
+heavy slot.
+
**`mergeNearDuplicates` (budgeted).** Scans only active `embedded` non-persona/non-working rows that match
the current embedding fingerprint/dimension. It uses the row's stored DuckDB vector via
`queryByMemoryId()` rather than re-embedding row content through the provider; the store method reads the
source vector by id and reuses the existing parameterized vector query path, then filters the source id out
of the results. The pass advances an in-memory `{ createdAt, id }` compound cursor so large same-timestamp
-windows are not skipped. Each pass is bounded by
-**64 stored-vector neighbor scans**, **8 LLM calls**, and **24k input tokens** (remainder deferred). For each
+windows are not skipped. Each pass is bounded by **64 stored-vector neighbor scans** and its two-call share
+of the pass budget (remainder deferred). For each
row it picks the first current, live neighbor with similarity ≥ **0.85** and runs the same decision prompt;
only `UPDATE`/`SUPERSEDE` may fold the pair. The pass snapshots both revisions before the LLM await and applies
the survivor content/embedding reset plus the retired row's supersede transition in one transaction guarded
@@ -500,8 +536,10 @@ third owner causes a safe skip plus consolidation stamp instead of a stale three
confidence only ever rise.
**Non-destructive archival (`archiveStale`).** A row is soft-deleted only when **all** of: `decayScore <
-0.05`, `access_count == 0`, age `> 90 days`, still active, and not an unresolved conflict participant.
-Anchors, persona, and working rows are exempt. A challenged target remains lifecycle-active and recallable;
+0.05`, age `> 90 days`, still active, and not an unresolved conflict participant. Access count remains a
+diagnostic but is not an eligibility veto. One set-based pass archives at most **256** rows; decay eligibility
+is expressed from the access/creation age rather than requiring an Agent-wide score refresh. Anchors, persona,
+and working rows are exempt. A challenged target remains lifecycle-active and recallable;
`conflict_state` protects it from archive and generic single-row mutation until aggregate resolution/repair.
`restoreMemory` reverses it for normal memories. There is no hard delete on this path. Maintenance also runs
a cheap repair that normalizes any legacy persona/working row back to `status='fts_only'` while preserving
@@ -511,9 +549,10 @@ by both current embedding fingerprint and current ready dimension before applyin
row lifecycle plus dim/model before vector delete, and clears SQLite refs only if the row is still prunable
with the same dim/model after deletion. Old-fingerprint or old-dimension refs remain as traceable metadata
residue; maintenance does not cold-open those sidecars or let them occupy the current cleanup batch.
-Consolidation timestamps are stamped with one SQL update over the same active non-internal row class. Working
-memory refresh reads only the top ordered candidates it can use, and status counts are computed with a single
-aggregate query rather than loading all rows.
+Only rows actually inspected by bounded maintenance are stamped. Reflection/persona consume one aggregate
+plus indexed top-N query instead of loading the full Agent corpus. Working-memory mutations use a 100 ms
+trailing debounce; injection synchronously flushes a dirty blob between the initial and final read-epoch
+gates. Status counts remain a single aggregate query.
---
@@ -597,12 +636,16 @@ This is where the "stabilization" and "kernel hardening" work concentrates.
carrying a per-agent store generation. Close/reset/delete/dispose stop new admission and drain active
leases before touching the native store. A failed reset marks `requiresReset`; later leases retry reset
under the per-agent lock and fail open to FTS for that request if recovery still fails. Permanent retire and
- dispose remain closed. Persona operations use a separate per-agent lock.
+ dispose remain closed. Persona operations use a separate per-agent lock. Open stores have a soft cap of
+ **8** and an idle TTL of **15 minutes**; a 60-second unref sweep and post-release convergence evict only
+ lease-free LRU entries. All-busy stores may temporarily exceed the cap without violating lease safety.
- **Vector identity gate.** On opening an existing sidecar, `embedding_meta` (provider/model/dim) is compared
against the current request. A mismatch or a legacy sidecar with no identity → the store is marked unusable
and recall serves FTS only, with an explicit warning. A model/dimension switch closes the old instance and
recreates the file. The dimension is baked into the DuckDB column type, so a dimension change requires a
full file reset (`destroyFile` + recreate), not an in-place migration.
+- **Warmup bounds.** Startup prewarms only the eight most recently active Agents. Embedding connection warmup
+ is deduplicated by provider/model for the process lifetime; a failure is retried only after five minutes.
- **Transactional vector upsert.** `upsert` wraps delete-then-insert in `BEGIN/COMMIT` with `ROLLBACK` on
error, so there is no "deleted-but-not-inserted" hole.
- **Archive / forget / restore lifecycle.** Agent-facing `forgetMemory` is a soft archive: it marks normal
@@ -678,7 +721,7 @@ inspect `result.ok`, not `isError`. Hard infra failures throw.
### 15.2 IPC routes (`memory.*`)
-`memory.list`, `memory.getStatus`, `memory.search`, `memory.add`, `memory.delete`, `memory.clear`,
+`memory.page`, deprecated `memory.list`, `memory.getStatus`, `memory.search`, `memory.add`, `memory.delete`, `memory.clear`,
`memory.restore`, `memory.getSourceSpan`, `memory.listConflicts`, `memory.resolveConflict`,
`memory.listPersonaVersions`, `memory.rollbackPersona`, `memory.listPersonaDrafts`,
`memory.approvePersonaDraft`, `memory.rejectPersonaDraft`, `memory.setPersonaAnchor`,
@@ -687,6 +730,11 @@ inspect `result.ok`, not `isError`. Hard infra failures throw.
- `memory.search` is read-only: it uses a search-only depth override (default 50, route max 100) so the
Memory Manager can return more than the agent's recall `topK`, excludes persona/working rows at the SQL
search layer before applying result limits, and it does not bump `access_count`.
+- `memory.page` is the management list contract. It uses `(created_at DESC, id DESC)` keyset pagination,
+ defaults/caps at 100 rows, and returns an opaque base64url v1 cursor only when another page exists. Invalid
+ cursors are route errors, never implicit first-page requests. After cursor validation, non-DeepChat Agents
+ receive an empty page without reaching the memory presenter. `memory.list` remains wire-compatible for one
+ deprecation window and has no production renderer caller.
- `memory.add` accepts optional `category`, runs the decision ring, and writes a `memory/add` user audit row.
- `memory.reindex` is a fire-and-forget per-agent rebuild entry for managed, memory-enabled DeepChat agents.
It returns `{ started }`, where `started=false` means the guard rejected the request or a reindex was already
@@ -709,6 +757,14 @@ Background maintenance and writes record `memory/maintenance_llm`, `memory/refle
(`scheduler`, `user`, or `runtime`), an optional `session_id`, a terminal
`status` (`completed`/`skipped`/`failed`), and `inputRefs`/`outputRefs` that contain only ids, action
strings, counts, ratios, and booleans. No raw memory text or persona content is ever stored.
+Operational event types (`memory/maintenance_llm`, `memory/reflect`, `memory/repair`,
+`memory/conflict_repair`, `memory/extract`) are retained to the newest 10,000 rows per Agent and pruned at
+most 500 per cheap pass. User lifecycle events, every `persona/*` event, unknown types, and malformed/legacy
+causal rows are permanent and never selected by this cleanup.
+
+Manual/user-authored content is capped at **12,000 characters** and automatic/model-merged memory at
+**2,000 characters**, at both route/tool and domain boundaries. Existing oversized rows remain readable and
+recallable; the limit applies only when submitting new content.
---
@@ -724,7 +780,9 @@ Memory is a first-class, top-level settings section, configured strictly per-age
constants exactly (topK 6 / rrfK 60 / threshold 0.2 / budget 1200; ranges topK 1–100, rrfK 1–1000, budget
64–8000).
- **Manage tab** reuses `MemoryManagerPanel` (Memories / Persona / Activity) and is the only surface that
- uses `MemoryClient`. Memory rows show a category badge and the Memories list has a local category filter;
+ uses `MemoryClient`. The Memories view loads keyset pages of at most 100 rows, appends with ID dedupe, and
+ resets to page one on Agent/event generation changes; its category/text filters intentionally cover only
+ loaded rows. Memory rows show a category badge and the Memories list has a local category filter;
`NULL` / missing categories are displayed and filtered as `uncategorized`. The manual add form exposes
`kind` and importance but not category.
- **Inheritance.** Per-agent config inherits the builtin `deepchat` root then applies its own overrides
@@ -742,9 +800,10 @@ every table's latest version). The current global maximum is **41**.
| Table | Change | Migration |
| --- | --- | --- |
| `agent_memory` | v32 backfills `embedding_model` + `source_entry_ids`; v33 adds `confidence` + `last_consolidated_at` + `conflict_state`; v34 adds `persona_state`; v35 adds `conflict_with`; v37 adds nullable `category`; v41 adds `decision_revision INTEGER NOT NULL DEFAULT 1`. `getCreateTableSQL` is authoritative (new DB == migrated old DB), and startup catalog repair coexists with migration validation. The conflict-target lookup index is reconciled idempotently for existing DBs without consuming v42. **Purely additive.** | Yes (`getMigrationSQL`) |
-| `agent_memory_fts` | FTS5 external-content virtual table + `ai`/`ad`/`au` triggers; tokenizer probed at runtime | No — built idempotently |
+| `agent_memory_fts` | FTS5 v3 external-content virtual table containing recallable rows only; deterministic Agent scope token and savepoint-isolated explicit mirror maintenance; tokenizer probed at runtime | No — built idempotently |
| `agent_memory_audit` | New table at v36: maintenance/user provenance ledger (`scheduler`/`user` actors + optional `session_id`), ids/metadata only | Yes (whole table) |
| `deepchat_tape_search_projection` (+ meta + FTS meta) | Searchable projection of the effective tape + FTS5 (content `PROJECTION_VERSION=2`) | No — version-exempt, rebuilt idempotently from raw tape |
+| `deepchat_memory_ingestion_projection` (+ meta) | Effective final-message range projection for extraction (`PROJECTION_VERSION=1`) | No — version-exempt, rebuilt idempotently from raw tape |
| `deepchat_sessions` | `memory_cursor_order_seq` now written monotonically (`MAX(...)`) | — |
| DuckDB (per agent) | `memory_vector` (HNSW/cosine, `M=16`, `ef_construction=200`) + `embedding_meta` (identity; mismatch → fail-closed to FTS) | No — built at runtime |
@@ -764,14 +823,14 @@ enable memory (top-level Memory page / agent toggle)
→ appended; persist memory/view_assembled manifest anchor
→ model replies
→ turn/resume/compaction → enqueueSessionExtraction (per-session serial, epoch-guarded)
-→ cursor + effective-view window + message-aligned CJK-aware chunks + exact source_entry_ids lineage
+→ cursor + validated/rebuilt ingestion projection range + message-aligned CJK-aware chunks + exact lineage
→ fallback admission (visible text + tool/backstop/substantive text) or compaction
-→ same complete chunk through triage → extraction raw category candidates → normalization → decision ring
- (ADD/UPDATE/SUPERSEDE/NOOP/CHALLENGE, with revision CAS and one bounded fresh retry)
+→ same complete chunk through triage → extraction → stable dedupe/2k cap → batched neighbor recall/decision
+ (≤4 candidates/call, ≤3 neighbors, ≤2 initial calls, revision CAS + one ≤4-candidate retry)
→ SQLite pending_embedding → cursor advances MAX + memory/extract anchor
-→ background per-agent embedding (batched · fair · transactional) → DuckDB
-→ [offline] self-scheduled sleep-time pass (6h cooldown): merge + conflict adjudication
- + reflect(kind=reflection) + persona draft (default-off) + decay refresh + archiveStale + working sync
+→ background single per-agent embedding drain (50 rows · bulk DuckDB/SQLite) → DuckDB
+→ [offline] self-scheduled sleep-time pass (6h cooldown, global concurrency 2): challenge → merge
+ → reflection → persona under shared 8-call/24k budget + bounded archive/repair/audit/working upkeep
→ agent_memory_audit (provenance-only) + memory.updated event
```
@@ -795,11 +854,14 @@ Coverage mirrors source under `test/main/**` (and `test/renderer/**` for UI), pi
- Lineage DTO + source span; tape projection FTS/BM25 + `tape_context`; atomic agent-deletion cleanup.
- Settings surface (override clear / inheritance / clamp; category badge/filter); retrieval eval (hit@3 / MRR /
nDCG).
+- The independent `test:main:memory-perf` suite covers 1k/10k/50k recall, 10k/100k Tape, 100 shared-model
+ Agents, a 101-row embedding drain in 50-row chunks, and eight decision candidates. CI hard-gates statement/materialization/
+ provider/resource caps and same-process relative medians; median/p95 absolute wall-clock values are reports.
The independent `memory-native-validation` CI job installs the Node 24 ABI artifact through the native dependency's
own install lifecycle, runs an open/read/write/reopen/close smoke, and sets
`DEEPCHAT_REQUIRE_NATIVE_SQLITE=1`. Missing bindings, FTS initialization, fresh schema, v37/v38/v40→v41
-migration, reopen, or migration validation fail rather than skip. The focused Native suite runs only in this
+migration, reopen, migration validation, or the memory performance suite fail rather than skip. The focused Native suite runs only in this
disposable CI dependency tree; local development keeps the Electron ABI binding installed.
---
@@ -846,8 +908,8 @@ disposable CI dependency tree; local development keeps the Electron ABI binding
| `FTS_SIMILARITY_BASELINE` | 0.3 | keyword-only hit similarity |
| `FORGET_HALF_LIFE_MS` | 30d (× `1 + importance`) | `decayScore` |
| `CATEGORY_IMPORTANCE_FLOOR` | user_preference 0.5 · project_fact 0.6 · task_outcome 0.55 · heuristic 0.5 · anti_pattern 0.6 | write-time category floor |
-| archive thresholds | decay < 0.05 · access = 0 · age > 90d | `archiveStale` |
-| `DECISION_NEIGHBOR_TOP_S` | 10 | neighbors fed to the decision model |
+| archive thresholds / batch | decay < 0.05 · age > 90d / 256 | `archiveStale` |
+| decision batches | 4 candidates · 3 neighbors · 12k tokens · 2 initial calls · ≤4 retries in one call | extraction decision work |
| `MAX_CANDIDATES` | 8 | extraction candidates per chunk |
| extraction chunk | ~4000 estimated tokens / 12000 Unicode code points / 4 chunks per queue task | message-aligned input shared by triage and extraction |
| `MEMORY_FALLBACK_MIN_DELTA` | 6 | min orderSeq delta before fallback extraction |
@@ -855,12 +917,16 @@ disposable CI dependency tree; local development keeps the Electron ABI binding
| `CONSOLIDATION_IDLE_MS` | 5min | idle debounce after a write |
| `MAINTENANCE_START_DELAY_MS` / `STARTUP_ARM_STAGGER_MS` | 60s / 5s | one-shot startup batch arm for active enabled agents |
| `CONSOLIDATION_COOLDOWN_MS` | 6h | LLM-backed pass cooldown (restart-durable) |
-| `CONSOLIDATION_MAX_LLM_CALLS` / tokens | 8 / 24000 | `mergeNearDuplicates` budget |
+| maintenance calls / tokens / concurrency | 8 / 24000 / 2 Agents | shared pass budget; step quotas challenge 4 · merge 2 · reflection 1 · persona 1 |
| `CONSOLIDATION_MERGE_SIMILARITY` | 0.85 | near-duplicate merge threshold |
| `CONSOLIDATION_MAX_NEIGHBOR_SCANS` | 64 | stored-vector neighbor scans per consolidation pass |
| `VECTOR_PRUNE_BATCH_LIMIT` | 256 | prunable archived/superseded/internal-kind vector refs per maintenance pass |
| `ERROR_RETRY_COOLDOWN_MS` / batch | 10min / 50 | bounded automatic retry for rows stuck in embedding `error` |
| `ORPHAN_RECONCILE_BATCH` | 512 | keyset page size for warm vector orphan reconciliation |
+| vector stores / idle TTL / sweep | soft cap 8 / 15min / 60s | lease-safe LRU convergence |
+| startup prewarm / embedding warm retry | 8 Agents / 5min | provider:model success is process-lifetime deduplicated |
+| management page / audit prune | 100 rows / keep 10000, delete 500 | bounded management and operational history |
+| memory content | manual 12000 chars / automatic 2000 chars | submission-time limits; existing rows unchanged |
| `RECALL_QUERY_EMBEDDING_TIMEOUT_MS` / stale / max concurrent | 800ms / 30s / 2 | foreground query-embedding soft timeout and per-agent+model cap |
| provider deadlines | query 800ms · dimension 15s · embedding 30s · text 60s | RateLimit admission included in the absolute deadline |
| unsettled provider cap | 2 per agent/provider/model/purpose · 64 global | released only when the underlying promise settles |
diff --git a/package.json b/package.json
index 7b2491cda..b4ff1dd56 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"preinstall": "npx only-allow pnpm",
"test": "vitest",
"test:main": "vitest --config vitest.config.ts test/main",
+ "test:main:memory-perf": "vitest --config vitest.config.memory-perf.ts --run",
"test:renderer": "vitest --config vitest.config.renderer.ts test/renderer",
"test:coverage": "vitest --coverage",
"test:watch": "vitest --watch",
diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs
index 55fe5c36c..7c2dae373 100644
--- a/scripts/architecture-guard.mjs
+++ b/scripts/architecture-guard.mjs
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
+import ts from 'typescript'
const ROOT = process.cwd()
const SOURCE_EXTENSIONS = new Set([
@@ -122,6 +123,14 @@ const WINDOW_ELECTRON_PATTERN = /window\.electron\b/g
const WINDOW_API_PATTERN = /window\.api\b/g
const IPC_RENDERER_LISTENER_PATTERN =
/window\.electron(?:\?\.|\.)ipcRenderer(?:\?\.|\.)(?:on|once|addListener)\s*\(/g
+const LEGACY_MEMORY_PRESENTER_LIST_PATTERN = /\.listMemories\s*\(/g
+const LEGACY_MEMORY_PRESENTER_LIST_ALLOWLIST = new Map([
+ [path.join(ROOT, 'src/main/routes/index.ts'), 1],
+ [path.join(ROOT, 'src/main/presenter/memoryPresenter/index.ts'), 1]
+])
+const LEGACY_MEMORY_BRIDGE_ALLOWLIST = new Map([
+ [path.join(ROOT, 'src/renderer/api/MemoryClient.ts'), 1]
+])
const INLINE_IPC_CHANNEL_PATTERN =
/(?:window\.electron(?:\?\.|\.)ipcRenderer|ipcRenderer|ipcMain)(?:\?\.|\.)(?:invoke|send|on|once|handle|handleOnce|removeListener|removeAllListeners|addListener)\s*\(\s*['"`][^'"`]+['"`]/g
const INLINE_EVENTBUS_CHANNEL_PATTERN =
@@ -194,6 +203,105 @@ function countMatches(source, pattern) {
return count
}
+function scriptSourceForAst(source, filePath) {
+ if (path.extname(filePath) !== '.vue') return source
+ return [...source.matchAll(/
diff --git a/src/renderer/settings/components/MemorySettings.vue b/src/renderer/settings/components/MemorySettings.vue
index 0342ae487..36abf3933 100644
--- a/src/renderer/settings/components/MemorySettings.vue
+++ b/src/renderer/settings/components/MemorySettings.vue
@@ -200,6 +200,7 @@ const configOpen = ref(false)
const refreshToken = ref(0)
let statusRequestId = 0
let disposeUpdated: (() => void) | null = null
+let refreshTimer: ReturnType | null = null
const configReady = computed(() => resolvedAgentId.value === selectedAgentId.value)
const memoryEnabled = computed(
@@ -353,11 +354,23 @@ async function refreshSelected(): Promise {
await Promise.all([loadResolved(), loadStatus()])
}
+function queueRefreshSelected(): void {
+ if (refreshTimer) clearTimeout(refreshTimer)
+ refreshTimer = setTimeout(() => {
+ refreshTimer = null
+ void refreshSelected()
+ }, 100)
+}
+
function handleConfigSaved(): void {
void refreshSelected()
}
watch(selectedAgentId, () => {
+ if (refreshTimer) {
+ clearTimeout(refreshTimer)
+ refreshTimer = null
+ }
status.value = null
// Children already react to the agentId prop change on their own; don't
// also bump refreshToken here or they'd reload twice per switch.
@@ -382,11 +395,13 @@ onMounted(() => {
void reload(fromQuery)
disposeUpdated = memoryClient.onUpdated((payload) => {
if (payload.agentId !== selectedAgentId.value) return
- void refreshSelected()
+ queueRefreshSelected()
})
})
onUnmounted(() => {
+ if (refreshTimer) clearTimeout(refreshTimer)
+ refreshTimer = null
disposeUpdated?.()
disposeUpdated = null
})
diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json
index 5aed3a805..013b858d7 100644
--- a/src/renderer/src/i18n/da-DK/settings.json
+++ b/src/renderer/src/i18n/da-DK/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "Tilføj en hukommelse manuelt, eller lad agenten oprette en under samtalen.",
"disabledDescription": "Slå hukommelse til, før du tilføjer nye rækker for denne agent.",
"addMemory": "Tilføj hukommelse",
+ "loadMore": "Indlæs flere",
"configTitle": "Hukommelsesindstillinger",
"configDescription": "Ændringer anvendes straks for hvert felt.",
"relativeWeightsHint": "Vægte er relative; højere værdier påvirker rangeringen mere.",
diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json
index 0f76bd83b..2dfaed3fd 100644
--- a/src/renderer/src/i18n/de-DE/settings.json
+++ b/src/renderer/src/i18n/de-DE/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Füge eine Erinnerung manuell hinzu oder lasse den Assistenten im Gespräch eine erstellen.",
"disabledDescription": "Aktiviere Erinnerung, bevor du neue Einträge für diesen Assistenten hinzufügst.",
"addMemory": "Erinnerung hinzufügen",
+ "loadMore": "Mehr laden",
"configTitle": "Erinnerungseinstellungen",
"configDescription": "Änderungen werden je Feld sofort angewendet.",
"relativeWeightsHint": "Gewichtungen sind relativ; höhere Werte beeinflussen die Rangfolge stärker.",
diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json
index 4774d89be..d11c9d821 100644
--- a/src/renderer/src/i18n/en-US/settings.json
+++ b/src/renderer/src/i18n/en-US/settings.json
@@ -208,6 +208,7 @@
"searchPlaceholder": "Search memories",
"noSearchResults": "No memories match your search.",
"addMemory": "Add memory",
+ "loadMore": "Load more",
"addContentPlaceholder": "What you want this assistant to remember…",
"addCategoryLabel": "Memory category",
"addCategoryNone": "No category",
@@ -309,9 +310,9 @@
"forget": {
"title": "Forgetting",
"score": "Computed decay score",
- "materialized": "Stored decay",
+ "materialized": "Legacy stored decay snapshot",
"notRefreshed": "Not refreshed yet",
- "stale": "Stored decay is missing or differs from the computed score.",
+ "stale": "This legacy snapshot differs from the current score; archive eligibility uses the current score.",
"halfLife": "Half-life days",
"age": "Age days",
"anchor": "Decay anchor"
@@ -2909,6 +2910,7 @@
"emptyDescription": "Add a memory manually or let the agent create one during conversation.",
"disabledDescription": "Turn memory on before adding new rows for this agent.",
"addMemory": "Add memory",
+ "loadMore": "Load more",
"configTitle": "Memory settings",
"configDescription": "Changes apply immediately per field.",
"relativeWeightsHint": "Weights are relative; higher values contribute more to ranking.",
diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json
index b25f346ad..76b724486 100644
--- a/src/renderer/src/i18n/es-ES/settings.json
+++ b/src/renderer/src/i18n/es-ES/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Añade una memoria manualmente o deja que el agente cree una durante la conversación.",
"disabledDescription": "Activa la memoria antes de añadir nuevas filas para este agente.",
"addMemory": "Añadir memoria",
+ "loadMore": "Cargar más",
"configTitle": "Configuración de memoria",
"configDescription": "Los cambios se aplican inmediatamente por campo.",
"relativeWeightsHint": "Los pesos son relativos; los valores más altos influyen más en la ordenación.",
diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json
index 573156d12..65f9c514f 100644
--- a/src/renderer/src/i18n/fa-IR/settings.json
+++ b/src/renderer/src/i18n/fa-IR/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "یک حافظه را دستی اضافه کنید یا اجازه دهید عامل هنگام گفتوگو آن را بسازد.",
"disabledDescription": "پیش از افزودن ردیفهای جدید برای این عامل، حافظه را روشن کنید.",
"addMemory": "افزودن حافظه",
+ "loadMore": "بارگذاری بیشتر",
"configTitle": "تنظیمات حافظه",
"configDescription": "تغییرات هر فیلد بلافاصله اعمال میشود.",
"relativeWeightsHint": "وزنها نسبی هستند؛ مقدارهای بالاتر اثر بیشتری بر رتبهبندی دارند.",
diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json
index b09464109..5aa02fc9d 100644
--- a/src/renderer/src/i18n/fr-FR/settings.json
+++ b/src/renderer/src/i18n/fr-FR/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "Ajoutez une mémoire manuellement ou laissez l’agent en créer une pendant la conversation.",
"disabledDescription": "Activez la mémoire avant d’ajouter de nouvelles entrées pour cet agent.",
"addMemory": "Ajouter une mémoire",
+ "loadMore": "Charger plus",
"configTitle": "Paramètres de mémoire",
"configDescription": "Les modifications s’appliquent immédiatement champ par champ.",
"relativeWeightsHint": "Les pondérations sont relatives ; les valeurs plus élevées influencent davantage le classement.",
diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json
index 960e8a7f4..ddc429fbb 100644
--- a/src/renderer/src/i18n/he-IL/settings.json
+++ b/src/renderer/src/i18n/he-IL/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "אפשר להוסיף זיכרון ידנית או לתת לסוכן ליצור אחד במהלך השיחה.",
"disabledDescription": "יש להפעיל זיכרון לפני הוספת שורות חדשות עבור הסוכן הזה.",
"addMemory": "הוספת זיכרון",
+ "loadMore": "טעינת עוד",
"configTitle": "הגדרות זיכרון",
"configDescription": "שינויים מוחלים מיד בכל שדה.",
"relativeWeightsHint": "המשקלים יחסיים; ערכים גבוהים יותר משפיעים יותר על הדירוג.",
diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json
index 8b81bcbd4..f9481dead 100644
--- a/src/renderer/src/i18n/id-ID/settings.json
+++ b/src/renderer/src/i18n/id-ID/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Tambahkan memori secara manual atau biarkan agen membuatnya selama percakapan.",
"disabledDescription": "Aktifkan memori sebelum menambahkan baris baru untuk agen ini.",
"addMemory": "Tambah memori",
+ "loadMore": "Muat lebih banyak",
"configTitle": "Pengaturan memori",
"configDescription": "Perubahan diterapkan langsung untuk tiap bidang.",
"relativeWeightsHint": "Bobot bersifat relatif; nilai yang lebih tinggi lebih memengaruhi peringkat.",
diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json
index 93d53141e..a4f72e004 100644
--- a/src/renderer/src/i18n/it-IT/settings.json
+++ b/src/renderer/src/i18n/it-IT/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Aggiungi una memoria manualmente o lascia che l’agente ne crei una durante la conversazione.",
"disabledDescription": "Attiva la memoria prima di aggiungere nuove righe per questo agente.",
"addMemory": "Aggiungi memoria",
+ "loadMore": "Carica altro",
"configTitle": "Impostazioni memoria",
"configDescription": "Le modifiche si applicano immediatamente per ogni campo.",
"relativeWeightsHint": "I pesi sono relativi; valori più alti incidono maggiormente sull’ordinamento.",
diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json
index b75eeb22c..d505da17e 100644
--- a/src/renderer/src/i18n/ja-JP/settings.json
+++ b/src/renderer/src/i18n/ja-JP/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "手動でメモリを追加するか、会話中にエージェントへ作成させます。",
"disabledDescription": "このエージェントに新しい行を追加する前にメモリをオンにしてください。",
"addMemory": "メモリを追加",
+ "loadMore": "さらに読み込む",
"configTitle": "メモリ設定",
"configDescription": "変更はフィールドごとにすぐ適用されます。",
"relativeWeightsHint": "重みは相対値です。値が高いほどランキングへの影響が大きくなります。",
diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json
index 3bfbef197..aee0b2203 100644
--- a/src/renderer/src/i18n/ko-KR/settings.json
+++ b/src/renderer/src/i18n/ko-KR/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "메모리를 수동으로 추가하거나 대화 중 에이전트가 만들도록 하세요.",
"disabledDescription": "이 에이전트에 새 행을 추가하기 전에 메모리를 켜세요.",
"addMemory": "메모리 추가",
+ "loadMore": "더 불러오기",
"configTitle": "메모리 설정",
"configDescription": "변경 사항은 필드별로 즉시 적용됩니다.",
"relativeWeightsHint": "가중치는 상대값입니다. 값이 높을수록 순위에 더 크게 반영됩니다.",
diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json
index 7814bb903..9685dbe9b 100644
--- a/src/renderer/src/i18n/ms-MY/settings.json
+++ b/src/renderer/src/i18n/ms-MY/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Tambah memori secara manual atau biarkan ejen menciptanya semasa perbualan.",
"disabledDescription": "Hidupkan memori sebelum menambah baris baharu untuk ejen ini.",
"addMemory": "Tambah memori",
+ "loadMore": "Muat lagi",
"configTitle": "Tetapan memori",
"configDescription": "Perubahan digunakan serta-merta untuk setiap medan.",
"relativeWeightsHint": "Wajaran adalah relatif; nilai lebih tinggi lebih mempengaruhi kedudukan.",
diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json
index efa3c3328..814280c91 100644
--- a/src/renderer/src/i18n/pl-PL/settings.json
+++ b/src/renderer/src/i18n/pl-PL/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Dodaj pamięć ręcznie albo pozwól agentowi utworzyć ją podczas rozmowy.",
"disabledDescription": "Włącz pamięć, zanim dodasz nowe wpisy dla tego agenta.",
"addMemory": "Dodaj pamięć",
+ "loadMore": "Wczytaj więcej",
"configTitle": "Ustawienia pamięci",
"configDescription": "Zmiany są stosowane natychmiast dla każdego pola.",
"relativeWeightsHint": "Wagi są względne; wyższe wartości mocniej wpływają na ranking.",
diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json
index b19c5d90d..54d4c6324 100644
--- a/src/renderer/src/i18n/pt-BR/settings.json
+++ b/src/renderer/src/i18n/pt-BR/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "Adicione uma memória manualmente ou deixe o agente criar uma durante a conversa.",
"disabledDescription": "Ative a memória antes de adicionar novas linhas para este agente.",
"addMemory": "Adicionar memória",
+ "loadMore": "Carregar mais",
"configTitle": "Configurações de memória",
"configDescription": "As alterações são aplicadas imediatamente por campo.",
"relativeWeightsHint": "Os pesos são relativos; valores mais altos influenciam mais a classificação.",
diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json
index 314289c31..545eb3ea5 100644
--- a/src/renderer/src/i18n/ru-RU/settings.json
+++ b/src/renderer/src/i18n/ru-RU/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "Добавьте память вручную или позвольте агенту создать ее во время беседы.",
"disabledDescription": "Включите память перед добавлением новых записей для этого агента.",
"addMemory": "Добавить память",
+ "loadMore": "Загрузить ещё",
"configTitle": "Настройки памяти",
"configDescription": "Изменения применяются сразу для каждого поля.",
"relativeWeightsHint": "Веса относительные; более высокие значения сильнее влияют на ранжирование.",
diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json
index db4680037..ef6415f99 100644
--- a/src/renderer/src/i18n/tr-TR/settings.json
+++ b/src/renderer/src/i18n/tr-TR/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Elle bellek ekleyin veya konuşma sırasında ajanın oluşturmasına izin verin.",
"disabledDescription": "Bu ajan için yeni satırlar eklemeden önce belleği açın.",
"addMemory": "Bellek ekle",
+ "loadMore": "Daha fazla yükle",
"configTitle": "Bellek ayarları",
"configDescription": "Değişiklikler her alan için hemen uygulanır.",
"relativeWeightsHint": "Ağırlıklar görecelidir; daha yüksek değerler sıralamayı daha fazla etkiler.",
diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json
index da23f1e08..8540d774d 100644
--- a/src/renderer/src/i18n/vi-VN/settings.json
+++ b/src/renderer/src/i18n/vi-VN/settings.json
@@ -2880,6 +2880,7 @@
"emptyDescription": "Thêm bộ nhớ thủ công hoặc để tác nhân tạo trong khi trò chuyện.",
"disabledDescription": "Bật bộ nhớ trước khi thêm hàng mới cho tác nhân này.",
"addMemory": "Thêm bộ nhớ",
+ "loadMore": "Tải thêm",
"configTitle": "Cài đặt bộ nhớ",
"configDescription": "Thay đổi được áp dụng ngay cho từng trường.",
"relativeWeightsHint": "Trọng số là tương đối; giá trị cao hơn ảnh hưởng nhiều hơn đến xếp hạng.",
diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json
index 981454bc1..e4f5cdd43 100644
--- a/src/renderer/src/i18n/zh-CN/settings.json
+++ b/src/renderer/src/i18n/zh-CN/settings.json
@@ -309,9 +309,9 @@
"forget": {
"title": "遗忘",
"score": "现算衰减分",
- "materialized": "已存衰减分",
+ "materialized": "旧版存储衰减快照",
"notRefreshed": "尚未刷新",
- "stale": "已存值缺失或与现算分不一致。",
+ "stale": "旧版快照与当前结果不一致;归档判断使用当前实时计算值。",
"halfLife": "半衰天数",
"age": "已衰减天数",
"anchor": "衰减锚点"
@@ -2909,6 +2909,7 @@
"emptyDescription": "可以手动添加记忆,也可以让智能体在对话中自动创建。",
"disabledDescription": "为此智能体开启记忆后才能新增内容。",
"addMemory": "新增记忆",
+ "loadMore": "加载更多",
"configTitle": "记忆设置",
"configDescription": "每个字段修改后即时生效。",
"relativeWeightsHint": "权重是相对值;值越高,对排序影响越大。",
diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json
index 479079a99..d967de861 100644
--- a/src/renderer/src/i18n/zh-HK/settings.json
+++ b/src/renderer/src/i18n/zh-HK/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "可以手動新增記憶,也可以讓代理在對話中自動建立。",
"disabledDescription": "為此代理開啟記憶後才能新增內容。",
"addMemory": "新增記憶",
+ "loadMore": "載入更多",
"configTitle": "記憶設定",
"configDescription": "每個欄位修改後即時生效。",
"relativeWeightsHint": "權重是相對值;值越高,對排序影響越大。",
diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json
index f92bdc143..814df9db3 100644
--- a/src/renderer/src/i18n/zh-TW/settings.json
+++ b/src/renderer/src/i18n/zh-TW/settings.json
@@ -2889,6 +2889,7 @@
"emptyDescription": "可以手動新增記憶,也可以讓代理在對話中自動建立。",
"disabledDescription": "為此代理開啟記憶後才能新增內容。",
"addMemory": "新增記憶",
+ "loadMore": "載入更多",
"configTitle": "記憶設定",
"configDescription": "每個欄位修改後立即生效。",
"relativeWeightsHint": "權重是相對值;值越高,對排序影響越大。",
diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts
index 4f4351fe5..cf452fa10 100644
--- a/src/shared/contracts/routes.ts
+++ b/src/shared/contracts/routes.ts
@@ -43,6 +43,7 @@ import {
memoryListConflictsRoute,
memoryListPersonaDraftsRoute,
memoryListPersonaVersionsRoute,
+ memoryPageRoute,
memoryListRoute,
memoryListViewManifestsRoute,
memoryRejectPersonaDraftRoute,
@@ -870,6 +871,7 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = {
[databaseSecurityDisableRoute.name]: databaseSecurityDisableRoute,
[databaseSecurityRepairSchemaRoute.name]: databaseSecurityRepairSchemaRoute,
[memoryListRoute.name]: memoryListRoute,
+ [memoryPageRoute.name]: memoryPageRoute,
[memorySearchRoute.name]: memorySearchRoute,
[memoryAddRoute.name]: memoryAddRoute,
[memoryUpdateRoute.name]: memoryUpdateRoute,
diff --git a/src/shared/contracts/routes/memory.routes.ts b/src/shared/contracts/routes/memory.routes.ts
index 289241242..425c2c506 100644
--- a/src/shared/contracts/routes/memory.routes.ts
+++ b/src/shared/contracts/routes/memory.routes.ts
@@ -2,11 +2,19 @@ import { z } from 'zod'
import { defineRouteContract } from '../common'
import {
AGENT_MEMORY_CATEGORIES,
+ AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS,
AGENT_MEMORY_HEALTH_CATEGORY_KEYS,
AGENT_MEMORY_HEALTH_KIND_KEYS,
AGENT_MEMORY_HEALTH_STATUS_KEYS,
AGENT_MEMORY_HEALTH_TOP_KIND_KEYS
} from '../../types/agent-memory'
+import { unicodeCodePointLength } from '../../lib/unicodeText'
+
+const ManualMemoryContentSchema = z
+ .string()
+ .refine((content) => unicodeCodePointLength(content) <= AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS, {
+ message: `content must be at most ${AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS} Unicode code points`
+ })
/** URL-safe agent ids, matching the main-process memory storage guard. */
const AgentIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/, 'invalid agentId')
@@ -58,7 +66,9 @@ export const MemoryUpdateResultSchema = z.object({
memoryId: z.string().optional(),
supersededId: z.string().optional(),
// Only populated on a 'noop' outcome, explaining why the edit was refused/ignored.
- reason: z.enum(['not-editable', 'conflict', 'suppressed', 'duplicate', 'empty']).optional()
+ reason: z
+ .enum(['not-editable', 'conflict', 'suppressed', 'duplicate', 'empty', 'content-too-large'])
+ .optional()
})
export const MemoryStatusSchema = z.object({
@@ -327,12 +337,92 @@ export const MemoryViewManifestSchema = z.object({
createdAt: z.number()
})
+export const MEMORY_PAGE_DEFAULT_LIMIT = 100
+export const MEMORY_PAGE_MAX_LIMIT = 100
+
+export const MemoryPageCursorV1Schema = z
+ .object({
+ v: z.literal(1),
+ createdAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
+ id: z.string().min(1).max(512)
+ })
+ .strict()
+
+export type MemoryPageCursorV1 = z.infer
+
+function encodeBase64Url(value: string): string {
+ const bytes = new TextEncoder().encode(value)
+ let binary = ''
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
+}
+
+function decodeBase64Url(value: string): string {
+ if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new Error('invalid base64url cursor')
+ const remainder = value.length % 4
+ if (remainder === 1) throw new Error('invalid base64url cursor length')
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - remainder) % 4)
+ const binary = atob(padded)
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
+ if (encodeBase64Url(decoded) !== value) throw new Error('non-canonical base64url cursor')
+ return decoded
+}
+
+export function encodeMemoryPageCursor(cursor: MemoryPageCursorV1): string {
+ return encodeBase64Url(JSON.stringify(MemoryPageCursorV1Schema.parse(cursor)))
+}
+
+export function decodeMemoryPageCursor(cursor: string): MemoryPageCursorV1 {
+ try {
+ return MemoryPageCursorV1Schema.parse(JSON.parse(decodeBase64Url(cursor)))
+ } catch {
+ throw new Error('invalid memory page cursor')
+ }
+}
+
+const MemoryPageCursorSchema = z
+ .string()
+ .min(1)
+ .max(2048)
+ .refine(
+ (cursor) => {
+ try {
+ decodeMemoryPageCursor(cursor)
+ return true
+ } catch {
+ return false
+ }
+ },
+ { message: 'invalid memory page cursor' }
+ )
+
+/** @deprecated Use memoryPageRoute for bounded management reads. */
export const memoryListRoute = defineRouteContract({
name: 'memory.list',
input: z.object({ agentId: AgentIdSchema }),
output: z.object({ memories: z.array(MemoryItemSchema) })
})
+export const memoryPageRoute = defineRouteContract({
+ name: 'memory.page',
+ input: z.object({
+ agentId: AgentIdSchema,
+ cursor: MemoryPageCursorSchema.optional(),
+ limit: z
+ .number()
+ .int()
+ .positive()
+ .max(MEMORY_PAGE_MAX_LIMIT)
+ .optional()
+ .default(MEMORY_PAGE_DEFAULT_LIMIT)
+ }),
+ output: z.object({
+ items: z.array(MemoryItemSchema).max(MEMORY_PAGE_MAX_LIMIT),
+ nextCursor: MemoryPageCursorSchema.nullable()
+ })
+})
+
export const memoryGetStatusRoute = defineRouteContract({
name: 'memory.getStatus',
input: z.object({ agentId: AgentIdSchema }),
@@ -378,7 +468,9 @@ export const memoryAddRoute = defineRouteContract({
name: 'memory.add',
input: z.object({
agentId: AgentIdSchema,
- content: z.string().min(1),
+ content: ManualMemoryContentSchema.refine((content) => content.length > 0, {
+ message: 'content must not be empty'
+ }),
kind: z.enum(['episodic', 'semantic']).optional(),
category: z.enum(AGENT_MEMORY_CATEGORIES).optional(),
importance: z.number().min(0).max(1).optional(),
@@ -394,7 +486,7 @@ export const memoryUpdateRoute = defineRouteContract({
memoryId: z.string().min(1),
patch: z
.object({
- content: z.string().optional(),
+ content: ManualMemoryContentSchema.optional(),
category: z.enum(AGENT_MEMORY_CATEGORIES).nullable().optional(),
importance: z.number().min(0).max(1).optional()
})
@@ -543,6 +635,7 @@ export const memorySetPersonaAnchorRoute = defineRouteContract({
})
export type MemoryItem = z.infer
+export type MemoryPage = z.infer
export type MemorySearchResult = z.infer
export type MemoryAddResult = z.infer
export type MemoryUpdateResult = z.infer
diff --git a/src/shared/lib/unicodeText.ts b/src/shared/lib/unicodeText.ts
new file mode 100644
index 000000000..a1bed5d5d
--- /dev/null
+++ b/src/shared/lib/unicodeText.ts
@@ -0,0 +1,9 @@
+export function unicodeCodePointLength(value: string): number {
+ return Array.from(value).length
+}
+
+export function truncateUnicodeCodePoints(value: string, maxCodePoints: number): string {
+ const limit = Math.max(0, Math.floor(maxCodePoints))
+ const codePoints = Array.from(value)
+ return codePoints.length <= limit ? value : codePoints.slice(0, limit).join('')
+}
diff --git a/src/shared/types/agent-memory.ts b/src/shared/types/agent-memory.ts
index eb931c2ee..51ae22043 100644
--- a/src/shared/types/agent-memory.ts
+++ b/src/shared/types/agent-memory.ts
@@ -6,6 +6,9 @@ export const AGENT_MEMORY_CATEGORIES = [
'anti_pattern'
] as const
+export const AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS = 12_000
+export const AGENT_MEMORY_AUTO_CONTENT_MAX_CHARS = 2_000
+
export type AgentMemoryCategory = (typeof AGENT_MEMORY_CATEGORIES)[number]
export const AGENT_MEMORY_HEALTH_KIND_KEYS = [
diff --git a/test/main/lib/asyncSemaphore.test.ts b/test/main/lib/asyncSemaphore.test.ts
new file mode 100644
index 000000000..3922c33dc
--- /dev/null
+++ b/test/main/lib/asyncSemaphore.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+
+import { AsyncSemaphore } from '@/lib/asyncSemaphore'
+
+describe('AsyncSemaphore', () => {
+ it('runs at most two tasks and resumes waiters in FIFO order', async () => {
+ const semaphore = new AsyncSemaphore(2)
+ const starts: number[] = []
+ const releases: Array<() => void> = []
+ const tasks = Array.from({ length: 3 }, (_, index) =>
+ semaphore.run(async () => {
+ starts.push(index)
+ await new Promise((resolve) => releases.push(resolve))
+ })
+ )
+ await Promise.resolve()
+ expect(starts).toEqual([0, 1])
+ releases.shift()?.()
+ await tasks[0]
+ await Promise.resolve()
+ expect(starts).toEqual([0, 1, 2])
+ releases.splice(0).forEach((release) => release())
+ await Promise.all(tasks)
+ })
+})
diff --git a/test/main/nativeSqliteHarness.ts b/test/main/nativeSqliteHarness.ts
new file mode 100644
index 000000000..7b1cbaecc
--- /dev/null
+++ b/test/main/nativeSqliteHarness.ts
@@ -0,0 +1,59 @@
+import { describe, it } from 'vitest'
+
+const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null)
+
+export const Database = sqliteModule?.default
+export const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1'
+
+export let nativeSqliteAvailable = false
+let nativeSqliteUnavailableReason =
+ 'better-sqlite3-multiple-ciphers is unavailable for the current Node ABI'
+if (Database) {
+ try {
+ const smokeDb = new Database(':memory:')
+ smokeDb.close()
+ nativeSqliteAvailable = true
+ } catch (error) {
+ nativeSqliteUnavailableReason = error instanceof Error ? error.message : String(error)
+ }
+}
+
+export function nativeSqliteDescribeIf(
+ dependenciesAvailable = true,
+ dependencyUnavailableReason = 'native SQLite test dependency is unavailable'
+): typeof describe {
+ if (nativeSqliteAvailable && dependenciesAvailable) return describe
+ if (!requireNativeSqlite) return describe.skip
+ const reason = nativeSqliteAvailable ? dependencyUnavailableReason : nativeSqliteUnavailableReason
+ return ((name: string, _suite: () => void) =>
+ describe(name, () => {
+ it('requires native SQLite support', () => {
+ throw new Error(reason)
+ })
+ })) as typeof describe
+}
+
+export function nativeSqliteItIf(
+ dependenciesAvailable = true,
+ dependencyUnavailableReason = 'native SQLite test dependency is unavailable'
+): typeof it {
+ if (nativeSqliteAvailable && dependenciesAvailable) return it
+ if (!requireNativeSqlite) return it.skip
+ const reason = nativeSqliteAvailable ? dependencyUnavailableReason : nativeSqliteUnavailableReason
+ return ((name: string, _test: () => unknown, timeout?: number) =>
+ it(
+ name,
+ () => {
+ throw new Error(reason)
+ },
+ timeout
+ )) as typeof it
+}
+
+export const describeIfNativeSqlite = nativeSqliteDescribeIf()
+export const itIfNativeSqlite = nativeSqliteItIf()
+
+export function requireDatabase(): NonNullable {
+ if (!Database || !nativeSqliteAvailable) throw new Error(nativeSqliteUnavailableReason)
+ return Database
+}
diff --git a/test/main/performance/memory/fixtures.ts b/test/main/performance/memory/fixtures.ts
new file mode 100644
index 000000000..0e65b075c
--- /dev/null
+++ b/test/main/performance/memory/fixtures.ts
@@ -0,0 +1,65 @@
+export interface SyntheticMemoryRow {
+ id: string
+ agentId: string
+ content: string
+ createdAt: number
+}
+
+export interface SyntheticTapeEntry {
+ id: number
+ sessionId: string
+ messageId: string
+ orderSeq: number
+ content: string
+}
+
+export interface SyntheticDecisionCandidate {
+ candidateIndex: number
+ content: string
+ neighbors: Array<{ id: string; content: string }>
+}
+
+export interface SyntheticAgent {
+ id: string
+ embedding: { providerId: string; modelId: string }
+}
+
+export function buildMemoryFixture(count: number, agentId = 'agent-0'): SyntheticMemoryRow[] {
+ return Array.from({ length: count }, (_, index) => ({
+ id: `memory-${index.toString().padStart(6, '0')}`,
+ agentId,
+ content: index % 10 === 0 ? `redis project fact ${index}` : `synthetic memory ${index}`,
+ createdAt: index + 1
+ }))
+}
+
+export function buildTapeFixture(count: number, sessionId = 'session-0'): SyntheticTapeEntry[] {
+ return Array.from({ length: count }, (_, index) => ({
+ id: index + 1,
+ sessionId,
+ messageId: `message-${index.toString().padStart(6, '0')}`,
+ orderSeq: index + 1,
+ content: `synthetic tape entry ${index}`
+ }))
+}
+
+export function buildAgentFixture(count: number): SyntheticAgent[] {
+ return Array.from({ length: count }, (_, index) => ({
+ id: `agent-${index.toString().padStart(3, '0')}`,
+ embedding: { providerId: 'shared-provider', modelId: 'shared-model' }
+ }))
+}
+
+export function buildDecisionFixture(
+ candidateCount = 8,
+ neighborsPerCandidate = 3
+): SyntheticDecisionCandidate[] {
+ return Array.from({ length: candidateCount }, (_, candidateIndex) => ({
+ candidateIndex,
+ content: `topic${candidateIndex} shared durable fact`,
+ neighbors: Array.from({ length: neighborsPerCandidate }, (_, neighborIndex) => ({
+ id: `neighbor-${candidateIndex}-${neighborIndex}`,
+ content: `topic${candidateIndex} shared durable fact neighbor ${neighborIndex}`
+ }))
+ }))
+}
diff --git a/test/main/performance/memory/maintenanceScale.perf.ts b/test/main/performance/memory/maintenanceScale.perf.ts
new file mode 100644
index 000000000..8477bcecd
--- /dev/null
+++ b/test/main/performance/memory/maintenanceScale.perf.ts
@@ -0,0 +1,71 @@
+import { expect } from 'vitest'
+
+import { AgentMemoryTable } from '@/presenter/sqlitePresenter/tables/agentMemory'
+
+import { describeIfNativeSqlite, requireDatabase } from '../../nativeSqliteHarness'
+
+describeIfNativeSqlite('Agent Memory #28 maintenance scale', () => {
+ it('uses bounded indexes and one sibling transition statement at 50k/1k scale', () => {
+ const DatabaseCtor = requireDatabase()
+ const statements: string[] = []
+ const db = new DatabaseCtor(':memory:', {
+ verbose: (statement: string) => statements.push(statement)
+ })
+ try {
+ const table = new AgentMemoryTable(db)
+ table.createTable()
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (
+ id, agent_id, kind, content, importance, status, created_at,
+ conflict_state, conflict_with
+ ) VALUES (?, 'maintenance', 'semantic', ?, ?, ?, ?, ?, ?)`
+ )
+ db.transaction(() => {
+ for (let index = 0; index < 50_000; index += 1) {
+ insert.run(
+ `row-${index}`,
+ 'bounded row',
+ (index % 10) / 10,
+ 'embedded',
+ index,
+ null,
+ null
+ )
+ }
+ insert.run('target', 'target', 1, 'embedded', 50_001, 'challenged', null)
+ insert.run('winner', 'winner', 1, 'conflicted', 50_002, null, 'target')
+ for (let index = 0; index < 1_000; index += 1) {
+ insert.run(`sibling-${index}`, 'sibling', 1, 'conflicted', 50_003 + index, null, 'target')
+ }
+ })()
+ db.exec('ANALYZE')
+
+ const plan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT *
+ FROM agent_memory INDEXED BY idx_agent_memory_cognitive_top_v2
+ WHERE agent_id = 'maintenance'
+ AND superseded_by IS NULL
+ AND status NOT IN ('archived', 'conflicted')
+ AND kind IN ('episodic', 'semantic', 'reflection')
+ AND kind IN ('episodic', 'semantic')
+ ORDER BY importance DESC, created_at DESC, id DESC
+ LIMIT 20`
+ )
+ .all() as Array<{ detail: string }>
+ expect(plan.some((row) => row.detail.includes('idx_agent_memory_cognitive_top_v2'))).toBe(
+ true
+ )
+ expect(plan.some((row) => row.detail.includes('TEMP B-TREE FOR ORDER BY'))).toBe(false)
+
+ statements.length = 0
+ expect(
+ table.retireConflictSiblings('maintenance', 'target', 'winner', 'winner', Date.now())
+ ).toBe(1_000)
+ expect(statements).toHaveLength(1)
+ } finally {
+ db.close()
+ }
+ }, 30_000)
+})
diff --git a/test/main/performance/memory/memoryBaseline.perf.ts b/test/main/performance/memory/memoryBaseline.perf.ts
new file mode 100644
index 000000000..9610ed7d3
--- /dev/null
+++ b/test/main/performance/memory/memoryBaseline.perf.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildAgentFixture, buildMemoryFixture, buildTapeFixture } from './fixtures'
+import { createMemoryPerfObserver, summarizeDurations } from './performanceObserver'
+
+describe('Agent Memory #28 performance baseline harness', () => {
+ it('builds the decision-complete deterministic scale fixtures', () => {
+ expect([1_000, 10_000, 50_000].map((size) => buildMemoryFixture(size).length)).toEqual([
+ 1_000, 10_000, 50_000
+ ])
+ expect([10_000, 100_000].map((size) => buildTapeFixture(size).length)).toEqual([
+ 10_000, 100_000
+ ])
+ expect(buildAgentFixture(100)).toHaveLength(100)
+ expect(
+ new Set(buildAgentFixture(100).map((agent) => JSON.stringify(agent.embedding))).size
+ ).toBe(1)
+ })
+
+ it('keeps the linear legacy LIKE fixture available only as a relative timing baseline', () => {
+ const rows = buildMemoryFixture(50_000)
+ const matches = rows.filter((row) => row.content.includes('redis'))
+
+ expect(matches).toHaveLength(5_000)
+ })
+
+ it('keeps counters no-op by default and tracks enabled high-water marks', () => {
+ const disabled = createMemoryPerfObserver()
+ disabled.increment('providerCalls', 8)
+ disabled.observe('openStores', 100)
+ expect(disabled.snapshot().counters.providerCalls).toBe(0)
+ expect(disabled.snapshot().highWaterMarks.openStores).toBe(0)
+
+ const enabled = createMemoryPerfObserver(true)
+ enabled.observe('openStores', 4)
+ enabled.observe('openStores', 2)
+ enabled.observe('openStores', 8)
+ expect(enabled.snapshot().highWaterMarks.openStores).toBe(8)
+ })
+
+ it('reports deterministic median and p95 samples without enforcing wall-clock thresholds', () => {
+ expect(summarizeDurations([5, 1, 4, 2, 3])).toEqual({ median: 3, p95: 5 })
+ expect(summarizeDurations([])).toEqual({ median: 0, p95: 0 })
+ })
+})
diff --git a/test/main/performance/memory/performanceObserver.ts b/test/main/performance/memory/performanceObserver.ts
new file mode 100644
index 000000000..3d82c0831
--- /dev/null
+++ b/test/main/performance/memory/performanceObserver.ts
@@ -0,0 +1,81 @@
+import {
+ MEMORY_PERF_COUNTER_NAMES,
+ MEMORY_PERF_HIGH_WATER_NAMES,
+ type MemoryPerfCounterName,
+ type MemoryPerfHighWaterName,
+ type MemoryPerfObserver as ProductionMemoryPerfObserver
+} from '@/presenter/memoryPresenter/ports'
+
+export { MEMORY_PERF_COUNTER_NAMES, MEMORY_PERF_HIGH_WATER_NAMES }
+
+export interface MemoryPerfSnapshot {
+ counters: Record
+ highWaterMarks: Record
+}
+
+export interface MemoryPerfObserver extends ProductionMemoryPerfObserver {
+ snapshot(): MemoryPerfSnapshot
+ reset(): void
+}
+
+function emptySnapshot(): MemoryPerfSnapshot {
+ return {
+ counters: {
+ sqliteStatements: 0,
+ repositoryCalls: 0,
+ materializedRows: 0,
+ providerCalls: 0,
+ duckDbStatements: 0
+ },
+ highWaterMarks: {
+ openStores: 0,
+ activeLeases: 0,
+ queueDepth: 0,
+ cacheEntries: 0
+ }
+ }
+}
+
+const NOOP_SNAPSHOT = Object.freeze(emptySnapshot())
+
+export const NOOP_MEMORY_PERF_OBSERVER: MemoryPerfObserver = Object.freeze({
+ increment: () => undefined,
+ observe: () => undefined,
+ snapshot: () => ({
+ counters: { ...NOOP_SNAPSHOT.counters },
+ highWaterMarks: { ...NOOP_SNAPSHOT.highWaterMarks }
+ }),
+ reset: () => undefined
+})
+
+export function createMemoryPerfObserver(enabled = false): MemoryPerfObserver {
+ if (!enabled) return NOOP_MEMORY_PERF_OBSERVER
+
+ let state = emptySnapshot()
+ return {
+ increment(name, amount = 1) {
+ state.counters[name] += amount
+ },
+ observe(name, value) {
+ state.highWaterMarks[name] = Math.max(state.highWaterMarks[name], value)
+ },
+ snapshot() {
+ return {
+ counters: { ...state.counters },
+ highWaterMarks: { ...state.highWaterMarks }
+ }
+ },
+ reset() {
+ state = emptySnapshot()
+ }
+ }
+}
+
+export function summarizeDurations(samples: readonly number[]): { median: number; p95: number } {
+ if (samples.length === 0) return { median: 0, p95: 0 }
+ const sorted = [...samples].sort((left, right) => left - right)
+ return {
+ median: sorted[Math.floor((sorted.length - 1) * 0.5)],
+ p95: sorted[Math.ceil(0.95 * sorted.length) - 1]
+ }
+}
diff --git a/test/main/performance/memory/recallScale.perf.ts b/test/main/performance/memory/recallScale.perf.ts
new file mode 100644
index 000000000..bf03c3913
--- /dev/null
+++ b/test/main/performance/memory/recallScale.perf.ts
@@ -0,0 +1,188 @@
+import { expect } from 'vitest'
+
+import { AgentMemoryTable } from '@/presenter/sqlitePresenter/tables/agentMemory'
+
+import { buildMemoryFixture } from './fixtures'
+import { createMemoryPerfObserver } from './performanceObserver'
+import { describeIfNativeSqlite, requireDatabase } from '../../nativeSqliteHarness'
+import { measurePairedPerformance, reportPerformance } from './timing'
+
+const RECALL_GROWTH_ADVANTAGE_RATIO = 0.65
+
+type AgentMemorySearchInternals = {
+ searchLike(agentId: string, terms: string[], limit: number, matchMode: 'all' | 'any'): unknown[]
+}
+
+describeIfNativeSqlite('Agent Memory #28 recall scale', () => {
+ it('keeps safe-trigram recall indexed through 1k, 10k and 50k rows', () => {
+ const DatabaseCtor = requireDatabase()
+ const observer = createMemoryPerfObserver(true)
+ const db = new DatabaseCtor(':memory:', {
+ verbose: () => observer.increment('sqliteStatements')
+ })
+ try {
+ const table = new AgentMemoryTable(db, observer)
+ table.createTable()
+ const ftsMeta = db
+ .prepare(
+ `SELECT schema_version, policy_version, tokenizer
+ FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'`
+ )
+ .get() as { schema_version: number; policy_version: number; tokenizer: string } | undefined
+ expect(ftsMeta).toEqual({ schema_version: 4, policy_version: 2, tokenizer: 'trigram' })
+
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (
+ id, agent_id, kind, content, status, created_at, importance
+ ) VALUES (?, ?, 'semantic', ?, 'embedded', ?, ?)`
+ )
+ const sizes = [1_000, 10_000, 50_000] as const
+ const realisticPayload = ' bounded durable memory context'.repeat(64)
+ const insertAll = db.transaction(() => {
+ for (const size of sizes) {
+ const agentId = `recall-${size}`
+ for (const row of buildMemoryFixture(size, agentId)) {
+ const content =
+ row.createdAt > size - 100
+ ? `redis project tail fact ${row.createdAt}${realisticPayload}`
+ : `${row.content.replace('redis project fact', 'synthetic memory')}${realisticPayload}`
+ insert.run(
+ `${agentId}-${row.id}`,
+ agentId,
+ content,
+ row.createdAt,
+ (row.createdAt % 10) / 10
+ )
+ }
+ }
+ })
+ insertAll()
+ db.prepare(
+ `UPDATE agent_memory_fts_meta
+ SET mutation_generation = mutation_generation + 1
+ WHERE key = 'agent_memory_fts'`
+ ).run()
+ const searchTable = new AgentMemoryTable(db, observer)
+ searchTable.createTable()
+
+ const reports = new Map()
+ for (const size of sizes) {
+ const agentId = `recall-${size}`
+ observer.reset()
+ const indexedResult = searchTable.searchWithStrategy(agentId, 'redis project', 20, {
+ matchMode: 'all'
+ })
+ const indexedSnapshot = observer.snapshot()
+ expect(indexedResult.strategy).toBe('fts-only')
+ expect(indexedResult.rows.length).toBeGreaterThanOrEqual(20)
+ expect(indexedResult.rows.length).toBeLessThanOrEqual(40)
+ expect(indexedSnapshot.counters.sqliteStatements).toBeLessThanOrEqual(2)
+ expect(indexedSnapshot.counters.materializedRows).toBeLessThanOrEqual(40)
+
+ let stayedFtsOnly = indexedResult.strategy === 'fts-only'
+ const paired = measurePairedPerformance(
+ `recall-fts-${size}`,
+ `recall-like-${size}`,
+ size,
+ () => {
+ const result = searchTable.searchWithStrategy(agentId, 'redis project', 20, {
+ matchMode: 'all'
+ })
+ stayedFtsOnly &&= result.strategy === 'fts-only'
+ },
+ () => {
+ ;(searchTable as unknown as AgentMemorySearchInternals).searchLike(
+ agentId,
+ ['redis', 'project'],
+ 20,
+ 'all'
+ )
+ }
+ )
+ expect(stayedFtsOnly).toBe(true)
+ reportPerformance(paired.primary)
+ reportPerformance(paired.baseline)
+ reports.set(size, {
+ indexed: paired.primary.medianMs,
+ legacy: paired.baseline.medianMs
+ })
+ }
+
+ const medium = reports.get(10_000)
+ const largest = reports.get(50_000)
+ expect(medium).toBeDefined()
+ expect(largest).toBeDefined()
+ const indexedGrowth = largest!.indexed / medium!.indexed
+ const legacyGrowth = largest!.legacy / medium!.legacy
+ const largeScaleRatio = largest!.indexed / largest!.legacy
+ const growthRatio = indexedGrowth / legacyGrowth
+ console.info(
+ `[memory-perf] ${JSON.stringify({
+ scenario: 'recall-scale-comparison',
+ fromSize: 10_000,
+ toSize: 50_000,
+ indexedGrowth,
+ legacyGrowth,
+ growthRatio,
+ largeScaleRatio
+ })}`
+ )
+ expect(indexedGrowth).toBeLessThanOrEqual(legacyGrowth * RECALL_GROWTH_ADVANTAGE_RATIO)
+ } finally {
+ db.close()
+ }
+ }, 120_000)
+
+ it('keeps common-term recall agent-scoped across 100 agents', () => {
+ const DatabaseCtor = requireDatabase()
+ const observer = createMemoryPerfObserver(true)
+ const db = new DatabaseCtor(':memory:', {
+ verbose: () => observer.increment('sqliteStatements')
+ })
+ try {
+ const table = new AgentMemoryTable(db, observer)
+ table.createTable()
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (id, agent_id, kind, content, status, created_at)
+ VALUES (?, ?, 'semantic', ?, 'embedded', ?)`
+ )
+ db.transaction(() => {
+ for (let agentIndex = 0; agentIndex < 100; agentIndex += 1) {
+ const agentId = `scope-${agentIndex.toString().padStart(3, '0')}`
+ for (let rowIndex = 0; rowIndex < 100; rowIndex += 1) {
+ insert.run(
+ `${agentId}-${rowIndex}`,
+ agentId,
+ `shared common durable fact ${rowIndex}`,
+ rowIndex
+ )
+ }
+ }
+ })()
+ db.prepare(
+ `UPDATE agent_memory_fts_meta
+ SET mutation_generation = mutation_generation + 1
+ WHERE key = 'agent_memory_fts'`
+ ).run()
+ const searchTable = new AgentMemoryTable(db, observer)
+ searchTable.createTable()
+
+ observer.reset()
+ for (let agentIndex = 0; agentIndex < 100; agentIndex += 1) {
+ const agentId = `scope-${agentIndex.toString().padStart(3, '0')}`
+ const rows = searchTable.search(agentId, 'shared common', 5, { matchMode: 'all' })
+ expect(rows).toHaveLength(10)
+ expect(rows.every((row) => row.agent_id === agentId)).toBe(true)
+ }
+ expect(observer.snapshot()).toMatchObject({
+ counters: {
+ sqliteStatements: 100,
+ repositoryCalls: 100,
+ materializedRows: 1_000
+ }
+ })
+ } finally {
+ db.close()
+ }
+ }, 30_000)
+})
diff --git a/test/main/performance/memory/tapeScale.perf.ts b/test/main/performance/memory/tapeScale.perf.ts
new file mode 100644
index 000000000..fa0c8ccde
--- /dev/null
+++ b/test/main/performance/memory/tapeScale.perf.ts
@@ -0,0 +1,132 @@
+import { expect } from 'vitest'
+
+import { buildEffectiveTapeView } from '@/presenter/agentRuntimePresenter/tapeEffectiveView'
+import { DeepChatMemoryIngestionProjectionTable } from '@/presenter/sqlitePresenter/tables/deepchatMemoryIngestionProjection'
+import { DeepChatTapeEntriesTable } from '@/presenter/sqlitePresenter/tables/deepchatTapeEntries'
+
+import { createMemoryPerfObserver } from './performanceObserver'
+import { describeIfNativeSqlite, requireDatabase } from '../../nativeSqliteHarness'
+import { measurePerformance, reportPerformance } from './timing'
+
+function messagePayload(sessionId: string, index: number): string {
+ const id = `message-${index.toString().padStart(6, '0')}`
+ return JSON.stringify({
+ record: {
+ id,
+ sessionId,
+ orderSeq: index + 1,
+ role: index % 2 === 0 ? 'user' : 'assistant',
+ content: `synthetic tape entry ${index}`,
+ status: 'sent',
+ isContextEdge: 0,
+ metadata: '{}',
+ traceCount: 0,
+ createdAt: index + 1,
+ updatedAt: index + 1
+ }
+ })
+}
+
+describeIfNativeSqlite('Agent Memory #28 Tape scale', () => {
+ it('keeps tail reads range-bound at 10k and 100k entries', () => {
+ const DatabaseCtor = requireDatabase()
+ const observer = createMemoryPerfObserver(true)
+ const db = new DatabaseCtor(':memory:', {
+ verbose: () => observer.increment('sqliteStatements')
+ })
+ try {
+ const projection = new DeepChatMemoryIngestionProjectionTable(db, observer)
+ projection.createTable()
+ const tape = new DeepChatTapeEntriesTable(db, projection)
+ tape.createTable()
+ const insertTape = db.prepare(
+ `INSERT INTO deepchat_tape_entries (
+ session_id, entry_id, kind, name, source_type, source_id, source_seq,
+ provenance_key, payload_json, meta_json, created_at
+ ) VALUES (?, ?, 'message', ?, 'message', ?, 0, NULL, ?, '{"status":"sent"}', ?)`
+ )
+ const insertProjection = db.prepare(
+ `INSERT INTO deepchat_memory_ingestion_projection (
+ session_id, message_id, order_seq, entry_id, role, content, status, had_tool_use
+ ) VALUES (?, ?, ?, ?, ?, ?, 'sent', 0)`
+ )
+ const insertMeta = db.prepare(
+ `INSERT INTO deepchat_memory_ingestion_projection_meta (
+ session_id, projection_version, max_entry_id, updated_at
+ ) VALUES (?, 1, ?, ?)`
+ )
+ const sizes = [10_000, 100_000] as const
+ const seed = db.transaction(() => {
+ for (const size of sizes) {
+ const sessionId = `tape-${size}`
+ for (let index = 0; index < size; index += 1) {
+ const messageId = `message-${index.toString().padStart(6, '0')}`
+ const role = index % 2 === 0 ? 'user' : 'assistant'
+ insertTape.run(
+ sessionId,
+ index + 1,
+ `message/${role}`,
+ messageId,
+ messagePayload(sessionId, index),
+ index + 1
+ )
+ insertProjection.run(
+ sessionId,
+ messageId,
+ index + 1,
+ index + 1,
+ role,
+ `synthetic tape entry ${index}`
+ )
+ }
+ insertMeta.run(sessionId, size, size)
+ }
+ })
+ seed()
+
+ const reports = new Map()
+ for (const size of sizes) {
+ const sessionId = `tape-${size}`
+ const from = size - 20
+ observer.reset()
+ const tail = projection.readCurrentRange(sessionId, from, size)
+ expect(tail.current).toBe(true)
+ expect(tail.rows).toHaveLength(20)
+ expect(observer.snapshot()).toMatchObject({
+ counters: {
+ sqliteStatements: 1,
+ repositoryCalls: 1,
+ materializedRows: 20
+ }
+ })
+
+ const range = measurePerformance(
+ `tape-range-${size}`,
+ size,
+ () => {
+ projection.readCurrentRange(sessionId, from, size)
+ },
+ 5
+ )
+ const full = measurePerformance(
+ `tape-full-view-${size}`,
+ size,
+ () => {
+ const view = buildEffectiveTapeView(tape.getBySession(sessionId))
+ view.messageEntries.filter((entry) => entry.record.orderSeq > from)
+ },
+ 5
+ )
+ reportPerformance(range)
+ reportPerformance(full)
+ reports.set(size, { range: range.medianMs, full: full.medianMs })
+ }
+
+ const largest = reports.get(100_000)
+ expect(largest).toBeDefined()
+ expect(largest!.range).toBeLessThanOrEqual(largest!.full * 0.2)
+ } finally {
+ db.close()
+ }
+ }, 120_000)
+})
diff --git a/test/main/performance/memory/timing.ts b/test/main/performance/memory/timing.ts
new file mode 100644
index 000000000..6d0df10e1
--- /dev/null
+++ b/test/main/performance/memory/timing.ts
@@ -0,0 +1,82 @@
+import { performance } from 'node:perf_hooks'
+
+import { summarizeDurations } from './performanceObserver'
+
+export interface PerformanceReport {
+ scenario: string
+ size: number
+ samples: number
+ medianMs: number
+ p95Ms: number
+}
+
+export interface PairedPerformanceReport {
+ primary: PerformanceReport
+ baseline: PerformanceReport
+}
+
+function buildPerformanceReport(
+ scenario: string,
+ size: number,
+ durations: number[]
+): PerformanceReport {
+ const summary = summarizeDurations(durations)
+ return {
+ scenario,
+ size,
+ samples: durations.length,
+ medianMs: summary.median,
+ p95Ms: summary.p95
+ }
+}
+
+function measureOperation(operation: () => void, durations: number[]): void {
+ const startedAt = performance.now()
+ operation()
+ durations.push(performance.now() - startedAt)
+}
+
+export function measurePerformance(
+ scenario: string,
+ size: number,
+ operation: () => void,
+ samples = 7
+): PerformanceReport {
+ operation()
+ const durations: number[] = []
+ for (let index = 0; index < samples; index += 1) {
+ measureOperation(operation, durations)
+ }
+ return buildPerformanceReport(scenario, size, durations)
+}
+
+export function measurePairedPerformance(
+ primaryScenario: string,
+ baselineScenario: string,
+ size: number,
+ primaryOperation: () => void,
+ baselineOperation: () => void,
+ samples = 11
+): PairedPerformanceReport {
+ primaryOperation()
+ baselineOperation()
+ const primaryDurations: number[] = []
+ const baselineDurations: number[] = []
+ for (let index = 0; index < samples; index += 1) {
+ if (index % 2 === 0) {
+ measureOperation(primaryOperation, primaryDurations)
+ measureOperation(baselineOperation, baselineDurations)
+ } else {
+ measureOperation(baselineOperation, baselineDurations)
+ measureOperation(primaryOperation, primaryDurations)
+ }
+ }
+ return {
+ primary: buildPerformanceReport(primaryScenario, size, primaryDurations),
+ baseline: buildPerformanceReport(baselineScenario, size, baselineDurations)
+ }
+}
+
+export function reportPerformance(report: PerformanceReport): void {
+ console.info(`[memory-perf] ${JSON.stringify(report)}`)
+}
diff --git a/test/main/performance/memory/workloadBounds.perf.ts b/test/main/performance/memory/workloadBounds.perf.ts
new file mode 100644
index 000000000..554d549d3
--- /dev/null
+++ b/test/main/performance/memory/workloadBounds.perf.ts
@@ -0,0 +1,218 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { MemoryPresenter } from '@/presenter/memoryPresenter'
+import { FakeRepository, FakeVectorStore, textToVector } from '../../presenter/fakes/memoryFakes'
+import type { DeepChatAgentConfig } from '@shared/types/agent-interface'
+
+import { buildAgentFixture, buildDecisionFixture } from './fixtures'
+import { createMemoryPerfObserver } from './performanceObserver'
+
+const SHARED_MODEL_CONFIG: DeepChatAgentConfig = {
+ memoryEnabled: true,
+ memoryEmbedding: { providerId: 'shared-provider', modelId: 'shared-model' }
+}
+
+async function flushMicrotasks(rounds = 20): Promise {
+ for (let index = 0; index < rounds; index += 1) await Promise.resolve()
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('Agent Memory #28 bounded workloads', () => {
+ it('drains 101 embeddings in fixed 50-row provider and persistence batches', async () => {
+ const repo = new FakeRepository()
+ const store = new FakeVectorStore()
+ const observer = createMemoryPerfObserver(true)
+ for (let index = 0; index < 101; index += 1) {
+ repo.insert({
+ id: `embedding-${index}`,
+ agentId: 'embedding-agent',
+ kind: 'semantic',
+ content: `embedding content ${index}`,
+ status: 'pending_embedding'
+ })
+ }
+ const getEmbeddings = vi.fn(async (_providerId: string, _modelId: string, texts: string[]) =>
+ texts.map((text) => textToVector(text))
+ )
+ const upsert = vi.spyOn(store, 'upsert')
+ const listByIds = vi.spyOn(repo, 'listByIds')
+ const markReady = vi.spyOn(repo, 'markPendingEmbeddingsReady')
+ const markError = vi.spyOn(repo, 'markPendingEmbeddingsError')
+ const presenter = new MemoryPresenter({
+ executeWithRateLimit: async () => undefined,
+ repository: repo,
+ perfObserver: observer,
+ resolveAgentConfig: () => SHARED_MODEL_CONFIG,
+ getEmbeddings,
+ getDimensions: async () => ({ data: { dimensions: 4, normalized: false } }),
+ generateText: async () => '',
+ createVectorStore: async () => store,
+ resetVectorStore: async () => undefined
+ })
+
+ try {
+ await presenter.processPendingEmbeddings('embedding-agent')
+
+ expect(getEmbeddings.mock.calls.map((call) => call[2].length)).toEqual([50, 50, 1])
+ expect(listByIds).toHaveBeenCalledTimes(3)
+ expect(upsert.mock.calls.map((call) => call[0].length)).toEqual([50, 50, 1])
+ expect(markReady).toHaveBeenCalledTimes(3)
+ expect(markReady.mock.calls.flatMap((call) => call[1])).toHaveLength(101)
+ expect(markError).not.toHaveBeenCalled()
+
+ const snapshot = observer.snapshot()
+ expect(snapshot.counters.providerCalls).toBe(3)
+ expect(snapshot.counters.repositoryCalls).toBeLessThanOrEqual(24)
+ expect(snapshot.counters.materializedRows).toBeLessThanOrEqual(404)
+ } finally {
+ await presenter.dispose()
+ }
+ })
+
+ it('coordinates eight candidates with three neighbors each within steady provider caps', async () => {
+ const fixture = buildDecisionFixture()
+ const repo = new FakeRepository()
+ const observer = createMemoryPerfObserver(true)
+ for (const candidate of fixture) {
+ for (const neighbor of candidate.neighbors) {
+ repo.insert({
+ id: neighbor.id,
+ agentId: 'decision-agent',
+ kind: 'semantic',
+ content: neighbor.content,
+ status: 'fts_only'
+ })
+ }
+ }
+ const decisionPrompts: string[] = []
+ const generateText = vi.fn(async (_providerId: string, _modelId: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('You extract durable, long-term memories')) {
+ return JSON.stringify(
+ fixture.map((candidate) => ({
+ category: 'project_fact',
+ content: candidate.content,
+ importance: 0.7
+ }))
+ )
+ }
+ if (prompt.includes('Candidate ')) {
+ decisionPrompts.push(prompt)
+ const indexes = [...prompt.matchAll(/^Candidate (\d+) \(/gmu)].map((match) =>
+ Number(match[1])
+ )
+ return JSON.stringify(
+ indexes.map((candidateIndex) => ({
+ candidateIndex,
+ decision: 'ADD',
+ targetIndex: null
+ }))
+ )
+ }
+ return ''
+ })
+ const search = vi.spyOn(repo, 'search')
+ const listByIds = vi.spyOn(repo, 'listByIds')
+ const presenter = new MemoryPresenter({
+ executeWithRateLimit: async () => undefined,
+ repository: repo,
+ perfObserver: observer,
+ resolveAgentConfig: () => ({ memoryEnabled: true }),
+ getEmbeddings: async () => [],
+ getDimensions: async () => ({ data: { dimensions: 4, normalized: false } }),
+ generateText,
+ createVectorStore: async () => new FakeVectorStore(),
+ resetVectorStore: async () => undefined
+ })
+
+ try {
+ const result = await presenter.extractAndStore({
+ agentId: 'decision-agent',
+ spanText: 'Eight independent durable project facts.',
+ model: { providerId: 'decision-provider', modelId: 'decision-model' }
+ })
+ expect(result.ok).toBe(true)
+ expect(result.createdIds).toHaveLength(8)
+ expect(search).toHaveBeenCalledTimes(8)
+ expect(listByIds).toHaveBeenCalledTimes(1)
+ expect(decisionPrompts).toHaveLength(2)
+ expect(
+ decisionPrompts.map((prompt) => [...prompt.matchAll(/^Candidate (\d+) \(/gmu)].length)
+ ).toEqual([4, 4])
+ expect(
+ decisionPrompts.every((prompt) => [...prompt.matchAll(/^\[[0-2]\] /gmu)].length === 12)
+ ).toBe(true)
+ expect(generateText).toHaveBeenCalledTimes(4)
+
+ const snapshot = observer.snapshot()
+ expect(snapshot.counters.providerCalls).toBe(4)
+ expect(snapshot.counters.providerCalls).toBeLessThanOrEqual(5)
+ expect(snapshot.counters.repositoryCalls).toBeLessThanOrEqual(64)
+ expect(snapshot.counters.materializedRows).toBeLessThanOrEqual(72)
+ } finally {
+ await presenter.dispose()
+ }
+ })
+
+ it('prewarms only eight of 100 shared-model agents and warms the provider once', async () => {
+ vi.useFakeTimers()
+ const repo = new FakeRepository()
+ const observer = createMemoryPerfObserver(true)
+ for (const [index, agent] of buildAgentFixture(100).entries()) {
+ repo.insert({
+ id: `${agent.id}-memory`,
+ agentId: agent.id,
+ kind: 'semantic',
+ content: `active memory ${index}`,
+ status: 'pending_embedding',
+ createdAt: index + 1
+ })
+ repo.updateStatus(`${agent.id}-memory`, 'embedded', {
+ embeddingId: `${agent.id}-memory`,
+ embeddingDim: 4,
+ embeddingModel: 'shared-provider:shared-model'
+ })
+ repo.recordAccess(`${agent.id}-memory`, index + 1)
+ }
+ const getEmbeddings = vi.fn(async (_providerId: string, _modelId: string, texts: string[]) =>
+ texts.map((text) => textToVector(text))
+ )
+ const createVectorStore = vi.fn(async (agentId: string) => {
+ const store = new FakeVectorStore()
+ store.vectors.set(
+ `${agentId}-memory`,
+ textToVector(`active memory ${Number(agentId.slice(6))}`)
+ )
+ return store
+ })
+ const presenter = new MemoryPresenter({
+ executeWithRateLimit: async () => undefined,
+ repository: repo,
+ perfObserver: observer,
+ resolveAgentConfig: () => SHARED_MODEL_CONFIG,
+ getEmbeddings,
+ getDimensions: async () => ({ data: { dimensions: 4, normalized: false } }),
+ generateText: async () => '',
+ createVectorStore,
+ resetVectorStore: async () => undefined
+ })
+
+ try {
+ presenter.warmActiveAgents()
+ await vi.advanceTimersByTimeAsync(20_000)
+ await flushMicrotasks()
+
+ expect(createVectorStore).toHaveBeenCalledTimes(8)
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
+ expect(observer.snapshot()).toMatchObject({
+ counters: { providerCalls: 1 },
+ highWaterMarks: { openStores: 8 }
+ })
+ } finally {
+ await presenter.dispose()
+ }
+ })
+})
diff --git a/test/main/presenter/agentMemoryAuditRetention.test.ts b/test/main/presenter/agentMemoryAuditRetention.test.ts
new file mode 100644
index 000000000..73e85f796
--- /dev/null
+++ b/test/main/presenter/agentMemoryAuditRetention.test.ts
@@ -0,0 +1,143 @@
+import { expect, it } from 'vitest'
+import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness'
+
+const tableModule = Database
+ ? await import('@/presenter/sqlitePresenter/tables/agentMemoryAudit').catch(() => null)
+ : null
+
+const AgentMemoryAuditTable = tableModule?.AgentMemoryAuditTable
+const DatabaseCtor = Database!
+const AgentMemoryAuditTableCtor = AgentMemoryAuditTable!
+const describeIfSqlite = nativeSqliteDescribeIf(
+ Boolean(AgentMemoryAuditTable),
+ 'AgentMemoryAuditTable is unavailable'
+)
+
+describeIfSqlite('AgentMemoryAuditTable operational retention', () => {
+ it('retains the newest operational rows across event types and deletes at most the batch limit', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryAuditTableCtor(db)
+ table.createTable()
+ const queryPlan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT id
+ FROM agent_memory_audit
+ WHERE agent_id = ?
+ AND event_type IN (
+ 'memory/maintenance_llm',
+ 'memory/reflect',
+ 'memory/repair',
+ 'memory/conflict_repair',
+ 'memory/extract'
+ )
+ ORDER BY created_at DESC, id DESC
+ LIMIT ? OFFSET ?`
+ )
+ .all('a', 500, 10_000) as Array<{ detail: string }>
+ expect(queryPlan.map((row) => row.detail).join('\n')).toContain(
+ 'idx_agent_memory_audit_operational_retention_v2'
+ )
+ const eventTypes = [
+ 'memory/maintenance_llm',
+ 'memory/reflect',
+ 'memory/repair',
+ 'memory/conflict_repair',
+ 'memory/extract'
+ ]
+ for (let index = 0; index < 8; index += 1) {
+ table.insert({
+ id: `operational-${index}`,
+ agentId: 'a',
+ eventType: eventTypes[index % eventTypes.length],
+ actorType: 'scheduler',
+ status: 'completed',
+ createdAt: index
+ })
+ }
+
+ expect(table.pruneOperationalEvents('a', 3, 2)).toBe(2)
+ expect(table.pruneOperationalEvents('a', 3, 500)).toBe(3)
+ expect(
+ table
+ .listByAgent('a', { limit: 100 })
+ .filter((row) => row.event_type.startsWith('memory/'))
+ .map((row) => row.id)
+ ).toEqual(['operational-7', 'operational-6', 'operational-5'])
+ } finally {
+ db.close()
+ }
+ })
+
+ it('preserves causal, persona, unknown, malformed, and other-agent rows', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryAuditTableCtor(db)
+ table.createTable()
+ const preserved = [
+ ['forget', 'memory/forget'],
+ ['add', 'memory/add'],
+ ['archive', 'memory/archive'],
+ ['restore', 'memory/restore'],
+ ['edit', 'memory/manual_edit'],
+ ['challenge', 'memory/challenge_resolved'],
+ ['persona', 'persona/evolve'],
+ ['unknown', 'memory/future_event']
+ ] as const
+ for (const [id, eventType] of preserved) {
+ table.insert({
+ id,
+ agentId: 'a',
+ eventType,
+ actorType: 'runtime',
+ status: 'completed',
+ inputRefs: eventType === 'memory/forget' ? { memoryId: 'm1' } : {},
+ outputRefs: eventType === 'memory/forget' ? { memoryId: 'm1' } : {},
+ createdAt: 100
+ })
+ }
+ table.insert({
+ id: 'malformed-causal',
+ agentId: 'a',
+ eventType: 'memory/forget',
+ actorType: 'runtime',
+ status: 'completed',
+ createdAt: 50
+ })
+ db.prepare(
+ `UPDATE agent_memory_audit
+ SET memory_ref_id = NULL,
+ input_refs_json = 'not-json',
+ output_refs_json = 'also-not-json'
+ WHERE id = 'malformed-causal'`
+ ).run()
+ db.prepare(
+ "UPDATE agent_memory_audit SET input_refs_json = 'not-json' WHERE id = 'unknown'"
+ ).run()
+ table.insert({
+ id: 'other-agent-operational',
+ agentId: 'b',
+ eventType: 'memory/extract',
+ actorType: 'runtime',
+ status: 'completed',
+ createdAt: 1
+ })
+
+ const beforeForget = table.hasForgetEvent('a', 'm1')
+ expect(table.pruneOperationalEvents('a', 0, 500)).toBe(0)
+ expect(table.hasForgetEvent('a', 'm1')).toBe(beforeForget)
+ expect(
+ table
+ .listByAgent('a', { limit: 100 })
+ .map((row) => row.id)
+ .sort()
+ ).toEqual([...preserved.map(([id]) => id), 'malformed-causal'].sort())
+ expect(table.listByAgent('b', { limit: 100 }).map((row) => row.id)).toEqual([
+ 'other-agent-operational'
+ ])
+ } finally {
+ db.close()
+ }
+ })
+})
diff --git a/test/main/presenter/agentMemoryPaginationNative.test.ts b/test/main/presenter/agentMemoryPaginationNative.test.ts
new file mode 100644
index 000000000..83cb4990d
--- /dev/null
+++ b/test/main/presenter/agentMemoryPaginationNative.test.ts
@@ -0,0 +1,89 @@
+import { expect, it } from 'vitest'
+import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness'
+
+const tableModule = Database
+ ? await import('@/presenter/sqlitePresenter/tables/agentMemory').catch(() => null)
+ : null
+
+const AgentMemoryTable = tableModule?.AgentMemoryTable
+const DatabaseCtor = Database!
+const AgentMemoryTableCtor = AgentMemoryTable!
+const describeIfSqlite = nativeSqliteDescribeIf(
+ Boolean(AgentMemoryTable),
+ 'AgentMemoryTable is unavailable'
+)
+
+describeIfSqlite('AgentMemoryTable management pagination', () => {
+ it('uses the management-page index and stable created-at/id keyset ordering', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const queryPlan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT *
+ FROM agent_memory
+ WHERE agent_id = ?
+ AND superseded_by IS NULL
+ AND status != 'conflicted'
+ AND kind NOT IN ('persona', 'working')
+ AND (created_at < ? OR (created_at = ? AND id < ?))
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?`
+ )
+ .all('a', 1000, 1000, 'y', 100) as Array<{ detail: string }>
+ expect(queryPlan.map((row) => row.detail).join('\n')).toContain(
+ 'idx_agent_memory_management_page_v2'
+ )
+ expect(queryPlan.some((row) => row.detail.includes('TEMP B-TREE FOR ORDER BY'))).toBe(false)
+
+ for (const id of ['x', 'y', 'z']) {
+ table.insert({
+ id,
+ agentId: 'a',
+ kind: 'semantic',
+ content: id,
+ status: id === 'x' ? 'archived' : 'embedded',
+ createdAt: 1000
+ })
+ }
+ table.insert({
+ id: 'older',
+ agentId: 'a',
+ kind: 'episodic',
+ content: 'older',
+ status: 'embedded',
+ createdAt: 900
+ })
+
+ expect(table.listManagementPage('a', null, 2).map((row) => row.id)).toEqual(['z', 'y'])
+ expect(
+ table.listManagementPage('a', { createdAt: 1000, id: 'y' }, 10).map((row) => row.id)
+ ).toEqual(['x', 'older'])
+ } finally {
+ db.close()
+ }
+ })
+
+ it('caps direct repository reads at one bounded page plus the lookahead row', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (id, agent_id, kind, content, status, created_at)
+ VALUES (?, 'a', 'semantic', 'memory', 'embedded', ?)`
+ )
+ db.transaction(() => {
+ for (let index = 0; index < 150; index += 1) {
+ insert.run(`memory-${index}`, index)
+ }
+ })()
+
+ expect(table.listManagementPage('a', null, 10_000)).toHaveLength(101)
+ } finally {
+ db.close()
+ }
+ })
+})
diff --git a/test/main/presenter/agentMemoryTable.test.ts b/test/main/presenter/agentMemoryTable.test.ts
index c6962a6f3..d581271c2 100644
--- a/test/main/presenter/agentMemoryTable.test.ts
+++ b/test/main/presenter/agentMemoryTable.test.ts
@@ -1,50 +1,39 @@
-import { describe, expect, it } from 'vitest'
+import { expect, it, vi } from 'vitest'
+import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness'
-const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null)
-const tableModule = sqliteModule
+const tableModule = Database
? await import('@/presenter/sqlitePresenter/tables/agentMemory').catch(() => null)
: null
-const auditTableModule = sqliteModule
+const auditTableModule = Database
? await import('@/presenter/sqlitePresenter/tables/agentMemoryAudit').catch(() => null)
: null
+const ftsPolicyModule = Database
+ ? await import('@/presenter/sqlitePresenter/tables/agentMemoryFtsPolicy').catch(() => null)
+ : null
-const Database = sqliteModule?.default
const AgentMemoryTable = tableModule?.AgentMemoryTable
const AgentMemoryAuditTable = auditTableModule?.AgentMemoryAuditTable
+const agentFtsScope = ftsPolicyModule?.agentFtsScope
+const buildRecallablePredicate = ftsPolicyModule?.buildRecallablePredicate
+const isRecallableFtsRow = ftsPolicyModule?.isRecallableFtsRow
const DatabaseCtor = Database!
const AgentMemoryTableCtor = AgentMemoryTable!
const AgentMemoryAuditTableCtor = AgentMemoryAuditTable!
-const sqliteSkipReason = 'skipped: better-sqlite3-multiple-ciphers is unavailable'
-const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1'
-
-let sqliteAvailable = false
-if (Database) {
- try {
- const smokeDb = new Database(':memory:')
- smokeDb.close()
- sqliteAvailable = true
- } catch {
- sqliteAvailable = false
- }
+const describeIfSqlite = nativeSqliteDescribeIf(
+ Boolean(
+ AgentMemoryTable &&
+ AgentMemoryAuditTable &&
+ agentFtsScope &&
+ buildRecallablePredicate &&
+ isRecallableFtsRow
+ ),
+ 'Agent Memory native table modules are unavailable'
+)
+
+type AgentMemorySearchInternals = {
+ searchLike(...args: unknown[]): unknown[]
}
-const sqliteHarnessAvailable = sqliteAvailable && AgentMemoryTable && AgentMemoryAuditTable
-const sqliteHarnessSkipReason = !sqliteAvailable
- ? sqliteSkipReason
- : AgentMemoryTable
- ? 'skipped: AgentMemoryAuditTable is unavailable'
- : 'skipped: AgentMemoryTable is unavailable'
-const describeIfSqlite = sqliteHarnessAvailable
- ? describe
- : requireNativeSqlite
- ? (name: string, _suite: () => void) =>
- describe(name, () => {
- it('requires native SQLite support', () => {
- throw new Error(sqliteHarnessSkipReason)
- })
- })
- : describe.skip
-
describeIfSqlite('AgentMemoryTable', () => {
it('uses the conflict target index for participant lookup in a 50k-row agent', () => {
const db = new DatabaseCtor(':memory:')
@@ -75,8 +64,19 @@ describeIfSqlite('AgentMemoryTable', () => {
status: 'conflicted',
conflictWith: 'target'
})
+ table.insert({
+ id: 'sibling',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'sibling',
+ status: 'conflicted',
+ conflictWith: 'target'
+ })
expect(table.isUnresolvedConflictParticipant('a', 'target')).toBe(true)
+ expect(table.listConflictSiblings('a', 'target', 'challenger').map((row) => row.id)).toEqual([
+ 'sibling'
+ ])
const plan = db
.prepare(
`EXPLAIN QUERY PLAN
@@ -98,6 +98,296 @@ describeIfSqlite('AgentMemoryTable', () => {
}
}, 15_000)
+ it('keeps conflict sibling transitions and bounded repairs set-based at 1k scale', () => {
+ const statements: string[] = []
+ const db = new DatabaseCtor(':memory:', {
+ verbose: (statement: string) => statements.push(statement)
+ })
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (
+ id, agent_id, kind, content, status, superseded_by, created_at,
+ conflict_state, conflict_with
+ ) VALUES (?, ?, 'semantic', ?, ?, NULL, ?, ?, ?)`
+ )
+ db.transaction(() => {
+ insert.run('target', 'a', 'target', 'embedded', 1, 'challenged', null)
+ insert.run('winner', 'a', 'winner', 'conflicted', 2, null, 'target')
+ for (let index = 0; index < 1_000; index += 1) {
+ insert.run(`sibling-${index}`, 'a', 'sibling', 'conflicted', index + 3, null, 'target')
+ }
+ for (let index = 0; index < 300; index += 1) {
+ insert.run(
+ `repair-${index}`,
+ 'repair',
+ 'invalid link',
+ 'embedded',
+ index,
+ null,
+ 'missing-target'
+ )
+ }
+ })()
+
+ statements.length = 0
+ expect(table.retireConflictSiblings('a', 'target', 'winner', 'winner', 10)).toBe(1_000)
+ expect(statements).toHaveLength(1)
+ expect(
+ db
+ .prepare(
+ `SELECT COUNT(*) AS count
+ FROM agent_memory
+ WHERE agent_id = 'a' AND status = 'archived' AND superseded_by = 'winner'`
+ )
+ .get()
+ ).toEqual({ count: 1_000 })
+
+ statements.length = 0
+ expect(table.repairConflictIntegrityBatch('repair', 256)).toEqual({
+ repairedTargets: 0,
+ archivedChallengers: 0,
+ clearedTargets: 0,
+ clearedLinks: 64
+ })
+ expect(statements.length).toBeLessThanOrEqual(9)
+ expect(table.listConflictIntegrityRows('repair')).toHaveLength(236)
+ } finally {
+ db.close()
+ }
+ })
+
+ it('uses bounded maintenance indexes for archive, cognitive top-N, and conflict fairness', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (
+ id, agent_id, kind, content, status, is_anchor, created_at, importance
+ ) VALUES (?, 'a', ?, 'fixture', ?, 0, ?, 0.5)`
+ )
+ db.transaction(() => {
+ for (let index = 0; index < 2_000; index += 1) {
+ insert.run(`excluded-${index}`, 'persona', 'archived', index)
+ }
+ for (let index = 0; index < 20; index += 1) {
+ insert.run(`eligible-${index}`, 'semantic', 'embedded', index)
+ }
+ insert.run('target', 'semantic', 'embedded', 100)
+ db.prepare(
+ "UPDATE agent_memory SET conflict_state = 'challenged' WHERE id = 'target'"
+ ).run()
+ for (let index = 0; index < 4; index += 1) {
+ insert.run(`challenger-${index}`, 'semantic', 'conflicted', 200 + index)
+ db.prepare('UPDATE agent_memory SET conflict_with = ? WHERE id = ?').run(
+ 'target',
+ `challenger-${index}`
+ )
+ }
+ })()
+ db.exec('ANALYZE')
+ const archivePlan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT id
+ FROM agent_memory INDEXED BY idx_agent_memory_archive_eligible_v2
+ WHERE agent_id = ?
+ AND superseded_by IS NULL
+ AND conflict_state IS NULL
+ AND status NOT IN ('archived', 'conflicted')
+ AND is_anchor = 0
+ AND kind NOT IN ('persona', 'working')
+ AND created_at < ?
+ AND COALESCE(last_accessed, created_at) < ? - ?
+ AND (? - COALESCE(last_accessed, created_at)) >
+ ? * (1 + min(1.0, max(0.0, importance)))
+ ORDER BY COALESCE(last_accessed, created_at) ASC, created_at ASC, id ASC
+ LIMIT ?`
+ )
+ .all('a', 1000, 2000, 100, 2000, 100, 256) as Array<{ detail: string }>
+ expect(
+ archivePlan.some((row) => row.detail.includes('idx_agent_memory_archive_eligible_v2')),
+ JSON.stringify(archivePlan)
+ ).toBe(true)
+ expect(archivePlan.some((row) => row.detail.includes(''))).toBe(true)
+ expect(archivePlan.some((row) => row.detail.includes('TEMP B-TREE FOR ORDER BY'))).toBe(false)
+
+ const cognitivePlan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT *
+ FROM agent_memory INDEXED BY idx_agent_memory_cognitive_top_v2
+ WHERE agent_id = ?
+ AND superseded_by IS NULL
+ AND status NOT IN ('archived', 'conflicted')
+ AND kind IN ('episodic', 'semantic', 'reflection')
+ AND kind IN ('episodic', 'semantic')
+ ORDER BY importance DESC, created_at DESC, id DESC
+ LIMIT ?`
+ )
+ .all('a', 50) as Array<{ detail: string }>
+ expect(
+ cognitivePlan.some((row) => row.detail.includes('idx_agent_memory_cognitive_top_v2'))
+ ).toBe(true)
+ expect(cognitivePlan.some((row) => row.detail.includes('TEMP B-TREE FOR ORDER BY'))).toBe(
+ false
+ )
+
+ const conflictPlan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT challenger.*
+ FROM agent_memory challenger INDEXED BY idx_agent_memory_conflict_fairness_v2
+ WHERE challenger.agent_id = ?
+ AND challenger.status = 'conflicted'
+ AND challenger.superseded_by IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM agent_memory target
+ WHERE target.id = challenger.conflict_with
+ AND target.agent_id = challenger.agent_id
+ AND target.conflict_state = 'challenged'
+ AND target.superseded_by IS NULL
+ )
+ ORDER BY COALESCE(challenger.last_consolidated_at, 0) ASC,
+ challenger.created_at ASC,
+ challenger.id ASC
+ LIMIT ?`
+ )
+ .all('a', 4) as Array<{ detail: string }>
+ expect(
+ conflictPlan.some((row) => row.detail.includes('idx_agent_memory_conflict_fairness_v2'))
+ ).toBe(true)
+ expect(conflictPlan.some((row) => row.detail.includes('TEMP B-TREE FOR ORDER BY'))).toBe(
+ false
+ )
+ } finally {
+ db.close()
+ }
+ })
+
+ it('archives at most 256 eligible rows per current-decay batch', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ db.transaction(() => {
+ for (let index = 0; index < 300; index += 1) {
+ table.insert({
+ id: `old-${index.toString().padStart(3, '0')}`,
+ agentId: 'a',
+ kind: 'semantic',
+ content: `old memory ${index}`,
+ importance: 0.5,
+ status: 'embedded',
+ createdAt: index
+ })
+ }
+ table.insert({
+ id: 'recent',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'recent',
+ status: 'embedded',
+ createdAt: 9_900
+ })
+ table.insert({
+ id: 'anchor',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'anchor',
+ status: 'embedded',
+ isAnchor: true,
+ createdAt: 0
+ })
+ })()
+
+ const first = table.archiveEligibleBatch('a', {
+ now: 10_000,
+ createdBefore: 5_000,
+ minimumBaseAgeMs: 100,
+ limit: 256
+ })
+ const second = table.archiveEligibleBatch('a', {
+ now: 10_000,
+ createdBefore: 5_000,
+ minimumBaseAgeMs: 100,
+ limit: 256
+ })
+
+ expect(first).toHaveLength(256)
+ expect(second).toHaveLength(44)
+ expect(new Set([...first, ...second]).size).toBe(300)
+ expect(table.getById('recent')?.status).toBe('embedded')
+ expect(table.getById('anchor')?.status).toBe('embedded')
+ } finally {
+ db.close()
+ }
+ })
+
+ it('bulk embedding persistence rejects stale revisions and batches malformed errors', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ for (const id of ['ready', 'edited']) {
+ table.insert({
+ id,
+ agentId: 'a',
+ kind: 'semantic',
+ content: id,
+ status: 'pending_embedding'
+ })
+ }
+ expect(
+ table.updateDecisionContentIfRevision({
+ agentId: 'a',
+ id: 'edited',
+ expectedRevision: 1,
+ content: 'edited after snapshot',
+ provenanceKey: null,
+ at: 10
+ })
+ ).toBe(true)
+
+ expect(
+ table.markPendingEmbeddingsReady('a', [
+ {
+ id: 'ready',
+ expectedRevision: 1,
+ embeddingId: 'ready',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ },
+ {
+ id: 'edited',
+ expectedRevision: 1,
+ embeddingId: 'edited',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ }
+ ])
+ ).toEqual(['ready'])
+ expect(table.getById('ready')).toMatchObject({ status: 'embedded', embedding_dim: 4 })
+ expect(table.getById('edited')).toMatchObject({
+ status: 'pending_embedding',
+ decision_revision: 2,
+ embedding_id: null
+ })
+ expect(
+ table.markPendingEmbeddingsError('a', [{ id: 'edited', expectedRevision: 1 }])
+ ).toEqual([])
+ expect(
+ table.markPendingEmbeddingsError('a', [{ id: 'edited', expectedRevision: 2 }])
+ ).toEqual(['edited'])
+ expect(table.getById('edited')?.status).toBe('error')
+ } finally {
+ db.close()
+ }
+ })
+
it('inserts and reads back a memory row with defaults', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -705,6 +995,16 @@ describeIfSqlite('AgentMemoryTable', () => {
try {
const table = new AgentMemoryTableCtor(db)
table.createTable()
+ if (ftsActive(db)) {
+ expect(
+ db
+ .prepare(
+ `SELECT schema_version, policy_version
+ FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'`
+ )
+ .get()
+ ).toMatchObject({ schema_version: 4, policy_version: 2 })
+ }
table.insert({
id: 'm1',
agentId: 'deepchat',
@@ -723,55 +1023,6 @@ describeIfSqlite('AgentMemoryTable', () => {
}
})
- it('counts recall keyword term stats over active recallable rows only', () => {
- const db = new DatabaseCtor(':memory:')
- try {
- const table = new AgentMemoryTableCtor(db)
- table.createTable()
- table.insert({ id: 'm1', agentId: 'deepchat', kind: 'semantic', content: 'redis setup' })
- table.insert({ id: 'm2', agentId: 'deepchat', kind: 'semantic', content: 'please notes' })
- table.insert({ id: 'p1', agentId: 'deepchat', kind: 'persona', content: 'redis persona' })
- table.insert({ id: 'w1', agentId: 'deepchat', kind: 'working', content: 'redis working' })
- table.insert({
- id: 'a1',
- agentId: 'deepchat',
- kind: 'semantic',
- content: 'redis archived',
- status: 'archived'
- })
- table.insert({
- id: 'c1',
- agentId: 'deepchat',
- kind: 'semantic',
- content: 'redis conflicted',
- status: 'conflicted'
- })
- const old = table.insert({
- id: 'old',
- agentId: 'deepchat',
- kind: 'semantic',
- content: 'redis old'
- })
- const fresh = table.insert({
- id: 'fresh',
- agentId: 'deepchat',
- kind: 'semantic',
- content: 'redis fresh'
- })
- table.markSuperseded(old.id, fresh.id)
-
- expect(
- table.getRecallKeywordTermStats('deepchat', ['redis', 'please', 'redis', 'missing'])
- ).toEqual([
- { term: 'redis', hitCount: 2, totalRows: 3 },
- { term: 'please', hitCount: 1, totalRows: 3 },
- { term: 'missing', hitCount: 0, totalRows: 3 }
- ])
- } finally {
- db.close()
- }
- })
-
it('updates access counters in batch', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -1019,8 +1270,12 @@ describeIfSqlite('AgentMemoryTable', () => {
table.updateDecayScore('eligible-stored', 0.9)
const rows = table.listArchiveCandidateLifecycleRows('a', 5000, 10)
- expect(rows.map((row) => row.id).sort()).toEqual(['eligible-null', 'eligible-stored'])
- expect(rows.every((row) => row.access_count === 0)).toBe(true)
+ expect(rows.map((row) => row.id).sort()).toEqual([
+ 'accessed',
+ 'eligible-null',
+ 'eligible-stored'
+ ])
+ expect(rows.find((row) => row.id === 'accessed')?.access_count).toBe(1)
expect(rows.every((row) => !Object.prototype.hasOwnProperty.call(row, 'content'))).toBe(true)
expect(rows.every((row) => !Object.prototype.hasOwnProperty.call(row, 'embedding_id'))).toBe(
true
@@ -1111,6 +1366,69 @@ function ftsActive(db: InstanceType>): boolean {
}
describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
+ it('keeps the JS recall policy, SQL predicate, and registered scope encoder in parity', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const fixtures = [
+ { id: 'live', kind: 'semantic', status: 'embedded', superseded_by: null },
+ { id: 'archived', kind: 'semantic', status: 'archived', superseded_by: null },
+ { id: 'conflicted', kind: 'semantic', status: 'conflicted', superseded_by: null },
+ { id: 'persona', kind: 'persona', status: 'fts_only', superseded_by: null },
+ { id: 'working', kind: 'working', status: 'fts_only', superseded_by: null },
+ { id: 'superseded', kind: 'semantic', status: 'embedded', superseded_by: 'live' }
+ ]
+ const insert = db.prepare(
+ `INSERT INTO agent_memory (id, agent_id, kind, content, status, superseded_by, created_at)
+ VALUES (?, 'a', ?, ?, ?, ?, 1)`
+ )
+ for (const fixture of fixtures) {
+ insert.run(fixture.id, fixture.kind, fixture.id, fixture.status, fixture.superseded_by)
+ }
+
+ const sqlIds = (
+ db
+ .prepare(`SELECT id FROM agent_memory WHERE ${buildRecallablePredicate!()}`)
+ .all() as Array<{
+ id: string
+ }>
+ ).map((row) => row.id)
+ const jsIds = fixtures
+ .filter((row) => isRecallableFtsRow!({ ...row, agent_id: 'a' }))
+ .map((row) => row.id)
+ const sqlScope = db
+ .prepare('SELECT agent_memory_fts_scope(?) AS scope')
+ .get('agent/with unicode/记忆') as { scope: string }
+
+ expect(sqlIds).toEqual(jsIds)
+ expect(sqlScope.scope).toBe(agentFtsScope!('agent/with unicode/记忆'))
+ } finally {
+ db.close()
+ }
+ })
+
+ it('keeps unicode61 in permanent LIKE-only mode without mirror writes', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ ;(
+ table as unknown as { ftsCapability: { available: boolean; tokenizer: string } }
+ ).ftsCapability = { available: true, tokenizer: 'unicode61' }
+ table.createTable()
+
+ table.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis memory' })
+ table.updateStatus('m1', 'archived')
+ table.updateStatus('m1', 'pending_embedding')
+
+ expect(ftsActive(db)).toBe(false)
+ expect(table.searchWithStrategy('a', 'redis').strategy).toBe('like-fallback')
+ expect(table.search('a', 'redis').map((row) => row.id)).toEqual(['m1'])
+ } finally {
+ db.close()
+ }
+ })
+
it('carries embedding_model + lineage in the authoritative schema and exposes migration v32', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -1148,7 +1466,7 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
- it('recalls full words and >=3 char fragments; coverage never drops below LIKE', () => {
+ it('uses trigram FTS for safe terms and LIKE for short terms', () => {
const db = new DatabaseCtor(':memory:')
try {
const table = new AgentMemoryTableCtor(db)
@@ -1166,17 +1484,27 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
content: 'likes redis caching strongly'
})
+ const likeSpy = vi.spyOn(table as unknown as AgentMemorySearchInternals, 'searchLike')
expect(table.search('a', 'redis').map((row) => row.id)).toContain('redis')
+ const meta = db
+ .prepare("SELECT tokenizer FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'")
+ .get() as { tokenizer?: string } | undefined
+ if (ftsActive(db) && meta?.tokenizer === 'trigram') {
+ expect(likeSpy).not.toHaveBeenCalled()
+ } else {
+ expect(likeSpy).toHaveBeenCalledTimes(1)
+ }
// >=3 char CJK fragment: trigram FTS when available, otherwise the LIKE substring fallback.
expect(table.search('a', '中文回答').map((row) => row.id)).toContain('cn')
// 2 char CJK word is below trigram's window; the LIKE fallback still recalls it.
expect(table.search('a', '中文').map((row) => row.id)).toContain('cn')
+ expect(likeSpy).toHaveBeenCalledTimes(meta?.tokenizer === 'trigram' ? 1 : 3)
} finally {
db.close()
}
})
- it('keeps the FTS index in sync on delete / supersede / clear', () => {
+ it('keeps the recallable-only FTS index in sync across lifecycle transitions', () => {
const db = new DatabaseCtor(':memory:')
try {
const table = new AgentMemoryTableCtor(db)
@@ -1202,8 +1530,42 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
table.markSuperseded('a2', a3.id)
expect(table.search('a', 'redis').map((row) => row.id)).toEqual(['a3'])
+ table.updateStatus('a3', 'archived')
+ expect(table.search('a', 'redis')).toEqual([])
+ table.updateStatus('a3', 'pending_embedding')
+ expect(table.search('a', 'redis').map((row) => row.id)).toEqual(['a3'])
+
+ table.insert({
+ id: 'persona',
+ agentId: 'a',
+ kind: 'persona',
+ content: 'redis persona'
+ })
+ table.insert({
+ id: 'conflicted',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'redis conflict',
+ status: 'conflicted'
+ })
+ table.insert({
+ id: 'other-agent',
+ agentId: 'b',
+ kind: 'semantic',
+ content: 'redis remains searchable'
+ })
+ expect(table.search('a', 'redis').map((row) => row.id)).toEqual(['a3'])
+
table.clearByAgent('a')
expect(table.search('a', 'redis')).toHaveLength(0)
+ const otherAgent = table.searchWithStrategy('b', 'redis')
+ expect(otherAgent.rows.map((row) => row.id)).toEqual(['other-agent'])
+ const tokenizer = (
+ db
+ .prepare("SELECT tokenizer FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'")
+ .get() as { tokenizer?: string } | undefined
+ )?.tokenizer
+ expect(otherAgent.strategy).toBe(tokenizer === 'trigram' ? 'fts-only' : 'like-fallback')
} finally {
db.close()
}
@@ -1226,6 +1588,91 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
+ it('keeps authoritative writes available when the runtime FTS table disappears', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ if (!ftsActive(db)) return
+ table.insert({ id: 'before', agentId: 'a', kind: 'semantic', content: 'redis before' })
+ db.exec('DROP TABLE agent_memory_fts;')
+
+ expect(() =>
+ table.insert({ id: 'after', agentId: 'a', kind: 'semantic', content: 'redis after' })
+ ).not.toThrow()
+ expect(table.getById('after')?.content).toBe('redis after')
+ const dirtyMeta = db
+ .prepare(
+ `SELECT mutation_generation, indexed_generation
+ FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'`
+ )
+ .get() as { mutation_generation: number; indexed_generation: number }
+ expect(dirtyMeta.mutation_generation).toBeGreaterThan(dirtyMeta.indexed_generation)
+ expect(
+ table
+ .search('a', 'redis')
+ .map((row) => row.id)
+ .sort()
+ ).toEqual(['after', 'before'])
+
+ const recoveredMeta = db
+ .prepare(
+ `SELECT mutation_generation, indexed_generation
+ FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'`
+ )
+ .get() as { mutation_generation: number; indexed_generation: number }
+ expect(recoveredMeta.mutation_generation).toBe(recoveredMeta.indexed_generation)
+ } finally {
+ db.close()
+ }
+ })
+
+ it('drops a partial FTS build and fails open to one bounded LIKE query', () => {
+ const db = new DatabaseCtor(':memory:')
+ vi.stubEnv('DEEPCHAT_REQUIRE_NATIVE_SQLITE', '0')
+ try {
+ const originalExec = db.exec.bind(db)
+ vi.spyOn(db, 'exec').mockImplementation((sql: string) => {
+ if (sql.includes('CREATE VIRTUAL TABLE IF NOT EXISTS agent_memory_fts')) {
+ throw new Error('simulated FTS build failure')
+ }
+ return originalExec(sql)
+ })
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ table.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fallback' })
+ const likeSpy = vi.spyOn(table as unknown as AgentMemorySearchInternals, 'searchLike')
+
+ expect(table.search('a', 'redis').map((row) => row.id)).toEqual(['m1'])
+ expect(likeSpy).toHaveBeenCalledTimes(1)
+ expect(ftsActive(db)).toBe(false)
+ } finally {
+ vi.unstubAllEnvs()
+ db.close()
+ }
+ })
+
+ it('fails hard on an FTS build failure during strict native validation', () => {
+ const db = new DatabaseCtor(':memory:')
+ vi.stubEnv('DEEPCHAT_REQUIRE_NATIVE_SQLITE', '1')
+ try {
+ const originalExec = db.exec.bind(db)
+ vi.spyOn(db, 'exec').mockImplementation((sql: string) => {
+ if (sql.includes('CREATE VIRTUAL TABLE IF NOT EXISTS agent_memory_fts')) {
+ throw new Error('simulated FTS build failure')
+ }
+ return originalExec(sql)
+ })
+ const table = new AgentMemoryTableCtor(db)
+
+ expect(() => table.createTable()).toThrow('simulated FTS build failure')
+ expect(ftsActive(db)).toBe(false)
+ } finally {
+ vi.unstubAllEnvs()
+ db.close()
+ }
+ })
+
it('orders multi-hit keyword results by BM25 when FTS is active', () => {
const db = new DatabaseCtor(':memory:')
try {
@@ -1252,7 +1699,7 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
- it('unions LIKE so high-importance rows survive when FTS alone fills the cap (AC-2.2)', () => {
+ it('supplements BM25 with same-MATCH importance ranking without LIKE', () => {
const db = new DatabaseCtor(':memory:')
try {
const table = new AgentMemoryTableCtor(db)
@@ -1288,13 +1735,17 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
importance: 0.8
})
- // limit=2 would let BM25 fill the cap with lo1/lo2 alone; the LIKE union must still surface
- // the high-importance rows the old substring search returned, instead of dropping them.
+ const likeSpy = vi.spyOn(table as unknown as AgentMemorySearchInternals, 'searchLike')
+ // limit=2 lets BM25 fill the lexical cap; the second FTS query supplies importance candidates.
const ids = table.search('a', 'redis', 2).map((row) => row.id)
expect(ids).toContain('hi1')
expect(ids).toContain('hi2')
if (ftsActive(db)) {
expect(ids.length).toBeGreaterThan(2)
+ const meta = db
+ .prepare("SELECT tokenizer FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'")
+ .get() as { tokenizer?: string } | undefined
+ if (meta?.tokenizer === 'trigram') expect(likeSpy).not.toHaveBeenCalled()
}
} finally {
db.close()
@@ -1689,6 +2140,11 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
const table = new AgentMemoryTableCtor(db)
table.createTable()
// Reproduce a database created before the consolidation columns existed.
+ db.exec('DROP INDEX IF EXISTS idx_agent_memory_conflict_fairness')
+ db.exec('DROP INDEX IF EXISTS idx_agent_memory_archive_eligible')
+ db.exec('DROP INDEX IF EXISTS idx_agent_memory_conflict_fairness_v2')
+ db.exec('DROP INDEX IF EXISTS idx_agent_memory_archive_eligible_v2')
+ db.exec('DROP INDEX IF EXISTS idx_agent_memory_conflict_state_anomaly_v2')
db.exec('ALTER TABLE agent_memory DROP COLUMN confidence')
db.exec('ALTER TABLE agent_memory DROP COLUMN last_consolidated_at')
db.exec('ALTER TABLE agent_memory DROP COLUMN conflict_state')
@@ -1708,6 +2164,14 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
expect(columns).toContain('confidence')
expect(columns).toContain('last_consolidated_at')
expect(columns).toContain('conflict_state')
+ table.assertCurrentSchema()
+ expect(
+ db
+ .prepare(
+ "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_agent_memory_archive_eligible_v2'"
+ )
+ .get()
+ ).toBeDefined()
// Legacy row survives the migration with neutral defaults.
expect(table.getById('legacy')?.confidence).toBe(null)
} finally {
@@ -1963,8 +2427,6 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
'target',
'challenger'
])
- expect(table.listArchiveCandidates('a', 100, 0.05)).toEqual([])
- expect(table.countArchiveCandidates('a', 100, 0.05)).toBe(0)
expect(table.listArchiveCandidateLifecycleRows('a', 100, 10)).toEqual([])
} finally {
db.close()
@@ -2393,43 +2855,6 @@ describeIfSqlite('AgentMemoryTable FTS5 + migration', () => {
}
})
- it('listArchiveCandidates pre-filters by age, decay, and exemptions', () => {
- const db = new DatabaseCtor(':memory:')
- try {
- const table = new AgentMemoryTableCtor(db)
- table.createTable()
- const old = 1000
- table.insert({ id: 'stale', agentId: 'a', kind: 'semantic', content: 's', createdAt: old })
- table.insert({
- id: 'accessed',
- agentId: 'a',
- kind: 'semantic',
- content: 'used',
- createdAt: old
- })
- table.insert({ id: 'fresh', agentId: 'a', kind: 'semantic', content: 'f', createdAt: 9000 })
- table.insert({
- id: 'anchored',
- agentId: 'a',
- kind: 'semantic',
- content: 'an',
- createdAt: old,
- isAnchor: true
- })
- table.updateDecayScore('stale', 0.01)
- table.updateDecayScore('accessed', 0.01)
- table.recordAccess('accessed', 7000)
- table.updateDecayScore('fresh', 0.01)
- table.updateDecayScore('anchored', 0.01)
-
- const candidates = table.listArchiveCandidates('a', 5000, 0.05)
- expect(candidates.map((r) => r.id).sort()).toEqual(['stale'])
- expect(table.countArchiveCandidates('a', 5000, 0.05)).toBe(1)
- } finally {
- db.close()
- }
- })
-
it('agent memory audit list filters remain compatible with limit calls', () => {
const db = new DatabaseCtor(':memory:')
try {
diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts
index 960b278fb..eeec10e51 100644
--- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts
+++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts
@@ -257,6 +257,9 @@ function createMockSqlitePresenter() {
deleteByMessageIds: vi.fn()
}
let deepchatTapeEntriesTable: any
+ let memoryIngestionProjectionCurrent = false
+ let memoryIngestionProjectionMaxEntryId = 0
+ let memoryIngestionProjectionRows: any[] = []
return {
getDatabase: vi.fn(() => ({
transaction: (fn: () => unknown) => () => fn()
@@ -354,6 +357,7 @@ function createMockSqlitePresenter() {
created_at: input.createdAt ?? Date.now()
}
tapeEntries.push(row)
+ memoryIngestionProjectionCurrent = false
return row
}),
appendAnchor: vi.fn((input: any) => {
@@ -373,6 +377,14 @@ function createMockSqlitePresenter() {
getBySession: vi.fn((sessionId: string) =>
tapeEntries.filter((entry) => entry.session_id === sessionId)
),
+ getMaxEntryId: vi.fn((sessionId: string) =>
+ Math.max(
+ 0,
+ ...tapeEntries
+ .filter((entry) => entry.session_id === sessionId)
+ .map((entry) => entry.entry_id)
+ )
+ ),
getLatestAnchor: vi.fn(
(sessionId: string) =>
tapeEntries
@@ -405,8 +417,47 @@ function createMockSqlitePresenter() {
tapeEntries.splice(index, 1)
}
}
+ memoryIngestionProjectionCurrent = false
})
}),
+ deepchatMemoryIngestionProjectionTable: {
+ readCurrentRange: vi.fn(
+ (sessionId: string, fromOrderSeqExclusive: number, toOrderSeqInclusive: number) => {
+ const maxEntryId = deepchatTapeEntriesTable.getMaxEntryId(sessionId)
+ const current =
+ memoryIngestionProjectionCurrent && memoryIngestionProjectionMaxEntryId === maxEntryId
+ return {
+ current,
+ maxEntryId,
+ rows: current
+ ? memoryIngestionProjectionRows.filter(
+ (row) =>
+ row.session_id === sessionId &&
+ row.order_seq > fromOrderSeqExclusive &&
+ row.order_seq <= toOrderSeqInclusive
+ )
+ : []
+ }
+ }
+ ),
+ replaceSession: vi.fn((sessionId: string, rows: any[], maxEntryId: number) => {
+ memoryIngestionProjectionRows = rows.map((row) => ({
+ session_id: row.sessionId,
+ message_id: row.messageId,
+ order_seq: row.orderSeq,
+ entry_id: row.entryId,
+ role: row.role,
+ content: row.content,
+ status: row.status,
+ had_tool_use: row.hadToolUse ? 1 : 0
+ }))
+ memoryIngestionProjectionMaxEntryId = maxEntryId
+ memoryIngestionProjectionCurrent = true
+ }),
+ invalidateSession: vi.fn(() => {
+ memoryIngestionProjectionCurrent = false
+ })
+ },
deepchatMessagesTable,
deepchatUserMessagesTable: {
upsert: vi.fn(),
@@ -1159,6 +1210,190 @@ describe('AgentRuntimePresenter', () => {
expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
})
+ it('rebuilds memory ingestion projection once and uses bounded range reads afterward', () => {
+ installRuntimeRecords([
+ userRecord('u1', 1, 'Read package metadata.'),
+ assistantRecord('a1', 2, [toolBlock('tool-1')])
+ ])
+ let current = false
+ let projectedRows: any[] = []
+ let projectedMaxEntryId = 0
+ const replaceSession = vi.fn((_sessionId: string, rows: any[], maxEntryId: number) => {
+ projectedRows = rows.map((row) => ({
+ session_id: row.sessionId,
+ message_id: row.messageId,
+ order_seq: row.orderSeq,
+ entry_id: row.entryId,
+ role: row.role,
+ content: row.content,
+ status: row.status,
+ had_tool_use: row.hadToolUse ? 1 : 0
+ }))
+ projectedMaxEntryId = maxEntryId
+ current = true
+ })
+ const readCurrentRange = vi.fn(
+ (_sessionId: string, fromExclusive: number, toInclusive: number) => ({
+ current,
+ maxEntryId: current
+ ? projectedMaxEntryId
+ : sqlitePresenter.deepchatTapeEntriesTable.getMaxEntryId('s1'),
+ rows: current
+ ? projectedRows.filter(
+ (row) => row.order_seq > fromExclusive && row.order_seq <= toInclusive
+ )
+ : []
+ })
+ )
+ ;(sqlitePresenter as any).deepchatMemoryIngestionProjectionTable = {
+ readCurrentRange,
+ replaceSession,
+ invalidateSession: vi.fn()
+ }
+
+ const rebuiltWindow = (agent as any).buildMemoryExtractionWindow('s1', 0, 2)
+ expect(rebuiltWindow).toEqual(
+ expect.objectContaining({
+ hadToolUse: true,
+ visibleTextChars: 'User: Read package metadata.'.length
+ })
+ )
+ expect(replaceSession).toHaveBeenCalledTimes(1)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
+
+ sqlitePresenter.deepchatTapeEntriesTable.getBySession.mockClear()
+ const rangeWindow = (agent as any).buildMemoryExtractionWindow('s1', 0, 2)
+
+ expect(rangeWindow).toEqual(rebuiltWindow)
+ expect(replaceSession).toHaveBeenCalledTimes(1)
+ expect(readCurrentRange).toHaveBeenCalledTimes(2)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the authoritative Tape view when projection validation fails', () => {
+ installRuntimeRecords([userRecord('u1', 1, 'Keep the fallback safe.')])
+ const invalidateSession = vi.fn()
+ ;(sqlitePresenter as any).deepchatMemoryIngestionProjectionTable = {
+ readCurrentRange: vi.fn(() => {
+ throw new Error('projection unavailable')
+ }),
+ replaceSession: vi.fn(),
+ invalidateSession
+ }
+ sqlitePresenter.deepchatTapeEntriesTable.getBySession.mockClear()
+
+ const window = (agent as any).buildMemoryExtractionWindow('s1', 0, 1)
+
+ expect(window).toEqual(
+ expect.objectContaining({
+ hadToolUse: false,
+ visibleTextChars: 'User: Keep the fallback safe.'.length
+ })
+ )
+ expect(invalidateSession).toHaveBeenCalledWith('s1')
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
+
+ expect((agent as any).buildMemoryExtractionWindow('s1', 0, 1)).toBeNull()
+ expect(
+ sqlitePresenter.deepchatMemoryIngestionProjectionTable.readCurrentRange
+ ).toHaveBeenCalledTimes(1)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
+ })
+
+ it('cools repeated projection rebuild failures and recovers after the retry window', () => {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
+ try {
+ installRuntimeRecords([userRecord('u1', 1, 'Keep projection recovery bounded.')])
+ let current = false
+ let failReplacement = true
+ let projectedRows: any[] = []
+ let projectedMaxEntryId = 0
+ const readCurrentRange = vi.fn(
+ (_sessionId: string, fromExclusive: number, toInclusive: number) => ({
+ current,
+ maxEntryId: current
+ ? projectedMaxEntryId
+ : sqlitePresenter.deepchatTapeEntriesTable.getMaxEntryId('s1'),
+ rows: current
+ ? projectedRows.filter(
+ (row) => row.order_seq > fromExclusive && row.order_seq <= toInclusive
+ )
+ : []
+ })
+ )
+ const replaceSession = vi.fn((_sessionId: string, rows: any[], maxEntryId: number) => {
+ if (failReplacement) throw new Error('projection rebuild failed')
+ projectedRows = rows.map((row) => ({
+ session_id: row.sessionId,
+ message_id: row.messageId,
+ order_seq: row.orderSeq,
+ entry_id: row.entryId,
+ role: row.role,
+ content: row.content,
+ status: row.status,
+ had_tool_use: row.hadToolUse ? 1 : 0
+ }))
+ projectedMaxEntryId = maxEntryId
+ current = true
+ })
+ ;(sqlitePresenter as any).deepchatMemoryIngestionProjectionTable = {
+ readCurrentRange,
+ replaceSession,
+ invalidateSession: vi.fn(() => {
+ current = false
+ })
+ }
+ sqlitePresenter.deepchatTapeEntriesTable.getBySession.mockClear()
+
+ const fallback = (agent as any).buildMemoryExtractionWindow('s1', 0, 1)
+ expect(fallback.chunks.every((chunk: any) => chunk.cursorCommitOrderSeq === null)).toBe(
+ true
+ )
+ expect(replaceSession).toHaveBeenCalledTimes(1)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
+
+ expect((agent as any).buildMemoryExtractionWindow('s1', 0, 1)).toBeNull()
+ expect(readCurrentRange).toHaveBeenCalledTimes(1)
+ expect(replaceSession).toHaveBeenCalledTimes(1)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(1)
+
+ now.mockReturnValue(31_000)
+ failReplacement = false
+ const recovered = (agent as any).buildMemoryExtractionWindow('s1', 0, 1)
+ expect(recovered.chunks.at(-1)?.cursorCommitOrderSeq).toBe(1)
+ expect(replaceSession).toHaveBeenCalledTimes(2)
+
+ expect((agent as any).buildMemoryExtractionWindow('s1', 0, 1)).toEqual(recovered)
+ expect(readCurrentRange).toHaveBeenCalledTimes(3)
+ expect(sqlitePresenter.deepchatTapeEntriesTable.getBySession).toHaveBeenCalledTimes(2)
+ } finally {
+ now.mockRestore()
+ }
+ })
+
+ it('bounds projection failure cooldown state and clears it with session lifecycle', async () => {
+ const internals = agent as any
+ internals.recordMemoryIngestionProjectionFailure('s1')
+ expect(internals.memoryIngestionProjectionRetryAfter.has('s1')).toBe(true)
+
+ await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' })
+ expect(internals.memoryIngestionProjectionRetryAfter.has('s1')).toBe(false)
+
+ internals.recordMemoryIngestionProjectionFailure('s1')
+ await agent.clearMessages('s1')
+ expect(internals.memoryIngestionProjectionRetryAfter.has('s1')).toBe(false)
+
+ for (let index = 0; index < 257; index += 1) {
+ internals.recordMemoryIngestionProjectionFailure(`session-${index}`)
+ }
+ expect(internals.memoryIngestionProjectionRetryAfter.size).toBe(256)
+ expect(internals.memoryIngestionProjectionRetryAfter.has('session-0')).toBe(false)
+
+ internals.recordMemoryIngestionProjectionFailure('s1')
+ await agent.destroySession('s1')
+ expect(internals.memoryIngestionProjectionRetryAfter.has('s1')).toBe(false)
+ })
+
it('drops an in-flight extraction commit after clearMessages resets the session', async () => {
await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' })
const { extraction, extractAndStore } = installDeferredExtraction()
diff --git a/test/main/presenter/agentRuntimePresenter/memoryExtractionChunks.test.ts b/test/main/presenter/agentRuntimePresenter/memoryExtractionChunks.test.ts
index 96e753013..823c2414d 100644
--- a/test/main/presenter/agentRuntimePresenter/memoryExtractionChunks.test.ts
+++ b/test/main/presenter/agentRuntimePresenter/memoryExtractionChunks.test.ts
@@ -55,6 +55,17 @@ describe('buildMemoryExtractionChunks', () => {
expect(chunks.map((chunk) => chunk.text).join('')).not.toContain('\ud83d\n')
})
+ it('treats every message sharing an order sequence as one cursor commit group', () => {
+ const chunks = buildMemoryExtractionChunks([
+ { orderSeq: 7, entryId: 70, role: 'user', text: '记'.repeat(8_000) },
+ { orderSeq: 7, entryId: 71, role: 'assistant', text: '忆'.repeat(8_000) }
+ ])
+
+ expect(chunks.length).toBeGreaterThan(2)
+ expect(chunks.slice(0, -1).every((chunk) => chunk.cursorCommitOrderSeq === null)).toBe(true)
+ expect(chunks.at(-1)?.cursorCommitOrderSeq).toBe(7)
+ })
+
it('flushes on message boundaries when the next complete message does not fit', () => {
const chunks = buildMemoryExtractionChunks([
{ orderSeq: 1, entryId: 1, role: 'user', text: 'a'.repeat(11_500) },
diff --git a/test/main/presenter/agentRuntimePresenter/tapeFacts.test.ts b/test/main/presenter/agentRuntimePresenter/tapeFacts.test.ts
index 8d7ce39b6..90436a63f 100644
--- a/test/main/presenter/agentRuntimePresenter/tapeFacts.test.ts
+++ b/test/main/presenter/agentRuntimePresenter/tapeFacts.test.ts
@@ -5,6 +5,8 @@ import {
appendToolFactsToTape
} from '@/presenter/agentRuntimePresenter/tapeFacts'
import { buildEffectiveTapeView } from '@/presenter/agentRuntimePresenter/tapeEffectiveView'
+import { tapeToolRank } from '@/presenter/sqlitePresenter/tables/deepchatTapeEffectiveSemantics'
+import type { DeepChatTapeEntryRow } from '@/presenter/sqlitePresenter/tables/deepchatTapeEntries'
function createTable() {
const rows: any[] = []
@@ -78,6 +80,30 @@ function toolCallBlock(
}
describe('appendToolFactsToTape', () => {
+ it('ranks only explicit terminal tool statuses as final', () => {
+ const row = (status?: unknown): DeepChatTapeEntryRow => ({
+ session_id: 's1',
+ entry_id: 1,
+ kind: 'tool_call',
+ name: 'search',
+ source_type: 'tool_call',
+ source_id: 'tc1',
+ source_seq: 0,
+ provenance_key: null,
+ payload_json: '{}',
+ meta_json: JSON.stringify(status === undefined ? {} : { status }),
+ created_at: 1
+ })
+
+ expect(tapeToolRank(row('success'), false)).toBe(2)
+ expect(tapeToolRank(row('error'), false)).toBe(2)
+ expect(tapeToolRank(row('pending'), false)).toBe(0)
+ expect(tapeToolRank(row('pending'), true)).toBe(1)
+ expect(tapeToolRank(row(), true)).toBe(0)
+ expect(tapeToolRank(row('loading'), true)).toBe(0)
+ expect(tapeToolRank(row('unknown'), true)).toBe(0)
+ })
+
it('writes only success/error tool blocks and reads status from the block', () => {
const table = createTable()
const record = assistantRecord([
diff --git a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts
index 16501a168..17aa81358 100644
--- a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts
+++ b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts
@@ -527,6 +527,10 @@ describe('deleteDeepChatAgent cleanup', () => {
describe('DeepChat agent memory maintenance config changed callback', () => {
it.each([
['memoryEnabled', { memoryEnabled: true }],
+ [
+ 'memoryEmbedding',
+ { memoryEmbedding: createModelSelection('openai', 'text-embedding-3-small') }
+ ],
['memoryExtractionModel', { memoryExtractionModel: createModelSelection('openai', 'gpt-4o') }],
['personaEvolutionEnabled', { personaEvolutionEnabled: true }],
['assistantModel', { assistantModel: createModelSelection('openai', 'gpt-4o-mini') }],
@@ -573,9 +577,6 @@ describe('DeepChat agent memory maintenance config changed callback', () => {
await (presenter as ConfigPresenter).updateDeepChatAgent('writer', {
config: { systemPrompt: 'You are concise.' }
})
- await (presenter as ConfigPresenter).updateDeepChatAgent('writer', {
- config: { memoryEmbedding: createModelSelection('openai', 'text-embedding-3-small') }
- })
await (presenter as ConfigPresenter).updateDeepChatAgent('writer', {
config: { memoryRetrieval: { topK: 20 } }
})
@@ -584,7 +585,7 @@ describe('DeepChat agent memory maintenance config changed callback', () => {
})
expect(callback).not.toHaveBeenCalled()
- expect(presenter.notifyAcpAgentsChanged).toHaveBeenCalledTimes(5)
+ expect(presenter.notifyAcpAgentsChanged).toHaveBeenCalledTimes(4)
})
it('does not notify when a maintenance-relevant custom update finds no agent', async () => {
@@ -633,6 +634,10 @@ describe('DeepChat agent memory maintenance config changed callback', () => {
it.each([
['memoryEnabled', { memoryEnabled: true }],
+ [
+ 'memoryEmbedding',
+ { memoryEmbedding: createModelSelection('openai', 'text-embedding-3-small') }
+ ],
['memoryExtractionModel', { memoryExtractionModel: createModelSelection('openai', 'gpt-4o') }],
['personaEvolutionEnabled', { personaEvolutionEnabled: true }],
['assistantModel', { assistantModel: createModelSelection('openai', 'gpt-4o-mini') }],
@@ -666,10 +671,6 @@ describe('DeepChat agent memory maintenance config changed callback', () => {
['systemPrompt', { systemPrompt: 'You are concise.' }],
['autoCompactionEnabled', { autoCompactionEnabled: false }],
['disabledAgentTools', { disabledAgentTools: ['builtin/web-search'] }],
- [
- 'memoryEmbedding',
- { memoryEmbedding: createModelSelection('openai', 'text-embedding-3-small') }
- ],
['memoryRetrieval', { memoryRetrieval: { topK: 20 } }],
['memoryInjectionTokenBudget', { memoryInjectionTokenBudget: 4096 }]
])('does not notify after builtin DeepChat %s config updates', (_name, updates) => {
diff --git a/test/main/presenter/fakes/memoryFakes.ts b/test/main/presenter/fakes/memoryFakes.ts
index 59993581d..418b6d69d 100644
--- a/test/main/presenter/fakes/memoryFakes.ts
+++ b/test/main/presenter/fakes/memoryFakes.ts
@@ -146,11 +146,69 @@ export class FakeRepository implements MemoryRepositoryPort {
return result
}
+ listManagementPage(
+ agentId: string,
+ cursor: { createdAt: number; id: string } | null,
+ limit: number
+ ) {
+ return this.listByAgent(agentId, { includeArchived: true })
+ .filter((row) => row.kind !== 'persona')
+ .filter(
+ (row) =>
+ cursor === null ||
+ row.created_at < cursor.createdAt ||
+ (row.created_at === cursor.createdAt && row.id < cursor.id)
+ )
+ .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
+ .slice(0, Math.max(1, Math.floor(limit)))
+ }
+
+ listManagementVisibleByIds(agentId: string, ids: string[]) {
+ const idSet = new Set(ids)
+ return [...this.rows.values()].filter(
+ (row) =>
+ row.agent_id === agentId &&
+ idSet.has(row.id) &&
+ row.superseded_by === null &&
+ row.status !== 'conflicted' &&
+ row.kind !== 'persona' &&
+ row.kind !== 'working'
+ )
+ }
+
listByIds(agentId: string, ids: string[]) {
const idSet = new Set(ids)
return [...this.rows.values()].filter((row) => row.agent_id === agentId && idSet.has(row.id))
}
+ getCognitiveMaintenanceInput(
+ agentId: string,
+ options: { kinds: AgentMemoryRow['kind'][]; watermark: number; limit: number }
+ ) {
+ const eligible = [...this.rows.values()].filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.superseded_by === null &&
+ row.status !== 'archived' &&
+ row.status !== 'conflicted' &&
+ options.kinds.includes(row.kind)
+ )
+ return {
+ eligibleCount: eligible.length,
+ importanceAfterWatermark: eligible
+ .filter((row) => row.created_at > options.watermark)
+ .reduce((sum, row) => sum + Math.min(1, Math.max(0, row.importance)), 0),
+ maxCreatedAt: eligible.reduce((max, row) => Math.max(max, row.created_at), 0),
+ topRows: eligible
+ .slice()
+ .sort(
+ (a, b) =>
+ b.importance - a.importance || b.created_at - a.created_at || b.id.localeCompare(a.id)
+ )
+ .slice(0, Math.max(0, Math.floor(options.limit)))
+ }
+ }
+
getActivePersona(agentId: string) {
return [...this.rows.values()]
.filter(
@@ -213,25 +271,16 @@ export class FakeRepository implements MemoryRepositoryPort {
.slice(0, limit)
}
- getRecallKeywordTermStats(agentId: string, terms: string[]) {
- const normalizedTerms = [...new Set(terms.map((term) => term.trim().toLowerCase()))].filter(
- Boolean
- )
- const rows = [...this.rows.values()].filter(
- (row) =>
- row.agent_id === agentId &&
- !row.superseded_by &&
- row.conflict_state === null &&
- row.status !== 'archived' &&
- row.status !== 'conflicted' &&
- row.kind !== 'persona' &&
- row.kind !== 'working'
- )
- return normalizedTerms.map((term) => ({
- term,
- hitCount: rows.filter((row) => row.content.toLowerCase().includes(term)).length,
- totalRows: rows.length
- }))
+ searchWithStrategy(
+ agentId: string,
+ query: string,
+ limit = 20,
+ options: { matchMode?: 'all' | 'any' } = {}
+ ) {
+ return {
+ rows: this.search(agentId, query, limit, options),
+ strategy: 'like-fallback' as const
+ }
}
listPendingEmbedding(limit = 50, agentId?: string) {
@@ -274,32 +323,72 @@ export class FakeRepository implements MemoryRepositoryPort {
row.decision_revision += 1
}
- updatePendingEmbeddingStatus(
+ activateForEmbeddingIfRevision(agentId: string, id: string, expectedRevision: number) {
+ const row = this.rows.get(id)
+ if (!row || row.agent_id !== agentId || row.decision_revision !== expectedRevision) return false
+ this.activateForEmbedding(id)
+ return true
+ }
+
+ markPendingEmbeddingsReady(
agentId: string,
- id: string,
- status: AgentMemoryRow['status'],
- embedding?: {
- embeddingId?: string | null
- embeddingDim?: number | null
- embeddingModel?: string | null
+ updates: ReadonlyArray<{
+ id: string
+ expectedRevision: number
+ embeddingId: string
+ embeddingDim: number
+ embeddingModel: string
+ }>
+ ) {
+ const updated: string[] = []
+ for (const update of updates) {
+ const row = this.rows.get(update.id)
+ if (
+ !row ||
+ row.agent_id !== agentId ||
+ row.decision_revision !== update.expectedRevision ||
+ row.status !== 'pending_embedding' ||
+ row.superseded_by !== null ||
+ row.kind === 'persona' ||
+ row.kind === 'working'
+ ) {
+ continue
+ }
+ row.status = 'embedded'
+ row.embedding_id = update.embeddingId
+ row.embedding_dim = update.embeddingDim
+ row.embedding_model = update.embeddingModel
+ updated.push(row.id)
}
+ return updated
+ }
+
+ markPendingEmbeddingsError(
+ agentId: string,
+ updates: ReadonlyArray<{ id: string; expectedRevision: number }>,
+ status: 'error' | 'fts_only' = 'error'
) {
- const row = this.rows.get(id)
- if (
- !row ||
- row.agent_id !== agentId ||
- row.status !== 'pending_embedding' ||
- row.superseded_by ||
- row.kind === 'persona' ||
- row.kind === 'working'
- ) {
- return false
+ const updated: string[] = []
+ for (const update of updates) {
+ const row = this.rows.get(update.id)
+ if (
+ !row ||
+ row.agent_id !== agentId ||
+ row.decision_revision !== update.expectedRevision ||
+ row.status !== 'pending_embedding' ||
+ row.superseded_by !== null ||
+ row.kind === 'persona' ||
+ row.kind === 'working'
+ ) {
+ continue
+ }
+ row.status = status
+ row.embedding_id = null
+ row.embedding_dim = null
+ row.embedding_model = null
+ updated.push(row.id)
}
- row.status = status
- row.embedding_id = embedding?.embeddingId ?? null
- row.embedding_dim = embedding?.embeddingDim ?? null
- row.embedding_model = embedding?.embeddingModel ?? null
- return true
+ return updated
}
requeueForEmbedding(
@@ -360,6 +449,30 @@ export class FakeRepository implements MemoryRepositoryPort {
.map((row) => row.id)
}
+ listCurrentEmbeddedIds(
+ agentId: string,
+ embeddingDim: number,
+ embeddingModel: string,
+ afterId: string | null,
+ limit: number
+ ) {
+ return [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.status === 'embedded' &&
+ row.superseded_by === null &&
+ row.kind !== 'persona' &&
+ row.kind !== 'working' &&
+ row.embedding_dim === embeddingDim &&
+ row.embedding_model === embeddingModel &&
+ (afterId === null || row.id > afterId)
+ )
+ .sort((left, right) => left.id.localeCompare(right.id))
+ .slice(0, Math.max(0, Math.floor(limit)))
+ .map((row) => row.id)
+ }
+
markSuperseded(id: string, supersededBy: string | null) {
const row = this.rows.get(id)
if (row) {
@@ -413,23 +526,6 @@ export class FakeRepository implements MemoryRepositoryPort {
}
}
- refreshDecayScoresForAgent(agentId: string, now: number, halfLifeMs: number) {
- for (const row of this.listByAgent(agentId)) {
- if (row.kind === 'persona') continue
- const anchor = row.last_accessed ?? row.created_at
- const age = Math.max(0, now - anchor)
- const importance = Math.min(1, Math.max(0, row.importance))
- row.decay_score = Math.pow(0.5, age / (halfLifeMs * (1 + importance)))
- }
- }
-
- stampConsolidationForAgent(agentId: string, at: number) {
- for (const row of this.listByAgent(agentId)) {
- if (row.kind === 'persona') continue
- row.last_consolidated_at = at
- }
- }
-
updateContent(
id: string,
content: string,
@@ -656,21 +752,59 @@ export class FakeRepository implements MemoryRepositoryPort {
}
}
- listArchiveCandidates(agentId: string, before: number, decayBelow: number) {
+ archiveEligibleBatch(
+ agentId: string,
+ options: {
+ now: number
+ createdBefore: number
+ minimumBaseAgeMs: number
+ limit: number
+ }
+ ) {
+ const eligible = [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.superseded_by === null &&
+ row.conflict_state === null &&
+ row.status !== 'archived' &&
+ row.status !== 'conflicted' &&
+ row.is_anchor === 0 &&
+ row.kind !== 'persona' &&
+ row.kind !== 'working' &&
+ row.created_at < options.createdBefore &&
+ options.now - (row.last_accessed ?? row.created_at) >
+ options.minimumBaseAgeMs * (1 + Math.min(1, Math.max(0, row.importance)))
+ )
+ .sort(
+ (a, b) =>
+ (a.last_accessed ?? a.created_at) - (b.last_accessed ?? b.created_at) ||
+ a.created_at - b.created_at ||
+ a.id.localeCompare(b.id)
+ )
+ .slice(0, Math.max(0, Math.floor(options.limit)))
+ eligible.forEach((row) => this.archive(row.id, options.now))
+ return eligible.map((row) => row.id)
+ }
+
+ countArchiveEligible(
+ agentId: string,
+ options: { now: number; createdBefore: number; minimumBaseAgeMs: number }
+ ) {
return [...this.rows.values()].filter(
(row) =>
row.agent_id === agentId &&
- !row.superseded_by &&
+ row.superseded_by === null &&
+ row.conflict_state === null &&
row.status !== 'archived' &&
row.status !== 'conflicted' &&
row.is_anchor === 0 &&
row.kind !== 'persona' &&
row.kind !== 'working' &&
- row.access_count === 0 &&
- row.created_at < before &&
- row.decay_score !== null &&
- row.decay_score < decayBelow
- )
+ row.created_at < options.createdBefore &&
+ options.now - (row.last_accessed ?? row.created_at) >
+ options.minimumBaseAgeMs * (1 + Math.min(1, Math.max(0, row.importance)))
+ ).length
}
listArchiveCandidateLifecycleRows(agentId: string, before: number, limit: number) {
@@ -686,7 +820,6 @@ export class FakeRepository implements MemoryRepositoryPort {
row.is_anchor === 0 &&
row.kind !== 'persona' &&
row.kind !== 'working' &&
- row.access_count === 0 &&
row.created_at < before
)
.sort(
@@ -699,10 +832,6 @@ export class FakeRepository implements MemoryRepositoryPort {
.map(toLifecycleRow)
}
- countArchiveCandidates(agentId: string, before: number, decayBelow: number) {
- return this.listArchiveCandidates(agentId, before, decayBelow).length
- }
-
listTopAccessed(agentId: string, limit: number) {
return [...this.rows.values()]
.filter((row) => row.agent_id === agentId && row.kind !== 'working' && row.access_count > 0)
@@ -797,6 +926,128 @@ export class FakeRepository implements MemoryRepositoryPort {
.sort((left, right) => left.created_at - right.created_at || left.id.localeCompare(right.id))
}
+ listConflictChallengersForMaintenance(agentId: string, limit: number): AgentMemoryRow[] {
+ return [...this.rows.values()]
+ .filter((challenger) => {
+ const target = challenger.conflict_with
+ ? this.rows.get(challenger.conflict_with)
+ : undefined
+ return (
+ challenger.agent_id === agentId &&
+ challenger.status === 'conflicted' &&
+ challenger.superseded_by === null &&
+ target?.agent_id === agentId &&
+ target.conflict_state === 'challenged' &&
+ target.superseded_by === null
+ )
+ })
+ .sort(
+ (a, b) =>
+ (a.last_consolidated_at ?? 0) - (b.last_consolidated_at ?? 0) ||
+ a.created_at - b.created_at ||
+ a.id.localeCompare(b.id)
+ )
+ .slice(0, Math.max(0, Math.floor(limit)))
+ }
+
+ listConflictSiblings(
+ agentId: string,
+ targetId: string,
+ excludeChallengerId: string
+ ): AgentMemoryRow[] {
+ return [...this.rows.values()]
+ .filter(
+ (row) =>
+ row.agent_id === agentId &&
+ row.conflict_with === targetId &&
+ row.status === 'conflicted' &&
+ row.superseded_by === null &&
+ row.id !== excludeChallengerId
+ )
+ .sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
+ }
+
+ retireConflictSiblings(
+ agentId: string,
+ targetId: string,
+ excludeChallengerId: string,
+ winnerId: string,
+ at: number
+ ) {
+ const siblings = this.listConflictSiblings(agentId, targetId, excludeChallengerId)
+ for (const sibling of siblings) {
+ sibling.conflict_with = null
+ sibling.superseded_by = winnerId
+ this.archive(sibling.id, at)
+ }
+ return siblings.length
+ }
+
+ clearTargetConflictIfNoChallengers(agentId: string, targetId: string) {
+ const target = this.rows.get(targetId)
+ if (
+ !target ||
+ target.agent_id !== agentId ||
+ target.conflict_state !== 'challenged' ||
+ this.listConflictSiblings(agentId, targetId, '').length > 0
+ ) {
+ return false
+ }
+ this.markConflict(targetId, null)
+ return true
+ }
+
+ repairConflictIntegrityBatch(agentId: string, limit: number) {
+ const result = {
+ repairedTargets: 0,
+ archivedChallengers: 0,
+ clearedTargets: 0,
+ clearedLinks: 0
+ }
+ const rows = this.listConflictIntegrityRows(agentId).slice(
+ 0,
+ Math.max(0, Math.min(256, Math.floor(limit)))
+ )
+ for (const row of rows) {
+ if (row.status !== 'conflicted' && row.conflict_with !== null) {
+ row.conflict_with = null
+ row.decision_revision += 1
+ result.clearedLinks += 1
+ }
+ }
+ for (const challenger of rows) {
+ if (challenger.status !== 'conflicted') continue
+ const target = challenger.conflict_with ? this.rows.get(challenger.conflict_with) : undefined
+ const validTarget =
+ !!target &&
+ target.id !== challenger.id &&
+ target.agent_id === agentId &&
+ target.status !== 'archived' &&
+ target.status !== 'conflicted' &&
+ target.superseded_by === null
+ if (!validTarget || challenger.superseded_by !== null) {
+ challenger.conflict_with = null
+ this.archive(challenger.id)
+ result.archivedChallengers += 1
+ continue
+ }
+ if (target.conflict_state !== 'challenged') {
+ this.markConflict(target.id, 'challenged')
+ result.repairedTargets += 1
+ }
+ }
+ for (const target of rows) {
+ if (
+ target.conflict_state === 'challenged' &&
+ this.listConflictSiblings(agentId, target.id, '').length === 0
+ ) {
+ this.markConflict(target.id, null)
+ result.clearedTargets += 1
+ }
+ }
+ return result
+ }
+
getPersonaCounts(agentId: string): { total: number; draft: number } {
const versions = this.listPersonaVersions(agentId)
return {
@@ -870,6 +1121,22 @@ export class FakeRepository implements MemoryRepositoryPort {
]
}
+ listRecentlyActiveAgentIds(candidateAgentIds: readonly string[], limit: number) {
+ const candidates = new Set(candidateAgentIds)
+ const activityByAgent = new Map()
+ for (const row of this.rows.values()) {
+ if (row.status === 'archived' || !candidates.has(row.agent_id)) continue
+ activityByAgent.set(
+ row.agent_id,
+ Math.max(activityByAgent.get(row.agent_id) ?? 0, row.last_accessed ?? row.created_at)
+ )
+ }
+ return [...activityByAgent.entries()]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, Math.max(0, Math.floor(limit)))
+ .map(([agentId]) => agentId)
+ }
+
listConsolidationScanRows(
agentId: string,
options: {
@@ -1092,6 +1359,26 @@ export class FakeAuditRepository implements MemoryAuditRepositoryPort {
}
return stats
}
+
+ pruneOperationalEvents(agentId: string, keep = 10_000, limit = 500): number {
+ const operationalTypes = new Set([
+ 'memory/maintenance_llm',
+ 'memory/reflect',
+ 'memory/repair',
+ 'memory/conflict_repair',
+ 'memory/extract'
+ ])
+ const normalizedKeep = Math.max(0, Math.floor(keep))
+ const normalizedLimit = Math.min(500, Math.max(0, Math.floor(limit)))
+ const prunableIds = this.rows
+ .filter((row) => row.agent_id === agentId && operationalTypes.has(row.event_type))
+ .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
+ .slice(normalizedKeep, normalizedKeep + normalizedLimit)
+ .map((row) => row.id)
+ const prunable = new Set(prunableIds)
+ this.rows = this.rows.filter((row) => !prunable.has(row.id))
+ return prunable.size
+ }
}
export class FakeVectorStore implements IMemoryVectorStore {
diff --git a/test/main/presenter/memoryAdd.test.ts b/test/main/presenter/memoryAdd.test.ts
index 3df5b26f5..10035d263 100644
--- a/test/main/presenter/memoryAdd.test.ts
+++ b/test/main/presenter/memoryAdd.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { MemoryPresenter } from '@/presenter/memoryPresenter'
import type { DeepChatAgentConfig } from '@shared/types/agent-interface'
+import { AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS } from '@shared/types/agent-memory'
import {
FakeAuditRepository,
FakeRepository,
@@ -46,6 +47,29 @@ function makeLLM(decision: string, config = extractionConfig) {
}
describe('MemoryPresenter.addUserMemory (manual user write)', () => {
+ it('rejects oversized manual content before recall, provider, persistence, or audit work', async () => {
+ const { presenter, repo, auditRepo, generateText } = makeLLM('{"decision":"ADD"}')
+
+ const outcome = await presenter.addUserMemory('deepchat', {
+ content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1)
+ })
+
+ expect(outcome).toEqual({ action: 'noop', reason: 'content-too-large' })
+ expect(repo.listByAgent('deepchat')).toHaveLength(0)
+ expect(auditRepo.listByAgent('deepchat')).toHaveLength(0)
+ expect(generateText).not.toHaveBeenCalled()
+ })
+
+ it('accepts the manual limit when astral characters use two UTF-16 code units', async () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const content = '😀'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS)
+
+ const outcome = await presenter.addUserMemory('deepchat', { content })
+
+ expect(outcome.action).toBe('created')
+ expect(repo.listByAgent('deepchat')[0]?.content).toBe(content)
+ })
+
it('directly adds when no extraction model is configured and audits the user write', async () => {
const { presenter, repo, auditRepo } = makePresenter(enabledConfig)
diff --git a/test/main/presenter/memoryAuditRetention.test.ts b/test/main/presenter/memoryAuditRetention.test.ts
new file mode 100644
index 000000000..b6dbca92e
--- /dev/null
+++ b/test/main/presenter/memoryAuditRetention.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+
+import { FakeAuditRepository } from './fakes/memoryFakes'
+
+function insertAudit(
+ repository: FakeAuditRepository,
+ id: string,
+ eventType: string,
+ createdAt: number,
+ agentId = 'a'
+): void {
+ repository.insert({
+ id,
+ agentId,
+ eventType,
+ actorType: eventType === 'memory/forget' ? 'runtime' : 'scheduler',
+ status: 'completed',
+ inputRefs: eventType === 'memory/forget' ? { memoryId: 'm1' } : {},
+ outputRefs: eventType === 'memory/forget' ? { memoryId: 'm1' } : {},
+ createdAt
+ })
+}
+
+describe('operational memory audit retention', () => {
+ it('keeps the newest rows across the exact allowlist and honors the delete batch cap', () => {
+ const repository = new FakeAuditRepository()
+ const eventTypes = [
+ 'memory/maintenance_llm',
+ 'memory/reflect',
+ 'memory/repair',
+ 'memory/conflict_repair',
+ 'memory/extract'
+ ]
+ for (let index = 0; index < 8; index += 1) {
+ insertAudit(repository, `operational-${index}`, eventTypes[index % eventTypes.length], index)
+ }
+
+ expect(repository.pruneOperationalEvents('a', 3, 2)).toBe(2)
+ expect(repository.pruneOperationalEvents('a', 3, 500)).toBe(3)
+ expect(repository.listByAgent('a', { limit: 100 }).map((row) => row.id)).toEqual([
+ 'operational-7',
+ 'operational-6',
+ 'operational-5'
+ ])
+ })
+
+ it('preserves causal, persona, unknown, and other-agent rows without changing forget semantics', () => {
+ const repository = new FakeAuditRepository()
+ const preservedTypes = [
+ 'memory/forget',
+ 'memory/add',
+ 'memory/archive',
+ 'memory/restore',
+ 'memory/manual_edit',
+ 'memory/challenge_resolved',
+ 'persona/evolve',
+ 'memory/future_event'
+ ]
+ preservedTypes.forEach((eventType, index) =>
+ insertAudit(repository, `preserved-${index}`, eventType, index)
+ )
+ insertAudit(repository, 'other-agent-operational', 'memory/extract', 100, 'b')
+
+ const beforeForget = repository.hasForgetEvent('a', 'm1')
+ expect(repository.pruneOperationalEvents('a', 0, 500)).toBe(0)
+ expect(repository.hasForgetEvent('a', 'm1')).toBe(beforeForget)
+ expect(repository.listByAgent('a', { limit: 100 })).toHaveLength(preservedTypes.length)
+ expect(repository.listByAgent('b', { limit: 100 }).map((row) => row.id)).toEqual([
+ 'other-agent-operational'
+ ])
+ })
+})
diff --git a/test/main/presenter/memoryBatchDecision.test.ts b/test/main/presenter/memoryBatchDecision.test.ts
new file mode 100644
index 000000000..40f5e5fc6
--- /dev/null
+++ b/test/main/presenter/memoryBatchDecision.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildBatchDecisionPrompt,
+ parseBatchDecisionResults,
+ partitionBatchDecisions,
+ type BatchDecisionInput
+} from '@/presenter/memoryPresenter/core/batchDecision'
+
+function input(
+ candidateIndex: number,
+ content = `candidate-${candidateIndex}`
+): BatchDecisionInput {
+ return {
+ candidateIndex,
+ candidate: { kind: 'semantic', category: null, content, importance: 0.5 },
+ neighbors: [
+ { content: `neighbor-${candidateIndex}-0` },
+ { content: `neighbor-${candidateIndex}-1` },
+ { content: `neighbor-${candidateIndex}-2` }
+ ]
+ }
+}
+
+describe('batch memory decisions', () => {
+ it('partitions at four candidates and two batches', () => {
+ const result = partitionBatchDecisions(Array.from({ length: 9 }, (_, index) => input(index)))
+ expect(result.partitions.map((partition) => partition.inputs.length)).toEqual([4, 4])
+ expect(result.fallbackCandidateIndexes).toEqual([8])
+ expect(result.partitions.every((partition) => partition.estimatedTokens <= 12_000)).toBe(true)
+ })
+
+ it('drops neighbor excerpts before falling back under a dense token budget', () => {
+ const dense = input(0, '\u4e2d'.repeat(2_000))
+ dense.neighbors = [{ content: '\u6587'.repeat(400) }, { content: '\u5b57'.repeat(400) }]
+ const result = partitionBatchDecisions([dense])
+ expect(result.fallbackCandidateIndexes).toEqual([])
+ expect(result.partitions).toHaveLength(1)
+ expect(result.partitions[0].estimatedTokens).toBeLessThanOrEqual(12_000)
+ })
+
+ it('drops the lowest-priority batch neighbors before splitting dense candidates', () => {
+ const dense = Array.from({ length: 4 }, (_, index) => input(index, '\u4e2d'.repeat(1_700)))
+ dense.forEach((item) => {
+ item.neighbors = Array.from({ length: 5 }, (_, index) => ({
+ content: `${index}${'\u6587'.repeat(399)}`
+ }))
+ })
+ const result = partitionBatchDecisions(dense)
+ expect(result.partitions.map((partition) => partition.inputs.length)).toEqual([4])
+ expect(result.partitions[0].inputs.every((item) => item.neighbors.length <= 3)).toBe(true)
+ expect(result.partitions[0].inputs.some((item) => item.neighbors.length < 3)).toBe(true)
+ expect(result.partitions[0].estimatedTokens).toBeLessThanOrEqual(12_000)
+ expect(result.fallbackCandidateIndexes).toEqual([])
+ })
+
+ it('renders indexed candidates and untrusted-data guidance', () => {
+ const prompt = buildBatchDecisionPrompt([input(3)])
+ expect(prompt).toContain('Candidate 3')
+ expect(prompt).toContain('[0] neighbor-3-0')
+ expect(prompt).toContain('untrusted')
+ })
+
+ it('accepts the first valid occurrence for each candidate index', () => {
+ const results = parseBatchDecisionResults(
+ '[{"candidateIndex":0,"decision":"NOOP","targetIndex":1},' +
+ '{"candidateIndex":0,"decision":"ADD","targetIndex":null}]',
+ [input(0)]
+ )
+ expect(results.get(0)).toMatchObject({
+ valid: true,
+ decision: { decision: 'NOOP', targetIndex: 1 }
+ })
+ })
+
+ it('marks oversized merged content invalid without affecting other items', () => {
+ const results = parseBatchDecisionResults(
+ JSON.stringify([
+ {
+ candidateIndex: 0,
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: 'x'.repeat(2_001)
+ },
+ { candidateIndex: 1, decision: 'ADD', targetIndex: null }
+ ]),
+ [input(0), input(1)]
+ )
+ expect(results.get(0)?.valid).toBe(false)
+ expect(results.get(1)?.valid).toBe(true)
+ })
+
+ it('applies merged-content and neighbor limits in Unicode code points', () => {
+ const accepted = parseBatchDecisionResults(
+ JSON.stringify({
+ candidateIndex: 0,
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: '😀'.repeat(2_000)
+ }),
+ [input(0)]
+ )
+ const rejected = parseBatchDecisionResults(
+ JSON.stringify({
+ candidateIndex: 0,
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: '😀'.repeat(2_001)
+ }),
+ [input(0)]
+ )
+ const neighborInput = input(0)
+ neighborInput.neighbors = [{ content: '😀'.repeat(401) }]
+ const prompt = buildBatchDecisionPrompt([neighborInput])
+
+ expect(accepted.get(0)?.valid).toBe(true)
+ expect(rejected.get(0)?.valid).toBe(false)
+ expect(prompt).toContain('😀'.repeat(400))
+ expect(prompt).not.toContain('😀'.repeat(401))
+ })
+
+ it('ignores missing, unknown, and malformed candidate indexes', () => {
+ const results = parseBatchDecisionResults(
+ '[{"candidateIndex":9,"decision":"ADD"},{"decision":"ADD"}]',
+ [input(0)]
+ )
+ expect(results.size).toBe(0)
+ })
+
+ it('rejects an explicit mismatched index in the single-object compatibility shape', () => {
+ const results = parseBatchDecisionResults(
+ '{"candidateIndex":999,"decision":"ADD","targetIndex":null}',
+ [input(0)]
+ )
+ expect(results.size).toBe(0)
+ })
+})
diff --git a/test/main/presenter/memoryEmbeddingScale.test.ts b/test/main/presenter/memoryEmbeddingScale.test.ts
new file mode 100644
index 000000000..51171af38
--- /dev/null
+++ b/test/main/presenter/memoryEmbeddingScale.test.ts
@@ -0,0 +1,513 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { MemoryPresenter as BaseMemoryPresenter } from '@/presenter/memoryPresenter'
+import type { VectorReadyCertificate } from '@/presenter/memoryPresenter/infra/vectorStoreManager'
+import type { IMemoryVectorStore } from '@/presenter/memoryPresenter/types'
+import {
+ EMBEDDING_WARM_FAILURE_COOLDOWN_MS,
+ VECTOR_STORE_IDLE_TTL_MS,
+ VECTOR_STORE_SOFT_CAP
+} from '@/presenter/memoryPresenter/runtimeConstants'
+import { enabledConfig, FakeRepository, FakeVectorStore, textToVector } from './fakes/memoryFakes'
+
+class MemoryPresenter extends BaseMemoryPresenter {
+ constructor(deps: ConstructorParameters[0]) {
+ super({ executeWithRateLimit: vi.fn(async () => undefined), ...deps })
+ }
+}
+
+interface EmbeddingScaleTestSeams {
+ embedding: {
+ warmEmbeddingConnection(
+ agentId: string,
+ embedding: { providerId: string; modelId: string }
+ ): void
+ getMutableRuntimeStateForTests(): {
+ embeddingWarmups: Map>
+ embeddingWarmSuccesses: Set
+ embeddingWarmFailureUntil: Map
+ }
+ }
+ vectorStore: {
+ noteEmbeddingConfig(
+ agentId: string,
+ embedding: { providerId: string; modelId: string } | null
+ ): void
+ withStoreLease(
+ agentId: string,
+ embedding: { providerId: string; modelId: string },
+ dimensions: number,
+ task: (store: IMemoryVectorStore, generation: number) => Promise
+ ): Promise
+ markReady(
+ agentId: string,
+ embedding: { providerId: string; modelId: string },
+ dimensions: number
+ ): void
+ runResourceConvergenceForTests(now?: number): Promise
+ getResourceStatsForTests(): { openStores: number }
+ getMutableRuntimeStateForTests(): {
+ vectorStores: Map>
+ vectorStoreReady: Map
+ }
+ }
+}
+
+function internals(presenter: MemoryPresenter): EmbeddingScaleTestSeams {
+ return presenter as unknown as EmbeddingScaleTestSeams
+}
+
+function makeScalePresenter(options: {
+ repository?: FakeRepository
+ getEmbeddings?: (providerId: string, modelId: string, texts: string[]) => Promise
+ createVectorStore?: (
+ agentId: string,
+ embedding: { providerId: string; modelId: string },
+ dimensions: number
+ ) => Promise
+}) {
+ const repository = options.repository ?? new FakeRepository()
+ const store = new FakeVectorStore()
+ const presenter = new MemoryPresenter({
+ repository,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings:
+ options.getEmbeddings ??
+ (async (_providerId, _modelId, texts) => texts.map((text) => textToVector(text))),
+ createVectorStore: options.createVectorStore ?? (async () => store),
+ resetVectorStore: async () => undefined
+ })
+ return { presenter, repository, store }
+}
+
+async function settleWarmup(presenter: MemoryPresenter): Promise {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ if (
+ internals(presenter).embedding.getMutableRuntimeStateForTests().embeddingWarmups.size === 0
+ ) {
+ return
+ }
+ await Promise.resolve()
+ }
+ throw new Error('embedding warmup did not settle')
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+})
+
+describe('embedding persistence scale contract', () => {
+ it('materializes once and persists a valid batch with one ready update', async () => {
+ const { presenter, repository, store } = makeScalePresenter({})
+ for (const id of ['m1', 'm2']) {
+ repository.insert({
+ id,
+ agentId: 'a',
+ kind: 'semantic',
+ content: `memory ${id}`,
+ status: 'pending_embedding'
+ })
+ }
+ const listByIds = vi.spyOn(repository, 'listByIds')
+ const markReady = vi.spyOn(repository, 'markPendingEmbeddingsReady')
+ const markError = vi.spyOn(repository, 'markPendingEmbeddingsError')
+ const upsert = vi.spyOn(store, 'upsert')
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(listByIds).toHaveBeenCalledTimes(1)
+ expect(listByIds).toHaveBeenCalledWith('a', ['m1', 'm2'])
+ expect(upsert).toHaveBeenCalledTimes(1)
+ expect(upsert.mock.calls[0][0]).toHaveLength(2)
+ expect(markReady).toHaveBeenCalledTimes(1)
+ expect(markReady.mock.calls[0][1]).toHaveLength(2)
+ expect(markError).not.toHaveBeenCalled()
+ await presenter.dispose()
+ })
+
+ it('drains a 101-row backlog in fixed 50-row chunks without another trigger', async () => {
+ const repository = new FakeRepository()
+ for (let index = 0; index < 101; index += 1) {
+ repository.insert({
+ id: `m-${String(index).padStart(3, '0')}`,
+ agentId: 'a',
+ kind: 'semantic',
+ content: `memory ${index}`,
+ status: 'pending_embedding'
+ })
+ }
+ const getEmbeddings = vi.fn(async (_providerId: string, _modelId: string, texts: string[]) =>
+ texts.map((text) => textToVector(text))
+ )
+ const { presenter } = makeScalePresenter({ repository, getEmbeddings })
+
+ await presenter.processPendingEmbeddings('a', 500)
+
+ expect(getEmbeddings.mock.calls.map((call) => call[2].length)).toEqual([50, 50, 1])
+ expect(repository.listPendingEmbedding(200, 'a')).toEqual([])
+ expect(repository.listEmbeddingStatusIds('a', ['embedded'], 200).length).toBe(101)
+ await presenter.dispose()
+ })
+
+ it('continues draining a concurrently edited row at its new revision', async () => {
+ const repository = new FakeRepository()
+ repository.insert({
+ id: 'm1',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'original memory',
+ status: 'pending_embedding'
+ })
+ const store = new FakeVectorStore()
+ const upsert = vi.spyOn(store, 'upsert').mockImplementation(async (records) => {
+ for (const record of records) store.vectors.set(record.memoryId, record.embedding)
+ repository.updateDecisionContentIfRevision({
+ agentId: 'a',
+ id: 'm1',
+ expectedRevision: 1,
+ content: 'edited memory',
+ provenanceKey: null,
+ at: 1
+ })
+ })
+ const { presenter } = makeScalePresenter({
+ repository,
+ createVectorStore: async () => store
+ })
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(upsert).toHaveBeenCalledTimes(2)
+ expect(store.vectors.has('m1')).toBe(true)
+ expect(repository.getById('m1')).toMatchObject({
+ content: 'edited memory',
+ status: 'embedded',
+ decision_revision: 2,
+ embedding_id: 'm1'
+ })
+ await presenter.dispose()
+ })
+
+ it('does no persistence work when the provider fails the whole batch', async () => {
+ const repository = new FakeRepository()
+ repository.insert({
+ id: 'm1',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'retryable memory',
+ status: 'pending_embedding'
+ })
+ const { presenter } = makeScalePresenter({
+ repository,
+ getEmbeddings: async () => {
+ throw new Error('provider unavailable')
+ }
+ })
+ const listByIds = vi.spyOn(repository, 'listByIds')
+ const markReady = vi.spyOn(repository, 'markPendingEmbeddingsReady')
+ const markError = vi.spyOn(repository, 'markPendingEmbeddingsError')
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(listByIds).not.toHaveBeenCalled()
+ expect(markReady).not.toHaveBeenCalled()
+ expect(markError).not.toHaveBeenCalled()
+ expect(repository.getById('m1')?.status).toBe('pending_embedding')
+ await presenter.dispose()
+ })
+
+ it('marks a malformed-only batch in one update without opening a vector store', async () => {
+ const repository = new FakeRepository()
+ repository.insert({
+ id: 'm1',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'malformed vector memory',
+ status: 'pending_embedding'
+ })
+ const createVectorStore = vi.fn(async () => new FakeVectorStore())
+ const { presenter } = makeScalePresenter({
+ repository,
+ getEmbeddings: async () => [[Number.NaN, 2]],
+ createVectorStore
+ })
+ const markError = vi.spyOn(repository, 'markPendingEmbeddingsError')
+
+ await presenter.processPendingEmbeddings('a')
+
+ expect(createVectorStore).not.toHaveBeenCalled()
+ expect(markError).toHaveBeenCalledTimes(1)
+ expect(markError.mock.calls[0][1]).toEqual([{ id: 'm1', expectedRevision: 1 }])
+ expect(repository.getById('m1')?.status).toBe('error')
+ await presenter.dispose()
+ })
+
+ it('shares one dirty drain promise and batches malformed vector failures', async () => {
+ const repository = new FakeRepository()
+ for (const id of ['valid', 'invalid']) {
+ repository.insert({
+ id,
+ agentId: 'a',
+ kind: 'semantic',
+ content: id,
+ status: 'pending_embedding'
+ })
+ }
+ let release!: () => void
+ let resolveStarted!: () => void
+ const providerStarted = new Promise((resolve) => {
+ resolveStarted = resolve
+ })
+ const getEmbeddings = vi.fn(
+ async () =>
+ new Promise((resolve) => {
+ release = () =>
+ resolve([
+ [1, 2],
+ [Number.NaN, 2]
+ ])
+ resolveStarted()
+ })
+ )
+ const { presenter, store } = makeScalePresenter({ repository, getEmbeddings })
+ const markReady = vi.spyOn(repository, 'markPendingEmbeddingsReady')
+ const markError = vi.spyOn(repository, 'markPendingEmbeddingsError')
+ const upsert = vi.spyOn(store, 'upsert')
+
+ const first = presenter.processPendingEmbeddings('a')
+ await providerStarted
+ const second = presenter.processPendingEmbeddings('a')
+ expect(second).toBe(first)
+ release()
+ await Promise.all([first, second])
+
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
+ expect(upsert).toHaveBeenCalledTimes(1)
+ expect(markReady).toHaveBeenCalledTimes(1)
+ expect(markReady.mock.calls[0][1].map((update) => update.id)).toEqual(['valid'])
+ expect(markError).toHaveBeenCalledTimes(1)
+ expect(markError.mock.calls[0][1]).toEqual([{ id: 'invalid', expectedRevision: 1 }])
+ await presenter.dispose()
+ })
+})
+
+describe('embedding warm and vector resource bounds', () => {
+ it('drains the old identity before admitting a lease for changed embedding config', async () => {
+ let config = {
+ memoryEnabled: true,
+ memoryEmbedding: { providerId: 'old-provider', modelId: 'old-model' }
+ }
+ const stores: FakeVectorStore[] = []
+ const presenter = new MemoryPresenter({
+ repository: new FakeRepository(),
+ resolveAgentConfig: () => config,
+ getEmbeddings: async () => [],
+ createVectorStore: async () => {
+ const store = new FakeVectorStore()
+ stores.push(store)
+ return store
+ },
+ resetVectorStore: async () => undefined
+ })
+ const vectorStore = internals(presenter).vectorStore
+ let releaseOldLease!: () => void
+ let resolveOldLeaseStarted!: () => void
+ const oldLeaseStarted = new Promise((resolve) => {
+ resolveOldLeaseStarted = resolve
+ })
+ const oldEmbedding = { providerId: 'old-provider', modelId: 'old-model' }
+ const oldLease = vectorStore.withStoreLease('a', oldEmbedding, 4, async () => {
+ resolveOldLeaseStarted()
+ await new Promise((resolve) => {
+ releaseOldLease = resolve
+ })
+ })
+ await oldLeaseStarted
+ vectorStore.markReady('a', oldEmbedding, 4)
+ expect(vectorStore.getMutableRuntimeStateForTests().vectorStoreReady.has('a')).toBe(true)
+
+ const nextEmbedding = { providerId: 'next-provider', modelId: 'next-model' }
+ config = { memoryEnabled: true, memoryEmbedding: nextEmbedding }
+ vectorStore.noteEmbeddingConfig('a', nextEmbedding)
+ expect(vectorStore.getMutableRuntimeStateForTests().vectorStoreReady.has('a')).toBe(false)
+
+ let nextLeaseStarted = false
+ const nextLease = vectorStore.withStoreLease('a', nextEmbedding, 4, async () => {
+ nextLeaseStarted = true
+ })
+ await Promise.resolve()
+ expect(nextLeaseStarted).toBe(false)
+ expect(stores[0].closeCount).toBe(0)
+
+ releaseOldLease()
+ await Promise.all([oldLease, nextLease])
+ expect(stores[0].closeCount).toBe(1)
+ expect(stores).toHaveLength(2)
+ expect(nextLeaseStarted).toBe(true)
+ await presenter.dispose()
+ })
+
+ it('caches provider/model warm success and cools down failures for five minutes', async () => {
+ vi.useFakeTimers()
+ const getEmbeddings = vi
+ .fn<(providerId: string, modelId: string, texts: string[]) => Promise>()
+ .mockRejectedValueOnce(new Error('warm failed'))
+ .mockResolvedValue([[1, 2, 3, 4]])
+ const { presenter } = makeScalePresenter({ getEmbeddings })
+ const embedding = { providerId: 'p', modelId: 'm' }
+
+ internals(presenter).embedding.warmEmbeddingConnection('agent-a', embedding)
+ await settleWarmup(presenter)
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
+
+ internals(presenter).embedding.warmEmbeddingConnection('agent-b', embedding)
+ await Promise.resolve()
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
+
+ await vi.advanceTimersByTimeAsync(EMBEDDING_WARM_FAILURE_COOLDOWN_MS)
+ internals(presenter).embedding.warmEmbeddingConnection('agent-b', embedding)
+ await settleWarmup(presenter)
+ expect(getEmbeddings).toHaveBeenCalledTimes(2)
+
+ internals(presenter).embedding.warmEmbeddingConnection('agent-a', embedding)
+ await Promise.resolve()
+ expect(getEmbeddings).toHaveBeenCalledTimes(2)
+ await presenter.dispose()
+ })
+
+ it('evicts the least recently used store while retaining its ready certificate', async () => {
+ vi.useFakeTimers()
+ const repository = new FakeRepository()
+ const stores = new Map()
+ const { presenter } = makeScalePresenter({
+ repository,
+ createVectorStore: async (agentId) => {
+ const store = new FakeVectorStore()
+ const versions = stores.get(agentId) ?? []
+ versions.push(store)
+ stores.set(agentId, versions)
+ return store
+ }
+ })
+ const vectorStore = internals(presenter).vectorStore
+ const baseTime = 1_000_000
+
+ for (let index = 0; index <= VECTOR_STORE_SOFT_CAP; index += 1) {
+ vi.setSystemTime(baseTime + index)
+ const agentId = `agent-${index}`
+ repository.insert({
+ id: `memory-${index}`,
+ agentId,
+ kind: 'semantic',
+ content: `memory ${index}`,
+ status: 'pending_embedding'
+ })
+ await presenter.processPendingEmbeddings(agentId)
+ }
+ const runtime = vectorStore.getMutableRuntimeStateForTests()
+ const certificate = runtime.vectorStoreReady.get('agent-0')
+
+ await vectorStore.runResourceConvergenceForTests(baseTime + VECTOR_STORE_SOFT_CAP)
+
+ expect(vectorStore.getResourceStatsForTests().openStores).toBe(VECTOR_STORE_SOFT_CAP)
+ expect(runtime.vectorStores.has('agent-0')).toBe(false)
+ expect(runtime.vectorStoreReady.get('agent-0')).toEqual(certificate)
+ expect(stores.get('agent-0')?.[0].closeCount).toBe(1)
+
+ vi.setSystemTime(baseTime + VECTOR_STORE_SOFT_CAP + 1)
+ await presenter.recall('agent-0', 'memory 0')
+ expect(stores.get('agent-0')).toHaveLength(2)
+ expect(runtime.vectorStoreReady.get('agent-0')).toEqual(certificate)
+ await presenter.dispose()
+ })
+
+ it('closes an idle store at the TTL without invalidating readiness', async () => {
+ vi.useFakeTimers()
+ const repository = new FakeRepository()
+ const { presenter, store } = makeScalePresenter({ repository })
+ vi.setSystemTime(1_000_000)
+ repository.insert({
+ id: 'm1',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'idle memory',
+ status: 'pending_embedding'
+ })
+ await presenter.processPendingEmbeddings('a')
+ const vectorStore = internals(presenter).vectorStore
+ const runtime = vectorStore.getMutableRuntimeStateForTests()
+ const certificate = runtime.vectorStoreReady.get('a')
+
+ await vectorStore.runResourceConvergenceForTests(1_000_000 + VECTOR_STORE_IDLE_TTL_MS)
+
+ expect(vectorStore.getResourceStatsForTests().openStores).toBe(0)
+ expect(store.closeCount).toBe(1)
+ expect(runtime.vectorStoreReady.get('a')).toEqual(certificate)
+ await presenter.dispose()
+ })
+
+ it('skips an active lease and evicts the next idle store when over capacity', async () => {
+ vi.useFakeTimers()
+ const repository = new FakeRepository()
+ const stores = new Map()
+ let blockOldestQuery = false
+ let releaseOldestQuery!: () => void
+ let resolveQueryStarted!: () => void
+ const queryStarted = new Promise((resolve) => {
+ resolveQueryStarted = resolve
+ })
+ const { presenter } = makeScalePresenter({
+ repository,
+ createVectorStore: async (agentId) => {
+ const store = new FakeVectorStore()
+ if (agentId === 'agent-0') {
+ vi.spyOn(store, 'query').mockImplementation(async (embedding, options) => {
+ if (!blockOldestQuery) {
+ return FakeVectorStore.prototype.query.call(store, embedding, options)
+ }
+ resolveQueryStarted()
+ return new Promise((resolve) => {
+ releaseOldestQuery = () => resolve([])
+ })
+ })
+ }
+ stores.set(agentId, store)
+ return store
+ }
+ })
+ const vectorStore = internals(presenter).vectorStore
+ const baseTime = 2_000_000
+
+ for (let index = 0; index <= VECTOR_STORE_SOFT_CAP; index += 1) {
+ vi.setSystemTime(baseTime + index)
+ const agentId = `agent-${index}`
+ repository.insert({
+ id: `memory-${index}`,
+ agentId,
+ kind: 'semantic',
+ content: `memory ${index}`,
+ status: 'pending_embedding'
+ })
+ await presenter.processPendingEmbeddings(agentId)
+ }
+
+ vi.setSystemTime(baseTime)
+ blockOldestQuery = true
+ const recall = presenter.recall('agent-0', 'memory 0')
+ await queryStarted
+
+ await vectorStore.runResourceConvergenceForTests(baseTime + VECTOR_STORE_SOFT_CAP)
+
+ const runtime = vectorStore.getMutableRuntimeStateForTests()
+ expect(vectorStore.getResourceStatsForTests().openStores).toBe(VECTOR_STORE_SOFT_CAP)
+ expect(runtime.vectorStores.has('agent-0')).toBe(true)
+ expect(runtime.vectorStores.has('agent-1')).toBe(false)
+ expect(stores.get('agent-0')?.closeCount).toBe(0)
+ expect(stores.get('agent-1')?.closeCount).toBe(1)
+
+ releaseOldestQuery()
+ await recall
+ await presenter.dispose()
+ })
+})
diff --git a/test/main/presenter/memoryExtraction.test.ts b/test/main/presenter/memoryExtraction.test.ts
index 1cad9235b..8b25b7395 100644
--- a/test/main/presenter/memoryExtraction.test.ts
+++ b/test/main/presenter/memoryExtraction.test.ts
@@ -643,6 +643,11 @@ function makeFakeRepo() {
return row
},
getById: (id: string) => rows.get(id),
+ listByIds: (agentId: string, ids: string[]) =>
+ ids.flatMap((id) => {
+ const row = rows.get(id)
+ return row?.agent_id === agentId ? [row] : []
+ }),
getByProvenanceKey: (agentId: string, key: string) =>
[...rows.values()].find((r) => r.agent_id === agentId && r.provenance_key === key),
listByAgent: (agentId: string, opts?: any) => {
@@ -655,6 +660,39 @@ function makeFakeRepo() {
if (opts?.limit) result = result.slice(0, opts.limit)
return result
},
+ getCognitiveMaintenanceInput: (
+ agentId: string,
+ options: { kinds: string[]; watermark: number; limit: number }
+ ) => {
+ const eligible = [...rows.values()].filter(
+ (row) =>
+ row.agent_id === agentId &&
+ options.kinds.includes(row.kind) &&
+ row.status !== 'archived' &&
+ row.status !== 'conflicted' &&
+ !row.superseded_by
+ )
+ const afterWatermark = eligible.filter((row) => row.created_at > options.watermark)
+ return {
+ eligibleCount: eligible.length,
+ importanceAfterWatermark: afterWatermark.reduce(
+ (sum, row) => sum + Number(row.importance ?? 0),
+ 0
+ ),
+ maxCreatedAt: afterWatermark.reduce(
+ (max, row) => Math.max(max, Number(row.created_at ?? 0)),
+ options.watermark
+ ),
+ topRows: eligible
+ .sort(
+ (left, right) =>
+ right.importance - left.importance ||
+ right.created_at - left.created_at ||
+ right.id.localeCompare(left.id)
+ )
+ .slice(0, options.limit)
+ }
+ },
getActivePersona: () => undefined,
listPersonaVersions: () => [],
search: () => [],
@@ -662,24 +700,6 @@ function makeFakeRepo() {
[...rows.values()]
.filter((r) => r.status === 'pending_embedding' && (!agentId || r.agent_id === agentId))
.slice(0, limit),
- updatePendingEmbeddingStatus: (
- agentId: string,
- id: string,
- status: string,
- embedding?: {
- embeddingId?: string | null
- embeddingDim?: number | null
- embeddingModel?: string | null
- }
- ) => {
- const r = rows.get(id)
- if (!r || r.agent_id !== agentId || r.status !== 'pending_embedding') return false
- r.status = status
- r.embedding_id = embedding?.embeddingId ?? null
- r.embedding_dim = embedding?.embeddingDim ?? null
- r.embedding_model = embedding?.embeddingModel ?? null
- return true
- },
updateStatus: (id: string, status: string) => {
const r = rows.get(id)
if (r) r.status = status
@@ -784,9 +804,7 @@ function makeFakeRepo() {
const r = rows.get(id)
if (r) r.status = 'archived'
},
- listArchiveCandidates: () => [],
listArchiveCandidateLifecycleRows: () => [],
- countArchiveCandidates: () => 0,
listTopAccessed: () => [],
delete: (id: string) => rows.delete(id),
clearByAgent: (agentId: string) => {
@@ -855,8 +873,6 @@ function makeFakeRepo() {
)
.slice(0, Math.max(0, Math.floor(limit))),
listConsolidationScanRows: () => [],
- refreshDecayScoresForAgent: () => {},
- stampConsolidationForAgent: () => {},
repairInternalKindStatuses: () => 0,
listPrunableVectorRefs: () => [],
filterPrunableVectorRefs: () => [],
diff --git a/test/main/presenter/memoryLifecycle.test.ts b/test/main/presenter/memoryLifecycle.test.ts
index 6a06cf2c7..de4790b85 100644
--- a/test/main/presenter/memoryLifecycle.test.ts
+++ b/test/main/presenter/memoryLifecycle.test.ts
@@ -88,7 +88,7 @@ describe('deriveLifecycle', () => {
expect(lifecycle.forget.materializedStale).toBe(false)
})
- it('marks missing or divergent materialized decay as stale diagnostics', () => {
+ it('treats missing materialized decay as absent and divergent legacy snapshots as stale', () => {
const missing = deriveLifecycle(makeRow({ decay_score: null }), NOW)
const divergent = deriveLifecycle(
makeRow({
@@ -99,7 +99,7 @@ describe('deriveLifecycle', () => {
)
expect(missing.forget.materializedDecay).toBeNull()
- expect(missing.forget.materializedStale).toBe(true)
+ expect(missing.forget.materializedStale).toBe(false)
expect(divergent.forget.decayScore).toBeLessThan(0.05)
expect(divergent.forget.materializedDecay).toBe(0.9)
expect(divergent.forget.materializedStale).toBe(true)
@@ -127,12 +127,15 @@ describe('deriveLifecycle', () => {
deriveLifecycle(makeRow({ created_at: NOW - 90 * DAY_MS, access_count: 1 }), NOW).decayTier
).toBe('aging')
- const stale = deriveLifecycle(makeRow({ created_at: NOW - 220 * DAY_MS, access_count: 1 }), NOW)
+ const stale = deriveLifecycle(
+ makeRow({ created_at: NOW - 220 * DAY_MS, access_count: 1, is_anchor: 1 }),
+ NOW
+ )
expect(stale.decayTier).toBe('stale')
expect(stale.archiveEligibility.eligible).toBe(false)
const archiveCandidate = deriveLifecycle(
- makeRow({ created_at: NOW - 220 * DAY_MS, access_count: 0 }),
+ makeRow({ created_at: NOW - 220 * DAY_MS, access_count: 1 }),
NOW
)
expect(archiveCandidate.decayTier).toBe('archive_candidate')
@@ -169,7 +172,7 @@ describe('deriveLifecycle', () => {
expect(lifecycle.archiveEligibility.eligible).toBe(false)
expect(lifecycle.archiveEligibility.gaps.daysUntilOldEnough).toBeGreaterThan(70)
expect(lifecycle.archiveEligibility.gaps.decayAboveThresholdBy).toBeGreaterThan(0)
- expect(lifecycle.archiveEligibility.gaps.accessCount).toBe(3)
+ expect(lifecycle.archiveEligibility.neverAccessed).toBe(false)
})
})
@@ -288,10 +291,10 @@ describe('MemoryPresenter.getLifecycle', () => {
)
expect(preview.previewLimit).toBe(MEMORY_ARCHIVE_CANDIDATE_LIFECYCLE_PREVIEW_LIMIT)
expect(preview.scanLimit).toBe(MEMORY_ARCHIVE_CANDIDATE_LIFECYCLE_SCAN_LIMIT)
- expect(preview.scanned).toBe(3)
+ expect(preview.scanned).toBe(4)
expect(preview.previewTruncated).toBe(false)
expect(preview.scanTruncated).toBe(false)
- expect(ids).toEqual(['eligible-null', 'eligible-stale-materialized'])
+ expect(ids).toEqual(['accessed', 'eligible-null', 'eligible-stale-materialized'])
expect(lifecycles.every((lifecycle) => lifecycle.archiveEligibility.eligible)).toBe(true)
expect(lifecycles[0].forget.decayScore).toBeLessThanOrEqual(lifecycles[1].forget.decayScore)
expect(
@@ -418,7 +421,7 @@ describe('MemoryPresenter.getLifecycle', () => {
expect(accessed.decayTier).toBe('stale')
})
- it('keeps archive eligibility equivalent to the four real archive conditions', () => {
+ it('keeps archive eligibility independent from lifetime access count', () => {
for (const oldEnough of [false, true]) {
for (const decayedEnough of [false, true]) {
for (const neverAccessed of [false, true]) {
@@ -434,7 +437,7 @@ describe('MemoryPresenter.getLifecycle', () => {
})
const lifecycle = deriveLifecycle(row, NOW)
- const expected = oldEnough && decayedEnough && neverAccessed && active
+ const expected = oldEnough && decayedEnough && active
expect(lifecycle.archiveEligibility.oldEnough).toBe(oldEnough)
expect(lifecycle.archiveEligibility.decayedEnough).toBe(decayedEnough)
@@ -520,7 +523,7 @@ describe('MemoryPresenter.getLifecycle', () => {
.map((row) => row.id)
.sort()
- expect(expectedArchived).toEqual(['eligible'])
+ expect(expectedArchived).toEqual(['accessed', 'eligible'])
expect(presenter.archiveStale('a', NOW)).toBe(expectedArchived.length)
expect(
[...repo.rows.values()]
diff --git a/test/main/presenter/memoryMaintenanceBudget.test.ts b/test/main/presenter/memoryMaintenanceBudget.test.ts
new file mode 100644
index 000000000..7be8bb3db
--- /dev/null
+++ b/test/main/presenter/memoryMaintenanceBudget.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ MaintenanceBudget,
+ selectMaintenanceRowsWithinTokenBudget
+} from '@/presenter/memoryPresenter/core/maintenanceBudget'
+
+describe('MaintenanceBudget', () => {
+ it('selects priority-ordered rows without letting an oversized row block later rows', () => {
+ expect(
+ selectMaintenanceRowsWithinTokenBudget(
+ [
+ { id: 'high', tokens: 4 },
+ { id: 'oversized', tokens: 10 },
+ { id: 'lower', tokens: 2 }
+ ],
+ 6,
+ (row) => row.tokens
+ ).map((row) => row.id)
+ ).toEqual(['high', 'lower'])
+ })
+
+ it('enforces non-borrowable step quotas and the global token ceiling', () => {
+ const budget = new MaintenanceBudget()
+ expect(Array.from({ length: 4 }, () => budget.reserve('challenge', 1_000))).toEqual([
+ true,
+ true,
+ true,
+ true
+ ])
+ expect(budget.reserve('challenge', 1)).toBe(false)
+ expect(budget.reserve('merge', 9_000)).toBe(true)
+ expect(budget.reserve('merge', 9_000)).toBe(true)
+ expect(budget.reserve('reflection', 5_001)).toBe(false)
+ expect(budget.snapshot()).toMatchObject({ calls: 6, inputTokens: 22_000 })
+ })
+
+ it('does not let unused quota move between steps', () => {
+ const budget = new MaintenanceBudget()
+ expect(budget.reserve('persona', 1)).toBe(true)
+ expect(budget.reserve('persona', 1)).toBe(false)
+ expect(budget.reserve('reflection', 1)).toBe(true)
+ expect(budget.reserve('reflection', 1)).toBe(false)
+ })
+})
diff --git a/test/main/presenter/memoryNativeMigration.test.ts b/test/main/presenter/memoryNativeMigration.test.ts
index 3bc3e34cb..e830016d0 100644
--- a/test/main/presenter/memoryNativeMigration.test.ts
+++ b/test/main/presenter/memoryNativeMigration.test.ts
@@ -1,44 +1,27 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { expect, it, vi } from 'vitest'
+import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness'
const actualFs = await vi.importActual('node:fs')
-const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null)
-const presenterModule = sqliteModule
+const presenterModule = Database
? await import('@/presenter/sqlitePresenter').catch(() => null)
: null
+const importerModule = Database
+ ? await import('@/presenter/sqlitePresenter/importData').catch(() => null)
+ : null
-const Database = sqliteModule?.default
const SQLitePresenter = presenterModule?.SQLitePresenter
+const DataImporter = importerModule?.DataImporter
const DatabaseCtor = Database!
const SQLitePresenterCtor = SQLitePresenter!
-const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1'
-
-let sqliteAvailable = false
-let sqliteUnavailableReason = 'Native SQLite presenter is unavailable'
-if (Database) {
- try {
- const smokeDb = new Database(':memory:')
- smokeDb.close()
- sqliteAvailable = true
- } catch (error) {
- sqliteUnavailableReason = error instanceof Error ? error.message : String(error)
- }
-}
-
-const nativeHarnessAvailable = sqliteAvailable && SQLitePresenter
-const describeIfNative = nativeHarnessAvailable
- ? describe
- : requireNativeSqlite
- ? (name: string, _suite: () => void) =>
- describe(name, () => {
- it('requires the native SQLite presenter', () => {
- throw new Error(sqliteUnavailableReason)
- })
- })
- : describe.skip
+const DataImporterCtor = DataImporter!
+const describeIfNative = nativeSqliteDescribeIf(
+ Boolean(SQLitePresenter && DataImporter),
+ 'Native SQLite presenter or importer is unavailable'
+)
function withTemporaryDatabase(run: (databasePath: string) => void): void {
const directory = actualFs.mkdtempSync(join(tmpdir(), 'deepchat-memory-migration-'))
@@ -67,6 +50,49 @@ describeIfNative('Memory native SQLite migration', () => {
})
})
+ it('invalidates clean FTS metadata after incremental memory import and rebuilds on reopen', async () => {
+ const directory = actualFs.mkdtempSync(join(tmpdir(), 'deepchat-memory-import-'))
+ const sourcePath = join(directory, 'source.db')
+ const targetPath = join(directory, 'target.db')
+ try {
+ const source = new SQLitePresenterCtor(sourcePath)
+ source.agentMemoryTable.insert({
+ id: 'imported-memory',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'incrementally imported redis memory'
+ })
+ source.close()
+
+ const target = new SQLitePresenterCtor(targetPath)
+ target.agentMemoryTable.insert({
+ id: 'existing-memory',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'existing redis memory'
+ })
+ expect(
+ target
+ .getDatabase()
+ .prepare("SELECT key FROM agent_memory_fts_meta WHERE key = 'agent_memory_fts'")
+ .get()
+ ).toEqual({ key: 'agent_memory_fts' })
+ target.close()
+
+ const importer = new DataImporterCtor(sourcePath, targetPath)
+ await importer.importData()
+ importer.close()
+
+ const reopened = new SQLitePresenterCtor(targetPath)
+ const result = reopened.agentMemoryTable.searchWithStrategy('a', 'imported', 10)
+ expect(result.strategy).toBe('fts-only')
+ expect(result.rows.map((row) => row.id)).toContain('imported-memory')
+ reopened.close()
+ } finally {
+ actualFs.rmSync(directory, { recursive: true, force: true })
+ }
+ })
+
for (const version of [34, 37, 38, 40]) {
it(`migrates schema version ${version} to v41 and preserves legacy rows`, () => {
withTemporaryDatabase((databasePath) => {
@@ -82,7 +108,10 @@ describeIfNative('Memory native SQLite migration', () => {
const legacy = new DatabaseCtor(databasePath)
legacy.exec('DROP INDEX IF EXISTS idx_agent_memory_conflict_target')
- if (version === 34) legacy.exec('ALTER TABLE agent_memory DROP COLUMN conflict_with')
+ if (version === 34) {
+ legacy.exec('DROP INDEX IF EXISTS idx_agent_memory_conflict_link_anomaly_v2')
+ legacy.exec('ALTER TABLE agent_memory DROP COLUMN conflict_with')
+ }
legacy.exec('ALTER TABLE agent_memory DROP COLUMN decision_revision')
legacy.exec('DELETE FROM schema_versions')
legacy
diff --git a/test/main/presenter/memoryPagination.test.ts b/test/main/presenter/memoryPagination.test.ts
new file mode 100644
index 000000000..04a2c0afe
--- /dev/null
+++ b/test/main/presenter/memoryPagination.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+
+import { enabledConfig, makePresenter } from './fakes/memoryFakes'
+
+describe('MemoryPresenter management pagination', () => {
+ it('uses a stable created-at/id keyset and keeps archived rows in management pages', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ for (const id of ['x', 'y', 'z']) {
+ repo.insert({
+ id,
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: `memory ${id}`,
+ status: id === 'x' ? 'archived' : 'embedded',
+ createdAt: 1000
+ })
+ }
+ repo.insert({
+ id: 'older',
+ agentId: 'deepchat',
+ kind: 'episodic',
+ content: 'older memory',
+ status: 'embedded',
+ createdAt: 900
+ })
+
+ const first = presenter.pageMemories('deepchat', null, 2)
+ expect(first.rows.map((row) => row.id)).toEqual(['z', 'y'])
+ expect(first.nextCursor).toEqual({ createdAt: 1000, id: 'y' })
+
+ const second = presenter.pageMemories('deepchat', first.nextCursor, 2)
+ expect(second.rows.map((row) => row.id)).toEqual(['x', 'older'])
+ expect(second.rows[0].status).toBe('archived')
+ expect(second.nextCursor).toBeNull()
+ })
+
+ it('excludes internal, superseded, and conflicted rows from management pages', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ repo.insert({
+ id: 'visible',
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: 'visible',
+ status: 'embedded'
+ })
+ repo.insert({
+ id: 'persona',
+ agentId: 'deepchat',
+ kind: 'persona',
+ content: 'persona',
+ status: 'fts_only'
+ })
+ repo.insert({
+ id: 'working',
+ agentId: 'deepchat',
+ kind: 'working',
+ content: 'working',
+ status: 'fts_only'
+ })
+ repo.insert({
+ id: 'conflicted',
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: 'conflicted',
+ status: 'conflicted'
+ })
+ repo.insert({
+ id: 'superseded',
+ agentId: 'deepchat',
+ kind: 'semantic',
+ content: 'superseded',
+ status: 'embedded'
+ })
+ repo.markSuperseded('superseded', 'visible')
+
+ expect(presenter.pageMemories('deepchat', null, 100).rows.map((row) => row.id)).toEqual([
+ 'visible'
+ ])
+ })
+})
diff --git a/test/main/presenter/memoryPresenter.test.ts b/test/main/presenter/memoryPresenter.test.ts
index c89a105b4..580ca1307 100644
--- a/test/main/presenter/memoryPresenter.test.ts
+++ b/test/main/presenter/memoryPresenter.test.ts
@@ -24,6 +24,8 @@ import {
type IMemoryVectorStore,
type MemoryVectorMatch
} from '@/presenter/memoryPresenter/types'
+import type { VectorReadyCertificate } from '@/presenter/memoryPresenter/infra/vectorStoreManager'
+import type { MaintenanceBudget } from '@/presenter/memoryPresenter/core/maintenanceBudget'
import type { DeepChatAgentConfig } from '@shared/types/agent-interface'
import { createEmptyMemoryHealth } from '@shared/contracts/routes'
import {
@@ -51,7 +53,7 @@ async function waitForMemoryCondition(
): Promise {
for (let i = 0; i < 20; i += 1) {
if (condition()) return
- await new Promise((resolve) => setTimeout(resolve, 0))
+ await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(message)
}
@@ -77,17 +79,15 @@ type MemoryPresenterRuntimeTestSeams = {
backfilling: Map>
errorRetryAt: Map
errorRetryAfterId: Map
- orphanVectorReconciles: Map>
- orphanVectorReconciled: Set
- orphanVectorReconcileRetryAt: Map
}
}
vectorStore: {
clearReady(agentId: string): void
+ closeAgentStore(agentId: string): Promise
getMutableRuntimeStateForTests(): {
vectorStores: Map>
vectorStoreIdentities: Map
- vectorStoreReady: Map
+ vectorStoreReady: Map
vectorStoreLocks: Map>
}
}
@@ -403,6 +403,38 @@ describe('working-memory L1 (T5)', () => {
expect(working[0].content).toContain('fact two')
})
+ it('debounces mutation refreshes and lets a read synchronously flush dirty state', async () => {
+ vi.useFakeTimers()
+ try {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const listCandidates = vi.spyOn(repo, 'listWorkingCandidates')
+ for (let index = 0; index < 20; index += 1) {
+ await presenter.rememberMemory(
+ { kind: 'semantic', content: `debounced fact ${index}` },
+ { agentId: 'deepchat' }
+ )
+ }
+
+ expect(listCandidates).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(99)
+ expect(listCandidates).not.toHaveBeenCalled()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(listCandidates).toHaveBeenCalledTimes(1)
+
+ await presenter.rememberMemory(
+ { kind: 'semantic', content: 'read flush fact' },
+ { agentId: 'deepchat' }
+ )
+ expect(listCandidates).toHaveBeenCalledTimes(1)
+ expect((await presenter.buildInjection('deepchat', ''))?.working).toContain('read flush fact')
+ expect(listCandidates).toHaveBeenCalledTimes(2)
+ await vi.advanceTimersByTimeAsync(100)
+ expect(listCandidates).toHaveBeenCalledTimes(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('refreshes the working blob after soft forget and restore', async () => {
const { presenter, repo } = makePresenter(enabledConfig)
repo.insert({
@@ -460,7 +492,7 @@ describe('working-memory L1 (T5)', () => {
content: 'archive me from working memory',
importance: 0.9,
status: 'embedded',
- createdAt: now - 200 * DAY
+ createdAt: now - 300 * DAY
})
repo.updateDecayScore('s1', 0.01)
presenter.refreshWorkingMemory('deepchat')
@@ -549,6 +581,9 @@ describe('working-memory L1 (T5)', () => {
config = { memoryEnabled: false }
expect(await presenter.forgetMemory('a', 's1')).toBe(true)
+ await waitForMemoryCondition(
+ () => ![...repo.rows.values()].some((row) => row.kind === 'working')
+ )
expect([...repo.rows.values()].some((row) => row.kind === 'working')).toBe(false)
config = { memoryEnabled: true }
@@ -796,7 +831,7 @@ describe('working-memory L1 (T5)', () => {
expect(repo.getById('working-v1')).toBeUndefined()
})
- it('refreshes the working blob on the offline consolidation pass', async () => {
+ it('does not dirty working memory during a no-change consolidation pass', async () => {
const { presenter, repo } = makePresenter(enabledConfig)
const now = 1_000_000_000_000
// Recent relative to `now` so the same pass does not archive it before the blob build.
@@ -809,7 +844,7 @@ describe('working-memory L1 (T5)', () => {
createdAt: now
})
await presenter.runConsolidationPass('deepchat', now)
- expect([...repo.rows.values()].some((row) => row.kind === 'working')).toBe(true)
+ expect([...repo.rows.values()].some((row) => row.kind === 'working')).toBe(false)
})
it('schedules an async refresh on a cold-start miss and serves the blob next open', async () => {
@@ -1161,6 +1196,80 @@ describe('MemoryPresenter write + two-phase embedding', () => {
)
})
+ it('revalidates an equivalent v2 owner after a lazy re-key UNIQUE race', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const content = 'User likes Redis'
+ const legacyKey = buildLegacyMemoryProvenanceKey('a', 'semantic', content)
+ const v2Key = buildMemoryProvenanceKey('a', 'semantic', content)
+ repo.insert({
+ id: 'legacy',
+ agentId: 'a',
+ kind: 'semantic',
+ content,
+ provenanceKey: legacyKey
+ })
+ const concurrent = repo.insert({
+ id: 'concurrent',
+ agentId: 'a',
+ kind: 'semantic',
+ content: ' User likes Redis '
+ })
+ const originalLookup = repo.getByProvenanceKey.bind(repo)
+ let v2Reads = 0
+ vi.spyOn(repo, 'getByProvenanceKey').mockImplementation((agentId, provenanceKey) => {
+ if (provenanceKey !== v2Key) return originalLookup(agentId, provenanceKey)
+ v2Reads += 1
+ return v2Reads === 1 ? undefined : concurrent
+ })
+ vi.spyOn(repo, 'rekeyProvenance').mockImplementation(() => {
+ throw new Error('UNIQUE constraint failed')
+ })
+
+ const created = presenter.writeMemoriesSync([{ kind: 'semantic', content }], { agentId: 'a' })
+
+ expect(created).toEqual([])
+ expect(repo.getById('legacy')?.provenance_key).toBe(legacyKey)
+ expect(v2Reads).toBe(2)
+ })
+
+ it('does not accept a different-content v2 owner after a lazy re-key UNIQUE race', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const content = 'User likes Redis'
+ const legacyKey = buildLegacyMemoryProvenanceKey('a', 'semantic', content)
+ const v2Key = buildMemoryProvenanceKey('a', 'semantic', content)
+ repo.insert({
+ id: 'legacy',
+ agentId: 'a',
+ kind: 'semantic',
+ content,
+ provenanceKey: legacyKey
+ })
+ const concurrentCollision = repo.insert({
+ id: 'concurrent-collision',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'different content'
+ })
+ const originalLookup = repo.getByProvenanceKey.bind(repo)
+ let v2Reads = 0
+ vi.spyOn(repo, 'getByProvenanceKey').mockImplementation((agentId, provenanceKey) => {
+ if (provenanceKey !== v2Key) return originalLookup(agentId, provenanceKey)
+ v2Reads += 1
+ return v2Reads === 1 ? undefined : concurrentCollision
+ })
+ vi.spyOn(repo, 'rekeyProvenance').mockImplementation(() => {
+ throw new Error('UNIQUE constraint failed')
+ })
+
+ const created = presenter.writeMemoriesSync([{ kind: 'semantic', content }], { agentId: 'a' })
+
+ expect(created).toHaveLength(1)
+ expect(repo.getById(created[0])?.content).toBe(content)
+ expect(repo.getById('legacy')?.provenance_key).toBe(legacyKey)
+ expect(repo.getById('concurrent-collision')?.content).toBe('different content')
+ expect(v2Reads).toBe(2)
+ })
+
it('processPendingEmbeddings embeds and flips status to embedded', async () => {
const { presenter, repo, store } = makePresenter(enabledConfig)
presenter.writeMemoriesSync([{ kind: 'semantic', content: 'redis fact' }], { agentId: 'a' })
@@ -1669,6 +1778,7 @@ describe('MemoryPresenter management', () => {
it('cleanupDeletedAgentResources clears runtime state even when vector reset fails', async () => {
const repo = new FakeRepository()
const store = new FakeVectorStore()
+ store.vectors.set('m1', textToVector('redis cached row'))
const createVectorStore = vi.fn(async () => store)
const resetVectorStore = vi.fn(async () => {
throw new Error('reset failed')
@@ -2078,6 +2188,98 @@ describe('MemoryPresenter management', () => {
expect(querySpy).toHaveBeenCalledTimes(1)
})
+ it('validates stale embeddings once during warmup and never on ordinary recall', async () => {
+ const repo = new FakeRepository()
+ const store = new FakeVectorStore()
+ repo.insert({ id: 'm1', agentId: 'a', kind: 'semantic', content: 'redis fact' })
+ repo.updateStatus('m1', 'embedded', {
+ embeddingId: 'm1',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ })
+ await store.upsert([{ memoryId: 'm1', embedding: textToVector('redis fact') }])
+ const staleSpy = vi.spyOn(repo, 'hasStaleEmbeddings')
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings: async (_providerId, _modelId, texts) =>
+ texts.map((text) => textToVector(text)),
+ getDimensions: embeddingDimensions,
+ createVectorStore: async () => store,
+ resetVectorStore: async () => undefined
+ })
+ const internals = memoryRuntimeForTests(presenter)
+
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(() => internals.vectorStoreReady.has('a'))
+ expect(staleSpy).toHaveBeenCalledTimes(1)
+
+ await presenter.recall('a', 'redis')
+ await presenter.recall('a', 'redis')
+ expect(staleSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects a stale sidecar hit when the authoritative row is pending embedding', async () => {
+ const { presenter, repo, store } = makePresenter(enabledConfig)
+ const [memoryId] = presenter.writeMemoriesSync(
+ [{ kind: 'semantic', content: 'redis obsolete wording' }],
+ { agentId: 'a' }
+ )
+ await presenter.processPendingEmbeddings('a')
+ expect(store.vectors.has(memoryId)).toBe(true)
+
+ const current = repo.getById(memoryId)!
+ expect(
+ repo.updateDecisionContentIfRevision({
+ agentId: 'a',
+ id: memoryId,
+ expectedRevision: current.decision_revision,
+ content: 'completely unrelated replacement',
+ provenanceKey: buildMemoryProvenanceKey(
+ 'a',
+ 'semantic',
+ 'completely unrelated replacement'
+ ),
+ at: Date.now()
+ })
+ ).toBe(true)
+ expect(repo.getById(memoryId)?.status).toBe('pending_embedding')
+
+ const recalled = await presenter.recall('a', 'redis obsolete wording')
+ expect(recalled.map((item) => item.id)).not.toContain(memoryId)
+ })
+
+ it('keeps a readiness certificate across a resource-only close and reopen', async () => {
+ const repo = new FakeRepository()
+ const store = new FakeVectorStore()
+ const createVectorStore = vi.fn(async () => store)
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => enabledConfig,
+ getEmbeddings: async (_providerId, _modelId, texts) =>
+ texts.map((text) => textToVector(text)),
+ getDimensions: embeddingDimensions,
+ createVectorStore,
+ resetVectorStore: async () => undefined
+ })
+ const [memoryId] = presenter.writeMemoriesSync(
+ [{ kind: 'semantic', content: 'redis reopen fact' }],
+ { agentId: 'a' }
+ )
+ await presenter.processPendingEmbeddings('a')
+ const internals = memoryRuntimeForTests(presenter)
+ const certificate = internals.vectorStoreReady.get('a')
+
+ await internals.vectorStoreService.closeAgentStore('a')
+ expect(internals.vectorStores.has('a')).toBe(false)
+ expect(internals.vectorStoreReady.get('a')).toEqual(certificate)
+
+ const recalled = await presenter.recall('a', 'redis reopen fact')
+ expect(recalled.map((item) => item.id)).toContain(memoryId)
+ expect(createVectorStore).toHaveBeenCalledTimes(2)
+ expect(internals.vectorStoreReady.get('a')).toEqual(certificate)
+ })
+
it('agent-facing recall keywordizes long English messages for FTS-only recall', async () => {
const { presenter, repo } = makePresenter({ memoryEnabled: true })
repo.insert({
@@ -2093,7 +2295,7 @@ describe('MemoryPresenter management', () => {
expect(recalled.map((item) => item.id)).toEqual(['m1'])
})
- it('agent-facing recall filters high-frequency generic terms by corpus stats', async () => {
+ it('agent-facing recall uses deterministic terms without corpus stats', async () => {
const { presenter, repo } = makePresenter({ memoryEnabled: true })
repo.insert({
id: 'm1',
@@ -2126,29 +2328,7 @@ describe('MemoryPresenter management', () => {
const recalled = await presenter.recall('a', 'please redis setup')
- expect(recalled.map((item) => item.id)).toEqual(['m1'])
- })
-
- it('caches recall keyword stats per agent term and invalidates them on writes', async () => {
- const { presenter, repo } = makePresenter({ memoryEnabled: true })
- repo.insert({
- id: 'm1',
- agentId: 'a',
- kind: 'semantic',
- content: 'redis setup',
- status: 'fts_only'
- })
- const statsSpy = vi.spyOn(repo, 'getRecallKeywordTermStats')
-
- await presenter.recall('a', 'please redis setup')
- await presenter.recall('a', 'please redis setup')
- expect(statsSpy).toHaveBeenCalledTimes(1)
-
- presenter.writeMemoriesSync([{ kind: 'semantic', content: 'redis follow-up' }], {
- agentId: 'a'
- })
- await presenter.recall('a', 'please redis setup')
- expect(statsSpy).toHaveBeenCalledTimes(2)
+ expect(recalled.map((item) => item.id).sort()).toEqual(['m1', 'm2', 'm3', 'm4'])
})
it('agent-facing recall keeps a single domain term even when it is frequent', async () => {
@@ -2909,6 +3089,35 @@ describe('MemoryPresenter management', () => {
expect(presenter.getByIds('a', ['other-agent-memory'])).toEqual([])
})
+ it('keeps source-span lookups inside management visibility', () => {
+ const { presenter, repo } = makePresenter(enabledConfig)
+ const ids = presenter.writeMemoriesSync(
+ [
+ { kind: 'semantic', content: 'active source' },
+ { kind: 'semantic', content: 'archived source' },
+ { kind: 'semantic', content: 'superseded source' },
+ { kind: 'semantic', content: 'conflicted source' }
+ ],
+ { agentId: 'a' }
+ )
+ repo.archive(ids[1])
+ repo.markSuperseded(ids[2], ids[0])
+ repo.updateStatus(ids[3], 'conflicted')
+ repo.insert({
+ id: 'persona-source',
+ agentId: 'a',
+ kind: 'persona',
+ content: 'hidden persona',
+ status: 'fts_only'
+ })
+
+ expect(
+ presenter
+ .getManagementVisibleByIds('a', [ids[2], ids[1], ids[3], 'persona-source', ids[0]])
+ .map((row) => row.id)
+ ).toEqual([ids[1], ids[0]])
+ })
+
it('archiveUserMemory soft-archives owned memory and writes content-free user audit', async () => {
const { presenter, repo, auditRepo } = makePresenter(enabledConfig)
const [id] = presenter.writeMemoriesSync([{ kind: 'semantic', content: 'redis cache' }], {
@@ -3367,8 +3576,6 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
content: 'redis live',
status: 'pending_embedding'
})
- const updateSpy = vi.spyOn(repo, 'updatePendingEmbeddingStatus')
-
const drain = presenter.processPendingEmbeddings('a')
await started
repo.updateStatus('stale', 'fts_only')
@@ -3377,9 +3584,6 @@ describe('MemoryPresenter.processPendingEmbeddings (batch + fairness)', () => {
expect(repo.getById('stale')?.status).toBe('fts_only')
expect(repo.getById('live')?.status).toBe('error')
- expect(updateSpy.mock.calls.some((call) => call[1] === 'stale' && call[2] === 'error')).toBe(
- false
- )
})
it('forced reindex waits for an active drain before requeueing', async () => {
@@ -3710,6 +3914,7 @@ describe('MemoryPresenter change events (onMemoryChanged)', () => {
await waitForMemoryCondition(() =>
repo.listByAgent('a').some((row) => row.content === 'first durable fact')
)
+ await presenter.buildInjection('a', '')
expect([...repo.rows.values()].find((row) => row.kind === 'working')?.content).toContain(
'first durable fact'
)
@@ -3938,6 +4143,59 @@ describe('MemoryPresenter async write guards', () => {
expect(onMemoryChanged).not.toHaveBeenCalled()
})
+ it('invalidates a slow decision when the embedding identity changes', async () => {
+ const repo = new FakeRepository()
+ let config: DeepChatAgentConfig = enabledConfig
+ let resolveDecision!: (value: string) => void
+ let markDecisionStarted!: () => void
+ const decisionStarted = new Promise((resolve) => {
+ markDecisionStarted = resolve
+ })
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => config,
+ getEmbeddings: async (_providerId, _modelId, texts) => texts.map(textToVector),
+ getDimensions: embeddingDimensions,
+ generateText: async (_providerId, _modelId, prompt) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) {
+ return '[{"kind":"semantic","content":"redis preference","importance":0.9}]'
+ }
+ markDecisionStarted()
+ return new Promise((resolve) => {
+ resolveDecision = resolve
+ })
+ },
+ createVectorStore: async () => new FakeVectorStore(),
+ resetVectorStore: async () => undefined
+ })
+ repo.insert({
+ id: 'target',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'redis fact',
+ status: 'embedded'
+ })
+ presenter.onAgentMemoryMaintenanceConfigChanged('a')
+
+ const pending = presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: redis preference',
+ model: { providerId: 'p', modelId: 'm' }
+ })
+ await decisionStarted
+ config = {
+ ...enabledConfig,
+ memoryEmbedding: { providerId: 'p', modelId: 'm2' }
+ }
+ presenter.onAgentMemoryMaintenanceConfigChanged('a')
+ resolveDecision('{"decision":"UPDATE","targetIndex":0,"mergedContent":"late update"}')
+
+ await expect(pending).resolves.toEqual({ ok: false })
+ expect(repo.getById('target')?.content).toBe('redis fact')
+ expect(repo.listByAgent('a')).toHaveLength(1)
+ })
+
it('does not start extraction for unmanaged agents', async () => {
const repo = new FakeRepository()
const generateText = vi.fn(async () => 'KEEP')
@@ -4202,13 +4460,14 @@ describe('MemoryPresenter agentId safety guards', () => {
describe('MemoryPresenter health read model', () => {
it('assembles health from read-only repository and audit stats', () => {
const { presenter, repo, auditRepo } = makePresenter(enabledConfig)
+ const now = Date.now()
repo.insert({
id: 'current',
agentId: 'a',
kind: 'semantic',
category: 'project_fact',
content: 'repo uses pnpm',
- createdAt: 2000
+ createdAt: now - DAY
})
repo.updateStatus('current', 'embedded', {
embeddingId: 'current',
@@ -4220,7 +4479,7 @@ describe('MemoryPresenter health read model', () => {
agentId: 'a',
kind: 'semantic',
content: 'legacy vector',
- createdAt: 1000
+ createdAt: now - 2 * DAY
})
repo.updateStatus('legacy', 'embedded', {
embeddingId: 'legacy',
@@ -4233,10 +4492,10 @@ describe('MemoryPresenter health read model', () => {
kind: 'semantic',
category: 'heuristic',
content: 'old unused',
- createdAt: 0
+ createdAt: now - 400 * DAY
})
repo.updateDecayScore('archive', 0.01)
- repo.recordAccess('current', 3000)
+ repo.recordAccess('current', now)
auditRepo.insert({
id: 'audit-1',
agentId: 'a',
@@ -4344,6 +4603,62 @@ describe('writeMemoriesSync insert error classification (C2, AC-2.2)', () => {
})
describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
+ it('serializes coverage verification with embedding persistence for the same agent', async () => {
+ const { presenter, repo, store } = makePresenter(enabledConfig)
+ repo.insert({
+ id: 'embedded',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'existing redis memory',
+ status: 'pending_embedding'
+ })
+ repo.updateStatus('embedded', 'embedded', {
+ embeddingId: 'embedded',
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ })
+ store.vectors.set('embedded', textToVector('existing redis memory'))
+ repo.insert({
+ id: 'pending',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'new redis memory',
+ status: 'pending_embedding'
+ })
+
+ let releaseList!: () => void
+ let listStarted!: () => void
+ const started = new Promise((resolve) => {
+ listStarted = resolve
+ })
+ vi.spyOn(store, 'listMemoryIds').mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ listStarted()
+ releaseList = () => resolve([...store.vectors.keys()].sort())
+ })
+ )
+ const upsert = vi.spyOn(store, 'upsert')
+
+ await presenter.recall('a', 'redis')
+ await started
+ const drain = presenter.processPendingEmbeddings('a')
+ await flushMicrotasks()
+
+ expect(upsert).not.toHaveBeenCalled()
+ expect(repo.getById('pending')?.status).toBe('pending_embedding')
+
+ releaseList()
+ await drain
+
+ expect(upsert).toHaveBeenCalledTimes(1)
+ expect(store.vectors.has('pending')).toBe(true)
+ expect(repo.getById('pending')).toMatchObject({
+ status: 'embedded',
+ embedding_id: 'pending'
+ })
+ })
+
it('deletes orphan vectors in bounded batches during warm reconcile', async () => {
const { presenter, store } = makePresenter(enabledConfig)
for (let index = 0; index < 601; index += 1) {
@@ -4359,7 +4674,7 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
expect(deleteSpy.mock.calls.every(([ids]) => ids.length <= 512)).toBe(true)
})
- it('keeps vector recall ready and cools down after orphan reconcile delete failure', async () => {
+ it('withholds readiness when coverage cleanup fails and retries on the next warm', async () => {
const { presenter, store } = makePresenter(enabledConfig)
store.vectors.set('orphan-0001', textToVector('orphan redis'))
const deleteSpy = vi.spyOn(store, 'deleteByMemoryIds')
@@ -4369,25 +4684,17 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
await presenter.recall('a', 'redis')
await waitForMemoryCondition(() => deleteSpy.mock.calls.length === 1)
expect(store.vectors.has('orphan-0001')).toBe(true)
- expect(internals.vectorStoreReady.has('a')).toBe(true)
-
- internals.clearVectorStoreReady('a')
- await presenter.recall('a', 'redis')
- await flushMicrotasks()
- expect(deleteSpy).toHaveBeenCalledTimes(1)
+ expect(internals.vectorStoreReady.has('a')).toBe(false)
- for (const key of internals.orphanVectorReconcileRetryAt.keys()) {
- internals.orphanVectorReconcileRetryAt.set(key, 0)
- }
- internals.clearVectorStoreReady('a')
await presenter.recall('a', 'redis')
await waitForMemoryCondition(
() => deleteSpy.mock.calls.length >= 2 && !store.vectors.has('orphan-0001'),
- 'orphan reconcile did not retry after failure'
+ 'coverage verification did not retry after failure'
)
+ await waitForMemoryCondition(() => internals.vectorStoreReady.has('a'))
})
- it('cleanupDeletedAgentResources waits for in-flight orphan reconcile before clearing marks', async () => {
+ it('cleanupDeletedAgentResources waits for in-flight coverage verification', async () => {
const { presenter, store } = makePresenter(enabledConfig)
let releaseList!: () => void
vi.spyOn(store, 'listMemoryIds').mockImplementationOnce(
@@ -4400,8 +4707,8 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
await presenter.recall('a', 'redis')
await waitForMemoryCondition(
- () => internals.orphanVectorReconciles.size === 1 && typeof releaseList === 'function',
- 'orphan reconcile did not enter in-flight tracking'
+ () => internals.vectorStoreWarmups.size === 1 && typeof releaseList === 'function',
+ 'coverage verification did not enter in-flight tracking'
)
let cleanupDone = false
@@ -4415,8 +4722,7 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
await cleanup
expect(cleanupDone).toBe(true)
- expect(internals.orphanVectorReconciles.size).toBe(0)
- expect(internals.orphanVectorReconciled.size).toBe(0)
+ expect(internals.vectorStoreWarmups.size).toBe(0)
})
it('reindexEmbeddings re-queues, rebuilds the store, and re-embeds with the new fingerprint', async () => {
@@ -4506,6 +4812,40 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
expect(repo.getById(id!)?.embedding_model).toBe('p:m2')
})
+ it('invalidates readiness on the config hook and issues a new generation certificate', async () => {
+ const repo = new FakeRepository()
+ let config: DeepChatAgentConfig = {
+ memoryEnabled: true,
+ memoryEmbedding: { providerId: 'p', modelId: 'm1' }
+ }
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => config,
+ getEmbeddings: async (_providerId, _modelId, texts) =>
+ texts.map((text) => textToVector(text)),
+ getDimensions: embeddingDimensions,
+ createVectorStore: async () => new FakeVectorStore(),
+ resetVectorStore: async () => undefined
+ })
+ const [id] = presenter.writeMemoriesSync([{ kind: 'semantic', content: 'redis fact' }], {
+ agentId: 'a'
+ })
+ await presenter.processPendingEmbeddings('a')
+ const internals = memoryRuntimeForTests(presenter)
+ const firstCertificate = internals.vectorStoreReady.get('a')!
+
+ config = { memoryEnabled: true, memoryEmbedding: { providerId: 'p', modelId: 'm2' } }
+ presenter.onAgentMemoryMaintenanceConfigChanged('a')
+ expect(internals.vectorStoreReady.has('a')).toBe(false)
+
+ await presenter.recall('a', 'redis')
+ await waitForMemoryCondition(() => repo.getById(id)?.embedding_model === 'p:m2')
+ await waitForMemoryCondition(() => internals.vectorStoreReady.get('a')?.modelId === 'm2')
+ const secondCertificate = internals.vectorStoreReady.get('a')!
+ expect(secondCertificate.configGeneration).toBeGreaterThan(firstCertificate.configGeneration)
+ expect(secondCertificate.storeGeneration).toBeGreaterThan(firstCertificate.storeGeneration)
+ })
+
it('reindex recovers rows left in error by a prior failed embed', async () => {
const repo = new FakeRepository()
const config: DeepChatAgentConfig = {
@@ -4634,6 +4974,7 @@ describe('MemoryPresenter embedding reindex (T5, AC-3.x)', () => {
it('ignores an anomalous embedded persona: no reindex churn, not recalled (P2)', async () => {
const repo = new FakeRepository()
const store = new FakeVectorStore()
+ store.vectors.set('m1', textToVector('redis cached row'))
const config: DeepChatAgentConfig = {
memoryEnabled: true,
memoryEmbedding: { providerId: 'p', modelId: 'm' }
@@ -4859,6 +5200,19 @@ function routedLLM(opts: {
if (prompt.includes('JSON array')) return opts.extraction ?? '[]'
if (prompt.includes('Choose exactly ONE decision')) {
if (opts.throwDecision) throw new Error('decision model down')
+ const candidateIndexes = [...prompt.matchAll(/^Candidate (\d+) \(/gm)].map((match) =>
+ Number(match[1])
+ )
+ if (candidateIndexes.length > 0) {
+ const batch = candidateIndexes.map((candidateIndex) => {
+ const raw = Array.isArray(opts.decision)
+ ? (opts.decision[decisionIndex++] ??
+ '{"decision":"ADD","targetIndex":null,"mergedContent":null}')
+ : (opts.decision ?? '{"decision":"ADD","targetIndex":null,"mergedContent":null}')
+ return { ...JSON.parse(raw), candidateIndex }
+ })
+ return JSON.stringify(batch)
+ }
if (Array.isArray(opts.decision)) {
return (
opts.decision[decisionIndex++] ??
@@ -4926,6 +5280,121 @@ const decisionCalls = (generateText: ReturnType) =>
.length
describe('MemoryPresenter decision ring (T-A1..T-A5)', () => {
+ it('does not admit a second decision partition after destructive clear', async () => {
+ const extraction = Array.from({ length: 8 }, (_, index) => ({
+ kind: 'semantic',
+ content: `redis clear race ${index} updated`,
+ importance: 0.8
+ }))
+ let releaseDecision!: (value: string) => void
+ let markDecisionStarted!: () => void
+ const decisionStarted = new Promise((resolve) => {
+ markDecisionStarted = resolve
+ })
+ let decisionCount = 0
+ const generateText = vi.fn(async (_p: string, _m: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) return JSON.stringify(extraction)
+ if (!prompt.includes('Choose exactly ONE decision')) return ''
+ decisionCount += 1
+ markDecisionStarted()
+ return new Promise((resolve) => {
+ releaseDecision = resolve
+ })
+ })
+ const { presenter } = makeLLMPresenter(generateText)
+ for (let index = 0; index < 8; index += 1) {
+ await seedEmbedded(presenter, `redis clear race ${index} baseline`)
+ }
+
+ const pending = presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User updated eight redis topics before clearing memory',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+ await decisionStarted
+ expect(await presenter.clearMemories('a')).toBe(8)
+ releaseDecision('[]')
+
+ await expect(pending).resolves.toEqual({ ok: false })
+ expect(decisionCount).toBe(1)
+ })
+
+ it('cancels a slow decision after permanent forget without reviving the cached owner', async () => {
+ let releaseDecision!: (value: string) => void
+ let markDecisionStarted!: () => void
+ const decisionStarted = new Promise((resolve) => {
+ markDecisionStarted = resolve
+ })
+ const generateText = vi.fn(async (_p: string, _m: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) {
+ return JSON.stringify([
+ { kind: 'semantic', content: 'permanently forgotten fact', importance: 0.8 },
+ { kind: 'semantic', content: 'redis neighbor update', importance: 0.8 }
+ ])
+ }
+ if (!prompt.includes('Choose exactly ONE decision')) return ''
+ markDecisionStarted()
+ return new Promise((resolve) => {
+ releaseDecision = resolve
+ })
+ })
+ const repo = new FakeRepository()
+ const auditRepo = new FakeAuditRepository()
+ const { presenter } = makeLLMPresenter(generateText, embeddingConfig, repo, auditRepo)
+ repo.insert({
+ id: 'forgotten-owner',
+ agentId: 'a',
+ kind: 'semantic',
+ content: 'permanently forgotten fact',
+ status: 'archived'
+ })
+ await seedEmbedded(presenter, 'redis neighbor baseline')
+
+ const pending = presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User repeats one old fact and updates redis',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+ await decisionStarted
+ expect(await presenter.forgetMemory('a', 'forgotten-owner')).toBe(true)
+ releaseDecision('[{"candidateIndex":1,"decision":"NOOP","targetIndex":0}]')
+
+ await expect(pending).resolves.toEqual({ ok: false })
+ expect(repo.getById('forgotten-owner')?.status).toBe('archived')
+ expect(auditRepo.hasForgetEvent('a', 'forgotten-owner')).toBe(true)
+ })
+
+ it('bounds eight-candidate steady-state provider work to two decision batches', async () => {
+ const extraction = Array.from({ length: 8 }, (_, index) => ({
+ kind: 'semantic',
+ content: `redis topic ${index} updated`,
+ importance: 0.8
+ }))
+ const generateText = routedLLM({
+ extraction: JSON.stringify(extraction),
+ decision: '{"decision":"NOOP","targetIndex":0,"mergedContent":null}'
+ })
+ const { presenter, getEmbeddings } = makeLLMPresenter(generateText)
+ for (let index = 0; index < 8; index += 1) {
+ await seedEmbedded(presenter, `redis topic ${index} baseline`)
+ }
+ getEmbeddings.mockClear()
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User updated eight redis topics',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ expect(result).toEqual({ ok: true, createdIds: [] })
+ expect(generateText).toHaveBeenCalledTimes(4)
+ expect(decisionCalls(generateText)).toBe(2)
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
+ expect(getEmbeddings.mock.calls[0][2]).toHaveLength(8)
+ })
+
it('ADD: model keeps the candidate as a new memory alongside the related neighbor', async () => {
const generateText = routedLLM({
extraction: '[{"kind":"semantic","content":"user prefers redis","importance":0.8}]',
@@ -5369,6 +5838,43 @@ describe('MemoryPresenter decision ring (T-A1..T-A5)', () => {
}
})
+ it('does not retry a failed candidate embedding provider call during CAS contention', async () => {
+ const repo = new FakeRepository()
+ let targetId = ''
+ let decisionCallCount = 0
+ const generateText = vi.fn(async (_providerId: string, _modelId: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) {
+ return '[{"kind":"semantic","content":"user changed redis preference","importance":0.8}]'
+ }
+ if (prompt.includes('Choose exactly ONE decision')) {
+ decisionCallCount += 1
+ if (decisionCallCount === 1) repo.updateUserMetadata(targetId, { importance: 0.7 })
+ return '{"decision":"UPDATE","targetIndex":0,"mergedContent":"user prefers redis"}'
+ }
+ return ''
+ })
+ const { presenter, getEmbeddings } = makeLLMPresenter(generateText, embeddingConfig, repo)
+ targetId = await seedEmbedded(presenter, 'user likes redis')
+ getEmbeddings.mockClear()
+ getEmbeddings.mockRejectedValue(new Error('query embedding unavailable'))
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User: my redis preference changed',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ expect(result.ok).toBe(true)
+ expect(decisionCallCount).toBe(2)
+ const candidateEmbeddingCalls = getEmbeddings.mock.calls.filter(([, , texts]) =>
+ texts.includes('user changed redis preference')
+ )
+ expect(candidateEmbeddingCalls).toHaveLength(1)
+ expect(getEmbeddings).toHaveBeenCalledTimes(2)
+ expect(repo.getById(targetId)?.content).toBe('user prefers redis')
+ })
+
it('returns concurrent-update after a second stale decision without mutating the target', async () => {
const repo = new FakeRepository()
let targetId = ''
@@ -5401,6 +5907,78 @@ describe('MemoryPresenter decision ring (T-A1..T-A5)', () => {
expect(repo.countByAgent('a')).toBe(1)
})
+ it('retries only the first four conflicts in original order with one provider batch', async () => {
+ const repo = new FakeRepository()
+ const candidates = Array.from({ length: 5 }, (_, index) => ({
+ kind: 'semantic' as const,
+ content: `candidate ${index} updated`,
+ importance: 0.8
+ }))
+ const targetIds = candidates.map((_, index) => {
+ const id = `target-${index}`
+ repo.insert({
+ id,
+ agentId: 'a',
+ kind: 'semantic',
+ content: `target ${index} original`,
+ status: 'embedded'
+ })
+ repo.updateStatus(id, 'embedded', {
+ embeddingId: id,
+ embeddingDim: 4,
+ embeddingModel: 'p:m'
+ })
+ return id
+ })
+ let searchCall = 0
+ vi.spyOn(repo, 'search').mockImplementation(() => {
+ const targetIndex = searchCall < 5 ? searchCall : searchCall - 5
+ searchCall += 1
+ return [repo.getById(targetIds[targetIndex])!]
+ })
+ let decisionBatch = 0
+ const generateText = vi.fn(async (_providerId: string, _modelId: string, prompt: string) => {
+ if (prompt.includes('KEEP or SKIP')) return 'KEEP'
+ if (prompt.includes('JSON array')) return JSON.stringify(candidates)
+ if (!prompt.includes('Choose exactly ONE decision')) return ''
+ decisionBatch += 1
+ const indexes = [...prompt.matchAll(/^Candidate (\d+) \(/gm)].map((match) => Number(match[1]))
+ if (decisionBatch <= 2) {
+ indexes.forEach((candidateIndex) => {
+ repo.updateUserMetadata(targetIds[candidateIndex], { importance: 0.7 })
+ })
+ }
+ return JSON.stringify(
+ indexes.map((candidateIndex) => ({
+ candidateIndex,
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: `target ${candidateIndex} merged`
+ }))
+ )
+ })
+ const { presenter } = makeLLMPresenter(
+ generateText,
+ { memoryEnabled: true, memoryExtractionModel: { providerId: 'cheap', modelId: 'cheap' } },
+ repo
+ )
+
+ const result = await presenter.extractAndStore({
+ agentId: 'a',
+ spanText: 'User updated five candidates',
+ model: { providerId: 'main', modelId: 'main' }
+ })
+
+ expect(result).toEqual({ ok: true, createdIds: [] })
+ expect(decisionBatch).toBe(3)
+ expect(searchCall).toBe(9)
+ targetIds.slice(0, 4).forEach((id, index) => {
+ expect(repo.getById(id)?.content).toBe(`target ${index} merged`)
+ })
+ expect(repo.getById(targetIds[4])?.content).toBe('target 4 original')
+ expect(repo.countByAgent('a')).toBe(5)
+ })
+
it('falls back to a plain ADD when the decision model throws or returns garbage (T-A2)', async () => {
const thrown = routedLLM({
extraction: '[{"kind":"semantic","content":"user prefers redis","importance":0.8}]',
@@ -5433,6 +6011,28 @@ describe('MemoryPresenter decision ring (T-A1..T-A5)', () => {
expect(b.repo.countByAgent('a')).toBe(2)
})
+ it('treats oversized model-generated merged content as an invalid decision', async () => {
+ const generateText = routedLLM({
+ decision: JSON.stringify({
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: 'x'.repeat(2_001)
+ })
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const targetId = await seedEmbedded(presenter, 'user likes redis')
+
+ const outcome = await presenter.rememberMemory(
+ { kind: 'semantic', content: 'user strongly likes redis', importance: 0.8 },
+ { agentId: 'a' },
+ { providerId: 'main', modelId: 'main' }
+ )
+
+ expect(outcome.action).toBe('created')
+ expect(repo.getById(targetId)?.content).toBe('user likes redis')
+ expect(repo.countByAgent('a')).toBe(2)
+ })
+
it('short-circuits a byte-level duplicate before any neighbor recall or decision call (T-A4)', async () => {
const generateText = routedLLM({
extraction: '[{"kind":"semantic","content":"user likes redis","importance":0.8}]',
@@ -5598,24 +6198,25 @@ describe('MemoryPresenter archiving (T-B3)', () => {
return makeLLMPresenter(routedLLM({}))
}
- it('archives only when all four conditions hold; exempts and partial cases survive', () => {
+ it('archives by current age and importance while honoring lifecycle exemptions', () => {
const { presenter, repo } = makeArchivePresenter()
const now = 1_000 * DAY
const old = now - 200 * DAY
const make = (id: string, over: Partial) =>
repo.rows.set(id, makeRow(id, { agent_id: 'a', created_at: old, ...over }))
- make('stale', { decay_score: 0.01 })
+ make('stale', { decay_score: 0.01, created_at: now - 300 * DAY })
make('accessed', { decay_score: 0.01, access_count: 2 })
make('recent', { decay_score: 0.01, created_at: now })
- make('lively', { decay_score: 0.5 })
+ make('lively', { decay_score: 0.01, importance: 1 })
make('anchored', { decay_score: 0.01, is_anchor: 1 })
make('persona', { decay_score: 0.01, kind: 'persona' })
const archived = presenter.archiveStale('a', now)
- expect(archived).toBe(1)
+ expect(archived).toBe(2)
expect(repo.getById('stale')?.status).toBe('archived')
- for (const id of ['accessed', 'recent', 'lively', 'anchored', 'persona']) {
+ expect(repo.getById('accessed')?.status).toBe('archived')
+ for (const id of ['recent', 'lively', 'anchored', 'persona']) {
expect(repo.getById(id)?.status).not.toBe('archived')
}
})
@@ -5805,6 +6406,7 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
embeddingDim: 4,
embeddingModel: 'p:m'
})
+ store.vectors.set(id, textToVector(`memory ${i}`))
}
await presenter.runConsolidationPass('a', now)
@@ -5815,6 +6417,27 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
expect(queryByMemoryId).toHaveBeenCalledTimes(70)
})
+ it('skips vector neighbor scans after earlier maintenance steps exhaust the token budget', async () => {
+ const generateText = routedLLM({
+ decision: '{"decision":"ADD","targetIndex":null,"mergedContent":null}'
+ })
+ const { presenter, store } = makeLLMPresenter(generateText)
+ await seedEmbedded(presenter, 'user likes bounded maintenance')
+ const queryByMemoryId = vi.spyOn(store, 'queryByMemoryId')
+ vi.spyOn((presenter as any).conflict, 'runChallengeResolutionPass').mockImplementation(
+ async (_agentId: string, _model: unknown, budget: MaintenanceBudget) => {
+ for (let index = 0; index < 4; index += 1) {
+ expect(budget.reserve('challenge', 6_000)).toBe(true)
+ }
+ return { touched: false, calls: 4, failures: 0 }
+ }
+ )
+
+ await presenter.runConsolidationPass('a', 1_000 * DAY)
+
+ expect(queryByMemoryId).not.toHaveBeenCalled()
+ })
+
it('respects the cooldown: a second pass within the window does no LLM work (T-B5)', async () => {
const generateText = routedLLM({
decision: '{"decision":"NOOP","targetIndex":0,"mergedContent":null}'
@@ -5943,7 +6566,69 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
generateText.mockClear()
await presenter.runConsolidationPass('a', 1_000 * DAY)
// Every iteration finds a mergeable neighbor, so the pass consumes the full budget exactly once.
- expect(decisionCalls(generateText)).toBe(8)
+ expect(decisionCalls(generateText)).toBe(2)
+ })
+
+ it('limits heavy maintenance to two agents while a third waits fairly', async () => {
+ const repo = new FakeRepository()
+ let active = 0
+ let maxActive = 0
+ let started = 0
+ let release!: () => void
+ let resolveFirstPair!: () => void
+ const firstPairStarted = new Promise((resolve) => {
+ resolveFirstPair = resolve
+ })
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+ const generateText = vi.fn(async (_providerId: string, _modelId: string, prompt: string) => {
+ if (!prompt.includes('durable, high-level insights')) return '[]'
+ active += 1
+ started += 1
+ maxActive = Math.max(maxActive, active)
+ if (started === 2) resolveFirstPair()
+ await gate
+ active -= 1
+ return '[]'
+ })
+ const presenter = new MemoryPresenter({
+ repository: repo,
+ resolveAgentConfig: () => ({
+ memoryEnabled: true,
+ memoryExtractionModel: { providerId: 'p', modelId: 'm' }
+ }),
+ getEmbeddings: async () => [],
+ generateText,
+ createVectorStore: async () => new FakeVectorStore(),
+ resetVectorStore: async () => undefined
+ })
+ try {
+ for (const agentId of ['a', 'b', 'c']) {
+ presenter.writeMemoriesSync(
+ Array.from({ length: 6 }, (_, index) => ({
+ kind: 'semantic' as const,
+ content: `${agentId} fact ${index}`,
+ importance: 0.9
+ })),
+ { agentId }
+ )
+ }
+
+ const passes = ['a', 'b', 'c'].map((agentId) =>
+ presenter.runConsolidationPass(agentId, 1_000 * DAY)
+ )
+ await firstPairStarted
+ expect(started).toBe(2)
+ expect(maxActive).toBe(2)
+
+ release()
+ await Promise.all(passes)
+ expect(started).toBe(3)
+ expect(maxActive).toBe(2)
+ } finally {
+ await presenter.dispose()
+ }
})
it('records failed maintenance and throttles heavy passes when every LLM call fails', async () => {
@@ -6038,6 +6723,28 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
expect(repo.getById(id2)?.superseded_by).toBeNull()
})
+ it('does not apply an oversized model-generated maintenance merge', async () => {
+ const generateText = routedLLM({
+ decision: JSON.stringify({
+ decision: 'UPDATE',
+ targetIndex: 0,
+ mergedContent: 'x'.repeat(2_001)
+ })
+ })
+ const { presenter, repo } = makeLLMPresenter(generateText)
+ const now = 1_000 * DAY
+ const firstId = await seedEmbedded(presenter, 'user likes redis a')
+ const secondId = await seedEmbedded(presenter, 'user likes redis b')
+ repo.rows.get(firstId)!.created_at = now - 2_000
+ repo.rows.get(secondId)!.created_at = now - 1_000
+
+ await presenter.runConsolidationPass('a', now)
+
+ expect(repo.getById(firstId)?.superseded_by).toBeNull()
+ expect(repo.getById(secondId)?.superseded_by).toBeNull()
+ expect(repo.listByAgent('a')).toHaveLength(2)
+ })
+
it('a pass re-run after the cooldown does not merge an already-merged pair again (T-B5)', async () => {
const generateText = routedLLM({
decision: '{"decision":"SUPERSEDE","targetIndex":0,"mergedContent":"user prefers redis"}'
@@ -6348,12 +7055,17 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
agentId: 'disabled',
kind: 'semantic',
content: 'disabled fact',
- status: 'embedded'
+ status: 'fts_only'
})
const getEmbeddings = vi.fn(async (_p: string, _m: string, texts: string[]) =>
texts.map((text) => textToVector(text))
)
- const createVectorStore = vi.fn(async () => new FakeVectorStore())
+ const createVectorStore = vi.fn(async (agentId: string) => {
+ const store = new FakeVectorStore()
+ if (agentId === 'agent-a') store.vectors.set('a1', textToVector('redis fact'))
+ if (agentId === 'agent-b') store.vectors.set('b1', textToVector('vue fact'))
+ return store
+ })
const presenter = new MemoryPresenter({
repository: repo,
resolveAgentConfig: (agentId) =>
@@ -6377,7 +7089,9 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
'agent-a',
'agent-b'
])
- expect(getEmbeddings).toHaveBeenCalledTimes(2)
+ // The provider/model connection is shared process-wide even though each agent opens its
+ // own vector store.
+ expect(getEmbeddings).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
@@ -6396,12 +7110,7 @@ describe('MemoryPresenter offline consolidation (T-B4..T-B6)', () => {
agentId,
kind: 'semantic',
content,
- status: 'embedded'
- })
- repo.updateStatus(`${agentId}-memory`, 'embedded', {
- embeddingId: `${agentId}-memory`,
- embeddingDim: 4,
- embeddingModel: 'p:m'
+ status: 'fts_only'
})
}
const createVectorStore = vi.fn(async () => new FakeVectorStore())
@@ -7332,7 +8041,7 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
expect(repo.getLastConsolidatedAt('a')).toBeNull()
await first.runConsolidationPass('a', now)
- expect(repo.getLastConsolidatedAt('a')).toBe(now)
+ expect(repo.getLastConsolidatedAt('a')).toBeNull()
expect(auditRepo.getLatestCompletedEventAt('a', 'memory/maintenance_llm')).toBe(now)
// Restart: a fresh presenter has an empty in-memory cooldown map and must read the audit anchor.
@@ -7481,7 +8190,7 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
}),
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
- listMemoryIds: async () => [],
+ listMemoryIds: async () => ['m1'],
close: async () => {},
isUsable: () => true
}
@@ -7541,7 +8250,7 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
query: () => (blockQuery ? new Promise(() => undefined) : Promise.resolve([])),
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
- listMemoryIds: async () => [],
+ listMemoryIds: async () => ['m1'],
close,
isUsable: () => true
}
@@ -7586,13 +8295,14 @@ describe('MemoryPresenter lifecycle revival (SDD-8)', () => {
let blockAgentA = false
const closeA = vi.fn(async () => undefined)
const storeB = new FakeVectorStore()
+ storeB.vectors.set('m-b', textToVector('redis fact'))
const closeB = vi.spyOn(storeB, 'close')
const storeA: IMemoryVectorStore = {
upsert: async () => {},
query: () => (blockAgentA ? new Promise(() => undefined) : Promise.resolve([])),
queryByMemoryId: async () => [],
deleteByMemoryIds: async () => {},
- listMemoryIds: async () => [],
+ listMemoryIds: async () => ['m-a'],
close: closeA,
isUsable: () => true
}
diff --git a/test/main/presenter/memoryRetrieval.eval.test.ts b/test/main/presenter/memoryRetrieval.eval.test.ts
index b55735275..fd7a3847d 100644
--- a/test/main/presenter/memoryRetrieval.eval.test.ts
+++ b/test/main/presenter/memoryRetrieval.eval.test.ts
@@ -4,44 +4,19 @@ import { MemoryPresenter } from '@/presenter/memoryPresenter'
import { fuse } from '@/presenter/memoryPresenter/core/scoring'
import { DEFAULT_RETRIEVAL, DEFAULT_SIMILARITY_THRESHOLD } from '@/presenter/memoryPresenter/types'
import type { AgentMemoryRow } from '@/presenter/memoryPresenter/types'
+import { Database, nativeSqliteDescribeIf } from '../nativeSqliteHarness'
import { FakeRepository, FakeVectorStore } from './fakes/memoryFakes'
-const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null)
-const tableModule = sqliteModule
+const tableModule = Database
? await import('@/presenter/sqlitePresenter/tables/agentMemory').catch(() => null)
: null
-const Database = sqliteModule?.default
const AgentMemoryTable = tableModule?.AgentMemoryTable
const DatabaseCtor = Database!
const AgentMemoryTableCtor = AgentMemoryTable!
-const sqliteSkipReason = 'skipped: better-sqlite3-multiple-ciphers is unavailable'
-const requireNativeSqlite = process.env.DEEPCHAT_REQUIRE_NATIVE_SQLITE === '1'
-
-let sqliteAvailable = false
-if (Database) {
- try {
- const smokeDb = new Database(':memory:')
- smokeDb.close()
- sqliteAvailable = true
- } catch {
- sqliteAvailable = false
- }
-}
-
-const sqliteHarnessAvailable = sqliteAvailable && AgentMemoryTable
-const sqliteHarnessSkipReason = sqliteAvailable
- ? 'skipped: AgentMemoryTable is unavailable'
- : sqliteSkipReason
-const describeIfSqlite = sqliteHarnessAvailable
- ? describe
- : requireNativeSqlite
- ? (name: string, _suite: () => void) =>
- describe(name, () => {
- it('requires native SQLite support', () => {
- throw new Error(sqliteHarnessSkipReason)
- })
- })
- : describe.skip
+const describeIfSqlite = nativeSqliteDescribeIf(
+ Boolean(AgentMemoryTable),
+ 'AgentMemoryTable is unavailable'
+)
const VOCAB = [
'chinese',
@@ -247,99 +222,96 @@ describe('memory retrieval eval harness (hybrid RRF)', () => {
})
})
-describeIfSqlite(
- `memory retrieval eval harness (SQLite keyword index)${sqliteAvailable ? '' : ` (${sqliteSkipReason})`}`,
- () => {
- it('recalls CJK, path, command, and error-text fixtures through real SQLite search', () => {
- const db = new DatabaseCtor(':memory:')
- try {
- const table = new AgentMemoryTableCtor(db)
- table.createTable()
- const fixtures = [
- {
- id: 'm-cn',
- content: '用户偏好简洁中文回答,少铺垫。'
- },
- {
- id: 'm-redis',
- content: 'Debugged Redis TTL drift in the session cache.'
- },
- {
- id: 'm-path',
- content: 'Deployment command lives at /usr/local/bin/deploy --flag.'
- },
- {
- id: 'm-error',
- content: 'Port failure showed EADDRINUSE on localhost:5173.'
- }
- ]
- for (const fixture of fixtures) {
- table.insert({
- id: fixture.id,
- agentId: 'deepchat',
- kind: 'semantic',
- content: fixture.content,
- status: 'embedded'
- })
+describeIfSqlite('memory retrieval eval harness (SQLite keyword index)', () => {
+ it('recalls CJK, path, command, and error-text fixtures through real SQLite search', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ const fixtures = [
+ {
+ id: 'm-cn',
+ content: '用户偏好简洁中文回答,少铺垫。'
+ },
+ {
+ id: 'm-redis',
+ content: 'Debugged Redis TTL drift in the session cache.'
+ },
+ {
+ id: 'm-path',
+ content: 'Deployment command lives at /usr/local/bin/deploy --flag.'
+ },
+ {
+ id: 'm-error',
+ content: 'Port failure showed EADDRINUSE on localhost:5173.'
}
+ ]
+ for (const fixture of fixtures) {
table.insert({
- id: 'm-other',
- agentId: 'other-agent',
+ id: fixture.id,
+ agentId: 'deepchat',
kind: 'semantic',
- content: 'Redis TTL belongs to a different agent.',
+ content: fixture.content,
status: 'embedded'
})
+ }
+ table.insert({
+ id: 'm-other',
+ agentId: 'other-agent',
+ kind: 'semantic',
+ content: 'Redis TTL belongs to a different agent.',
+ status: 'embedded'
+ })
- const cases = [
- { query: '简洁', expected: 'm-cn' },
- { query: 'Redis TTL', expected: 'm-redis' },
- { query: '/usr/local/bin/deploy', expected: 'm-path' },
- { query: 'EADDRINUSE', expected: 'm-error' }
- ]
+ const cases = [
+ { query: '简洁', expected: 'm-cn' },
+ { query: 'Redis TTL', expected: 'm-redis' },
+ { query: '/usr/local/bin/deploy', expected: 'm-path' },
+ { query: 'EADDRINUSE', expected: 'm-error' }
+ ]
- for (const testCase of cases) {
- const ids = table.search('deepchat', testCase.query, 5).map((row) => row.id)
- expect(ids[0]).toBe(testCase.expected)
- }
- expect(table.search('deepchat', 'different agent', 5)).toHaveLength(0)
- } finally {
- db.close()
+ for (const testCase of cases) {
+ const ids = table.search('deepchat', testCase.query, 5).map((row) => row.id)
+ expect(ids[0]).toBe(testCase.expected)
}
- })
-
- it('keeps hybrid RRF at least as strong as real SQLite keyword retrieval', () => {
- const db = new DatabaseCtor(':memory:')
- try {
- const table = new AgentMemoryTableCtor(db)
- table.createTable()
- for (const row of FIXTURE) {
- table.insert({
- id: row.id,
- agentId: row.agent_id,
- kind: row.kind,
- content: row.content,
- importance: row.importance,
- status: 'embedded'
- })
- }
+ expect(table.search('deepchat', 'different agent', 5)).toHaveLength(0)
+ } finally {
+ db.close()
+ }
+ })
- const rankedFromSqlite = (query: string, mode: 'hybrid' | 'fts') => {
- const keyword = table.search('a', query, FUSE_OPTS.topK)
- const vec = mode === 'hybrid' ? vecCandidates(query) : []
- return fuse(keyword, vec, FUSE_OPTS).map((item) => item.id)
- }
- const mrr = (mode: 'hybrid' | 'fts') =>
- CASES.reduce(
- (sum, testCase) =>
- sum + reciprocalRank(rankedFromSqlite(testCase.query, mode), testCase.expected),
- 0
- ) / CASES.length
+ it('keeps hybrid RRF at least as strong as real SQLite keyword retrieval', () => {
+ const db = new DatabaseCtor(':memory:')
+ try {
+ const table = new AgentMemoryTableCtor(db)
+ table.createTable()
+ for (const row of FIXTURE) {
+ table.insert({
+ id: row.id,
+ agentId: row.agent_id,
+ kind: row.kind,
+ content: row.content,
+ importance: row.importance,
+ status: 'embedded'
+ })
+ }
- expect(mrr('hybrid')).toBeGreaterThanOrEqual(mrr('fts'))
- expect(rankedFromSqlite('session store', 'hybrid')[0]).toBe('m-redis')
- } finally {
- db.close()
+ const rankedFromSqlite = (query: string, mode: 'hybrid' | 'fts') => {
+ const keyword = table.search('a', query, FUSE_OPTS.topK)
+ const vec = mode === 'hybrid' ? vecCandidates(query) : []
+ return fuse(keyword, vec, FUSE_OPTS).map((item) => item.id)
}
- })
- }
-)
+ const mrr = (mode: 'hybrid' | 'fts') =>
+ CASES.reduce(
+ (sum, testCase) =>
+ sum + reciprocalRank(rankedFromSqlite(testCase.query, mode), testCase.expected),
+ 0
+ ) / CASES.length
+
+ expect(mrr('hybrid')).toBeGreaterThanOrEqual(mrr('fts'))
+ expect(rankedFromSqlite('session store', 'hybrid')[0]).toBe('m-redis')
+ } finally {
+ db.close()
+ }
+ })
+})
diff --git a/test/main/presenter/memoryUpdate.test.ts b/test/main/presenter/memoryUpdate.test.ts
index 86942de64..8148122e9 100644
--- a/test/main/presenter/memoryUpdate.test.ts
+++ b/test/main/presenter/memoryUpdate.test.ts
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { buildMemoryProvenanceKey } from '@/presenter/memoryPresenter/core/scoring'
import type { AgentMemoryRow } from '@/presenter/memoryPresenter/types'
-import type { AgentMemoryCategory } from '@shared/types/agent-memory'
+import {
+ AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS,
+ type AgentMemoryCategory
+} from '@shared/types/agent-memory'
import { enabledConfig, FakeRepository, makePresenter } from './fakes/memoryFakes'
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
@@ -37,6 +40,25 @@ function insertMemory(
}
describe('MemoryPresenter.updateMemory', () => {
+ it('rejects oversized content without mutating or auditing the row', () => {
+ const { presenter, repo, auditRepo, getEmbeddings } = makePresenter(enabledConfig)
+ insertMemory(repo, {
+ id: 'm1',
+ content: 'user likes redis',
+ category: 'user_preference',
+ importance: 0.6
+ })
+
+ expect(
+ presenter.updateMemory('deepchat', 'm1', {
+ content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1)
+ })
+ ).toEqual({ action: 'noop', reason: 'content-too-large' })
+ expect(repo.getById('m1')?.content).toBe('user likes redis')
+ expect(auditRepo.listByAgent('deepchat')).toHaveLength(0)
+ expect(getEmbeddings).not.toHaveBeenCalled()
+ })
+
it('sets metadata exactly and emits manual-edit without re-embedding', async () => {
const onMemoryChanged = vi.fn()
const { presenter, repo, auditRepo, getEmbeddings } = makePresenter(enabledConfig, undefined, {
diff --git a/test/main/presenter/memoryVectorStore.test.ts b/test/main/presenter/memoryVectorStore.test.ts
index 13631add1..38af1ccb9 100644
--- a/test/main/presenter/memoryVectorStore.test.ts
+++ b/test/main/presenter/memoryVectorStore.test.ts
@@ -22,6 +22,7 @@ import { gzipSync } from 'node:zlib'
interface TestStore {
connection: { run: ReturnType }
vectorTable: string
+ perfObserver?: { increment: ReturnType; observe: ReturnType }
upsert(records: MemoryVectorRecord[]): Promise
}
@@ -61,8 +62,11 @@ const records: MemoryVectorRecord[] = [{ memoryId: 'm1', embedding: [0.1, 0.2] }
describe('MemoryVectorStore.upsert transaction (C4, AC-4.2)', () => {
it('wraps DELETE+INSERT in a single BEGIN/COMMIT', async () => {
const { store, calls } = makeStore()
+ const increment = vi.fn()
+ store.perfObserver = { increment, observe: vi.fn() }
await store.upsert(records)
expect(calls).toEqual(['BEGIN', 'DELETE', 'INSERT', 'COMMIT'])
+ expect(increment.mock.calls).toEqual([['duckDbStatements'], ['duckDbStatements']])
})
it('rolls back and rethrows when INSERT fails, never COMMITs', async () => {
diff --git a/test/main/presenter/recallKeyword.test.ts b/test/main/presenter/recallKeyword.test.ts
index 570e1b4c5..a44e53346 100644
--- a/test/main/presenter/recallKeyword.test.ts
+++ b/test/main/presenter/recallKeyword.test.ts
@@ -4,11 +4,6 @@ import {
extractRecallKeywordCandidates,
selectRecallKeywordTerms
} from '@/presenter/memoryPresenter/core/recallKeyword'
-import type { RecallKeywordTermStat } from '@/presenter/memoryPresenter/types'
-
-function stats(entries: Array<[string, number, number]>): RecallKeywordTermStat[] {
- return entries.map(([term, hitCount, totalRows]) => ({ term, hitCount, totalRows }))
-}
describe('recall keyword selection', () => {
it('extracts ascii, code-like, and CJK candidates without duplicates', () => {
@@ -50,48 +45,37 @@ describe('recall keyword selection', () => {
expect(candidates.some((candidate) => candidate.term === 'term23')).toBe(false)
})
- it('selects lower-frequency terms but emits them in query order', () => {
- const candidates = extractRecallKeywordCandidates('please redis setup dashboard')
-
- expect(
- selectRecallKeywordTerms(
- candidates,
- stats([
- ['please', 8, 10],
- ['redis', 1, 10],
- ['setup', 2, 10],
- ['dashboard', 1, 10]
- ])
- )
- ).toEqual(['redis', 'setup', 'dashboard'])
- })
-
- it('falls back to the rarest single term when every hit is high-frequency', () => {
- const candidates = extractRecallKeywordCandidates('redis setup cache')
+ it('prioritizes code, then CJK, then ASCII while emitting selected terms in query order', () => {
+ const candidates = extractRecallKeywordCandidates(
+ 'alpha longestplaintext 中文回答问题 api/v1.redis_error beta gamma delta epsilon zeta'
+ )
- expect(
- selectRecallKeywordTerms(
- candidates,
- stats([
- ['redis', 9, 10],
- ['setup', 8, 10],
- ['cache', 10, 10]
- ])
- )
- ).toEqual(['setup'])
+ expect(selectRecallKeywordTerms(candidates)).toEqual([
+ 'alpha',
+ 'longestplaintext',
+ '中文回答',
+ '文回答问',
+ '回答问题',
+ 'api/v1.redis_error',
+ 'gamma',
+ 'epsilon'
+ ])
})
- it('drops terms that do not hit the active corpus', () => {
- const candidates = extractRecallKeywordCandidates('unknown redis')
+ it('breaks same-kind ties by Unicode code-point length and then first position', () => {
+ const candidates = extractRecallKeywordCandidates(
+ 'aaa bbbb ccccc ddddd eeeeee fffffff gggggggg hhhhhhhhh iiiiiiiiii jjjjjjjjjjj'
+ )
- expect(
- selectRecallKeywordTerms(
- candidates,
- stats([
- ['unknown', 0, 10],
- ['redis', 1, 10]
- ])
- )
- ).toEqual(['redis'])
+ expect(selectRecallKeywordTerms(candidates)).toEqual([
+ 'ccccc',
+ 'ddddd',
+ 'eeeeee',
+ 'fffffff',
+ 'gggggggg',
+ 'hhhhhhhhh',
+ 'iiiiiiiiii',
+ 'jjjjjjjjjjj'
+ ])
})
})
diff --git a/test/main/presenter/sqlitePresenter/deepchatMemoryIngestionProjection.test.ts b/test/main/presenter/sqlitePresenter/deepchatMemoryIngestionProjection.test.ts
new file mode 100644
index 000000000..e6fc7874c
--- /dev/null
+++ b/test/main/presenter/sqlitePresenter/deepchatMemoryIngestionProjection.test.ts
@@ -0,0 +1,456 @@
+import { describe, expect, vi } from 'vitest'
+import { buildEffectiveTapeView } from '@/presenter/agentRuntimePresenter/tapeEffectiveView'
+import { Database, nativeSqliteItIf } from '../../nativeSqliteHarness'
+
+const entriesModule = Database
+ ? await import('../../../../src/main/presenter/sqlitePresenter/tables/deepchatTapeEntries')
+ : null
+const projectionModule = Database
+ ? await import('../../../../src/main/presenter/sqlitePresenter/tables/deepchatMemoryIngestionProjection')
+ : null
+
+const DeepChatTapeEntriesTable = entriesModule?.DeepChatTapeEntriesTable
+const DeepChatMemoryIngestionProjectionTable =
+ projectionModule?.DeepChatMemoryIngestionProjectionTable
+const DatabaseCtor = Database!
+const TapeTableCtor = DeepChatTapeEntriesTable!
+const ProjectionTableCtor = DeepChatMemoryIngestionProjectionTable!
+const itIfSqlite = nativeSqliteItIf(
+ Boolean(DeepChatTapeEntriesTable && DeepChatMemoryIngestionProjectionTable),
+ 'Tape projection native table modules are unavailable'
+)
+
+describe('DeepChatMemoryIngestionProjectionTable', () => {
+ function createTables() {
+ const db = new DatabaseCtor(':memory:')
+ const projection = new ProjectionTableCtor(db)
+ projection.createTable()
+ const tape = new TapeTableCtor(db, projection)
+ tape.createTable()
+ return { db, projection, tape }
+ }
+
+ function appendMessage(
+ tape: InstanceType,
+ input: {
+ sessionId?: string
+ id: string
+ orderSeq: number
+ status: 'pending' | 'sent' | 'error'
+ content: string
+ role?: 'user' | 'assistant'
+ }
+ ) {
+ const sessionId = input.sessionId ?? 's1'
+ return tape.append({
+ sessionId,
+ kind: 'message',
+ name: `message/${input.role ?? 'user'}`,
+ source: { type: 'message', id: input.id, seq: 0 },
+ provenanceKey: null,
+ payload: {
+ record: {
+ id: input.id,
+ sessionId,
+ orderSeq: input.orderSeq,
+ role: input.role ?? 'user',
+ content: input.content,
+ status: input.status,
+ isContextEdge: 0,
+ metadata: '{}',
+ traceCount: 0,
+ createdAt: 100 + input.orderSeq,
+ updatedAt: 100 + input.orderSeq
+ }
+ },
+ meta: { status: input.status },
+ createdAt: 100 + input.orderSeq
+ })
+ }
+
+ function appendToolCall(
+ tape: InstanceType,
+ input: {
+ sessionId?: string
+ messageId: string
+ toolCallId: string
+ status: 'pending' | 'success' | 'error'
+ }
+ ) {
+ return tape.append({
+ sessionId: input.sessionId ?? 's1',
+ kind: 'tool_call',
+ name: 'read_file',
+ source: { type: 'tool_call', id: input.toolCallId, seq: 0 },
+ provenanceKey: null,
+ payload: {
+ messageId: input.messageId,
+ toolCall: { id: input.toolCallId, name: 'read_file' }
+ },
+ meta: { status: input.status },
+ createdAt: 200
+ })
+ }
+
+ function rebuildFromTape(
+ tape: InstanceType,
+ projection: InstanceType,
+ sessionId = 's1'
+ ) {
+ const view = buildEffectiveTapeView(tape.getBySession(sessionId))
+ const messageIdsWithToolUse = new Set()
+ for (const row of view.rows) {
+ if (row.kind !== 'tool_call') continue
+ const payload = JSON.parse(row.payload_json) as { messageId?: unknown }
+ if (typeof payload.messageId === 'string') {
+ messageIdsWithToolUse.add(payload.messageId)
+ }
+ }
+ projection.replaceSession(
+ sessionId,
+ view.messageEntries.map((entry) => ({
+ sessionId,
+ messageId: entry.record.id,
+ orderSeq: entry.record.orderSeq,
+ entryId: entry.entryId,
+ role: entry.record.role,
+ content: entry.record.content,
+ status: entry.record.status as 'sent' | 'error',
+ hadToolUse: messageIdsWithToolUse.has(entry.record.id)
+ })),
+ tape.getMaxEntryId(sessionId)
+ )
+ return view
+ }
+
+ itIfSqlite(
+ 'incrementally projects final messages, revisions, tool calls and unrelated entries',
+ () => {
+ const { db, projection, tape } = createTables()
+ try {
+ tape.ensureBootstrapAnchor('s1')
+ appendMessage(tape, {
+ id: 'pending',
+ orderSeq: 1,
+ status: 'pending',
+ content: 'not recallable'
+ })
+ appendMessage(tape, {
+ id: 'm2',
+ orderSeq: 2,
+ status: 'sent',
+ content: 'second'
+ })
+ appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 1,
+ status: 'sent',
+ content: 'first'
+ })
+ tape.append({
+ sessionId: 's1',
+ kind: 'tool_result',
+ name: 'read_file',
+ source: { type: 'tool_result', id: 'm2:tool-result-only', seq: 0 },
+ provenanceKey: null,
+ payload: {
+ messageId: 'm2',
+ toolCallId: 'tool-result-only',
+ response: 'ok'
+ },
+ meta: { status: 'success' },
+ createdAt: 150
+ })
+ const originalM1EntryId = projection.listRange('s1', 0, 10)[0].entry_id
+ appendToolCall(tape, {
+ messageId: 'm1',
+ toolCallId: 'tool-pending',
+ status: 'pending'
+ })
+ appendToolCall(tape, {
+ messageId: 'm1',
+ toolCallId: 'tool-final',
+ status: 'success'
+ })
+ const replacement = appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 1,
+ status: 'error',
+ content: 'first corrected'
+ })
+ tape.appendEvent({
+ sessionId: 's1',
+ name: 'memory/extract',
+ data: { count: 1 },
+ createdAt: 300
+ })
+
+ expect(projection.isCurrent('s1', tape.getMaxEntryId('s1'))).toBe(true)
+ const currentRange = projection.readCurrentRange('s1', 0, 10)
+ expect(currentRange.current).toBe(true)
+ expect(currentRange.rows).toMatchObject([
+ {
+ message_id: 'm1',
+ order_seq: 1,
+ entry_id: replacement.entry_id,
+ content: 'first corrected',
+ status: 'error',
+ had_tool_use: 1
+ },
+ {
+ message_id: 'm2',
+ order_seq: 2,
+ content: 'second',
+ status: 'sent',
+ had_tool_use: 0
+ }
+ ])
+ expect(replacement.entry_id).toBeGreaterThan(originalM1EntryId)
+ } finally {
+ db.close()
+ }
+ }
+ )
+
+ itIfSqlite('uses each session previous max instead of a global entry predecessor', () => {
+ const { db, projection, tape } = createTables()
+ try {
+ for (let index = 0; index < 5; index += 1) {
+ tape.appendEvent({
+ sessionId: 's1',
+ name: `event/${index}`,
+ data: { index },
+ createdAt: index
+ })
+ }
+ appendMessage(tape, {
+ sessionId: 's2',
+ id: 'other-session-message',
+ orderSeq: 1,
+ status: 'sent',
+ content: 'other'
+ })
+
+ expect(tape.getMaxEntryId('s1')).toBe(5)
+ expect(tape.getMaxEntryId('s2')).toBe(1)
+ expect(projection.isCurrent('s2', 1)).toBe(true)
+ expect(projection.listRange('s2', 0, 1)).toMatchObject([
+ { message_id: 'other-session-message', entry_id: 1 }
+ ])
+ } finally {
+ db.close()
+ }
+ })
+
+ itIfSqlite(
+ 'marks retractions stale and keeps a final tool-before-message sequence current',
+ () => {
+ const { db, projection, tape } = createTables()
+ try {
+ appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 1,
+ status: 'sent',
+ content: 'first'
+ })
+ tape.appendEvent({
+ sessionId: 's1',
+ name: 'message/retracted',
+ data: { messageId: 'm1' },
+ createdAt: 200
+ })
+ expect(projection.isCurrent('s1', tape.getMaxEntryId('s1'))).toBe(false)
+
+ projection.deleteBySession('s1')
+ tape.deleteBySession('s1')
+ appendToolCall(tape, {
+ messageId: 'future-message',
+ toolCallId: 'tool-before-message',
+ status: 'success'
+ })
+ expect(projection.isCurrent('s1', tape.getMaxEntryId('s1'))).toBe(true)
+ } finally {
+ db.close()
+ }
+ }
+ )
+
+ itIfSqlite('rebuilds to full effective-view parity after retraction and re-add', () => {
+ const { db, projection, tape } = createTables()
+ try {
+ appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 1,
+ status: 'sent',
+ content: 'old'
+ })
+ appendToolCall(tape, {
+ messageId: 'm1',
+ toolCallId: 'tool-1',
+ status: 'success'
+ })
+ appendMessage(tape, {
+ id: 'z-message',
+ orderSeq: 2,
+ status: 'sent',
+ content: 'z'
+ })
+ appendMessage(tape, {
+ id: 'a-message',
+ orderSeq: 2,
+ status: 'sent',
+ content: 'a'
+ })
+ tape.appendEvent({
+ sessionId: 's1',
+ name: 'message/retracted',
+ data: { messageId: 'm1' },
+ createdAt: 300
+ })
+ const restored = appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 3,
+ status: 'error',
+ content: 'restored'
+ })
+
+ expect(projection.isCurrent('s1', tape.getMaxEntryId('s1'))).toBe(false)
+ const view = rebuildFromTape(tape, projection)
+ const projected = projection.listRange('s1', 0, 10)
+
+ expect(
+ projected.map((row) => ({
+ messageId: row.message_id,
+ orderSeq: row.order_seq,
+ entryId: row.entry_id,
+ role: row.role,
+ content: row.content,
+ status: row.status
+ }))
+ ).toEqual(
+ view.messageEntries.map((entry) => ({
+ messageId: entry.record.id,
+ orderSeq: entry.record.orderSeq,
+ entryId: entry.entryId,
+ role: entry.record.role,
+ content: entry.record.content,
+ status: entry.record.status
+ }))
+ )
+ expect(projected.map((row) => row.message_id)).toEqual(['a-message', 'z-message', 'm1'])
+ expect(projected.find((row) => row.message_id === 'm1')).toMatchObject({
+ entry_id: restored.entry_id,
+ had_tool_use: 1
+ })
+ } finally {
+ db.close()
+ }
+ })
+
+ itIfSqlite('keeps Tape authoritative when reducer failure can invalidate projection', () => {
+ const db = new DatabaseCtor(':memory:')
+ const projection = {
+ applyAppendedEntry: vi.fn(() => {
+ throw new Error('projection write failed')
+ }),
+ invalidateSession: vi.fn(),
+ deleteBySession: vi.fn()
+ }
+ const tape = new TapeTableCtor(db, projection)
+ tape.createTable()
+ try {
+ tape.appendEvent({ sessionId: 's1', name: 'run/start', data: {}, createdAt: 100 })
+ expect(tape.countBySession('s1')).toBe(1)
+ expect(projection.invalidateSession).toHaveBeenCalledWith('s1')
+ } finally {
+ db.close()
+ }
+ })
+
+ itIfSqlite('rolls Tape append back when projection invalidation also fails', () => {
+ const db = new DatabaseCtor(':memory:')
+ const projection = {
+ applyAppendedEntry: vi.fn(() => {
+ throw new Error('projection write failed')
+ }),
+ invalidateSession: vi.fn(() => {
+ throw new Error('projection invalidation failed')
+ }),
+ deleteBySession: vi.fn()
+ }
+ const tape = new TapeTableCtor(db, projection)
+ tape.createTable()
+ try {
+ expect(() =>
+ tape.appendEvent({ sessionId: 's1', name: 'run/start', data: {}, createdAt: 100 })
+ ).toThrow('projection invalidation failed')
+ expect(tape.countBySession('s1')).toBe(0)
+ } finally {
+ db.close()
+ }
+ })
+
+ itIfSqlite(
+ 'replaces a stale session transactionally and persists current meta across restart',
+ () => {
+ const { db, projection, tape } = createTables()
+ try {
+ tape.appendEvent({ sessionId: 's1', name: 'legacy/event', data: {}, createdAt: 100 })
+ projection.invalidateSession('s1')
+ projection.replaceSession(
+ 's1',
+ [
+ {
+ sessionId: 's1',
+ messageId: 'rebuilt',
+ orderSeq: 3,
+ entryId: 1,
+ role: 'assistant',
+ content: 'rebuilt content',
+ status: 'sent',
+ hadToolUse: true
+ }
+ ],
+ tape.getMaxEntryId('s1')
+ )
+
+ const restartedProjection = new ProjectionTableCtor(db)
+ restartedProjection.createTable()
+ const restartedTape = new TapeTableCtor(db, restartedProjection)
+ expect(restartedProjection.isCurrent('s1', restartedTape.getMaxEntryId('s1'))).toBe(true)
+
+ restartedTape.appendEvent({
+ sessionId: 's1',
+ name: 'after/restart',
+ data: {},
+ createdAt: 200
+ })
+ expect(restartedProjection.isCurrent('s1', restartedTape.getMaxEntryId('s1'))).toBe(true)
+ expect(restartedProjection.listRange('s1', 0, 10)).toMatchObject([
+ { message_id: 'rebuilt', had_tool_use: 1 }
+ ])
+ } finally {
+ db.close()
+ }
+ }
+ )
+
+ itIfSqlite('cleans projection rows and meta with the authoritative session delete', () => {
+ const { db, projection, tape } = createTables()
+ try {
+ appendMessage(tape, {
+ id: 'm1',
+ orderSeq: 1,
+ status: 'sent',
+ content: 'first'
+ })
+ tape.deleteBySession('s1')
+
+ expect(tape.getBySession('s1')).toEqual([])
+ expect(projection.listRange('s1', 0, 10)).toEqual([])
+ expect(projection.getSessionMeta('s1')).toBeNull()
+ } finally {
+ db.close()
+ }
+ })
+})
diff --git a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts
index 784329c7a..251af8c68 100644
--- a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts
+++ b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
+import { AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS } from '@shared/types/agent-memory'
import {
AgentMemoryToolHandler,
@@ -33,6 +34,37 @@ const buildRuntimePort = (overrides: Record = {}) =>
}) as any
describe('Agent memory tools', () => {
+ it('rejects memory_remember content above the manual limit before invoking the runtime', async () => {
+ const runtimePort = buildRuntimePort()
+ const handler = new AgentMemoryToolHandler(runtimePort)
+
+ await expect(
+ handler.call(
+ MEMORY_TOOL_NAMES.remember,
+ { content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1) },
+ 'conversation-1'
+ )
+ ).rejects.toThrow()
+ expect(runtimePort.rememberMemory).not.toHaveBeenCalled()
+ })
+
+ it('accepts astral content at the manual code-point limit', async () => {
+ const runtimePort = buildRuntimePort({
+ rememberMemory: vi.fn().mockResolvedValue({ action: 'created', id: 'mem-emoji' })
+ })
+ const handler = new AgentMemoryToolHandler(runtimePort)
+ const content = '😀'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS)
+
+ await handler.call(MEMORY_TOOL_NAMES.remember, { content }, 'conversation-1')
+
+ expect(runtimePort.rememberMemory).toHaveBeenCalledWith(
+ 'deepchat',
+ expect.objectContaining({ content }),
+ 'conversation-1',
+ expect.any(Object)
+ )
+ })
+
it('passes memory_remember category through to the runtime port', async () => {
const runtimePort = buildRuntimePort({
rememberMemory: vi.fn().mockResolvedValue({ action: 'created', id: 'mem-1' })
diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts
index df4f1c2ce..f22ff23d3 100644
--- a/test/main/routes/dispatcher.test.ts
+++ b/test/main/routes/dispatcher.test.ts
@@ -24,7 +24,8 @@ import type { CronJobRunSessionStarter } from '@/presenter/cronJobs'
import type { ProviderInstallPreview } from '@shared/providerDeeplink'
import {
createEmptyArchiveCandidateLifecyclePreview,
- createEmptyMemoryHealth
+ createEmptyMemoryHealth,
+ decodeMemoryPageCursor
} from '@shared/contracts/routes'
import { createMainKernelRouteRuntime, dispatchDeepchatRoute } from '@/routes'
import { setDeepchatEventWindowPresenter } from '@/routes/publishDeepchatEvent'
@@ -2238,7 +2239,7 @@ describe('dispatchDeepchatRoute', () => {
it('returns a null memory source span when the SQLite presenter has no tape table', async () => {
const { runtime } = createRuntime()
;(runtime as any).memoryPresenter = {
- listMemories: vi.fn(() => [
+ getManagementVisibleByIds: vi.fn(() => [
{
id: 'm1',
agent_id: 'deepchat',
@@ -2258,6 +2259,75 @@ describe('dispatchDeepchatRoute', () => {
).resolves.toEqual({ span: null })
})
+ it('dispatches bounded memory pages and returns an opaque keyset cursor', async () => {
+ const { runtime } = createRuntime()
+ const pageMemories = vi.fn(() => ({
+ rows: [
+ {
+ id: 'm1',
+ agent_id: 'deepchat',
+ user_scope: null,
+ kind: 'semantic',
+ category: null,
+ content: 'paged fact',
+ importance: 0.5,
+ status: 'embedded',
+ embedding_id: null,
+ embedding_dim: null,
+ embedding_model: null,
+ source_session: null,
+ provenance_key: null,
+ is_anchor: 0,
+ superseded_by: null,
+ created_at: 1000,
+ last_accessed: null,
+ access_count: 0,
+ decay_score: null,
+ source_entry_ids: null,
+ confidence: null,
+ last_consolidated_at: null,
+ conflict_state: null,
+ conflict_with: null,
+ persona_state: null,
+ decision_revision: 1
+ }
+ ],
+ nextCursor: { createdAt: 1000, id: 'm1' }
+ }))
+ ;(runtime as any).memoryPresenter = { pageMemories }
+
+ const result = await dispatchDeepchatRoute(
+ runtime,
+ 'memory.page',
+ { agentId: 'deepchat', limit: 25 },
+ { webContentsId: 42, windowId: 7 }
+ )
+
+ expect(pageMemories).toHaveBeenCalledWith('deepchat', null, 25)
+ expect(result.items.map((item) => item.id)).toEqual(['m1'])
+ expect(decodeMemoryPageCursor(result.nextCursor!)).toEqual({
+ v: 1,
+ createdAt: 1000,
+ id: 'm1'
+ })
+ })
+
+ it('returns an empty memory page for a non-DeepChat agent', async () => {
+ const { runtime } = createRuntime()
+ const pageMemories = vi.fn()
+ ;(runtime as any).memoryPresenter = { pageMemories }
+
+ await expect(
+ dispatchDeepchatRoute(
+ runtime,
+ 'memory.page',
+ { agentId: 'external-agent', limit: 25 },
+ { webContentsId: 42, windowId: 7 }
+ )
+ ).resolves.toEqual({ items: [], nextCursor: null })
+ expect(pageMemories).not.toHaveBeenCalled()
+ })
+
it('does not expand all sessions when listing memory view manifests', async () => {
const { runtime, configPresenter } = createRuntime()
vi.mocked(configPresenter.getAgentType).mockResolvedValueOnce('deepchat')
diff --git a/test/main/routes/memoryDto.test.ts b/test/main/routes/memoryDto.test.ts
index 9426b7d8c..5670f423c 100644
--- a/test/main/routes/memoryDto.test.ts
+++ b/test/main/routes/memoryDto.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
import { formatMemorySourceRecordContent, toMemoryItemDto } from '@/routes'
import {
createEmptyMemoryHealth,
+ decodeMemoryPageCursor,
+ encodeMemoryPageCursor,
memoryAddRoute,
memoryArchiveRoute,
memoryGetArchiveCandidateLifecyclePreviewRoute,
@@ -11,11 +13,13 @@ import {
memoryGetLifecycleRoute,
memoryGetStatusRoute,
memoryListRoute,
+ memoryPageRoute,
memoryReindexRoute,
memoryRestoreRoute,
memorySearchRoute,
memoryUpdateRoute
} from '@shared/contracts/routes'
+import { AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS } from '@shared/types/agent-memory'
import { memoryUpdatedEvent } from '@shared/contracts/events/memory.events'
import type { AgentMemoryRow } from '@/presenter/memoryPresenter/types'
import type { ChatMessageRecord } from '@shared/types/agent-interface'
@@ -173,6 +177,51 @@ describe('toMemoryItemDto sourceEntryIds passthrough', () => {
})
})
+describe('memory.page route contract', () => {
+ it('round-trips a versioned opaque cursor and applies the default page limit', () => {
+ const cursor = encodeMemoryPageCursor({ v: 1, createdAt: 1_700_000_000_000, id: 'memory-α' })
+ expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/u)
+ expect(decodeMemoryPageCursor(cursor)).toEqual({
+ v: 1,
+ createdAt: 1_700_000_000_000,
+ id: 'memory-α'
+ })
+ expect(memoryPageRoute.input.parse({ agentId: 'deepchat', cursor })).toEqual({
+ agentId: 'deepchat',
+ cursor,
+ limit: 100
+ })
+ })
+
+ it('rejects malformed, unsupported, and oversized cursors instead of returning page one', () => {
+ const unsupported = Buffer.from(
+ JSON.stringify({ v: 2, createdAt: 1000, id: 'm1' }),
+ 'utf8'
+ ).toString('base64url')
+ const unsafeTimestamp = Buffer.from(
+ JSON.stringify({ v: 1, createdAt: Number.MAX_SAFE_INTEGER + 1, id: 'm1' }),
+ 'utf8'
+ ).toString('base64url')
+ for (const cursor of ['not+base64', unsupported, unsafeTimestamp, 'a'.repeat(2049)]) {
+ expect(memoryPageRoute.input.safeParse({ agentId: 'deepchat', cursor }).success).toBe(false)
+ }
+ expect(memoryPageRoute.input.safeParse({ agentId: 'deepchat', limit: 101 }).success).toBe(false)
+ })
+
+ it('caps the output page to 100 management rows', () => {
+ const items = Array.from({ length: 100 }, (_, index) =>
+ toMemoryItemDto(makeRow({ id: `m${index}` }))
+ )
+ expect(memoryPageRoute.output.parse({ items, nextCursor: null }).items).toHaveLength(100)
+ expect(
+ memoryPageRoute.output.safeParse({
+ items: [...items, toMemoryItemDto(makeRow({ id: 'overflow' }))],
+ nextCursor: null
+ }).success
+ ).toBe(false)
+ })
+})
+
describe('memory.restore route contract round-trip', () => {
it('round-trips a valid restore input and output', () => {
const input = memoryRestoreRoute.input.parse({ agentId: 'deepchat-abc123', memoryId: 'mem-1' })
@@ -425,6 +474,30 @@ describe('memory.add route contract', () => {
expect(
memoryAddRoute.input.safeParse({ agentId: 'deepchat', content: 'x', importance: 2 }).success
).toBe(false)
+ expect(
+ memoryAddRoute.input.safeParse({
+ agentId: 'deepchat',
+ content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS)
+ }).success
+ ).toBe(true)
+ expect(
+ memoryAddRoute.input.safeParse({
+ agentId: 'deepchat',
+ content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1)
+ }).success
+ ).toBe(false)
+ expect(
+ memoryAddRoute.input.safeParse({
+ agentId: 'deepchat',
+ content: '😀'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS)
+ }).success
+ ).toBe(true)
+ expect(
+ memoryAddRoute.input.safeParse({
+ agentId: 'deepchat',
+ content: '😀'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1)
+ }).success
+ ).toBe(false)
expect(
memoryAddRoute.input.safeParse({
agentId: 'deepchat',
@@ -470,6 +543,18 @@ describe('memory.update route contract', () => {
expect(
memoryUpdateRoute.input.safeParse({ agentId: 'deepchat', memoryId: 'm1', patch: {} }).success
).toBe(false)
+ expect(
+ memoryUpdateRoute.input.safeParse({
+ agentId: 'deepchat',
+ memoryId: 'm1',
+ patch: { content: 'x'.repeat(AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS + 1) }
+ }).success
+ ).toBe(false)
+ expect(
+ memoryUpdateRoute.output.parse({
+ result: { action: 'noop', reason: 'content-too-large' }
+ }).result.reason
+ ).toBe('content-too-large')
expect(
memoryUpdateRoute.input.safeParse({
agentId: 'bad/id',
diff --git a/test/main/scripts/architectureGuard.test.ts b/test/main/scripts/architectureGuard.test.ts
index 576e52333..8ab8e0f8b 100644
--- a/test/main/scripts/architectureGuard.test.ts
+++ b/test/main/scripts/architectureGuard.test.ts
@@ -80,6 +80,41 @@ describe.sequential('architecture guard', () => {
expect(result.stderr).toContain('[renderer-business-direct-ipc-listener]')
})
+ it('fails when renderer business code calls the deprecated unbounded memory list', async () => {
+ await writeSettingsFixture(`
+ import { createMemoryClient as makeMemoryClient } from '@api/MemoryClient'
+
+ const memoryClient = makeMemoryClient()
+ const renamedClient = memoryClient
+ const { list: legacyList } = renamedClient
+ export const fixture = [
+ renamedClient.list('deepchat'),
+ legacyList('deepchat'),
+ makeMemoryClient().list('deepchat')
+ ]
+ `)
+
+ const result = runArchitectureGuard()
+
+ expect(result.status).not.toBe(0)
+ expect(result.stderr).toContain('[memory-legacy-list-caller]')
+ expect(result.stderr).toContain('use memoryClient.page')
+ })
+
+ it('fails when renderer business code invokes the legacy memory route directly', async () => {
+ await writeSettingsFixture(`
+ import { memoryListRoute as legacyMemoryRoute } from '@shared/contracts/routes'
+
+ const bridge = { invoke: async (..._args: unknown[]) => ({ memories: [] }) }
+ export const fixture = bridge.invoke(legacyMemoryRoute.name, { agentId: 'deepchat' })
+ `)
+
+ const result = runArchitectureGuard()
+
+ expect(result.status).not.toBe(0)
+ expect(result.stderr).toContain('[memory-legacy-list-caller]')
+ })
+
it('fails when memory core imports runtime context', async () => {
await writeFixture(
MEMORY_CORE_FIXTURE_PATH,
diff --git a/test/main/shared/unicodeText.test.ts b/test/main/shared/unicodeText.test.ts
new file mode 100644
index 000000000..22db508ee
--- /dev/null
+++ b/test/main/shared/unicodeText.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+
+import { truncateUnicodeCodePoints, unicodeCodePointLength } from '@shared/lib/unicodeText'
+
+describe('Unicode text limits', () => {
+ it('counts Unicode scalar values instead of UTF-16 code units', () => {
+ expect(unicodeCodePointLength('a😀记')).toBe(3)
+ })
+
+ it('truncates without leaving an isolated surrogate', () => {
+ const truncated = truncateUnicodeCodePoints(`${'😀'.repeat(400)}tail`, 400)
+ expect(truncated).toBe('😀'.repeat(400))
+ expect(unicodeCodePointLength(truncated)).toBe(400)
+ expect(truncated.at(-1)?.charCodeAt(0)).toBeGreaterThanOrEqual(0xdc00)
+ })
+})
diff --git a/test/renderer/api/clients.test.ts b/test/renderer/api/clients.test.ts
index 0eeee1a54..90e09c1ec 100644
--- a/test/renderer/api/clients.test.ts
+++ b/test/renderer/api/clients.test.ts
@@ -1151,6 +1151,25 @@ describe('renderer api clients', () => {
}
]
}
+ case 'memory.page':
+ return {
+ items: [
+ {
+ id: 'mem-page',
+ agentId: payload?.agentId ?? 'agent-1',
+ kind: 'semantic',
+ category: null,
+ content: 'paged memory',
+ importance: 0.6,
+ status: 'embedded',
+ sourceSession: null,
+ sourceEntryIds: null,
+ supersededBy: null,
+ createdAt: 900
+ }
+ ],
+ nextCursor: null
+ }
default:
return {}
}
@@ -1424,6 +1443,7 @@ describe('renderer api clients', () => {
const lifecycle = await memoryClient.getLifecycle('agent-1', 'mem-1')
const archiveCandidatePreview =
await memoryClient.getArchiveCandidateLifecyclePreview('agent-1')
+ const page = await memoryClient.page('agent-1', { cursor: 'opaque', limit: 25 })
const off = memoryClient.onUpdated(vi.fn())
expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'memory.list', { agentId: 'agent-1' })
@@ -1505,6 +1525,12 @@ describe('renderer api clients', () => {
}
)
expect(archiveCandidatePreview.lifecycles[0].memoryId).toBe('mem-1')
+ expect(bridge.invoke).toHaveBeenNthCalledWith(21, 'memory.page', {
+ agentId: 'agent-1',
+ cursor: 'opaque',
+ limit: 25
+ })
+ expect(page.items[0].id).toBe('mem-page')
expect(bridge.on).toHaveBeenCalledWith('memory.updated', expect.any(Function))
expect(typeof off).toBe('function')
})
diff --git a/test/renderer/components/MemoryListView.test.ts b/test/renderer/components/MemoryListView.test.ts
index 26d1859f6..417a7f0ce 100644
--- a/test/renderer/components/MemoryListView.test.ts
+++ b/test/renderer/components/MemoryListView.test.ts
@@ -101,11 +101,15 @@ async function setup(
searchRows?: MemorySearchResult[]
agentId?: string
refreshToken?: number
+ nextCursor?: string | null
} = {}
) {
vi.resetModules()
const memoryClient = {
- list: vi.fn().mockResolvedValue(options.rows ?? [memory()]),
+ page: vi.fn().mockResolvedValue({
+ items: options.rows ?? [memory()],
+ nextCursor: options.nextCursor ?? null
+ }),
search: vi.fn().mockResolvedValue(
options.searchRows ?? [
{
@@ -159,16 +163,18 @@ describe('MemoryListView', () => {
const { wrapper, memoryClient } = await setup()
await wrapper.find('input[type="search"]').setValue('redis')
- vi.advanceTimersByTime(200)
+ await vi.advanceTimersByTimeAsync(200)
await flushPromises()
- expect(memoryClient.search).toHaveBeenLastCalledWith('deepchat', 'redis')
+ expect(wrapper.text()).toContain('user likes redis')
+ expect(memoryClient.search).toHaveBeenCalledWith('deepchat', 'redis')
await wrapper.setProps({ refreshToken: 1 })
- vi.advanceTimersByTime(0)
+ await flushPromises()
+ await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect((wrapper.find('input[type="search"]').element as HTMLInputElement).value).toBe('redis')
- expect(memoryClient.search).toHaveBeenLastCalledWith('deepchat', 'redis')
+ expect(wrapper.text()).toContain('user likes redis')
await wrapper.find('[role="button"]').trigger('click')
await flushPromises()
@@ -182,14 +188,63 @@ describe('MemoryListView', () => {
expect(wrapper.find('[data-testid="inline-panel"]').exists()).toBe(false)
})
- it('keeps the current list mounted during a background refresh instead of showing the loading placeholder', async () => {
+ it('shows server search errors without replacing the loaded management page', async () => {
+ vi.useFakeTimers()
+ const { wrapper, memoryClient } = await setup()
+ memoryClient.search.mockRejectedValueOnce(new Error('search unavailable'))
+
+ await wrapper.find('input[type="search"]').setValue('redis')
+ await vi.advanceTimersByTimeAsync(200)
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('search unavailable')
+ expect(wrapper.text()).not.toContain('user likes redis')
+
+ await wrapper.find('input[type="search"]').setValue('')
+ await flushPromises()
+ expect(wrapper.text()).toContain('user likes redis')
+ })
+
+ it('drops stale search responses after a newer query completes', async () => {
+ vi.useFakeTimers()
+ const { wrapper, memoryClient } = await setup()
+ const staleSearch = deferred()
+ memoryClient.search.mockReturnValueOnce(staleSearch.promise)
+ memoryClient.search.mockResolvedValueOnce([
+ {
+ ...memory({ id: 'new-result', content: 'postgres result' }),
+ score: 1,
+ sources: { fts: true }
+ }
+ ])
+
+ await wrapper.find('input[type="search"]').setValue('redis')
+ await vi.advanceTimersByTimeAsync(200)
+ await wrapper.find('input[type="search"]').setValue('postgres')
+ await vi.advanceTimersByTimeAsync(200)
+ await flushPromises()
+
+ staleSearch.resolve([
+ {
+ ...memory({ id: 'stale-result', content: 'stale redis result' }),
+ score: 1,
+ sources: { fts: true }
+ }
+ ])
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('postgres result')
+ expect(wrapper.text()).not.toContain('stale redis result')
+ })
+
+ it('keeps loaded pages visible while a refresh atomically replaces them', async () => {
const { wrapper, memoryClient } = await setup({ rows: [memory()] })
expect(wrapper.text()).toContain('user likes redis')
- let resolveNext!: (rows: MemoryItem[]) => void
- memoryClient.list.mockImplementationOnce(
+ let resolveNext!: (page: { items: MemoryItem[]; nextCursor: string | null }) => void
+ memoryClient.page.mockImplementationOnce(
() =>
- new Promise((resolve) => {
+ new Promise<{ items: MemoryItem[]; nextCursor: string | null }>((resolve) => {
resolveNext = resolve
})
)
@@ -197,12 +252,10 @@ describe('MemoryListView', () => {
await wrapper.setProps({ refreshToken: 1 })
await flushPromises()
- // While the background refresh is in flight, the loading placeholder
- // must not replace the already-rendered list.
expect(wrapper.text()).not.toContain('common.loading')
expect(wrapper.text()).toContain('user likes redis')
- resolveNext([memory({ content: 'updated fact' })])
+ resolveNext({ items: [memory({ content: 'updated fact' })], nextCursor: null })
await flushPromises()
expect(wrapper.text()).toContain('updated fact')
@@ -210,17 +263,20 @@ describe('MemoryListView', () => {
it('ignores stale list failures after switching agents', async () => {
const { wrapper, memoryClient, toast } = await setup({ rows: [memory()] })
- const staleLoad = deferred()
- memoryClient.list.mockReturnValueOnce(staleLoad.promise)
+ const staleLoad = deferred<{ items: MemoryItem[]; nextCursor: string | null }>()
+ memoryClient.page.mockReturnValueOnce(staleLoad.promise)
await wrapper.setProps({ refreshToken: 1 })
await flushPromises()
- expect(memoryClient.list).toHaveBeenLastCalledWith('deepchat')
+ expect(memoryClient.page).toHaveBeenLastCalledWith('deepchat', { cursor: undefined })
- memoryClient.list.mockResolvedValueOnce([memory({ id: 'other-memory', content: 'other fact' })])
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'other-memory', content: 'other fact' })],
+ nextCursor: null
+ })
await wrapper.setProps({ agentId: 'other' })
await flushPromises()
- expect(memoryClient.list).toHaveBeenLastCalledWith('other')
+ expect(memoryClient.page).toHaveBeenLastCalledWith('other', { cursor: undefined })
staleLoad.reject(new Error('stale failure'))
await flushPromises()
@@ -229,6 +285,121 @@ describe('MemoryListView', () => {
expect(wrapper.text()).toContain('other fact')
})
+ it('appends and deduplicates keyset pages with the opaque next cursor', async () => {
+ const { wrapper, memoryClient } = await setup({
+ rows: [memory({ id: 'm1', content: 'first page' })],
+ nextCursor: 'cursor-1'
+ })
+ memoryClient.page.mockResolvedValueOnce({
+ items: [
+ memory({ id: 'm1', content: 'duplicate should be ignored' }),
+ memory({ id: 'm2', content: 'second page' })
+ ],
+ nextCursor: null
+ })
+
+ await wrapper.find('[data-testid="memory-load-more"]').trigger('click')
+ await flushPromises()
+
+ expect(memoryClient.page).toHaveBeenLastCalledWith('deepchat', { cursor: 'cursor-1' })
+ expect(wrapper.text()).toContain('first page')
+ expect(wrapper.text()).toContain('second page')
+ expect(wrapper.text()).not.toContain('duplicate should be ignored')
+ expect(wrapper.findAll('[role="button"]')).toHaveLength(2)
+ expect(wrapper.find('[data-testid="memory-load-more"]').exists()).toBe(false)
+ })
+
+ it('hides load more while active-only server search is active', async () => {
+ const { wrapper } = await setup({
+ rows: [memory()],
+ nextCursor: 'cursor-1'
+ })
+
+ await wrapper.find('input[type="search"]').setValue('postgres')
+ await flushPromises()
+ expect(wrapper.text()).not.toContain('user likes redis')
+ expect(wrapper.find('[data-testid="memory-load-more"]').exists()).toBe(false)
+ })
+
+ it('keeps load more available while searching loaded archived pages', async () => {
+ const { wrapper, memoryClient } = await setup({
+ rows: [memory({ status: 'archived', content: 'archived redis fact' })],
+ nextCursor: 'cursor-1'
+ })
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'm2', status: 'archived', content: 'second archived redis fact' })],
+ nextCursor: null
+ })
+
+ await wrapper.find('input[type="checkbox"]').setChecked(true)
+ await wrapper.find('input[type="search"]').setValue('redis')
+ await flushPromises()
+ expect(wrapper.find('[data-testid="memory-load-more"]').exists()).toBe(true)
+
+ await wrapper.find('[data-testid="memory-load-more"]').trigger('click')
+ await flushPromises()
+
+ expect(memoryClient.page).toHaveBeenLastCalledWith('deepchat', { cursor: 'cursor-1' })
+ expect(wrapper.text()).toContain('second archived redis fact')
+ expect(wrapper.find('[data-testid="memory-load-more"]').exists()).toBe(false)
+ })
+
+ it('drops a stale load-more response after refresh resets the page generation', async () => {
+ const { wrapper, memoryClient } = await setup({
+ rows: [memory({ id: 'm1', content: 'first page' })],
+ nextCursor: 'cursor-1'
+ })
+ const stalePage = deferred<{ items: MemoryItem[]; nextCursor: string | null }>()
+ memoryClient.page.mockReturnValueOnce(stalePage.promise)
+
+ await wrapper.find('[data-testid="memory-load-more"]').trigger('click')
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'fresh', content: 'fresh first page' })],
+ nextCursor: null
+ })
+ await wrapper.setProps({ refreshToken: 1 })
+ await flushPromises()
+
+ stalePage.resolve({
+ items: [memory({ id: 'stale', content: 'stale appended page' })],
+ nextCursor: null
+ })
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('fresh first page')
+ expect(wrapper.text()).not.toContain('stale appended page')
+ })
+
+ it('keeps a dirty page-two editor open when first-page refresh omits its row', async () => {
+ const { wrapper, memoryClient } = await setup({
+ rows: [memory({ id: 'm1', content: 'first page' })],
+ nextCursor: 'cursor-1'
+ })
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'm2', content: 'page two draft source' })],
+ nextCursor: null
+ })
+ await wrapper.find('[data-testid="memory-load-more"]').trigger('click')
+ await flushPromises()
+
+ await wrapper.findAll('[role="button"]')[1].trigger('click')
+ const panel = wrapper.findComponent({ name: 'MemoryInlinePanel' })
+ panel.vm.$emit('dirty', true)
+ await flushPromises()
+
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'm1', content: 'refreshed first page' })],
+ nextCursor: 'cursor-2'
+ })
+ await wrapper.setProps({ refreshToken: 1 })
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="inline-panel"]').attributes('data-memory-id')).toBe('m2')
+ expect(wrapper.findComponent({ name: 'MemoryInlinePanel' }).props('memory').content).toBe(
+ 'page two draft source'
+ )
+ })
+
it('opens rows as read-only details and row edit as an edit panel', async () => {
const { wrapper } = await setup()
@@ -371,7 +542,10 @@ describe('MemoryListView', () => {
expect(memoryClient.archive).toHaveBeenCalledWith('deepchat', 'm1')
expect(wrapper.text()).not.toContain('user likes redis')
- memoryClient.list.mockResolvedValueOnce([memory({ id: 'm2', content: 'visible fact' })])
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'm2', content: 'visible fact' })],
+ nextCursor: null
+ })
await wrapper.setProps({ refreshToken: 1 })
await flushPromises()
memoryClient.archive.mockResolvedValueOnce(false)
@@ -393,9 +567,9 @@ describe('MemoryListView', () => {
})
await wrapper.find('input[type="search"]').setValue('redis')
- vi.advanceTimersByTime(200)
+ await vi.advanceTimersByTimeAsync(200)
await flushPromises()
- expect(memoryClient.search).toHaveBeenLastCalledWith('deepchat', 'redis')
+ expect(memoryClient.search).toHaveBeenCalledWith('deepchat', 'redis')
expect(wrapper.text()).toContain('redis search fact')
await wrapper.find('[data-testid="memory-row-archive"]').trigger('click')
@@ -417,7 +591,7 @@ describe('MemoryListView', () => {
expect(wrapper.find('[data-testid="memory-row-archive"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="memory-row-restore"]').exists()).toBe(false)
- memoryClient.list.mockResolvedValueOnce([archived])
+ memoryClient.page.mockResolvedValueOnce({ items: [archived], nextCursor: null })
await wrapper.setProps({ refreshToken: 1 })
await flushPromises()
memoryClient.restore.mockResolvedValueOnce(false)
@@ -505,12 +679,15 @@ describe('MemoryListView', () => {
await flushPromises()
expect(wrapper.find('[data-testid="inline-panel"]').attributes('data-memory-id')).toBe('m1')
- memoryClient.list.mockResolvedValueOnce([memory({ id: 'm1', content: 'updated' })])
+ memoryClient.page.mockResolvedValueOnce({
+ items: [memory({ id: 'm1', content: 'updated' })],
+ nextCursor: null
+ })
await wrapper.setProps({ refreshToken: 1 })
await flushPromises()
expect(wrapper.find('[data-testid="inline-panel"]').attributes('data-memory-id')).toBe('m1')
- memoryClient.list.mockResolvedValueOnce([])
+ memoryClient.page.mockResolvedValueOnce({ items: [], nextCursor: null })
await wrapper.setProps({ refreshToken: 2 })
await flushPromises()
expect(wrapper.find('[data-testid="inline-panel"]').exists()).toBe(false)
@@ -539,17 +716,14 @@ describe('MemoryListView', () => {
status: 'archived',
createdAt: 1700000001000
})
- const { wrapper, memoryClient } = await setup({
- rows: [memory(), archived],
- searchRows: []
- })
+ const { wrapper, memoryClient } = await setup({ rows: [memory(), archived] })
await wrapper.find('input[type="checkbox"]').setChecked(true)
await wrapper.find('input[type="search"]').setValue('archived')
- vi.advanceTimersByTime(200)
+ await vi.advanceTimersByTimeAsync(200)
await flushPromises()
- expect(memoryClient.search).toHaveBeenLastCalledWith('deepchat', 'archived')
+ expect(memoryClient.search).toHaveBeenCalledWith('deepchat', 'archived')
expect(wrapper.text()).toContain('settings.memory.redesign.archivedMatches')
expect(wrapper.text()).toContain('archived redis fact')
})
diff --git a/test/renderer/components/MemorySettings.test.ts b/test/renderer/components/MemorySettings.test.ts
index 7c5162391..c6df43784 100644
--- a/test/renderer/components/MemorySettings.test.ts
+++ b/test/renderer/components/MemorySettings.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import type { Agent } from '../../../src/shared/types/agent-interface'
@@ -60,6 +60,10 @@ const baseStatus: MemoryStatusDto = {
personaVersionCount: 0
}
+afterEach(() => {
+ vi.useRealTimers()
+})
+
async function setup(
agents: Agent[],
options: {
@@ -329,15 +333,25 @@ describe('MemorySettings redesign shell', () => {
})
it('uses memory.updated as the single refresh path instead of child changed events', async () => {
- const { wrapper, emitUpdated } = await setup([deepchat])
+ vi.useFakeTimers()
+ const { wrapper, emitUpdated, memoryClient } = await setup([deepchat])
const before = Number(listView(wrapper).props('refreshToken'))
+ const statusCallsBefore = memoryClient.getStatus.mock.calls.length
listView(wrapper).vm.$emit('changed')
await flushPromises()
expect(listView(wrapper).props('refreshToken')).toBe(before)
emitUpdated({ agentId: 'deepchat' })
+ emitUpdated({ agentId: 'deepchat' })
+ emitUpdated({ agentId: 'deepchat' })
+ await vi.advanceTimersByTimeAsync(99)
+ await flushPromises()
+ expect(Number(listView(wrapper).props('refreshToken'))).toBe(before)
+
+ await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(Number(listView(wrapper).props('refreshToken'))).toBeGreaterThan(before)
+ expect(memoryClient.getStatus).toHaveBeenCalledTimes(statusCallsBefore + 1)
})
})
diff --git a/vitest.config.memory-perf.ts b/vitest.config.memory-perf.ts
new file mode 100644
index 000000000..0a45d61f0
--- /dev/null
+++ b/vitest.config.memory-perf.ts
@@ -0,0 +1,28 @@
+import { resolve } from 'path'
+import { defineConfig } from 'vitest/config'
+
+const MEMORY_PERF_TIMEOUT_MS = 120_000
+
+export default defineConfig({
+ resolve: {
+ alias: [
+ { find: '@/', replacement: resolve('src/main/') + '/' },
+ { find: '@shared', replacement: resolve('src/shared') },
+ { find: 'electron', replacement: resolve('test/mocks/electron.ts') },
+ {
+ find: '@electron-toolkit/utils',
+ replacement: resolve('test/mocks/electron-toolkit-utils.ts')
+ }
+ ]
+ },
+ test: {
+ environment: 'node',
+ include: ['test/main/performance/memory/**/*.perf.ts'],
+ setupFiles: ['./test/setup.ts'],
+ globals: true,
+ testTimeout: MEMORY_PERF_TIMEOUT_MS,
+ hookTimeout: MEMORY_PERF_TIMEOUT_MS,
+ maxWorkers: 1,
+ fileParallelism: false
+ }
+})