/ memdir / findRelevantMemories.ts
findRelevantMemories.ts
  1  import { feature } from 'bun:bundle'
  2  import { logForDebugging } from '../utils/debug.js'
  3  import { errorMessage } from '../utils/errors.js'
  4  import { getDefaultSonnetModel } from '../utils/model/model.js'
  5  import { sideQuery } from '../utils/sideQuery.js'
  6  import { jsonParse } from '../utils/slowOperations.js'
  7  import {
  8    formatMemoryManifest,
  9    type MemoryHeader,
 10    scanMemoryFiles,
 11  } from './memoryScan.js'
 12  
 13  export type RelevantMemory = {
 14    path: string
 15    mtimeMs: number
 16  }
 17  
 18  const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
 19  
 20  Return a list of filenames for the memories that will clearly be useful to Claude Code as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
 21  - If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.
 22  - If there are no memories in the list that would clearly be useful, feel free to return an empty list.
 23  - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (Claude Code is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.
 24  `
 25  
 26  /**
 27   * Find memory files relevant to a query by scanning memory file headers
 28   * and asking Sonnet to select the most relevant ones.
 29   *
 30   * Returns absolute file paths + mtime of the most relevant memories
 31   * (up to 5). Excludes MEMORY.md (already loaded in system prompt).
 32   * mtime is threaded through so callers can surface freshness to the
 33   * main model without a second stat.
 34   *
 35   * `alreadySurfaced` filters paths shown in prior turns before the
 36   * Sonnet call, so the selector spends its 5-slot budget on fresh
 37   * candidates instead of re-picking files the caller will discard.
 38   */
 39  export async function findRelevantMemories(
 40    query: string,
 41    memoryDir: string,
 42    signal: AbortSignal,
 43    recentTools: readonly string[] = [],
 44    alreadySurfaced: ReadonlySet<string> = new Set(),
 45  ): Promise<RelevantMemory[]> {
 46    const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
 47      m => !alreadySurfaced.has(m.filePath),
 48    )
 49    if (memories.length === 0) {
 50      return []
 51    }
 52  
 53    const selectedFilenames = await selectRelevantMemories(
 54      query,
 55      memories,
 56      signal,
 57      recentTools,
 58    )
 59    const byFilename = new Map(memories.map(m => [m.filename, m]))
 60    const selected = selectedFilenames
 61      .map(filename => byFilename.get(filename))
 62      .filter((m): m is MemoryHeader => m !== undefined)
 63  
 64    // Fires even on empty selection: selection-rate needs the denominator,
 65    // and -1 ages distinguish "ran, picked nothing" from "never ran".
 66    if (feature('MEMORY_SHAPE_TELEMETRY')) {
 67      /* eslint-disable @typescript-eslint/no-require-imports */
 68      const { logMemoryRecallShape } =
 69        require('./memoryShapeTelemetry.js') as typeof import('./memoryShapeTelemetry.js')
 70      /* eslint-enable @typescript-eslint/no-require-imports */
 71      logMemoryRecallShape(memories, selected)
 72    }
 73  
 74    return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))
 75  }
 76  
 77  async function selectRelevantMemories(
 78    query: string,
 79    memories: MemoryHeader[],
 80    signal: AbortSignal,
 81    recentTools: readonly string[],
 82  ): Promise<string[]> {
 83    const validFilenames = new Set(memories.map(m => m.filename))
 84  
 85    const manifest = formatMemoryManifest(memories)
 86  
 87    // When Claude Code is actively using a tool (e.g. mcp__X__spawn),
 88    // surfacing that tool's reference docs is noise — the conversation
 89    // already contains working usage.  The selector otherwise matches
 90    // on keyword overlap ("spawn" in query + "spawn" in a memory
 91    // description → false positive).
 92    const toolsSection =
 93      recentTools.length > 0
 94        ? `\n\nRecently used tools: ${recentTools.join(', ')}`
 95        : ''
 96  
 97    try {
 98      const result = await sideQuery({
 99        model: getDefaultSonnetModel(),
100        system: SELECT_MEMORIES_SYSTEM_PROMPT,
101        skipSystemPromptPrefix: true,
102        messages: [
103          {
104            role: 'user',
105            content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`,
106          },
107        ],
108        max_tokens: 256,
109        output_format: {
110          type: 'json_schema',
111          schema: {
112            type: 'object',
113            properties: {
114              selected_memories: { type: 'array', items: { type: 'string' } },
115            },
116            required: ['selected_memories'],
117            additionalProperties: false,
118          },
119        },
120        signal,
121        querySource: 'memdir_relevance',
122      })
123  
124      const textBlock = result.content.find(block => block.type === 'text')
125      if (!textBlock || textBlock.type !== 'text') {
126        return []
127      }
128  
129      const parsed: { selected_memories: string[] } = jsonParse(textBlock.text)
130      return parsed.selected_memories.filter(f => validFilenames.has(f))
131    } catch (e) {
132      if (signal.aborted) {
133        return []
134      }
135      logForDebugging(
136        `[memdir] selectRelevantMemories failed: ${errorMessage(e)}`,
137        { level: 'warn' },
138      )
139      return []
140    }
141  }