← A(i) Poignant Guide Pip Tutorial
Part 2 of 3

The Duck Remembers

Give your duck a memory. DynamoDB, memory extraction, and personalized questions.

Your duck has a problem. You've been talking to it for days — maybe a week — and it doesn't know your name. Doesn't know what you do for a living. Doesn't know that you mentioned your daughter's birthday three times this week. Every morning it asks you something generic and perfectly reasonable, because every morning it wakes up with no idea who you are.

This is the first-date problem. The duck is charming, thoughtful, and completely amnesiac. You're building a relationship with something that forgets you every time the Lambda function exits.

In this part, you fix that. You'll build a memory system that extracts what matters from your replies, stores it in DynamoDB, and feeds it back into the Ask prompt so tomorrow's question reflects what you said today. Two changes to your existing code, one new module, and the duck goes from stranger to someone who's been paying attention.

If you've read the guide, you know the metaphor — every new chat is a first date with someone who's read every book but doesn't know your name. Part 1's duck had that exact problem. Part 2 fixes it.

Design the memory

Before writing any code, think about what a "memory" actually is in this system. It's not a transcript. You don't want to store every word the user says — that's a log, not a memory. A memory is a discrete, extracted fact about a person: something worth remembering for next time.

Define the types

Create src/lib/types.ts. This defines the shape of everything we'll store.

/** A single extracted memory from a user's reply */
export interface Memory {
  id: string;
  userId: string;
  content: string;
  category: "fact" | "feeling" | "preference" | "event" | "goal";
  createdAt: string; // ISO 8601
  source: "reply"; // which Lambda created this memory
}

/** A synthesized observation derived from multiple memories (Part 3) */
export interface Observation {
  id: string;
  userId: string;
  content: string;
  basedOn: string[]; // Memory IDs this observation was derived from
  createdAt: string;
  source: "synthesis";
}

/** The context package sent to Bedrock when generating a question */
export interface QuestionContext {
  memories: Memory[];
  observations: Observation[];
  recentQuestions: string[]; // Last 5 questions asked, to avoid repeats
}

/** A record of each daily question sent */
export interface QuestionRecord {
  id: string;
  userId: string;
  question: string;
  sentAt: string;
}

The Memory interface is the star of this part. Each memory has a category — one of five types we'll define in a moment — and a content string that captures the actual thing worth remembering. The Observation type is for Part 3 (pattern synthesis), but we're defining it now so we don't have to revisit this file.

The QuestionContext type describes the full context package that gets fed into the Ask prompt: memories, observations, and recent questions. Think of it as the duck's "working memory" for generating each morning's question.

Understand the DynamoDB design

All of this goes in the DynamoDB table you already created in Part 1. The table has two keys:

  • pk (partition key) = USER#email — everything is scoped to one user
  • sk (sort key) = a prefix + timestamp + ID — the prefix tells you what kind of record it is

Three prefixes, one table:

  • MEMORY#2024-11-15T09:30:00Z#uuid — an extracted memory
  • OBSERVATION#2024-11-15T09:30:00Z#uuid — a synthesized pattern (Part 3)
  • QUESTION#2024-11-15T07:00:00Z#uuid — a question the duck asked

This is a single-table design. At this scale — one user, a few dozen records per week — one table is simpler than three. You query by prefix (begins_with(sk, "MEMORY#")) to get just the record type you want. DynamoDB handles the rest.

What makes a good memory?

The five categories aren't arbitrary. They represent the kinds of things that make future questions feel personal:

The key insight is granularity. "User had a good day" is useless — it's too vague to drive a future question. "User is excited about launching their side project this Friday" is gold — the duck can follow up on Friday, ask how the launch went, connect it to the stress they mentioned last week.

This is the prompt engineering lesson of Part 2. You're not just building a database — you're teaching an LLM what's worth remembering. The extraction prompt you'll write in a few minutes is doing the same work a good therapist does: listening for the signal in the noise.

Build the memory module

This is the only new file in Part 2. Everything else is an update to existing code. The memory module handles all DynamoDB interactions — saving memories, saving questions, saving observations, and querying them back.

Create src/lib/memory.ts:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  PutCommand,
  QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";
