keepm 0.2.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {keepm-0.2.0/src/keepm.egg-info → keepm-0.5.0}/PKG-INFO +1 -1
  2. keepm-0.5.0/README.md +184 -0
  3. {keepm-0.2.0 → keepm-0.5.0}/pyproject.toml +1 -1
  4. keepm-0.5.0/src/keepm/__init__.py +1 -0
  5. keepm-0.5.0/src/keepm/audit.py +140 -0
  6. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/cli.py +5 -0
  7. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/config.py +15 -0
  8. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/database.py +241 -72
  9. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/doctor.py +52 -17
  10. keepm-0.5.0/src/keepm/instructions.py +450 -0
  11. keepm-0.5.0/src/keepm/markdown.py +204 -0
  12. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/models.py +25 -3
  13. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/server.py +131 -54
  14. keepm-0.5.0/src/keepm/service.py +611 -0
  15. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/setup.py +30 -6
  16. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/storage.py +23 -6
  17. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/sync.py +42 -19
  18. keepm-0.5.0/src/keepm/tokens.py +49 -0
  19. keepm-0.5.0/src/keepm/validation.py +261 -0
  20. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/watcher.py +14 -3
  21. {keepm-0.2.0 → keepm-0.5.0/src/keepm.egg-info}/PKG-INFO +1 -1
  22. {keepm-0.2.0 → keepm-0.5.0}/src/keepm.egg-info/SOURCES.txt +5 -0
  23. keepm-0.5.0/tests/test_audit.py +78 -0
  24. {keepm-0.2.0 → keepm-0.5.0}/tests/test_cli.py +9 -0
  25. {keepm-0.2.0 → keepm-0.5.0}/tests/test_config.py +10 -2
  26. keepm-0.5.0/tests/test_database.py +192 -0
  27. {keepm-0.2.0 → keepm-0.5.0}/tests/test_end_to_end.py +18 -11
  28. keepm-0.5.0/tests/test_instructions.py +354 -0
  29. keepm-0.5.0/tests/test_markdown.py +238 -0
  30. {keepm-0.2.0 → keepm-0.5.0}/tests/test_server.py +103 -33
  31. keepm-0.5.0/tests/test_service.py +326 -0
  32. {keepm-0.2.0 → keepm-0.5.0}/tests/test_setup.py +95 -5
  33. {keepm-0.2.0 → keepm-0.5.0}/tests/test_storage.py +15 -1
  34. {keepm-0.2.0 → keepm-0.5.0}/tests/test_sync.py +45 -9
  35. keepm-0.5.0/tests/test_tokens.py +20 -0
  36. {keepm-0.2.0 → keepm-0.5.0}/tests/test_watcher.py +43 -4
  37. keepm-0.2.0/README.md +0 -161
  38. keepm-0.2.0/src/keepm/__init__.py +0 -1
  39. keepm-0.2.0/src/keepm/instructions.py +0 -226
  40. keepm-0.2.0/src/keepm/markdown.py +0 -152
  41. keepm-0.2.0/src/keepm/service.py +0 -299
  42. keepm-0.2.0/tests/test_database.py +0 -104
  43. keepm-0.2.0/tests/test_instructions.py +0 -201
  44. keepm-0.2.0/tests/test_markdown.py +0 -60
  45. keepm-0.2.0/tests/test_service.py +0 -145
  46. {keepm-0.2.0 → keepm-0.5.0}/LICENSE +0 -0
  47. {keepm-0.2.0 → keepm-0.5.0}/setup.cfg +0 -0
  48. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/integrations.py +0 -0
  49. {keepm-0.2.0 → keepm-0.5.0}/src/keepm/projects.py +0 -0
  50. {keepm-0.2.0 → keepm-0.5.0}/src/keepm.egg-info/dependency_links.txt +0 -0
  51. {keepm-0.2.0 → keepm-0.5.0}/src/keepm.egg-info/entry_points.txt +0 -0
  52. {keepm-0.2.0 → keepm-0.5.0}/src/keepm.egg-info/requires.txt +0 -0
  53. {keepm-0.2.0 → keepm-0.5.0}/src/keepm.egg-info/top_level.txt +0 -0
  54. {keepm-0.2.0 → keepm-0.5.0}/tests/test_integrations.py +0 -0
  55. {keepm-0.2.0 → keepm-0.5.0}/tests/test_projects.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepm
3
- Version: 0.2.0
3
+ Version: 0.5.0
4
4
  Summary: Local Markdown memory MCP for coding agents
5
5
  Requires-Python: >=3.11
6
6
  License-File: LICENSE
keepm-0.5.0/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # KeepM
2
+
3
+ KeepM is a local Markdown memory MCP for coding agents. It stores durable memories in one Obsidian Vault, keeps Markdown as the source of truth, and uses a rebuildable SQLite FTS5 index for bounded context, search, WikiLinks, and backlinks.
4
+
5
+ [中文文档](README-zh.md)
6
+
7
+ KeepM is local-only: no embeddings, vector database, cloud service, HTTP server, required Obsidian plugin, or local model. Obsidian may be closed while KeepM runs.
8
+
9
+ ## Install and setup
10
+
11
+ Requirements:
12
+
13
+ - Python 3.11+
14
+ - [uv](https://docs.astral.sh/uv/) for the recommended `uvx` flow
15
+ - One local Obsidian Vault reachable from the machine running KeepM
16
+
17
+ From WSL, a Windows Vault is normally addressed through its mount, for example:
18
+
19
+ ```text
20
+ /mnt/c/Users/alice/Documents/ObsidianVault
21
+ ```
22
+
23
+ Install and configure one Vault:
24
+
25
+ ```bash
26
+ uvx keepm setup
27
+ ```
28
+
29
+ The colored setup wizard asks for the Vault, default memory language (`zh` or `en`), one Agent (Codex or Claude Code), and either user or project scope. Use arrow keys and Enter to select options. Non-interactive setup is available with:
30
+
31
+ ```bash
32
+ keepm setup --yes --agent codex --scope project
33
+ ```
34
+
35
+ Add `--audit` or `--no-audit` to set the optional local audit preference without editing the config file.
36
+
37
+ The Agent registration scope determines where setup installs the managed instruction entry:
38
+
39
+ | Agent | User scope | Project scope |
40
+ | --- | --- | --- |
41
+ | Codex | `~/.codex/AGENTS.md` | `<project>/AGENTS.md` |
42
+ | Claude Code | `~/.claude/CLAUDE.md` | `<project>/CLAUDE.md` |
43
+
44
+ Setup also installs the detailed policy referenced by that entry:
45
+
46
+ ```text
47
+ User scope: ~/.config/keepm/policies/keepm-memory-policy.md
48
+ Project scope: <project>/.keepm/keepm-memory-policy.md
49
+ ```
50
+
51
+ KeepM manages only content between `<!-- keepm:start -->` and `<!-- keepm:end -->`. Re-running setup replaces that block without changing surrounding user instructions. Broken, reversed, or duplicated markers are rejected rather than overwritten.
52
+
53
+ The single-Vault configuration is stored at `~/.config/keepm/config.toml`; it is not a Codex or Claude configuration file. A typical file is:
54
+
55
+ ```toml
56
+ vault_path = "/mnt/c/Users/alice/Documents/ObsidianVault"
57
+ memory_dir = "Memory"
58
+ stale_after_seconds = 30
59
+ language = "zh"
60
+ audit_enabled = false
61
+ ```
62
+
63
+ Run `keepm` without a subcommand only as the MCP stdio server. Vault configuration is deliberately performed by `keepm setup`, not by a normal Agent MCP tool.
64
+
65
+ ## Storage and V2 format
66
+
67
+ ```text
68
+ <Vault>/Memory/
69
+ ├── global/
70
+ ├── projects/
71
+ │ └── <project-id>/
72
+ └── .trash/
73
+
74
+ ~/.local/share/keepm/
75
+ ├── indexes/<vault-id>.sqlite3
76
+ └── audit/<vault-id>.jsonl
77
+ ```
78
+
79
+ - `global/` contains cross-project preferences, workflows, feedback, and references.
80
+ - `projects/<project-id>/` contains repository-specific knowledge.
81
+ - `.trash/` holds soft-deleted memories and is never indexed.
82
+ - SQLite is derived data. It can be removed and rebuilt from Markdown without losing memory.
83
+
84
+ The current project ID comes from `.keepm.toml`, Git origin, or the workspace directory name. A project can make the mapping stable with:
85
+
86
+ ```toml
87
+ # <repository>/.keepm.toml
88
+ project = "keepm"
89
+ ```
90
+
91
+ A V2 memory has explicit range and content semantics:
92
+
93
+ ```markdown
94
+ ---
95
+ id: 00000000-0000-0000-0000-000000000001
96
+ name: markdown-source-of-truth
97
+ description: Markdown is the canonical KeepM memory record
98
+ scope: project
99
+ project_id: keepm
100
+ kind: constraint
101
+ language: en
102
+ tags: [storage, obsidian]
103
+ pinned: true
104
+ created: 2026-07-18
105
+ updated: 2026-07-18
106
+ ---
107
+
108
+ Why: Users must be able to inspect, edit, back up, and recover every memory in Obsidian.
109
+
110
+ How to apply: Store durable facts in Markdown first. Treat SQLite only as a rebuildable index.
111
+
112
+ ## Relations
113
+
114
+ - depends_on [[local-first]]
115
+ ```
116
+
117
+ `scope` is `global` or `project`; a project memory must include `project_id`, while a global memory must not. New documents also record `language: zh|en`. KeepM preserves non-KeepM frontmatter added manually in Obsidian when an Agent updates the note.
118
+
119
+ | kind | Typical use | Required non-empty labels |
120
+ | --- | --- | --- |
121
+ | `preference` | User preference | `How to apply:` |
122
+ | `workflow` | Repeatable workflow | `How to apply:` |
123
+ | `feedback` | Corrective feedback | `Why:`, `How to apply:` |
124
+ | `overview` | Project purpose and boundaries | None |
125
+ | `capability` | Stable completed capability | None |
126
+ | `decision` | Technical or product decision | `Why:`, `How to apply:` |
127
+ | `constraint` | Invariant or prohibition | `Why:`, `How to apply:` |
128
+ | `fix` | Reusable diagnosis and repair | `Why:`, `How to apply:` |
129
+ | `reference` | Stable terminology or external material | None |
130
+
131
+ V1 documents using `type: user|feedback|project|reference` are intentionally not compatible with V2 and are left untouched but excluded from the index. Rewrite them as V2 Markdown if they should remain active memories.
132
+
133
+ ## MCP tools
134
+
135
+ | Tool | Purpose |
136
+ | --- | --- |
137
+ | `memory_status` | Report configuration, active project, watcher, index, dirty state, and audit status |
138
+ | `memory_context` | Return bounded pinned and query-relevant global/project memories |
139
+ | `memory_search` | Search the SQLite FTS5 index |
140
+ | `memory_read` | Read one current Markdown memory, checksum, and an update token when output is complete |
141
+ | `memory_create` | Create a new memory; it never overwrites an existing logical name |
142
+ | `memory_update` | Replace mutable fields after a full read, checksum check, and update token |
143
+ | `memory_delete` | Soft-delete a full-read, checksum-verified memory into `.trash` |
144
+ | `memory_links` | Return forward WikiLinks and backlinks |
145
+ | `memory_doctor` | Diagnose Markdown/index problems and return a short-lived full-rebuild token |
146
+ | `memory_sync` | Run quick reconciliation or an explicitly authorized full SQLite rebuild |
147
+
148
+ Creating a memory needs no checksum. Updating or deleting one requires a complete `memory_read`, then its `expected_checksum` and one-time, ten-minute `update_token`. A truncated read intentionally has no update token. If the user edited the note in Obsidian after the read, the operation is rejected instead of overwritten.
149
+
150
+ The setup policy tells Agents when to use these tools. The Agent decides whether a verified result has durable value; the server enforces document format, version protection, body size, duplicate prevention, and sensitive-content protection.
151
+
152
+ ## Synchronization, safety, and recovery
153
+
154
+ KeepM reconciles the Vault at MCP startup. It compares path, mtime, and size first, hashes only changed files, and updates only the affected index rows. While the MCP process runs, `watchfiles` provides lower-latency external-edit handling; startup and stale reconciliation remain the correctness fallback, including Windows-to-WSL event loss.
155
+
156
+ `memory_create`, `memory_update`, and `memory_delete` immediately reconcile their affected paths. Do not call `memory_sync` as routine task cleanup.
157
+
158
+ For external bulk edits or suspected inconsistency:
159
+
160
+ 1. Call `memory_status` and `memory_doctor`.
161
+ 2. Use `memory_sync(full=false)` for a quick reconcile if needed.
162
+ 3. Use `memory_sync(full=true, confirm=true, repair_token=...)` only with the recent token from `memory_doctor` to rebuild SQLite.
163
+
164
+ The full rebuild never rewrites or deletes Markdown.
165
+
166
+ KeepM limits a memory body to 16 KiB and rejects high-confidence private keys, vendor tokens, and AWS credentials passed through MCP writes. It does not use fuzzy raw-log detection. A file edited directly in Obsidian that is invalid, oversized, or sensitive remains on disk but is excluded from the index and reported by `memory_doctor`.
167
+
168
+ ## Optional audit log
169
+
170
+ Auditing is disabled by default. Set `audit_enabled = true` in KeepM configuration to enable a local, rotating JSONL log under `~/.local/share/keepm/audit/`.
171
+
172
+ The log is for the local owner, not normal Agent context. It records only operation metadata, IDs, scope/kind, checksums, result, and stable error codes—never body text, titles, descriptions, tags, raw exceptions, or secrets.
173
+
174
+ ```bash
175
+ keepm audit --limit 50
176
+ ```
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ uv sync --extra dev
182
+ uv run pytest -q
183
+ uv run keepm setup
184
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keepm"
7
- version = "0.2.0"
7
+ version = "0.5.0"
8
8
  description = "Local Markdown memory MCP for coding agents"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import argparse
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from keepm.config import ConfigStore, default_data_root, vault_id
12
+
13
+
14
+ MAX_AUDIT_BYTES = 10 * 1024 * 1024
15
+ MAX_AUDIT_HISTORY = 5
16
+ SAFE_FIELDS = frozenset(
17
+ {
18
+ "time",
19
+ "operation",
20
+ "result",
21
+ "memory_id",
22
+ "scope",
23
+ "project_id",
24
+ "kind",
25
+ "before_checksum",
26
+ "after_checksum",
27
+ "code",
28
+ }
29
+ )
30
+
31
+
32
+ class AuditLogger:
33
+ """Best-effort, metadata-only audit sink for the local Vault owner."""
34
+
35
+ def __init__(self, path: Path, *, enabled: bool) -> None:
36
+ self.path = path.expanduser().resolve()
37
+ self.enabled = enabled
38
+
39
+ def record(
40
+ self,
41
+ *,
42
+ operation: str,
43
+ result: str,
44
+ memory_id: str | None,
45
+ scope: str | None,
46
+ project_id: str | None,
47
+ kind: str | None,
48
+ before_checksum: str | None,
49
+ after_checksum: str | None,
50
+ code: str | None,
51
+ ) -> None:
52
+ if not self.enabled:
53
+ return
54
+ event = {
55
+ "time": dt.datetime.now(dt.timezone.utc).isoformat(),
56
+ "operation": operation,
57
+ "result": result,
58
+ "memory_id": memory_id,
59
+ "scope": scope,
60
+ "project_id": project_id,
61
+ "kind": kind,
62
+ "before_checksum": before_checksum,
63
+ "after_checksum": after_checksum,
64
+ "code": code,
65
+ }
66
+ try:
67
+ payload = (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode(
68
+ "utf-8"
69
+ )
70
+ self.path.parent.mkdir(parents=True, exist_ok=True)
71
+ if self.path.exists() and self.path.stat().st_size + len(payload) > MAX_AUDIT_BYTES:
72
+ self._rotate()
73
+ with self.path.open("ab") as handle:
74
+ handle.write(payload)
75
+ handle.flush()
76
+ except OSError:
77
+ # Auditing must never make an otherwise valid memory operation fail.
78
+ return
79
+
80
+ def _rotate(self) -> None:
81
+ try:
82
+ oldest = self.path.with_name(f"{self.path.name}.{MAX_AUDIT_HISTORY}")
83
+ oldest.unlink(missing_ok=True)
84
+ for index in range(MAX_AUDIT_HISTORY - 1, 0, -1):
85
+ source = self.path.with_name(f"{self.path.name}.{index}")
86
+ if source.exists():
87
+ os.replace(source, self.path.with_name(f"{self.path.name}.{index + 1}"))
88
+ if self.path.exists():
89
+ os.replace(self.path, self.path.with_name(f"{self.path.name}.1"))
90
+ except OSError:
91
+ # A later append can still succeed if a platform disallows rotation.
92
+ return
93
+
94
+
95
+ def read_events(path: Path, *, limit: int = 100) -> tuple[dict[str, Any], ...]:
96
+ """Read a bounded, schema-filtered audit history without exposing arbitrary JSON."""
97
+ if limit < 1:
98
+ return ()
99
+ events: list[dict[str, Any]] = []
100
+ paths = [
101
+ path.with_name(f"{path.name}.{index}")
102
+ for index in range(MAX_AUDIT_HISTORY, 0, -1)
103
+ ] + [path]
104
+ for candidate in paths:
105
+ if not candidate.is_file():
106
+ continue
107
+ try:
108
+ lines = candidate.read_text(encoding="utf-8").splitlines()
109
+ except OSError:
110
+ continue
111
+ for line in lines:
112
+ try:
113
+ decoded = json.loads(line)
114
+ except json.JSONDecodeError:
115
+ continue
116
+ if not isinstance(decoded, Mapping):
117
+ continue
118
+ events.append(
119
+ {key: decoded.get(key) for key in SAFE_FIELDS if key in decoded}
120
+ )
121
+ return tuple(events[-limit:])
122
+
123
+
124
+ def main(argv: list[str] | None = None) -> None:
125
+ parser = argparse.ArgumentParser(
126
+ prog="keepm audit", description="Show local KeepM audit metadata for the Vault owner."
127
+ )
128
+ parser.add_argument("--limit", type=int, default=100, help="maximum recent events")
129
+ arguments = parser.parse_args(argv)
130
+ if arguments.limit < 1:
131
+ parser.error("--limit must be positive")
132
+ try:
133
+ config = ConfigStore().load()
134
+ except FileNotFoundError as error:
135
+ parser.error(str(error))
136
+ if not config.audit_enabled:
137
+ parser.error("KeepM audit is disabled; set audit_enabled = true in KeepM config")
138
+ path = default_data_root() / "audit" / f"{vault_id(config)}.jsonl"
139
+ for event in read_events(path, limit=arguments.limit):
140
+ print(json.dumps(event, ensure_ascii=False, sort_keys=True))
@@ -12,6 +12,11 @@ def main(argv: Sequence[str] | None = None) -> None:
12
12
 
13
13
  setup_main(arguments[1:])
14
14
  return
15
+ if arguments[:1] == ["audit"]:
16
+ from keepm.audit import main as audit_main
17
+
18
+ audit_main(arguments[1:])
19
+ return
15
20
 
16
21
  from keepm.server import main as server_main
17
22
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import hashlib
5
6
  import tomllib
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
@@ -21,12 +22,19 @@ def default_data_root() -> Path:
21
22
  return root / "keepm"
22
23
 
23
24
 
25
+ def vault_id(config: "KeepMConfig") -> str:
26
+ """Return a stable, non-secret identifier for one configured memory root."""
27
+ identity = f"{config.vault_path}\0{config.memory_root.resolve()}"
28
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
29
+
30
+
24
31
  @dataclass(frozen=True, slots=True)
25
32
  class KeepMConfig:
26
33
  vault_path: Path
27
34
  memory_dir: str = "Memory"
28
35
  stale_after_seconds: int = 30
29
36
  language: MemoryLanguage = "en"
37
+ audit_enabled: bool = False
30
38
 
31
39
  def __post_init__(self) -> None:
32
40
  memory_path = Path(self.memory_dir)
@@ -40,6 +48,8 @@ class KeepMConfig:
40
48
  raise ValueError("stale_after_seconds must be non-negative")
41
49
  if self.language not in ("zh", "en"):
42
50
  raise ValueError("language must be 'zh' or 'en'")
51
+ if not isinstance(self.audit_enabled, bool):
52
+ raise ValueError("audit_enabled must be true or false")
43
53
  object.__setattr__(self, "vault_path", self.vault_path.expanduser().resolve())
44
54
 
45
55
  @property
@@ -67,11 +77,15 @@ class ConfigStore:
67
77
  if not self.path.is_file():
68
78
  raise FileNotFoundError(f"KeepM is not configured: {self.path}")
69
79
  data = tomllib.loads(self.path.read_text(encoding="utf-8"))
80
+ audit_enabled = data.get("audit_enabled", False)
81
+ if not isinstance(audit_enabled, bool):
82
+ raise ValueError("audit_enabled must be true or false")
70
83
  return KeepMConfig(
71
84
  vault_path=Path(data["vault_path"]),
72
85
  memory_dir=str(data.get("memory_dir", "Memory")),
73
86
  stale_after_seconds=int(data.get("stale_after_seconds", 30)),
74
87
  language=cast(MemoryLanguage, str(data.get("language", "en"))),
88
+ audit_enabled=audit_enabled,
75
89
  )
76
90
 
77
91
  def load_optional(self) -> KeepMConfig | None:
@@ -90,6 +104,7 @@ class ConfigStore:
90
104
  f"memory_dir = {json.dumps(config.memory_dir)}\n"
91
105
  f"stale_after_seconds = {config.stale_after_seconds}\n"
92
106
  f"language = {json.dumps(config.language)}\n"
107
+ f"audit_enabled = {str(config.audit_enabled).lower()}\n"
93
108
  )
94
109
  temporary = self.path.with_suffix(".tmp")
95
110
  temporary.write_text(content, encoding="utf-8", newline="\n")