zettelkasten.usage¶
zettelkasten.usage ¶
Access-recency sidecar for Zettelkasten retrieval reranking.
A tiny, concurrency-safe SQLite database recording when and how often each
note was surfaced to the agent, so retrieval can boost recently-used notes as
one channel of a blended reranker (see :mod:zettelkasten.reranking).
Design guardrails:
- Recency is a BOOST, never a penalty. A note with no access record is
simply ABSENT from :func:
recency_scores— it carries no recency signal and ranks purely on the other channels. This is deliberately NOT a decay/forgetting mechanism: nothing is ever pushed down for lacking a record. - Sessions, not wall-clock. "Recency" is measured in sessions since last
use against a session
timeline(see :func:session_timeline), so a note used last session ranks ahead of one untouched for several sessions regardless of how many calendar days elapsed. The downstream reranker fuses on RANK (:func:memory.ranking.rrf_fuse), so the exponential-decay magnitude is not used for ordering — the only thing the decay shape controls is the STALENESS CUTOFF: a note whose last use is more than a few half-lives ago drops out of the recency channel entirely (absent, never penalized).half_lifeis therefore a membership knob (how long a note keeps counting as "recent"), not a ranking-shape knob. - Universal timestamps.
last_accessed_tsis a UTC unix epoch float, so a note accessed while working in a sibling (federated) repo carries a timestamp that maps correctly onto whichever repo's session timeline is queried.
The DB lives beside the embedding .kgl caches under
<ANGELO_DIR>/zettel/usage.db (same base :mod:zettelkasten.embeddings
resolves). Because ANGELO_DIR sits inside a cloud-synced (OneDrive
CloudStorage) repo — where SQLite's WAL -shm/-wal shared-memory
sidecars are unreliable and can corrupt the DB — it uses a rollback journal
(journal_mode=DELETE): a single plain file the sync layer handles safely,
with short transactions and a busy-timeout. The schema is created on the WRITE
path only (never on a read), and every query path degrades gracefully (returns
empty / no-ops) rather than crashing when the DB is missing or locked.
record_access ¶
Record that note_id in graph was surfaced/used now.
Upserts the (graph, note_id) row: sets last_accessed_ts to the
current UTC epoch, records last_session_id, and bumps access_count.
access_count counts DISCRETE accesses and is bumped by exactly 1 per
call (it is an integer tally of "how many times surfaced"). weight is
accepted for forward-compatibility with a future weighted-recency scheme —
the current recency model keys off last_accessed_ts (sessions-since-last-
use), not the count — but it does not currently scale the tally. Passing it
is harmless.
Never raises: a missing/locked DB is logged at debug level and swallowed, so a write on a hot path can never break note surfacing.
Source code in zettelkasten/usage.py
recency_scores ¶
recency_scores(graph: str, note_ids: list[str], timeline: list[float], *, half_life_sessions: float = DEFAULT_HALF_LIFE_SESSIONS) -> dict[str, float]
Session-recency score in (0, 1] for each recorded note.
For every id in note_ids that has a recorded last_accessed_ts in
graph, map that timestamp onto timeline (an ascending list of session
start timestamps, e.g. from :func:session_timeline) to get "how many
sessions ago" it was last used, then convert to a score via a session-based
exponential decay (half_life_sessions). A score of 1.0 means "used this
session".
Notes with NO record are ABSENT from the returned dict — they carry no
recency signal and must never be zeroed or penalized. A note whose last use
is STALE is likewise OMITTED rather than included with a near-zero score — so
a single long-stale access can never make a note the sole rank-1 member of the
recency channel. Staleness is bounded two ways: an ABSOLUTE ceiling of
:data:_MAX_SESSIONS_AGO sessions (independent of half_life, so a large
half_life cannot admit an ancient note) AND the half-life-relative floor
:data:_RECENCY_FLOOR (more than :data:_STALENESS_HALF_LIVES half-lives
ago). When timeline is
empty there is no way to place a timestamp on it, so an empty dict is
returned (again: absence, not penalty). Never raises: a missing/locked DB
(including a not-yet-created schema) yields an empty dict.
Source code in zettelkasten/usage.py
session_timeline ¶
Build the querying repo's session timeline as ascending start timestamps.
Enumerates finalized sessions (.memory/sessions/*.md, start time parsed
from the started_at frontmatter field) plus any live-session sidecars
(.memory/active-sessions/*.json, started_at field), and returns their
start timestamps sorted ascending. The last entry is therefore the most
recent (current) session.
Concurrently-open chats each write their own live sidecar, but they are all
active "now" — they OVERLAP into a single current session. Counting each as
its own session would inflate the session count and over-count "sessions
ago" for older notes (unfairly aging their recency). So the ACTIVE live
sidecars are COLLAPSED into a single representative start (the earliest, so
the whole concurrent window counts as one session). Only sidecars with a
fresh heartbeat count as active (see :func:_sidecar_is_stale); a
stale/abandoned sidecar left by a crashed chat is ignored so it is not
mistaken for a concurrent session.
The collapsed live start is treated as the CURRENT session and is guaranteed
to sort LAST: any finalized session whose start falls at/after it began
DURING the still-open live window (it ran concurrently), so it is folded into
the current session rather than left as a separate, "newer" bucket that would
steal current-session status from the live chat. This keeps notes touched
during the live chat anchored to "now" instead of aging several sessions
stale (the leapfrog the caller's timeline[-1] == current assumption relies
on).
Timestamps are UNIVERSAL (UTC epoch) so a note accessed in a sibling repo maps correctly onto this repo's timeline. Robust to a missing directory or unparseable files: those are skipped, never raised.
Source code in zettelkasten/usage.py
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 | |