From 8fc5d84b634c4b7932b36f29a3f893e1047466ad Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 13 Nov 2026 16:20:04 -0800 Subject: [PATCH 01/11] feat(memory): dokimi memory lint checks an agent's memory directory (#728) Reads a Claude Code auto-memory directory (MEMORY.md, topic files, archive/) and reports deterministic findings: dead index links, the 200-line / 25KB load limit, orphans, frontmatter, types, duplicate names, dangling wikilinks, undated relative dates, stray separators. Signed-off-by: Nikolay Petrov --- .../20261113_120000_napetrov_memory-lint.md | 9 + dokimi/docs/README.md | 3 + dokimi/docs/architecture.md | 6 + dokimi/docs/memory.md | 33 ++++ dokimi/docs/reference/cli-interface.json | 100 +++++++++++ dokimi/dokimi/__init__.py | 1 + dokimi/dokimi/cli.py | 2 + dokimi/dokimi/cli_routes.py | 2 + dokimi/dokimi/commands/memory.py | 47 +++++ dokimi/dokimi/memory/__init__.py | 13 ++ dokimi/dokimi/memory/lint.py | 166 ++++++++++++++++++ dokimi/dokimi/memory/store.py | 128 ++++++++++++++ dokimi/mkdocs.yml | 1 + dokimi/tests/test_cli_routes.py | 2 +- dokimi/tests/test_import_architecture.py | 1 + dokimi/tests/test_memory_lint.py | 112 ++++++++++++ 16 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md create mode 100644 dokimi/docs/memory.md create mode 100644 dokimi/dokimi/commands/memory.py create mode 100644 dokimi/dokimi/memory/__init__.py create mode 100644 dokimi/dokimi/memory/lint.py create mode 100644 dokimi/dokimi/memory/store.py create mode 100644 dokimi/tests/test_memory_lint.py diff --git a/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md b/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md new file mode 100644 index 000000000..d0c1eb3e0 --- /dev/null +++ b/dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md @@ -0,0 +1,9 @@ +### Added + +- **`dokimi memory lint` checks an agent's memory directory** — the first leaf of the `memory` root (#728). + It reads a Claude Code auto-memory directory (`MEMORY.md`, topic files, `archive/`) and reports, with no + model call: index links to missing files (`fail`), an index past the 200-line / 25KB load limit (`fail`), + files the index never links, frontmatter that is missing, not YAML or lacks `name`/`description`/`type`, + unknown types, duplicate names, dangling `[[wikilinks]]`, relative dates ("today", "вчера") with no date + next to them, and empty list slots left in the index. Exit 1 on a `fail` (on a `warn` too with `--fail-on warn`); + `--json` prints `cli_output.v1`. diff --git a/dokimi/docs/README.md b/dokimi/docs/README.md index 7c420031d..11c7e85e8 100644 --- a/dokimi/docs/README.md +++ b/dokimi/docs/README.md @@ -327,6 +327,9 @@ code. - [context-window.md](context-window.md) — read a run's context window from its Claude Code trace with `cli.py trace inspect`: start-up load, growth, compactions, tool-result weight. No LLM call. +- [memory.md](memory.md) — check an agent's memory directory (Claude Code auto + memory) with `dokimi memory lint`: index links and load limit, frontmatter, + wikilinks, undated relative dates. No LLM call. - [session-prefilter.md](session-prefilter.md) — which AGENTS.md or CLAUDE.md rules the Claude Code session logs triggered, acted on and were corrected on, with `cli.py trace prefilter`: candidates for ablation, not verdicts. No LLM call. diff --git a/dokimi/docs/architecture.md b/dokimi/docs/architecture.md index cafc3eefe..1e391e843 100644 --- a/dokimi/docs/architecture.md +++ b/dokimi/docs/architecture.md @@ -443,6 +443,12 @@ against them (trigger, acted, corrected) and ranks candidates for ablation. Both are deterministic; `python cli.py trace prefilter`. Guide: [session-prefilter.md](session-prefilter.md). +## Agent memory + +`dokimi/memory/store.py` reads a Claude Code auto-memory directory (`MEMORY.md`, +topic files with YAML frontmatter, `archive/`); `dokimi/memory/lint.py` runs +deterministic checks on it; `dokimi memory lint`. Guide: [memory.md](memory.md). + ## Shared building blocks - `dokimi/process.py` — the one place in the package that spawns a process diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md new file mode 100644 index 000000000..2c74e2554 --- /dev/null +++ b/dokimi/docs/memory.md @@ -0,0 +1,33 @@ +# Agent memory + +`dokimi memory` treats an agent's memory store as a subject (tracking issue +[#728](https://github.com/intel/dokimi/issues/728)). The first reader is Claude Code auto memory, +`~/.claude/projects//memory/`: a `MEMORY.md` index whose first 200 lines or 25KB load at +every session start, and one topic file per memory with YAML frontmatter (`name`, `description`, +`type`: user, feedback, project or reference). Files under `archive/` are read, never expected in the index. + +## memory lint + +```bash +dokimi memory lint ~/.claude/projects//memory +dokimi memory lint ~/.claude/projects//memory --json --fail-on warn +``` + +No model call. Each finding has a rule, a severity, a file and a line: + +| rule | severity | what | +|---|---|---| +| `index-dead-link` | fail | the index links a file that does not exist | +| `index-over-limit` | fail | the index is past the load limit (200 lines or 25KB, as Claude Code loads it) | +| `index-near-limit` | warn | over 80% of the limit | +| `index-missing` | warn | no `MEMORY.md` | +| `index-orphan` | warn | a top-level file the index never links | +| `frontmatter-missing`, `frontmatter-invalid`, `frontmatter-field` | warn | no frontmatter, not YAML, no `name`/`description`/`type` | +| `type-unknown` | warn | a type other than the four | +| `name-duplicate` | warn | two files share a `name` | +| `relative-date` | warn | "today", "next week", "вчера" with no date after it | +| `wikilink-dangling` | note | `[[x]]` matches no file stem or name (`-` and `_` fold) | +| `index-stray-separator` | note | `:,`, `,,`, `()` or a trailing `,` in an index line | + +Exit 0 when there is no `fail` (no `warn` either with `--fail-on warn`), 1 otherwise, 2 when the path is +not a memory directory. diff --git a/dokimi/docs/reference/cli-interface.json b/dokimi/docs/reference/cli-interface.json index 2fd48f345..b1e73a681 100644 --- a/dokimi/docs/reference/cli-interface.json +++ b/dokimi/docs/reference/cli-interface.json @@ -9644,6 +9644,106 @@ } } }, + "memory": { + "allow_abbrev": true, + "params": [ + { + "action": "_HelpAction", + "choices": null, + "const": null, + "default": "==SUPPRESS==", + "group_key": null, + "group_required": null, + "hidden": false, + "is_flag": true, + "key": "--help/-h", + "kind": "option", + "nargs": 0, + "position": null, + "required": false, + "type": null + } + ], + "path": "dokimi memory", + "subcommand_required": true, + "subcommands": { + "lint": { + "allow_abbrev": true, + "params": [ + { + "action": "_StoreAction", + "choices": null, + "const": null, + "default": null, + "group_key": null, + "group_required": null, + "hidden": false, + "is_flag": false, + "key": "memory_dir", + "kind": "argument", + "nargs": null, + "position": 0, + "required": true, + "type": null + }, + { + "action": "_StoreAction", + "choices": [ + "'fail'", + "'warn'" + ], + "const": null, + "default": "fail", + "group_key": null, + "group_required": null, + "hidden": false, + "is_flag": false, + "key": "--fail-on", + "kind": "option", + "nargs": null, + "position": null, + "required": false, + "type": null + }, + { + "action": "_HelpAction", + "choices": null, + "const": null, + "default": "==SUPPRESS==", + "group_key": null, + "group_required": null, + "hidden": false, + "is_flag": true, + "key": "--help/-h", + "kind": "option", + "nargs": 0, + "position": null, + "required": false, + "type": null + }, + { + "action": "_StoreTrueAction", + "choices": null, + "const": true, + "default": false, + "group_key": null, + "group_required": null, + "hidden": false, + "is_flag": true, + "key": "--json", + "kind": "option", + "nargs": 0, + "position": null, + "required": false, + "type": null + } + ], + "path": "dokimi memory lint", + "subcommand_required": null, + "subcommands": {} + } + } + }, "novelty": { "allow_abbrev": true, "params": [ diff --git a/dokimi/dokimi/__init__.py b/dokimi/dokimi/__init__.py index c8cccae2e..cc858f110 100644 --- a/dokimi/dokimi/__init__.py +++ b/dokimi/dokimi/__init__.py @@ -25,6 +25,7 @@ directories; [`task_formats`][dokimi.task_formats] names the format of other eval-task files and directories (detection only). - [`context`][dokimi.context] reads what a run's context window held. +- [`memory`][dokimi.memory] reads an agent's memory store and lints it. - [`runs`][dokimi.runs] lists what the runs wrote: the artifacts under the runs root (`results/`, or `DOKIMI_RUNS_DIR`). - Content novelty: [`novelty`][dokimi.novelty]. Repositories as subjects: diff --git a/dokimi/dokimi/cli.py b/dokimi/dokimi/cli.py index d64a2be22..e377d5e63 100755 --- a/dokimi/dokimi/cli.py +++ b/dokimi/dokimi/cli.py @@ -25,6 +25,7 @@ data, evaluate, explore, + memory, novelty, orchestrate, personas, @@ -226,6 +227,7 @@ def positive_int(value: str) -> int: novelty.register(sub, positive_int) # novelty (routing, view: not yet moved) repo.register(sub, positive_int, eval_sub) # repo; eval check tools.register(sub, positive_int) # tools (tool-choice probes) + memory.register(sub, positive_int) # memory: lint (#728) context.register(sub, positive_int) # trace: inspect, consistency, prefilter (#94 M2) runs.register(sub, positive_int) # runs (the artifacts under the runs root) backends.register(sub, positive_int) # backends (CLI providers) diff --git a/dokimi/dokimi/cli_routes.py b/dokimi/dokimi/cli_routes.py index 774601ef9..74b2b8b0b 100644 --- a/dokimi/dokimi/cli_routes.py +++ b/dokimi/dokimi/cli_routes.py @@ -77,6 +77,7 @@ class Tombstone: Root("skill", "Subjects"), Root("novelty", "Subjects"), Root("repo", "Subjects"), + Root("memory", "Subjects"), Root("eval", "Evals"), Root("tools", "Analysis and data"), Root("trace", "Analysis and data"), @@ -168,6 +169,7 @@ class Tombstone: Leaf(("data", "export"), FREE), Leaf(("data", "import", "docling"), FREE), Leaf(("tools", "preference"), PAID), + Leaf(("memory", "lint"), FREE), Leaf(("trace", "inspect"), FREE), Leaf(("trace", "consistency"), FREE), Leaf(("trace", "prefilter"), FREE), diff --git a/dokimi/dokimi/commands/memory.py b/dokimi/dokimi/commands/memory.py new file mode 100644 index 000000000..2206ef929 --- /dev/null +++ b/dokimi/dokimi/commands/memory.py @@ -0,0 +1,47 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""memory subcommand group: an agent's memory store as a subject (#728).""" + +from __future__ import annotations + +import argparse +import sys + +from dokimi.utils import InputError + + +def cmd_memory_lint(args: argparse.Namespace) -> None: + """Deterministic checks of a memory directory; exit 1 when a finding is ``fail`` (or ``warn`` with --fail-on warn).""" + from dokimi import cli_output + from dokimi.memory import MemoryStoreError, read_claude_memory + from dokimi.memory.lint import lint, render_text, report + + try: + store = read_claude_memory(args.memory_dir) + except (MemoryStoreError, OSError) as exc: + raise InputError(str(exc)) from None + doc = report(store, lint(store)) + bad = doc["counts"]["fail"] or (args.fail_on == "warn" and doc["counts"]["warn"]) + if args.json: + cli_output.emit_leaf("memory lint", doc, status="complete") + else: + print(render_text(doc)) + if bad: + sys.exit(1) + + +def register(sub, positive_int) -> None: + """Add the `memory` group: check an agent's memory store. No LLM call.""" + mem_p = sub.add_parser("memory", help="An agent's memory store (Claude Code auto memory) as a subject. No LLM call.") + mem_sub = mem_p.add_subparsers(dest="memory_cmd", required=True) + lint_p = mem_sub.add_parser( + "lint", + help="Deterministic checks of a memory directory: index dead links, orphans and load limit, " + "frontmatter fields and types, duplicate names, dangling [[wikilinks]], undated relative dates.", + ) + lint_p.add_argument("memory_dir", help="The memory directory, e.g. ~/.claude/projects//memory") + lint_p.add_argument("--fail-on", choices=("warn", "fail"), default="fail", + help="Exit 1 on a finding of this severity or worse (default fail)") + lint_p.add_argument("--json", action="store_true", help="Print cli_output.v1 on stdout (the report under data)") + lint_p.set_defaults(func=cmd_memory_lint, emits_cli_output=True, cli_kind="memory lint") diff --git a/dokimi/dokimi/memory/__init__.py b/dokimi/dokimi/memory/__init__.py new file mode 100644 index 000000000..507ce80b9 --- /dev/null +++ b/dokimi/dokimi/memory/__init__.py @@ -0,0 +1,13 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Agent memory as an evaluation subject: read a harness's memory store, then check it. + +The first reader is Claude Code auto memory (``~/.claude/projects//memory/``): +a ``MEMORY.md`` index loaded at every session start and one topic file per memory. +Tracking issue: https://github.com/intel/dokimi/issues/728 +""" + +from dokimi.memory.store import MemoryEntry, MemoryStore, MemoryStoreError, read_claude_memory + +__all__ = ["MemoryEntry", "MemoryStore", "MemoryStoreError", "read_claude_memory"] diff --git a/dokimi/dokimi/memory/lint.py b/dokimi/dokimi/memory/lint.py new file mode 100644 index 000000000..86d8c1de0 --- /dev/null +++ b/dokimi/dokimi/memory/lint.py @@ -0,0 +1,166 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Deterministic checks of a memory store: index links and size, frontmatter, links, dates. + +No model call and no network. Every finding names a rule, a severity, a file and a line. +Severities: ``fail`` (the index points at nothing, or loses entries past the load limit), +``warn`` (the entry is likely wrong or stale) and ``note`` (worth a look, often fine). +""" + +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass +from pathlib import PurePosixPath + +from dokimi.memory.store import ARCHIVE_DIR, INDEX_NAME, MemoryEntry, MemoryStore, slug + +#: The four kinds Claude Code writes (https://code.claude.com/docs/en/memory). +MEMORY_TYPES = ("user", "feedback", "project", "reference") +#: Claude Code loads the first 200 lines or 25KB of MEMORY.md, whichever comes first. +INDEX_MAX_LINES = 200 +INDEX_MAX_BYTES = 25 * 1024 +SEVERITIES = ("fail", "warn", "note") + +_RELATIVE = re.compile( + r"\b(today|yesterday|tomorrow|tonight|this (?:week|morning|afternoon)|next week|last week)\b" + r"|(? Iterable[Finding]: + index = store.index + if index is None: + yield Finding("index-missing", "warn", INDEX_NAME, None, + f"no {INDEX_NAME}: nothing is loaded at session start") + return + lines = index.text.splitlines() + size = len(index.text.encode("utf-8")) + if len(lines) > max_lines or size > max_bytes: + yield Finding("index-over-limit", "fail", INDEX_NAME, min(len(lines), max_lines + 1), + f"{len(lines)} lines / {size} bytes; only the first {max_lines} lines or " + f"{max_bytes} bytes load, so later entries are never seen at start") + elif len(lines) > 0.8 * max_lines or size > 0.8 * max_bytes: + yield Finding("index-near-limit", "warn", INDEX_NAME, None, + f"{len(lines)} lines / {size} bytes, over 80% of the load limit " + f"({max_lines} lines, {max_bytes} bytes)") + for n, line in enumerate(lines, 1): + if line.lstrip().startswith(("-", "*")) and _STRAY_SEPARATOR.search(line): + yield Finding("index-stray-separator", "note", INDEX_NAME, n, + "empty list slot (':,', ',,', '()' or a trailing ','), often left by removing a link") + + +def _link_findings(store: MemoryStore) -> Iterable[Finding]: + linked: set[str] = set() + for n, target in store.index_links(): + path = PurePosixPath(target.removeprefix("./")) + if (store.root / path).exists(): + linked.add(path.as_posix()) + else: + yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which does not exist") + if store.index is None: + return + for entry in store.entries: + if not entry.archived and entry.path not in linked: + yield Finding("index-orphan", "warn", entry.path, None, + f"not linked from {INDEX_NAME}: the agent only finds it by searching") + + +def _frontmatter_findings(entry: MemoryEntry) -> Iterable[Finding]: + if entry.frontmatter_error: + yield Finding("frontmatter-invalid", "warn", entry.path, 1, entry.frontmatter_error) + return + if entry.frontmatter is None: + yield Finding("frontmatter-missing", "warn", entry.path, 1, + "no YAML frontmatter: no name, description or type to recall it by") + return + for key in ("name", "description"): + if not entry.frontmatter.get(key): + yield Finding("frontmatter-field", "warn", entry.path, 1, f"frontmatter has no '{key}'") + kind = entry.type + if kind is None: + yield Finding("frontmatter-field", "warn", entry.path, 1, "frontmatter has no 'type'") + elif kind not in MEMORY_TYPES: + yield Finding("type-unknown", "warn", entry.path, 1, + f"type '{kind}' is not one of {', '.join(MEMORY_TYPES)}") + + +def _entry_findings(store: MemoryStore) -> Iterable[Finding]: + slugs = store.slugs() + names = Counter(entry.name for entry in store.entries if entry.name) + for entry in store.entries: + yield from _frontmatter_findings(entry) + if entry.name and names[entry.name] > 1: + yield Finding("name-duplicate", "warn", entry.path, 1, + f"name '{entry.name}' is used by {names[entry.name]} files") + for n, target in entry.wikilinks: + if slug(target) not in slugs: + yield Finding("wikilink-dangling", "note", entry.path, n, + f"[[{target}]] matches no file stem or name (fine if it marks a memory to write)") + yield from _date_findings(entry) + + +def _date_findings(entry: MemoryEntry) -> Iterable[Finding]: + offset = entry.text.count("\n", 0, len(entry.text) - len(entry.body)) + for n, line in enumerate(entry.body.splitlines(), offset + 1): + for m in _RELATIVE.finditer(line): + if not _DATED.match(line, m.end()): + yield Finding("relative-date", "warn", entry.path, n, + f"'{m.group(0)}' without a date: it is wrong on the next read") + + +def lint(store: MemoryStore, *, max_lines: int = INDEX_MAX_LINES, max_bytes: int = INDEX_MAX_BYTES) -> list[Finding]: + """Every finding of ``store``, in rule order, then file and line.""" + checks: tuple[Callable[[], Iterable[Finding]], ...] = ( + lambda: _index_findings(store, max_lines, max_bytes), + lambda: _link_findings(store), + lambda: _entry_findings(store), + ) + return [f for check in checks for f in check()] + + +def report(store: MemoryStore, findings: list[Finding], *, max_lines: int = INDEX_MAX_LINES, + max_bytes: int = INDEX_MAX_BYTES) -> dict: + """The ``memory lint`` document: counts, then every finding.""" + by_sev = Counter(f.severity for f in findings) + by_rule = Counter(f.rule for f in findings) + index_text = store.index.text if store.index else "" + return { + "root": str(store.root), + "entries": sum(not e.archived for e in store.entries), + "archived": sum(e.archived for e in store.entries), + "archive_dir": ARCHIVE_DIR, + "index": {"lines": len(index_text.splitlines()), "bytes": len(index_text.encode("utf-8")), + "max_lines": max_lines, "max_bytes": max_bytes}, + "types": dict(Counter(e.type or "none" for e in store.entries if not e.archived)), + "counts": {s: by_sev.get(s, 0) for s in SEVERITIES}, + "rules": dict(sorted(by_rule.items())), + "findings": [asdict(f) for f in findings], + } + + +def render_text(doc: dict) -> str: + """Findings one per line, ``severity rule file:line message``, then a counts line.""" + out = [] + for f in doc["findings"]: + where = f"{f['file']}:{f['line']}" if f["line"] else f["file"] + out.append(f"{f['severity']:<4} {f['rule']:<22} {where} {f['message']}") + c, idx = doc["counts"], doc["index"] + out.append(f"{doc['entries']} entries ({doc['archived']} archived), index {idx['lines']} lines / " + f"{idx['bytes']} bytes: {c['fail']} fail, {c['warn']} warn, {c['note']} note") + return "\n".join(out) diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py new file mode 100644 index 000000000..de5d7d987 --- /dev/null +++ b/dokimi/dokimi/memory/store.py @@ -0,0 +1,128 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Read a Claude Code auto-memory directory into entries: the index, topic files, the archive. + +Layout (https://code.claude.com/docs/en/memory): ``MEMORY.md`` is the index, every other +``*.md`` is a topic file with optional YAML frontmatter (``name``, ``description``, ``type``, +``modified``). Files under ``archive/`` are kept but not linked from the index. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +INDEX_NAME = "MEMORY.md" +ARCHIVE_DIR = "archive" +_FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.S) +_MD_LINK = re.compile(r"\]\(([^)\s]+)\)") +_WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") + + +class MemoryStoreError(ValueError): + """The path is not a memory directory that can be read.""" + + +@dataclass +class MemoryEntry: + """One memory file. ``path`` is relative to the memory directory, with ``/`` separators.""" + + path: str + text: str + body: str + frontmatter: dict | None + frontmatter_error: str | None = None + archived: bool = False + + @property + def name(self) -> str | None: + value = (self.frontmatter or {}).get("name") + return str(value) if value else None + + @property + def type(self) -> str | None: + """``type`` at the top level, or under ``metadata`` as some writers put it.""" + fm = self.frontmatter or {} + meta = fm.get("metadata") + value = fm.get("type") or (meta.get("type") if isinstance(meta, dict) else None) + return str(value) if value else None + + @property + def wikilinks(self) -> list[tuple[int, str]]: + """``(line, target)`` of every ``[[target]]``, 1-based lines of the whole file.""" + return [(n, m.group(1).strip()) for n, line in enumerate(self.text.splitlines(), 1) + for m in _WIKILINK.finditer(line)] + + +@dataclass +class MemoryStore: + """A memory directory: the index (``None`` when absent) and every entry under it.""" + + root: Path + index: MemoryEntry | None + entries: list[MemoryEntry] = field(default_factory=list) + + def slugs(self) -> dict[str, MemoryEntry]: + """Every name an entry answers to: its file stem and its ``name``, ``-`` and ``_`` folded.""" + out: dict[str, MemoryEntry] = {} + for entry in self.entries: + for key in (Path(entry.path).stem, entry.name): + if key: + out.setdefault(slug(key), entry) + return out + + def index_links(self) -> list[tuple[int, str]]: + """``(line, target)`` of every relative markdown link in the index (URLs and anchors left out).""" + if self.index is None: + return [] + out = [] + for n, line in enumerate(self.index.text.splitlines(), 1): + for m in _MD_LINK.finditer(line): + target = m.group(1).split("#", 1)[0] + if target and "://" not in target and not target.startswith("mailto:"): + out.append((n, target)) + return out + + +def slug(name: str) -> str: + """The key two spellings of one memory name share: ``project_foo.md`` and ``project-foo``.""" + name = name.strip() + if name.lower().endswith(".md"): + name = name[:-3] + return name.replace("_", "-").lower() + + +def _entry(root: Path, path: Path, archived: bool) -> MemoryEntry: + text = path.read_text(encoding="utf-8", errors="replace") + rel = path.relative_to(root).as_posix() + m = _FRONTMATTER.match(text) + if not m: + return MemoryEntry(rel, text, text, None, archived=archived) + body = text[m.end():] + try: + data = yaml.safe_load(m.group(1)) + except yaml.YAMLError as exc: + return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) + if not isinstance(data, dict): + return MemoryEntry(rel, text, body, None, "frontmatter is not a mapping", archived) + return MemoryEntry(rel, text, body, data, archived=archived) + + +def read_claude_memory(root: str | Path) -> MemoryStore: + """Read ``root`` as a Claude Code memory directory; no file is written.""" + root = Path(root) + if not root.is_dir(): + raise MemoryStoreError(f"{root}: not a directory") + index_path = root / INDEX_NAME + index = _entry(root, index_path, False) if index_path.is_file() else None + entries = [_entry(root, p, False) for p in sorted(root.glob("*.md")) if p.name != INDEX_NAME and p.is_file()] + archive = root / ARCHIVE_DIR + if archive.is_dir(): + entries += [_entry(root, p, True) for p in sorted(archive.rglob("*.md")) if p.is_file()] + if index is None and not entries: + raise MemoryStoreError(f"{root}: no {INDEX_NAME} and no *.md files") + return MemoryStore(root, index, entries) diff --git a/dokimi/mkdocs.yml b/dokimi/mkdocs.yml index 4a69b3f74..131c62be2 100644 --- a/dokimi/mkdocs.yml +++ b/dokimi/mkdocs.yml @@ -121,6 +121,7 @@ nav: - subscription-backends.md - answer-formats.md - context-window.md + - memory.md - session-prefilter.md - Content novelty: - skill-novelty.md diff --git a/dokimi/tests/test_cli_routes.py b/dokimi/tests/test_cli_routes.py index 6a74fa827..dad782f6d 100644 --- a/dokimi/tests/test_cli_routes.py +++ b/dokimi/tests/test_cli_routes.py @@ -92,7 +92,7 @@ def test_help_lists_each_root_under_its_section(): loop, ), **dict.fromkeys( - ("docs", "catalog", "novelty", "repo", "skill"), subjects + ("docs", "catalog", "novelty", "repo", "memory", "skill"), subjects ), **dict.fromkeys(("eval",), evals), **dict.fromkeys(("tools", "trace", "runs", "backends", "data"), data), diff --git a/dokimi/tests/test_import_architecture.py b/dokimi/tests/test_import_architecture.py index 0e895d28e..18df85955 100644 --- a/dokimi/tests/test_import_architecture.py +++ b/dokimi/tests/test_import_architecture.py @@ -52,6 +52,7 @@ ("tracks", ( "dokimi.config_check", "dokimi.context", "dokimi.coverage", "dokimi.dashboard", "dokimi.datasets_export", "dokimi.eval_coverage", "dokimi.explore", "dokimi.external", "dokimi.harnesses", + "dokimi.memory", "dokimi.novelty", "dokimi.orchestrator", "dokimi.report", "dokimi.repos", "dokimi.runner", "dokimi.runs", "dokimi.schema_check", "dokimi.schema_compat", "dokimi.skill_coverage", diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py new file mode 100644 index 000000000..f3fb91db6 --- /dev/null +++ b/dokimi/tests/test_memory_lint.py @@ -0,0 +1,112 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""dokimi memory lint: the reader and every rule on small memory directories (offline).""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from dokimi.memory import MemoryStoreError, read_claude_memory +from dokimi.memory.lint import lint + + +def _entry(name: str, kind: str = "project", body: str = "fact") -> str: + return f"---\nname: {name}\ndescription: about {name}\ntype: {kind}\n---\n\n{body}\n" + + +@pytest.fixture +def mem(tmp_path): + (tmp_path / "MEMORY.md").write_text("# Index\n- [A](a.md) — hook\n- [B](b.md) — hook\n") + (tmp_path / "a.md").write_text(_entry("a", body="See [[b]].")) + (tmp_path / "b.md").write_text(_entry("b", "feedback")) + return tmp_path + + +def _rules(path, **kw): + return [(f.rule, f.severity, f.file) for f in lint(read_claude_memory(path), **kw)] + + +def test_clean_store_has_no_findings(mem): + assert _rules(mem) == [] + + +def test_dead_link_fails_and_unlinked_file_is_orphan(mem): + (mem / "MEMORY.md").write_text("- [A](a.md)\n- [Gone](gone.md)\n") + assert ("index-dead-link", "fail", "MEMORY.md") in _rules(mem) + assert ("index-orphan", "warn", "b.md") in _rules(mem) + + +def test_archive_is_read_but_never_orphan(mem): + (mem / "archive").mkdir() + (mem / "archive" / "old.md").write_text(_entry("old")) + store = read_claude_memory(mem) + assert [e.path for e in store.entries if e.archived] == ["archive/old.md"] + assert _rules(mem) == [] + + +def test_frontmatter_rules(mem): + (mem / "c.md").write_text("no frontmatter\n") + (mem / "d.md").write_text("---\nname: d\ndescription: x: y: z\n---\n") + (mem / "e.md").write_text("---\nname: a\ndescription: dup\nmetadata:\n type: diary\n---\n") + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [C](c.md) [D](d.md) [E](e.md)\n") + rules = {(r, f) for r, _, f in _rules(mem)} + assert {("frontmatter-missing", "c.md"), ("frontmatter-invalid", "d.md"), ("type-unknown", "e.md"), + ("name-duplicate", "a.md"), ("name-duplicate", "e.md")} <= rules + + +def test_wikilink_folds_dash_and_underscore(mem): + (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) + (mem / "project_a.md").write_text(_entry("pa")) + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [P](project_a.md)\n") + dangling = [f.message for f in lint(read_claude_memory(mem)) if f.rule == "wikilink-dangling"] + assert len(dangling) == 1 and "nobody" in dangling[0] + + +def test_relative_date_flagged_unless_dated(mem): + (mem / "a.md").write_text(_entry("a", body="Merged today.\nDone yesterday (as of ≤2026-11-01).\nСделано вчера.")) + found = [f for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] + assert [f.line for f in found] == [7, 9] + + +def test_index_limit_and_stray_separator(mem): + (mem / "MEMORY.md").write_text("- [A](a.md), [B](b.md)\n- Group:, [x](a.md)\n" + "line\n" * 10) + rules = _rules(mem, max_lines=10) + assert ("index-over-limit", "fail", "MEMORY.md") in rules + assert [r for r in rules if r[0] == "index-stray-separator"] == [("index-stray-separator", "note", "MEMORY.md")] + assert ("index-near-limit", "warn", "MEMORY.md") in _rules(mem, max_lines=14) + + +def test_missing_index_warns(tmp_path): + (tmp_path / "a.md").write_text(_entry("a")) + assert _rules(tmp_path) == [("index-missing", "warn", "MEMORY.md")] + + +def test_not_a_memory_dir(tmp_path): + with pytest.raises(MemoryStoreError): + read_claude_memory(tmp_path) + with pytest.raises(MemoryStoreError): + read_claude_memory(tmp_path / "nope") + + +def _cli(*args): + return subprocess.run([sys.executable, "-m", "dokimi.cli", "memory", "lint", *args], + capture_output=True, text=True, check=False) + + +def test_cli_exit_codes_and_json(mem): + out = _cli(str(mem), "--json") + doc = json.loads(out.stdout) + assert out.returncode == 0 and doc["kind"] == "memory lint" and doc["data"]["counts"]["fail"] == 0 + (mem / "c.md").write_text("orphan, no frontmatter\n") + assert _cli(str(mem)).returncode == 0 + assert _cli(str(mem), "--fail-on", "warn").returncode == 1 + (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") + out = _cli(str(mem)) + assert out.returncode == 1 and "index-dead-link" in out.stdout + bad = _cli(str(mem / "nope"), "--json") + assert bad.returncode == 2 and json.loads(bad.stdout)["error"]["code"] == "input" From 97539c8d9eedc59b72bb16f48ccc622e06d9b7a3 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 13 Nov 2026 16:23:48 -0800 Subject: [PATCH 02/11] fix(memory): link titles, %20, escapes, CRLF size, BOM, aliases, archive and date false positives Answers the local self-review: a titled or <> link resolves, a target outside the directory or a directory is dead, the size is read on disk, archive/ is not checked, a dated line or code span is no relative date. Signed-off-by: Nikolay Petrov --- dokimi/docs/memory.md | 8 +++--- dokimi/dokimi/commands/memory.py | 3 +- dokimi/dokimi/memory/lint.py | 48 ++++++++++++++++++++++---------- dokimi/dokimi/memory/store.py | 19 ++++++++----- dokimi/tests/test_memory_lint.py | 44 +++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 27 deletions(-) diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md index 2c74e2554..18cf557f6 100644 --- a/dokimi/docs/memory.md +++ b/dokimi/docs/memory.md @@ -4,7 +4,7 @@ [#728](https://github.com/intel/dokimi/issues/728)). The first reader is Claude Code auto memory, `~/.claude/projects//memory/`: a `MEMORY.md` index whose first 200 lines or 25KB load at every session start, and one topic file per memory with YAML frontmatter (`name`, `description`, -`type`: user, feedback, project or reference). Files under `archive/` are read, never expected in the index. +`type`: user, feedback, project or reference). Files under `archive/` are read and counted, but not checked and never expected in the index. ## memory lint @@ -17,7 +17,7 @@ No model call. Each finding has a rule, a severity, a file and a line: | rule | severity | what | |---|---|---| -| `index-dead-link` | fail | the index links a file that does not exist | +| `index-dead-link` | fail | the index links a file that does not exist, a directory, or a path outside the memory directory | | `index-over-limit` | fail | the index is past the load limit (200 lines or 25KB, as Claude Code loads it) | | `index-near-limit` | warn | over 80% of the limit | | `index-missing` | warn | no `MEMORY.md` | @@ -25,9 +25,9 @@ No model call. Each finding has a rule, a severity, a file and a line: | `frontmatter-missing`, `frontmatter-invalid`, `frontmatter-field` | warn | no frontmatter, not YAML, no `name`/`description`/`type` | | `type-unknown` | warn | a type other than the four | | `name-duplicate` | warn | two files share a `name` | -| `relative-date` | warn | "today", "next week", "вчера" with no date after it | +| `relative-date` | warn | "today", "next week", "вчера" on a line with no `YYYY-MM-DD` date (code spans skipped) | | `wikilink-dangling` | note | `[[x]]` matches no file stem or name (`-` and `_` fold) | -| `index-stray-separator` | note | `:,`, `,,`, `()` or a trailing `,` in an index line | +| `index-stray-separator` | note | `:,`, `,,` or a trailing `,` in an index line | Exit 0 when there is no `fail` (no `warn` either with `--fail-on warn`), 1 otherwise, 2 when the path is not a memory directory. diff --git a/dokimi/dokimi/commands/memory.py b/dokimi/dokimi/commands/memory.py index 2206ef929..7607f2178 100644 --- a/dokimi/dokimi/commands/memory.py +++ b/dokimi/dokimi/commands/memory.py @@ -24,7 +24,8 @@ def cmd_memory_lint(args: argparse.Namespace) -> None: doc = report(store, lint(store)) bad = doc["counts"]["fail"] or (args.fail_on == "warn" and doc["counts"]["warn"]) if args.json: - cli_output.emit_leaf("memory lint", doc, status="complete") + reason = f"{doc['counts']['fail']} fail, {doc['counts']['warn']} warn under --fail-on {args.fail_on}" if bad else None + cli_output.emit_leaf("memory lint", doc, status="failed" if bad else "complete", reason=reason) else: print(render_text(doc)) if bad: diff --git a/dokimi/dokimi/memory/lint.py b/dokimi/dokimi/memory/lint.py index 86d8c1de0..132780fbf 100644 --- a/dokimi/dokimi/memory/lint.py +++ b/dokimi/dokimi/memory/lint.py @@ -14,7 +14,7 @@ from collections import Counter from collections.abc import Callable, Iterable from dataclasses import asdict, dataclass -from pathlib import PurePosixPath +import posixpath from dokimi.memory.store import ARCHIVE_DIR, INDEX_NAME, MemoryEntry, MemoryStore, slug @@ -28,8 +28,10 @@ _RELATIVE = re.compile( r"\b(today|yesterday|tomorrow|tonight|this (?:week|morning|afternoon)|next week|last week)\b" r"|(? Itera yield Finding("index-missing", "warn", INDEX_NAME, None, f"no {INDEX_NAME}: nothing is loaded at session start") return - lines = index.text.splitlines() - size = len(index.text.encode("utf-8")) + lines = index.text.split("\n") + lines = lines[:-1] if lines and not lines[-1] else lines + size = _index_bytes(store) if len(lines) > max_lines or size > max_bytes: yield Finding("index-over-limit", "fail", INDEX_NAME, min(len(lines), max_lines + 1), f"{len(lines)} lines / {size} bytes; only the first {max_lines} lines or " @@ -62,17 +65,27 @@ def _index_findings(store: MemoryStore, max_lines: int, max_bytes: int) -> Itera for n, line in enumerate(lines, 1): if line.lstrip().startswith(("-", "*")) and _STRAY_SEPARATOR.search(line): yield Finding("index-stray-separator", "note", INDEX_NAME, n, - "empty list slot (':,', ',,', '()' or a trailing ','), often left by removing a link") + "empty list slot (':,', ',,' or a trailing ','), often left by removing a link") + + +def _index_bytes(store: MemoryStore) -> int: + """The size on disk, CRLF and BOM counted: that is what the harness truncates.""" + try: + return (store.root / INDEX_NAME).stat().st_size + except OSError: + return len(store.index.text.encode("utf-8")) if store.index else 0 def _link_findings(store: MemoryStore) -> Iterable[Finding]: linked: set[str] = set() for n, target in store.index_links(): - path = PurePosixPath(target.removeprefix("./")) - if (store.root / path).exists(): - linked.add(path.as_posix()) + path = posixpath.normpath(target) + if path.startswith(("../", "/")) or path == "..": + yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, outside the memory directory") + elif (store.root / path).is_file(): + linked.add(path) else: - yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which does not exist") + yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which is not a file") if store.index is None: return for entry in store.entries: @@ -102,8 +115,9 @@ def _frontmatter_findings(entry: MemoryEntry) -> Iterable[Finding]: def _entry_findings(store: MemoryStore) -> Iterable[Finding]: slugs = store.slugs() - names = Counter(entry.name for entry in store.entries if entry.name) - for entry in store.entries: + live = [entry for entry in store.entries if not entry.archived] # archive/ is history: not checked + names = Counter(entry.name for entry in live if entry.name) + for entry in live: yield from _frontmatter_findings(entry) if entry.name and names[entry.name] > 1: yield Finding("name-duplicate", "warn", entry.path, 1, @@ -117,9 +131,12 @@ def _entry_findings(store: MemoryStore) -> Iterable[Finding]: def _date_findings(entry: MemoryEntry) -> Iterable[Finding]: offset = entry.text.count("\n", 0, len(entry.text) - len(entry.body)) - for n, line in enumerate(entry.body.splitlines(), offset + 1): + for n, line in enumerate(entry.body.split("\n"), offset + 1): + if _DATE.search(line): # a dated line says when "today" was + continue + line = _CODE.sub(lambda c: " " * len(c.group(0)), line) for m in _RELATIVE.finditer(line): - if not _DATED.match(line, m.end()): + if not _NOT_A_WORD_USE.match(line, m.end()): yield Finding("relative-date", "warn", entry.path, n, f"'{m.group(0)}' without a date: it is wrong on the next read") @@ -140,12 +157,13 @@ def report(store: MemoryStore, findings: list[Finding], *, max_lines: int = INDE by_sev = Counter(f.severity for f in findings) by_rule = Counter(f.rule for f in findings) index_text = store.index.text if store.index else "" + index_lines = index_text.split("\n") return { "root": str(store.root), "entries": sum(not e.archived for e in store.entries), "archived": sum(e.archived for e in store.entries), "archive_dir": ARCHIVE_DIR, - "index": {"lines": len(index_text.splitlines()), "bytes": len(index_text.encode("utf-8")), + "index": {"lines": len(index_lines) - (not index_lines[-1]), "bytes": _index_bytes(store), "max_lines": max_lines, "max_bytes": max_bytes}, "types": dict(Counter(e.type or "none" for e in store.entries if not e.archived)), "counts": {s: by_sev.get(s, 0) for s in SEVERITIES}, diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py index de5d7d987..0d327ad78 100644 --- a/dokimi/dokimi/memory/store.py +++ b/dokimi/dokimi/memory/store.py @@ -11,6 +11,7 @@ from __future__ import annotations import re +from urllib.parse import unquote from dataclasses import dataclass, field from pathlib import Path @@ -18,8 +19,8 @@ INDEX_NAME = "MEMORY.md" ARCHIVE_DIR = "archive" -_FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.S) -_MD_LINK = re.compile(r"\]\(([^)\s]+)\)") +_FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)?---[ \t]*(?:\r?\n|\Z)", re.S) +_MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") _WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") @@ -54,7 +55,8 @@ def type(self) -> str | None: @property def wikilinks(self) -> list[tuple[int, str]]: """``(line, target)`` of every ``[[target]]``, 1-based lines of the whole file.""" - return [(n, m.group(1).strip()) for n, line in enumerate(self.text.splitlines(), 1) + return [(n, re.split(r"[|#]", m.group(1), maxsplit=1)[0].strip()) + for n, line in enumerate(self.text.split("\n"), 1) for m in _WIKILINK.finditer(line)] @@ -80,9 +82,9 @@ def index_links(self) -> list[tuple[int, str]]: if self.index is None: return [] out = [] - for n, line in enumerate(self.index.text.splitlines(), 1): + for n, line in enumerate(self.index.text.split("\n"), 1): for m in _MD_LINK.finditer(line): - target = m.group(1).split("#", 1)[0] + target = unquote(m.group(1).strip("<>").split("#", 1)[0]) if target and "://" not in target and not target.startswith("mailto:"): out.append((n, target)) return out @@ -97,14 +99,17 @@ def slug(name: str) -> str: def _entry(root: Path, path: Path, archived: bool) -> MemoryEntry: - text = path.read_text(encoding="utf-8", errors="replace") rel = path.relative_to(root).as_posix() + try: + text = path.read_text(encoding="utf-8-sig", errors="replace") + except OSError as exc: # one unreadable file is a finding, not the end of the run + return MemoryEntry(rel, "", "", None, f"unreadable: {exc.strerror or exc}", archived) m = _FRONTMATTER.match(text) if not m: return MemoryEntry(rel, text, text, None, archived=archived) body = text[m.end():] try: - data = yaml.safe_load(m.group(1)) + data = yaml.safe_load(m.group(1) or "") or {} except yaml.YAMLError as exc: return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) if not isinstance(data, dict): diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index f3fb91db6..4a633b53c 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -110,3 +110,47 @@ def test_cli_exit_codes_and_json(mem): assert out.returncode == 1 and "index-dead-link" in out.stdout bad = _cli(str(mem / "nope"), "--json") assert bad.returncode == 2 and json.loads(bad.stdout)["error"]["code"] == "input" + + +def test_link_forms_resolve_and_escapes_are_dead(mem): + (mem / "my file.md").write_text(_entry("mf")) + (mem / "sub").mkdir() + (mem / "MEMORY.md").write_text('- [A](a.md "title") [B]() [M](my%20file.md) [N](./sub/../a.md)\n' + "- [Up](../x.md) [Dir](sub)\n") + found = _rules(mem) + assert [r for r in found if r[0] == "index-orphan"] == [] + assert [r[0] for r in found].count("index-dead-link") == 2 + + +def test_index_size_counts_crlf_bytes_on_disk(mem): + (mem / "MEMORY.md").write_bytes(b"- [A](a.md) [B](b.md)\r\n" + b"x" * 98 + b"\r\n") + store = read_claude_memory(mem) + on_disk = (mem / "MEMORY.md").stat().st_size + assert lint(store, max_bytes=on_disk - 1)[0].rule == "index-over-limit" # LF text is 2 bytes shorter + assert lint(store, max_bytes=on_disk - 1)[0].message.startswith(f"2 lines / {on_disk} bytes") + + +def test_bom_alias_archive_and_date_false_positives(mem): + (mem / "a.md").write_text("" + _entry("a", body="[[b|alias]] [[b#Why]]\nFixed 2026-11-13 (today).\n" + "See `today()` and yesterday.md, yesterday's notes.")) + (mem / "archive").mkdir() + (mem / "archive" / "a.md").write_text(_entry("a", body="done today")) + assert _rules(mem) == [] + + +def test_unreadable_file_is_a_finding(mem): + import os + bad = mem / "b.md" + bad.chmod(0) + try: + if os.access(bad, os.R_OK): + pytest.skip("running as root: every file is readable") + assert ("frontmatter-invalid", "warn", "b.md") in _rules(mem) + finally: + bad.chmod(0o644) + + +def test_json_status_is_failed_on_a_fail(mem): + (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") + doc = json.loads(_cli(str(mem), "--json").stdout) + assert doc["status"] == "failed" and "1 fail" in doc["reason"] From cc21fd62b991b180b214f3d7a6f3d3dd680bfb41 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Fri, 13 Nov 2026 16:26:32 -0800 Subject: [PATCH 03/11] fix(memory): an empty frontmatter block ends at its own closing line Signed-off-by: Nikolay Petrov --- dokimi/dokimi/memory/lint.py | 2 +- dokimi/dokimi/memory/store.py | 2 +- dokimi/tests/test_memory_lint.py | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dokimi/dokimi/memory/lint.py b/dokimi/dokimi/memory/lint.py index 132780fbf..56f0f4b1c 100644 --- a/dokimi/dokimi/memory/lint.py +++ b/dokimi/dokimi/memory/lint.py @@ -30,7 +30,7 @@ r"|(?\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") _WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 4a633b53c..e0b815e4f 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -132,7 +132,7 @@ def test_index_size_counts_crlf_bytes_on_disk(mem): def test_bom_alias_archive_and_date_false_positives(mem): (mem / "a.md").write_text("" + _entry("a", body="[[b|alias]] [[b#Why]]\nFixed 2026-11-13 (today).\n" - "See `today()` and yesterday.md, yesterday's notes.")) + "See `today()` and yesterday.md.")) (mem / "archive").mkdir() (mem / "archive" / "a.md").write_text(_entry("a", body="done today")) assert _rules(mem) == [] @@ -154,3 +154,9 @@ def test_json_status_is_failed_on_a_fail(mem): (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") doc = json.loads(_cli(str(mem), "--json").stdout) assert doc["status"] == "failed" and "1 fail" in doc["reason"] + + +def test_empty_frontmatter_stops_at_its_own_closing_line(mem): + (mem / "a.md").write_text("---\n---\nSaw it today.\n\n---\nname: later\n---\n") + found = [(f.rule, f.line) for f in lint(read_claude_memory(mem)) if f.file == "a.md"] + assert ("relative-date", 3) in found and ("frontmatter-invalid", 1) not in found From ab4b0bcadf7ee222efdf311f54e82a7901c7f81a Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:39:01 -0800 Subject: [PATCH 04/11] docs(memory): add the license header to docs/memory.md The License header check in Static checks fails on the new page without it. Signed-off-by: Alexander Rybkin --- dokimi/docs/memory.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md index 18cf557f6..7fdf3b24a 100644 --- a/dokimi/docs/memory.md +++ b/dokimi/docs/memory.md @@ -1,3 +1,8 @@ + + # Agent memory `dokimi memory` treats an agent's memory store as a subject (tracking issue From 913b0b08e32ae6ebbb6882091ac9d3c5dad8529b Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:39:15 -0800 Subject: [PATCH 05/11] fix(memory): a bad frontmatter value is a finding, not a crash PyYAML constructors raise ValueError, AttributeError or RecursionError (modified: 2026-02-30, !!timestamp abc, deep nesting); only YAMLError was caught, so one file ended the run with exit 5. Catch any error of the one safe_load call. Signed-off-by: Alexander Rybkin --- dokimi/dokimi/memory/store.py | 2 +- dokimi/tests/test_memory_lint.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py index a960675e4..34c4ffefb 100644 --- a/dokimi/dokimi/memory/store.py +++ b/dokimi/dokimi/memory/store.py @@ -110,7 +110,7 @@ def _entry(root: Path, path: Path, archived: bool) -> MemoryEntry: body = text[m.end():] try: data = yaml.safe_load(m.group(1) or "") or {} - except yaml.YAMLError as exc: + except Exception as exc: # any constructor error (bad date, !!float abc, deep nesting) is this file's finding return MemoryEntry(rel, text, body, None, f"frontmatter is not YAML: {exc.__class__.__name__}", archived) if not isinstance(data, dict): return MemoryEntry(rel, text, body, None, "frontmatter is not a mapping", archived) diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index e0b815e4f..9f926238e 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -59,6 +59,15 @@ def test_frontmatter_rules(mem): ("name-duplicate", "a.md"), ("name-duplicate", "e.md")} <= rules +@pytest.mark.parametrize("value", ["modified: 2026-02-30", "x: !!float abc", "x: !!timestamp abc", + "x: " + "[" * 3000 + "]" * 3000]) +def test_bad_frontmatter_value_is_a_finding_and_the_run_goes_on(mem, value): + (mem / "b.md").write_text(f"---\nname: b\ndescription: d\ntype: project\n{value}\n---\n") + (mem / "c.md").write_text(_entry("c")) + rules = _rules(mem) + assert ("frontmatter-invalid", "warn", "b.md") in rules and ("index-orphan", "warn", "c.md") in rules + + def test_wikilink_folds_dash_and_underscore(mem): (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) (mem / "project_a.md").write_text(_entry("pa")) From 526eab08e8309e5839fd89e589b9985ee73ee050 Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:39:42 -0800 Subject: [PATCH 06/11] fix(memory): code spans, fences and HTML comments hold no index link index_links matched every ](...) in MEMORY.md, so a link quoted in code or left in a comment gave a false index-dead-link fail (exit 1) or hid an orphan. Blank them first, keeping line numbers. Signed-off-by: Alexander Rybkin --- dokimi/docs/memory.md | 2 +- dokimi/dokimi/memory/store.py | 24 +++++++++++++++++++++++- dokimi/tests/test_memory_lint.py | 7 +++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md index 7fdf3b24a..9a3a7e0ee 100644 --- a/dokimi/docs/memory.md +++ b/dokimi/docs/memory.md @@ -22,7 +22,7 @@ No model call. Each finding has a rule, a severity, a file and a line: | rule | severity | what | |---|---|---| -| `index-dead-link` | fail | the index links a file that does not exist, a directory, or a path outside the memory directory | +| `index-dead-link` | fail | the index links a file that does not exist, a directory, or a path outside the memory directory (code spans, fenced blocks and HTML comments hold no link) | | `index-over-limit` | fail | the index is past the load limit (200 lines or 25KB, as Claude Code loads it) | | `index-near-limit` | warn | over 80% of the limit | | `index-missing` | warn | no `MEMORY.md` | diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py index 34c4ffefb..370134658 100644 --- a/dokimi/dokimi/memory/store.py +++ b/dokimi/dokimi/memory/store.py @@ -22,6 +22,9 @@ _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) _MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") _WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") +_CODE_SPAN = re.compile(r"`[^`\n]*`") +_HTML_COMMENT = re.compile(r"", re.S) +_FENCE = re.compile(r" {0,3}(`{3,}|~{3,})") class MemoryStoreError(ValueError): @@ -82,7 +85,7 @@ def index_links(self) -> list[tuple[int, str]]: if self.index is None: return [] out = [] - for n, line in enumerate(self.index.text.split("\n"), 1): + for n, line in enumerate(prose_lines(self.index.text), 1): for m in _MD_LINK.finditer(line): target = unquote(m.group(1).strip("<>").split("#", 1)[0]) if target and "://" not in target and not target.startswith("mailto:"): @@ -90,6 +93,25 @@ def index_links(self) -> list[tuple[int, str]]: return out +def prose_lines(text: str) -> list[str]: + """The lines of ``text`` with HTML comments, fenced blocks and code spans blanked: no link lives there.""" + text = _HTML_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text) + out: list[str] = [] + fence = "" + for line in text.split("\n"): + m = _FENCE.match(line) + if fence: + if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line[m.end():].strip(): + fence = "" + out.append("") + elif m: + fence = m.group(1) + out.append("") + else: + out.append(_CODE_SPAN.sub(lambda c: " " * len(c.group(0)), line)) + return out + + def slug(name: str) -> str: """The key two spellings of one memory name share: ``project_foo.md`` and ``project-foo``.""" name = name.strip() diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 9f926238e..0430c9207 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -68,6 +68,13 @@ def test_bad_frontmatter_value_is_a_finding_and_the_run_goes_on(mem, value): assert ("frontmatter-invalid", "warn", "b.md") in rules and ("index-orphan", "warn", "c.md") in rules +def test_links_in_code_and_comments_are_not_index_links(mem): + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md), write `[x](span.md)`\n\n```md\n[x](fenced.md)\n~~~\n```\n- [Gone](gone.md)\n") + dead = [(f.line, f.message) for f in lint(read_claude_memory(mem)) if f.rule == "index-dead-link"] + assert dead == [(8, "links gone.md, which is not a file")] + + def test_wikilink_folds_dash_and_underscore(mem): (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) (mem / "project_a.md").write_text(_entry("pa")) From 3d80235937291566b3e7fc7d64da792bfceb44f7 Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:40:09 -0800 Subject: [PATCH 07/11] fix(memory): read parenthesised targets and reference definitions as links [T](topic(v2).md) was read as topic(v2 (a false dead link), and [B][b] with [b]: b.md was not read at all (a false orphan, a missed dead link). Allow one level of balanced parentheses and read each [label]: target line. Signed-off-by: Alexander Rybkin --- dokimi/dokimi/memory/store.py | 5 +++-- dokimi/tests/test_memory_lint.py | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py index 370134658..5f99e6c1b 100644 --- a/dokimi/dokimi/memory/store.py +++ b/dokimi/dokimi/memory/store.py @@ -20,7 +20,8 @@ INDEX_NAME = "MEMORY.md" ARCHIVE_DIR = "archive" _FRONTMATTER = re.compile(r"\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)", re.S) -_MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") +_MD_LINK = re.compile(r"\]\(\s*(<[^>\n]+>|(?:[^()\s]|\([^()\s]*\))+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\s*\)") +_MD_REF_DEF = re.compile(r"^ {0,3}\[(?!\^)[^\]\n]+\]:[ \t]*(<[^>\n]+>|\S+)") _WIKILINK = re.compile(r"\[\[([^\]\n]+)\]\]") _CODE_SPAN = re.compile(r"`[^`\n]*`") _HTML_COMMENT = re.compile(r"", re.S) @@ -86,7 +87,7 @@ def index_links(self) -> list[tuple[int, str]]: return [] out = [] for n, line in enumerate(prose_lines(self.index.text), 1): - for m in _MD_LINK.finditer(line): + for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: target = unquote(m.group(1).strip("<>").split("#", 1)[0]) if target and "://" not in target and not target.startswith("mailto:"): out.append((n, target)) diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 0430c9207..8c16b875a 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -75,6 +75,13 @@ def test_links_in_code_and_comments_are_not_index_links(mem): assert dead == [(8, "links gone.md, which is not a file")] +def test_parenthesised_targets_and_reference_definitions_are_links(mem): + (mem / "topic(v2).md").write_text(_entry("t")) + (mem / "MEMORY.md").write_text("- [A](a.md) [T](topic(v2).md) [B][b] [G][g]\n\n[b]: b.md\n[g]: \n") + dead = [(f.rule, f.line) for f in lint(read_claude_memory(mem)) if f.severity != "note"] + assert dead == [("index-dead-link", 4)] + + def test_wikilink_folds_dash_and_underscore(mem): (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) (mem / "project_a.md").write_text(_entry("pa")) From 3dd3a9d844ded746bd29bef1f418e2f0f005bb1c Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:40:40 -0800 Subject: [PATCH 08/11] fix(memory): judge a link as outside the directory after resolving it The lexical check accepted esc.md -> ../outside.md and up/ -> .. symlinks, and failed an absolute link to a file inside the store (plus a false orphan). Resolve the target and test containment in the resolved root. Signed-off-by: Alexander Rybkin --- dokimi/dokimi/memory/lint.py | 12 ++++++++---- dokimi/tests/test_memory_lint.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/dokimi/dokimi/memory/lint.py b/dokimi/dokimi/memory/lint.py index 56f0f4b1c..48474ab97 100644 --- a/dokimi/dokimi/memory/lint.py +++ b/dokimi/dokimi/memory/lint.py @@ -78,12 +78,16 @@ def _index_bytes(store: MemoryStore) -> int: def _link_findings(store: MemoryStore) -> Iterable[Finding]: linked: set[str] = set() + root = store.root.resolve() for n, target in store.index_links(): - path = posixpath.normpath(target) - if path.startswith(("../", "/")) or path == "..": + try: # resolved, so a symlink or an absolute path is judged by where it lands + full = (store.root / target).resolve() + except (OSError, RuntimeError): + full = root.parent + if not full.is_relative_to(root): yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, outside the memory directory") - elif (store.root / path).is_file(): - linked.add(path) + elif full.is_file(): + linked.update({posixpath.normpath(target), full.relative_to(root).as_posix()}) else: yield Finding("index-dead-link", "fail", INDEX_NAME, n, f"links {target}, which is not a file") if store.index is None: diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 8c16b875a..1dede8ff0 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -82,6 +82,21 @@ def test_parenthesised_targets_and_reference_definitions_are_links(mem): assert dead == [("index-dead-link", 4)] +def test_containment_is_judged_after_resolving(tmp_path): + mem = tmp_path / "m" + mem.mkdir() + (tmp_path / "outside.md").write_text(_entry("o")) + (mem / "a.md").write_text(_entry("a")) + (mem / "esc.md").symlink_to(tmp_path / "outside.md") + (mem / "up").symlink_to(tmp_path, target_is_directory=True) + (mem / "loop1.md").symlink_to(mem / "loop2.md") + (mem / "loop2.md").symlink_to(mem / "loop1.md") + (mem / "MEMORY.md").write_text(f"- [A]({mem / 'a.md'})\n- [E](esc.md)\n- [U](up/outside.md)\n" + f"- [O]({tmp_path / 'outside.md'})\n- [L](loop1.md)\n") + found = [(f.rule, f.file, f.line) for f in lint(read_claude_memory(mem)) if f.rule.startswith("index")] + assert found == [("index-dead-link", "MEMORY.md", n) for n in (2, 3, 4, 5)] + [("index-orphan", "esc.md", None)] + + def test_wikilink_folds_dash_and_underscore(mem): (mem / "a.md").write_text(_entry("a", body="[[B]] [[project-a]] [[nobody]]")) (mem / "project_a.md").write_text(_entry("pa")) From 1e1fb2e504b9f1a94ef14ce2895cc40dd9c7f3a8 Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:40:51 -0800 Subject: [PATCH 09/11] fix(memory): an unreadable MEMORY.md is an input error, not a clean pass The read error landed in the index's frontmatter_error, which no rule reads, so a store whose index could not be read gave no findings and exit 0. Raise MemoryStoreError for the index (exit 2); a topic file stays a finding. Signed-off-by: Alexander Rybkin --- dokimi/docs/memory.md | 2 +- dokimi/dokimi/memory/store.py | 4 +++- dokimi/tests/test_memory_lint.py | 13 +++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/dokimi/docs/memory.md b/dokimi/docs/memory.md index 9a3a7e0ee..2e4336c09 100644 --- a/dokimi/docs/memory.md +++ b/dokimi/docs/memory.md @@ -35,4 +35,4 @@ No model call. Each finding has a rule, a severity, a file and a line: | `index-stray-separator` | note | `:,`, `,,` or a trailing `,` in an index line | Exit 0 when there is no `fail` (no `warn` either with `--fail-on warn`), 1 otherwise, 2 when the path is -not a memory directory. +not a memory directory or its `MEMORY.md` cannot be read. diff --git a/dokimi/dokimi/memory/store.py b/dokimi/dokimi/memory/store.py index 5f99e6c1b..e19dfe9f2 100644 --- a/dokimi/dokimi/memory/store.py +++ b/dokimi/dokimi/memory/store.py @@ -125,7 +125,9 @@ def _entry(root: Path, path: Path, archived: bool) -> MemoryEntry: rel = path.relative_to(root).as_posix() try: text = path.read_text(encoding="utf-8-sig", errors="replace") - except OSError as exc: # one unreadable file is a finding, not the end of the run + except OSError as exc: # one unreadable topic is a finding; an unreadable index is bad input (exit 2) + if path == root / INDEX_NAME: + raise MemoryStoreError(f"{path}: unreadable: {exc.strerror or exc}") from None return MemoryEntry(rel, "", "", None, f"unreadable: {exc.strerror or exc}", archived) m = _FRONTMATTER.match(text) if not m: diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 1dede8ff0..49ce270be 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -188,6 +188,19 @@ def test_unreadable_file_is_a_finding(mem): bad.chmod(0o644) +def test_unreadable_index_is_an_input_error(mem): + import os + index = mem / "MEMORY.md" + index.chmod(0) + try: + if os.access(index, os.R_OK): + pytest.skip("running as root: every file is readable") + out = _cli(str(mem), "--json") + assert out.returncode == 2 and json.loads(out.stdout)["error"]["code"] == "input" + finally: + index.chmod(0o644) + + def test_json_status_is_failed_on_a_fail(mem): (mem / "MEMORY.md").write_text("- [Gone](gone.md)\n") doc = json.loads(_cli(str(mem), "--json").stdout) From ee8f89faeb80907a9cad64ff5367c8e1563ea8e0 Mon Sep 17 00:00:00 2001 From: Alexander Rybkin Date: Sat, 14 Nov 2026 00:41:07 -0800 Subject: [PATCH 10/11] test(memory): pin the 200-line edge, byte near-limit, anchors, mailto, code spans Each of these guards survived a mutant: '>' as '>=', the byte near-limit dropped, the #anchor split or mailto exclusion removed (both false dead-link fails), and the code-span skip of the relative-date rule removed. Signed-off-by: Alexander Rybkin --- dokimi/tests/test_memory_lint.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dokimi/tests/test_memory_lint.py b/dokimi/tests/test_memory_lint.py index 49ce270be..b8c0dd16e 100644 --- a/dokimi/tests/test_memory_lint.py +++ b/dokimi/tests/test_memory_lint.py @@ -119,6 +119,17 @@ def test_index_limit_and_stray_separator(mem): assert ("index-near-limit", "warn", "MEMORY.md") in _rules(mem, max_lines=14) +def test_load_limit_edges_anchors_mailto_and_code_span_dates(mem): + (mem / "MEMORY.md").write_text("- [A](a.md#why) [B](b.md) [M](mailto:x@example.com)\n" + "x\n" * 199) + assert [r[0] for r in _rules(mem)] == ["index-near-limit"] # 200 lines load + (mem / "MEMORY.md").write_text("- [A](a.md#why) [B](b.md)\n" + "x\n" * 200) + assert [r[0] for r in _rules(mem)] == ["index-over-limit"] + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md)\n" + "x" * 900 + "\n") + assert [r[0] for r in _rules(mem, max_bytes=1000)] == ["index-near-limit"] + (mem / "a.md").write_text(_entry("a", body="Run `make today` first.")) + assert _rules(mem) == [] + + def test_missing_index_warns(tmp_path): (tmp_path / "a.md").write_text(_entry("a")) assert _rules(tmp_path) == [("index-missing", "warn", "MEMORY.md")] From 3de7e3ff22a6f7f984992ace4dfaa52776664838 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:02:08 -0600 Subject: [PATCH 11/11] fix(memory): read escaped links, code spans and fences correctly - `topic\(v2\).md` in an index link is unescaped before the lookup - `[t](x%00.md)` is an index-dead-link, not a ValueError traceback - a code-span delimiter run closes on a run of the same length, and code spans are blanked before HTML comments open - fences are read before comments, so `", re.S) +#: A code span (a backtick run closed by a run of the same length, on one line) or an HTML comment, whichever starts first. +_HIDDEN = re.compile(r"(?", re.S) +_ESCAPE = re.compile(r"\\([!-/:-@\[-`{-~])") _FENCE = re.compile(r" {0,3}(`{3,}|~{3,})") @@ -88,7 +89,7 @@ def index_links(self) -> list[tuple[int, str]]: out = [] for n, line in enumerate(prose_lines(self.index.text), 1): for m in [*_MD_LINK.finditer(line), *_MD_REF_DEF.finditer(line)]: - target = unquote(m.group(1).strip("<>").split("#", 1)[0]) + target = unquote(_ESCAPE.sub(r"\1", m.group(1).strip("<>")).split("#", 1)[0]) if target and "://" not in target and not target.startswith("mailto:"): out.append((n, target)) return out @@ -96,20 +97,33 @@ def index_links(self) -> list[tuple[int, str]]: def prose_lines(text: str) -> list[str]: """The lines of ``text`` with HTML comments, fenced blocks and code spans blanked: no link lives there.""" - text = _HTML_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text) out: list[str] = [] fence = "" + in_comment = False for line in text.split("\n"): m = _FENCE.match(line) if fence: if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line[m.end():].strip(): fence = "" out.append("") + continue + if in_comment: + end = line.find("-->") + if end < 0: + out.append("") + continue + line = " " * (end + 3) + line[end + 3:] + in_comment = False elif m: fence = m.group(1) out.append("") - else: - out.append(_CODE_SPAN.sub(lambda c: " " * len(c.group(0)), line)) + continue + line = _HIDDEN.sub(lambda h: " " * len(h.group(0)), line) + opener = line.find("`\n") + dead = [(f.rule, f.line) for f in lint(read_claude_memory(mem))] + assert dead == [("index-dead-link", 3)] + + +def test_relative_date_skips_fences_and_comments(mem): + (mem / "a.md").write_text(_entry("a", body="```\nrun it tomorrow\n```\n\nDo it tomorrow.")) + assert [f.line for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] == [11] + + +def test_outside_link_fails_urls_are_skipped_and_byte_limit_edge(mem): + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md) [U](https://example.com/x.md) [Up](../x.md)\n") + assert [(f.rule, f.severity) for f in lint(read_claude_memory(mem))] == [("index-dead-link", "fail")] + (mem / "MEMORY.md").write_text("- [A](a.md) [B](b.md)\n") + size = (mem / "MEMORY.md").stat().st_size + assert "index-over-limit" not in [r[0] for r in _rules(mem, max_bytes=size)] + assert ("index-over-limit", "fail", "MEMORY.md") in _rules(mem, max_bytes=size - 1) + + +def test_comment_opener_in_a_fence_does_not_hide_the_prose_after_it(mem): + (mem / "a.md").write_text(_entry("a", body="```html\n")) + assert [f.line for f in lint(read_claude_memory(mem)) if f.rule == "relative-date"] == [10]