Skip to content

zettelkasten.config

zettelkasten.config

Lightweight config store for the Zettelkasten.

Config lives at .zettelkasten/config.yaml — committed alongside the notes, mirroring how the memory subsystem keeps .memory/config.yaml. It is read by zettelkasten.federation to discover related repos to project read-only.

The path is resolved against zettelkasten.graph.GRAPHS_DIR at call time (not import time) so tests that repoint graph.GRAPHS_DIR see the change.

config_path

config_path() -> Path

Absolute path to .zettelkasten/config.yaml for the local store.

Source code in zettelkasten/config.py
def config_path() -> Path:
    """Absolute path to ``.zettelkasten/config.yaml`` for the local store."""
    from zettelkasten import graph

    return graph.GRAPHS_DIR / "config.yaml"

read_config

read_config() -> dict[str, Any]

Read the config, returning {} when missing or malformed.

Source code in zettelkasten/config.py
def read_config() -> dict[str, Any]:
    """Read the config, returning ``{}`` when missing or malformed."""
    path = config_path()
    if not path.exists():
        return {}
    try:
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    except (OSError, yaml.YAMLError) as exc:
        logger.warning("Could not read zettelkasten config %s: %s", path, exc)
        return {}
    return data if isinstance(data, dict) else {}

write_config

write_config(config: dict[str, Any]) -> Path

Write the config atomically (temp file + rename).

Source code in zettelkasten/config.py
def write_config(config: dict[str, Any]) -> Path:
    """Write the config atomically (temp file + rename)."""
    path = config_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".yaml.tmp")
    tmp.write_text(
        yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False),
        encoding="utf-8",
    )
    tmp.replace(path)
    return path

rerank_config

rerank_config() -> 'Any'

Build a populated :class:zettelkasten.reranking.RerankConfig.

Starts from :func:zettelkasten.reranking.default_config (the canonical defaults live there) and applies .zettelkasten/config.yaml rerank: values, then ANGELO_ZK_RERANK_* env overrides (env wins). A missing/ malformed config or unparseable value falls back to the default for that field, so retrieval never crashes on bad config.

Note: session_half_life is a usage-recency knob (consumed by :func:zettelkasten.usage.recency_scores, not a RerankConfig field) — read it via :func:session_half_life.

Source code in zettelkasten/config.py
def rerank_config() -> "Any":
    """Build a populated :class:`zettelkasten.reranking.RerankConfig`.

    Starts from :func:`zettelkasten.reranking.default_config` (the canonical
    defaults live there) and applies ``.zettelkasten/config.yaml`` ``rerank:``
    values, then ``ANGELO_ZK_RERANK_*`` env overrides (env wins). A missing/
    malformed config or unparseable value falls back to the default for that
    field, so retrieval never crashes on bad config.

    Note: ``session_half_life`` is a *usage-recency* knob (consumed by
    :func:`zettelkasten.usage.recency_scores`, not a ``RerankConfig`` field) — read
    it via :func:`session_half_life`.
    """
    from zettelkasten.reranking import default_config

    cfg = default_config()
    data = read_config()
    raw = data.get(_RERANK_CONFIG_KEY)
    section = raw if isinstance(raw, dict) else {}

    cfg.w_sim = _resolve_float(section, "w_sim", "W_SIM", cfg.w_sim)
    cfg.w_importance = _resolve_float(section, "w_importance", "W_IMPORTANCE", cfg.w_importance)
    cfg.w_recency = _resolve_float(section, "w_recency", "W_RECENCY", cfg.w_recency)
    cfg.w_calendar_recency = _resolve_float(
        section, "w_calendar_recency", "W_CALENDAR_RECENCY", cfg.w_calendar_recency
    )
    cfg.calendar_half_life = _resolve_float(
        section, "calendar_half_life", "CALENDAR_HALF_LIFE", cfg.calendar_half_life
    )
    cfg.mmr_lambda = _resolve_float(section, "mmr_lambda", "MMR_LAMBDA", cfg.mmr_lambda)
    cfg.overfetch = max(1, _resolve_int(section, "overfetch", "OVERFETCH", cfg.overfetch))
    # rrf_k: keep ``None`` (defer to reranking's own default) unless explicitly set.
    rrf_env = _env("RRF_K")
    if rrf_env is not None:
        try:
            cfg.rrf_k = int(rrf_env)
        except (TypeError, ValueError):
            logger.warning("Ignoring non-integer %sRRF_K=%r", _ENV_PREFIX, rrf_env)
    elif section.get("rrf_k") is not None:
        try:
            cfg.rrf_k = int(section["rrf_k"])
        except (TypeError, ValueError):
            logger.warning("Ignoring non-integer rerank.rrf_k=%r", section["rrf_k"])
    cfg.use_source_importance = _resolve_bool(
        section, "use_source_importance", "USE_SOURCE_IMPORTANCE", cfg.use_source_importance
    )
    return cfg

