Skip to content

feat: agentmemory v0.3.0 — semantic search, memory evolution, profiles#4

Merged
rohitg00 merged 3 commits intomainfrom
feat/v0.3.0-competitive-upgrade
Feb 27, 2026
Merged

feat: agentmemory v0.3.0 — semantic search, memory evolution, profiles#4
rohitg00 merged 3 commits intomainfrom
feat/v0.3.0-competitive-upgrade

Conversation

@rohitg00
Copy link
Copy Markdown
Owner

@rohitg00 rohitg00 commented Feb 26, 2026

Summary

  • Hybrid BM25 + vector semantic search with Reciprocal Rank Fusion (6 embedding providers: Gemini, OpenAI, Voyage AI, Cohere, OpenRouter, local @xenova/transformers)
  • Memory versioning & relationships — supersedes, extends, derives, contradicts, related; BFS graph traversal
  • Persistent search indices — debounced KV writes for both BM25 and vector indices; survives restarts
  • Progressive disclosure search — compact results first (id, title, score), expand specific IDs for full details
  • Project profiles — aggregated top concepts, files, conventions, common errors with 1-hour cache
  • Chronological timeline — navigate observations around anchor points (ISO dates or keywords)
  • Auto-forget — TTL expiry via forgetAfter field + Jaccard contradiction detection (threshold 0.9)
  • Provider fallback chain — ordered LLM provider list with automatic failover
  • Full JSON export/import — round-trip with merge/replace/skip strategies
  • 10 MCP tools, 28 REST endpoints, 21 functions (up from 5/20/13 in v0.2.0)

Changes

Category Files Details
New source 17 6 embedding providers, vector index, hybrid search, index persistence, fallback chain, relations, timeline, smart-search, profile, auto-forget, export-import
New tests 11 62 new tests (144 total passing)
Modified 14 types, config, schema, search-index, providers, compress, remember, consolidate, context, evict, index, mcp/server, triggers/api, telemetry, eval/schemas
Build 39.88 KB 0 type errors

v0.2.0 → v0.3.0 comparison

Capability v0.2.0 v0.3.0
Search BM25 keyword only Hybrid BM25 + vector (RRF)
Embeddings None 6 providers (auto-detect from env)
Memory model Flat KV Versioned with relationships
Index persistence In-memory (lost on restart) KV-persisted with debounced writes
Search results Full observations Progressive disclosure
Profiles None Project profiles
Timeline None Chronological navigation
Forgetting Manual eviction only Auto-forget (TTL + contradiction)
Provider resilience Circuit breaker Circuit breaker + fallback chain
Data portability SQLite import Full JSON export/import
MCP tools 5 10
API endpoints 20 28
Functions 13 21

Test plan

  • npm run build — 0 type errors, 39.88 KB bundle
  • npm test — 144 tests pass, 14 integration skipped (needs running server)
  • Start with GEMINI_API_KEY → logs "Embedding provider: gemini (768 dims)"
  • Start without embedding keys → logs "Embedding provider: none (BM25-only mode)"
  • Restart → logs "Loaded persisted BM25 index (N docs)"
  • POST /agentmemory/smart-search returns compact results
  • POST /agentmemory/timeline returns chronological observations
  • GET /agentmemory/profile?project=... returns aggregated profile
  • GET /agentmemory/exportPOST /agentmemory/import round-trip
  • POST /agentmemory/auto-forget?dryRun=true shows candidates

Summary by CodeRabbit

  • New Features

    • Hybrid BM25+vector search with persistent index, multiple embedding providers (OpenAI, Cohere, Gemini, Voyage, OpenRouter, local), and fallback chains.
    • Memory versioning, relations, evolve, automatic cleanup (auto-forget/evict), smart search (compact/expand), timeline browsing, project profiling, and export/import (merge/replace/skip).
    • New HTTP and MCP endpoints to access these capabilities.
  • Chores

    • Bumped release to v0.3.0; added telemetry metrics and updated dependencies.

Add 10 major capabilities for production-grade agentic memory:

- Hybrid BM25 + vector semantic search with Reciprocal Rank Fusion
- 6 embedding providers (Gemini, OpenAI, Voyage, Cohere, OpenRouter, local)
- Memory versioning with relationships (supersedes/extends/derives/contradicts)
- Persistent BM25 + vector indices with debounced KV writes
- Progressive disclosure search (compact results → expandable full details)
- Project profiles (top concepts, files, conventions, common errors)
- Chronological timeline navigation around anchor points
- Auto-forget with TTL expiry and contradiction detection
- Ordered provider fallback chain with automatic failover
- Full JSON export/import round-trip (merge/replace/skip strategies)

17 new source files, 11 new test files, 14 modified files.
144 tests pass, build 39.88 KB. 10 MCP tools, 28 REST endpoints, 21 functions.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c719b83 and 31cd297.

📒 Files selected for processing (5)
  • src/config.ts
  • src/functions/remember.ts
  • src/index.ts
  • src/state/hybrid-search.ts
  • src/triggers/api.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/config.ts
  • src/state/hybrid-search.ts

📝 Walkthrough

Walkthrough

Release v0.3.0: adds hybrid BM25+vector search with embedding providers and persistence, vector index and serialization, provider fallbacks and local xenova support, memory versioning/relations, six new SDK memory functions (search/timeline/profile/relations/export-import/auto-forget), expanded types/schemas, and extensive tests.

Changes

