Selectively port source hashing, locked manifest/memory writes, path-aware link lint and graph analysis, and revision-checked staging from upstream 4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions. Preserve local archive/catalog citations, curated manifest metadata, config location, file-index and MCP behavior. Wire maintenance guidance into four skills without migrating vault contents or changing running services. Validation: 609 tests passed, including existing MCP/file-asset regressions and imported upstream maintenance tests. Read-only vault status left shared metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
1676 lines
62 KiB
Python
1676 lines
62 KiB
Python
"""The vault's memory surface: log, index, hot cache, owner profile, todo index.
|
|
|
|
Before this module these files were maintained by prose. Fifteen-odd skills each
|
|
restated "append a line to ``log.md``, add the new pages to ``index.md``, rewrite
|
|
``hot.md``" in their own words, and every one of them rewrote the prose files
|
|
wholesale with no lock. Two skills running in parallel silently dropped one of
|
|
the two updates, and nothing enforced the documented ~500-word cap on the hot
|
|
cache.
|
|
|
|
Everything here is one code path instead, serialised by the same advisory lock
|
|
the manifest writer uses (:func:`obsidian_wiki.cache.advisory_lock`) and written
|
|
atomically, so a reader never sees a torn file.
|
|
|
|
Four artifacts, three of them generated and one hybrid:
|
|
|
|
``log.md``
|
|
Append-only, one parseable ``- [TS] VERB key=value`` line per operation.
|
|
``index.md``
|
|
Category catalog, reconciled against what is actually on disk. A preamble
|
|
and any non-category section are preserved verbatim.
|
|
``hot.md``
|
|
Generated session snapshot, word-capped. ``## Key Takeaways`` is the one
|
|
LLM-owned slot and survives every rebuild unless it is explicitly replaced.
|
|
``_meta/profile.md`` / ``_meta/todos.md``
|
|
The owner's durable facts and open threads. Markdown tables, so a human can
|
|
edit them in Obsidian and the parser still round-trips them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, replace
|
|
from datetime import date, datetime, timedelta, timezone, tzinfo
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Iterable, Optional, Sequence
|
|
|
|
from obsidian_wiki import clock
|
|
from obsidian_wiki.cache import advisory_lock
|
|
from obsidian_wiki.provenance import archive_wikilink_relpath
|
|
from obsidian_wiki.vault import BLOCK_SCALAR_RE, FRONTMATTER_RE, iter_md, split_frontmatter
|
|
from obsidian_wiki.vault import SKIP_DIRS as VAULT_SKIP_DIRS
|
|
|
|
MEMORY_LOCK_NAME = ".memory.lock"
|
|
PROFILE_REL = "_meta/profile.md"
|
|
TODOS_REL = "_meta/todos.md"
|
|
#: A scope name namespaces the *person-shaped* memory — the profile and the
|
|
#: todo list — so one deployment can serve several users or agents. Knowledge
|
|
#: pages stay shared: a vault is a shared brain, and only what it remembers
|
|
#: *about someone* is per-someone.
|
|
_SCOPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
|
|
#: Directories that hold staging, archives, or tool state rather than pages.
|
|
SKIP_DIRS = VAULT_SKIP_DIRS | {"_readouts", "_meta"}
|
|
#: Root files that are the memory surface itself, not catalog entries.
|
|
SKIP_FILES = frozenset({
|
|
"index.md", "log.md", "hot.md", "_insights.md",
|
|
"AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md",
|
|
})
|
|
|
|
#: Canonical ordering for index sections; anything else sorts after, alphabetically.
|
|
CATEGORY_ORDER = (
|
|
"concepts", "entities", "skills", "references",
|
|
"synthesis", "journal", "projects",
|
|
)
|
|
|
|
DEFAULT_HOT_MAX_WORDS = 500
|
|
DEFAULT_RECENT_ACTIVITY = 3
|
|
DEFAULT_ACTIVE_THREADS = 7
|
|
DEFAULT_CONTRADICTIONS = 5
|
|
DEFAULT_TODO_STALE_DAYS = 30
|
|
|
|
#: Frontmatter marker proving a file is ours to regenerate. A vault whose
|
|
#: index.md or hot.md predates this module is hand-curated: regenerating it
|
|
#: would reorder or discard someone's work, so we refuse until `memory migrate`
|
|
#: has run and taken a backup.
|
|
GENERATED_MARKER = "obsidian-wiki memory"
|
|
#: Durable adoption record. The per-file marker alone was fragile: a skill
|
|
#: that still rewrites hot.md by hand drops the marker, and the next sync
|
|
#: would then refuse as if the vault had never been migrated. Adoption is a
|
|
#: property of the vault, so it lives in the vault, not in a file a legacy
|
|
#: skill can overwrite.
|
|
ADOPTED_REL = "_meta/.memory-adopted"
|
|
#: Raw log lines can be enormous (a research ingest records every page it made).
|
|
#: Pasting them into a word-capped snapshot spends the whole budget on one line.
|
|
HOT_FIELDS_PER_ENTRY = 3
|
|
HOT_VALUE_CHARS = 60
|
|
|
|
_LOG_LINE_RE = re.compile(r"^-\s*\[(?P<ts>[^\]]+)\]\s+(?P<verb>[A-Z][A-Z0-9_-]*)\s*(?P<rest>.*)$")
|
|
_FIELD_RE = re.compile(r"""(?P<key>[A-Za-z_][\w-]*)=(?P<value>"[^"]*"|'[^']*'|\S*)""")
|
|
_VERB_RE = re.compile(r"^[A-Z][A-Z0-9_-]*$")
|
|
_TAG_RE = re.compile(r"#[\w/-]+")
|
|
_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
|
_MD_DECORATION_RE = re.compile(r"[*_`]|^\s*[-*+]\s+|^\s*>\s?|^#{1,6}\s+")
|
|
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]")
|
|
#: Split a table row on unescaped pipes only — `\|` is a literal in a cell.
|
|
_CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
|
|
|
|
|
class MemoryError_(RuntimeError):
|
|
"""A memory-surface operation could not be completed."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# primitives
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def utc_now() -> str:
|
|
"""UTC in the ``Z`` spelling: the stamp this writer produced before vaults had a timezone."""
|
|
return clock.stamp(None)
|
|
|
|
|
|
def now_stamp(vault: Path) -> str:
|
|
"""The full timestamp for a write to *vault*, in the vault's configured zone.
|
|
|
|
``timezone:`` in ``index.md`` picks the zone; without it this is
|
|
:func:`utc_now`. See :mod:`obsidian_wiki.clock`.
|
|
"""
|
|
return clock.stamp(clock.wiki_timezone(vault))
|
|
|
|
|
|
def today(tz: Optional[tzinfo] = None) -> str:
|
|
"""Bare calendar date in *tz* (UTC when the vault has no zone)."""
|
|
return clock.today(tz)
|
|
|
|
|
|
def memory_lock(vault: Path, *, timeout: float = 10.0):
|
|
"""Serialise writes to the prose memory files.
|
|
|
|
Deliberately one lock for all four artifacts rather than one each: a skill
|
|
that finishes an ingest touches the log, the index, and the hot cache as a
|
|
single logical update, and interleaving another writer between them is what
|
|
produced inconsistent snapshots before.
|
|
"""
|
|
return advisory_lock(Path(vault) / MEMORY_LOCK_NAME, timeout=timeout)
|
|
|
|
|
|
def atomic_write(path: Path, text: str) -> None:
|
|
"""Replace *path* with *text* via a temp file and ``os.replace``."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
try:
|
|
tmp.write_text(text, encoding="utf-8")
|
|
os.replace(tmp, path) # atomic on POSIX and Windows
|
|
except BaseException:
|
|
try:
|
|
tmp.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _require_vault(vault: Path) -> Path:
|
|
vault = Path(vault)
|
|
if not vault.is_dir():
|
|
raise MemoryError_("vault_not_found", f"vault not found: {vault}")
|
|
return vault
|
|
|
|
|
|
def _scalar(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1].strip()
|
|
return value
|
|
|
|
|
|
def parse_frontmatter(frontmatter: str) -> dict:
|
|
"""Scalars (including blocks), inline lists, and block lists for page headers.
|
|
|
|
Fold both literal and folded blocks to one line, as in graphrag, since
|
|
memory surfaces use titles and summaries as single-line previews.
|
|
"""
|
|
values: dict = {}
|
|
lines = frontmatter.splitlines()
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if not line.strip() or line.startswith((" ", "\t")) or ":" not in line:
|
|
i += 1
|
|
continue
|
|
key, raw = line.split(":", 1)
|
|
key, raw = key.strip(), raw.strip()
|
|
if raw.startswith("[") and raw.endswith("]"):
|
|
values[key] = [_scalar(part) for part in raw[1:-1].split(",") if part.strip()]
|
|
i += 1
|
|
continue
|
|
if BLOCK_SCALAR_RE.match(raw):
|
|
block_lines = []
|
|
j = i + 1
|
|
while j < len(lines) and (lines[j].startswith((" ", "\t")) or not lines[j].strip()):
|
|
stripped = lines[j].strip()
|
|
if stripped:
|
|
block_lines.append(stripped)
|
|
j += 1
|
|
values[key] = " ".join(block_lines).strip()
|
|
i = j
|
|
continue
|
|
if not raw:
|
|
block: list = []
|
|
j = i + 1
|
|
while j < len(lines) and (lines[j].startswith((" ", "\t")) or not lines[j].strip()):
|
|
stripped = lines[j].strip()
|
|
if stripped.startswith("- "):
|
|
block.append(stripped[2:].strip())
|
|
elif stripped and block and ":" in stripped:
|
|
# continuation of a mapping item, e.g. `type: contradicts`
|
|
block.append(stripped)
|
|
j += 1
|
|
values[key] = block if block else ""
|
|
i = j
|
|
continue
|
|
values[key] = _scalar(raw)
|
|
i += 1
|
|
return values
|
|
|
|
|
|
def is_adopted(vault: Path) -> bool:
|
|
return (Path(vault) / ADOPTED_REL).is_file()
|
|
|
|
|
|
def mark_adopted(vault: Path) -> None:
|
|
"""Record that this vault's memory files are ours to regenerate.
|
|
|
|
Delete the file to re-arm the guard — say, after restoring a curated
|
|
index.md from ``_archives/`` — and ``memory migrate`` will ask again.
|
|
"""
|
|
atomic_write(Path(vault) / ADOPTED_REL, f"adopted: {now_stamp(vault)}\nby: {GENERATED_MARKER}\n")
|
|
|
|
|
|
def check_scope(scope: str) -> str:
|
|
"""Validate a scope name. It becomes a filename, so it is never trusted.
|
|
|
|
In a server deployment the scope arrives as a ``user_id`` from a request,
|
|
which makes it exactly the sort of value that must not contain ``..`` or a
|
|
path separator.
|
|
"""
|
|
scope = (scope or "").strip()
|
|
if not scope:
|
|
return ""
|
|
if not _SCOPE_RE.match(scope):
|
|
raise MemoryError_(
|
|
"bad_scope",
|
|
f"scope must be 1-64 chars of letters, digits, '.', '_' or '-': {scope!r}",
|
|
)
|
|
return scope
|
|
|
|
|
|
def profile_path(vault: Path, scope: str = "") -> Path:
|
|
scope = check_scope(scope)
|
|
return Path(vault) / (f"_meta/profile.{scope}.md" if scope else PROFILE_REL)
|
|
|
|
|
|
def todos_path(vault: Path, scope: str = "") -> Path:
|
|
scope = check_scope(scope)
|
|
return Path(vault) / (f"_meta/todos.{scope}.md" if scope else TODOS_REL)
|
|
|
|
|
|
def list_scopes(vault: Path) -> list:
|
|
"""Scope names that have a profile or a todo list in this vault."""
|
|
meta = Path(vault) / "_meta"
|
|
if not meta.is_dir():
|
|
return []
|
|
names = set()
|
|
for path in meta.glob("*.md"):
|
|
for prefix in ("profile.", "todos."):
|
|
if path.name.startswith(prefix) and path.name != f"{prefix}md":
|
|
names.add(path.name[len(prefix):-3])
|
|
return sorted(names)
|
|
|
|
|
|
def is_generated(path: Path) -> bool:
|
|
"""True when *path* carries our ``generated_by`` marker.
|
|
|
|
Absence means a human or an older version of the framework wrote it, so it
|
|
is not ours to overwrite.
|
|
"""
|
|
path = Path(path)
|
|
if not path.is_file():
|
|
return True # nothing to clobber
|
|
head = path.read_text(encoding="utf-8", errors="replace")[:2000]
|
|
return f"generated_by: {GENERATED_MARKER}" in head
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# log.md
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LogEntry:
|
|
timestamp: str
|
|
verb: str
|
|
fields: dict
|
|
raw: str
|
|
|
|
|
|
def format_log_line(verb: str, fields: Optional[dict] = None, *, timestamp: Optional[str] = None) -> str:
|
|
"""Render one ``- [TS] VERB key=value`` line in the documented format."""
|
|
verb = verb.strip().upper()
|
|
if not _VERB_RE.match(verb):
|
|
raise MemoryError_("bad_verb", f"log verb must be UPPERCASE alphanumeric: {verb!r}")
|
|
parts = [f"- [{timestamp or utc_now()}] {verb}"]
|
|
for key, value in (fields or {}).items():
|
|
text = "" if value is None else str(value)
|
|
text = text.replace("\n", " ").replace('"', "'")
|
|
parts.append(f'{key}="{text}"' if (" " in text or not text) else f"{key}={text}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def parse_log_line(line: str) -> Optional[LogEntry]:
|
|
match = _LOG_LINE_RE.match(line.strip())
|
|
if not match:
|
|
return None
|
|
fields = {
|
|
found.group("key"): _scalar(found.group("value"))
|
|
for found in _FIELD_RE.finditer(match.group("rest"))
|
|
}
|
|
return LogEntry(match.group("ts"), match.group("verb"), fields, line.rstrip())
|
|
|
|
|
|
def append_log(
|
|
vault: Path,
|
|
verb: str,
|
|
fields: Optional[dict] = None,
|
|
*,
|
|
timestamp: Optional[str] = None,
|
|
lock: bool = True,
|
|
) -> str:
|
|
"""Append one operation line to ``log.md``. Returns the line written.
|
|
|
|
Append-only and O_APPEND, so this stays correct even for a writer that
|
|
skipped the lock; the lock is still taken by default so a caller doing
|
|
log+index+hot as one update holds it across all three.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
line = format_log_line(verb, fields, timestamp=timestamp or now_stamp(vault))
|
|
log = vault / "log.md"
|
|
|
|
def _write() -> None:
|
|
if not log.exists():
|
|
atomic_write(log, "---\ntitle: Wiki Log\n---\n\n# Wiki Log\n\n")
|
|
existing = log.read_text(encoding="utf-8")
|
|
prefix = "" if existing.endswith("\n") or not existing else "\n"
|
|
with log.open("a", encoding="utf-8") as handle:
|
|
handle.write(f"{prefix}{line}\n")
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
_write()
|
|
else:
|
|
_write()
|
|
return line
|
|
|
|
|
|
def read_log(vault: Path, *, limit: Optional[int] = None, verbs: Optional[Iterable[str]] = None) -> list:
|
|
"""Parsed log entries, newest last. *limit* keeps the newest *limit* entries."""
|
|
log = Path(vault) / "log.md"
|
|
if not log.is_file():
|
|
return []
|
|
wanted = {v.upper() for v in verbs} if verbs else None
|
|
entries = []
|
|
for line in log.read_text(encoding="utf-8").splitlines():
|
|
entry = parse_log_line(line)
|
|
if entry and (wanted is None or entry.verb in wanted):
|
|
entries.append(entry)
|
|
return entries[-limit:] if limit else entries
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# page scan
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PageInfo:
|
|
path: str
|
|
category: str
|
|
title: str
|
|
summary: str
|
|
tags: tuple
|
|
updated: str
|
|
lifecycle: str
|
|
contradicts: tuple
|
|
|
|
|
|
def _summary_from_body(body: str) -> str:
|
|
for line in body.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith(("#", ">", "---", "|", "!", "*[")):
|
|
continue
|
|
if stripped.startswith("*") and stripped.endswith("*") and len(stripped) > 2:
|
|
continue # the italic "this file is generated" note
|
|
text = _MD_DECORATION_RE.sub("", stripped).strip()
|
|
if text:
|
|
return text[:200]
|
|
return ""
|
|
|
|
|
|
def _contradicts_targets(values: dict) -> tuple:
|
|
"""Targets of ``relationships:`` items whose ``type`` is ``contradicts``."""
|
|
raw = values.get("relationships")
|
|
if not isinstance(raw, list):
|
|
return ()
|
|
targets: list = []
|
|
pending_target = None
|
|
pending_type = None
|
|
for item in raw:
|
|
text = item.strip()
|
|
if text.startswith("target:"):
|
|
if pending_target and pending_type == "contradicts":
|
|
targets.append(pending_target)
|
|
pending_target, pending_type = _scalar(text[7:]), None
|
|
elif text.startswith("type:"):
|
|
pending_type = _scalar(text[5:])
|
|
if pending_type == "contradicts" and pending_target:
|
|
targets.append(pending_target)
|
|
pending_target, pending_type = None, None
|
|
if pending_target and pending_type == "contradicts":
|
|
targets.append(pending_target)
|
|
cleaned = []
|
|
for target in targets:
|
|
match = _WIKILINK_RE.search(target)
|
|
cleaned.append(match.group(1).strip() if match else target)
|
|
return tuple(cleaned)
|
|
|
|
|
|
def page_from_path(path: Path, vault: Path) -> PageInfo:
|
|
rel = path.relative_to(vault)
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
frontmatter, body = split_frontmatter(text)
|
|
values = parse_frontmatter(frontmatter)
|
|
tags_raw = values.get("tags", [])
|
|
if isinstance(tags_raw, str):
|
|
tags = tuple(tag.lstrip("#") for tag in _TAG_RE.findall(tags_raw)) or tuple(
|
|
part.strip().lstrip("#") for part in tags_raw.split(",") if part.strip()
|
|
)
|
|
else:
|
|
tags = tuple(str(tag).strip().lstrip("#") for tag in tags_raw if str(tag).strip())
|
|
title = str(values.get("title") or "").strip() or path.stem.replace("-", " ")
|
|
summary = str(values.get("summary") or "").strip() or _summary_from_body(body)
|
|
return PageInfo(
|
|
path=rel.as_posix(),
|
|
category=rel.parts[0] if len(rel.parts) > 1 else "",
|
|
title=title,
|
|
summary=summary,
|
|
tags=tags,
|
|
updated=str(values.get("updated") or "").strip(),
|
|
lifecycle=str(values.get("lifecycle") or "").strip(),
|
|
contradicts=_contradicts_targets(values),
|
|
)
|
|
|
|
|
|
def scan_pages(vault: Path) -> list:
|
|
"""Every real wiki page, excluding staging, archives, and the memory files."""
|
|
vault = _require_vault(vault)
|
|
pages = []
|
|
for path in iter_md(vault, SKIP_DIRS):
|
|
if path.name in SKIP_FILES and path.parent == vault:
|
|
continue
|
|
try:
|
|
pages.append(page_from_path(path, vault))
|
|
except OSError:
|
|
continue
|
|
return pages
|
|
|
|
|
|
def _category_sort_key(category: str):
|
|
label = category or "uncategorized"
|
|
try:
|
|
return (0, CATEGORY_ORDER.index(label), label)
|
|
except ValueError:
|
|
return (1, 0, label)
|
|
|
|
|
|
def _heading_for(category: str) -> str:
|
|
return (category or "uncategorized").replace("-", " ").replace("_", " ").title()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# index.md
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
#: Characters a ``[[wikilink]]`` target cannot carry: ``]`` ends the link, ``#``
|
|
#: starts a heading anchor and ``|`` starts an alias. A page whose name holds one
|
|
#: of them is unlinkable in that form.
|
|
_WIKILINK_UNSAFE = frozenset("]#|")
|
|
|
|
|
|
def _wikilink_target_is_valid(node: str) -> bool:
|
|
return not (_WIKILINK_UNSAFE & set(node))
|
|
|
|
|
|
def _markdown_link_text(title: str) -> str:
|
|
"""Escape ``]`` so a title cannot close the link text early."""
|
|
return title.replace("]", r"\]")
|
|
|
|
|
|
def _index_entry(page: PageInfo, link_format: str, *, hub_pages: Optional[int] = None) -> str:
|
|
node = page.path[:-3] if page.path.endswith(".md") else page.path
|
|
if link_format == "markdown" or not _wikilink_target_is_valid(node):
|
|
# A name a wikilink cannot express falls back to the markdown form so
|
|
# ``rebuild_index`` can read its own output back (and hand the entry to
|
|
# Obsidian in a form it can resolve).
|
|
link = f"[{_markdown_link_text(page.title)}]({page.path})"
|
|
elif hub_pages is not None:
|
|
# A hub's node ends in `/index`, which Obsidian would display as
|
|
# "index"; the alias shows the folder's own title instead.
|
|
link = f"[[{node}|{page.title}]]"
|
|
else:
|
|
link = f"[[{node}]]"
|
|
# Format rule: a space after the opening paren, or tag parsing breaks.
|
|
tags = f" ( {' '.join('#' + tag for tag in page.tags)})" if page.tags else ""
|
|
summary = f" — {page.summary}" if page.summary else ""
|
|
count = "" if hub_pages is None else f" ({hub_pages} page{'' if hub_pages == 1 else 's'})"
|
|
return f"- {link}{summary}{count}{tags}"
|
|
|
|
|
|
#: Root ``index.md`` frontmatter key that turns on folder hubs (see
|
|
#: ``collapse_folder_hubs``). It lives in the file it governs, so the setting
|
|
#: travels with the vault instead of depending on which ``.env`` a caller saw.
|
|
FOLDER_HUBS_KEY = "index_folder_hubs"
|
|
|
|
_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
|
|
|
|
def folder_hubs_enabled(index_text: str) -> bool:
|
|
values = parse_frontmatter(split_frontmatter(index_text)[0])
|
|
return str(values.get(FOLDER_HUBS_KEY) or "").strip().lower() in {"true", "yes", "1"}
|
|
|
|
|
|
def collapse_folder_hubs(vault: Path, pages: Sequence) -> tuple:
|
|
"""Replace every page under a folder that has its own ``index.md`` with
|
|
one entry for that index, so a sub-wiki is linked rather than inlined.
|
|
|
|
Returns ``(entries, hub_counts)``: ``entries`` keeps every page outside a
|
|
hub plus each hub's index page, and ``hub_counts`` maps a hub index's path
|
|
to how many pages it stands for. The outermost hub wins when hubs nest.
|
|
"""
|
|
hubs = sorted(
|
|
{
|
|
str(PurePosixPath(page.path).parent)
|
|
for page in pages
|
|
if PurePosixPath(page.path).name == "index.md" and "/" in page.path
|
|
},
|
|
key=len,
|
|
)
|
|
outermost: list = []
|
|
for hub in hubs:
|
|
if not any(hub.startswith(f"{outer}/") for outer in outermost):
|
|
outermost.append(hub)
|
|
|
|
entries, hub_counts = [], {}
|
|
by_path = {page.path: page for page in pages}
|
|
for page in pages:
|
|
hub = next((h for h in outermost if page.path.startswith(f"{h}/")), None)
|
|
if hub is None:
|
|
entries.append(page)
|
|
else:
|
|
hub_counts[f"{hub}/index.md"] = hub_counts.get(f"{hub}/index.md", 0) + 1
|
|
for hub_path, count in hub_counts.items():
|
|
entries.append(_hub_page(vault, by_path[hub_path], count - 1))
|
|
hub_counts[hub_path] = count - 1 # the index itself is not one of its pages
|
|
return entries, hub_counts
|
|
|
|
|
|
def _hub_page(vault: Path, page: PageInfo, count: int) -> PageInfo:
|
|
"""The hub's index page, titled for display: frontmatter title, else its
|
|
H1, else the folder name — never the bare stem "index"."""
|
|
title = page.title
|
|
if title == "index":
|
|
body = split_frontmatter((vault / page.path).read_text(encoding="utf-8", errors="replace"))[1]
|
|
heading = _H1_RE.search(body)
|
|
folder = PurePosixPath(page.path).parent.name
|
|
title = heading.group(1) if heading else folder.replace("-", " ").replace("_", " ").title()
|
|
return replace(page, title=title)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IndexResult:
|
|
added: tuple
|
|
removed: tuple
|
|
total: int
|
|
changed: bool
|
|
text: str
|
|
|
|
|
|
def render_index(
|
|
pages: Sequence,
|
|
*,
|
|
link_format: str = "wikilink",
|
|
preamble: str = "",
|
|
extra_sections: str = "",
|
|
hub_counts: Optional[dict] = None,
|
|
) -> str:
|
|
hub_counts = hub_counts or {}
|
|
by_category: dict = {}
|
|
for page in pages:
|
|
by_category.setdefault(page.category, []).append(page)
|
|
chunks = [_ensure_index_marker(preamble.rstrip("\n"))]
|
|
# Sections the author wrote come first, in their original order. Putting
|
|
# the generated catalog above them reordered a curated document and pushed
|
|
# hand-written reference material below a wall of auto-generated entries.
|
|
if extra_sections.strip():
|
|
chunks.append(extra_sections.strip("\n"))
|
|
for category in sorted(by_category, key=_category_sort_key):
|
|
entries = sorted(by_category[category], key=lambda p: (p.title.casefold(), p.path))
|
|
chunks.append(
|
|
f"## {_heading_for(category)}\n\n"
|
|
+ "\n".join(
|
|
_index_entry(page, link_format, hub_pages=hub_counts.get(page.path))
|
|
for page in entries
|
|
)
|
|
)
|
|
if not by_category:
|
|
chunks.append("## Concepts\n\n*No pages yet. Use `wiki-ingest` to add your first source.*")
|
|
return "\n\n".join(chunks) + "\n"
|
|
|
|
|
|
def _ensure_index_marker(preamble: str) -> str:
|
|
"""Guarantee the preamble carries the ``generated_by`` marker.
|
|
|
|
Migration preserves a vault's own preamble verbatim, so the marker has to
|
|
be injected into it — otherwise the file stays unmarked, every later call
|
|
sees an unmigrated vault, and the guard refuses in perpetuity.
|
|
"""
|
|
marker = f"generated_by: {GENERATED_MARKER} index"
|
|
if marker in preamble:
|
|
return preamble
|
|
match = FRONTMATTER_RE.match(preamble + "\n")
|
|
if match:
|
|
frontmatter = match.group(1)
|
|
if re.search(r"^generated_by:", frontmatter, re.MULTILINE):
|
|
frontmatter = re.sub(r"^generated_by:.*$", marker, frontmatter, count=1, flags=re.MULTILINE)
|
|
else:
|
|
frontmatter = f"{frontmatter}\n{marker}"
|
|
return f"---\n{frontmatter}\n---" + preamble[match.end() - 1:].rstrip("\n")
|
|
body = preamble.lstrip("\n")
|
|
return f"---\ntitle: Wiki Index\n{marker}\n---\n\n{body}" if body else \
|
|
f"---\ntitle: Wiki Index\n{marker}\n---\n\n# Wiki Index"
|
|
|
|
|
|
def _split_index(text: str) -> tuple:
|
|
"""Preamble, category-section paths, and verbatim non-category sections.
|
|
|
|
A section is treated as generated when its heading matches a category that
|
|
exists on disk; anything else is a human's own section and is preserved.
|
|
"""
|
|
headings = list(_HEADING_RE.finditer(text))
|
|
if not headings:
|
|
return text, {}, ""
|
|
preamble = text[: headings[0].start()].rstrip("\n")
|
|
sections = {}
|
|
for position, match in enumerate(headings):
|
|
end = headings[position + 1].start() if position + 1 < len(headings) else len(text)
|
|
sections[match.group(1).strip().casefold()] = text[match.start(): end]
|
|
return preamble, sections, ""
|
|
|
|
|
|
def rebuild_index(
|
|
vault: Path,
|
|
*,
|
|
link_format: str = "wikilink",
|
|
write: bool = True,
|
|
lock: bool = True,
|
|
force: bool = False,
|
|
) -> IndexResult:
|
|
"""Reconcile ``index.md`` against the pages actually on disk.
|
|
|
|
Regenerates one section per category and preserves the preamble plus any
|
|
section whose heading is not a category — a hand-written "Reading queue"
|
|
section survives, a stale page entry does not.
|
|
|
|
With ``index_folder_hubs: true`` in the index frontmatter, a folder that
|
|
has its own ``index.md`` is listed once, as a link to that index, instead
|
|
of page by page (see ``collapse_folder_hubs``).
|
|
"""
|
|
vault = _require_vault(vault)
|
|
index = vault / "index.md"
|
|
if write and not force and not (is_adopted(vault) or is_generated(index)):
|
|
raise MemoryError_(
|
|
"unmigrated",
|
|
"index.md was not written by this tool; run `obsidian-wiki memory migrate` "
|
|
"to review the change and take a backup first",
|
|
)
|
|
pages = scan_pages(vault)
|
|
existing = index.read_text(encoding="utf-8") if index.is_file() else ""
|
|
preamble, sections, _ = _split_index(existing)
|
|
|
|
# A heading counts as generated when it names a category, whether or not a
|
|
# page currently lives there. Deriving this from pages alone meant deleting
|
|
# the last page in a category left its stale section behind, preserved as
|
|
# though a human had written it.
|
|
categories = {_heading_for(name).casefold() for name in CATEGORY_ORDER}
|
|
categories.add(_heading_for("").casefold())
|
|
categories |= {_heading_for(page.category).casefold() for page in pages}
|
|
categories |= {
|
|
_heading_for(child.name).casefold()
|
|
for child in vault.iterdir()
|
|
if child.is_dir() and child.name not in SKIP_DIRS and not child.name.startswith(".")
|
|
}
|
|
extra = "\n\n".join(
|
|
body.strip("\n") for heading, body in sections.items() if heading not in categories
|
|
)
|
|
|
|
# Only the link that opens an entry line is what the index lists; links in
|
|
# the entry's summary text point elsewhere and are not catalog entries.
|
|
listed = set()
|
|
for heading, body in sections.items():
|
|
if heading in categories:
|
|
for line in body.splitlines():
|
|
entry = re.match(r"^-\s*(\[\[[^\]]+\]\])", line.strip())
|
|
if entry:
|
|
match = _WIKILINK_RE.match(entry.group(1))
|
|
if match and archive_wikilink_relpath(vault, match.group(1)) is None:
|
|
listed.add(match.group(1).strip())
|
|
continue
|
|
# Link text may carry an escaped ``]`` (see _index_entry); the
|
|
# capture is the path, so the escape only has to be skipped.
|
|
md = re.match(r"^-\s*\[(?:\\.|[^\]])*\]\(([^)]+)\)", line.strip())
|
|
if md:
|
|
listed.add(md.group(1)[:-3] if md.group(1).endswith(".md") else md.group(1))
|
|
|
|
entries, hub_counts = (
|
|
collapse_folder_hubs(vault, pages) if folder_hubs_enabled(existing) else (pages, {})
|
|
)
|
|
current = {page.path[:-3] for page in entries}
|
|
added = tuple(sorted(current - listed))
|
|
removed = tuple(sorted(listed - current))
|
|
text = render_index(
|
|
entries,
|
|
link_format=link_format,
|
|
preamble=preamble,
|
|
extra_sections=extra,
|
|
hub_counts=hub_counts,
|
|
)
|
|
changed = text != existing
|
|
|
|
if write and changed:
|
|
if lock:
|
|
with memory_lock(vault):
|
|
atomic_write(index, text)
|
|
else:
|
|
atomic_write(index, text)
|
|
return IndexResult(added=added, removed=removed, total=len(pages), changed=changed, text=text)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# _meta/profile.md — who the vault owner is
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fact:
|
|
key: str
|
|
value: str
|
|
confidence: float
|
|
source: str
|
|
updated: str
|
|
until: str = "" # YYYY-MM-DD after which the fact no longer holds; empty = durable
|
|
|
|
def is_expired(self, on: Optional[str] = None) -> bool:
|
|
return bool(self.until) and self.until < (on or today())
|
|
|
|
|
|
def _cell(text: str) -> str:
|
|
return str(text).replace("|", r"\|").replace("\n", " ").strip()
|
|
|
|
|
|
def _uncell(text: str) -> str:
|
|
return text.replace(r"\|", "|").strip()
|
|
|
|
|
|
def _parse_table(text: str, columns: int, optional: int = 0) -> list:
|
|
"""Rows of the first markdown table in *text*, header and rule dropped.
|
|
|
|
*optional* trailing columns may be missing (a table written before they
|
|
existed); they come back as empty strings.
|
|
"""
|
|
rows = []
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped.startswith("|") or not stripped.endswith("|"):
|
|
continue
|
|
cells = [_uncell(cell) for cell in _CELL_SPLIT_RE.split(stripped[1:-1])]
|
|
if len(cells) < columns:
|
|
continue
|
|
if all(set(cell) <= {"-", ":", " "} and cell for cell in cells):
|
|
continue # the |---|---| rule
|
|
cells += [""] * optional
|
|
rows.append(cells[: columns + optional])
|
|
return rows[1:] if rows else [] # drop the header row
|
|
|
|
|
|
_PROFILE_HEADER = """---
|
|
title: Owner Profile
|
|
category: _meta
|
|
tags: [meta/profile]
|
|
sources: [conversation]
|
|
created: {created}
|
|
updated: {updated}
|
|
generated_by: obsidian-wiki memory profile
|
|
---
|
|
|
|
# Owner Profile
|
|
|
|
*Durable facts about the vault owner, injected into an agent's context at session
|
|
start. Written by `obsidian-wiki memory profile set`; safe to edit by hand — the
|
|
table round-trips. Confidence is the writer's own calibration, not a measurement.
|
|
A fact with an Until date drops out of the session recap once that date passes.*
|
|
|
|
| Fact | Value | Confidence | Source | Updated | Until |
|
|
|---|---|---|---|---|---|
|
|
"""
|
|
|
|
|
|
def load_profile(vault: Path, scope: str = "") -> list:
|
|
path = profile_path(vault, scope)
|
|
if not path.is_file():
|
|
return []
|
|
facts = []
|
|
for row in _parse_table(path.read_text(encoding="utf-8"), 5, optional=1):
|
|
key, value, confidence, source, updated, until = row
|
|
if not key:
|
|
continue
|
|
try:
|
|
score = float(confidence)
|
|
except ValueError:
|
|
score = 0.5
|
|
facts.append(Fact(key, value, score, source, updated, until))
|
|
return facts
|
|
|
|
|
|
def render_profile(
|
|
facts: Sequence, *, created: str = "", updated: str = "", tz: Optional[tzinfo] = None
|
|
) -> str:
|
|
header = _PROFILE_HEADER.format(created=created or today(tz), updated=updated or today(tz))
|
|
rows = "\n".join(
|
|
f"| {_cell(f.key)} | {_cell(f.value)} | {f.confidence:.2f} | {_cell(f.source)} | {_cell(f.updated)} | {_cell(f.until)} |"
|
|
for f in sorted(facts, key=lambda f: f.key.casefold())
|
|
)
|
|
return header + (rows + "\n" if rows else "")
|
|
|
|
|
|
def set_fact(
|
|
vault: Path,
|
|
key: str,
|
|
value: str,
|
|
*,
|
|
confidence: float = 0.6,
|
|
source: str = "conversation",
|
|
until: str = "",
|
|
scope: str = "",
|
|
lock: bool = True,
|
|
) -> Fact:
|
|
"""Add or replace one fact about the owner.
|
|
|
|
*until* (YYYY-MM-DD) marks a time-bound fact — "exams this weekend" — so it
|
|
stops being injected once it is over instead of lingering until a human
|
|
notices. The row stays in the table; `forget` removes it.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
key = key.strip()
|
|
if not key:
|
|
raise MemoryError_("bad_key", "fact key must not be empty")
|
|
if not 0.0 <= confidence <= 1.0:
|
|
raise MemoryError_("bad_confidence", f"confidence must be in [0.0, 1.0]: {confidence}")
|
|
until = until.strip()
|
|
if until:
|
|
try:
|
|
until = date.fromisoformat(until).isoformat()
|
|
except ValueError:
|
|
raise MemoryError_("bad_until", f"until must be YYYY-MM-DD: {until}") from None
|
|
tz = clock.wiki_timezone(vault)
|
|
fact = Fact(key, value.strip(), confidence, source.strip(), today(tz), until)
|
|
|
|
def _write() -> None:
|
|
facts = [f for f in load_profile(vault, scope) if f.key.casefold() != key.casefold()]
|
|
facts.append(fact)
|
|
atomic_write(profile_path(vault, scope), render_profile(facts, tz=tz))
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
_write()
|
|
else:
|
|
_write()
|
|
return fact
|
|
|
|
|
|
def forget_fact(vault: Path, key: str, *, scope: str = "", lock: bool = True) -> bool:
|
|
vault = _require_vault(vault)
|
|
|
|
def _write() -> bool:
|
|
facts = load_profile(vault, scope)
|
|
kept = [f for f in facts if f.key.casefold() != key.strip().casefold()]
|
|
if len(kept) == len(facts):
|
|
return False
|
|
atomic_write(profile_path(vault, scope), render_profile(kept, tz=clock.wiki_timezone(vault)))
|
|
return True
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
return _write()
|
|
return _write()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# _meta/todos.md — open threads
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Todo:
|
|
id: str
|
|
text: str
|
|
status: str # open | done | dropped
|
|
origin: str
|
|
created: str
|
|
touched: str
|
|
|
|
def is_stale(self, *, days: int = DEFAULT_TODO_STALE_DAYS, now: Optional[datetime] = None) -> bool:
|
|
if self.status != "open" or not self.touched:
|
|
return False
|
|
try:
|
|
touched = datetime.strptime(self.touched, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
return False
|
|
return (now or datetime.now(timezone.utc)) - touched > timedelta(days=days)
|
|
|
|
|
|
TODO_STATUSES = ("open", "done", "dropped")
|
|
|
|
_TODOS_HEADER = """---
|
|
title: Todo Index
|
|
category: _meta
|
|
tags: [meta/todos]
|
|
sources: [conversation]
|
|
created: {created}
|
|
updated: {updated}
|
|
generated_by: obsidian-wiki memory todo
|
|
---
|
|
|
|
# Todo Index
|
|
|
|
*Open threads carried between sessions. Written by `obsidian-wiki memory todo`;
|
|
safe to edit by hand — the table round-trips. An open item untouched for
|
|
{stale} days is reported stale rather than silently kept alive.*
|
|
|
|
| ID | Status | Thread | Origin | Created | Touched |
|
|
|---|---|---|---|---|---|
|
|
"""
|
|
|
|
|
|
def load_todos(vault: Path, scope: str = "") -> list:
|
|
path = todos_path(vault, scope)
|
|
if not path.is_file():
|
|
return []
|
|
todos = []
|
|
for row in _parse_table(path.read_text(encoding="utf-8"), 6):
|
|
todo_id, status, text, origin, created, touched = row
|
|
if not todo_id:
|
|
continue
|
|
todos.append(Todo(todo_id, text, status or "open", origin, created, touched))
|
|
return todos
|
|
|
|
|
|
def render_todos(
|
|
todos: Sequence, *, created: str = "", updated: str = "", tz: Optional[tzinfo] = None
|
|
) -> str:
|
|
header = _TODOS_HEADER.format(
|
|
created=created or today(tz), updated=updated or today(tz), stale=DEFAULT_TODO_STALE_DAYS
|
|
)
|
|
order = {status: position for position, status in enumerate(TODO_STATUSES)}
|
|
rows = "\n".join(
|
|
f"| {_cell(t.id)} | {_cell(t.status)} | {_cell(t.text)} | {_cell(t.origin)} "
|
|
f"| {_cell(t.created)} | {_cell(t.touched)} |"
|
|
for t in sorted(todos, key=lambda t: (order.get(t.status, 9), _todo_number(t.id)))
|
|
)
|
|
return header + (rows + "\n" if rows else "")
|
|
|
|
|
|
def _todo_number(todo_id: str) -> int:
|
|
match = re.search(r"(\d+)$", todo_id or "")
|
|
return int(match.group(1)) if match else 0
|
|
|
|
|
|
def _next_todo_id(todos: Sequence) -> str:
|
|
return f"t{max((_todo_number(t.id) for t in todos), default=0) + 1}"
|
|
|
|
|
|
def add_todo(vault: Path, text: str, *, origin: str = "", scope: str = "", lock: bool = True) -> Todo:
|
|
vault = _require_vault(vault)
|
|
text = text.strip()
|
|
if not text:
|
|
raise MemoryError_("bad_todo", "todo text must not be empty")
|
|
tz = clock.wiki_timezone(vault)
|
|
holder: list = []
|
|
|
|
def _write() -> None:
|
|
todos = load_todos(vault, scope)
|
|
existing = next((t for t in todos if t.text.casefold() == text.casefold() and t.status == "open"), None)
|
|
if existing is not None: # idempotent: re-adding an open thread just touches it
|
|
todo = replace(existing, touched=today(tz))
|
|
todos = [todo if t.id == existing.id else t for t in todos]
|
|
else:
|
|
todo = Todo(_next_todo_id(todos), text, "open", origin.strip(), today(tz), today(tz))
|
|
todos.append(todo)
|
|
atomic_write(todos_path(vault, scope), render_todos(todos, tz=tz))
|
|
holder.append(todo)
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
_write()
|
|
else:
|
|
_write()
|
|
return holder[0]
|
|
|
|
|
|
def set_todo_status(vault: Path, todo_id: str, status: str, *, scope: str = "", lock: bool = True) -> Todo:
|
|
vault = _require_vault(vault)
|
|
if status not in TODO_STATUSES:
|
|
raise MemoryError_("bad_status", f"status must be one of {', '.join(TODO_STATUSES)}")
|
|
tz = clock.wiki_timezone(vault)
|
|
holder: list = []
|
|
|
|
def _write() -> None:
|
|
todos = load_todos(vault, scope)
|
|
match = next((t for t in todos if t.id.casefold() == todo_id.strip().casefold()), None)
|
|
if match is None:
|
|
raise MemoryError_("no_such_todo", f"no todo with id {todo_id!r}")
|
|
updated = replace(match, status=status, touched=today(tz))
|
|
atomic_write(
|
|
todos_path(vault, scope),
|
|
render_todos([updated if t.id == match.id else t for t in todos], tz=tz),
|
|
)
|
|
holder.append(updated)
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
_write()
|
|
else:
|
|
_write()
|
|
return holder[0]
|
|
|
|
|
|
def prune_todos(vault: Path, *, scope: str = "", lock: bool = True) -> int:
|
|
"""Drop closed items from the table. Returns how many were removed."""
|
|
vault = _require_vault(vault)
|
|
holder: list = []
|
|
|
|
def _write() -> None:
|
|
todos = load_todos(vault, scope)
|
|
kept = [t for t in todos if t.status == "open"]
|
|
holder.append(len(todos) - len(kept))
|
|
if len(kept) != len(todos):
|
|
atomic_write(todos_path(vault, scope), render_todos(kept, tz=clock.wiki_timezone(vault)))
|
|
|
|
if lock:
|
|
with memory_lock(vault):
|
|
_write()
|
|
else:
|
|
_write()
|
|
return holder[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# hot.md — the generated session snapshot
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
_HOT_NOTE = (
|
|
"*Generated snapshot of recent vault activity. Rebuilt by "
|
|
"`obsidian-wiki memory hot` — edits to the generated sections are "
|
|
"overwritten, so put durable prose in `## Key Takeaways`, which survives "
|
|
"every rebuild.*"
|
|
)
|
|
_TAKEAWAYS_PLACEHOLDER = "*None yet.*"
|
|
|
|
|
|
def _section_body(text: str, heading: str, *, prefix: bool = False) -> str:
|
|
suffix = r".*$" if prefix else r"\s*$"
|
|
pattern = re.compile(rf"^##\s+{re.escape(heading)}{suffix}", re.MULTILINE | re.IGNORECASE)
|
|
match = pattern.search(text)
|
|
if not match:
|
|
return ""
|
|
rest = text[match.end():]
|
|
following = _HEADING_RE.search(rest)
|
|
return (rest[: following.start()] if following else rest).strip()
|
|
|
|
|
|
def word_count(text: str) -> int:
|
|
return len(text.split())
|
|
|
|
|
|
def content_words(text: str) -> int:
|
|
"""Words a reader actually consumes: no frontmatter, no generated note.
|
|
|
|
The documented cap is on the snapshot's content. Counting the YAML header
|
|
and the "this file is generated" note against it put the floor within a few
|
|
words of the cap, so trimming could spin without ever getting under budget.
|
|
"""
|
|
body = split_frontmatter(text)[1].replace(_HOT_NOTE, "")
|
|
return len(body.split())
|
|
|
|
|
|
def hot_max_words() -> int:
|
|
raw = os.environ.get("OBSIDIAN_HOT_MAX_WORDS", "").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
return DEFAULT_HOT_MAX_WORDS
|
|
return value if value > 0 else DEFAULT_HOT_MAX_WORDS
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HotResult:
|
|
words: int
|
|
trimmed: bool
|
|
activity: int
|
|
threads: int
|
|
contradictions: int
|
|
text: str
|
|
|
|
|
|
def summarize_log_entry(entry: LogEntry) -> str:
|
|
"""One compact line for the hot cache.
|
|
|
|
A raw log line is unbounded: a research ingest records every page it
|
|
produced in a single ``note=`` field, which alone can exceed the whole
|
|
word budget. Keep the date, the verb, and the first few fields.
|
|
"""
|
|
date = entry.timestamp.split("T")[0]
|
|
parts = []
|
|
for index, (key, value) in enumerate(entry.fields.items()):
|
|
if index >= HOT_FIELDS_PER_ENTRY:
|
|
parts.append("...")
|
|
break
|
|
if len(value) > HOT_VALUE_CHARS:
|
|
value = value[: HOT_VALUE_CHARS - 1] + "..."
|
|
parts.append(f"{key}={value}" if value else key)
|
|
return f"- [{date}] {entry.verb}" + (" " + " ".join(parts) if parts else "")
|
|
|
|
|
|
def _recent_activity_lines(vault: Path, limit: int) -> list:
|
|
return [summarize_log_entry(entry) for entry in read_log(vault, limit=limit)]
|
|
|
|
|
|
def _contradiction_lines(pages: Sequence, limit: int, link_format: str) -> list:
|
|
def _link(node: str) -> str:
|
|
return f"[{node}]({node}.md)" if link_format == "markdown" else f"[[{node}]]"
|
|
|
|
lines = []
|
|
for page in sorted(pages, key=lambda p: p.updated, reverse=True):
|
|
node = page.path[:-3]
|
|
if page.lifecycle == "disputed":
|
|
since = f" since {page.updated}" if page.updated else ""
|
|
lines.append(f"- {_link(node)} — marked disputed{since}")
|
|
for target in page.contradicts:
|
|
lines.append(f"- {_link(node)} contradicts {_link(target)}")
|
|
if len(lines) >= limit:
|
|
break
|
|
return lines[:limit]
|
|
|
|
|
|
def build_hot(
|
|
vault: Path,
|
|
*,
|
|
takeaways: Optional[str] = None,
|
|
link_format: str = "wikilink",
|
|
max_words: Optional[int] = None,
|
|
activity_limit: int = DEFAULT_RECENT_ACTIVITY,
|
|
thread_limit: int = DEFAULT_ACTIVE_THREADS,
|
|
contradiction_limit: int = DEFAULT_CONTRADICTIONS,
|
|
) -> HotResult:
|
|
"""Render the hot cache without writing it.
|
|
|
|
Word-capped for real. When the render is over budget, sections are dropped
|
|
in increasing order of value: activity lines first, then contradictions,
|
|
then threads, and ``Key Takeaways`` is truncated last because it is the only
|
|
part a model wrote on purpose.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
cap = max_words or hot_max_words()
|
|
hot = vault / "hot.md"
|
|
existing = hot.read_text(encoding="utf-8") if hot.is_file() else ""
|
|
|
|
if takeaways is None:
|
|
carried = _section_body(existing, "Key Takeaways")
|
|
takeaways = carried if carried else _TAKEAWAYS_PLACEHOLDER
|
|
takeaways = takeaways.strip() or _TAKEAWAYS_PLACEHOLDER
|
|
|
|
pages = scan_pages(vault)
|
|
activity = _recent_activity_lines(vault, activity_limit)
|
|
threads = [t for t in load_todos(vault) if t.status == "open"][:thread_limit]
|
|
contradictions = _contradiction_lines(pages, contradiction_limit, link_format)
|
|
|
|
def _render(act: list, con: list, thr: list, take: str) -> str:
|
|
thread_lines = [
|
|
f"- **{t.id}** {t.text if len(t.text) <= 110 else t.text[:109].rstrip() + '...'}"
|
|
+ (f" — {t.origin}" if t.origin else "")
|
|
+ f" (touched {t.touched})"
|
|
for t in thr
|
|
]
|
|
blocks = [
|
|
"---\ntitle: Hot Cache\n"
|
|
f"updated: {now_stamp(vault)}\n"
|
|
f"pages: {len(pages)}\n"
|
|
f"generated_by: {GENERATED_MARKER} hot\n---",
|
|
"# Hot Cache",
|
|
_HOT_NOTE,
|
|
"## Recent Activity\n\n" + ("\n".join(act) if act else "*No logged operations yet.*"),
|
|
"## Active Threads\n\n" + ("\n".join(thread_lines) if thread_lines else "*No open threads.*"),
|
|
"## Key Takeaways\n\n" + take,
|
|
"## Flagged Contradictions\n\n" + ("\n".join(con) if con else "*None flagged.*"),
|
|
]
|
|
return "\n\n".join(blocks) + "\n"
|
|
|
|
text = _render(activity, contradictions, threads, takeaways)
|
|
trimmed = False
|
|
while content_words(text) > cap:
|
|
if len(activity) > 1:
|
|
activity = activity[1:]
|
|
elif contradictions:
|
|
contradictions = contradictions[:-1]
|
|
elif len(threads) > 1:
|
|
threads = threads[:-1]
|
|
else:
|
|
words = takeaways.split()
|
|
if len(words) <= 20:
|
|
break # the skeleton alone exceeds the cap; report it instead of looping
|
|
takeaways = " ".join(words[: max(20, len(words) - 25)]) + " …"
|
|
trimmed = True
|
|
text = _render(activity, contradictions, threads, takeaways)
|
|
|
|
return HotResult(
|
|
words=content_words(text),
|
|
trimmed=trimmed,
|
|
activity=len(activity),
|
|
threads=len(threads),
|
|
contradictions=len(contradictions),
|
|
text=text,
|
|
)
|
|
|
|
|
|
def rebuild_hot(
|
|
vault: Path, *, write: bool = True, lock: bool = True, force: bool = False, **kwargs
|
|
) -> HotResult:
|
|
vault = _require_vault(vault)
|
|
if write and not force and not (is_adopted(vault) or is_generated(vault / "hot.md")):
|
|
raise MemoryError_(
|
|
"unmigrated",
|
|
"hot.md was not written by this tool; run `obsidian-wiki memory migrate` "
|
|
"to review the change and take a backup first",
|
|
)
|
|
if lock:
|
|
with memory_lock(vault):
|
|
result = build_hot(vault, **kwargs)
|
|
if write:
|
|
atomic_write(vault / "hot.md", result.text)
|
|
return result
|
|
result = build_hot(vault, **kwargs)
|
|
if write:
|
|
atomic_write(vault / "hot.md", result.text)
|
|
return result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# recap — the block injected into a fresh session
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def build_recap(
|
|
vault: Path,
|
|
*,
|
|
max_words: int = 400,
|
|
min_confidence: float = 0.0,
|
|
project: Optional[str] = None,
|
|
scope: str = "",
|
|
) -> str:
|
|
"""Profile, open threads, and the newest activity as one injectable block.
|
|
|
|
This is what a SessionStart or PreCompact hook feeds back into context: the
|
|
left-hand column of the memory diagram, rendered once, instead of three
|
|
separate file reads the model has to remember to make.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
on = today(clock.wiki_timezone(vault))
|
|
facts = [
|
|
f for f in load_profile(vault, scope)
|
|
if f.confidence >= min_confidence and not f.is_expired(on)
|
|
]
|
|
todos = [t for t in load_todos(vault, scope) if t.status == "open"]
|
|
entries = read_log(vault, limit=5)
|
|
|
|
# Scoping keeps a session about project A from being handed project B's
|
|
# threads. Facts describe the person, so they stay; threads and activity
|
|
# are project-shaped. An unscoped thread is shown either way, and a filter
|
|
# that matches nothing falls back rather than claiming there is no history.
|
|
if project:
|
|
needle = project.strip().casefold()
|
|
scoped = [t for t in todos if needle in f"{t.origin} {t.text}".casefold()]
|
|
todos = scoped if scoped else todos
|
|
matched = [e for e in entries if needle in e.raw.casefold()]
|
|
entries = matched or read_log(vault, limit=3)
|
|
|
|
stale = [t for t in todos if t.is_stale()]
|
|
|
|
lines = ["# Vault memory", ""]
|
|
if facts:
|
|
lines.append("## Who you are working with")
|
|
lines.append("")
|
|
lines += [f"- **{f.key}**: {f.value} ({f.confidence:.2f})" for f in facts]
|
|
lines.append("")
|
|
if todos:
|
|
lines.append("## Open threads")
|
|
lines.append("")
|
|
for todo in todos:
|
|
mark = " *(stale)*" if todo in stale else ""
|
|
origin = f" — {todo.origin}" if todo.origin else ""
|
|
lines.append(f"- **{todo.id}** {todo.text}{origin}{mark}")
|
|
lines.append("")
|
|
if entries:
|
|
lines.append("## Recent vault activity")
|
|
lines.append("")
|
|
lines += [summarize_log_entry(entry) for entry in entries]
|
|
lines.append("")
|
|
if not facts and not todos and not entries:
|
|
lines.append("*No vault memory recorded yet.*")
|
|
lines.append("")
|
|
elif not facts and not todos and all(e.verb == "INIT" for e in read_log(vault)):
|
|
# A vault that was set up and never used: say what to do first rather
|
|
# than replaying its creation line every session.
|
|
lines.append("## Getting started")
|
|
lines.append("")
|
|
lines.append(
|
|
"- The vault is empty. Save something worth keeping with `/wiki-capture`, "
|
|
"add documents with `/wiki-ingest <path>` (or drop drafts in `_raw/`), "
|
|
"and ask with `/wiki-query`."
|
|
)
|
|
lines.append("")
|
|
|
|
text = "\n".join(lines).rstrip() + "\n"
|
|
if word_count(text) > max_words:
|
|
words = text.split()
|
|
text = " ".join(words[:max_words]) + " …\n"
|
|
return text
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# migration — adopting a vault that predates this module
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
MIGRATION_BACKUP_DIR = "_archives"
|
|
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d+\.)\s+(.*)$")
|
|
|
|
|
|
def extract_threads(hot_text: str) -> list:
|
|
"""Open threads written as prose in a hand-maintained ``Active Threads``.
|
|
|
|
Migration would otherwise drop this outright: the generated section is
|
|
built from the todo table, which is empty on a vault that never had one.
|
|
Converting the bullets into real todos preserves the content *and* seeds
|
|
the index that keeps it alive.
|
|
"""
|
|
body = _section_body(hot_text, "Active Threads", prefix=True)
|
|
threads = []
|
|
for line in body.splitlines():
|
|
match = _BULLET_RE.match(line)
|
|
if not match:
|
|
continue
|
|
text = match.group(1).strip()
|
|
text = re.sub(r"^\*\*(.+?)\*\*\s*[-—:]*\s*", r"\1: ", text).strip()
|
|
text = _MD_DECORATION_RE.sub("", text).strip()
|
|
if len(text) > 160:
|
|
text = text[:159].rstrip() + "..."
|
|
if text:
|
|
threads.append(text)
|
|
return threads
|
|
|
|
|
|
def migration_status(vault: Path) -> dict:
|
|
"""What adopting this vault would change, without changing anything.
|
|
|
|
Existing vaults have hand-curated memory files. Regenerating them silently
|
|
would reorder a document someone built on purpose and drop narrative the
|
|
generator cannot reconstruct, so adoption is explicit and backed up.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
index, hot = vault / "index.md", vault / "hot.md"
|
|
adopted = is_adopted(vault)
|
|
index_generated, hot_generated = adopted or is_generated(index), adopted or is_generated(hot)
|
|
|
|
preview = rebuild_index(vault, write=False, lock=False, force=True)
|
|
old_index = index.read_text(encoding="utf-8") if index.is_file() else ""
|
|
custom = [
|
|
heading for heading in _HEADING_RE.findall(preview.text)
|
|
if heading in _HEADING_RE.findall(old_index)
|
|
]
|
|
|
|
hot_text = hot.read_text(encoding="utf-8") if hot.is_file() else ""
|
|
carried = _section_body(hot_text, "Key Takeaways")
|
|
dropped = [
|
|
name for name in ("Active Threads", "Recent Activity")
|
|
if _section_body(hot_text, name, prefix=True) and not hot_generated
|
|
]
|
|
recoverable = extract_threads(hot_text) if not hot_generated else []
|
|
|
|
return {
|
|
"vault": str(vault),
|
|
"migrated": index_generated and hot_generated,
|
|
"index": {
|
|
"generated": index_generated,
|
|
"lines_before": len(old_index.splitlines()),
|
|
"lines_after": len(preview.text.splitlines()),
|
|
"entries_added": len(preview.added),
|
|
"sections_preserved": custom,
|
|
},
|
|
"hot": {
|
|
"generated": hot_generated,
|
|
"takeaways_carried": bool(carried.strip()) and carried.strip() != _TAKEAWAYS_PLACEHOLDER,
|
|
"sections_regenerated": dropped,
|
|
"threads_to_seed": recoverable,
|
|
},
|
|
}
|
|
|
|
|
|
def migrate(vault: Path, *, link_format: str = "wikilink", backup: bool = True) -> dict:
|
|
"""Adopt the memory files, taking a timestamped backup first.
|
|
|
|
The backup is the whole point: everything the generator cannot reconstruct
|
|
— a hand-written Active Threads narrative, a custom section's position — is
|
|
recoverable from it.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
status = migration_status(vault)
|
|
hot = vault / "hot.md"
|
|
stamp = clock.compact(clock.wiki_timezone(vault))
|
|
backup_dir = vault / MIGRATION_BACKUP_DIR / f"pre-memory-migration-{stamp}"
|
|
saved = []
|
|
|
|
with memory_lock(vault):
|
|
if backup:
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
for name in ("index.md", "hot.md"):
|
|
source = vault / name
|
|
if source.is_file():
|
|
atomic_write(backup_dir / name, source.read_text(encoding="utf-8"))
|
|
saved.append(f"{MIGRATION_BACKUP_DIR}/{backup_dir.name}/{name}")
|
|
# Seed the todo table from the old prose before regenerating, so the
|
|
# rebuilt Active Threads section shows the same threads rather than
|
|
# "none". Only when the table is empty — never overwrite real todos.
|
|
seeded = []
|
|
if not load_todos(vault):
|
|
for text in extract_threads(
|
|
(vault / "hot.md").read_text(encoding="utf-8") if hot.is_file() else ""
|
|
):
|
|
seeded.append(add_todo(vault, text, origin="migrated from hot.md", lock=False).id)
|
|
|
|
index = rebuild_index(vault, link_format=link_format, lock=False, force=True)
|
|
hot_result = rebuild_hot(vault, lock=False, force=True, link_format=link_format)
|
|
mark_adopted(vault)
|
|
|
|
return {
|
|
"vault": str(vault),
|
|
"backup": saved,
|
|
"backup_dir": str(backup_dir) if saved else "",
|
|
"index": {"total": index.total, "added": list(index.added), "removed": list(index.removed)},
|
|
"hot": {"words": hot_result.words, "trimmed": hot_result.trimmed},
|
|
"threads_seeded": seeded,
|
|
"was_migrated": status["migrated"],
|
|
}
|
|
|
|
|
|
TIMEZONE_BACKUP_PREFIX = "pre-timezone-migration-"
|
|
_TIMEZONE_SKIP_DIRS = frozenset({MIGRATION_BACKUP_DIR, "_archived"})
|
|
_BARE_DATE_KEY_RE = re.compile(
|
|
r"^(?:created|updated|lifecycle_changed|reviewed_at|adopted):[ \t]*\"?\d{4}-\d{2}-\d{2}\"?[ \t]*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def _timezone_targets(vault: Path, skip: Sequence[str]) -> list:
|
|
"""Every file the timezone migration may rewrite: pages, the memory files,
|
|
the ``_meta/`` tables, drafts in ``_raw/`` and ``_staging/``, and the
|
|
adoption record. Never ``_archives/`` (the backups), ``_raw/_archived/``
|
|
(source snapshots), hidden dirs, instruction files, ``.okignore`` matches,
|
|
or anything that is not Markdown — JSON ledgers keep their own stamps."""
|
|
import fnmatch
|
|
|
|
from obsidian_wiki.vault import _walk_md, is_instruction_file, okignore_patterns, okignored, skipped_dir
|
|
|
|
patterns = okignore_patterns(vault)
|
|
targets = []
|
|
for path in sorted(_walk_md(vault)):
|
|
rel = path.relative_to(vault)
|
|
if skipped_dir(rel, _TIMEZONE_SKIP_DIRS) or is_instruction_file(rel) or okignored(rel, patterns):
|
|
continue
|
|
targets.append(path)
|
|
adopted = vault / ADOPTED_REL
|
|
if adopted.is_file():
|
|
targets.append(adopted)
|
|
return [
|
|
path for path in targets
|
|
if not any(fnmatch.fnmatch(path.relative_to(vault).as_posix(), pattern) for pattern in skip)
|
|
]
|
|
|
|
|
|
def _timezone_target_zone(vault: Path, zone: str) -> str:
|
|
name = (zone or "").strip().strip("\"'")
|
|
if not name:
|
|
name = clock.timezone_name(vault)
|
|
if not name:
|
|
raise MemoryError_(
|
|
"no_timezone",
|
|
"no timezone to convert to: pass one (`memory timezone America/Detroit`) "
|
|
"or set `timezone:` in index.md first",
|
|
)
|
|
clock.parse_timezone(name) # TimezoneError on an unknown name, before any file is read
|
|
return name
|
|
|
|
|
|
def timezone_migration_plan(vault: Path, zone: str = "", *, skip: Sequence[str] = ()) -> dict:
|
|
"""What ``migrate_timezone`` would change, without writing anything.
|
|
|
|
Every zoned stamp in every target file is re-rendered in the vault's zone
|
|
(or *zone*, which ``--apply`` would also record in ``index.md``). Bare dates
|
|
and naive stamps are counted and left alone.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
name = _timezone_target_zone(vault, zone)
|
|
tz = clock.parse_timezone(name)
|
|
files = []
|
|
stamps = naive_total = bare_total = 0
|
|
for path in _timezone_targets(vault, skip):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
_new, changes, naive = clock.convert_text(text, tz)
|
|
naive_total += naive
|
|
bare_total += len(_BARE_DATE_KEY_RE.findall(split_frontmatter(text)[0]))
|
|
if changes:
|
|
rel = path.relative_to(vault).as_posix()
|
|
files.append({
|
|
"path": rel,
|
|
"stamps": [{"line": c.line, "old": c.old, "new": c.new} for c in changes],
|
|
})
|
|
stamps += len(changes)
|
|
return {
|
|
"vault": str(vault),
|
|
"timezone": name,
|
|
"key_current": clock.timezone_name(vault),
|
|
"files": files,
|
|
"file_count": len(files),
|
|
"stamp_count": stamps,
|
|
"naive_skipped": naive_total,
|
|
"bare_dates": bare_total,
|
|
"skip": list(skip),
|
|
}
|
|
|
|
|
|
def migrate_timezone(vault: Path, zone: str = "", *, skip: Sequence[str] = (), backup: bool = True) -> dict:
|
|
"""Rewrite every zoned stamp in the vault in its zone, with a backup first.
|
|
|
|
Under the memory lock: the original of every file that will change is
|
|
copied to ``_archives/pre-timezone-migration-<stamp>/<same path>``, the
|
|
key is recorded in ``index.md`` when *zone* was given, the files are
|
|
rewritten atomically, and one ``TIMEZONE`` line is appended to the log —
|
|
in the new zone, after the log itself was converted. Re-running is a
|
|
no-op: a stamp already in the zone renders identically.
|
|
"""
|
|
vault = _require_vault(vault)
|
|
name = _timezone_target_zone(vault, zone)
|
|
tz = clock.parse_timezone(name)
|
|
backup_dir = vault / MIGRATION_BACKUP_DIR / f"{TIMEZONE_BACKUP_PREFIX}{clock.compact(tz)}"
|
|
index = vault / "index.md"
|
|
files = []
|
|
stamps = naive_total = bare_total = 0
|
|
saved: list = []
|
|
|
|
def _save(path: Path, text: str) -> None:
|
|
if not backup:
|
|
return
|
|
target = backup_dir / path.relative_to(vault)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_write(target, text)
|
|
saved.append(path.relative_to(vault).as_posix())
|
|
|
|
with memory_lock(vault):
|
|
set_key = bool(zone.strip()) and clock.timezone_name(vault) != name
|
|
if set_key and index.is_file():
|
|
_save(index, index.read_text(encoding="utf-8"))
|
|
clock.set_timezone(vault, name)
|
|
for path in _timezone_targets(vault, skip):
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
new_text, changes, naive = clock.convert_text(text, tz)
|
|
naive_total += naive
|
|
bare_total += len(_BARE_DATE_KEY_RE.findall(split_frontmatter(text)[0]))
|
|
if not changes:
|
|
continue
|
|
rel = path.relative_to(vault).as_posix()
|
|
if rel not in saved:
|
|
_save(path, text)
|
|
atomic_write(path, new_text)
|
|
files.append({
|
|
"path": rel,
|
|
"stamps": [{"line": c.line, "old": c.old, "new": c.new} for c in changes],
|
|
})
|
|
stamps += len(changes)
|
|
line = append_log(
|
|
vault,
|
|
"TIMEZONE",
|
|
{
|
|
"zone": name,
|
|
"files": len(files),
|
|
"stamps": stamps,
|
|
"backup": f"{MIGRATION_BACKUP_DIR}/{backup_dir.name}" if saved else "",
|
|
},
|
|
lock=False,
|
|
)
|
|
|
|
return {
|
|
"vault": str(vault),
|
|
"timezone": name,
|
|
"key_set": set_key,
|
|
"files": files,
|
|
"file_count": len(files),
|
|
"stamp_count": stamps,
|
|
"naive_skipped": naive_total,
|
|
"bare_dates": bare_total,
|
|
"skip": list(skip),
|
|
"backup": saved,
|
|
"backup_dir": str(backup_dir) if saved else "",
|
|
"log_line": line,
|
|
}
|
|
|
|
|
|
def memory_status(vault: Path) -> dict:
|
|
"""Machine-readable health of the memory surface."""
|
|
vault = _require_vault(vault)
|
|
hot = vault / "hot.md"
|
|
hot_text = hot.read_text(encoding="utf-8") if hot.is_file() else ""
|
|
hot_values = parse_frontmatter(split_frontmatter(hot_text)[0]) if hot_text else {}
|
|
index_drift = rebuild_index(vault, write=False, lock=False, force=True)
|
|
todos = load_todos(vault)
|
|
return {
|
|
"vault": str(vault),
|
|
"timezone": clock.timezone_name(vault) or "UTC",
|
|
"pages": index_drift.total,
|
|
"index_drift": {
|
|
"added": list(index_drift.added),
|
|
"removed": list(index_drift.removed),
|
|
"stale": index_drift.changed,
|
|
},
|
|
"log_entries": len(read_log(vault)),
|
|
"hot": {
|
|
"exists": hot.is_file(),
|
|
"updated": str(hot_values.get("updated") or ""),
|
|
"words": content_words(hot_text),
|
|
"max_words": hot_max_words(),
|
|
"over_budget": content_words(hot_text) > hot_max_words(),
|
|
"generated": bool(hot_values.get("generated_by")),
|
|
},
|
|
"migrated": is_adopted(vault) or (is_generated(vault / "index.md") and is_generated(hot)),
|
|
"profile_facts": len(load_profile(vault)),
|
|
"todos": {
|
|
"open": sum(1 for t in todos if t.status == "open"),
|
|
"stale": sum(1 for t in todos if t.is_stale()),
|
|
"closed": sum(1 for t in todos if t.status != "open"),
|
|
},
|
|
}
|