Allen Helton, Ecosystem Engineer


A step-by-step tutorial for building three layers of agent memory in Oracle AI Database to help an AI agent learn to write social media posts in your voice.

Companion notebook: https://github.com/oracle-devrel/oracle-ai-developer-hub/tree/main/apps/oracle-agent-memory


Key takeaways

  1. The problem is not that AI writes badly. It’s that AI writes from zero.
    A stateless model has no memory of your older posts, your cadence, your weird little phrases, or what you never say. So it defaults to the internet-average voice, which is why so much AI-written social content feels the same.
  2. Good writing help needs three kinds of memory.
    Episodic memory gives the agent examples of what you’ve written before. Semantic memory gives it a structured style profile. Reflective memory lets that profile evolve as your writing changes.
  3. Oracle AI Database keeps the memory stack simple.
    Posts, vectors, JSON style profiles, and reflection logs all live in the same database. That means the agent can retrieve similar posts, load your voice profile, and update its understanding without stitching together a pile of separate services.
  4. The reflection loop is what makes it feel like learning.
    Every few new posts, the agent compares your current style profile against your latest writing, creates a conservative diff, and updates the profile without overreacting to one weird week of posts.
  5. The actual agent is deceptively small.
    The final generatePost function only needs a style profile, a few similar examples, and one LLM call. The hard part is not the prompt. The hard part is giving the prompt the right memory.

The last time you went on social media, did it feel… stale? Every post you scroll past reads the same with slightly different words. A generic opener starting with “Most X think Y”, three points and a call to action, the same six emojis. Yeah, those were created by AI.

I’m not here to say that all AI-generated content is bad, but we’re definitely seeing a lack of originality these days. Which is a shame, because using generative AI as a tool in the creative process is incredible. But copy/pasting the output of a “write me a LinkedIn post about security issues with AI agents” is the wrong way to go about it.

AI models are stateless. Every time you ask one to write a post for you, it starts from zero. It has no idea what you’ve written before, what worked, what fell flat, or how you sound when you’re not trying. So it falls back on the average… which is exactly what you’re seeing in your feed these days.

But it doesn’t have to be this way. You can still use an AI agent to help you with social posts AND to sound like your natural voice. You just have to give it some memory.

This post walks through how to build an AI agent with three layers of memory backed by Oracle AI Database 26ai, with a reflection loop that updates the agent’s understanding of your voice over time. The stack is TypeScript end-to-end: Node.js backend, React + Vite frontend, the official oracledb driver. If you prefer Python, langchain-oracledb is the direct equivalent.

To learn a little bit more about agent memory, check out this blog by Casius Lee.


What we’re building

Our agent has one job: given a topic and a platform (LinkedIn, X, whatever), drafts a post that sounds like me. Easy enough as a one-shot LLM call. But the cool part is how we make it better over time without changing the prompt.

To do that, the agent needs three different kinds of memory:

LayerWhat it storesHow it’s used
Episodic memoryEvery post I’ve written, embedded as a vectorRetrieve the K most similar past posts as examples
Semantic memoryA structured JSON object describing my voice traitsInject into the system prompt as explicit guidance
Reflective memoryObservations about how my writing style is evolving over timePeriodically refine the semantic memory

All three live in Oracle AI Database, which makes this easier than it looks. Vector search, JSON, and relational rows all live in the same database with the same query engine. So in a single database, we can store everything we need to make this work.

Here’s the loop:

Flowchart showing a writing workflow. A topic and platform feed into generatePost(), which reads a style profile, performs vector search over previous posts, and calls an LLM. After drafting, editing, and publishing, the final post is saved. Saved posts expand episodic memory. Every N new posts, a reflection step compares new posts with the existing style profile, generates updates, and feeds the revised style profile back into future post generation.
Published posts build episodic memory, while periodic reflection updates the style profile used for future content generation.

Setup

If you don’t already have a database, the repo includes a Terraform stack that provisions an Always Free Autonomous AI Database 26ai and writes a populated .env. Just clone the repository and run these three commands:

cd terraform
terraform init && terraform apply
terraform output -raw env_file > ../.env

