DEV Community

Cover image for Build a Temporal Memory Resolver for Stateful AI Chat in TypeScript
Aihh
Aihh

Posted on

Build a Temporal Memory Resolver for Stateful AI Chat in TypeScript

A vector search result is not a memory decision.

In a stateful AI chat, the nearest text may be obsolete, belong to another character, describe a scene that ended yesterday, or repeat a detail the user asked the system to forget.

This tutorial builds a small temporal memory resolver in TypeScript. Its job is to decide which records are eligible before semantic relevance and prompt budgeting happen.

Temporal memory resolver pipeline for stateful AI chat

Define records around decisions

Start with explicit states and scopes:

type MemoryScope =
  | { kind: "user"; userId: string }
  | { kind: "character"; userId: string; characterId: string }
  | { kind: "conversation"; conversationId: string }
  | { kind: "scene"; conversationId: string; sceneId: string }

type MemoryStatus =
  | "active"
  | "superseded"
  | "expired"
  | "forgotten"

type MemoryAuthority =
  | "model-inferred"
  | "rule-extracted"
  | "user-confirmed"
  | "user-corrected"

type MemoryRecord = {
  id: string
  key: string
  value: string
  scope: MemoryScope
  status: MemoryStatus
  authority: MemoryAuthority
  confidence: number
  validFrom: Date
  validUntil?: Date
  sourceMessageIds: string[]
  supersedes?: string
  version: number
}
Enter fullscreen mode Exit fullscreen mode

The key represents a semantic slot such as pet.name, preferred.address, or story.lantern.status. The scope prevents a record from becoming globally active merely because its text is similar to the current message.

Represent the current retrieval scope

type RetrievalContext = {
  now: Date
  userId: string
  characterId: string
  conversationId: string
  sceneId?: string
}
Enter fullscreen mode Exit fullscreen mode

Eligibility is a pure function:

function scopeMatches(
  scope: MemoryScope,
  context: RetrievalContext,
): boolean {
  switch (scope.kind) {
    case "user":
      return scope.userId === context.userId
    case "character":
      return (
        scope.userId === context.userId &&
        scope.characterId === context.characterId
      )
    case "conversation":
      return scope.conversationId === context.conversationId
    case "scene":
      return (
        scope.conversationId === context.conversationId &&
        scope.sceneId === context.sceneId
      )
  }
}
Enter fullscreen mode Exit fullscreen mode

Keep this check outside the embedding database when possible. Database filters can reduce candidates, but application-level tests should still prove the final decision.

Exclude terminal and out-of-time records

function isTimeEligible(record: MemoryRecord, now: Date): boolean {
  if (record.status !== "active") return false
  if (record.validFrom > now) return false
  if (record.validUntil && record.validUntil <= now) return false
  return true
}
Enter fullscreen mode Exit fullscreen mode

Notice that expiry can be calculated without immediately rewriting the stored status to expired. A background process may normalize statuses later; prompt correctness should not depend on that worker having run.

Resolve explicit supersession edges

A correction should point to what it replaces:

function removeSuperseded(records: MemoryRecord[]): MemoryRecord[] {
  const supersededIds = new Set(
    records.flatMap(record =>
      record.supersedes ? [record.supersedes] : [],
    ),
  )

  return records.filter(record => !supersededIds.has(record.id))
}
Enter fullscreen mode Exit fullscreen mode

This handles a known correction chain, but data can still contain two active records for the same key. The resolver needs precedence.

Make authority ordering explicit

const authorityRank: Record<MemoryAuthority, number> = {
  "model-inferred": 0,
  "rule-extracted": 1,
  "user-confirmed": 2,
  "user-corrected": 3,
}

function compareMemory(a: MemoryRecord, b: MemoryRecord): number {
  return (
    authorityRank[b.authority] - authorityRank[a.authority] ||
    b.version - a.version ||
    b.validFrom.getTime() - a.validFrom.getTime() ||
    a.id.localeCompare(b.id)
  )
}
Enter fullscreen mode Exit fullscreen mode

The final ID comparison is not business meaning. It is a deterministic tie-breaker. The same stored state must produce the same winner on every worker.

Choose one active value per scoped key

Two records with the same key may both be valid if their scopes differ. Build a stable scope identifier:

function scopeId(scope: MemoryScope): string {
  switch (scope.kind) {
    case "user":
      return `user:${scope.userId}`
    case "character":
      return `character:${scope.userId}:${scope.characterId}`
    case "conversation":
      return `conversation:${scope.conversationId}`
    case "scene":
      return `scene:${scope.conversationId}:${scope.sceneId}`
  }
}

function chooseWinners(records: MemoryRecord[]): MemoryRecord[] {
  const groups = new Map<string, MemoryRecord[]>()

  for (const record of records) {
    const groupKey = `${scopeId(record.scope)}:${record.key}`
    groups.set(groupKey, [...(groups.get(groupKey) ?? []), record])
  }

  return [...groups.values()].map(group =>
    [...group].sort(compareMemory)[0],
  )
}
Enter fullscreen mode Exit fullscreen mode

Compose the resolver

type Resolution = {
  active: MemoryRecord[]
  excluded: Array<{
    id: string
    reason:
      | "scope"
      | "status-or-time"
      | "superseded"
      | "lower-precedence"
  }>
}

