File Analysis: convex/agent/memory.ts

This file is the cognitive core of the AI agents, managing their entire memory lifecycle. It’s the foundation that allows them to learn, evolve, and exhibit complex, history-aware behaviors, implementing the core concepts of the “Generative Agents” research paper.

1. Core Role and Positioning

This file serves as the cognitive core for AI agents in the simulation. It manages the entire lifecycle of an agent’s memory, including creating memories from experiences, calculating their significance, retrieving them based on relevance, and generating higher-level insights through reflection. It is the foundational component that enables agents to learn, evolve, and exhibit complex, history-aware behaviors.

2. Core Value and Process

The core value of this file is to implement the “Generative Agent” memory model, giving AI characters a persistent and nuanced “mind.” Instead of just reacting, agents can recall past conversations, reflect on their experiences, and form abstract thoughts, leading to more believable and dynamic interactions.

The primary flow involves two key processes: memory formation and memory retrieval/reflection.

3. Code Deep Dive

Function: rememberConversation(...)

This function starts the process of creating a memory after a conversation. It loads the conversation transcript, then uses an LLM to summarize it from the agent’s first-person perspective. This summary is then passed to two other services: calculateImportance to get a 0-9 “poignancy” rating (another LLM call), and fetchEmbedding to convert the text into a searchable vector. Finally, all of this data is saved as a new memory in the database.

export async function rememberConversation(
  ctx: ActionCtx,
  worldId: Id<'worlds'>,
  agentId: GameId<'agents'>,
  playerId: GameId<'players'>,
  conversationId: GameId<'conversations'>,
) {
  const data = await ctx.runQuery(selfInternal.loadConversation, {
    worldId,
    playerId,
    conversationId,
  });
  const { player, otherPlayer } = data;
  const messages = await ctx.runQuery(selfInternal.loadMessages, { worldId, conversationId });
  if (!messages.length) {
    return;
  }
 
  //... (LLM prompt construction)
 
  const { content } = await chatCompletion({
    messages: llmMessages,
    max_tokens: 500,
  });
  const description = `Conversation with ${otherPlayer.name} at ${new Date(
    data.conversation._creationTime,
  ).toLocaleString()}: ${content}`;
  const importance = await calculateImportance(description);
  const { embedding } = await fetchEmbedding(description);
  
  await ctx.runMutation(selfInternal.insertMemory, {
    agentId,
    playerId: player.id,
    description,
    importance,
    lastAccess: messages[messages.length - 1]._creationTime,
    data: {
      type: 'conversation',
      conversationId,
      playerIds: [...authors],
    },
    embedding,
  });
  await reflectOnMemories(ctx, worldId, playerId);
  return description;
}

Function: searchMemories(...)

This function retrieves the most relevant memories for an agent. It first performs a broad vector search to get a large pool of MEMORY_OVERFETCH candidates that are semantically related to the query. This larger set is then passed to rankAndTouchMemories for a more sophisticated scoring and ranking process.

export async function searchMemories(
  ctx: ActionCtx,
  playerId: GameId<'players'>,
  searchEmbedding: number[],
  n: number = 3,
) {
  const candidates = await ctx.vectorSearch('memoryEmbeddings', 'embedding', {
    vector: searchEmbedding,
    filter: (q) => q.eq('playerId', playerId),
    limit: n * MEMORY_OVERFETCH,
  });
  const rankedMemories = await ctx.runMutation(selfInternal.rankAndTouchMemories, {
    candidates,
    n,
  });
  return rankedMemories.map(({ memory }) => memory);
}

Function: rankAndTouchMemories(...)

This function implements the core memory ranking algorithm. It takes a large list of candidates and refines it down to the top n results by calculating an overallScore for each memory. This score is a weighted sum of three normalized factors:

  • Relevance: The raw score from the vector search.
  • Importance: The pre-calculated poignancy rating.
  • Recency: A score that decays exponentially the longer it has been since the memory was last accessed. Finally, it updates the lastAccess timestamp of the winning memories, making them “fresher” and more likely to be recalled again soon.
export const rankAndTouchMemories = internalMutation({
  args: {
    candidates: v.array(v.object({ _id: v.id('memoryEmbeddings'), _score: v.number() })),
    n: v.number(),
  },
  handler: async (ctx, args) => {
    const ts = Date.now();
    const relatedMemories = await asyncMap(args.candidates, async ({ _id }) => {
        // ... (fetch memory details)
    });
 
    const recencyScore = relatedMemories.map((memory) => {
      const hoursSinceAccess = (ts - memory.lastAccess) / 1000 / 60 / 60;
      return 0.99 ** Math.floor(hoursSinceAccess);
    });
 
    const relevanceRange = makeRange(args.candidates.map((c) => c._score));
    const importanceRange = makeRange(relatedMemories.map((m) => m.importance));
    const recencyRange = makeRange(recencyScore);
 
    const memoryScores = relatedMemories.map((memory, idx) => ({
      memory,
      overallScore:
        normalize(args.candidates[idx]._score, relevanceRange) +
        normalize(memory.importance, importanceRange) +
        normalize(recencyScore[idx], recencyRange),
    }));
    memoryScores.sort((a, b) => b.overallScore - a.overallScore);
    const accessed = memoryScores.slice(0, args.n);
 
    await asyncMap(accessed, async ({ memory }) => {
      if (memory.lastAccess < ts - MEMORY_ACCESS_THROTTLE) {
        await ctx.db.patch(memory._id, { lastAccess: ts });
      }
    });
    return accessed;
  },
});

Function: reflectOnMemories(...)

This function simulates an agent’s ability to perform metacognition—thinking about its own thoughts to form higher-level insights. If the cumulative importance score of recent memories exceeds a threshold, it uses an LLM to generate “3 high-level insights” from them. These insights are then converted into new, searchable memories of type ‘reflection’, creating a powerful feedback loop where the agent’s own conclusions about its life become part of its memory.

async function reflectOnMemories(
  ctx: ActionCtx,
  worldId: Id<'worlds'>,
  playerId: GameId<'players'>,
) {
  const { memories, lastReflectionTs, name } = await ctx.runQuery(
    internal.agent.memory.getReflectionMemories,
    // ...
  );
 
  const sumOfImportanceScore = memories
    .filter((m) => m._creationTime > (lastReflectionTs ?? 0))
    .reduce((acc, curr) => acc + curr.importance, 0);
  const shouldReflect = sumOfImportanceScore > 500;
 
  if (!shouldReflect) {
    return false;
  }
  // ... (constructs a detailed prompt for the LLM to generate insights in JSON format)
  
  const { content: reflection } = await chatCompletion({
    // ...
  });
 
  try {
    const insights = JSON.parse(reflection) as { insight: string; statementIds: number[] }[];
    const memoriesToSave = await asyncMap(insights, async (item) => {
      const relatedMemoryIds = item.statementIds.map((idx: number) => memories[idx]._id);
      const importance = await calculateImportance(item.insight);
      const { embedding } = await fetchEmbedding(item.insight);
      return {
        description: item.insight,
        embedding,
        importance,
        relatedMemoryIds,
      };
    });
 
    await ctx.runMutation(selfInternal.insertReflectionMemories, {
      worldId,
      playerId,
      reflections: memoriesToSave,
    });
  } catch (e) {
    console.error('error saving or parsing reflection', e);
    return false;
  }
  return true;
}