Skip to content

Core API

Everything below is importable from the top-level package and works with the zero-dependency install:

python
from wikimem import (
    MemoryStore, MemoryIndex, Journal, Diary,
    RecallItem, DiaryItem, WikiLink, RetrievalResult, RetrievedItem,
    tokenize, est_tokens, parse_wiki_links,
    validate_file, sanitize_item_name,
)

The optional embedding layer lives in wikimem.vectors and is documented separately — it is deliberately not re-exported here, so importing wikimem never touches numpy.

MemoryStore

python
MemoryStore(root: Path | str)

Read/write access to a store's wiki RecallFiles, which live as markdown files under root / "wiki/". Creating the store does not touch the filesystem; directories appear on first write. The store owns a Journal at root / "journal.jsonl", and exposes the event-stream primitive at store.diary (which shares that journal).

Reads

Reads are tolerant by design — hand-edited files must never crash a read (exact parsing rules in On-disk Format).

methodreturns
files()sorted RecallFile names — one per *.md file in root / "wiki/"
items(file=None)all items, or one RecallFile's
get(file, name)the item, or None (name is whitespace-normalized before comparing)

Writes

Writes are strict (validated names) and atomic (temp file + os.replace per RecallFile), and each appends one journal line.

python
store.add(
    "preferences",            # file: lowercase slug (validated)
    "likes-the-sea",          # item name (sanitized)
    "喜欢海边。[[daily_life:beach-trip-plan]]",
    owner="user:xnne",        # optional provenance
    source_conv="conv_001",   # optional provenance
    ts=None,                  # optional ISO-8601; defaults to now (UTC)
) -> RecallItem
  • add inserts or replaces: an existing item with the same name is overwritten, and the journal records update instead of add. This is the update model — there is no separate update().
  • remove(file, name, *, owner=None) -> boolFalse if the name wasn't present. Removing a RecallFile's last item deletes its file.
  • Raises ValueError for an invalid RecallFileslug or reserved characters in the item name (see below). Content is stored strip()ed.

revision

An integer bumped on every successful in-process write; MemoryIndex uses it to rebuild lazily. Out-of-band file edits do not bump it — call index.rebuild() after those. Diary writes do not bump it — the wiki BM25 index is not built over diary files.

Diary

python
store.diary            # -> Diary, lazily constructed, shares the store's journal
Diary(root, *, journal=None)   # or construct standalone

The event-stream primitive (ADR-0001): where the wiki is state ("what is true now"), the diary is events ("what happened, and when"). Entries live as ## HH:MM sections in per-day files root / "diary" / "YYYY-MM-DD.md", in the same serialization as wiki items (exact rules in On-disk Format).

Writes

python
store.diary.append(
    "他说换了工作,语气很兴奋。[[work:current-job]]",
    ts=None,          # optional ISO-8601 instant; defaults to now (UTC)
    date=None,        # optional YYYY-MM-DD; defaults to ts rendered in tz
    time=None,        # optional HH:MM;     defaults to ts rendered in tz
    owner=None,       # optional provenance
    source_conv=None, # optional provenance
    tz=None,          # zone for the default date/time (default: system local)
) -> DiaryItem