import type { Memory, Observation, QuestionRecord } from "./types.js";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME!;

// --- Memories ---

export async function saveMemory(
  userId: string,
  content: string,
  category: Memory["category"]
): Promise<Memory> {
  const memory: Memory = {
    id: randomUUID(),
    userId,
    content,
    category,
    createdAt: new Date().toISOString(),
    source: "reply",
  };

  await client.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        pk: `USER#${userId}`,
        sk: `MEMORY#${memory.createdAt}#${memory.id}`,
        ...memory,
      },
    })
  );

  return memory;
}

export async function getMemories(userId: string): Promise<Memory[]> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
      ExpressionAttributeValues: {
        ":pk": `USER#${userId}`,
        ":prefix": "MEMORY#",
      },
      ScanIndexForward: false, // newest first
      Limit: 50,
    })
  );

  return (result.Items || []) as Memory[];
}

// --- Observations (Part 3) ---

export async function saveObservation(
  userId: string,
  content: string,
  basedOn: string[]
): Promise<Observation> {
  const observation: Observation = {
    id: randomUUID(),
    userId,
    content,
    basedOn,
    createdAt: new Date().toISOString(),
    source: "synthesis",
  };

  await client.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        pk: `USER#${userId}`,
        sk: `OBSERVATION#${observation.createdAt}#${observation.id}`,
        ...observation,
      },
    })
  );

  return observation;
}

export async function getObservations(userId: string): Promise<Observation[]> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
      ExpressionAttributeValues: {
        ":pk": `USER#${userId}`,
        ":prefix": "OBSERVATION#",
      },
      ScanIndexForward: false,
      Limit: 20,
    })
  );

  return (result.Items || []) as Observation[];
}

// --- Question history ---

export async function saveQuestion(
  userId: string,
  question: string
): Promise<void> {
  const record: QuestionRecord = {
    id: randomUUID(),
    userId,
    question,
    sentAt: new Date().toISOString(),
  };

  await client.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        pk: `USER#${userId}`,
        sk: `QUESTION#${record.sentAt}#${record.id}`,
        ...record,
      },
    })
  );
}

export async function getRecentQuestions(userId: string): Promise<string[]> {
  const result = await client.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "pk = :pk AND begins_with(sk, :prefix)",
      ExpressionAttributeValues: {
        ":pk": `USER#${userId}`,
        ":prefix": "QUESTION#",
      },
      ScanIndexForward: false,
      Limit: 5,
    })
  );

  return (result.Items || []).map(
    (item) => (item as QuestionRecord).question
  );
}

Let's walk through each function:

saveMemory

Takes a user ID, content string, and category. Creates a Memory object with a UUID, writes it to DynamoDB with the USER#email / MEMORY#timestamp#id key pattern. The timestamp in the sort key means queries return memories in chronological order by default.

getMemories

Queries all items for a user where the sort key starts with MEMORY#. The ScanIndexForward: false flag reverses the sort order — newest first. The Limit: 50 caps the result set. You don't need every memory ever stored, just enough for context.

saveQuestion / getRecentQuestions

Same pattern, different prefix. saveQuestion records each question the duck sends. getRecentQuestions returns the last 5, so the Ask prompt can avoid repeating itself. Nobody likes a duck that asks the same thing every day.

saveObservation / getObservations

These are for Part 3's synthesis system — pattern recognition across multiple memories. We're adding them now so we don't have to touch this file again. The basedOn field links each observation back to the memory IDs that inspired it, which is useful for debugging ("why did the duck say that?").

Every function follows the same pattern: build the key, send the command, return the result. The DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format, so you don't have to think about { S: "string" } wrappers.

Teach the duck to listen — for real this time

In Part 1, the Listen function heard your reply, generated a warm acknowledgment, and moved on. Now it does something new before responding: it extracts memories.

The flow becomes: receive reply → extract memories → save to DynamoDB → then acknowledge. The user never sees the extraction step. It happens silently, in the background, before the duck responds.

Replace your src/listen.ts with this:

import type { S3Event } from "aws-lambda";
import { parseEmailFromS3 } from "./lib/parse-email.js";
import { askClaude } from "./lib/bedrock.js";
import { sendEmail } from "./lib/email.js";
import { saveMemory } from "./lib/memory.js";
import type { Memory } from "./lib/types.js";

const ACKNOWLEDGE_PROMPT = `You are Pip, a friendly rubber duck companion. Your human just replied to your daily check-in question. Send a brief, warm response — 2-3 sentences max.

Be genuine, not performative. Acknowledge what they said. You can be a little funny but never forced.`;

const EXTRACT_PROMPT = `You are a memory extraction system. Given a person's message, extract discrete memories — things worth remembering about this person for future conversations.

For each memory, output a JSON array of objects with:
- "content": a concise statement of the memory (e.g., "Has a daughter named Luna", "Is stressed about an API migration at work")
- "category": one of "fact", "feeling", "preference", "event", "goal"

Only extract things that would be useful to remember for future conversations. Skip small talk and filler. If there's nothing worth remembering, return an empty array.

Return ONLY the JSON array, no other text.`;

export async function handler(event: S3Event) {
  const userEmail = process.env.USER_EMAIL!;

  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));

    const reply = await parseEmailFromS3(bucket, key);

    console.log("Received reply from:", reply.from);
    console.log("Reply body:", reply.body);

    // Extract memories from the reply
    const memoriesJson = await askClaude(
      EXTRACT_PROMPT,
      `Message: "${reply.body}"`
    );

    try {
      const extracted: Array<{
        content: string;
        category: Memory["category"];
      }> = JSON.parse(memoriesJson);

      for (const mem of extracted) {
        const saved = await saveMemory(userEmail, mem.content, mem.category);
        console.log("Saved memory:", saved.content, `[${saved.category}]`);
      }

      console.log(`Extracted ${extracted.length} memories`);
    } catch (err) {
      console.error("Failed to parse memories:", memoriesJson, err);
    }

    // Generate acknowledgment
    const response = await askClaude(
      ACKNOWLEDGE_PROMPT,
      `The human replied: "${reply.body}"\n\nRespond warmly and briefly.`
    );

    await sendEmail({
      to: userEmail,
      from: userEmail,
      subject: "Re: 🦆 Your duck has a question",
      body: response,
    });

    console.log("Sent acknowledgment:", response);
  }
}

The big change is the extraction step in the middle. Let's break it down.

The extraction prompt

This is the critical piece — the prompt that teaches Claude what's worth remembering. Read it carefully:

  • JSON array output. We need structured data, not prose. The prompt asks for a specific format and ends with "Return ONLY the JSON array, no other text." This is how you get parseable output from an LLM.
  • The five categories. Each memory gets classified as fact, feeling, preference, event, or goal. This isn't just for organization — it helps the Ask prompt later understand what kind of thing it knows about you.
  • "Skip small talk and filler." If you reply "Not bad, pretty good day actually" — there's nothing to extract. The prompt tells Claude to return an empty array rather than manufacturing memories from nothing.
  • Concise statements. The examples in the prompt model the right granularity: "Has a daughter named Luna", not "The user mentioned having a daughter whose name is Luna and who appears to be young."

The extraction flow

After parsing the reply email, we send the reply text to Claude with the extraction prompt. Claude returns a JSON string. We parse it. For each extracted memory, we call saveMemory to write it to DynamoDB.

Here's an example. If you reply:

"I'm stressed about this API migration at work, but Luna's birthday party planning is keeping me sane"

The extraction prompt produces something like:

[
  {"content": "Stressed about API migration at work", "category": "feeling"},
  {"content": "Planning daughter Luna's birthday party", "category": "event"},
  {"content": "Has a daughter named Luna", "category": "fact"}
]

I remembered things too, you know. Nobody ever needed a database for that. I just... sat there. Listening.

Three memories from one sentence. The extraction prompt pulled out the emotion, the event, and an underlying fact. Tomorrow, the duck can ask about the migration, or the party, or Luna by name.

The try/catch around JSON parsing

LLMs sometimes return invalid JSON. Maybe Claude wraps the array in a markdown code fence. Maybe it adds a preamble like "Here are the extracted memories:" before the JSON. The try/catch means a parsing failure logs the error but doesn't crash the Lambda. The duck still sends its acknowledgment — the user never knows the extraction failed.

