Core API
Everything below is importable from the top-level package and works with the zero-dependency install:
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
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).
| method | returns |
|---|---|
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.
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)
) -> RecallItemaddinserts or replaces: an existing item with the same name is overwritten, and the journal recordsupdateinstead ofadd. This is the update model — there is no separateupdate().remove(file, name, *, owner=None) -> bool—Falseif the name wasn't present. Removing a RecallFile's last item deletes its file.- Raises
ValueErrorfor an invalid RecallFileslug or reserved characters in the item name (see below). Content is storedstrip()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
store.diary # -> Diary, lazily constructed, shares the store's journal
Diary(root, *, journal=None) # or construct standaloneThe 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
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)
) -> DiaryItemAppend-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
| method | returns |
|---|---|
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
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
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
diary_tool() -> dict # function-call schema for append_diary(content)
handle_diary_tool(
diary, args, **append_kwargs, # args: JSON string or already-parsed dict
) -> DiaryItemThe 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
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).
RecallItem / DiaryItem / WikiLink
@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@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@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
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
index.retrieve("前天晚上吃了什么") # window parsed from the query
index.retrieve("吃了什么", time_range=("2026-07-22", "2026-07-22")) # or passed explicitlyA 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).
| param | meaning |
|---|---|
time_range | inclusive ("YYYY-MM-DD", "YYYY-MM-DD"). This is the exit of a host's own intent recognition or tool call |
tz | the 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 — seeparse_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
| field | type | meaning |
|---|---|---|
items | list[RetrievedItem] | survived the budget, in injection order |
budget_tokens | int | None | the cap that was applied (None = uncapped) |
budget_used | int | estimated tokens of items |
embedding_used | bool | True only when the cosine path actually ran |
dropped | list[RetrievedItem] | what the budget cut — populated only with explain=True |
unresolved_links | list[str] | rendered links whose target is missing, e.g. "[[a:b]]" |
time_range | tuple[str, str] | None | the window actually applied (None = no gate) |
time_range_source | str | None | "explicit" (you passed it) or "parsed" (regex fast path) |
time_range_widened | bool | the 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
| field | type | meaning |
|---|---|---|
item | RecallItem | the memory itself |
source | str | "hit" (search match) or "link" (one-hop expansion) |
score | float | None | ranking score: fused when embedding ran, else BM25; None for links |
bm25_score | float | None | raw BM25 component (hits only) |
cos_score | float | None | raw cosine component (hits, fusion runs only) |
via | str | None | for links: name of the hit that pulled this in |
matched_terms | list[str] | query terms present in this item (sorted) |
tokens_est | int | budget cost of this entry |
Journal
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
parse_time_range(text, *, tz=None, today=None) -> tuple[str, str] | NoneThe 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
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.
est_tokens(text: str) -> intRough 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.