Always Free covers the cost of the database forever. OCI Generative AI isn’t on the always-free tier, but new accounts get $300 in trial credits, and the per-call cost for what we’re about to build costs pennies. If you already have an Oracle 26ai instance, skip the Terraform and fill in .env by hand.

Then install the dependencies:

npm install

Once dependencies are installed, we’re ready to start building! But before we do that, we should talk about the three tables in our schema, each representing one of the layers of our agentic memory.

-- Episodic memory: every post you've ever written
CREATE TABLE posts (
    id            VARCHAR2(36) PRIMARY KEY,
    user_id       VARCHAR2(64) NOT NULL,
    platform      VARCHAR2(32) NOT NULL,
    topic         VARCHAR2(256),
    content       CLOB NOT NULL,
    embedding     VECTOR(1024, FLOAT32),
    created_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_deleted    NUMBER(1) DEFAULT 0
);

CREATE VECTOR INDEX posts_hnsw_idx ON posts (embedding)
    ORGANIZATION INMEMORY NEIGHBOR GRAPH
    DISTANCE COSINE
    PARAMETERS (TYPE HNSW, NEIGHBORS 32, EFCONSTRUCTION 200);


-- Semantic memory: the style profile per user
CREATE TABLE style_profile (
    user_id       VARCHAR2(64) PRIMARY KEY,
    profile       JSON NOT NULL,
    updated_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    version       NUMBER(10) DEFAULT 1
);

-- Reflective memory: what changed and when
CREATE TABLE reflections (
    id            VARCHAR2(36) PRIMARY KEY,
    user_id       VARCHAR2(64) NOT NULL,
    triggered_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    posts_window  JSON NOT NULL,
    diff          JSON NOT NULL,
    profile_after JSON NOT NULL
);

It’s important to note that VECTOR(1024, FLOAT32) matches OCI’s cohere.embed-english-v3.0 model. If you swap embedding functions, make sure you update the dimension to match. And as a bonus, JSON is a first-class type in Oracle 26ai with indexable paths, so the style profile doesn’t need to be re-parsed on every read.

Before we get to the memory layers themselves, let’s set up a thin wrapper around the OCI SDK for consistency and simplicity. Two functions: embed(), responsible for creating text embeddings from our social posts, and chat(), for communicating with the model.

// src/server/llm.ts
import * as common from 'oci-common';
import { GenerativeAiInferenceClient } from 'oci-generativeaiinference';
const provider = new common.ConfigFileAuthenticationDetailsProvider();
const client = new GenerativeAiInferenceClient({
  authenticationDetailsProvider: provider,
});

const compartmentId = process.env.OCI_COMPARTMENT_ID!;
export async function embed(texts: string[]): Promise<number[][]> {
  const res = await client.embedText({
    embedTextDetails: {
      inputs: texts,
      servingMode: { servingType: 'ON_DEMAND', modelId: 'cohere.embed-english-v3.0' },
      compartmentId
    }
  });

  return res.embedTextResult.embeddings;
}

export async function chat(args: { system: string; user: string }): Promise<string> {
  const res = await client.chat({
    chatDetails: {
      servingMode: { servingType: 'ON_DEMAND', modelId: 'cohere.command-r-plus-08-2024' },
      compartmentId,
      chatRequest: {
        apiFormat: 'COHERE',
        preambleOverride: args.system,
        message: args.user,
        temperature: 0.2,
        maxTokens: 1500
      }
    }
  });

  return res.chatResult.chatResponse.text;
}

ConfigFileAuthenticationDetailsProvider reads ~/.oci/config (the DEFAULT profile) automatically. servingType: 'ON_DEMAND' is the pay-as-you-go mode that uses your trial credits without provisioning a cluster. Everything from here on calls these embed() and chat() functions.


Episodic memory

The first layer is the simplest. Every time I publish a post, I save it. Every time I want to draft a new one, the agent retrieves the K most similar past posts to use as few-shot examples.