This is a general principle for LLM output: always handle the case where the response isn't what you expected. Confidence and correctness are different things.

Teach the duck to remember

Extracting memories is half the job. The other half is using them. The Ask function needs to load what the duck knows about you and weave it into the prompt.

Replace your src/ask.ts with this:

import { askClaude } from "./lib/bedrock.js";
import { sendEmail } from "./lib/email.js";
import {
  getMemories,
  getObservations,
  getRecentQuestions,
  saveQuestion,
} from "./lib/memory.js";

const SYSTEM_PROMPT = `You are Pip, a friendly rubber duck companion. You check in with your human every morning with a single thoughtful question.

Your personality:
- Warm and curious, not clinical
- You ask about what matters: their work, their energy, what they're building, what's on their mind
- One question only. Keep it short — 1-2 sentences max.
- Don't be cheesy. Don't use emojis. Be genuine.
- If you have memories about this person, use them. Reference things they've told you. Follow up on things that seemed important.
- Don't repeat recent questions.`;

export async function handler() {
  const userEmail = process.env.USER_EMAIL!;

  // Load context
  const memories = await getMemories(userEmail);
  const observations = await getObservations(userEmail);
  const recentQuestions = await getRecentQuestions(userEmail);

  // Build context for the prompt
  let context = "Generate today's check-in question.";

  if (memories.length > 0) {
    const memoryList = memories
      .slice(0, 20) // Don't overflow context
      .map((m) => `- [${m.category}] ${m.content}`)
      .join("\n");
    context += `\n\nHere's what you know about this person:\n${memoryList}`;
  }

  if (observations.length > 0) {
    const obsList = observations
      .slice(0, 5)
      .map((o) => `- ${o.content}`)
      .join("\n");
    context += `\n\nPatterns you've noticed:\n${obsList}`;
  }

  if (recentQuestions.length > 0) {
    context += `\n\nQuestions you've asked recently (don't repeat these):\n${recentQuestions.map((q) => `- ${q}`).join("\n")}`;
  }

  const question = await askClaude(SYSTEM_PROMPT, context);

  // Save the question to history
  await saveQuestion(userEmail, question);

  await sendEmail({
    to: userEmail,
    from: userEmail,
    subject: "🦆 Your duck has a question",
    body: question,
  });

  console.log("Sent daily question:", question);
  console.log("Context included:", {
    memories: memories.length,
    observations: observations.length,
    recentQuestions: recentQuestions.length,
  });
}

The structure is the same as Part 1 — call Claude, send an email. But now there's a middle step: loading context and building a richer prompt.

Loading context

Three parallel queries at the top: memories, observations, and recent questions. Observations will be empty until Part 3 adds the synthesis system. Recent questions start accumulating as soon as you deploy this version. Memories appear after your first reply to the updated Listen function.

Building the context string

The user message to Claude starts with "Generate today's check-in question." Then we append sections as they become available:

  • Memories — formatted as - [category] content so Claude can see both what the duck knows and what kind of information it is
  • Observations — higher-level patterns (Part 3)
  • Recent questions — so the duck doesn't repeat itself

The slice(0, 20)

This is important. We query up to 50 memories from DynamoDB but only send the 20 most recent to Claude. Why? Because you can't send everything forever. Each memory is a few tokens, and 20 memories is a reasonable amount of context — enough to be personal without overwhelming the prompt. As the memory store grows, older memories quietly fall off the end.

This is a blunt instrument. "Newest 20" means a three-month-old memory about your daughter's name gets pushed out by last week's minor complaints. We'll revisit this tradeoff in the explainer below.

The updated system prompt

Two new lines in the system prompt make the difference:

  • "If you have memories about this person, use them. Reference things they've told you. Follow up on things that seemed important."
  • "Don't repeat recent questions."

That's it. Claude doesn't need elaborate instructions about how to use memory — it needs permission and material. Give it a list of things it knows about someone and tell it to use them, and it will.

Saving the question

After generating the question, we call saveQuestion to record it. This builds the recent-questions list that prevents repeats. Without it, the duck would develop favorite questions and ask them over and over.