Append-only — this is the only write. There is deliberately no edit or delete: entries are only ever added, and the journal records one diary line per append. Two events may share a minute; both are kept (unlike the wiki's last-wins). Raises ValueError on empty content or a malformed date / time / ts.

Reads

methodreturns
day(date)entries for one YYYY-MM-DD, in chronological (file) order
window(start, end)entries across the inclusive [start, end] date range, chronological (a reversed pair is swapped)
dates()every day that has a file, ascending

ts is stored as a normalized UTC ISO-8601 second-precision string; date / time are the human-local day and wall clock. Raises ValueError on a malformed ts (no silent fallback to "now"). window is the O(days) file-set lookup ADR-0002's time gate builds on — the diary offers only the range, no scoring.

Memorize

python
memorize(
    diary, turn, *,
    llm,                          # your LLM (see the port below)
    character="the assistant",    # interpolated into the prompt
    prompt=None,                  # replaces DIARY_PROMPT wholesale
    owner=None, source_conv=None, # provenance, passed to append
    **append_kwargs,              # e.g. date= / time= / ts=
) -> list[DiaryItem]

Turns a conversation turn into diary entries with one LLM call: run the prompt, parse the reply, append. The framework owns prompt + parsing + validation; the host owns the LLM (ADR-0005). Returns the appended entries — [] when nothing was worth keeping, which is the common, healthy case.

Fail-open: fenced JSON is unwrapped, a bare object is accepted, and prose or malformed JSON yields [] — never an exception. Guide, with the full prompt and wiring: Writing the Diary.

The LLM port

python
class LLM(Protocol):
    def chat(self, messages: list[dict[str, str]]) -> str: ...

chat_completion-shaped and synchronous — the one protocol every provider and gateway speaks, and the smallest thing that can work. wikimem never constructs a client, holds a key, or picks a provider; you implement chat() over the client you already have. Async scheduling is the host's job: run memorize() in a background task so it never delays a turn.

DIARY_PROMPT

The bundled reference prompt (English instructions; entries are written in the conversation's language). Override per call with prompt= — that one parameter is why wikimem ships a single default instead of a per-language matrix.

The diary tool

python
diary_tool() -> dict                  # function-call schema for append_diary(content)
handle_diary_tool(
    diary, args, **append_kwargs,     # args: JSON string or already-parsed dict
) -> DiaryItem

The second memorize mode (ADR-0005): instead of extracting after the turn, the character calls a tool during it. Register diary_tool() with your agent and route append_diary calls to handle_diary_tool().

Zero LLM calls — the agent already wrote the content, so the handler only validates and appends. The schema exposes only content: a model has no clock, so date / time / owner come from the host as keyword arguments and are forwarded to Diary.append.

Raises, unlike memorize(). Invalid JSON, a non-object, a missing or non-string content, or any argument beyond content raises ValueError. memorize() returning [] means "nothing worth keeping"; a broken tool call means the character tried to save something and it did not land — so the message is phrased to be handed straight back as the tool result. Guide, with the full agent loop: Writing the Diary.

DIARY_TOOL_DESCRIPTION

The tool's description, carrying the same style rules as DIARY_PROMPT — one recipe for both modes, so they cannot drift into different voices.

Naming helpers

python
validate_file(file: str) -> str    # raises ValueError if invalid
sanitize_item_name(name: str) -> str       # raises ValueError if invalid
  • RecallFile names must match [a-z0-9_][a-z0-9_-]* — lowercase ASCII slugs, because they double as filenames and link prefixes.
  • Item names may be any language; whitespace runs collapse to single spaces; the characters [[, ]], :, |, # are rejected (they would break headings, links, or metadata).
python
@dataclass
class RecallItem:                   # wiki: the retrieval unit (state)
    file: str
    name: str
    content: str
    owner: str | None = None        # None for hand-written items — tolerated
    source_conv: str | None = None
    ts: str | None = None           # ISO-8601 UTC string

    @property
    def links(self) -> list[WikiLink]   # parsed from content on access
python
@dataclass
class DiaryItem:                   # diary: one event (parallel to RecallItem)
    date: str                       # YYYY-MM-DD — the day file
    time: str                       # HH:MM — the heading (human-local wall clock)
    content: str
    owner: str | None = None
    source_conv: str | None = None
    ts: str | None = None           # ISO-8601 UTC instant

    @property
    def links(self) -> list[WikiLink]   # same wiki-link parsing as RecallItem
python
@dataclass(frozen=True)
class WikiLink:
    file: str
    name: str
    def render(self) -> str    # "[[file:name]]"

parse_wiki_links(text: str) -> list[WikiLink] extracts links in order of appearance; malformed links are ignored, not errors.

MemoryIndex

python
MemoryIndex(
    store: MemoryStore,
    *,
    use_jieba: bool | None = None,     # None = auto-detect the [zh] extra
    embedder = None,                   # activates fusion — see Vectors API
    vectors_dir: Path | str | None = None,  # vector cache location, default: store root
    fusion_weight: float = 0.5,        # BM25 share of the fused score
    binary_threshold: int = 10_000,    # memmap tier switch — see Vectors API
)

BM25 (+ optional embedding fusion) over a MemoryStore. The BM25 index is in-memory derived state: built on first use, rebuilt automatically when store.revision changes, never persisted.

  • rebuild() — rescan the store now. Needed only after out-of-band file edits; cheap at personal-memory scale.
  • retrieve(query, *, limit=10, budget_tokens=None, expand_links=True, explain=False, time_range=None, tz=None) -> RetrievalResult — rank, expand one hop, trim to budget. Zero LLM calls, synchronous, never raises for a degraded embedding path. Semantics: Retrieval.

The time gate

python
index.retrieve("前天晚上吃了什么")                              # window parsed from the query
index.retrieve("吃了什么", time_range=("2026-07-22", "2026-07-22"))  # or passed explicitly

A window brings the diary entries of those days into the same ranking as the wiki. Time filters candidates — it never scores them, so the fusion formula is untouched (ADR-0002).

parammeaning
time_rangeinclusive ("YYYY-MM-DD", "YYYY-MM-DD"). This is the exit of a host's own intent recognition or tool call
tzthe calendar relative words resolve against (default: system local, matching how diary files are named)
  • Two ways in. Pass time_range, or let the regex fast path find one in the query (昨天 / 前天 / 上周三 / 3天前 / 7月21号 / ISO dates — see parse_time_range). It is deliberately narrow: unrecognized means no time intent, never a guess.
  • The wiki is never time-filtered. The timeline belongs to the diary, so standing facts keep competing — "海边" can return both that day's event and a standing preference.
  • Empty query + a window returns that window, newest first — recalling a day without keywords.
  • An empty window relaxes by a day either side rather than answering "nothing", and reports it (time_range_widened).
  • Without a window, behaviour is exactly as before and the diary stays out of retrieval entirely.

RetrievalResult

fieldtypemeaning
itemslist[RetrievedItem]survived the budget, in injection order
budget_tokensint | Nonethe cap that was applied (None = uncapped)
budget_usedintestimated tokens of items
embedding_usedboolTrue only when the cosine path actually ran
droppedlist[RetrievedItem]what the budget cut — populated only with explain=True
unresolved_linkslist[str]rendered links whose target is missing, e.g. "[[a:b]]"
time_rangetuple[str, str] | Nonethe window actually applied (None = no gate)
time_range_sourcestr | None"explicit" (you passed it) or "parsed" (regex fast path)
time_range_widenedboolthe window held nothing, so it was relaxed by a day either side

A diary entry that surfaces through the gate arrives as a RecallItem with file = the day ("2026-07-21") and name = the time ("14:30") — the day file is its RecallFile, exactly as wiki/preferences.md is file="preferences" (ADR-0006). So it ranks, expands links, and gets budget-trimmed like any other item, with no synthetic "diary" bucket. The bridge is public as as_recall_item(entry).

RetrievedItem

fieldtypemeaning
itemRecallItemthe memory itself
sourcestr"hit" (search match) or "link" (one-hop expansion)
scorefloat | Noneranking score: fused when embedding ran, else BM25; None for links
bm25_scorefloat | Noneraw BM25 component (hits only)
cos_scorefloat | Noneraw cosine component (hits, fusion runs only)
viastr | Nonefor links: name of the hit that pulled this in
matched_termslist[str]query terms present in this item (sorted)
tokens_estintbudget cost of this entry

Journal

python
Journal(path: Path | str)

journal.append(action, *, file, name,
               owner=None, source_conv=None, detail=None)   # wiki mutations
journal.append_diary(*, date, time, owner=None, source_conv=None)  # diary appends
journal.entries() -> list[dict]

Append-only JSONL log, shared by both primitives. MemoryStore writes it automatically (add / update / remove), and Diary.append writes the diary line — you rarely construct one yourself. Line schema: On-disk Format.

parse_time_range

python
parse_time_range(text, *, tz=None, today=None) -> tuple[str, str] | None

The regex fast path behind the time gate: turns a time expression into an inclusive ("YYYY-MM-DD", "YYYY-MM-DD") window, or None when there is none. Pure stdlib — no dateparser / arrow / TimeNLP.

Recognized: 今天 昨天 前天 大前天 明天 后天, N天前 (incl. 三天前), N days ago, 上周三 / 这周五, 上周 / 这周, N周前, 上个月 / 这个月, 2026-07-21, 2026/7/1, 7月21号. English today / yesterday / tomorrow need word boundaries.

Narrow beats wrong. Expressions with no defensible boundary — 最近, 前几天, 以前 — return None on purpose. A wrong window silently hides the right memory, which is worse than no window, because the caller never learns the search was filtered. Regex is the framework's floor; a host LLM that understands "the day we argued" is the ceiling, and passes time_range= directly.

today= pins "now" for deterministic tests or a host-supplied clock.

Tokenization

python
tokenize(text: str, *, use_jieba: bool | None = None) -> list[str]

Lowercased latin words ([a-z0-9]+) plus CJK handling: character bigrams by default, jieba when the [zh] extra is importable. use_jieba=None auto-detects; True forces jieba (still falls back to bigrams if absent); False forces bigrams — useful for reproducible benchmarks.

python
est_tokens(text: str) -> int

Rough LLM-token estimate: one per latin word, one per CJK character. Used for budget trimming, where stability matters more than accuracy — not suitable for billing math.

Released under the Apache-2.0 License.