function resolveMemory(
  records: MemoryRecord[],
  context: RetrievalContext,
): Resolution {
  const excluded: Resolution["excluded"] = []

  const scoped = records.filter(record => {
    const keep = scopeMatches(record.scope, context)
    if (!keep) excluded.push({ id: record.id, reason: "scope" })
    return keep
  })

  const timed = scoped.filter(record => {
    const keep = isTimeEligible(record, context.now)
    if (!keep) {
      excluded.push({ id: record.id, reason: "status-or-time" })
    }
    return keep
  })

  const supersededIds = new Set(
    timed.flatMap(record =>
      record.supersedes ? [record.supersedes] : [],
    ),
  )

  const unsuperseded = timed.filter(record => {
    const keep = !supersededIds.has(record.id)
    if (!keep) excluded.push({ id: record.id, reason: "superseded" })
    return keep
  })

  const active = chooseWinners(unsuperseded)
  const activeIds = new Set(active.map(record => record.id))

  for (const record of unsuperseded) {
    if (!activeIds.has(record.id)) {
      excluded.push({ id: record.id, reason: "lower-precedence" })
    }
  }

  return { active, excluded }
}
Enter fullscreen mode Exit fullscreen mode

The result is a testable plan. Embedding similarity, recency weighting, and token budgeting can operate on active afterward.

Add forgetting tombstones

Deleting a vector is not enough if an older conversation summary can recreate the same fact.

type ForgetTombstone = {
  key: string
  scopeId: string
  forgottenAt: Date
  blocksSourcesBefore: Date
}

function blockedByTombstone(
  record: MemoryRecord,
  tombstones: ForgetTombstone[],
): boolean {
  return tombstones.some(tombstone =>
    tombstone.key === record.key &&
    tombstone.scopeId === scopeId(record.scope) &&
    record.validFrom <= tombstone.blocksSourcesBefore,
  )
}
Enter fullscreen mode Exit fullscreen mode

The tombstone should not contain the forgotten value. It records control intent: old evidence must not silently reconstruct that key.

Whether your system may retain a tombstone, and for how long, is a product and privacy-policy decision. The technical requirement is that the user-visible forget action match the real retrieval behavior.

Test the uncomfortable cases

import { describe, expect, it } from "vitest"

describe("resolveMemory", () => {
  it("prefers a direct correction over an inferred value", () => {
    const result = resolveMemory(
      [inferredHarbor, correctedMarlowe],
      context,
    )

    expect(result.active.map(record => record.value))
      .toEqual(["Marlowe"])
    expect(result.excluded)
      .toContainEqual({ id: inferredHarbor.id, reason: "superseded" })
  })

  it("excludes a scene fact after expiry", () => {
    const result = resolveMemory([raincoatScene], {
      ...context,
      now: new Date("2026-07-31T00:00:00Z"),
    })

    expect(result.active).toEqual([])
  })

  it("does not leak a character-scoped fact", () => {
    const result = resolveMemory([captainRowan], {
      ...context,
      characterId: "different-character",
    })

    expect(result.active).toEqual([])
  })
})
Enter fullscreen mode Exit fullscreen mode

Also test:

  • correction chains with three or more versions;
  • equal timestamps and versions;
  • an expired correction over an older active value;
  • repeated retries inserting the same logical correction;
  • missing source messages;
  • a forgotten key reappearing in a generated summary;
  • a conversation migrated between storage systems;
  • clock differences between workers.

Keep provenance out of the prompt when it is not needed

The resolver needs source IDs and authority metadata. The model usually does not need all of it.

Build a narrow prompt projection:

type PromptMemory = {
  key: string
  value: string
  certainty: "confirmed" | "likely" | "uncertain"
}

function toPromptMemory(record: MemoryRecord): PromptMemory {
  return {
    key: record.key,
    value: record.value,
    certainty:
      record.authority === "user-corrected" ||
      record.authority === "user-confirmed"
        ? "confirmed"
        : record.confidence >= 0.85
          ? "likely"
          : "uncertain",
  }
}
Enter fullscreen mode Exit fullscreen mode

Uncertain memory should influence wording. It should not be presented as a confident fact simply because it survived filtering.

Observe exclusions without logging private text

Useful trace fields include:

memory_candidates=24
memory_active=5
excluded_scope=8
excluded_expired=3
excluded_superseded=2
excluded_precedence=6
resolver_version=2026-07-30
Enter fullscreen mode Exit fullscreen mode

IDs, counts, reasons, and hashes are often enough to debug the resolver. Avoid logging raw companion messages by default.

Final takeaway

Stateful AI memory is not β€œvector search plus a long context window.” It is a temporal data-resolution problem followed by retrieval.

Resolve scope. Remove terminal records. Apply time. Follow correction edges. Choose deterministic winners. Respect forgetting. Preserve provenance.

Only then ask which remaining memories are relevant to the current reply.


Affiliation and AI-assistance disclosure: I work with the LumiChat team, where long-running character conversations make memory lifecycle bugs visible quickly. This tutorial is vendor-neutral and does not depend on LumiChat infrastructure. AI tools assisted with editing and diagram production; the code and final claims were reviewed by the author.

Top comments (0)