// src/server/memory.ts
import { randomUUID } from 'node:crypto';
import { withConn, oracledb } from './db';
import { embed } from './llm';
export async function savePost(args: {
  userId: string; platform: string; topic: string; content: string;
}): Promise<string> {
  const id = randomUUID();
  const [embedding] = await embed([args.content]);
  await withConn(async (conn) => {
    await conn.execute(
      `INSERT INTO posts (id, user_id, platform, topic, content, embedding)
       VALUES (:id, :userId, :platform, :topic, :content, :embedding)`,
      {
        id, userId: args.userId, platform: args.platform,
        topic: args.topic, content: args.content,
        embedding: { type: oracledb.DB_TYPE_VECTOR, val: new Float32Array(embedding) }
      },
      { autoCommit: true }
    );
  });

  return id;
}

Retrieval is a little more nuanced, and we take full advantage of the hybrid filter here. We want to find the most similar posts on the same platform, by this user, that aren’t deleted. To do this, we perform a vector search plus a WHERE clause and are able to get the results we want with a single query.

export async function retrieveSimilarPosts(args: {
  userId: string; platform: string; topic: string; k?: number;
}) {
  const k = args.k ?? 5;
  const [queryEmbedding] = await embed([args.topic]);
  return withConn(async (conn) => {
    const r = await conn.execute<[string, string, string, number]>(
      `SELECT id, content, topic, VECTOR_DISTANCE(embedding, :q, COSINE) AS distance
       FROM posts
       WHERE user_id = :userId AND platform = :platform AND is_deleted = 0
       ORDER BY distance
       FETCH APPROX FIRST :k ROWS ONLY`,
      {
        q: { type: oracledb.DB_TYPE_VECTOR, val: new Float32Array(queryEmbedding) },
        userId: args.userId, platform: args.platform, k,
      }
    );

    return (r.rows ?? []).map(([id, content, topic, distance]) =>
      ({ id, content, topic, distance }));
  });
}

FETCH APPROX FIRST :k ROWS ONLY is what lets Oracle use the HNSW index for approximate nearest neighbor. Without APPROX, the query would fall back to exact scan, which is fine for thousands of vectors, but virtually unusable for millions.


Semantic memory

Episodic retrieval gets you “what have I said about this topic before.” But now we need “how do I sound when I write.” This is the perfect use case for semantic memory.

We keep our “style profile” as a structured JSON object. It stores attributes like voice traits more about the writing rather than in the writing. To capture a good approximation of what you sound like, our profile looks for the following behaviors:

{
  "tone": ["direct", "self-deprecating", "slightly skeptical of hype"],
  "sentenceLength": {
    "averageWords": 16,
    "habit": "short punchy sentences mixed with longer explanatory ones"
  },
  "structuralHabits": [
    "opens with a personal anchor or a small story",
    "uses italics for the one line that should stick",
    "closes posts with a question or a single-word punchline"
  ],
  "signaturePhrases": ["Happy coding!", "Let me explain", "Here's the thing"],
  "thingsINeverDo": [
    "use 'unlock', 'leverage', 'game-changer'",
    "more than two emoji per post"
  ],
  "topicsICareAbout": ["serverless", "AI agents", "developer experience"],
  "platformQuirks": {
    "linkedin": "longer hooks, line breaks every 1-2 sentences",
    "x": "thread-friendly, one idea per tweet"
  }
}

If you’ve ever asked someone to write something like this about themselves, you know people are absolutely terrible at this type of self-reflection. So naturally, we bypass the human element and ask the model to generate it from the first N posts a user adds in the system.

import { chat } from './llm';
const SEED_SYSTEM = `You are a voice analyst. You will read several social media posts by one author and produce a JSON style profile describing how they write. Be specific and concrete. "Tone is friendly" is useless. "Tone is direct, occasionally self-deprecating, slightly skeptical of hype" is useful.

Output ONLY valid JSON matching this schema:
{ 
  "tone": [string], 
  "sentenceLength": {
    "averageWords": int, "habit": string
  },
  "structuralHabits": [string], 
  "signaturePhrases": [string],
  "thingsINeverDo": [string], 
  "topicsICareAbout": [string],
  "platformQuirks": {string: string} 
}`;

export async function seedStyleProfile(userId: string, sampleSize = 20) {
  const rows = await withConn(async (conn) => {
    const r = await conn.execute<[string, string]>(
      `SELECT platform, content FROM posts
       WHERE user_id = :userId AND is_deleted = 0
       ORDER BY created_at DESC FETCH FIRST :n ROWS ONLY`,
      { userId, n: sampleSize }
    );

    return r.rows ?? [];
  });

  const postsText = rows
    .map(([p, c]) => `[${p}] ${c}`).join('\n\n---\n\n');
  const response = await chat({
    system: SEED_SYSTEM,
    user: `Posts:\n\n${postsText}`
  });

  const profile = JSON.parse(response);
  await withConn(async (conn) => {
    await conn.execute(
      `MERGE INTO style_profile sp
       USING (SELECT :userId AS user_id FROM dual) src ON (sp.user_id = src.user_id)
       WHEN MATCHED THEN UPDATE SET
         profile = :profile, updated_at = CURRENT_TIMESTAMP, version = version + 1
       WHEN NOT MATCHED THEN INSERT (user_id, profile) VALUES (:userId, :profile)`,
      { userId, profile: JSON.stringify(profile) },
      { autoCommit: true }
    );
  });

  return profile;
}

The MERGE statement handles both inserts and updates in one round trip (and deletes, it’s pretty impressive). The Oracle JSON type validates the value on insert, and if the LLM emits malformed JSON, the insert fails, which is exactly what we want.

Reading it back is just as few easy lines:

export async function loadStyleProfile(userId: string) {
  return withConn(async (conn) => {
    const r = await conn.execute<[unknown]>(
      `SELECT profile FROM style_profile WHERE user_id = :userId`,
      { userId },
    );

    if (!r.rows?.length) return null;
    return r.rows[0][0] as StyleProfile;
  });
}

Reflective memory

I’ve built many agents where I’m done after implementing episodic and semantic memory. In many instances, that’s good enough. But with this type of workload, aka people writing about what they care about, preferences change over time. Personally, I used to write nothing but dry, cold facts on serverless architectures. Today, I’m a pretty funny guy (right?!) and ponder on things that take software from good to great. Very different styles, but both me.

Building in reflective memory allows the agent to adjust over time. It’s what gives us the impression that it’s actually “learning.”

Every K new posts (I use K=5), we trigger a reflection. The reflection is an LLM call that reads the current style profile and the K newest posts, then creates a structured diff: what’s changed, what should be added, and what should be removed.

We go with the structured diff instead of a straight up overwrite to avoid profile thrashing. You aren’t just your last 5 posts. You’re a summary of everything you’ve ever posted with a recency bias. Asking for a diff lets the model commit to small, intentional updates, so you stay you and don’t appear like you have violent mood swings every week.

const REFLECT_SYSTEM = `You are reviewing how an author's voice may have evolved. You have:

1. Their CURRENT style profile (built from older posts)
2. Their MOST RECENT posts (not yet incorporated)

Read the recent posts and compare to the profile. Decide whether the profile needs updating. Be conservative: most of the time, voice is stable and you should change little or nothing. Only return updates that you can point to specific evidence for in the recent posts.

Output ONLY valid JSON:
{ "additions": [{"field": string, "value": any, "evidence": string}],
  "removals":  [{"field": string, "value": any, "reason": string}],
  "rationale": string 
}

If nothing should change, return empty arrays.`;

export async function reflect(userId: string, windowSize = 5) {
  const profile = await loadStyleProfile(userId);
  if (!profile) return seedStyleProfile(userId);
  const rows = await withConn(async (conn) => {
    const r = await conn.execute<[string, string]>(
      `SELECT id, content FROM posts
       WHERE user_id = :userId AND is_deleted = 0
       ORDER BY created_at DESC FETCH FIRST :n ROWS ONLY`,
      { userId, n: windowSize }
    );

    return r.rows ?? [];
  });

  const postIds = rows.map(([id]) => id);
  const postsText = rows.map(([, c]) => c).join('\n\n---\n\n');
  const response = await chat({
    system: REFLECT_SYSTEM,
    user: `CURRENT PROFILE:\n${JSON.stringify(profile, null, 2)}\n\nRECENT POSTS:\n${postsText}`,
  });

  const diff = JSON.parse(response);
  const updated = applyDiff(profile, diff);
  await withConn(async (conn) => {
    await conn.execute(
      `UPDATE style_profile SET profile = :profile,
         updated_at = CURRENT_TIMESTAMP, version = version + 1
       WHERE user_id = :userId`,
      { profile: JSON.stringify(updated), userId }
    );

    await conn.execute(
      `INSERT INTO reflections (id, user_id, posts_window, diff, profile_after)
       VALUES (:id, :userId, :window, :diff, :after)`,
      {
        id: randomUUID(), userId,
        window: JSON.stringify(postIds),
        diff: JSON.stringify(diff),
        after: JSON.stringify(updated)
      },
      { autoCommit: true }
    );
  });

  return updated;
}

