Skip to content

feat(conversation-memory): complete Phase 4 - observation system documentation and deprecation - #452

Merged
baleen37 merged 4 commits into
mainfrom
chore/claude-mem
Feb 8, 2026
Merged

feat(conversation-memory): complete Phase 4 - observation system documentation and deprecation#452
baleen37 merged 4 commits into
mainfrom
chore/claude-mem

Conversation

@baleen37

@baleen37 baleen37 commented Feb 8, 2026

Copy link
Copy Markdown
Owner

Summary

Complete Phase 4 of the observation system implementation - documentation updates and deprecation notices for the legacy exchange-based search.

Changes

  • Documentation updates: All documentation updated to reflect the new observation-based progressive disclosure system (3 layers)
  • Deprecation notices: Added deprecation notice to searchMultipleConcepts (v6.0 → v7.0 removal)
  • Bug fixes: Fixed observer CLI bugs (incorrect import and subcommand routing)
  • Schema enhancement: Added project field to PendingEvent schema and processing

Phases Complete

Phase 1: DB schema + pending_events queue + observer process + PostToolUse hook
Phase 2: Stop hook (session summary) + SessionStart inject hook
Phase 3: search/get_observations MCP tools + Progressive Disclosure
Phase 4: Agent/skill updates + exchange search deprecation

All 4 phases of the observation system design are now implemented, documented, and ready for use.

Files Modified

  • Documentation: README.md, agents/, commands/, skills/
  • Core: db.ts, observations.ts, search.ts, types.ts
  • CLI: observe-cli.ts, observer-cli.ts
  • MCP: server.ts
  • Build: dist/cli.mjs, dist/mcp-server.mjs

Testing

  • Build completes successfully
  • All changes follow the design specification from docs/plans/2026-02-08-context-injection-design.md

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Observation-based progressive disclosure search: compact results → full observation details → raw transcripts.
    • New get_observations tool to fetch complete observation details by ID.
    • Background observer captures and summarizes conversation insights; session context injection provides quick recent-context access.
  • Chores
    • New CLI commands to run/observe/inject the observer and summarization flows.
  • Documentation
    • Updated docs and examples to reflect observation-centric workflows and layer guidance; legacy multi-concept search marked for deprecation.

baleen37 and others added 3 commits February 8, 2026 15:35
…ation

Implements the foundation of the observation system inspired by claude-mem:
- DB schema: observations, session_summaries, pending_events, vec_observations tables
- TypeScript interfaces: Observation, SessionSummary, CompactObservation, PendingEvent
- Observer core logic with SQLite queue polling and 30min idle timeout
- Observation prompts for init, per-tool execution, and session summary
- Observation CRUD with embedding generation
- Session summary generation and storage
- PostToolUse hook integration (<5ms response, no LLM calls)
- Observer background process with PID file management
- Observer CLI commands: start, stop, status
- Hooks.json updates for PostToolUse and Stop events
- CLI routing for observe and observer commands
- Comprehensive tests for all new functionality

