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.
- cairn_mem-0.1.0/LICENSE +21 -0
- cairn_mem-0.1.0/PKG-INFO +76 -0
- cairn_mem-0.1.0/README.md +62 -0
- cairn_mem-0.1.0/pyproject.toml +28 -0
- cairn_mem-0.1.0/setup.cfg +4 -0
- cairn_mem-0.1.0/src/cairn_mem/__init__.py +1 -0
- cairn_mem-0.1.0/src/cairn_mem/anchor.py +20 -0
- cairn_mem-0.1.0/src/cairn_mem/cli.py +221 -0
- cairn_mem-0.1.0/src/cairn_mem/render.py +37 -0
- cairn_mem-0.1.0/src/cairn_mem/store.py +313 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/PKG-INFO +76 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/SOURCES.txt +19 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/dependency_links.txt +1 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/entry_points.txt +3 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/requires.txt +4 -0
- cairn_mem-0.1.0/src/cairn_mem.egg-info/top_level.txt +1 -0
- cairn_mem-0.1.0/tests/test_anchor.py +26 -0
- cairn_mem-0.1.0/tests/test_cli.py +194 -0
- cairn_mem-0.1.0/tests/test_golden.py +48 -0
- cairn_mem-0.1.0/tests/test_render.py +48 -0
- cairn_mem-0.1.0/tests/test_store.py +259 -0
cairn_mem-0.1.0/LICENSE
ADDED
|
@@ -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.
|
cairn_mem-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
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
|