The applyDiff function is simple. It iterates through the additions and removals, and edits the profile object in place. It’s in the repo if you’re interested. I need to point out again that the only reason applyDiff works is because we tell the model to be conservative with its reflections. Remember, we don’t want wild swings in the profile. Your posts won’t make it past the “AI sniff test” if you’re calm and collected one day, and corporate and metric-driven the next.

If that does happen though, we can use the reflection log as a point-in-time snapshot we can rollback to. Just rebuild from a previous profile_after snapshot and the agent effectively “unlearns” that unwanted style.


Agent memory in action

Now that we’ve gone through all three types of memory, it’s time to build the generatePost function and see them all in action as we compose the prompt.

// src/server/agent.ts
import { chat } from './llm';
import { loadStyleProfile, retrieveSimilarPosts } from './memory';
export async function generatePost(args: {
  userId: string; platform: string; topic: string;
}) {
  const profile = (await loadStyleProfile(args.userId)) ?? {};
  const examples = await retrieveSimilarPosts({ ...args, k: 5 });
  const examplesText = examples.map((e) => e.content).join('\n\n---\n\n');
  const system = `You are drafting a social media post in the user's voice.
    STYLE PROFILE (how this user writes):
    ${JSON.stringify(profile, null, 2)}
    
    EXAMPLES (recent posts by this user on similar topics):
    ${examplesText}


    Write ONE draft post. Match the style profile and the cadence of the examples. Do not copy phrases from the examples. Do not mention that you are an AI or that you are following a profile.`;

  const draft = await chat({
    system,
    user: `Platform: ${args.platform}\nTopic: ${args.topic}`,
  });

  return { draft, basedOn: examples };
}

That’s it. That’s the whole thing. This is deceptively simple. It’s doing two database reads to load the profile and perform a vector search, AND it’s making a call to an LLM.

The agent will get better over time. The first time you use it, it will sound like everything else you see on social media these days. But as you edit the drafts and build up the data with examples in your true voice, it gets better and eerily starts sounding like you.


FAQs

Q: Why not just prompt the model to “write in my voice”?
Because the model does not actually know your voice unless you give it evidence. The article uses past posts plus a style profile so the agent has something concrete to imitate instead of guessing.

Q: What does episodic memory do here?
It stores every past post with an embedding. When you ask for a new draft, the agent finds the most similar posts on the same platform and uses them as examples.

Q: What is the style profile?
It is the semantic memory layer: a JSON object that describes how you write, including tone, sentence habits, structural patterns, signature phrases, topics you care about, and things you avoid.

Q: Why use reflective memory instead of just overwriting the profile?
Because you are not just your last five posts. Reflection creates small, evidence-backed updates so the profile can evolve without thrashing every time your writing mood changes.

Q: What happens if the agent learns the wrong style?
The reflection table keeps a history of changes. Since each reflection stores the diff and the resulting profile, you can roll back to an earlier snapshot and effectively make the agent unlearn that bad update.


To summarize

Agent memory has a lot more to it than simply “remembering things.” There are different types of memory that represent similar artifacts, summaries, and long-term audits. To build a production-ready system, you need all three. Episodic and semantic memory to satisfy the business problem, and reflective to improve over time.

Oracle AI Database is the perfect database for these three types of memory. It supports vectors, JSON objects, and similarity search with filtering all in the same database. The schema for this is twenty lines. Reads are three-line functions. The complexity stays out of the database layer and in the prompt engineering where it belongs.

To walk through this project yourself, you can find the code on GitHub. It’s built on TypeScript end-to-end, and is easily portable to whatever your preferred programming language is.

If you try this out, send me what you generate. I want to see how well “sounds like you” holds up across different writers.

Happy coding!