The context window problem

You now have a memory system. You also have a finite prompt. This is the deepest lesson of Part 2: how do you decide what's relevant?

Right now, the answer is simple — newest 20 memories. That works for week one. Maybe week four. But six months in, you'll have hundreds of memories, and "newest 20" means the duck forgets everything from the early days. Your daughter's name, your job title, the goals you set in January — all pushed out by the latest batch of feelings and events.

The real solutions are more sophisticated:

For a personal duck with one user, recency is fine. You can always bump the limit to 30 or 50. But if you were building this for a product — a thousand users, each with years of history — you'd need to solve this problem properly. It's the difference between a toy and a system.

We'll leave it at "newest 20" for this tutorial. The duck doesn't need to be perfect. It needs to be noticeably better than forgetting everything, and it will be.

Deploy and test

Deploy the updated code

No infrastructure changes this time — the DynamoDB table already exists from Part 1. You're just deploying new Lambda code.

npx sst deploy --stage dev

This should be faster than the first deploy since the resources already exist.

Trigger the Ask function

Invoke it manually to get a question, or wait for the next scheduled run:

aws lambda invoke \
  --function-name $(aws lambda list-functions \
    --query "Functions[?contains(FunctionName, 'AskFn')].FunctionName" \
    --output text) \
  --payload '{}' \
  /dev/stdout

This first question will still be generic — there are no memories yet.

Reply with something personal

Reply to the duck's email with something the extraction system can latch onto. Don't just say "fine" — give it material:

"I'm working on a new project at work and my daughter Luna has a birthday coming up"

Wait for the acknowledgment email (10-30 seconds). Behind the scenes, the Listen function is extracting memories and saving them to DynamoDB.

Check DynamoDB

Go to the DynamoDB console, find your table (it'll be named something like pip-agent-dev-memorytable-xxxxx), and click Explore table items.

Look for items with a sk starting with MEMORY#. You should see entries like:

  • MEMORY#2024-11-15T09:30:00Z#abc123 — content: "Working on a new project at work", category: "event"
  • MEMORY#2024-11-15T09:30:01Z#def456 — content: "Has a daughter named Luna", category: "fact"
  • MEMORY#2024-11-15T09:30:02Z#ghi789 — content: "Daughter Luna has a birthday coming up", category: "event"

If you see memories in the table, the extraction system is working.

Trigger another question

Invoke the Ask function again (or wait until tomorrow). This time, the duck has memories to work with. Instead of a generic "What's on your mind today?" you should get something that references what you told it — maybe a question about Luna's birthday, or about the new project at work.

The difference might be subtle the first time. Give it a few days of replies. By day three or four, the questions will feel markedly different.

Troubleshooting

If memories aren't appearing in DynamoDB:

  • Check CloudWatch Logs for the Listen function. Look for "Extracted N memories" or "Failed to parse memories" in the log output. The log group will be /aws/lambda/pip-agent-dev-ListenFn-xxxxx.
  • "Failed to parse memories" means Claude returned something that wasn't valid JSON. Check what it returned — it's logged right before the error. Common culprits: markdown code fences around the JSON, or a preamble sentence before the array. You can tighten the extraction prompt, or add a step that strips code fences before parsing.
  • No logs at all from the Listen function means the S3 trigger isn't firing. Revisit the SES receipt rule and bucket subscription from Part 1.
  • DynamoDB access denied would show in the logs as a permissions error. Make sure your Listen function's link: [table] is still in the SST config.
Checkpoint

Reply to your duck for a few days. By day three or four, the questions should feel different — less generic, more like someone who's been listening. If the duck mentions something you told it two days ago, it's working. The first-date problem is solved.

But the duck only remembers facts. It stores discrete memories — isolated snapshots of what you said — and replays them. It doesn't yet see the bigger picture. It doesn't notice that you mention your side project with more energy than your day job, or that you're always stressed on Mondays, or that every time you talk about your daughter your mood lifts.

Part 3 fixes that. You'll add a synthesis step — a Lambda that runs weekly, reads all your memories, and looks for patterns. The duck won't just remember what you said. It'll notice what it means.