Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dokimi/changelog.d/20261113_120000_napetrov_memory-lint.md
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`.
3 changes: 3 additions & 0 deletions dokimi/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions dokimi/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions dokimi/docs/memory.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
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:

Suggested change
# Agent memory
<!- -
Copyright (C) 2026 Intel Corporation
SPDX-License-Identifier: Apache-2.0
-->
# Agent memory

Copy link
Copy Markdown

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 newly added dokimi/docs/memory.md has no copyright/SPDX header required by .github/.licenserc.yaml, and the license checker explicitly names it. Separately, pre-commit rejects dokimi/docs/reference/cli-interface.json at 504 KB; its baseline size is unavailable, so attribution of that additional failure is unconfirmed. Proposed fix, not run by the bot.

Suggested change
# Agent memory
<!- -
Copyright (C) 2026 Intel Corporation
SPDX-License-Identifier: Apache-2.0
-->
# 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/<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.
22 changes: 22 additions & 0 deletions dokimi/docs/reference/cli-interface.json
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,28 @@
}
}
},
"memory": {
"allow_abbrev": true,
"params": [
{"key": "--help/-h", "action": "_HelpAction", "choices": null, "const": null, "default": "==SUPPRESS==", "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null}
],
"path": "dokimi memory",
"subcommand_required": true,
"subcommands": {
"lint": {
"allow_abbrev": true,
"params": [
{"key": "memory_dir", "action": "_StoreAction", "choices": null, "const": null, "default": null, "group_key": null, "group_required": null, "hidden": false, "is_flag": false, "kind": "argument", "nargs": null, "position": 0, "required": true, "type": null},
{"key": "--fail-on", "action": "_StoreAction", "choices": ["'fail'", "'warn'"], "const": null, "default": "fail", "group_key": null, "group_required": null, "hidden": false, "is_flag": false, "kind": "option", "nargs": null, "position": null, "required": false, "type": null},
{"key": "--help/-h", "action": "_HelpAction", "choices": null, "const": null, "default": "==SUPPRESS==", "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null},
{"key": "--json", "action": "_StoreTrueAction", "choices": null, "const": true, "default": false, "group_key": null, "group_required": null, "hidden": false, "is_flag": true, "kind": "option", "nargs": 0, "position": null, "required": false, "type": null}
],
"path": "dokimi memory lint",
"subcommand_required": null,
"subcommands": {}
}
}
},
"novelty": {
"allow_abbrev": true,
"params": [
Expand Down
1 change: 1 addition & 0 deletions dokimi/dokimi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions dokimi/dokimi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
data,
evaluate,
explore,
memory,
novelty,
orchestrate,
personas,
Expand Down Expand Up @@ -227,6 +228,7 @@ def positive_int(value: str) -> int:
novelty.register(sub, positive_int) # novelty (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)
Expand Down
2 changes: 2 additions & 0 deletions dokimi/dokimi/cli_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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"),
Expand Down Expand Up @@ -174,6 +175,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),
Expand Down
48 changes: 48 additions & 0 deletions dokimi/dokimi/commands/memory.py
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")
13 changes: 13 additions & 0 deletions dokimi/dokimi/memory/__init__.py
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"]
187 changes: 187 additions & 0 deletions dokimi/dokimi/memory/lint.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Must-fix. Impact: lint can report no findings and exit successfully despite being unable to read the primary index, including under --fail-on warn. Trigger: a directory containing only a small MEMORY.md with read permission denied to the current user. Evidence: _entry stores the read error in index.frontmatter_error and substitutes empty text, but these index checks never inspect that error; _frontmatter_findings only runs on topic entries. Surface the index read error as a finding and add an unreadable-index test (the existing permission test covers only b.md). Workaround: restore read permission before linting. Severity justification: medium—a bounded false-success verdict hides failure to inspect the session-start input.

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)
Loading
Loading