Run temporal queries¶
Use temporal queries to see how stored threat intelligence changes over time.
ZettelForge 2.8.0 records automatic supersession as a SUPERSEDES edge from
the newer note to the older note. You can list those changes, inspect one
note's timeline, and choose whether recall returns current or historical notes.
In progress
Automatic supersession creates note-to-note history, but it does not
populate actor or other entity timelines. In the verified example below,
mm.store.get_entity_timeline("intrusion_set", "apt28") returns [].
Native timeline_analysis synthesis is also in progress; ZettelForge
2.8.0 falls back to direct_answer when you request that format.
Prerequisites¶
- You use ZettelForge 2.8.0.
- You completed the Quickstart.
- You use the SQLite backend, which is the default backend.
- You have two related notes in the same domain.
The examples use a fictional APT28 scenario for training. They do not make a current threat-intelligence claim.
Set these variables in the terminal where you run the examples:
export ZETTELFORGE_BACKEND=sqlite
export ZETTELFORGE_ENRICHMENT_ENABLED=false
This keeps the task on the verified SQLite path and skips background LLM enrichment. Recall still uses the configured local embedding model.
1. Store related notes¶
Store an older report, then store a newer report that shares the same actor and replaces part of the assessment.
from zettelforge.memory_manager import MemoryManager
mm = MemoryManager()
note1, status1 = mm.remember(
content="APT28 used Cobalt Strike for C2 in January 2026 campaigns targeting EU governments.",
source_type="report",
source_ref="vendor-report-jan-2026",
domain="cti",
)
note2, status2 = mm.remember(
content="APT28 shifted to Sliver C2 in March 2026, replacing Cobalt Strike in EU operations.",
source_type="report",
source_ref="vendor-report-mar-2026",
domain="cti",
)
print(status1, status2)
print(mm.store.get_note_by_id(note1.id).links.superseded_by == note2.id)
The verified run prints:
created created
True
The second remember() call detects the shared actor, marks the older note as
superseded, and creates the graph edge. You do not configure the supersession
check per call.
2. List changes after a timestamp¶
Query the active store that belongs to mm:
changes = mm.store.get_changes_since("2000-01-01T00:00:00")
for change in changes:
print(
f"[{change['timestamp']}] "
f"{change['from']} --{change['relationship']}--> "
f"{change['to']}"
)
Each result contains these keys:
| Key | Type | Meaning |
|---|---|---|
timestamp |
str |
The edge's created_at value. |
from |
str |
The source node as "type:value". |
relationship |
str |
SUPERSEDES or a TEMPORAL_* relationship. |
to |
str |
The target node as "type:value". |
The SUPERSEDES result points from note2 to note1: the newer stored
assertion replaces the older one. Pass consistently formatted ISO-8601 values
because the SQLite query compares the stored timestamp text with your input.
3. Inspect the note timeline¶
timeline = mm.store.get_entity_timeline("note", note2.id)
for entry in timeline:
print(f"[{entry['timestamp']}] {entry['edge']['relationship']} -> {entry['to_entity']}")
entity_timeline = mm.store.get_entity_timeline("intrusion_set", "apt28")
print(entity_timeline)
The note timeline contains one SUPERSEDES edge to the older note. The entity
timeline prints [] in ZettelForge 2.8.0 because the automatic write path
records replacement between stored assertions, not between actor nodes. This
note-level model preserves which source assertion changed instead of rewriting
the actor as if the earlier evidence never existed.
4. Recall current and historical notes¶
recall() hides superseded notes by default. Set exclude_superseded=False
when you need the historical record.
current_notes = mm.recall(
query="How has APT28 tooling changed since January 2026?",
domain="cti",
k=10,
)
history = mm.recall(
query="How has APT28 tooling changed since January 2026?",
domain="cti",
k=10,
exclude_superseded=False,
)
print(f"Current notes: {len(current_notes)}")
for note in history:
status = "SUPERSEDED" if note.links.superseded_by else "CURRENT"
print(f"[{status}] {note.content.raw[:72]}")
The verified run returns one current note and both notes in history. The
phrases changed and since classify this query as temporal. See
Retrieval policies for the policy weights
and the complete temporal keyword list.
5. Generate an answer from the recalled history¶
Use the verified direct_answer format:
result = mm.synthesize(
query="Timeline of APT28 tool changes in 2026",
format="direct_answer",
k=10,
)
print(result["format"])
print(result["synthesis"]["answer"])
The first line prints direct_answer. The generated answer depends on your
configured LLM provider. Check result["synthesis"]["confidence"] before you
use the answer; the controlled run with the mock provider returned a
zero-confidence fallback.
Do not request timeline_analysis in ZettelForge 2.8.0. That request returns
the direct_answer format instead of a timeline-shaped result.
If you are creating the notes from a report, pass its publication date during
ingest. Follow Ingest a news report for the verified
remember_report(..., published_date=...) workflow.
Working API calls¶
| Goal | API |
|---|---|
| List store-backed temporal changes | mm.store.get_changes_since(iso_timestamp) |
| Inspect note supersession history | mm.store.get_entity_timeline("note", note_id) |
| Include superseded notes in recall | mm.recall(..., exclude_superseded=False) |
| Check whether a note is superseded | note.links.superseded_by |
| Generate an answer | mm.synthesize(..., format="direct_answer") |