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.
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
}
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
}
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
)
}
}
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
}
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))
}
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)
)
}
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],
)
}
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 }
}
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,
)
}
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([])
})
})
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",
}
}
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
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)