Cohort / File(s) Summary
Version & Dependencies
package.json
Bump package to 0.3.0; add runtime dependency zod and optionalDependency @xenova/transformers.
Configuration & Env
src/config.ts
Centralize env merging (getMergedEnv), add loadEmbeddingConfig, detectEmbeddingProvider, and loadFallbackConfig.
Embedding Providers
src/providers/embedding/...
src/providers/embedding/index.ts
Add Gemini, OpenAI, Voyage, Cohere, OpenRouter, Local providers and factory createEmbeddingProvider (Local dynamically imports @xenova/transformers).
Provider Fallback
src/providers/fallback-chain.ts, src/providers/index.ts
Introduce FallbackChainProvider and createFallbackProvider to compose primary + fallback MemoryProviders.
Vector & Hybrid Search
src/state/vector-index.ts, src/state/hybrid-search.ts, src/state/index-persistence.ts, src/state/search-index.ts
Add VectorIndex (serialize/deserialize), HybridSearch (BM25 + vector fusion via RRF), IndexPersistence (debounced KV save/load), and SearchIndex serialization.
KV Schema & Utilities
src/state/schema.ts
Add KV keys (embeddings, bm25Index, relations, profiles) and jaccardSimilarity utility.
Types & Schemas
src/types.ts, src/eval/schemas.ts, src/xenova.d.ts
Add EmbeddingProvider, MemoryRelation, HybridSearchResult, ProjectProfile, ExportData, EmbeddingConfig, FallbackConfig, new eval input schemas, and @xenova/transformers ambient declaration.
Memory Model & Ops
src/functions/remember.ts, src/functions/consolidate.ts, src/functions/evict.ts, src/functions/context.ts
Extend Memory with version/parentId/supersedes/relatedIds/isLatest/forgetAfter; add supersession/versioning logic, non-latest eviction, and project profile injection into context.
New SDK Functions
src/functions/smart-search.ts, src/functions/timeline.ts, src/functions/profile.ts, src/functions/relations.ts, src/functions/export-import.ts, src/functions/auto-forget.ts
Register mem::smart-search, mem::timeline, mem::profile, mem::relate/mem::evolve/mem::get-related, mem::export/mem::import (merge/replace/skip), and mem::auto-forget (TTL, contradictions, low-value eviction).
API & MCP Integration
src/triggers/api.ts, src/mcp/server.ts, src/index.ts
Add REST/MCP endpoints for smart-search, timeline, profile, export/import, relations, evolve, auto-forget; wire embedding, VectorIndex, IndexPersistence, HybridSearch, and new register*Function exports into startup/shutdown.
Telemetry & Health
src/telemetry/setup.ts, src/health/monitor.ts
Bump OTEL serviceVersion to 0.3.0 and add embedding/vector/auto-forget/profile metrics; minor health handler and CPU calc formatting changes.
Tests
test/* (multiple files)
Add comprehensive tests for embedding providers, hybrid search, vector index, index persistence, fallback chain, profile, relations, export-import, smart-search, timeline, and auto-forget.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant HybridSearch
    participant BM25 as BM25Index
    participant Embedding as EmbeddingProvider
    participant Vector as VectorIndex
    participant KV as StateKV

    Client->>HybridSearch: search(query, limit)
    HybridSearch->>BM25: bm25.search(query, limit*2)
    alt embedding & vector available
        HybridSearch->>Embedding: embed(query)
        Embedding-->>HybridSearch: queryEmbedding
        HybridSearch->>Vector: vector.search(queryEmbedding)
        Vector-->>HybridSearch: vectorResults
        HybridSearch->>HybridSearch: combine BM25 + vector ranks (RRF)
    else fallback to BM25
        HybridSearch-->>HybridSearch: use BM25-only results (vectorScore=0)
    end
    HybridSearch->>KV: fetch observations for top results
    KV-->>HybridSearch: observations
    HybridSearch-->>Client: HybridSearchResult[] (combined, enriched)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A quick hop through vectors and lore,

Memories sorted, relations galore,
Profiles cached and timelines aligned,
Fallbacks stitched when providers decline,
v0.3.0 — a rabbit's delight in store! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main feature additions: semantic search (hybrid BM25 + vector), memory evolution (versioning/relationships), and project profiles.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/v0.3.0-competitive-upgrade

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (8)
src/health/monitor.ts-16-18 (1)

16-18: ⚠️ Potential issue | 🟡 Minor

Unsafe cast from unknown to string may cause downstream issues.

If the event payload is not a string (e.g., undefined, null, or an object), casting it directly to string will silently propagate an unexpected value into connectionState. Consider adding validation:

🛡️ Proposed fix with runtime check
     sdk.on("connection_state", (state?: unknown) => {
-      connectionState = state as string;
+      connectionState = typeof state === "string" ? state : "unknown";
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/health/monitor.ts` around lines 16 - 18, The handler for
sdk.on("connection_state") is unsafely casting an unknown to string; change it
to validate the payload before assignment by checking typeof state === "string"
(or explicitly handling null/undefined) and only then set connectionState =
state, otherwise log or set a safe default/keep previous value; update the
anonymous listener attached to sdk.on("connection_state", ...) and any code that
relies on connectionState to expect that validation instead of a blind cast.
src/state/vector-index.ts-5-7 (1)

5-7: ⚠️ Potential issue | 🟡 Minor

Potential byte alignment issue in base64ToFloat32.

Buffer.from(b64, "base64") may return a Buffer with byteOffset > 0 (since Buffers can be slices of a shared ArrayBuffer pool). Using .buffer directly could include unintended bytes. Use explicit offset/length:

🐛 Proposed fix
 function base64ToFloat32(b64: string): Float32Array {
-  return new Float32Array(Buffer.from(b64, "base64").buffer);
+  const buf = Buffer.from(b64, "base64");
+  return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/vector-index.ts` around lines 5 - 7, The base64ToFloat32 function
currently uses new Float32Array(Buffer.from(b64, "base64").buffer) which can
produce wrong results when the Buffer is a slice (non-zero byteOffset); update
base64ToFloat32 to create a Uint8Array from the Buffer (or capture
buffer.byteOffset and byteLength) and then build the Float32Array using the
underlying ArrayBuffer with the correct byteOffset and byteLength (divide length
by 4 for the Float32Array length) so the Float32Array views the exact bytes of
the decoded base64.
src/functions/evict.ts-132-149 (1)

132-149: ⚠️ Potential issue | 🟡 Minor

A memory can be double-counted and double-deleted in one pass.

If forgetAfter is expired and isLatest === false, the same record increments both stats and triggers two deletes (Line [132]-Line [149]).

💡 Proposed fix
       for (const mem of memories) {
         if (mem.forgetAfter) {
-          const expiry = new Date(mem.forgetAfter).getTime();
-          if (now > expiry) {
+          const expiry = Date.parse(mem.forgetAfter);
+          if (Number.isFinite(expiry) && now > expiry) {
             stats.expiredMemories++;
             if (!dryRun) {
               await kv.delete(KV.memories, mem.id).catch(() => {});
             }
+            continue;
           }
         }

         if (mem.isLatest === false && mem.createdAt) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/evict.ts` around lines 132 - 149, The two independent blocks
(the forgetAfter handling and the isLatest handling) can both operate on the
same memory causing double-counting and two deletes; modify the logic in the
evict function so that once a memory is identified as expired and deleted in the
forgetAfter branch you skip further processing for that memory (e.g., set a
local deleted flag or use continue/else) to prevent re-checking the isLatest
branch; ensure you only increment the appropriate stats (stats.expiredMemories
or stats.nonLatestMemories) and call kv.delete(KV.memories, mem.id) at most once
per memory (replace the second await kv.delete(...) with a no-op when the
deleted flag is set).
test/smart-search.test.ts-152-159 (1)

152-159: ⚠️ Potential issue | 🟡 Minor

Limit test does not verify actual limit behavior.

The mock searchFn (line 112) ignores the _limit parameter and always returns all searchResults. The assertion toBeLessThanOrEqual(2) always passes since searchResults has exactly 2 items. This test doesn't verify that the limit is actually forwarded to the search function.

🐛 Suggested fix to verify limit behavior
-  const searchFn = async (_query: string, _limit: number) => searchResults;
+  const searchFn = vi.fn(async (_query: string, limit: number) => 
+    searchResults.slice(0, limit)
+  );
   registerSmartSearchFunction(sdk as never, kv as never, searchFn);

Then update the limit test:

 it("respects limit parameter in compact mode", async () => {
   const result = (await sdk.trigger("mem::smart-search", {
     query: "auth",
     limit: 1,
   })) as { mode: string; results: CompactSearchResult[] };

-  expect(result.results.length).toBeLessThanOrEqual(2);
+  expect(result.results.length).toBe(1);
+  expect(searchFn).toHaveBeenCalledWith("auth", 1);
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/smart-search.test.ts` around lines 152 - 159, The test "respects limit
parameter in compact mode" is ineffective because the mock searchFn ignores its
_limit param and always returns the full searchResults; update the mock searchFn
(the mocked function referenced as searchFn around line 112) to respect and
apply the _limit argument (e.g., return searchResults.slice(0, _limit) or
similar) so the limit is actually forwarded, then change the assertion in the
test that calls sdk.trigger("mem::smart-search", { query: "auth", limit: 1 }) to
assert the result.results length matches the requested limit (e.g.,
toBeLessThanOrEqual(1) or toEqual(1) depending on desired behavior).
src/providers/embedding/openrouter.ts-6-18 (1)

6-18: ⚠️ Potential issue | 🟡 Minor

Hardcoded dimensions may mismatch configurable model.

The dimensions property is hardcoded to 1536, which is correct for the default model openai/text-embedding-3-small. However, if a user configures OPENROUTER_EMBEDDING_MODEL to a different model (e.g., one producing 768 or 3072 dimensions), the mismatch could cause issues in the vector index.

Consider either:

  1. Documenting that only 1536-dimension models are supported
  2. Allowing dimension configuration via environment variable
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/openrouter.ts` around lines 6 - 18, The dimensions
property in OpenRouterEmbeddingProvider is hardcoded to 1536 which can mismatch
a custom model set via getEnvVar("OPENROUTER_EMBEDDING_MODEL"); update the class
to derive dimensions from a new env var (e.g. OPENROUTER_EMBEDDING_DIMENSIONS)
or a small model-to-dimension mapping: in the constructor
(OpenRouterEmbeddingProvider) read and parse
getEnvVar("OPENROUTER_EMBEDDING_DIMENSIONS") as an integer, validate it (>0) and
assign it to this.dimensions, falling back to 1536 if not provided, and
optionally warn or throw if the provided dimensions conflict with known model
sizes for the model from getEnvVar("OPENROUTER_EMBEDDING_MODEL").
src/functions/profile.ts-92-102 (1)

92-102: ⚠️ Potential issue | 🟡 Minor

totalObservations only reflects observations from the most recent 20 sessions.

sessionCount includes all project sessions, but totalObservations is calculated only from the 20 sessions processed in the loop. This inconsistency could be misleading—users might expect the total count to cover all sessions.

Consider either:

  1. Documenting this behavior in the profile schema/response
  2. Summing observationCount from all projectSessions for accuracy
📝 Option 2: Calculate total from all sessions
+      const totalObsAllSessions = projectSessions.reduce(
+        (sum, s) => sum + s.observationCount,
+        0,
+      );
+
       const profile: ProjectProfile = {
         project: data.project,
         updatedAt: new Date().toISOString(),
         topConcepts,
         topFiles,
         conventions: extractConventions(topConcepts, topFiles),
         commonErrors: uniqueErrors,
         recentActivity: recentActivity.slice(0, 10),
         sessionCount: projectSessions.length,
-        totalObservations: totalObs,
+        totalObservations: totalObsAllSessions,
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/profile.ts` around lines 92 - 102, totalObservations is
currently computed from only the 20 sessions processed in the loop, causing
inconsistency with sessionCount (which uses all projectSessions); update the
calculation to sum observationCount across all projectSessions before building
the ProjectProfile (use the existing projectSessions array and its
observationCount field) so totalObservations reflects the true total for the
project, then construct the ProjectProfile with that summed value for
totalObservations.
src/state/index-persistence.ts-22-31 (1)

22-31: ⚠️ Potential issue | 🟡 Minor

Stale vector data may persist if the vector index becomes empty.

When this.vector.size === 0, the save() method skips writing the vector data but doesn't delete the existing "vectors" key. On subsequent load(), the stale vector data will be restored.

Consider deleting the vector key when the index is empty:

🐛 Proposed fix
   async save(): Promise<void> {
     if (this.timer) {
       clearTimeout(this.timer);
       this.timer = null;
     }
     await this.kv.set(KV.bm25Index, "data", this.bm25.serialize());
     if (this.vector && this.vector.size > 0) {
       await this.kv.set(KV.bm25Index, "vectors", this.vector.serialize());
+    } else {
+      await this.kv.delete(KV.bm25Index, "vectors").catch(() => {});
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/index-persistence.ts` around lines 22 - 31, The save() method
currently skips writing vector data when this.vector.size === 0, leaving any
previous "vectors" entry in the KV store and causing stale data to be reloaded;
update save() to remove the stored vectors key when the index is empty (use
this.kv.delete or equivalent for KV.bm25Index/"vectors") so that an empty
this.vector results in no persisted vectors, and ensure load() will not restore
stale vectors from KV; reference save(), load(), this.vector, KV.bm25Index, and
the "vectors" key when making the change.
src/providers/embedding/index.ts-23-38 (1)

23-38: ⚠️ Potential issue | 🟡 Minor

Non-null assertions may throw if EMBEDDING_PROVIDER is forced without the corresponding API key.

When EMBEDDING_PROVIDER is explicitly set (e.g., EMBEDDING_PROVIDER=gemini), detectEmbeddingProvider() returns that value without verifying the API key exists. The subsequent getEnvVar("GEMINI_API_KEY")! will then be undefined, and the provider constructor will receive an empty string or throw.

Consider adding validation or letting the provider constructors handle the missing key gracefully (they already throw, but the ! assertion is misleading).

🛡️ Proposed fix
   switch (detected) {
     case "gemini":
-      return new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY")!);
+      return new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY"));
     case "openai":
-      return new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY")!);
+      return new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY"));
     case "voyage":
-      return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!);
+      return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY"));
     case "cohere":
-      return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!);
+      return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY"));
     case "openrouter":
-      return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")!);
+      return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY"));

The provider constructors already handle missing keys by throwing descriptive errors, so the non-null assertion is unnecessary and potentially misleading.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/index.ts` around lines 23 - 38, The switch uses
non-null assertions on getEnvVar(...) which is misleading and can mask undefined
when EMBEDDING_PROVIDER is forced; update the factory to either validate the API
key before calling the provider constructors or remove the trailing "!" and let
the provider constructors (e.g., GeminiEmbeddingProvider,
OpenAIEmbeddingProvider, VoyageEmbeddingProvider, CohereEmbeddingProvider,
OpenRouterEmbeddingProvider) handle missing keys and throw; reference
detectEmbeddingProvider and getEnvVar to locate the logic and ensure you either
check getEnvVar(...) !== undefined and throw a clear error there or pass the raw
value into the constructors without using the non-null assertion.
🧹 Nitpick comments (24)
src/telemetry/setup.ts (1)

85-97: Consider adding a compile-time check for mapping completeness.

The as unknown as double assertion is a pragmatic workaround for Object.fromEntries returning { [k: string]: V }, but it bypasses TypeScript's ability to verify that all interface keys are present in the mapping arrays. If a new field is added to Counters or Histograms but not to the corresponding *_NAMES array, runtime access would return undefined with no compile-time warning.

One option is a type-level exhaustiveness check using a satisfies constraint on the arrays:

♻️ Optional: Add compile-time exhaustiveness check
-const COUNTER_NAMES: Array<[keyof Counters, string]> = [
+const COUNTER_NAMES = [
   ["observationsTotal", "observations.total"],
   ["compressionSuccess", "compression.success"],
   // ... other entries
   ["profileGenerated", "profile.generated"],
-];
+] as const satisfies ReadonlyArray<[keyof Counters, string]>;

This ensures the array covers all keys of Counters at compile time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/telemetry/setup.ts` around lines 85 - 97, Add a compile-time
exhaustiveness check by typing the name arrays so TypeScript verifies they
include every key of the target interfaces: change COUNTER_NAMES and
HISTOGRAM_NAMES declarations to use a "satisfies" constraint against
Record<keyof Counters, string> and Record<keyof Histograms, string> (or an
equivalent mapped type) so missing keys produce a compile error; then you can
build counters and histograms from those arrays (using Object.fromEntries or a
reduce) and cast to Counters/Histograms without the unsafe "as unknown as"
bypass—keep NOOP_COUNTER/NOOP_HISTOGRAM and
meter.createCounter/meter.createHistogram logic the same, only adjust the array
types and final cast to rely on the new compile-time check.
src/providers/embedding/gemini.ts (1)

27-36: Consider adding a timeout to the fetch call.

The fetch request has no timeout, which could cause the embedding operation to hang indefinitely if the Gemini API is unresponsive. This is especially important in batch scenarios where upstream consumers may be waiting.

♻️ Proposed fix with AbortSignal timeout
       const response = await fetch(`${API_BASE}?key=${this.apiKey}`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
+        signal: AbortSignal.timeout(30_000),
         body: JSON.stringify({
           requests: chunk.map((t) => ({
             model: "models/text-embedding-004",
             content: { parts: [{ text: t }] },
           })),
         }),
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/gemini.ts` around lines 27 - 36, The fetch call that
posts embeddings to `${API_BASE}?key=${this.apiKey}` has no timeout and can
hang; wrap the request in an AbortController with a configurable timeout (e.g.,
a constant or class setting), start a timer that calls controller.abort() after
the timeout, pass controller.signal to fetch, and clear the timer on success;
update the code around the fetch invocation (the POST that builds requests from
chunk and model "models/text-embedding-004") to handle aborts/errors gracefully
(treat AbortError as a timeout and surface a clear error).
src/functions/export-import.ts (2)

117-129: Clarify "merge" strategy semantics in documentation or code comments.

The current "merge" strategy overwrites existing items with imported data (same as default kv.set behavior). This might be unexpected—users may interpret "merge" as "add new items only, preserve existing." Consider either:

  1. Renaming to "overwrite" or "upsert" for clarity, or
  2. Adding a code comment explaining the behavior.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/export-import.ts` around lines 117 - 129, The "merge" strategy
in the import loop (inside the for (const session of importData.sessions) block
using the strategy variable, kv.set(KV.sessions, session.id, session)) actually
overwrites existing sessions rather than preserving them; update the code or
docs to make the semantics explicit: either rename the strategy value from
"merge" to "overwrite" or "upsert" throughout the codebase (references:
strategy, importData.sessions, kv.set, KV.sessions, stats.skipped) or add a
concise inline comment above this loop explaining that "merge" will overwrite
existing items (i.e., perform upsert/overwrite) so callers know the behavior.

96-115: Sequential deletes in "replace" strategy may be slow for large datasets.

Each entity is deleted individually with separate async calls. For large memory stores, this could be slow. Consider batching or documenting expected performance characteristics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/export-import.ts` around lines 96 - 115, The "replace" branch
in export-import.ts performs sequential deletes (see strategy === "replace",
kv.list, kv.delete over existing, existingMem, existingSummaries and their
observation loops) which will be slow for large datasets; change deletion to
batched/parallel deletes: collect ids per collection (sessions, observations,
memories, summaries), split into reasonable-size chunks, and issue concurrent
delete operations (e.g., using Promise.all over chunked kv.delete calls or a
kv.bulkDelete API if available) to reduce round-trips and improve throughput,
ensuring you still await completion and handle errors per-chunk.
src/state/schema.ts (1)

26-36: Consider case normalization for Jaccard similarity.

The tokenization doesn't normalize case, so "Error" and "error" would be treated as distinct tokens. This could affect contradiction detection accuracy if memory content varies in capitalization.

♻️ Proposed fix with case normalization
 export function jaccardSimilarity(a: string, b: string): number {
-  const setA = new Set(a.split(/\s+/).filter((t) => t.length > 2));
-  const setB = new Set(b.split(/\s+/).filter((t) => t.length > 2));
+  const setA = new Set(a.toLowerCase().split(/\s+/).filter((t) => t.length > 2));
+  const setB = new Set(b.toLowerCase().split(/\s+/).filter((t) => t.length > 2));
   if (setA.size === 0 && setB.size === 0) return 1;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/schema.ts` around lines 26 - 36, The jaccardSimilarity function
currently treats differently-cased tokens as distinct; update the tokenization
to normalize case before creating sets (e.g., call toLowerCase() on inputs or
lowercase each token when building setA and setB) so "Error" and "error" match,
keeping the existing length filter and return logic in jaccardSimilarity.
package.json (1)

38-42: Consider upgrading zod to the latest major version.

The dependency additions are sensible: zod for schema validation and @xenova/transformers as an optional dependency for local embeddings appropriately avoids forcing the heavy ML dependency on users. However, zod is currently pinned to ^3.23.0 while the latest stable version is 4.3.6 (as of Feb 2026). The @xenova/transformers version ^2.17.0 is appropriate and will allow auto-updates to 2.17.2 (the latest). Consider upgrading zod to version 4.x after testing for any breaking changes in your validation schemas.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 38 - 42, Update the zod dependency in package.json
from "^3.23.0" to a 4.x release (e.g. "^4.3.6"), run the project's test suite
and type checks, and fix any breaking changes in validation schemas or usages
(search for Zod types/usages in the codebase such as ZodSchema, z.object(),
z.string(), parse/parseAsync, safeParse, and any custom refinements) to conform
to Zod v4 APIs; after adjustments, update package-lock or yarn.lock and verify
integration tests and build succeed.
src/types.ts (1)

248-251: Tighten EmbeddingConfig.provider typing.

Line [249] is currently string, so provider typos won’t be caught at compile time and will fail later at runtime. Consider a dedicated string-literal union for supported embedding providers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types.ts` around lines 248 - 251, The provider field on EmbeddingConfig
is too permissive as plain string; define a string-literal union type (e.g.,
type SupportedEmbeddingProvider = 'openai' | 'huggingface' | 'cohere' | ...)
listing the allowed embedding providers, replace provider?: string with
provider?: SupportedEmbeddingProvider on the EmbeddingConfig interface, and
update any usages/assignments to use the new union (or cast/validate inputs) so
typos are caught at compile time; reference the EmbeddingConfig interface and
the new SupportedEmbeddingProvider type when making the change.
src/providers/fallback-chain.ts (1)

6-8: Fail fast for empty fallback chains.

If providers is empty, the object is constructed successfully and only fails on first call. Validate upfront to surface config errors earlier.

💡 Proposed fix
   constructor(private providers: MemoryProvider[]) {
+    if (providers.length === 0) {
+      throw new Error("Fallback chain requires at least one provider");
+    }
     this.name = `fallback(${providers.map((p) => p.name).join(" -> ")})`;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/fallback-chain.ts` around lines 6 - 8, The constructor in the
FallbackChain class currently allows an empty providers array and defers
failure; update the constructor to validate that the injected providers array
(constructor parameter providers / this.providers) is non-empty and throw a
clear, early Error if it's empty; preserve existing behavior of setting
this.name using providers.map(...) when non-empty so callers immediately get a
configuration error instead of failing on first method call.
test/timeline.test.ts (1)

116-147: Add boundary tests for invalid window and malformed date anchors.

Given the new index math paths, add cases for fractional/negative before/after and invalid ISO-like anchors to prevent runtime regressions.

💡 Suggested additional tests
+  it("returns invalid_window for non-finite/fractional window inputs", async () => {
+    const result = (await sdk.trigger("mem::timeline", {
+      anchor: "2026-02-01T12:00:00Z",
+      before: 1.5,
+      after: -1,
+    })) as { entries: TimelineEntry[]; reason?: string };
+
+    expect(result.entries).toEqual([]);
+    expect(result.reason).toBe("invalid_window");
+  });
+
+  it("returns invalid_anchor for malformed ISO anchor", async () => {
+    const result = (await sdk.trigger("mem::timeline", {
+      anchor: "2026-99-99",
+    })) as { entries: TimelineEntry[]; reason?: string };
+
+    expect(result.entries).toEqual([]);
+    expect(result.reason).toBe("invalid_anchor");
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/timeline.test.ts` around lines 116 - 147, Add boundary tests in
test/timeline.test.ts around the sdk.trigger("mem::timeline") calls to cover
invalid window values and malformed anchors: add cases that call sdk.trigger
with fractional (e.g., 0.5) and negative (e.g., -1) before/after values and
assert the call rejects/throws, and add cases that pass malformed ISO-like
anchors (e.g., "2026-02-30T12:00:00", "not-a-date") and assert the call
rejects/throws or returns an empty entries array; target the same test harness
and types (TimelineEntry, sdk.trigger("mem::timeline")) so failures surface if
input validation or index math regressions occur.
test/fallback-chain.test.ts (1)

44-93: Add an explicit empty-chain test case.

Given constructor-level validation is important here, add one test for new FallbackChainProvider([]) behavior.

💡 Suggested additional test
+  it("throws when initialized with an empty provider list", () => {
+    expect(() => new FallbackChainProvider([])).toThrow(
+      "Fallback chain requires at least one provider",
+    );
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/fallback-chain.test.ts` around lines 44 - 93, Add a test that verifies
constructor-level validation by asserting that instantiating
FallbackChainProvider with an empty array throws: create a new unit test that
calls new FallbackChainProvider([]) and expects it to throw (use rejects/toThrow
or toThrow for sync construction) to ensure the constructor enforces non-empty
providers; reference the FallbackChainProvider constructor in the test and
assert an appropriate error is thrown.
src/eval/schemas.ts (1)

83-87: Require at least one smart-search selector (query or expandIds).

Current schema accepts {}. That pushes an invalid request shape into runtime logic instead of rejecting at validation.

💡 Proposed schema refinement
 export const SmartSearchInputSchema = z.object({
   query: z.string().optional(),
   expandIds: z.array(z.string()).optional(),
   limit: z.number().int().positive().optional(),
-});
+}).refine((v) => Boolean(v.query) || (v.expandIds?.length ?? 0) > 0, {
+  message: "Either query or expandIds must be provided",
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/eval/schemas.ts` around lines 83 - 87, SmartSearchInputSchema currently
allows an empty object which bypasses validation; update SmartSearchInputSchema
to require at least one selector by adding a refinement on the object
(SmartSearchInputSchema) that checks that either query is a non-empty string or
expandIds is a non-empty array (treat empty array as invalid), and provide a
clear validation message (e.g., "either query or expandIds must be provided").
Keep existing types for query, expandIds and limit and add the
refine/superRefine to enforce the presence rule.
test/auto-forget.test.ts (1)

119-149: Assert deletion side effect in low-value eviction test.

This test checks result.lowValueObs but not that obs_old was actually removed from mem:obs:ses_1.

💡 Proposed assertion
     expect(result.lowValueObs).toContain("obs_old");
+    const deleted = await kv.get("mem:obs:ses_1", "obs_old");
+    expect(deleted).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/auto-forget.test.ts` around lines 119 - 149, The test "evicts low-value
old observations" currently asserts that result.lowValueObs contains "obs_old"
but doesn't verify the side-effect removal from the store; update the test to
fetch the observation after running sdk.trigger (using the same kv API used to
seed it, e.g., kv.get("mem:obs:ses_1", "obs_old")) and assert the entry is gone
(null/undefined or not found) to ensure the eviction actually deleted the obs.
Reference: the test block named "evicts low-value old observations", the seeded
key "mem:obs:ses_1"/"obs_old", the kv.set used to create it, and the
sdk.trigger("mem::auto-forget", {}) call.
src/providers/embedding/cohere.ts (1)

21-45: Extract shared embedding HTTP helper to avoid provider drift.

This request/parse/error path duplicates logic in src/providers/embedding/voyage.ts (Lines 21-45 there). Centralizing timeout, error normalization, and output validation will reduce future inconsistencies.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/cohere.ts` around lines 21 - 45, The embedBatch
implementation in Cohere (embedBatch, API_URL, this.apiKey) duplicates HTTP
request/response/error parsing logic found in voyage.ts — extract a shared
helper (e.g. requestEmbedding or fetchEmbeddings) that centralizes timeout
handling, request headers/body construction, normalized error creation, JSON
parsing and output validation (ensure embeddings is an array of number[] and
throw a normalized Error if not), and then update Cohere.embedBatch and
Voyage.embedBatch to call that helper and convert number[] to Float32Array;
ensure the helper surface accepts provider-specific payload fields
(model/input_type/texts) and returns number[][] so provider methods only map to
Float32Array.
test/export-import.test.ts (1)

177-205: Strengthen replace-strategy test to cover all cleared stores.

This test only checks old session removal. Please also assert old observations/memories/summaries are gone to fully validate replace semantics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/export-import.test.ts` around lines 177 - 205, Update the "import with
replace strategy clears existing data first" test to also assert that old
observations, memories and summaries are removed after the replace import: after
calling sdk.trigger("mem::import", { exportData, strategy: "replace" }) add
checks using the same kv helper (kv.get/kv.list or appropriate store accessors)
to confirm entries under the stores "mem:observations", "mem:memories" and
"mem:summaries" that correspond to the pre-existing test fixtures are gone
(e.g., expected keys like the existing observation id(s), memory id(s) and
summary id(s) return null/empty), so the test verifies all four stores
(sessions, observations, memories, summaries) were cleared.
test/index-persistence.test.ts (1)

86-101: Minor: Timer advance value exceeds debounce threshold.

Line 96 uses 5000ms while the actual DEBOUNCE_MS in src/state/index-persistence.ts is 3000ms. The test passes but using the exact threshold (or importing the constant) would make the test more precise and maintainable.

♻️ Suggestion
-    vi.advanceTimersByTime(5000);
+    vi.advanceTimersByTime(3000);

Or better, export and import the constant:

import { DEBOUNCE_MS } from "../src/state/index-persistence.js";
// ...
vi.advanceTimersByTime(DEBOUNCE_MS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/index-persistence.test.ts` around lines 86 - 101, Update the test so its
timer advance matches the actual debounce threshold: replace the hard-coded
5000ms with the DEBOUNCE_MS constant from src/state/index-persistence.ts (import
DEBOUNCE_MS into test/index-persistence.test.ts) and use
vi.advanceTimersByTime(DEBOUNCE_MS) before awaiting timers; this ensures the
behavior of IndexPersistence.scheduleSave is tested against the real debounce
value.
test/profile.test.ts (1)

143-151: Cache test could verify data consistency.

The test verifies cached: true but doesn't assert that the returned profile data matches the first call. This would catch bugs where caching returns stale or incorrect data.

♻️ Suggested enhancement
 it("returns cached profile if fresh", async () => {
-  await sdk.trigger("mem::profile", { project: "my-project" });
+  const first = (await sdk.trigger("mem::profile", { project: "my-project" })) as {
+    profile: ProjectProfile;
+    cached: boolean;
+  };

   const result = (await sdk.trigger("mem::profile", {
     project: "my-project",
   })) as { profile: ProjectProfile; cached: boolean };

   expect(result.cached).toBe(true);
+  expect(result.profile.project).toBe(first.profile.project);
+  expect(result.profile.sessionCount).toBe(first.profile.sessionCount);
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/profile.test.ts` around lines 143 - 151, Update the "returns cached
profile if fresh" test to also assert that the profile payload returned from the
second sdk.trigger("mem::profile", { project: "my-project" }) equals the profile
returned by the first call: capture the first call's return (e.g., first = await
sdk.trigger("mem::profile", { project: "my-project" }) as { profile:
ProjectProfile; cached: boolean }) then call again to get result and assert both
result.profile deep-equals first.profile in addition to
expect(result.cached).toBe(true); this ensures the cached data (ProjectProfile)
is consistent with the original.
test/embedding-provider.test.ts (1)

6-48: Test coverage is incomplete for all embedding providers.

The factory supports 6 providers (Gemini, OpenAI, Voyage, Cohere, OpenRouter, local) but only Gemini and OpenAI are tested. Consider adding tests for the remaining providers to ensure the factory correctly instantiates each one.

♻️ Suggested additional test cases
it("returns VoyageEmbeddingProvider when VOYAGE_API_KEY is set", () => {
  process.env["VOYAGE_API_KEY"] = "test-key";
  const provider = createEmbeddingProvider();
  expect(provider?.name).toBe("voyage");
});

it("returns CohereEmbeddingProvider when COHERE_API_KEY is set", () => {
  process.env["COHERE_API_KEY"] = "test-key";
  const provider = createEmbeddingProvider();
  expect(provider?.name).toBe("cohere");
});

it("returns OpenRouterEmbeddingProvider when OPENROUTER_API_KEY is set", () => {
  process.env["OPENROUTER_API_KEY"] = "test-key";
  const provider = createEmbeddingProvider();
  expect(provider?.name).toBe("openrouter");
});

it("returns LocalEmbeddingProvider when EMBEDDING_PROVIDER is 'local'", () => {
  process.env["EMBEDDING_PROVIDER"] = "local";
  const provider = createEmbeddingProvider();
  expect(provider?.name).toBe("local");
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/embedding-provider.test.ts` around lines 6 - 48, The test suite for
createEmbeddingProvider is missing coverage for Voyage, Cohere, OpenRouter and
the local provider; add tests mirroring the existing Gemini/OpenAI tests that
set process.env["VOYAGE_API_KEY"], process.env["COHERE_API_KEY"],
process.env["OPENROUTER_API_KEY"], and process.env["EMBEDDING_PROVIDER"]="local"
respectively, call createEmbeddingProvider(), and assert the returned provider
is the expected class/name (VoyageEmbeddingProvider, CohereEmbeddingProvider,
OpenRouterEmbeddingProvider, LocalEmbeddingProvider and/or provider?.name equals
"voyage"/"cohere"/"openrouter"/"local"); place these new it(...) blocks
alongside the existing tests and rely on the same beforeEach/afterEach env
setup.
test/relations.test.ts (1)

12-47: Consider extracting shared test utilities.

The mockKV() and mockSdk() helper functions are duplicated across multiple test files (relations.test.ts, profile.test.ts, smart-search.test.ts, index-persistence.test.ts). This increases maintenance burden when the mock interface needs to change.

♻️ Suggested refactor: Extract to shared test helper

Create a shared test utility file:

// test/helpers/mock-kv.ts
export function mockKV() {
  const store = new Map<string, Map<string, unknown>>();
  return {
    get: async <T>(scope: string, key: string): Promise<T | null> => {
      return (store.get(scope)?.get(key) as T) ?? null;
    },
    set: async <T>(scope: string, key: string, data: T): Promise<T> => {
      if (!store.has(scope)) store.set(scope, new Map());
      store.get(scope)!.set(key, data);
      return data;
    },
    delete: async (scope: string, key: string): Promise<void> => {
      store.get(scope)?.delete(key);
    },
    list: async <T>(scope: string): Promise<T[]> => {
      const entries = store.get(scope);
      return entries ? (Array.from(entries.values()) as T[]) : [];
    },
  };
}

export function mockSdk() {
  const functions = new Map<string, Function>();
  return {
    registerFunction: (opts: { id: string }, handler: Function) => {
      functions.set(opts.id, handler);
    },
    registerTrigger: () => {},
    trigger: async (id: string, data: unknown) => {
      const fn = functions.get(id);
      if (!fn) throw new Error(`No function: ${id}`);
      return fn(data);
    },
    getFunction: (id: string) => functions.get(id),
  };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/relations.test.ts` around lines 12 - 47, Extract the duplicated mock
helpers into a shared test helper module and import them where needed: move the
mockKV and mockSdk functions into a new file (e.g., export mockKV and mockSdk)
and update tests (relations.test.ts, profile.test.ts, smart-search.test.ts,
index-persistence.test.ts) to import and use those exported functions instead of
local copies; ensure the exported helpers preserve the same signatures
(mockKV.get/set/delete/list and
mockSdk.registerFunction/registerTrigger/trigger/getFunction) so existing tests
compile without further changes.
src/state/search-index.ts (1)

137-151: Consider adding validation in deserialize() for robustness.

The deserialize() method assumes the JSON structure is valid. Malformed data (e.g., from corrupted storage) would cause cryptic runtime errors. Consider adding basic validation or try-catch with meaningful error messages.

🛡️ Suggested defensive deserialization
 static deserialize(json: string): SearchIndex {
   const idx = new SearchIndex();
-  const data = JSON.parse(json);
+  let data: {
+    entries?: [string, IndexEntry][];
+    inverted?: [string, string[]][];
+    docTerms?: [string, [string, number][]][];
+    totalDocLength?: number;
+  };
+  try {
+    data = JSON.parse(json);
+  } catch {
+    throw new Error("SearchIndex: invalid JSON in deserialize");
+  }
+  if (!data.entries || !data.inverted || !data.docTerms) {
+    throw new Error("SearchIndex: missing required fields in serialized data");
+  }
   for (const [key, val] of data.entries) {
     idx.entries.set(key, val);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/search-index.ts` around lines 137 - 151, SearchIndex.deserialize
currently assumes JSON has the exact shape and will throw cryptic errors on
malformed input; wrap JSON.parse in try-catch and validate the top-level object
and required properties (entries, inverted, docTerms, totalDocLength) before
using them, checking that entries is an array of [key, val], inverted is an
array of [term, ids], docTerms is an array of [id, counts] and totalDocLength is
a number; on validation failure throw a clear Error (or return a rejected
result) describing which property is invalid so callers can handle corrupted
storage gracefully. Use the existing method name SearchIndex.deserialize and the
internal maps idx.entries, idx.invertedIndex, idx.docTermCounts when rebuilding
after validation.
src/providers/embedding/local.ts (1)

33-51: Potential race condition in lazy initialization.

If multiple concurrent calls to embed() or embedBatch() occur before the extractor is initialized, multiple pipelines could be created simultaneously due to the non-atomic check-then-set pattern.

Consider using a promise-based lock to ensure single initialization:

🔒 Proposed fix
 export class LocalEmbeddingProvider implements EmbeddingProvider {
   readonly name = "local";
   readonly dimensions = 384;
   private extractor: Awaited<ReturnType<Pipeline>> | null = null;
+  private initPromise: Promise<Awaited<ReturnType<Pipeline>>> | null = null;

   // ... embed and embedBatch unchanged ...

   private async getExtractor() {
     if (this.extractor) return this.extractor;
+    if (this.initPromise) return this.initPromise;

+    this.initPromise = this.initExtractor();
+    this.extractor = await this.initPromise;
+    return this.extractor;
+  }
+
+  private async initExtractor() {
     let transformers: { pipeline: Pipeline };
     try {
       // `@ts-ignore` - optional peer dependency
       transformers = await import("@xenova/transformers");
     } catch {
       throw new Error(
         "Install `@xenova/transformers` for local embeddings: npm install `@xenova/transformers`",
       );
     }

-    this.extractor = await transformers.pipeline(
+    return await transformers.pipeline(
       "feature-extraction",
       "Xenova/all-MiniLM-L6-v2",
     );
-    return this.extractor;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/local.ts` around lines 33 - 51, getExtractor
currently checks this.extractor then initializes, which can race when embed() or
embedBatch() call concurrently; make initialization atomic by adding a
Promise-based lock (e.g., this.extractorPromise) that is set immediately when
initialization starts, await that same promise in concurrent callers, and clear
it on failure. Specifically: in getExtractor set this.extractorPromise before
awaiting import("@xenova/transformers") and pipeline("feature-extraction", ...),
assign this.extractor when successful and resolve the promise, and on catch
reject/clear this.extractorPromise so future calls can retry; ensure embed() and
embedBatch() rely on getExtractor() as before so all callers await the single
initialization promise.
src/providers/embedding/openai.ts (1)

21-44: Consider adding a request timeout for resilience.

The fetch call has no timeout configured. In production, network issues could cause requests to hang indefinitely, blocking the embedding pipeline.

⏱️ Proposed fix using AbortSignal
   async embedBatch(texts: string[]): Promise<Float32Array[]> {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30_000);
+
     const response = await fetch(API_URL, {
       method: "POST",
       headers: {
         Authorization: `Bearer ${this.apiKey}`,
         "Content-Type": "application/json",
       },
       body: JSON.stringify({
         model: "text-embedding-3-small",
         input: texts,
       }),
+      signal: controller.signal,
     });
+    clearTimeout(timeoutId);

     if (!response.ok) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/openai.ts` around lines 21 - 44, The embedBatch
function currently calls fetch without a timeout; update embedBatch to create an
AbortController, pass controller.signal to fetch (using API_URL and this.apiKey
as before), and start a timer (e.g., 10s or a configurable value) that calls
controller.abort() on expiry; ensure you clear the timer after fetch completes
or errors so it doesn't leak. Keep existing error handling for non-ok responses,
but surface abort errors clearly (they will be thrown when controller aborts)
and ensure the controller and timer are created/cleared inside embedBatch to
avoid cross-request interference.
test/hybrid-search.test.ts (1)

55-67: Consider adding a test for hybrid search with a mock vector index.

The current tests cover BM25-only scenarios thoroughly, but there's no test for when a vector index is provided. This would exercise the reciprocal rank fusion (RRF) logic and ensure vectorScore contributes to combinedScore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/hybrid-search.test.ts` around lines 55 - 67, Add a new unit test that
constructs a HybridSearch instance with a mocked vector index to exercise
RRF/score fusion: create an observation via makeObs and index it into bm25 and
into a simple mock vector index that returns a non-empty vector result for the
query, set the kv with kv.set as in the existing test, instantiate
HybridSearch(bm25, mockVectorIndex, null, kv as never), call
hybrid.search("auth"), and assert that results include the observation and that
vectorScore > 0 and combinedScore reflects contribution from both vectorScore
and bm25Score (e.g., combinedScore > bm25Score); reference HybridSearch, search,
vectorScore, bm25Score, and the existing makeObs/bm25/kv usage to place the test
alongside the BM25-only test.
src/state/hybrid-search.ts (1)

104-112: Parallelize enrichment KV reads to reduce latency.

These lookups are independent; sequential awaits increase tail latency for larger limits.

♻️ Proposed refactor
-    const enriched: HybridSearchResult[] = [];
-    for (const r of results.slice(0, limit)) {
-      const obs = await this.kv.get<CompressedObservation>(
-        KV.observations(r.sessionId),
-        r.obsId,
-      );
-      if (obs) {
-        enriched.push({ observation: obs, ...r });
-      }
-    }
-    return enriched;
+    const enriched = await Promise.all(
+      results.slice(0, limit).map(async (r) => {
+        const obs = await this.kv.get<CompressedObservation>(
+          KV.observations(r.sessionId),
+          r.obsId,
+        );
+        return obs ? ({ observation: obs, ...r } as HybridSearchResult) : null;
+      }),
+    );
+    return enriched.filter((r): r is HybridSearchResult => r !== null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/hybrid-search.ts` around lines 104 - 112, The loop is doing
sequential awaits for enrichment which increases latency; change the enrichment
in getEnriched observations to run KV reads in parallel by mapping
results.slice(0, limit) to an array of promises that call this.kv.get with
KV.observations(r.sessionId) and r.obsId, then await Promise.all on that array,
filter out missing results, and push/return enriched entries (maintaining the
shape { observation: obs, ...r }); update references around results, limit,
this.kv.get, KV.observations, CompressedObservation, and enriched to use the
resolved promise array instead of sequential awaits.
src/functions/smart-search.ts (1)

32-33: Avoid re-listing sessions for every expanded ID.

You can fetch sessions once per request and reuse them. Current flow performs redundant KV list scans.

♻️ Proposed refactor
       if (data.expandIds && data.expandIds.length > 0) {
+        const sessions = await kv.list<{ id: string }>(KV.sessions);
         const expanded: Array<{
           obsId: string;
           sessionId: string;
           observation: CompressedObservation;
         }> = [];
@@
         for (const obsId of data.expandIds) {
-          const obs = await findObservation(kv, obsId);
+          const obs = await findObservation(kv, sessions, obsId);
@@
 async function findObservation(
   kv: StateKV,
+  sessions: Array<{ id: string }>,
   obsId: string,
 ): Promise<CompressedObservation | null> {
-  const sessions = await kv.list<{ id: string }>(KV.sessions);
   for (const session of sessions) {

Also applies to: 75-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/smart-search.ts` around lines 32 - 33, The code currently calls
KV/session listing repeatedly inside the loop over data.expandIds (around
findObservation(kv, obsId)); instead, call the session-listing function once
(e.g., listSessions or whatever code calls kv.list/kv.listSessions) before the
loop, store the sessions result in a local variable, and then reuse that
variable inside the loop when processing each obsId and in the similar block
around lines 75-80; move session fetching out of both loops and reference the
cached sessions variable instead of re-listing.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e4f1f3 and 8f9f59e.

📒 Files selected for processing (45)
  • package.json
  • src/config.ts
  • src/eval/schemas.ts
  • src/functions/auto-forget.ts
  • src/functions/consolidate.ts
  • src/functions/context.ts
  • src/functions/evict.ts
  • src/functions/export-import.ts
  • src/functions/profile.ts
  • src/functions/relations.ts
  • src/functions/remember.ts
  • src/functions/smart-search.ts
  • src/functions/timeline.ts
  • src/health/monitor.ts
  • src/index.ts
  • src/mcp/server.ts
  • src/providers/embedding/cohere.ts
  • src/providers/embedding/gemini.ts
  • src/providers/embedding/index.ts
  • src/providers/embedding/local.ts
  • src/providers/embedding/openai.ts
  • src/providers/embedding/openrouter.ts
  • src/providers/embedding/voyage.ts
  • src/providers/fallback-chain.ts
  • src/providers/index.ts
  • src/state/hybrid-search.ts
  • src/state/index-persistence.ts
  • src/state/schema.ts
  • src/state/search-index.ts
  • src/state/vector-index.ts
  • src/telemetry/setup.ts
  • src/triggers/api.ts
  • src/types.ts
  • src/xenova.d.ts
  • test/auto-forget.test.ts
  • test/embedding-provider.test.ts
  • test/export-import.test.ts
  • test/fallback-chain.test.ts
  • test/hybrid-search.test.ts
  • test/index-persistence.test.ts
  • test/profile.test.ts
  • test/relations.test.ts
  • test/smart-search.test.ts
  • test/timeline.test.ts
  • test/vector-index.test.ts

Comment thread src/config.ts
Comment on lines +98 to +104
export function loadEmbeddingConfig(): EmbeddingConfig {
const env = getMergedEnv();
return {
provider: env["EMBEDDING_PROVIDER"] || undefined,
bm25Weight: parseFloat(env["BM25_WEIGHT"] || "0.4"),
vectorWeight: parseFloat(env["VECTOR_WEIGHT"] || "0.6"),
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate embedding weights before returning config.

Lines 102-103 can return NaN (or invalid ranges), which can break hybrid score math downstream.

💡 Proposed fix
 export function loadEmbeddingConfig(): EmbeddingConfig {
   const env = getMergedEnv();
+  const bm25Weight = Number.parseFloat(env["BM25_WEIGHT"] || "0.4");
+  const vectorWeight = Number.parseFloat(env["VECTOR_WEIGHT"] || "0.6");
+  const safeBm25 = Number.isFinite(bm25Weight) ? bm25Weight : 0.4;
+  const safeVector = Number.isFinite(vectorWeight) ? vectorWeight : 0.6;
   return {
     provider: env["EMBEDDING_PROVIDER"] || undefined,
-    bm25Weight: parseFloat(env["BM25_WEIGHT"] || "0.4"),
-    vectorWeight: parseFloat(env["VECTOR_WEIGHT"] || "0.6"),
+    bm25Weight: safeBm25,
+    vectorWeight: safeVector,
   };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function loadEmbeddingConfig(): EmbeddingConfig {
const env = getMergedEnv();
return {
provider: env["EMBEDDING_PROVIDER"] || undefined,
bm25Weight: parseFloat(env["BM25_WEIGHT"] || "0.4"),
vectorWeight: parseFloat(env["VECTOR_WEIGHT"] || "0.6"),
};
export function loadEmbeddingConfig(): EmbeddingConfig {
const env = getMergedEnv();
const bm25Weight = Number.parseFloat(env["BM25_WEIGHT"] || "0.4");
const vectorWeight = Number.parseFloat(env["VECTOR_WEIGHT"] || "0.6");
const safeBm25 = Number.isFinite(bm25Weight) ? bm25Weight : 0.4;
const safeVector = Number.isFinite(vectorWeight) ? vectorWeight : 0.6;
return {
provider: env["EMBEDDING_PROVIDER"] || undefined,
bm25Weight: safeBm25,
vectorWeight: safeVector,
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 98 - 104, In loadEmbeddingConfig, validate the
parsed bm25Weight and vectorWeight values before returning: after parsing in
loadEmbeddingConfig, check Number.isFinite for each and if NaN fallback to the
defaults (0.4 for bm25Weight, 0.6 for vectorWeight), clamp each value into the
[0,1] range, and if their sum is not >0 normalize them so they sum to 1 (or
revert to the defaults if normalization is impossible); update the returned
object to use these validated/normalized values (referencing
loadEmbeddingConfig, bm25Weight, and vectorWeight).

Comment thread src/config.ts
Comment thread src/eval/schemas.ts
Comment on lines +113 to +124
export const ExportImportInputSchema = z.object({
exportData: z.object({
version: z.literal("0.3.0"),
exportedAt: z.string(),
sessions: z.array(z.unknown()),
observations: z.record(z.string(), z.array(z.unknown())),
memories: z.array(z.unknown()),
summaries: z.array(z.unknown()),
profiles: z.array(z.unknown()).optional(),
}),
strategy: z.enum(["merge", "replace", "skip"]).optional(),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the actual file to confirm the code state
wc -l src/eval/schemas.ts

Repository: rohitg00/agentmemory

Length of output: 87


🏁 Script executed:

# Read the specific section mentioned in the review
sed -n '100,130p' src/eval/schemas.ts

Repository: rohitg00/agentmemory

Length of output: 828


🏁 Script executed:

# Search for usages of ExportImportInputSchema
rg "ExportImportInputSchema" -A 3 -B 3

Repository: rohitg00/agentmemory

Length of output: 379


🏁 Script executed:

# Look for related import/export logic and how data is used after validation
rg "importData|exportData|export.*import" -t ts -A 5

Repository: rohitg00/agentmemory

Length of output: 8836


🏁 Script executed:

# Let's find the ExportData type definition
rg "type ExportData|interface ExportData" -A 10

Repository: rohitg00/agentmemory

Length of output: 480


🏁 Script executed:

# Read the full export-import.ts file to understand the import logic
cat -n src/functions/export-import.ts

Repository: rohitg00/agentmemory

Length of output: 6618


🏁 Script executed:

# Also check for Session, CompressedObservation, Memory, SessionSummary type definitions
rg "type Session|interface Session|type Memory|interface Memory|type CompressedObservation|interface CompressedObservation|type SessionSummary|interface SessionSummary" -A 5

Repository: rohitg00/agentmemory

Length of output: 1481


Replace z.unknown() with schemas matching the TypeScript interface definitions.

The schema validates only version and exportedAt; sessions, observations, memories, and summaries bypass structural validation via z.unknown(). This allows malformed data to be written directly to KV state without catching missing or invalid required fields (e.g., missing id on Session, invalid type on Memory).

Define Zod schemas for Session, CompressedObservation, Memory, and SessionSummary that match the TypeScript interfaces, or import existing Zod validators if available elsewhere in the codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/eval/schemas.ts` around lines 113 - 124, The ExportImportInputSchema
currently uses z.unknown() for sessions, observations, memories, and
summaries—replace those with concrete Zod validators that match the TypeScript
interfaces: create or import Session schema (for entries in sessions array),
CompressedObservation schema (for values in observations record arrays), Memory
schema (for entries in memories array), and SessionSummary schema (for entries
in summaries array), then use those schemas in ExportImportInputSchema (e.g.,
sessions: z.array(SessionSchema), observations: z.record(z.string(),
z.array(CompressedObservationSchema)), memories: z.array(MemorySchema),
summaries: z.array(SessionSummarySchema). Ensure optional profiles remains
unchanged or is similarly validated if a Profile schema exists.

Comment thread src/functions/auto-forget.ts
Comment thread src/functions/relations.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Comment on lines +21 to +45
async embedBatch(texts: string[]): Promise<Float32Array[]> {
const response = await fetch(API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "voyage-code-3",
input: texts,
input_type: "document",
}),
});

if (!response.ok) {
const err = await response.text();
throw new Error(`Voyage embedding failed (${response.status}): ${err}`);
}

const data = (await response.json()) as {
data: Array<{ embedding: number[] }>;
};

return data.data.map((d) => new Float32Array(d.embedding));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the full file structure and the specific file
find . -name "voyage.ts" -type f

Repository: rohitg00/agentmemory

Length of output: 99


🏁 Script executed:

# Read the full voyage.ts file to understand the complete implementation
cat -n ./src/providers/embedding/voyage.ts

Repository: rohitg00/agentmemory

Length of output: 1699


🏁 Script executed:

# Check if there are other embedding providers to see if they implement timeout/validation
find ./src/providers/embedding -name "*.ts" -type f | head -10

Repository: rohitg00/agentmemory

Length of output: 317


🏁 Script executed:

# Check how other providers handle timeouts and response validation
cat -n ./src/providers/embedding/openai.ts

Repository: rohitg00/agentmemory

Length of output: 1667


🏁 Script executed:

# Check another provider (Cohere) to see if they use timeouts or validation
cat -n ./src/providers/embedding/cohere.ts

Repository: rohitg00/agentmemory

Length of output: 1678


🏁 Script executed:

# Check if there's a fetch wrapper or timeout configuration anywhere in the codebase
rg -i "timeout|AbortController" --type ts --type js

Repository: rohitg00/agentmemory

Length of output: 1698


🏁 Script executed:

# Check one of the hooks to see how timeout is implemented
cat -n ./src/hooks/pre-tool-use.ts

Repository: rohitg00/agentmemory

Length of output: 2172


🏁 Script executed:

# Check the EmbeddingProvider type definition to understand the interface
cat -n ./src/types.ts

Repository: rohitg00/agentmemory

Length of output: 7242


🏁 Script executed:

# Verify if embedBatch is called with known input counts to see if validation mismatch is possible
rg "embedBatch" --type ts -B 2 -A 2

Repository: rohitg00/agentmemory

Length of output: 3692


🏁 Script executed:

# Check if there are tests for embedBatch to see expected behavior
find ./test -name "*embedding*" -o -name "*voyage*" | head -5

Repository: rohitg00/agentmemory

Length of output: 97


🏁 Script executed:

# Read the embedding provider test file
cat -n ./test/embedding-provider.test.ts

Repository: rohitg00/agentmemory

Length of output: 2242


🏁 Script executed:

# Check if there's any wrapper or middleware handling that might add timeouts
rg "VoyageEmbeddingProvider|embedBatch" --type ts -B 5 -A 5 | head -50

Repository: rohitg00/agentmemory

Length of output: 3004


Add timeout and response validation to embedBatch method.

The fetch call lacks an abort timeout and can hang indefinitely. Additionally, the response processing doesn't validate that the embedding count matches the input count. Both are critical at the external API boundary.

Suggested fixes:

  • Add signal: AbortSignal.timeout(30000) (or appropriate duration) to the fetch options
  • Validate response structure and count: check that data.data exists and data.data.length === texts.length before mapping
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/embedding/voyage.ts` around lines 21 - 45, The embedBatch
method's fetch can hang and its response isn't validated; update embedBatch to
add a timeout by passing signal: AbortSignal.timeout(30000) (or chosen ms) into
the fetch options, and after parsing JSON validate that the parsed object has a
data array (e.g., check typeof data === "object" && Array.isArray(data.data))
and that data.data.length === texts.length, throwing a descriptive Error if not;
also verify each item has an embedding array before converting to new
Float32Array(d.embedding) to avoid runtime errors.

Comment thread src/state/hybrid-search.ts Outdated
Comment on lines +39 to +40
const queryEmbedding = await this.embeddingProvider.embed(query);
const vectorResults = this.vector.search(queryEmbedding, limit * 2);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fallback to BM25 when embedding/vector retrieval fails.

A transient embedding failure currently bubbles up and breaks search. Degrade to BM25 instead of failing the request.

💡 Proposed fix
-    const queryEmbedding = await this.embeddingProvider.embed(query);
-    const vectorResults = this.vector.search(queryEmbedding, limit * 2);
+    let vectorResults: Array<{ obsId: string; sessionId: string; score: number }>;
+    try {
+      const queryEmbedding = await this.embeddingProvider.embed(query);
+      vectorResults = this.vector.search(queryEmbedding, limit * 2);
+    } catch {
+      return this.enrichResults(
+        bm25Results.map((r) => ({
+          obsId: r.obsId,
+          sessionId: r.sessionId,
+          bm25Score: r.score,
+          vectorScore: 0,
+          combinedScore: r.score,
+        })),
+        limit,
+      );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const queryEmbedding = await this.embeddingProvider.embed(query);
const vectorResults = this.vector.search(queryEmbedding, limit * 2);
let vectorResults: Array<{ obsId: string; sessionId: string; score: number }>;
try {
const queryEmbedding = await this.embeddingProvider.embed(query);
vectorResults = this.vector.search(queryEmbedding, limit * 2);
} catch {
return this.enrichResults(
bm25Results.map((r) => ({
obsId: r.obsId,
sessionId: r.sessionId,
bm25Score: r.score,
vectorScore: 0,
combinedScore: r.score,
})),
limit,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/hybrid-search.ts` around lines 39 - 40, When embedding or vector
retrieval fails for queryEmbedding (this.embeddingProvider.embed) or
vector.search, catch the error and degrade to a BM25 text-only search instead of
letting the exception bubble up; update the search flow around queryEmbedding
and vector.search to try embedding/search in a try-catch, and on catch call your
BM25 search method (e.g., this.bm25.search or equivalent) with the original
query and limit so the request succeeds with BM25 results.

Comment thread src/triggers/api.ts
Critical:
- auto-forget: filter TTL-deleted memories before contradiction pass
- timeline: validate before/after params and anchor date
- api: add auth check to profile endpoint

Major:
- config: NaN fallback for embedding weights
- config: detectEmbeddingProvider uses explicit env when provided
- relations: persist evolved memory before flipping old to non-latest
- relations: get-related traverses KV.relations for full graph
- remember: version increments from existing instead of hardcoded 2
- smart-search: validate query type and clamp limit 1-100
- hybrid-search: fallback to BM25 when embedding fails
- evict: prevent double-counting expired + non-latest memories
- index: log warnings instead of silently swallowing errors
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/functions/remember.ts (1)

34-49: ⚠️ Potential issue | 🟠 Major

Persist the new memory before demoting the current latest to avoid lineage corruption.

Line [44]-Line [46] flips the existing record to non-latest before Line [71] writes the replacement. If the second write fails (or concurrent requests interleave), the chain can end up inconsistent. Also, Line [62]-Line [64] can use the superseded version captured during the scan instead of re-searching.

💡 Proposed fix
       const existingMemories = await kv.list<Memory>(KV.memories);
       let supersededId: string | undefined;
+      let supersededVersion = 1;
+      let supersededMemory: Memory | undefined;
       const lowerContent = data.content.toLowerCase();
       for (const existing of existingMemories) {
-        if (!existing.isLatest) continue;
+        if (existing.isLatest === false) continue;
         const similarity = jaccardSimilarity(
           lowerContent,
           existing.content.toLowerCase(),
         );
         if (similarity > 0.7) {
-          existing.isLatest = false;
-          await kv.set(KV.memories, existing.id, existing);
           supersededId = existing.id;
+          supersededVersion = existing.version ?? 1;
+          supersededMemory = existing;
           break;
         }
       }
@@
-        version: supersededId
-          ? (existingMemories.find((m) => m.id === supersededId)?.version ??
-              1) + 1
-          : 1,
+        version: supersededId ? supersededVersion + 1 : 1,
         parentId: supersededId,
         supersedes: supersededId ? [supersededId] : [],
         isLatest: true,
       };

       await kv.set(KV.memories, memory.id, memory);
+      if (supersededMemory) {
+        supersededMemory.isLatest = false;
+        await kv.set(KV.memories, supersededMemory.id, supersededMemory);
+      }

Also applies to: 62-64, 71-71

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/functions/remember.ts` around lines 34 - 49, The current loop demotes the
found existing memory (setting existing.isLatest = false and kv.set) before the
new memory is persisted, risking lineage loss if the subsequent write fails;
instead, when you detect a similar existing memory in the scan (using
existingMemories, jaccardSimilarity, and Memory), capture and store that
existing record in supersededId/supersededMemory, then first persist the new
memory record to kv (kv.set for the new id), and only after the new memory is
successfully written update the captured existing memory to isLatest = false and
write it back; also avoid re-scanning by reusing the captured supersededMemory
for the later update and ensure error handling/rollback if either write fails.
src/index.ts (1)

152-163: ⚠️ Potential issue | 🟡 Minor

Rebuild condition ignores empty vector index.

When BM25 is restored but the vector index is empty (or wasn't persisted), semantic search silently degrades to BM25-only mode without attempting to rebuild embeddings. This matches the concern from the previous review.

💡 Proposed fix: Include vector index in rebuild decision
-  if (!loaded?.bm25 || loaded.bm25.size === 0) {
+  const needsBm25Rebuild = !loaded?.bm25 || loaded.bm25.size === 0;
+  const needsVectorRebuild = embeddingProvider && vectorIndex && vectorIndex.size === 0;
+  
+  if (needsBm25Rebuild || needsVectorRebuild) {
     const indexCount = await rebuildIndex(kv).catch((err) => {
       console.warn(`[agentmemory] Failed to rebuild search index:`, err);
       return 0;
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 152 - 163, The rebuild condition only checks
loaded?.bm25 and misses the case where the vector/semantic index is present but
empty, so update the if condition to also detect an empty or missing vector
index (the persisted embeddings/hnsw structure) and trigger rebuildIndex(kv)
accordingly; modify the guard around loaded?.bm25 to also check the vector index
property (e.g., loaded.vectorIndex, loaded.hnsw, or whatever field holds
embeddings) for undefined/null or size/length === 0, then call rebuildIndex(kv)
and keep the existing logging and indexPersistence.scheduleSave behavior.
🧹 Nitpick comments (4)
src/index.ts (1)

174-176: Consider logging persistence save failures on shutdown.

The empty .catch(() => {}) silently swallows save errors. If persistence fails, the user won't know their index wasn't saved, potentially leading to unexpected rebuild times on next startup.

📝 Proposed fix
     indexPersistence.stop();
-    await indexPersistence.save().catch(() => {});
+    await indexPersistence.save().catch((err) => {
+      console.warn(`[agentmemory] Failed to save index on shutdown:`, err);
+    });
     await sdk.shutdown();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 174 - 176, Replace the empty swallow on
indexPersistence.save() with error handling that logs failures: call
indexPersistence.stop() as before, then await indexPersistence.save() but catch
the error and pass it to a logger (e.g., processLogger.error or console.error)
with a clear message that persistence save failed; finally proceed to await
sdk.shutdown(). Ensure you reference the existing indexPersistence.save() call
and include the error object in the log so save failures are visible on
shutdown.
src/config.ts (2)

124-132: Unsafe type assertion on provider list.

The as FallbackConfig["providers"] cast trusts that all comma-separated values are valid ProviderType entries. Invalid provider names (e.g., typos like "opanai") will pass through and may cause runtime errors in createFallbackProvider.

🛡️ Proposed fix: Validate provider names
+const VALID_PROVIDERS = new Set(["anthropic", "gemini", "openai", "openrouter", "cohere", "voyage"]);
+
 export function loadFallbackConfig(): FallbackConfig {
   const env = getMergedEnv();
   const raw = env["FALLBACK_PROVIDERS"] || "";
   const providers = raw
     .split(",")
     .map((p) => p.trim())
-    .filter(Boolean) as FallbackConfig["providers"];
+    .filter((p): p is FallbackConfig["providers"][number] => 
+      Boolean(p) && VALID_PROVIDERS.has(p)
+    );
   return { providers };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 124 - 132, The loadFallbackConfig function
currently casts the parsed FALLBACK_PROVIDERS list to
FallbackConfig["providers"] unsafely; validate each comma-separated value from
getMergedEnv()["FALLBACK_PROVIDERS"] against the authoritative ProviderType set
(or allowedProviders array/enum) before returning so typos like "opanai" are
rejected or filtered out. Update loadFallbackConfig to map/trim the values, keep
only entries that exist in the ProviderType/allowedProviders lookup, and either
log/throw on unknown names or exclude them, ensuring createFallbackProvider
receives only valid ProviderType values.

100-106: Consider clamping weight values to valid range.

The isNaN fallback is good, but weights outside [0, 1] or weights that don't sum to a reasonable value could still produce unexpected RRF scores. For instance, negative weights would invert ranking.

🛡️ Optional: Add range validation
 export function loadEmbeddingConfig(): EmbeddingConfig {
   const env = getMergedEnv();
-  const bm25Weight = parseFloat(env["BM25_WEIGHT"] || "0.4");
-  const vectorWeight = parseFloat(env["VECTOR_WEIGHT"] || "0.6");
+  let bm25Weight = parseFloat(env["BM25_WEIGHT"] || "0.4");
+  let vectorWeight = parseFloat(env["VECTOR_WEIGHT"] || "0.6");
+  bm25Weight = isNaN(bm25Weight) || bm25Weight < 0 ? 0.4 : Math.min(bm25Weight, 1);
+  vectorWeight = isNaN(vectorWeight) || vectorWeight < 0 ? 0.6 : Math.min(vectorWeight, 1);
   return {
     provider: env["EMBEDDING_PROVIDER"] || undefined,
-    bm25Weight: isNaN(bm25Weight) ? 0.4 : bm25Weight,
-    vectorWeight: isNaN(vectorWeight) ? 0.6 : vectorWeight,
+    bm25Weight,
+    vectorWeight,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 100 - 106, Clamp parsed weights to the [0,1]
range and normalize them so they form a valid proportion: after reading
bm25Weight and vectorWeight (from parseFloat and the current isNaN fallbacks),
clamp each value to 0..1, then if both are zero revert to the defaults (0.4 and
0.6); otherwise divide each by (bm25Weight + vectorWeight) so they sum to 1.
Update the returned bm25Weight and vectorWeight values accordingly to ensure
neither is negative/out-of-range and the pair is a proper weighting.
src/state/hybrid-search.ts (1)

117-128: Consider parallelizing KV fetches for better latency.

The sequential await loop fetches observations one at a time. For limit=20, this means 20 round trips. Parallelizing would reduce latency.

⚡ Proposed parallel fetch
   private async enrichResults(
     results: Array<{
       obsId: string;
       sessionId: string;
       bm25Score: number;
       vectorScore: number;
       combinedScore: number;
     }>,
     limit: number,
   ): Promise<HybridSearchResult[]> {
-    const enriched: HybridSearchResult[] = [];
-    for (const r of results.slice(0, limit)) {
-      const obs = await this.kv.get<CompressedObservation>(
+    const sliced = results.slice(0, limit);
+    const observations = await Promise.all(
+      sliced.map((r) =>
+        this.kv.get<CompressedObservation>(
+          KV.observations(r.sessionId),
+          r.obsId,
+        ),
+      ),
+    );
+    const enriched: HybridSearchResult[] = [];
+    for (let i = 0; i < sliced.length; i++) {
+      const obs = observations[i];
+      const r = sliced[i];
-        KV.observations(r.sessionId),
-        r.obsId,
-      );
       if (obs) {
         enriched.push({ observation: obs, ...r });
       }
     }
     return enriched;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/state/hybrid-search.ts` around lines 117 - 128, The loop that builds
enriched: HybridSearchResult[] performs sequential awaits which causes N
round-trips; change it to fire all KV fetches in parallel by mapping
results.slice(0, limit) to an array of promises calling
this.kv.get<CompressedObservation>(KV.observations(r.sessionId), r.obsId), await
Promise.all on that array, then combine the resolved observations with their
corresponding result objects (from results.slice(0, limit)) to produce the
enriched array, filtering out any missing obs before returning; update the
method that contains enriched (the hybrid search result assembly) to use this
pattern to reduce latency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/triggers/api.ts`:
- Around line 369-384: The api::smart-search handler registered via
sdk.registerFunction should perform the same parameter validation as
mem::smart-search before calling sdk.trigger: after authenticating with
checkAuth, check that either req.body.query is present OR req.body.expandIds is
a non-empty array, and if neither is provided return a 400 response with a body
like { error: "query or expandIds is required" }; keep the rest of the handler
(calling sdk.trigger("mem::smart-search", req.body) and returning 200)
unchanged.

---

Duplicate comments:
In `@src/functions/remember.ts`:
- Around line 34-49: The current loop demotes the found existing memory (setting
existing.isLatest = false and kv.set) before the new memory is persisted,
risking lineage loss if the subsequent write fails; instead, when you detect a
similar existing memory in the scan (using existingMemories, jaccardSimilarity,
and Memory), capture and store that existing record in
supersededId/supersededMemory, then first persist the new memory record to kv
(kv.set for the new id), and only after the new memory is successfully written
update the captured existing memory to isLatest = false and write it back; also
avoid re-scanning by reusing the captured supersededMemory for the later update
and ensure error handling/rollback if either write fails.

In `@src/index.ts`:
- Around line 152-163: The rebuild condition only checks loaded?.bm25 and misses
the case where the vector/semantic index is present but empty, so update the if
condition to also detect an empty or missing vector index (the persisted
embeddings/hnsw structure) and trigger rebuildIndex(kv) accordingly; modify the
guard around loaded?.bm25 to also check the vector index property (e.g.,
loaded.vectorIndex, loaded.hnsw, or whatever field holds embeddings) for
undefined/null or size/length === 0, then call rebuildIndex(kv) and keep the
existing logging and indexPersistence.scheduleSave behavior.

---

Nitpick comments:
In `@src/config.ts`:
- Around line 124-132: The loadFallbackConfig function currently casts the
parsed FALLBACK_PROVIDERS list to FallbackConfig["providers"] unsafely; validate
each comma-separated value from getMergedEnv()["FALLBACK_PROVIDERS"] against the
authoritative ProviderType set (or allowedProviders array/enum) before returning
so typos like "opanai" are rejected or filtered out. Update loadFallbackConfig
to map/trim the values, keep only entries that exist in the
ProviderType/allowedProviders lookup, and either log/throw on unknown names or
exclude them, ensuring createFallbackProvider receives only valid ProviderType
values.
- Around line 100-106: Clamp parsed weights to the [0,1] range and normalize
them so they form a valid proportion: after reading bm25Weight and vectorWeight
(from parseFloat and the current isNaN fallbacks), clamp each value to 0..1,
then if both are zero revert to the defaults (0.4 and 0.6); otherwise divide
each by (bm25Weight + vectorWeight) so they sum to 1. Update the returned
bm25Weight and vectorWeight values accordingly to ensure neither is
negative/out-of-range and the pair is a proper weighting.

In `@src/index.ts`:
- Around line 174-176: Replace the empty swallow on indexPersistence.save() with
error handling that logs failures: call indexPersistence.stop() as before, then
await indexPersistence.save() but catch the error and pass it to a logger (e.g.,
processLogger.error or console.error) with a clear message that persistence save
failed; finally proceed to await sdk.shutdown(). Ensure you reference the
existing indexPersistence.save() call and include the error object in the log so
save failures are visible on shutdown.

In `@src/state/hybrid-search.ts`:
- Around line 117-128: The loop that builds enriched: HybridSearchResult[]
performs sequential awaits which causes N round-trips; change it to fire all KV
fetches in parallel by mapping results.slice(0, limit) to an array of promises
calling this.kv.get<CompressedObservation>(KV.observations(r.sessionId),
r.obsId), await Promise.all on that array, then combine the resolved
observations with their corresponding result objects (from results.slice(0,
limit)) to produce the enriched array, filtering out any missing obs before
returning; update the method that contains enriched (the hybrid search result
assembly) to use this pattern to reduce latency.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8f9f59e and c719b83.

📒 Files selected for processing (10)
  • src/config.ts
  • src/functions/auto-forget.ts
  • src/functions/evict.ts
  • src/functions/relations.ts
  • src/functions/remember.ts
  • src/functions/smart-search.ts
  • src/functions/timeline.ts
  • src/index.ts
  • src/state/hybrid-search.ts
  • src/triggers/api.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/functions/smart-search.ts
  • src/functions/evict.ts
  • src/functions/timeline.ts
  • src/functions/relations.ts
  • src/functions/auto-forget.ts

Comment thread src/triggers/api.ts
- remember: persist new memory before demoting old (write ordering)
- remember: capture superseded version during scan (no re-search)
- api: validate query/expandIds on smart-search endpoint (return 400)
- index: rebuild when vector index is empty (not just BM25)
- index: log persistence save failures on shutdown
- config: clamp embedding weights to [0, 1]
- config: validate fallback provider names against known set
- hybrid-search: parallelize KV fetches with Promise.all
@rohitg00 rohitg00 merged commit dbe421c into main Feb 27, 2026
1 check passed
@rohitg00 rohitg00 deleted the feat/v0.3.0-competitive-upgrade branch February 27, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant