cairn-mem 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DavidPerezOrfila
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: cairn-mem
3
+ Version: 0.1.0
4
+ Summary: Memoria persistente para agentes de código: CLI + archivos, sin LLM
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/DavidPerezOrfila/cairn-mem
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: ruff; extra == "dev"
13
+ Dynamic: license-file
14
+
15
+ # cairn-mem
16
+
17
+ Memoria persistente para agentes de código: **CLI + archivos**, sin LLM y sin dependencias.
18
+ Funciona con cualquier agente/arnés que tenga shell.
19
+
20
+ Una memoria es una decisión, convención, gotcha o hecho del proyecto, con su motivo.
21
+ Cairn la guarda en `.cairn/events.jsonl` (append-only, es la verdad) y mantiene un
22
+ índice FTS5 y un L0 compacto (`.cairn/MEMORY.md`) que el agente puede leer al arrancar.
23
+
24
+ ## Instalación
25
+
26
+ ```bash
27
+ pipx install cairn-mem # comando global `cairn`
28
+ # o, para desarrollar:
29
+ pip install -e ".[dev]"
30
+ ```
31
+
32
+ ## Uso
33
+
34
+ ```bash
35
+ cairn init [--share] # crea .cairn/ (local-only por defecto)
36
+ cairn save --kind gotcha --topic auth/redirect --title "..." --body "..." --why "..." --anchor src/auth.py
37
+ cairn search "oauth callback" --limit 5 # L1: id, kind, title, snippet, stale
38
+ cairn get <id> # L2: memoria completa
39
+ cairn render # L0: regenera e imprime MEMORY.md (≤ 3000 car.)
40
+ cairn check # memorias con ancla cambiada (⚠ revisar)
41
+ cairn archive <id> # deja de aparecer
42
+ cairn rebuild # reconstruye cairn.db desde events.jsonl
43
+ ```
44
+
45
+ - `kind`: `decision | convention | gotcha | fact`. `body` ≤ 500 caracteres.
46
+ - Mismo `--topic` reemplaza la memoria anterior; no dupliques.
47
+ - `--json` en `save`, `search`, `get` y `check` para consumo desde agentes.
48
+ - Secretos: se **rechazan** antes de escribir (claves cloud, JWT, `password = ...`, URLs con credenciales).
49
+
50
+ ## Integración con un agente
51
+
52
+ Copia el snippet de [`docs/AGENT_SNIPPET.md`](docs/AGENT_SNIPPET.md) en el `AGENTS.md`/`CLAUDE.md`
53
+ del proyecto. Verificado guardando en un arnés y recuperando en otro (OpenCode y GitHub Copilot CLI).
54
+
55
+ ## Diseño
56
+
57
+ - Verdad: `.cairn/events.jsonl` (2 operaciones: `save`, `archive`).
58
+ - Derivados y regenerables: `cairn.db` (SQLite + FTS5) y `MEMORY.md`.
59
+ - Búsqueda BM25 con consultas saneadas (AND y fallback OR), sin embeddings.
60
+ - `stale` se calcula al leer comparando el hash del ancla (CRLF→LF), no se persiste.
61
+ - Detalle: [`docs/DESIGN.md`](docs/DESIGN.md) · Decisiones: [`docs/DECISIONS.md`](docs/DECISIONS.md) · Medidas: [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md).
62
+
63
+ ## Estado
64
+
65
+ v0.1.0. Hitos: [`PLAN.md`](PLAN.md). Reglas de desarrollo: [`AGENTS.md`](AGENTS.md).
66
+
67
+ ## Desarrollo
68
+
69
+ ```bash
70
+ pip install -e ".[dev]"
71
+ pytest -q
72
+ ruff check src tests bench
73
+ python bench/bench_cli.py --reps 20
74
+ ```
75
+
76
+ Python 3.10+; el núcleo usa solo la stdlib.
@@ -0,0 +1,62 @@
1
+ # cairn-mem
2
+
3
+ Memoria persistente para agentes de código: **CLI + archivos**, sin LLM y sin dependencias.
4
+ Funciona con cualquier agente/arnés que tenga shell.
5
+
6
+ Una memoria es una decisión, convención, gotcha o hecho del proyecto, con su motivo.
7
+ Cairn la guarda en `.cairn/events.jsonl` (append-only, es la verdad) y mantiene un
8
+ índice FTS5 y un L0 compacto (`.cairn/MEMORY.md`) que el agente puede leer al arrancar.
9
+
10
+ ## Instalación
11
+
12
+ ```bash
13
+ pipx install cairn-mem # comando global `cairn`
14
+ # o, para desarrollar:
15
+ pip install -e ".[dev]"
16
+ ```
17
+
18
+ ## Uso
19
+
20
+ ```bash
21
+ cairn init [--share] # crea .cairn/ (local-only por defecto)
22
+ cairn save --kind gotcha --topic auth/redirect --title "..." --body "..." --why "..." --anchor src/auth.py
23
+ cairn search "oauth callback" --limit 5 # L1: id, kind, title, snippet, stale
24
+ cairn get <id> # L2: memoria completa
25
+ cairn render # L0: regenera e imprime MEMORY.md (≤ 3000 car.)
26
+ cairn check # memorias con ancla cambiada (⚠ revisar)
27
+ cairn archive <id> # deja de aparecer
28
+ cairn rebuild # reconstruye cairn.db desde events.jsonl
29
+ ```
30
+
31
+ - `kind`: `decision | convention | gotcha | fact`. `body` ≤ 500 caracteres.
32
+ - Mismo `--topic` reemplaza la memoria anterior; no dupliques.
33
+ - `--json` en `save`, `search`, `get` y `check` para consumo desde agentes.
34
+ - Secretos: se **rechazan** antes de escribir (claves cloud, JWT, `password = ...`, URLs con credenciales).
35
+
36
+ ## Integración con un agente
37
+
38
+ Copia el snippet de [`docs/AGENT_SNIPPET.md`](docs/AGENT_SNIPPET.md) en el `AGENTS.md`/`CLAUDE.md`
39
+ del proyecto. Verificado guardando en un arnés y recuperando en otro (OpenCode y GitHub Copilot CLI).
40
+
41
+ ## Diseño
42
+
43
+ - Verdad: `.cairn/events.jsonl` (2 operaciones: `save`, `archive`).
44
+ - Derivados y regenerables: `cairn.db` (SQLite + FTS5) y `MEMORY.md`.
45
+ - Búsqueda BM25 con consultas saneadas (AND y fallback OR), sin embeddings.
46
+ - `stale` se calcula al leer comparando el hash del ancla (CRLF→LF), no se persiste.
47
+ - Detalle: [`docs/DESIGN.md`](docs/DESIGN.md) · Decisiones: [`docs/DECISIONS.md`](docs/DECISIONS.md) · Medidas: [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md).
48
+
49
+ ## Estado
50
+
51
+ v0.1.0. Hitos: [`PLAN.md`](PLAN.md). Reglas de desarrollo: [`AGENTS.md`](AGENTS.md).
52
+
53
+ ## Desarrollo
54
+
55
+ ```bash
56
+ pip install -e ".[dev]"
57
+ pytest -q
58
+ ruff check src tests bench
59
+ python bench/bench_cli.py --reps 20
60
+ ```
61
+
62
+ Python 3.10+; el núcleo usa solo la stdlib.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cairn-mem"
7
+ version = "0.1.0"
8
+ description = "Memoria persistente para agentes de código: CLI + archivos, sin LLM"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/DavidPerezOrfila/cairn-mem"
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8", "ruff"]
19
+
20
+ [project.scripts]
21
+ cairn = "cairn_mem.cli:main"
22
+ cairn-mem = "cairn_mem.cli:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,20 @@
1
+ """Anclas de fichero: hash estable entre SO y stale calculado al leer."""
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+
7
+ def file_hash(path) -> str:
8
+ data = Path(path).read_bytes().replace(b"\r\n", b"\n")
9
+ return hashlib.sha1(data).hexdigest()
10
+
11
+
12
+ def is_stale(anchor_path, anchor_hash, project_root) -> bool:
13
+ if not anchor_path or not anchor_hash:
14
+ return False
15
+ try:
16
+ return file_hash(Path(project_root) / anchor_path) != anchor_hash
17
+ except OSError:
18
+ # No se pudo leer (falta, es carpeta o está bloqueado): no se puede
19
+ # confirmar el hash, así que se avisa. Nunca se oculta la memoria.
20
+ return True
@@ -0,0 +1,221 @@
1
+ """CLI de cairn-mem (M1)."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from cairn_mem.anchor import file_hash, is_stale
11
+ from cairn_mem.store import KINDS, Store
12
+
13
+ GITIGNORE = ("cairn.db", "cairn.db-*", "MEMORY.md")
14
+
15
+
16
+ def _data_dir() -> Path:
17
+ env = os.environ.get("CAIRN_DIR")
18
+ if env:
19
+ return Path(env)
20
+ cwd = Path.cwd().resolve()
21
+ for folder in (cwd, *cwd.parents):
22
+ if (folder / ".cairn").is_dir():
23
+ return folder / ".cairn"
24
+ raise FileNotFoundError(
25
+ "no se encontró .cairn/ en este árbol; ejecuta 'cairn init'"
26
+ )
27
+
28
+
29
+ def _print_json(value) -> None:
30
+ print(json.dumps(value, ensure_ascii=False))
31
+
32
+
33
+ def _anchor(path: str, project_root: Path) -> tuple[str, str]:
34
+ file = Path(path)
35
+ if not file.is_absolute():
36
+ file = project_root / file
37
+ if not file.is_file():
38
+ raise FileNotFoundError(f"ancla no encontrada: {path}")
39
+ try:
40
+ relative = Path(
41
+ os.path.relpath(file.resolve(), project_root.resolve())
42
+ ).as_posix()
43
+ except ValueError as error:
44
+ raise ValueError(f"el ancla debe estar dentro del proyecto: {path}") from error
45
+ return relative, file_hash(file)
46
+
47
+
48
+ def _cmd_init(ns) -> int:
49
+ env = os.environ.get("CAIRN_DIR")
50
+ root = Path(env) if env else Path.cwd() / ".cairn"
51
+ root.mkdir(parents=True, exist_ok=True)
52
+ (root / "events.jsonl").touch()
53
+ lines = list(GITIGNORE)
54
+ if not ns.share:
55
+ lines.append("events.jsonl")
56
+ (root / ".gitignore").write_text("\n".join(lines) + "\n", encoding="utf-8")
57
+ Store(str(root))
58
+ print(f"cairn inicializado en {root}")
59
+ return 0
60
+
61
+
62
+ def _cmd_save(ns) -> int:
63
+ anchor_path = anchor_hash = None
64
+ if ns.anchor:
65
+ anchor_path, anchor_hash = _anchor(ns.anchor, _data_dir().parent)
66
+ mid = Store(str(_data_dir())).save(
67
+ kind=ns.kind,
68
+ title=ns.title,
69
+ body=ns.body,
70
+ why=ns.why,
71
+ topic_key=ns.topic,
72
+ anchor_path=anchor_path,
73
+ anchor_hash=anchor_hash,
74
+ id=ns.id,
75
+ )
76
+ _print_json({"id": mid}) if ns.json else print(mid)
77
+ return 0
78
+
79
+
80
+ def _cmd_search(ns) -> int:
81
+ results = Store(str(_data_dir())).search(ns.query, limit=ns.limit)
82
+ if ns.json:
83
+ _print_json(results)
84
+ elif not results:
85
+ print("sin resultados")
86
+ else:
87
+ for r in results:
88
+ snippet = " ".join(r["snippet"].split())
89
+ stale = " ⚠ revisar" if r["stale"] else ""
90
+ print(f"{r['id']} {r['kind']} {r['title']} — {snippet}{stale}")
91
+ return 0
92
+
93
+
94
+ def _cmd_get(ns) -> int:
95
+ memory = Store(str(_data_dir())).get(ns.id)
96
+ if memory is None:
97
+ raise ValueError(f"no existe la memoria {ns.id}")
98
+ if ns.json:
99
+ _print_json(memory)
100
+ else:
101
+ for key, value in memory.items():
102
+ if value is not None:
103
+ print(f"{key}: {value}")
104
+ return 0
105
+
106
+
107
+ def _cmd_archive(ns) -> int:
108
+ store = Store(str(_data_dir()))
109
+ if store.get(ns.id) is None:
110
+ raise ValueError(f"no existe la memoria {ns.id}")
111
+ store.archive(ns.id)
112
+ print(f"archivada {ns.id}")
113
+ return 0
114
+
115
+
116
+ def _cmd_rebuild(ns) -> int:
117
+ store = Store(str(_data_dir()))
118
+ store.rebuild()
119
+ print(f"reconstruida: {len(store.dump())} memorias")
120
+ return 0
121
+
122
+
123
+ def _cmd_render(ns) -> int:
124
+ print(Store(str(_data_dir())).write_l0(), end="")
125
+ return 0
126
+
127
+
128
+ def _cmd_check(ns) -> int:
129
+ data_dir = _data_dir()
130
+ root = data_dir.parent
131
+ stale = [
132
+ row
133
+ for row in Store(str(data_dir)).dump()
134
+ if row["status"] == "active"
135
+ and is_stale(row["anchor_path"], row["anchor_hash"], root)
136
+ ]
137
+ if ns.json:
138
+ _print_json(
139
+ [
140
+ {
141
+ "id": row["id"],
142
+ "kind": row["kind"],
143
+ "title": row["title"],
144
+ "anchor_path": row["anchor_path"],
145
+ }
146
+ for row in stale
147
+ ]
148
+ )
149
+ elif not stale:
150
+ print("sin memorias stale")
151
+ else:
152
+ for row in stale:
153
+ print(f"{row['id']} {row['kind']} {row['title']} ({row['anchor_path']})")
154
+ return 0
155
+
156
+
157
+ def _parser() -> argparse.ArgumentParser:
158
+ p = argparse.ArgumentParser(
159
+ prog="cairn", description="Memoria persistente para agentes"
160
+ )
161
+ sub = p.add_subparsers(dest="command", required=True)
162
+
163
+ init = sub.add_parser("init", help="crea .cairn/ (local-only por defecto)")
164
+ init.add_argument("--share", action="store_true", help="versiona events.jsonl")
165
+ init.set_defaults(handler=_cmd_init)
166
+
167
+ save = sub.add_parser("save", help="guarda una memoria")
168
+ save.add_argument("--kind", required=True, choices=KINDS)
169
+ save.add_argument("--title", required=True)
170
+ save.add_argument("--body", required=True)
171
+ save.add_argument("--why")
172
+ save.add_argument("--topic", dest="topic")
173
+ save.add_argument("--anchor")
174
+ save.add_argument("--id")
175
+ save.add_argument("--json", action="store_true")
176
+ save.set_defaults(handler=_cmd_save)
177
+
178
+ search = sub.add_parser("search", help="busca memorias activas")
179
+ search.add_argument("query")
180
+ search.add_argument("--limit", type=int, default=5)
181
+ search.add_argument("--json", action="store_true")
182
+ search.set_defaults(handler=_cmd_search)
183
+
184
+ get = sub.add_parser("get", help="muestra una memoria completa")
185
+ get.add_argument("id")
186
+ get.add_argument("--json", action="store_true")
187
+ get.set_defaults(handler=_cmd_get)
188
+
189
+ archive = sub.add_parser("archive", help="archiva una memoria")
190
+ archive.add_argument("id")
191
+ archive.set_defaults(handler=_cmd_archive)
192
+
193
+ rebuild = sub.add_parser("rebuild", help="reconstruye la DB desde events.jsonl")
194
+ rebuild.set_defaults(handler=_cmd_rebuild)
195
+
196
+ render = sub.add_parser("render", help="regenera y muestra MEMORY.md (L0)")
197
+ render.set_defaults(handler=_cmd_render)
198
+
199
+ check = sub.add_parser("check", help="lista memorias con ancla stale")
200
+ check.add_argument("--json", action="store_true")
201
+ check.set_defaults(handler=_cmd_check)
202
+ return p
203
+
204
+
205
+ def main(argv=None) -> int:
206
+ for stream in (sys.stdout, sys.stderr):
207
+ if hasattr(stream, "reconfigure"):
208
+ stream.reconfigure(encoding="utf-8", errors="replace")
209
+ ns = _parser().parse_args(argv)
210
+ try:
211
+ return ns.handler(ns)
212
+ except (ValueError, FileNotFoundError) as error:
213
+ print(f"cairn: {error}", file=sys.stderr)
214
+ return 1
215
+ except sqlite3.Error:
216
+ print("cairn: error de base de datos; ejecuta 'cairn rebuild'", file=sys.stderr)
217
+ return 1
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ """L0: MEMORY.md determinista con presupuesto de caracteres."""
2
+
3
+ from pathlib import Path
4
+
5
+ from cairn_mem.anchor import is_stale
6
+
7
+ KIND_PRIORITY = {"convention": 0, "decision": 1, "gotcha": 2, "fact": 3}
8
+ EXCERPT = 120
9
+ TITLE = "# Memoria del proyecto (cairn L0)"
10
+
11
+
12
+ def render_l0(store, max_chars: int = 3000, project_root=None) -> str:
13
+ """Orden fijo por kind y ts desc, cortado al presupuesto."""
14
+ root = Path(project_root) if project_root else Path(store.root).parent
15
+ rows = [row for row in store.dump() if row["status"] == "active"]
16
+ rows.sort(
17
+ key=lambda row: (
18
+ KIND_PRIORITY.get(row["kind"], len(KIND_PRIORITY)),
19
+ -row["ts"],
20
+ row["id"],
21
+ )
22
+ )
23
+ lines = [TITLE]
24
+ for row in rows:
25
+ line = _line(row, root)
26
+ if len("\n".join([*lines, line])) + 1 > max_chars:
27
+ break
28
+ lines.append(line)
29
+ return "\n".join(lines) + "\n"
30
+
31
+
32
+ def _line(row: dict, root: Path) -> str:
33
+ body = " ".join(row["body"].split())[:EXCERPT]
34
+ text = f"- [{row['id']}] {row['kind']}: {row['title']} — {body}"
35
+ if is_stale(row["anchor_path"], row["anchor_hash"], root):
36
+ text += " ⚠ revisar"
37
+ return text