Patterns

Spoiler-safe narrative memory

Ingest full canon; answer only as far as the user has read or watched.

SurrealDB Agent Memory can hold an entire story - novel, series, franchise, training curriculum released in modules - while an agent answers only from what the user has reached so far. The pattern combines three ideas you already have elsewhere in the docs:

  1. observed_at / observedAt on ingest - stamp each chapter, page, or episode with a synthetic known time on the narrative axis (not wall-clock import time).

  2. asOf on recall - the user's current position ("I'm on chapter 8", "I've finished episode V") selects which stamped facts and relations are visible.

  3. labels and lens (optional) - navigate by chapter=3 / page=5, or narrow a query to a scope region without changing permissions.

See Temporal validity for the model; this page is the recipe.

ScenarioWhy bulk ingest breaksWhat you gate
Novel with a late revealHyde/Jekyll-style identity twistRelation edges and attributes stamped at the reveal
TV or film franchiseViewing order ≠ story chronologyPer-episode stamps in the order the user chose
Long book seriesReader is on book 3 of 14asOf at book 3's end - later books ingested but hidden
Policy / curriculum modulesUser certified on module 2 onlyModule-scoped stamps + labels

The Strange Case of Dr Jekyll and Mr Hyde is a useful mental model: the whole plot is a single-entity reveal. Ingest each chapter (or page) with its own synthetic observedAt, monotonically increasing through the book. Stamp the Hyde↔Jekyll same_as relation only when you ingest the reveal chapter.

  • Query with asOf at “chapter 8” → Hyde and Jekyll stay separate in the graph; no spoiler link.

  • Query with asOf at “book finished” → the same_as edge is visible.

Same reader, same permissions - only the asOf instant changes.

Release order and story order are different problems with the same mechanism: stamps follow discovery order, not calendar dates.

Star Wars (release order IV → V → VI, then I → II → III)

Ingest each film with observedAt (or per-scene triples with observed_at) on a timeline that matches when a release-order viewer learns each fact. "Darth Vader is Luke's father" gets a stamp after Empire - not after A New Hope. A separate reader profile watching I → II → III first would use a different stamp sequence on the same ingested records (or separate scope paths per viewing track), because for this viewer there is no spoiler in Episode V that Anakin Skywalker and Darth Vader are the same person. However, a viewer following this path does have a reveal in Episode III that Anakin is no longer a hero, a fact that a user watching in release order would already be aware of when viewing the prequels.

Wheel of Time (reader on book 3)

Ingest all books if you want one substrate, but stamp facts extracted from book N with monotonically increasing known times per book (or per chapter). Recall with asOf set to "end of book 3" so prophecies, deaths, and alliances from later books stay out of answers.

POST /api/v1/{context_id}/facts
Content-Type: application/json

{
  "text": "… excerpt from The Dragon Reborn …",
  "infer": "full",
  "scopes": [["org/my-app/reader/alice"]],
  "observed_at": "2000-10-15T00:00:00Z",
  "labels": ["book=3", "chapter=12"]
}
POST /api/v1/{context_id}/query
Content-Type: application/json

{
  "query": "Who is Rand al'Thor allied with?",
  "k": 10,
  "asOf": "2000-10-15T00:00:00Z",
  "lens": [["org/my-app/reader/alice"]],
  "labels": ["book=3"]
}

Adjust the synthetic timeline to your ordering scheme; the important part is that later books never share an earlier stamp.

For PDFs or markdown split into pages:

spectron documents upload ./chapters/ch08.txt \
  --scope org/my-app/reader/alice \
  --label chapter=8 --label page=5 \
  --as-of 1886-03-01T00:00:00Z

Metadata JSON equivalent:

{
  "title": "Dr Jekyll and Mr Hyde - ch. 8",
  "scopes": [["org/my-app/reader/alice"]],
  "labels": ["chapter=8", "page=5"],
  "observedAt": "1886-03-01T00:00:00Z"
}

Then query with matching asOf and optional labels for passage-only navigation.

While SurrealDB Agent Memory reconciles repeated mentions into a single state, it does not count them. Re-asserting the same fact does not increment a tally you can read back, and importance is a retrieval weight, not a mention count.

When you need a per-position measure - how prominent a character is by chapter N - compute it during ingest and store it as an ordinary attribute stamped with that chapter's known time. Each chapter's write supersedes the previous value, so the attribute becomes one supersession chain per character, and asOf returns the count as it stood at that point in the story.

The following example shows how this would be done with characters from the first few chapters of the fantasy series The Wheel of Time in order to track the importance of characters as the book progresses.

Write one batch per chapter. Use infer: "triples" to store the values verbatim, with no LLM extraction:

POST /api/v1/{context_id}/facts
Content-Type: application/json

