Repository navigation
feat(memory): dokimi memory lint checks an agent's memory directory (#728) #741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8fc5d84
97539c8
cc21fd6
ab4b0bc
913b0b0
526eab0
3d80235
3dd3a9d
1e1fb2e
ee8f89f
55725fc
3de7e3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,38 @@ | ||||||||||||||||
| <!-- | ||||||||||||||||
| Copyright (C) 2026 Intel Corporation | ||||||||||||||||
| SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||
| --> | ||||||||||||||||
|
|
||||||||||||||||
| # Agent memory | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CI check
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| `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/<project>/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 and counted, but not checked and never expected in the index. | ||||||||||||||||
|
|
||||||||||||||||
| ## memory lint | ||||||||||||||||
|
|
||||||||||||||||
| ```bash | ||||||||||||||||
| dokimi memory lint ~/.claude/projects/<project>/memory | ||||||||||||||||
| dokimi memory lint ~/.claude/projects/<project>/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, 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` | | ||||||||||||||||
| | `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", "вчера" on a line with no `YYYY-MM-DD` date (code spans, fenced blocks and HTML comments skipped) | | ||||||||||||||||
| | `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 or its `MEMORY.md` cannot be read. | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # 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: | ||
| 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: | ||
| 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/<project>/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") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<project>/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"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| # 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 | ||
| import posixpath | ||
|
|
||
| from dokimi.memory.store import ARCHIVE_DIR, INDEX_NAME, MemoryEntry, MemoryStore, prose_lines, 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"|(?<!\w)(сегодня|вчера|завтра)(?!\w)", re.I) | ||
| _DATE = re.compile(r"\d{4}-\d{2}-\d{2}") | ||
| _NOT_A_WORD_USE = re.compile(r"\.md|\(") | ||
| _STRAY_SEPARATOR = re.compile(r":\s*,|,\s*,|,\s*$") | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Finding: | ||
| """One problem: ``line`` is 1-based, ``None`` for the file as a whole.""" | ||
|
|
||
| rule: str | ||
| severity: str | ||
| file: str | ||
| line: int | None | ||
| message: str | ||
|
|
||
|
|
||
| def _index_findings(store: MemoryStore, max_lines: int, max_bytes: int) -> 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.split("\n") | ||
| lines = lines[:-1] if lines and not lines[-1] else lines | ||
|
Comment on lines
+53
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 " | ||
| 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 _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() | ||
| root = store.root.resolve() | ||
| for n, target in store.index_links(): | ||
| try: # resolved, so a symlink or an absolute path is judged by where it lands | ||
| full = (store.root / target).resolve() | ||
| except (OSError, RuntimeError, ValueError): | ||
| 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 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: | ||
| 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() | ||
| 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, | ||
| 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)) | ||
| prose = prose_lines(entry.body) # code spans, fences and HTML comments hold no claim about today | ||
| for n, (raw, line) in enumerate(zip(entry.body.split("\n"), prose, strict=True), offset + 1): | ||
| if _DATE.search(raw): # a dated line says when "today" was | ||
| continue | ||
| for m in _RELATIVE.finditer(line): | ||
| 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") | ||
|
|
||
|
|
||
| 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 "" | ||
| 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_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}, | ||
| "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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CI check
Static checks (ruff, schema, zizmor, licenses, pre-commit, bandit, secrets, changelog, schema com...fails: The new dokimi/docs/memory.md has no license header, violating .github/.licenserc.yaml. The same job also fails check-added-large-files on cli-interface.json (504 KB); although this diff adds 100 lines to it, the logs do not establish whether the base file was already above 500 KB, so attribution of that additional failure is unknown.Check run: inconclusive, the result is not shown as a pass.
Suggestion: