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.
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.
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.
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 usersk (sort key) = a prefix + timestamp + ID — the prefix tells you what kind of record it isThree prefixes, one table:
MEMORY#2024-11-15T09:30:00Z#uuid — an extracted memoryOBSERVATION#2024-11-15T09:30:00Z#uuid — a synthesized pattern (Part 3)QUESTION#2024-11-15T07:00:00Z#uuid — a question the duck askedThis 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.
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.
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:
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.
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.
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.
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.
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.
This is the critical piece — the prompt that teaches Claude what's worth remembering. Read it carefully:
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.
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.
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.
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.
The user message to Claude starts with "Generate today's check-in question." Then we append sections as they become available:
- [category] content so Claude can see both what the duck knows and what kind of information it isslice(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.
Two new lines in the system prompt make the difference:
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.
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.
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.
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.
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 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.
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.
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.
If memories aren't appearing in DynamoDB:
/aws/lambda/pip-agent-dev-ListenFn-xxxxx.link: [table] is still in the SST config.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.