{
  "infer": "triples",
  "observed_at": "2000-01-08T00:00:00Z",
  "triples": [
    { "entity": { "type": "person", "name": "Moiraine" }, "key": "mention_count", "value": "40" },
    { "entity": { "type": "person", "name": "Lan" }, "key": "mention_count", "value": "20" }
  ]
}

Read the value at any reader position:

curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities/person/moiraine?asOf=2000-01-08T00:00:00Z" \
  -H "authorization: Bearer $SPECTRON_API_KEY"

The character Moiraine shows up in the book after the first few chapters, with a mention_count that quickly accelerates once she is first introduced:

Reader positionasOfmention_count
Chapter 52000-01-06T00:00:00Z28
Chapter 72000-01-08T00:00:00Z40
Chapter 82000-01-09T00:00:00Z85

Decide two things before you start:

  • Cumulative or per unit. Cumulative totals only grow, and read as “prominence so far”. Per-chapter counts rise and fall, and read as “prominence in this chapter”. Store both under different keys if you need both - each key gets its own chain.

  • Counting is yours. SurrealDB Agent Memory stores the number you supply. Aliases, epithets, and pronouns are a counting problem to solve before the write.

Batch every character for one chapter into a single request. One request per character multiplies the write count for no benefit.

Warning

The entity type is half the identity, not a label. An entity's record id is the composite [type, normalised_name], and an unrecognised type falls back to other instead of failing. A counter written as {"type": "creature", "name": "Trolloc"} lands on other/trolloc, while the same character extracted from a document as a person is a separate entity with a separate chain. Use a type from the closed vocabulary - person, organisation, project, location, topic, product, policy, concept, event, agent, service, other - and use the same one every time you write the counter.

Note

One write per chapter per character adds one row to that character's chain per chapter. That is the intended shape - history is kept, not overwritten - but keep content volume in mind as well: a 700-chapter series produces a 700-row chain for every character you track.

Entity identity is a composite [type, normalised_name], and matching on the write path is an exact record-id lookup. There is no fuzzy, phonetic, or embedding-based matching, no alias field, and no merge operation.

A narrative works against this. Characters are introduced in full and then referred to informally, so extraction mints each surface form it meets as its own entity: Mat and Matrim Cauthon become two people, as do Egwene and Egwene al'Vere. A misspelling in the source text becomes a third.

same_as does not fix this. It is an ordinary relation label - it records the claim that two entities are the same, and nothing reconciles them into one row.

Some ways to work with name variants:

Detect it. A hygiene sweep reports near-identical entities above a cosine threshold:

POST /api/v1/{context_id}/fsck
Content-Type: application/json

{ "check": "duplicates", "duplicateThreshold": 0.95, "maxResults": 100 }

This reports, but does not merge.

Prevent it. Extraction is shown the entities the context already holds and told to reuse their exact names, so writing your canonical cast before a bulk ingest makes later mentions attach to the rows you chose. Seed the names you care about first, in the type you intend to keep.

asOf answers "what was true at this point". Rebuilding an entire timeline - every character at every chapter, to drive an export or a visualisation - by calling asOf once per character per position costs one request per cell. Three hundred characters across seven hundred chapters is 210,000 requests for a single attribute.

Ask for the chain instead. A single call returns the full supersession chain for one attribute key:

curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities/person/moiraine/history/mention_count" \
  -H "authorization: Bearer $SPECTRON_API_KEY"

Every row carries its value, its createdAt, and supersedes / supersededBy links. Because observed_at on ingest sets created_at as the row's known time - the same field asOf walks when it resolves a chain - createdAt here is the chapter's narrative stamp, not the wall-clock moment you uploaded it. The chain is therefore already the per-position series, in order.

That turns one request per character per position into one request per character per key. The same three hundred characters and one key: three hundred requests.

To enumerate the cast, list entities and page through them - the listing is unpaginated by default and capped at 500, so pass limit and offset for anything larger:

curl "$SPECTRON_HOST/api/v1/$CONTEXT_ID/entities?type=person&limit=500&offset=0" \
  -H "authorization: Bearer $SPECTRON_API_KEY"

On a single full-access reader you can combine three independent slices:

NarrowingQuestionMechanism
TimeHow far have I read?asOf - gates facts and relations by known time
Scope lensLimit this query to chapters 1-8lens: [["chapter/1"], …] - involvement filter within grant
LabelsWhat's on page 3?labels: ["page=3"] - row filter; use with include: ["passages"] for text

Use time for spoiler boundaries on the graph; use labels for locating content within what time already allows.

  • Passages are not reading-time-gated by asOf alone - they retain upload-time metadata. Rely on the structured graph for spoiler-safe answers, or filter passages with labels.

  • asOf on /query walks known-time on attributes and relations; pair with Temporal validity for valid_from / valid_until on in-world dates.

  • Response caching is bypassed when temporal filters are present - correct for narrative playback, slightly higher latency.

Was this page helpful?