session_half_life

session_half_life() -> float

Recency half-life in SESSIONS for :func:zettelkasten.usage.recency_scores.

Resolved like the reranker weights (rerank.session_half_life YAML key, ANGELO_ZK_RERANK_SESSION_HALF_LIFE env override), defaulting to :data:zettelkasten.usage.DEFAULT_HALF_LIFE_SESSIONS.

Source code in zettelkasten/config.py
def session_half_life() -> float:
    """Recency half-life in SESSIONS for :func:`zettelkasten.usage.recency_scores`.

    Resolved like the reranker weights (``rerank.session_half_life`` YAML key,
    ``ANGELO_ZK_RERANK_SESSION_HALF_LIFE`` env override), defaulting to
    :data:`zettelkasten.usage.DEFAULT_HALF_LIFE_SESSIONS`.
    """
    from zettelkasten.usage import DEFAULT_HALF_LIFE_SESSIONS

    data = read_config()
    raw = data.get(_RERANK_CONFIG_KEY)
    section = raw if isinstance(raw, dict) else {}
    return _resolve_float(
        section, "session_half_life", "SESSION_HALF_LIFE", DEFAULT_HALF_LIFE_SESSIONS
    )

use_global_index

use_global_index() -> bool

Whether project-scoped search routes through the global ANN index.

Resolved as env ANGELO_ZK_USE_GLOBAL_INDEX > top-level use_global_index YAML key > True (default on). When off, callers use the legacy per-box fan-out. The global index also transparently falls back to per-box when it is cold/empty or disabled (see server._semantic_recall).

Source code in zettelkasten/config.py
def use_global_index() -> bool:
    """Whether project-scoped search routes through the global ANN index.

    Resolved as env ``ANGELO_ZK_USE_GLOBAL_INDEX`` > top-level
    ``use_global_index`` YAML key > ``True`` (default on). When off, callers use
    the legacy per-box fan-out. The global index also transparently falls back to
    per-box when it is cold/empty or disabled (see ``server._semantic_recall``).
    """
    env_val = os.environ.get("ANGELO_ZK_USE_GLOBAL_INDEX")
    if env_val is not None and env_val.strip():
        return env_val.strip().lower() in ("1", "true", "yes", "on")
    data = read_config()
    raw = data.get("use_global_index")
    if raw is None:
        return True
    if isinstance(raw, bool):
        return raw
    return str(raw).strip().lower() in ("1", "true", "yes", "on")

rerank_disabled

rerank_disabled(cfg: 'Any' = None) -> bool

Whether a config reproduces today's pure-similarity ranking exactly.

Reranking degenerates to similarity-only ordering when BOTH boost channels are off (w_importance == w_recency == 0). In that case there is nothing to blend and, per the "no silent diversification" contract, MMR is treated as off too — REGARDLESS of mmr_lambda. This is deliberate: mmr_lambda defaults to 0.7, so keying disablement on mmr_lambda >= 1.0 would leave a user who zeroed only the weights getting silent MMR diversification they never asked for. (An MMR-only mode — diversify without importance/recency — would need its own explicit flag, not this weights-off path.) Callers use this to SKIP the rerank stage entirely in that case, so a "reranking off" config reproduces each surface's legacy ordering byte-for-byte (e.g. framing's fused-rank order, which pure cosine reranking would not reproduce).

Source code in zettelkasten/config.py
def rerank_disabled(cfg: "Any" = None) -> bool:
    """Whether a config reproduces today's pure-similarity ranking exactly.

    Reranking degenerates to similarity-only ordering when BOTH boost channels
    are off (``w_importance == w_recency == 0``). In that case there is nothing
    to blend and, per the "no silent diversification" contract, MMR is treated as
    off too — REGARDLESS of ``mmr_lambda``. This is deliberate: ``mmr_lambda``
    defaults to 0.7, so keying disablement on ``mmr_lambda >= 1.0`` would leave a
    user who zeroed only the weights getting silent MMR diversification they
    never asked for. (An MMR-only mode — diversify without importance/recency —
    would need its own explicit flag, not this weights-off path.) Callers use
    this to SKIP the rerank stage entirely in that case, so a "reranking off"
    config reproduces each surface's legacy ordering byte-for-byte (e.g. framing's
    fused-rank order, which pure cosine reranking would not reproduce).
    """
    if cfg is None:
        cfg = rerank_config()
    return (
        cfg.w_importance == 0.0
        and cfg.w_recency == 0.0
        and getattr(cfg, "w_calendar_recency", 0.0) == 0.0
    )