Key architectural differences from claude-mem:
- SQLite queue instead of Express HTTP for IPC
- One Gemini conversation per session (prompt caching benefits)
- Low-value tools skipped at code level (no LLM filter needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…observer shutdown

Implements SessionStart hook context injection and observer shutdown:
- Inject CLI for SessionStart hook (displays recent observations as compact table)
- Inject context formatter with stats and MCP tool usage hints
- Progressive disclosure Layer 1 format (~30 tokens per observation)
- Hooks.json updates for SessionStart (inject, sync, observer start)
- CLI routing for inject command
- Observer process shutdown handling after summarize event
- Graceful PID file cleanup and database connection closure

Key features:
- SessionStart inject shows last 7 days of observations (max 30)
- Observations grouped by date in markdown table format
- Includes stats (observation count, token estimate)
- MCP tool hints: search(), get_observations(), read()
- Observer shuts down gracefully after generating session summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mentation and deprecation

Complete the observation system implementation with documentation updates
and deprecation notices for legacy exchange-based search.

Changes:
- Update all documentation for observation-based progressive disclosure (3 layers)
- Add deprecation notice to searchMultipleConcepts (v6.0 → v7.0 removal)
- Fix observer CLI bugs (import and subcommand routing)
- Add project field to PendingEvent schema and processing

Phase 4 complete: All 4 phases of the observation system design are now
implemented, documented, and ready for use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 8, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The conversation-memory plugin was reworked into an observation-driven, progressive-disclosure system: new DB tables and types for observations and events, an observer pipeline (CLI + background process) that creates observations from tool events, a three-layer search/get_observations/read flow, CLI hooks, MCP tool changes, and updated docs/tests.

Changes

Cohort / File(s) Summary
Documentation
plugins/conversation-memory/README.md, plugins/conversation-memory/agents/search-conversation.md, plugins/conversation-memory/commands/search-conversation.md, plugins/conversation-memory/skills/remembering-conversations/MCP-TOOLS.md, plugins/conversation-memory/skills/remembering-conversations/SKILL.md
Replaced semantic-search language with an observation-based progressive disclosure model (Layer 1: search(), Layer 2: get_observations(), Layer 3: read()); updated examples, parameters (observation-type/concepts/files), payload token expectations, and guidance.
Database Schema & Types
plugins/conversation-memory/src/core/db.ts, plugins/conversation-memory/src/core/types.ts
Added tables: observations, session_summaries, pending_events, vec_observations; new types/interfaces (Observation, CompactObservation, SessionSummary, PendingEvent, XML shapes); DB APIs for inserting/querying observations, pending events, and summaries.
Observation Core
plugins/conversation-memory/src/core/observation-prompt.ts, plugins/conversation-memory/src/core/observations.ts, plugins/conversation-memory/src/core/session-summary.ts, plugins/conversation-memory/src/core/session-summary.test.ts, plugins/conversation-memory/src/core/observation-prompt.test.ts, plugins/conversation-memory/src/core/observations.test.ts
New observation prompt builders/parsers (XML), observation creation with embeddings, validation/parsing helpers, compact/full retrieval helpers, session-summary parsing/storage, and unit tests covering parsing, persistence, and retrieval.
Observer & Event Processing
plugins/conversation-memory/src/core/observer.ts, plugins/conversation-memory/src/core/inject.ts, plugins/conversation-memory/src/core/paths.ts
Added long-running observer: session/project utilities, event polling, processing (tool-use → observation, summarize), and context injection utilities (getObservationsForInjection/formatInjectContext/getInjectContext); added getObserverPidPath.
Search & MCP Integration
plugins/conversation-memory/src/core/search.ts, plugins/conversation-memory/src/mcp/server.ts
Implemented observation-based searchObservations (vector+text hybrid), result formatting, deprecation path for legacy multi-concept search, and new MCP tool get_observations to fetch full observation details; updated MCP search/read flows.
CLI & Hooks
plugins/conversation-memory/src/cli/index-cli.ts, plugins/conversation-memory/src/cli/inject-cli.ts, plugins/conversation-memory/src/cli/observe-cli.ts, plugins/conversation-memory/src/cli/observer-cli.ts, plugins/conversation-memory/hooks/hooks.json
Added CLI commands: inject, observe, observer (and alias observer-run); observe/inject used by SessionStart/PostToolUse/Stop hooks to queue events and inject compact observations; observer CLI manages detached background observer.
Tests
plugins/conversation-memory/src/core/observation-prompt.test.ts, plugins/conversation-memory/src/core/observations.test.ts, plugins/conversation-memory/src/core/session-summary.test.ts
Added tests for prompt building/parsing, observation CRUD and queries, embedding handling (mocked), and session-summary processing and retrieval.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Agent/Client
    participant Hook as Hook (SessionStart / PostToolUse)
    participant CLI as Inject/Observe CLI
    participant DB as Database
    participant Observer as Observer Process
    participant LLM as LLM

    Client->>Hook: trigger (SessionStart / PostToolUse / Stop)
    Hook->>CLI: run inject / observe (--summarize?)
    CLI->>DB: query or insert pending_event
    DB-->>CLI: results / ack
    Observer->>DB: poll pending_events
    DB-->>Observer: pending_event data
    Observer->>LLM: buildObservationPrompt / buildSummaryPrompt
    LLM-->>Observer: XML observation / summary
    Observer->>DB: insertObservation / insertSessionSummary
    DB-->>Observer: persisted
Loading
sequenceDiagram
    participant Agent as Search Agent
    participant MCP as MCP Server
    participant Search as searchObservations
    participant Vec as vec_observations (vector)
    participant DB as Database
    participant Agent2 as Agent (follow-up)

    Agent->>MCP: search(query, options)
    MCP->>Search: searchObservations(query, options)
    Search->>Vec: vector similarity query
    Vec-->>Search: candidate ids + scores
    Search->>DB: text filters & metadata joins
    DB-->>Search: compact observations
    Search-->>MCP: Layer 1 results (compact)
    Agent->>MCP: get_observations(ids)
    MCP->>DB: getObservationsByIds(ids)
    DB-->>MCP: full observation details (Layer 2)
    Agent2->>MCP: read(path) [opt]
    MCP->>DB: read raw transcript (Layer 3)
    DB-->>MCP: raw conversation
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 I nibble facts and stitch them neat,
Observations tucked where insights meet.
Three layers deep, the search can trod—
Compact hops, then fuller nod.
A cheerful observer in a burrowed code.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.61% 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 clearly summarizes the main change: completing Phase 4 of the observation system with documentation updates and deprecation of legacy search.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/claude-mem

No actionable comments were generated in the recent review. 🎉


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Note

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
plugins/conversation-memory/src/cli/index-cli.ts (1)

113-116: ⚠️ Potential issue | 🟡 Minor

Comments claim dependency check is bypassed, but ensureDependencies() runs for all commands.

Lines 120 and 127 say "handle directly without dependency check for speed", but ensureDependencies() at line 116 executes before the switch statement, so the check still runs. Either move the fast-path commands before the ensureDependencies() call, or update the comments.

Proposed fix — move fast-path commands before dependency check
 async function main() {
   try {
+    // Fast-path commands that bypass dependency check
+    if (command === 'inject') {
+      await import('./inject-cli.js');
+      return;
+    }
+    if (command === 'observe') {
+      await import('./observe-cli.js');
+      return;
+    }
+    if (command === 'observer' || command === 'observer-run') {
+      await import('./observer-cli.js');
+      return;
+    }
+
     // Ensure dependencies are installed
     await ensureDependencies();

     switch (command) {
-      case 'inject':
-        // Inject command - handle directly without dependency check for speed
-        await import('./inject-cli.js');
-        break;
-
-      case 'observe':
-      case 'observer':
-      case 'observer-run':
-        // Observer commands - handle directly without dependency check for speed
-        await import('./observer-cli.js');
-        break;
-
       case 'index-session':
plugins/conversation-memory/skills/remembering-conversations/MCP-TOOLS.md (1)

144-196: ⚠️ Potential issue | 🟡 Minor

Documentation inconsistency: Response Format and Error Handling sections still reference conversations instead of observations.

The "Response Format" section (lines 148-179) and the "Error Handling" section (lines 183-186) still show the old conversation-based output format (paths, summaries, "No conversations found"). However, single-concept search now returns observations, as documented earlier in lines 50-78. This creates confusion for readers—the same search tool appears to return two different formats.

Either:

  1. Clarify that this Response Format section applies only to multi-concept (legacy) searches, or
  2. Update it to show the observation-based response format for single-concept queries.

Similarly, line 185 says "No conversations found" but the error table at line 470 says "No observations found".

plugins/conversation-memory/src/core/db.ts (1)

24-34: ⚠️ Potential issue | 🔴 Critical

Bug: Migration for pending_events.project will fail due to redundant column definition.

The migration at line 24 targets the pending_events table, but the migration system (line 9) only checks columns from the exchanges table. This means columnNames.has('project_pending_events') is always false, causing ALTER TABLE pending_events ADD COLUMN project TEXT to execute on every initDatabase() call.

However, the CREATE TABLE IF NOT EXISTS pending_events at lines 144–159 already defines project TEXT at line 154. SQLite will throw a "duplicate column name: project" error on execution since there is no error handling.

Fix: Remove the redundant migration entry since the column is already included in the CREATE TABLE statement.

     { name: 'thinking_triggers', sql: 'ALTER TABLE exchanges ADD COLUMN thinking_triggers TEXT' },
     { name: 'compressed_tool_summary', sql: 'ALTER TABLE exchanges ADD COLUMN compressed_tool_summary TEXT' },
-    { name: 'project_pending_events', sql: 'ALTER TABLE pending_events ADD COLUMN project TEXT' },
   ];
🤖 Fix all issues with AI agents
In `@plugins/conversation-memory/hooks/hooks.json`:
- Around line 31-41: The PostToolUse/Stop hooks are invoking the "observe"
command but index-cli.ts routes "observe" to observer-cli.js which has no case
for "observe", so observe-cli.js (and its handlePostToolUse/--summarize Stop
logic) is never executed; fix by adding an "observe" branch in the command
switch in index-cli.ts that imports/dispatches to the module that implements the
observe handlers (the code in observe-cli.js / functions like handlePostToolUse
and summarize stop logic) or alternately change hooks.json to call the existing
subcommands (e.g., a direct "node .../dist/observe-cli.js observe" or the
correct subcommand name); ensure the new branch invokes the same entrypoint used
by observe-cli.js so PostToolUse and Stop events are queued to the DB.

In `@plugins/conversation-memory/src/cli/index-cli.ts`:
- Around line 119-129: The switch handling for commands incorrectly imports
observer-cli for the 'observe' case; update the case branch so that 'observe'
(used by PostToolUse/Stop hooks and event queuing) imports './observe-cli.js'
while leaving 'observer' and 'observer-run' importing './observer-cli.js'
unchanged; modify the import statement in the switch block that currently
references './observer-cli.js' for the 'observe' case to './observe-cli.js'
(look for the switch handling the 'inject'/'observe'/'observer'/'observer-run'
cases in index-cli.ts).

In `@plugins/conversation-memory/src/cli/observer-cli.ts`:
- Around line 16-17: The bug stems from calculating subcommandIndex by grouping
'observer' and 'observer-run' together; change the ternary so only 'observer'
expects a subcommand (e.g., subcommandIndex = process.argv[2] === 'observer' ? 3
: 2) so that when argv[2] is 'observer-run' the command variable picks
'observer-run' instead of defaulting to 'status'; additionally, wrap the
'observer-run' switch case body in braces and keep the const declarations scoped
inside that block to satisfy Biome's noSwitchDeclarations rule and avoid
block-scope issues for the code that handles the 'observer-run' case.

In `@plugins/conversation-memory/src/core/inject.ts`:
- Around line 69-72: The JSON.parse calls for row.facts, row.concepts,
row.filesRead, and row.filesModified in the observation construction within
plugins/conversation-memory/src/core/inject.ts are unguarded and will throw on
null/empty/malformed values; replace them with a safe parse helper (e.g.,
safeParseJson) or wrap each parse in try/catch returning a sensible default
(empty array or object) so malformed DB data doesn't crash injection; update the
code paths that build the observation objects (the location creating facts,
concepts, filesRead, filesModified) to use this safe parsing helper and ensure
defaults are explicit.

In `@plugins/conversation-memory/src/core/observation-prompt.ts`:
- Around line 63-71: The XML template in observation-prompt.ts interpolates
toolName, cwd, project, and JSON.stringify(toolInput) without escaping which can
break XML; update the template to pass each interpolated value through the
existing escapeXml function (i.e., replace direct ${toolName}, ${cwd},
${project}, and ${JSON.stringify(toolInput, null, 2)} with ${escapeXml(...)} )
so that toolName, cwd, project and the JSON stringified toolInput are
XML-escaped before being inserted into the <tool_event> tags, keeping the
already-escaped ${escapeXml(toolResponse)} as-is.
- Around line 96-101: The buildSummaryPrompt return currently injects raw
sessionContext and project into XML; update buildSummaryPrompt to XML-escape
those values (escape &, <, >, " and ') before interpolation to avoid malformed
XML—either call an existing xmlEscape/escapeXml helper or add a small utility
(e.g., escapeXml) and use it when inserting sessionContext and project into the
template string in observation-prompt.ts.

In `@plugins/conversation-memory/src/core/observer.ts`:
- Around line 280-313: processSummarizeEvent currently calls shutdownObserver
which invokes process.exit(0) and kills the process before
pollPendingEvents.markEventProcessed runs, causing re-processing of the
summarize event; to fix, ensure the event is marked processed before exiting by
calling the existing markEventProcessed for the summarize event (from
pollPendingEvents) prior to calling shutdownObserver, or refactor
shutdownObserver to remove process.exit and instead return control to the caller
(processSummarizeEvent) so the caller can call markEventProcessed and then exit;
update references in processSummarizeEvent, shutdownObserver, and
pollPendingEvents (symbols: processSummarizeEvent, shutdownObserver,
markEventProcessed, pollPendingEvents) accordingly.
- Around line 248-253: context.conversationHistory is unbounded and is passed
wholesale to llmProvider.complete which risks exceeding context limits and loses
role info; limit growth by trimming or using a sliding window before calling
llmProvider.complete (e.g., implement a helper like
trimConversationHistory(maxMessagesOrTokens) and call it after every push) and
change the payload generation to include role markers when joining (e.g., map
msg => `${msg.role}: ${msg.content}`) so the model can distinguish user vs
system/model messages; ensure trimming runs both when appending in the tool-use
path and right before calling llmProvider.complete.
- Around line 122-132: The setInterval-based polling creates overlapping async
executions of pollPendingEvents; replace the setInterval logic (the pollInterval
variable and its setInterval invocation) with a single async loop that awaits
pollPendingEvents before sleeping, using POLL_INTERVAL_MS for the delay and
honoring the shouldShutdown return to break the loop; ensure any existing
clearInterval usage or pidPath shutdown signaling is handled (stop referring to
pollInterval inside pollPendingEvents if it expects an interval id) so only one
pollPendingEvents runs at a time and errors are still caught and logged.

In `@plugins/conversation-memory/src/core/search.ts`:
- Around line 515-542: The text search SQL in search.ts constructs
`${whereClause}` which can be empty, then blindly appends `AND (...)`, causing
invalid SQL when there is no preceding WHERE; update the SQL assembly in the
text search block (the prepared statement that creates textStmt) to
conditionally insert either `WHERE` or `AND` before the `(...)` depending on
whether `whereClause` is non-empty (e.g. use `whereClause ? \`${whereClause} AND
(...)\` : \`WHERE (...)\``) so the query is valid when `whereClauses` is empty;
ensure the parameters passed to `textStmt.all(...whereParams, likeQuery,
likeQuery, likeQuery, limit * 2)` still align with the resulting SQL.
- Around line 427-433: The timestamp comparisons in the if (after) and if
(before) blocks use ISO date strings but observations.timestamp is stored as an
INTEGER epoch; parse the provided date strings into a Unix timestamp matching
the DB unit before pushing to whereParams (e.g., const ts = new
Date(after).getTime() / 1000 if the DB stores seconds, or .getTime() for
milliseconds), then push ts into whereParams for the whereClauses; verify the
exact epoch unit in db.ts and use that conversion when updating the if (after)
and if (before) blocks referencing whereClauses and whereParams.
- Line 419: The call to initDatabase() in searchObservations opens a DB
connection that is never closed; wrap the database usage in a try/finally so
db.close() is always invoked (move the existing logic that uses db into the try
block and put db.close() in finally), preserving return values and rethrowing
errors if necessary; apply the same try/finally pattern to the other location
that also calls initDatabase() later in the file so every code path ensures
db.close() is executed (refer to initDatabase(), db.close(), and
searchObservations to find the spots to change).
- Around line 465-512: The vector-query is missing the sqlite-vec
nearest-neighbor MATCH clause so v.distance is meaningless; update the prepared
SQL (used where stmt is built in the vector branch) to include the vec table's
MATCH condition (e.g., "AND v.embedding MATCH ? AND k = ?"/the same pattern used
in searchConversations) and pass the generated queryEmbedding (and k if
required) into whereParams before executing stmt.all(...whereParams, ...). Keep
existing calls to initEmbeddings(), generateEmbedding(), and
applyRecencyBoost(), and ensure JSON.parse usage for facts/concepts/files*
remains unchanged.

In `@plugins/conversation-memory/src/mcp/server.ts`:
- Around line 262-272: SearchInputSchema is strict but missing the types,
concepts, and files fields that clients send, so SearchInputSchema.parse(args)
will reject requests; update SearchInputSchema to include types:
z.array(z.string()).optional(), concepts: z.array(z.string()).optional(), and
files: z.array(z.string()).optional() (or the correct types per JSON tool
schema), then remove the (args as any) casts in the ObservationSearchOptions
construction and use args.types, args.concepts, and args.files directly so
ObservationSearchOptions (used in the single-concept search block and passed to
ObservationSearchOptions) receives the parsed values.
🟡 Minor comments (8)
plugins/conversation-memory/README.md-13-13 (1)

13-13: ⚠️ Potential issue | 🟡 Minor

Inconsistency: "SessionEnd hook" vs "SessionStart Hook" on Line 176.

Line 13 states "SessionEnd hook syncs conversations automatically" but the "How It Works" section at line 176 is titled "Automatic Indexing (SessionStart Hook)" and describes behavior triggered "When each Claude Code session starts." These are contradictory. Based on the PR objectives referencing "SessionStart injection," line 13 appears stale.

Proposed fix
-- **Automatic Indexing**: SessionEnd hook syncs conversations automatically
+- **Automatic Indexing**: SessionStart hook syncs conversations automatically
plugins/conversation-memory/src/cli/observer-cli.ts-71-75 (1)

71-75: ⚠️ Potential issue | 🟡 Minor

Wrap const declaration in a block to prevent scope leakage across switch clauses.

As flagged by Biome: the const declaration on line 73 is accessible from other switch clauses, which can lead to accidental reference before initialization (TDZ error).

Proposed fix
-      case 'observer-run':
-        // This is the internal command that actually runs the observer
-        const { startObserver: run } = await import('../core/observer.js');
-        await run();
-        break;
+      case 'observer-run': {
+        // This is the internal command that actually runs the observer
+        const { startObserver: run } = await import('../core/observer.js');
+        await run();
+        break;
+      }
plugins/conversation-memory/src/core/observations.test.ts-399-421 (1)

399-421: ⚠️ Potential issue | 🟡 Minor

Test for JSON-stringified arrays doesn't verify the parsed result.

The test only asserts toBeDefined() but never checks that the stringified arrays (facts, concepts) were actually parsed back into proper arrays. This test would pass even if parseObservation returned them as raw strings.

Proposed fix — add value assertions
       const observation = parseObservation(data);

       // Should handle stringified arrays
       expect(observation).toBeDefined();
+      expect(observation?.facts).toEqual(['fact1', 'fact2']);
+      expect(observation?.concepts).toEqual(['concept1']);
     });
plugins/conversation-memory/src/mcp/server.ts-327-331 (1)

327-331: ⚠️ Potential issue | 🟡 Minor

forEach callback should not return a value.

Per the Biome hint, the forEach callback at line 329 returns the result of output += .... Use a for...of loop instead:

Proposed fix
           if (obs.facts.length > 0) {
             output += `**Facts:**\n`;
-            obs.facts.forEach((f: string) => output += `- ${f}\n`);
+            for (const f of obs.facts) {
+              output += `- ${f}\n`;
+            }
             output += `\n`;
           }
plugins/conversation-memory/skills/remembering-conversations/MCP-TOOLS.md-106-112 (1)

106-112: ⚠️ Potential issue | 🟡 Minor

Missing blank line before fenced code block.

Per markdownlint MD031, fenced code blocks should be surrounded by blank lines. Line 107 starts a code block immediately after line 106 text.

 **Consider using single-concept search with filters instead:**
+
 ```json
 {
plugins/conversation-memory/src/core/observations.ts-66-68 (1)

66-68: ⚠️ Potential issue | 🟡 Minor

if (limit) is falsy for limit = 0, and LIMIT is string-interpolated instead of parameterized.

Two issues here:

  1. if (limit) treats 0 as falsy, so passing limit = 0 silently returns all rows instead of none. Use if (limit !== undefined) (or limit != null).
  2. The LIMIT value is interpolated directly into the SQL string. While TypeScript types it as number, this is inconsistent with getCompactObservations (Line 173) which correctly parameterizes the limit via ?. Prefer parameterized queries consistently.
Proposed fix (showing getObservationsByProject; apply same pattern to getObservationsByType)
-  if (limit) {
-    sql += ` LIMIT ${limit}`;
-  }
-
-  const stmt = db.prepare(sql);
-  const rows = stmt.all(project) as any[];
+  const params: (string | number)[] = [project];
+  if (limit != null) {
+    sql += ` LIMIT ?`;
+    params.push(limit);
+  }
+
+  const stmt = db.prepare(sql);
+  const rows = stmt.all(...params) as any[];

Also applies to: 100-102

plugins/conversation-memory/src/core/observations.ts-218-244 (1)

218-244: ⚠️ Potential issue | 🟡 Minor

parseObservation also doesn't guard against NaN for timestamp and createdAt.

Same parseInt issue applies to timestamp (Line 225) and createdAt (Line 236). If the input is non-numeric, these become NaN and validateObservation doesn't check them, leading to NaN values stored in the database.

plugins/conversation-memory/src/core/observations.ts-191-213 (1)

191-213: ⚠️ Potential issue | 🟡 Minor

validateObservation does not catch NaN for promptNumber.

NaN < 0 evaluates to false, so a NaN promptNumber passes validation. This matters because parseObservation (Line 224) uses parseInt which returns NaN for non-numeric input, and the result flows through validateObservation.

Proposed fix
-  if (observation.promptNumber < 0) {
+  if (!Number.isFinite(observation.promptNumber) || observation.promptNumber < 0) {
     return false;
   }
🧹 Nitpick comments (15)
plugins/conversation-memory/hooks/hooks.json (1)

5-13: Synchronous inject hook with 10s timeout — verify this is intentional.

The inject hook runs synchronously (no "async": true), which means it blocks session startup until completion or the 10s timeout. Given the comment in inject-cli.ts says it should complete in ~100ms, this is likely fine — but if the database is slow or locked, it could delay startup noticeably.

Consider whether a shorter timeout (e.g., 3-5s) would be safer, or if this is acceptable.

plugins/conversation-memory/src/cli/observe-cli.ts (1)

19-73: Stdin reading may hang if no data is piped and stdin is a TTY.

for await (const chunk of process.stdin) will block indefinitely if stdin is a TTY (no data piped). If this CLI is ever invoked without piped input (e.g., during debugging or a misconfigured hook), it will hang rather than timing out. The hook timeout (async) would eventually kill the process, but this is worth noting.

Consider adding a brief check or timeout guard, or at minimum documenting that this CLI must always receive piped input.

plugins/conversation-memory/agents/search-conversation.md (1)

119-128: Consider adding a deprecation timeline for multi-concept search.

The PR objectives mention deprecation of searchMultipleConcepts (planned removal v7.0, deprecation starting v6.0). This section notes it's "legacy" but doesn't mention the deprecation timeline, which would help agent users understand the migration urgency.

plugins/conversation-memory/README.md (1)

311-343: Project structure section is outdated and doesn't reflect new modules.

The tree listing omits all the new core files added in this PR (e.g., observations.ts, observer.ts, inject.ts, session-summary.ts, observation-prompt.ts, paths.ts, types.ts) and the new CLI files (observe-cli.ts, observer-cli.ts, inject-cli.ts). Since this PR significantly expands the architecture, the project structure should be updated to avoid misleading new contributors.

plugins/conversation-memory/src/core/observation-prompt.test.ts (2)

169-175: Marginally flaky timestamp assertion in generateId test.

generateId() captures Date.now() internally, and line 171 calls Date.now() separately after the fact. If the millisecond counter advances between the two calls, timestamp.substring(0, 10) could differ. In practice this is extremely unlikely (10-second granularity), but a more robust approach would capture the timestamp before and verify the ID falls within the range.

Proposed fix
     it('should generate IDs with timestamp prefix', () => {
+      const before = Date.now().toString();
       const id = generateId();
-      const timestamp = Date.now().toString();
+      const after = Date.now().toString();

       expect(id).toMatch(/^\d+-/);
-      expect(id.startsWith(timestamp.substring(0, 10))).toBe(true);
+      // Verify timestamp prefix is within the expected range
+      const idTimestamp = id.split('-')[0];
+      expect(Number(idTimestamp)).toBeGreaterThanOrEqual(Number(before));
+      expect(Number(idTimestamp)).toBeLessThanOrEqual(Number(after));
     });

11-11: Unused type imports.

Observation and SessionSummary are imported but never referenced in the test file.

Proposed fix
-import type { Observation, SessionSummary } from './types.js';
plugins/conversation-memory/src/cli/observer-cli.ts (1)

22-38: startObserver doesn't verify the spawned process started successfully.

The function logs "Observer process started" immediately after spawn() without checking for spawn errors. If cliPath doesn't exist or node can't be found, the user gets a false success message. Consider listening for the 'error' event before printing confirmation.

Proposed fix
 async function startObserver(): Promise<void> {
   const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.cwd();
   const cliPath = path.join(pluginRoot, 'dist', 'cli.mjs');

   const observer = spawn('node', [cliPath, 'observer-run'], {
     detached: true,
     stdio: 'ignore',
     env: {
       ...process.env,
       CLAUDE_PLUGIN_ROOT: pluginRoot
     }
   });

-  observer.unref();
-
-  console.log('Observer process started');
+  observer.on('error', (err) => {
+    console.error('Failed to start observer process:', err.message);
+    process.exit(1);
+  });
+
+  observer.unref();
+
+  console.log(`Observer process started (pid: ${observer.pid})`);
 }
plugins/conversation-memory/src/core/inject.ts (1)

5-9: sessionIds option is declared but never used in the query.

InjectOptions.sessionIds is defined but getObservationsForInjection never references it for filtering. Either implement session-based filtering or remove the field to avoid confusing callers.

plugins/conversation-memory/src/core/observations.test.ts (1)

35-46: Consider using fs import instead of inline require.

The require('fs') on line 39 inside the afterEach is a CommonJS-style import in what appears to be an ESM module (given the .js extension imports elsewhere). While it works in practice, using a top-level import fs from 'fs' would be more consistent with the rest of the file.

plugins/conversation-memory/src/core/session-summary.ts (2)

64-90: Duplicated row-to-SessionSummary mapping logic with db.ts.

The SQL column selection, aliasing, and JSON.parse mapping at lines 68-89 is nearly identical to getSessionSummary in db.ts (lines 440-466 in the relevant snippet). If the SessionSummary shape changes, both locations must be updated in lockstep.

Consider extracting a shared mapRowToSessionSummary(row: any): SessionSummary helper (or adding a getSessionSummariesByProject function in db.ts) to keep the mapping in one place.


9-24: Thin wrappers add indirection without added logic.

getLatestSessionSummary and saveSessionSummary are 1:1 pass-throughs to getSessionSummary and insertSessionSummary from db.ts. They don't add validation, logging, or transformation. Callers could use the db.ts functions directly. This is fine if the intent is to establish a facade for future extension, but worth noting to avoid confusion.

plugins/conversation-memory/src/core/db.ts (1)

383-405: getPendingEvents uses spread then override — works but is fragile.

Line 401 spreads the entire raw row object (...row) and then overrides toolInput and processed. This works because the SQL aliases match the PendingEvent interface property names. However, if a column alias ever drifts from the interface shape, the spread will silently produce incorrect data. A safer approach is explicit field mapping (like getSessionSummary does).

plugins/conversation-memory/src/core/observer.ts (1)

194-221: Redundant promptCount assignment for tool_use events.

Line 212 sets context.promptCount = promptNumber inside the tool_use branch, and line 219 sets it again unconditionally. For tool_use, this is redundant. For unknown event types, it silently increments the prompt counter without doing useful work.

Proposed cleanup
   if (event.eventType === 'tool_use') {
     await processToolUseEvent(db, llmProvider, event, context, promptNumber);
-    context.promptCount = promptNumber;
     return false;
   } else if (event.eventType === 'summarize') {
     await processSummarizeEvent(db, llmProvider, event, context, pollInterval, pidPath);
     return true; // Signal shutdown after summarize
   }
 
   context.promptCount = promptNumber;
   return false;
plugins/conversation-memory/src/core/observations.ts (1)

39-45: Extract a shared row-mapping helper to reduce duplication.

The JSON-parse mapping for facts, concepts, filesRead, filesModified is duplicated six times. A single helper (e.g., mapRowToObservation) would improve maintainability and provide a single place to add error handling for corrupt JSON.

Example helper
function mapRowToObservation(row: any): Observation {
  return {
    ...row,
    facts: JSON.parse(row.facts),
    concepts: JSON.parse(row.concepts),
    filesRead: JSON.parse(row.filesRead),
    filesModified: JSON.parse(row.filesModified),
  };
}

Also applies to: 73-78, 107-112, 136-141, 179-184, 270-275

plugins/conversation-memory/src/core/observation-prompt.ts (1)

265-270: generateId uses Date.now() + Math.random() — adequate for observation IDs but not collision-proof under high concurrency.

If the observer processes multiple events within the same millisecond, Date.now() is identical and Math.random() provides ~48 bits of entropy. For this use case (local observation store, not a distributed system), this is acceptable. Consider switching to crypto.randomUUID() if you want stronger guarantees in the future.

Comment on lines +31 to +41
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs observe",
"async": true
}
]
}
],

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the routing of 'observe' command in index-cli.ts
echo "=== index-cli.ts observe/observer routing ==="
rg -n "case 'observe'" plugins/conversation-memory/src/cli/index-cli.ts -A 5

echo ""
echo "=== What does observer-cli.ts export/handle? ==="
head -50 plugins/conversation-memory/src/cli/observer-cli.ts 2>/dev/null || echo "File not found"

echo ""
echo "=== What does observe-cli.ts export/handle? ==="
head -50 plugins/conversation-memory/src/cli/observe-cli.ts 2>/dev/null || echo "File not found"

echo ""
echo "=== Check for handlePostToolUse in observe-cli.ts ==="
rg -n "handlePostToolUse\|handleStop" plugins/conversation-memory/src/cli/observe-cli.ts 2>/dev/null || echo "Not found"

echo ""
echo "=== Check index-cli.ts for all command cases ==="
rg -n "^\s*case " plugins/conversation-memory/src/cli/index-cli.ts

Repository: baleen37/claude-plugins

Length of output: 3642


🏁 Script executed:

#!/bin/bash
# Verify if observe-cli.ts is imported/routed anywhere in index-cli.ts
echo "=== Is observe-cli.ts imported in index-cli.ts? ==="
rg "observe-cli" plugins/conversation-memory/src/cli/index-cli.ts

echo ""
echo "=== Check if observe-cli.ts is called as a direct module ==="
rg "observe-cli" plugins/conversation-memory/src/cli/ -l

echo ""
echo "=== Full routing in index-cli.ts for observe/observer cases ==="
sed -n '124,130p' plugins/conversation-memory/src/cli/index-cli.ts

Repository: baleen37/claude-plugins

Length of output: 458


🏁 Script executed:

#!/bin/bash
# Final verification: check what observer-cli.js actually exports/does with 'observe' as subcommand
echo "=== What happens when observer-cli.js receives 'observe' as subcommand? ==="
sed -n '1,80p' plugins/conversation-memory/src/cli/observer-cli.ts | tail -40

echo ""
echo "=== Check observer-cli.ts for 'observe' case handling ==="
rg -n "case.*observe\|if.*observe" plugins/conversation-memory/src/cli/observer-cli.ts

Repository: baleen37/claude-plugins

Length of output: 1094


observe command routes to observer-cli.js which has no handler for it — PostToolUse and Stop hooks will fail.

In index-cli.ts (lines 124-129), the observe command is routed to observer-cli.js, but observer-cli.js only implements handlers for start, stop, status, and observer-run subcommands. The observe subcommand has no case in the switch statement and falls through to the error handler. Meanwhile, observe-cli.js — which contains handlePostToolUse() and the --summarize Stop event logic — is never imported or reached.

This means the PostToolUse hook in hooks.json (line 36) and the Stop hook (line 41) will both fail to queue events to the database, leaving the observer process with nothing to process.

🤖 Prompt for AI Agents
In `@plugins/conversation-memory/hooks/hooks.json` around lines 31 - 41, The
PostToolUse/Stop hooks are invoking the "observe" command but index-cli.ts
routes "observe" to observer-cli.js which has no case for "observe", so
observe-cli.js (and its handlePostToolUse/--summarize Stop logic) is never
executed; fix by adding an "observe" branch in the command switch in
index-cli.ts that imports/dispatches to the module that implements the observe
handlers (the code in observe-cli.js / functions like handlePostToolUse and
summarize stop logic) or alternately change hooks.json to call the existing
subcommands (e.g., a direct "node .../dist/observe-cli.js observe" or the
correct subcommand name); ensure the new branch invokes the same entrypoint used
by observe-cli.js so PostToolUse and Stop events are queued to the DB.

Comment on lines +119 to +129
case 'inject':
// Inject command - handle directly without dependency check for speed
await import('./inject-cli.js');
break;

case 'observe':
case 'observer':
case 'observer-run':
// Observer commands - handle directly without dependency check for speed
await import('./observer-cli.js');
break;

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 | 🔴 Critical

observe command incorrectly routed to observer-cli.js — should be observe-cli.js.

The observe command (used by PostToolUse and Stop hooks) needs to go to observe-cli.js which handles event queuing. Currently it's routed to observer-cli.js (observer lifecycle management: start/stop/status), meaning PostToolUse events will never be queued.

observer and observer-run correctly route to observer-cli.js.

🐛 Proposed fix
       case 'inject':
         // Inject command - handle directly without dependency check for speed
         await import('./inject-cli.js');
         break;

       case 'observe':
-      case 'observer':
-      case 'observer-run':
-        // Observer commands - handle directly without dependency check for speed
+        // Observe command (PostToolUse/Stop hooks) - queues events
+        await import('./observe-cli.js');
+        break;
+
+      case 'observer':
+      case 'observer-run':
+        // Observer lifecycle commands (start/stop/status)
         await import('./observer-cli.js');
         break;
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/cli/index-cli.ts` around lines 119 - 129, The
switch handling for commands incorrectly imports observer-cli for the 'observe'
case; update the case branch so that 'observe' (used by PostToolUse/Stop hooks
and event queuing) imports './observe-cli.js' while leaving 'observer' and
'observer-run' importing './observer-cli.js' unchanged; modify the import
statement in the switch block that currently references './observer-cli.js' for
the 'observe' case to './observe-cli.js' (look for the switch handling the
'inject'/'observe'/'observer'/'observer-run' cases in index-cli.ts).

Comment on lines +16 to +17
const subcommandIndex = process.argv[2] === 'observer' || process.argv[2] === 'observer-run' ? 3 : 2;
const command = process.argv[subcommandIndex] || 'status';

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd 'index-cli\.ts' -t f

Repository: baleen37/claude-plugins

Length of output: 115


🏁 Script executed:

fd 'observer-cli\.ts' -t f

Repository: baleen37/claude-plugins

Length of output: 118


🏁 Script executed:

# Read observer-cli.ts to see the actual implementation
wc -l $(fd 'observer-cli\.ts' -t f)

Repository: baleen37/claude-plugins

Length of output: 122


🏁 Script executed:

cat -n plugins/conversation-memory/src/cli/observer-cli.ts

Repository: baleen37/claude-plugins

Length of output: 3235


🏁 Script executed:

cat -n plugins/conversation-memory/src/cli/index-cli.ts

Repository: baleen37/claude-plugins

Length of output: 10267


Critical bug: observer-run command is unreachable — spawned observer defaults to status check instead of running.

When startObserver spawns node dist/cli.mjs observer-run (line 26), the observer-cli module receives process.argv = ['node', 'dist/cli.mjs', 'observer-run']. The condition on line 16 treats 'observer-run' as a prefix expecting a subcommand at argv[3]. Since none exists, command defaults to 'status' (line 17), making the 'observer-run' case at line 71 unreachable. The spawned background process will only check status and exit.

Additionally, line 73 has a Biome noSwitchDeclarations violation: the const statement inside the switch case requires block scope.

Proposed fix
-const subcommandIndex = process.argv[2] === 'observer' || process.argv[2] === 'observer-run' ? 3 : 2;
-const command = process.argv[subcommandIndex] || 'status';
+let command: string;
+if (process.argv[2] === 'observer-run') {
+  command = 'observer-run';
+} else {
+  const subcommandIndex = process.argv[2] === 'observer' ? 3 : 2;
+  command = process.argv[subcommandIndex] || 'status';
+}

Also wrap the 'observer-run' case in braces:

       case 'observer-run':
+        {
           const { startObserver: run } = await import('../core/observer.js');
           await run();
           break;
+        }
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/cli/observer-cli.ts` around lines 16 - 17,
The bug stems from calculating subcommandIndex by grouping 'observer' and
'observer-run' together; change the ternary so only 'observer' expects a
subcommand (e.g., subcommandIndex = process.argv[2] === 'observer' ? 3 : 2) so
that when argv[2] is 'observer-run' the command variable picks 'observer-run'
instead of defaulting to 'status'; additionally, wrap the 'observer-run' switch
case body in braces and keep the const declarations scoped inside that block to
satisfy Biome's noSwitchDeclarations rule and avoid block-scope issues for the
code that handles the 'observer-run' case.

Comment on lines +69 to +72
facts: JSON.parse(row.facts),
concepts: JSON.parse(row.concepts),
filesRead: JSON.parse(row.filesRead),
filesModified: JSON.parse(row.filesModified),

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

JSON.parse calls on DB fields lack error handling — corrupted data will crash injection.

If any observation row has null, empty string, or malformed JSON in facts, concepts, filesRead, or filesModified, the unguarded JSON.parse will throw and abort the entire injection context generation. This is a production resilience concern since DB data could become inconsistent.

Proposed fix — safe parse with fallback
+    const safeParseArray = (val: string | null): string[] => {
+      if (!val) return [];
+      try { return JSON.parse(val); } catch { return []; }
+    };
+
     observationsByDate.get(date)!.push({
       id: row.id,
       sessionId: row.sessionId,
       project: row.project,
       timestamp: row.timestamp,
       type: row.type,
       title: row.title,
       subtitle: row.subtitle,
-      facts: JSON.parse(row.facts),
-      concepts: JSON.parse(row.concepts),
-      filesRead: JSON.parse(row.filesRead),
-      filesModified: JSON.parse(row.filesModified),
+      facts: safeParseArray(row.facts),
+      concepts: safeParseArray(row.concepts),
+      filesRead: safeParseArray(row.filesRead),
+      filesModified: safeParseArray(row.filesModified),
       time
     });
📝 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
facts: JSON.parse(row.facts),
concepts: JSON.parse(row.concepts),
filesRead: JSON.parse(row.filesRead),
filesModified: JSON.parse(row.filesModified),
const safeParseArray = (val: string | null): string[] => {
if (!val) return [];
try { return JSON.parse(val); } catch { return []; }
};
observationsByDate.get(date)!.push({
id: row.id,
sessionId: row.sessionId,
project: row.project,
timestamp: row.timestamp,
type: row.type,
title: row.title,
subtitle: row.subtitle,
facts: safeParseArray(row.facts),
concepts: safeParseArray(row.concepts),
filesRead: safeParseArray(row.filesRead),
filesModified: safeParseArray(row.filesModified),
time
});
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/inject.ts` around lines 69 - 72, The
JSON.parse calls for row.facts, row.concepts, row.filesRead, and
row.filesModified in the observation construction within
plugins/conversation-memory/src/core/inject.ts are unguarded and will throw on
null/empty/malformed values; replace them with a safe parse helper (e.g.,
safeParseJson) or wrap each parse in try/catch returning a sensible default
(empty array or object) so malformed DB data doesn't crash injection; update the
code paths that build the observation objects (the location creating facts,
concepts, filesRead, filesModified) to use this safe parsing helper and ensure
defaults are explicit.

Comment on lines +63 to +71
return `${context}

<tool_event>
<tool_name>${toolName}</tool_name>
<cwd>${cwd}</cwd>
<project>${project}</project>
<tool_input>${JSON.stringify(toolInput, null, 2)}</tool_input>
<tool_response>${escapeXml(toolResponse)}</tool_response>
</tool_event>

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

toolName, cwd, project, and toolInput are not XML-escaped, unlike toolResponse.

Line 70 correctly escapes toolResponse via escapeXml(), but toolName (Line 66), cwd (Line 67), project (Line 68), and the JSON.stringify(toolInput) output (Line 69) are interpolated raw. If any of these contain characters like <, >, or &, the XML structure sent to the LLM will be malformed, potentially causing observation parsing failures.

Proposed fix
 <tool_event>
-  <tool_name>${toolName}</tool_name>
-  <cwd>${cwd}</cwd>
-  <project>${project}</project>
-  <tool_input>${JSON.stringify(toolInput, null, 2)}</tool_input>
+  <tool_name>${escapeXml(toolName)}</tool_name>
+  <cwd>${escapeXml(cwd)}</cwd>
+  <project>${escapeXml(project)}</project>
+  <tool_input>${escapeXml(JSON.stringify(toolInput, null, 2))}</tool_input>
   <tool_response>${escapeXml(toolResponse)}</tool_response>
 </tool_event>
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/observation-prompt.ts` around lines 63 -
71, The XML template in observation-prompt.ts interpolates toolName, cwd,
project, and JSON.stringify(toolInput) without escaping which can break XML;
update the template to pass each interpolated value through the existing
escapeXml function (i.e., replace direct ${toolName}, ${cwd}, ${project}, and
${JSON.stringify(toolInput, null, 2)} with ${escapeXml(...)} ) so that toolName,
cwd, project and the JSON stringified toolInput are XML-escaped before being
inserted into the <tool_event> tags, keeping the already-escaped
${escapeXml(toolResponse)} as-is.

if (after) validateISODate(after, '--after');
if (before) validateISODate(before, '--before');

const db = initDatabase();

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

Database connection is never closed in searchObservations.

initDatabase() is called at line 419 but db.close() is never called. Compare with searchConversations which correctly calls db.close() at line 170. This leaks a database connection on every search call.

Proposed fix: wrap in try/finally
   const db = initDatabase();
+  try {
 
   let results: CompactObservationResult[] = [];
   // ... existing logic ...
 
   // Apply limit
-  return results.slice(0, limit);
+  return results.slice(0, limit);
+  } finally {
+    db.close();
+  }
 }

Also applies to: 583-584

🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/search.ts` at line 419, The call to
initDatabase() in searchObservations opens a DB connection that is never closed;
wrap the database usage in a try/finally so db.close() is always invoked (move
the existing logic that uses db into the try block and put db.close() in
finally), preserving return values and rethrowing errors if necessary; apply the
same try/finally pattern to the other location that also calls initDatabase()
later in the file so every code path ensures db.close() is executed (refer to
initDatabase(), db.close(), and searchObservations to find the spots to change).

Comment on lines +427 to +433
if (after) {
whereClauses.push('o.timestamp >= ?');
whereParams.push(after);
}
if (before) {
whereClauses.push('o.timestamp <= ?');
whereParams.push(before);

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

Timestamp type mismatch: date string compared against INTEGER column.

The observations.timestamp column is INTEGER NOT NULL (Unix epoch, per db.ts line 113), but the date filter compares it with an ISO date string like "2025-01-01". SQLite will coerce the comparison, but the semantics are unreliable — an integer like 1735689600 won't meaningfully compare with "2025-01-01".

You need to convert the ISO date to a Unix timestamp for the comparison, e.g.:

if (after) {
  whereClauses.push('o.timestamp >= ?');
  whereParams.push(new Date(after).getTime());
}

Alternatively, use SQLite's date functions, but converting in TypeScript is simpler and more explicit.

🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/search.ts` around lines 427 - 433, The
timestamp comparisons in the if (after) and if (before) blocks use ISO date
strings but observations.timestamp is stored as an INTEGER epoch; parse the
provided date strings into a Unix timestamp matching the DB unit before pushing
to whereParams (e.g., const ts = new Date(after).getTime() / 1000 if the DB
stores seconds, or .getTime() for milliseconds), then push ts into whereParams
for the whereClauses; verify the exact epoch unit in db.ts and use that
conversion when updating the if (after) and if (before) blocks referencing
whereClauses and whereParams.

Comment on lines +465 to +512
if (mode === 'vector' || mode === 'both') {
// Vector similarity search using vec_observations
await initEmbeddings();
const queryEmbedding = await generateEmbedding(query);

const stmt = db.prepare(`
SELECT
o.id,
o.session_id as sessionId,
o.project,
o.timestamp,
o.type,
o.title,
o.subtitle,
o.facts,
o.concepts,
o.files_read as filesRead,
o.files_modified as filesModified,
v.distance
FROM observations o
INNER JOIN vec_observations v ON o.id = v.id
${whereClause}
ORDER BY v.distance
LIMIT ?
`);

const vectorResults = stmt.all(...whereParams, limit * 2) as any[];

for (const row of vectorResults) {
// Convert distance to similarity (1 - distance for cosine distance)
const similarity = Math.max(0, 1 - row.distance);
const boostedSimilarity = applyRecencyBoost(similarity, row.timestamp);

results.push({
id: row.id,
sessionId: row.sessionId,
project: row.project,
timestamp: row.timestamp,
type: row.type,
title: row.title,
subtitle: row.subtitle,
facts: JSON.parse(row.facts),
concepts: JSON.parse(row.concepts),
filesRead: JSON.parse(row.filesRead),
filesModified: JSON.parse(row.filesModified),
similarity: boostedSimilarity
});
}

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 | 🔴 Critical

Critical: Vector search is missing the MATCH clause — no similarity comparison is performed.

The vec_observations virtual table (sqlite-vec) requires a WHERE v.embedding MATCH ? AND k = ? clause to perform nearest-neighbor search, as done correctly in searchConversations (lines 96-114). Without it, v.distance is undefined/meaningless and the query will likely error or return arbitrary rows.

Proposed fix
     const stmt = db.prepare(`
       SELECT
         o.id,
         o.session_id as sessionId,
         o.project,
         o.timestamp,
         o.type,
         o.title,
         o.subtitle,
         o.facts,
         o.concepts,
         o.files_read as filesRead,
         o.files_modified as filesModified,
         v.distance
-      FROM observations o
-      INNER JOIN vec_observations v ON o.id = v.id
-      ${whereClause}
-      ORDER BY v.distance
-      LIMIT ?
+      FROM vec_observations v
+      JOIN observations o ON v.id = o.id
+      WHERE v.embedding MATCH ?
+        AND k = ?
+        ${whereClauses.length > 0 ? 'AND ' + whereClauses.join(' AND ') : ''}
+      ORDER BY v.distance ASC
     `);

-    const vectorResults = stmt.all(...whereParams, limit * 2) as any[];
+    const vectorResults = stmt.all(
+      Buffer.from(new Float32Array(queryEmbedding).buffer),
+      limit * 2,
+      ...whereParams
+    ) as any[];
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/search.ts` around lines 465 - 512, The
vector-query is missing the sqlite-vec nearest-neighbor MATCH clause so
v.distance is meaningless; update the prepared SQL (used where stmt is built in
the vector branch) to include the vec table's MATCH condition (e.g., "AND
v.embedding MATCH ? AND k = ?"/the same pattern used in searchConversations) and
pass the generated queryEmbedding (and k if required) into whereParams before
executing stmt.all(...whereParams, ...). Keep existing calls to
initEmbeddings(), generateEmbedding(), and applyRecencyBoost(), and ensure
JSON.parse usage for facts/concepts/files* remains unchanged.

Comment on lines +515 to +542
if (mode === 'text' || mode === 'both') {
// Text-based search using LIKE
const textStmt = db.prepare(`
SELECT
o.id,
o.session_id as sessionId,
o.project,
o.timestamp,
o.type,
o.title,
o.subtitle,
o.facts,
o.concepts,
o.files_read as filesRead,
o.files_modified as filesModified
FROM observations o
${whereClause}
AND (
o.title LIKE ? OR
o.subtitle LIKE ? OR
o.narrative LIKE ?
)
ORDER BY o.timestamp DESC
LIMIT ?
`);

const likeQuery = `%${query}%`;
const textResults = textStmt.all(...whereParams, likeQuery, likeQuery, likeQuery, limit * 2) as any[];

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 | 🔴 Critical

Critical: Invalid SQL when no filter clauses are active in text search.

When whereClauses is empty, whereClause is '' (line 463). The text query at lines 531-536 becomes:

FROM observations o
 AND (
  o.title LIKE ? OR ...

This is a syntax error — AND without a preceding WHERE.

Proposed fix
     const textStmt = db.prepare(`
       SELECT
         o.id,
         o.session_id as sessionId,
         o.project,
         o.timestamp,
         o.type,
         o.title,
         o.subtitle,
         o.facts,
         o.concepts,
         o.files_read as filesRead,
         o.files_modified as filesModified
       FROM observations o
-      ${whereClause}
-      AND (
+      WHERE ${whereClauses.length > 0 ? whereClauses.join(' AND ') + ' AND' : ''} (
         o.title LIKE ? OR
         o.subtitle LIKE ? OR
         o.narrative LIKE ?
       )
       ORDER BY o.timestamp DESC
       LIMIT ?
     `);
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/core/search.ts` around lines 515 - 542, The
text search SQL in search.ts constructs `${whereClause}` which can be empty,
then blindly appends `AND (...)`, causing invalid SQL when there is no preceding
WHERE; update the SQL assembly in the text search block (the prepared statement
that creates textStmt) to conditionally insert either `WHERE` or `AND` before
the `(...)` depending on whether `whereClause` is non-empty (e.g. use
`whereClause ? \`${whereClause} AND (...)\` : \`WHERE (...)\``) so the query is
valid when `whereClauses` is empty; ensure the parameters passed to
`textStmt.all(...whereParams, likeQuery, likeQuery, likeQuery, limit * 2)` still
align with the resulting SQL.

Comment on lines +262 to 272
// Single-concept search (use observations)
const options: ObservationSearchOptions = {
mode: params.mode,
limit: params.limit,
after: params.after,
before: params.before,
projects: params.projects,
types: (args as any).types,
concepts: (args as any).concepts,
files: (args as any).files,
};

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 | 🔴 Critical

Critical: types, concepts, files params will be rejected by the strict Zod schema.

SearchInputSchema (lines 32-73) uses .strict() and does not include types, concepts, or files properties. When a client sends these parameters, SearchInputSchema.parse(args) at line 232 will throw a Zod error for unrecognized keys — they never reach lines 269-271.

The Zod schema must be updated to match the JSON Schema exposed in the tool definition (lines 160-162):

Proposed fix: add missing fields to SearchInputSchema
 const SearchInputSchema = z
   .object({
     query: z
       .union([
         z.string().min(2, 'Query must be at least 2 characters'),
         z
           .array(z.string().min(2))
           .min(2, 'Must provide at least 2 concepts for multi-concept search')
           .max(5, 'Cannot search more than 5 concepts at once'),
       ])
       .describe(
         'Search query - string for single concept, array of strings for multi-concept AND search'
       ),
     mode: SearchModeEnum.default('both').describe(
       'Search mode: "vector" for semantic similarity, "text" for exact matching, "both" for combined (default: "both"). Only used for single-concept searches.'
     ),
     limit: z
       .number()
       .int()
       .min(1)
       .max(50)
       .default(10)
       .describe('Maximum number of results to return (default: 10)'),
     after: z
       .string()
       .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
       .optional()
       .describe('Only return conversations after this date (YYYY-MM-DD format)'),
     before: z
       .string()
       .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format')
       .optional()
       .describe('Only return conversations before this date (YYYY-MM-DD format)'),
     projects: z
       .array(z.string().min(1))
       .optional()
       .describe('Filter results to specific project names'),
+    types: z
+      .array(z.string().min(1))
+      .optional()
+      .describe('Filter by observation types (single-concept only)'),
+    concepts: z
+      .array(z.string().min(1))
+      .optional()
+      .describe('Filter by tagged concepts (single-concept only)'),
+    files: z
+      .array(z.string().min(1))
+      .optional()
+      .describe('Filter by files mentioned or modified (single-concept only)'),
     response_format: ResponseFormatEnum.default('markdown').describe(
       'Output format: "markdown" for human-readable or "json" for machine-readable (default: "markdown")'
     ),
   })
   .strict();

Then replace the (args as any) casts:

-          types: (args as any).types,
-          concepts: (args as any).concepts,
-          files: (args as any).files,
+          types: params.types,
+          concepts: params.concepts,
+          files: params.files,
🤖 Prompt for AI Agents
In `@plugins/conversation-memory/src/mcp/server.ts` around lines 262 - 272,
SearchInputSchema is strict but missing the types, concepts, and files fields
that clients send, so SearchInputSchema.parse(args) will reject requests; update
SearchInputSchema to include types: z.array(z.string()).optional(), concepts:
z.array(z.string()).optional(), and files: z.array(z.string()).optional() (or
the correct types per JSON tool schema), then remove the (args as any) casts in
the ObservationSearchOptions construction and use args.types, args.concepts, and
args.files directly so ObservationSearchOptions (used in the single-concept
search block and passed to ObservationSearchOptions) receives the parsed values.

Fix test failure by adding required "matcher" field to PostToolUse and Stop
hook entries. The hooks.json schema requires all hook entries to have both
"matcher" and "hooks" fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@baleen37
baleen37 merged commit 121e20f into main Feb 8, 2026
5 checks passed
@baleen37
baleen37 deleted the chore/claude-mem branch February 8, 2026 07:59
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