cogmem 2.7.0__py3-none-any.whl

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.
cogmem/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """cogmem — a self-improving, verifiable memory layer for AI coding agents.
2
+
3
+ The engine package. Installed under the import name ``cogmem`` (setuptools maps
4
+ it from the ``engine/`` directory); modules import each other as ``cogmem.<name>``.
5
+ """
6
+
7
+ __version__ = "2.7.0"
cogmem/acquire.py ADDED
@@ -0,0 +1,226 @@
1
+ """
2
+ Cognitive Memory — Acquisition Engine
3
+
4
+ Reads a session transcript, detects whether it contains behavior-change signal,
5
+ and (only when it does) extracts candidate rules: durable, scope-tagged things
6
+ that should change how the assistant acts in future sessions.
7
+
8
+ Design goal: learn how the user works so the assistant completes their work more
9
+ accurately and more autonomously. The unit of value is a behavior-change rule
10
+ that can be reliably surfaced when relevant.
11
+
12
+ Two-step cadence (cost discipline):
13
+ 1. detect — cheap model decides if the session has any signal at all
14
+ 2. extract — strong model runs ONLY when signal is present
15
+
16
+ Output: candidate rule files (markdown + YAML frontmatter) under vault/candidates/.
17
+ Candidates are never auto-promoted; consolidation dedups them and queues Layer-A
18
+ rules for human approval. The markdown files are the source of truth; nothing is
19
+ locked into a database.
20
+
21
+ Usage:
22
+ python acquire.py <transcript.jsonl> # explicit path
23
+ cat hook_payload.json | python acquire.py # Stop-hook mode (reads transcript_path)
24
+ python acquire.py <transcript.jsonl> --dry-run # print, don't write
25
+ """
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import re
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ from cogmem import config
36
+ from cogmem.common import api_call, parse_json_block
37
+
38
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
39
+ log = logging.getLogger("cogmem.acquire")
40
+
41
+ COGMEM = Path(os.environ.get("COGMEM_HOME", Path.home() / ".claude" / "cogmem"))
42
+ CANDIDATES_DIR = COGMEM / "vault" / "candidates"
43
+
44
+ DETECT_MODEL = config.model("detect") # cheap: runs every session
45
+ EXTRACT_MODEL = config.model("extract") # strong: runs only on signal
46
+
47
+ # Cap transcript size sent to the model. Most signal lives in user turns
48
+ # (corrections, preferences) and assistant text; we drop thinking/tool noise.
49
+ MAX_TRANSCRIPT_CHARS = 60_000
50
+
51
+
52
+ def extract_conversation(transcript_path: Path) -> str:
53
+ """Flatten a Claude Code transcript to role-tagged text, dropping
54
+ thinking blocks and tool noise (low signal, high token cost)."""
55
+ lines = []
56
+ for raw in transcript_path.read_text(errors="replace").splitlines():
57
+ raw = raw.strip()
58
+ if not raw:
59
+ continue
60
+ try:
61
+ obj = json.loads(raw)
62
+ except json.JSONDecodeError:
63
+ continue
64
+ if obj.get("type") not in ("user", "assistant"):
65
+ continue
66
+ msg = obj.get("message") or {}
67
+ role = msg.get("role")
68
+ content = msg.get("content")
69
+ if isinstance(content, str):
70
+ text = content
71
+ elif isinstance(content, list):
72
+ text = "\n".join(
73
+ b.get("text", "")
74
+ for b in content
75
+ if isinstance(b, dict) and b.get("type") == "text"
76
+ )
77
+ else:
78
+ text = ""
79
+ text = text.strip()
80
+ if text:
81
+ lines.append(f"[{role}] {text}")
82
+ convo = "\n".join(lines)
83
+ if len(convo) > MAX_TRANSCRIPT_CHARS:
84
+ # keep the tail: corrections and conclusions cluster at the end
85
+ convo = convo[-MAX_TRANSCRIPT_CHARS:]
86
+ return convo
87
+
88
+
89
+ DETECT_PROMPT = """You are a signal detector for a coding assistant's memory system.
90
+ Decide whether this session contains BEHAVIOR-CHANGE SIGNAL worth learning: a user
91
+ correction, a stated preference, a repeated or notable mistake, a non-obvious decision,
92
+ or a genuine surprise. Routine sessions with nothing transferable have no signal.
93
+
94
+ Answer with ONLY one word: YES or NO.
95
+
96
+ TRANSCRIPT:
97
+ {convo}"""
98
+
99
+ EXTRACT_PROMPT = """You are the acquisition engine for a coding assistant's memory system.
100
+ Goal: learn how this user works so the assistant completes their work more accurately and
101
+ more autonomously next time.
102
+
103
+ Extract BEHAVIOR-CHANGE RULES from the session: specific things that should change how the
104
+ assistant acts in future sessions. For each rule provide:
105
+ - layer: "A" or "B". DEFAULT TO "B". A rule is "A" (always-load) ONLY if forgetting it
106
+ would ship a bug or repeat a costly mistake, AND it must be present from the very start
107
+ of EVERY relevant session because there is no prompt that would reliably recall it in
108
+ time. Facts, situational knowledge, one-off context, anything you would only need "when
109
+ relevant", and anything a recall query could surface in the moment are ALL "B".
110
+ - scope: a project name, a language, or "universal"
111
+ - rule: the imperative rule itself, self-contained
112
+ - evidence: brief paraphrase of what in the session supports it
113
+
114
+ Be strict. Prefer a few high-value rules over many weak ones. Expect most rules to be "B";
115
+ "A" is rare. Layer A is a tiny, precious budget; reserve it for true must-never-miss guardrails.
116
+ If the session has nothing worth learning, return {{"signal": false, "rules": []}}.
117
+
118
+ Return ONLY JSON: {{"signal": true/false, "rules": [...]}}.
119
+
120
+ TRANSCRIPT:
121
+ {convo}"""
122
+
123
+
124
+ def slugify(text: str, maxlen: int = 50) -> str:
125
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
126
+ return s[:maxlen].rstrip("-") or "rule"
127
+
128
+
129
+ def write_candidate(rule: dict, session_id: str, now: str) -> Path | None:
130
+ rule_text = (rule.get("rule") or "").strip()
131
+ if not rule_text:
132
+ return None
133
+ layer = rule.get("layer", "B").upper()
134
+ if layer not in ("A", "B"):
135
+ layer = "B"
136
+ scope = (rule.get("scope") or "universal").strip()
137
+ evidence = (rule.get("evidence") or "").replace('"', "'").strip()
138
+
139
+ slug = f"{scope}-{slugify(rule_text)}"
140
+ path = CANDIDATES_DIR / f"{slug}.md"
141
+ # avoid clobbering distinct candidates that slugify the same
142
+ n = 2
143
+ while path.exists():
144
+ path = CANDIDATES_DIR / f"{slug}-{n}.md"
145
+ n += 1
146
+
147
+ frontmatter = (
148
+ "---\n"
149
+ f"id: {path.stem}\n"
150
+ f"layer: {layer}\n"
151
+ f"scope: {scope}\n"
152
+ "status: candidate\n"
153
+ f"created: {now}\n"
154
+ f"source_session: {session_id}\n"
155
+ f'evidence: "{evidence}"\n'
156
+ "---\n\n"
157
+ )
158
+ path.write_text(frontmatter + rule_text + "\n")
159
+ return path
160
+
161
+
162
+ def acquire(transcript_path: Path, dry_run: bool = False) -> int:
163
+ if not transcript_path.exists():
164
+ log.error("Transcript not found: %s", transcript_path)
165
+ return 0
166
+ convo = extract_conversation(transcript_path)
167
+ if len(convo) < 200:
168
+ log.info("Transcript too short to carry signal; skipping.")
169
+ return 0
170
+
171
+ detect = api_call(DETECT_MODEL, DETECT_PROMPT.format(convo=convo), max_tokens=5)
172
+ if detect is None:
173
+ return 0
174
+ if "yes" not in detect.lower():
175
+ log.info("No behavior-change signal detected; skipping extraction.")
176
+ return 0
177
+ log.info("Signal detected; running extraction.")
178
+
179
+ raw = api_call(EXTRACT_MODEL, EXTRACT_PROMPT.format(convo=convo), max_tokens=2000)
180
+ if raw is None:
181
+ return 0
182
+ result = parse_json_block(raw)
183
+ if not result or not result.get("signal"):
184
+ log.info("Extractor found no rules.")
185
+ return 0
186
+
187
+ session_id = transcript_path.stem
188
+ now = datetime.now(timezone.utc).isoformat()
189
+ rules = result.get("rules", [])
190
+
191
+ if dry_run:
192
+ sys.stdout.write(json.dumps(result, indent=2) + "\n")
193
+ log.info("[dry-run] would write %d candidate(s).", len(rules))
194
+ return len(rules)
195
+
196
+ CANDIDATES_DIR.mkdir(parents=True, exist_ok=True)
197
+ written = 0
198
+ for rule in rules:
199
+ p = write_candidate(rule, session_id, now)
200
+ if p:
201
+ written += 1
202
+ log.info(" CANDIDATE [%s/%s] %s", rule.get("layer"), rule.get("scope"), p.name)
203
+ log.info("Wrote %d candidate rule(s) to %s", written, CANDIDATES_DIR)
204
+ return written
205
+
206
+
207
+ def resolve_transcript_arg(arg: str) -> Path | None:
208
+ """Accept either a transcript path or a Stop-hook JSON payload on stdin."""
209
+ if arg and arg != "-":
210
+ return Path(arg)
211
+ try:
212
+ payload = json.loads(sys.stdin.read())
213
+ tp = payload.get("transcript_path")
214
+ return Path(tp) if tp else None
215
+ except json.JSONDecodeError:
216
+ return None
217
+
218
+
219
+ if __name__ == "__main__":
220
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
221
+ dry = "--dry-run" in sys.argv
222
+ path = resolve_transcript_arg(args[0] if args else "-")
223
+ if path is None:
224
+ log.error("No transcript path (arg or stdin hook payload required)")
225
+ sys.exit(1)
226
+ acquire(path, dry_run=dry)
cogmem/artifacts.py ADDED
@@ -0,0 +1,99 @@
1
+ """
2
+ Cognitive Memory — Artifact Grounding (git history)
3
+
4
+ Transcripts are a thin slice of the work. The repo records far more: what was
5
+ fixed, what got reverted, the gotchas that show up in commit after commit. This
6
+ mines a project's recent git history into candidate rules, so the system learns
7
+ from what was actually done, not only what was discussed. Output goes into the
8
+ normal candidate pipeline (consolidation dedups against existing knowledge).
9
+
10
+ Usage:
11
+ python artifacts.py [repo_path] # default: current directory
12
+ """
13
+
14
+ import logging
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from cogmem.common import VAULT, api_call, parse_json_block, write_note
21
+ from cogmem import config
22
+ from cogmem.acquire import slugify
23
+
24
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
25
+ log = logging.getLogger("cogmem.artifacts")
26
+
27
+ CANDIDATES = VAULT / "candidates"
28
+ MODEL = config.model("artifacts")
29
+ MAX_COMMITS = 150
30
+
31
+
32
+ def git_history(repo: Path) -> str | None:
33
+ try:
34
+ r = subprocess.run(
35
+ ["git", "-C", str(repo), "log", f"-n{MAX_COMMITS}", "--no-merges",
36
+ "--pretty=format:%h %s%n%b%n---"],
37
+ capture_output=True, text=True, timeout=30,
38
+ )
39
+ except (subprocess.SubprocessError, OSError) as e:
40
+ log.error("git failed: %s", e)
41
+ return None
42
+ if r.returncode != 0:
43
+ log.info("Not a git repo or no history: %s", repo)
44
+ return None
45
+ return r.stdout[:24000]
46
+
47
+
48
+ PROMPT = """Below are recent git commits (subjects and bodies) from the project "{project}".
49
+ Extract durable lessons that should change how a coding assistant works in THIS repo:
50
+ recurring bug classes, things that had to be reverted (and why), non-obvious gotchas,
51
+ and conventions revealed by fix commits. These are situational (Layer B) rules.
52
+
53
+ Be strict: only real, recurring or hard-won signal evident in the history, not generic
54
+ advice. Return at most 8 of the strongest. If nothing solid, return {{"rules": []}}.
55
+
56
+ Return ONLY JSON: {{"rules": [{{"rule": "imperative, self-contained", "evidence": "which commits/pattern"}}]}}
57
+
58
+ === COMMITS ===
59
+ {history}"""
60
+
61
+
62
+ def ingest(repo: Path) -> int:
63
+ history = git_history(repo)
64
+ if not history or len(history) < 200:
65
+ log.info("No usable history to ingest.")
66
+ return 0
67
+ project = repo.name
68
+ raw = api_call(MODEL, PROMPT.format(project=project, history=history), max_tokens=2500)
69
+ if raw is None:
70
+ return 0
71
+ result = parse_json_block(raw)
72
+ if not isinstance(result, dict):
73
+ return 0
74
+ rules = result.get("rules", [])
75
+ now = datetime.now(timezone.utc).isoformat()
76
+ CANDIDATES.mkdir(parents=True, exist_ok=True)
77
+ written = 0
78
+ for item in rules:
79
+ text = (item.get("rule") or "").strip()
80
+ if not text:
81
+ continue
82
+ scope = project.lower()
83
+ slug = f"{scope}-{slugify(text)}"
84
+ meta = {
85
+ "id": slug, "layer": "B", "scope": scope, "status": "candidate",
86
+ "created": now, "source": f"git:{project}",
87
+ "evidence": (item.get("evidence") or "").replace('"', "'"),
88
+ }
89
+ write_note(CANDIDATES / f"{slug}.md", meta, text)
90
+ written += 1
91
+ log.info(" CANDIDATE [%s] %s", scope, slug)
92
+ log.info("Ingested %d candidate rule(s) from %s history.", written, project)
93
+ return written
94
+
95
+
96
+ if __name__ == "__main__":
97
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
98
+ repo = Path(args[0]) if args else Path.cwd()
99
+ ingest(repo)
cogmem/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ cogmem — Python CLI entry point (console_scripts: ``cogmem``).
3
+
4
+ Mirrors the bash ``cogmem`` dispatcher so a ``pip install cogmem`` gets the same
5
+ commands. Most subcommands delegate to a module's ``__main__`` via runpy, reusing
6
+ the exact tested code paths; ``recall``, ``status``, and ``init`` are handled
7
+ directly. ``init`` performs the post-install wiring the clone installer's
8
+ install.sh does: Claude Code hooks, the warm daemon, and the recall index.
9
+ """
10
+
11
+ import os
12
+ import runpy
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ COGMEM_HOME = Path(os.environ.get("COGMEM_HOME", Path.home() / ".claude" / "cogmem"))
17
+
18
+ USAGE = (
19
+ "Usage: cogmem [status|doctor|trust|recall|note|guard|review|capture|mcp|"
20
+ "consolidate|index|verify|init]"
21
+ )
22
+
23
+ # subcommand -> (module, prefix args prepended to the user's args)
24
+ _DELEGATE = {
25
+ "doctor": ("cogmem.metrics", ["doctor"]),
26
+ "trust": ("cogmem.provenance", ["trust"]),
27
+ "witness": ("cogmem.provenance", ["witness"]),
28
+ "verify": ("cogmem.provenance", ["verify"]),
29
+ "receipt": ("cogmem.provenance", ["receipt"]),
30
+ "statement": ("cogmem.provenance", ["statement"]),
31
+ "sign-vault": ("cogmem.provenance", ["sign-vault"]),
32
+ "provenance": ("cogmem.provenance", []),
33
+ "review": ("cogmem.review", []),
34
+ "model": ("cogmem.usermodel", []),
35
+ "progress": ("cogmem.narrative", []),
36
+ "note": ("cogmem.note", []),
37
+ "capture": ("cogmem.acquire", []),
38
+ "guard": ("cogmem.guard", []),
39
+ "ingest": ("cogmem.artifacts", []),
40
+ "tune": ("cogmem.tune", []),
41
+ "eval": ("cogmem.eval", []),
42
+ "consolidate": ("cogmem.consolidate", []),
43
+ "index": ("cogmem.index", []),
44
+ "mcp": ("cogmem.mcp_server", []),
45
+ }
46
+
47
+
48
+ def _delegate(module: str, argv: list[str]) -> int:
49
+ sys.argv = [module.rsplit(".", 1)[-1], *argv]
50
+ try:
51
+ runpy.run_module(module, run_name="__main__")
52
+ except SystemExit as e: # modules call sys.exit; treat as their return code
53
+ return int(e.code or 0)
54
+ return 0
55
+
56
+
57
+ def _recall(args: list[str]) -> int:
58
+ from cogmem import config, recall
59
+
60
+ if not args:
61
+ sys.stdout.write("(no relevant memories)\n")
62
+ return 0
63
+ query = args[0]
64
+ cfg = config.load()
65
+ try:
66
+ results = recall.recall(query, k=3, scope=None)
67
+ results = recall.filter_results(results, cfg["recall_floor"], cfg["recall_gap"])
68
+ except Exception: # noqa: BLE001 — daemon down / cold: match the fail-open CLI contract
69
+ results = []
70
+ if not results:
71
+ sys.stdout.write("(no relevant memories)\n")
72
+ return 0
73
+ for r in results:
74
+ sys.stdout.write("- " + r["text"] + "\n")
75
+ return 0
76
+
77
+
78
+ def _status() -> int:
79
+ from cogmem.common import SOCK_PATH
80
+
81
+ _delegate("cogmem.metrics", [])
82
+ warm = "warm" if _daemon_alive(SOCK_PATH) else "cold (lazy-spawns)"
83
+ sys.stdout.write(f"Daemon: {warm}\n")
84
+ pending = COGMEM_HOME / "vault" / "pending"
85
+ n = len(list(pending.glob("*.md"))) if pending.exists() else 0
86
+ if n:
87
+ sys.stdout.write(f"-> {n} rule(s) await approval: cogmem review list\n")
88
+ return 0
89
+
90
+
91
+ def _daemon_alive(sock: Path) -> bool:
92
+ if not sock.exists():
93
+ return False
94
+ import socket
95
+
96
+ try:
97
+ c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
98
+ c.settimeout(1.0)
99
+ c.connect(str(sock))
100
+ c.sendall(b'{"cmd":"ping"}\n')
101
+ ok = b"ok" in c.recv(64)
102
+ c.close()
103
+ return ok
104
+ except OSError:
105
+ return False
106
+
107
+
108
+ def _init() -> int:
109
+ """Post-install wiring, idempotent: record the cogmem interpreter, materialize
110
+ the hook scripts under COGMEM_HOME, wire them into settings.json, build the
111
+ recall index. Makes a pip install a full Claude Code integration, not just a CLI."""
112
+ from importlib import resources
113
+
114
+ from cogmem import wire_hooks
115
+
116
+ COGMEM_HOME.mkdir(parents=True, exist_ok=True)
117
+ # The hooks are bash; record the interpreter that can `import cogmem` so they can
118
+ # invoke `python -m cogmem.<module>` regardless of how cogmem was installed.
119
+ (COGMEM_HOME / ".cogmem-python").write_text(sys.executable + "\n")
120
+
121
+ hooks_dir = COGMEM_HOME / "hooks"
122
+ hooks_dir.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ packaged = resources.files("cogmem") / "hooks"
125
+ count = 0
126
+ for entry in packaged.iterdir():
127
+ if entry.name.endswith(".sh"):
128
+ dest = hooks_dir / entry.name
129
+ dest.write_text(entry.read_text())
130
+ dest.chmod(0o755)
131
+ count += 1
132
+ sys.stdout.write(f" hooks: materialized {count} script(s) to {hooks_dir}\n")
133
+ except (FileNotFoundError, ModuleNotFoundError, OSError):
134
+ sys.stdout.write(" hooks: skipped (no packaged hook scripts found)\n")
135
+
136
+ claude_dir = Path(os.environ.get("CLAUDE_DIR", Path.home() / ".claude"))
137
+ added = wire_hooks.wire(claude_dir / "settings.json", hooks_dir)
138
+ sys.stdout.write(
139
+ f" settings: {added} hook(s) added, {len(wire_hooks.WIRING) - added} already present\n"
140
+ )
141
+
142
+ # Build the recall index if the recall extra is installed; harmless no-op otherwise.
143
+ try:
144
+ _delegate("cogmem.index", [])
145
+ except ModuleNotFoundError:
146
+ sys.stdout.write(" index: skipped (install the [recall] extra for semantic recall)\n")
147
+ sys.stdout.write("cogmem initialized. Try: cogmem status\n")
148
+ return 0
149
+
150
+
151
+ def main() -> int:
152
+ argv = sys.argv[1:]
153
+ cmd = argv[0] if argv else "status"
154
+ rest = argv[1:]
155
+ if cmd == "status":
156
+ return _status()
157
+ if cmd == "recall":
158
+ return _recall(rest)
159
+ if cmd == "init":
160
+ return _init()
161
+ if cmd in _DELEGATE:
162
+ module, prefix = _DELEGATE[cmd]
163
+ return _delegate(module, prefix + rest)
164
+ sys.stderr.write(USAGE + "\n")
165
+ return 1
166
+
167
+
168
+ if __name__ == "__main__":
169
+ sys.exit(main())
cogmem/common.py ADDED
@@ -0,0 +1,115 @@
1
+ """Shared helpers for the cogmem engine: API access and frontmatter I/O.
2
+
3
+ The markdown files are the source of truth. Frontmatter is a small, fixed set of
4
+ scalar fields, so a minimal parser is used rather than pulling in a YAML dependency.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import urllib.request
11
+ import urllib.error
12
+ from pathlib import Path
13
+
14
+ log = logging.getLogger("cogmem")
15
+
16
+ COGMEM = Path(os.environ.get("COGMEM_HOME", Path.home() / ".claude" / "cogmem"))
17
+ VAULT = COGMEM / "vault"
18
+ CLAUDE_DIR = Path.home() / ".claude"
19
+
20
+ # Derived runtime artifacts live under COGMEM_HOME, NOT next to the code: a pip
21
+ # install runs from site-packages (read-only, shared across COGMEM_HOMEs), so the
22
+ # recall socket and semantic index must sit in the user's home to stay writable and
23
+ # per-install isolated.
24
+ SOCK_PATH = COGMEM / "recall.sock"
25
+ INDEX_DB = COGMEM / "index.db"
26
+
27
+ API_URL = "https://api.anthropic.com/v1/messages"
28
+
29
+
30
+ def api_call(model: str, prompt: str, max_tokens: int) -> str | None:
31
+ key = os.environ.get("ANTHROPIC_API_KEY")
32
+ if not key:
33
+ log.error("ANTHROPIC_API_KEY not set")
34
+ return None
35
+ body = json.dumps(
36
+ {
37
+ "model": model,
38
+ "max_tokens": max_tokens,
39
+ "messages": [{"role": "user", "content": prompt}],
40
+ }
41
+ ).encode()
42
+ req = urllib.request.Request(
43
+ API_URL,
44
+ data=body,
45
+ headers={
46
+ "x-api-key": key,
47
+ "anthropic-version": "2023-06-01",
48
+ "content-type": "application/json",
49
+ },
50
+ method="POST",
51
+ )
52
+ try:
53
+ with urllib.request.urlopen(req, timeout=90) as resp:
54
+ return json.loads(resp.read())["content"][0]["text"]
55
+ except urllib.error.HTTPError as e:
56
+ log.error("API HTTP %s: %s", e.code, e.read()[:200])
57
+ except Exception as e: # noqa: BLE001 — callers run inside hooks; never crash a session
58
+ log.error("API call failed: %s", e)
59
+ return None
60
+
61
+
62
+ def parse_json_block(text: str) -> object | None:
63
+ text = text.strip()
64
+ if text.startswith("```"):
65
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
66
+ try:
67
+ return json.loads(text)
68
+ except json.JSONDecodeError:
69
+ log.warning("Could not parse model output: %s", text[:200])
70
+ return None
71
+
72
+
73
+ def read_note(path: Path) -> tuple[dict, str]:
74
+ """Return (frontmatter_dict, body) for a markdown note. Tolerant of files
75
+ without frontmatter (returns empty dict + full text as body)."""
76
+ text = path.read_text(errors="replace")
77
+ if not text.startswith("---\n"):
78
+ return {}, text.strip()
79
+ _, fm, body = text.split("---\n", 2)
80
+ meta: dict = {}
81
+ for line in fm.splitlines():
82
+ if ":" not in line:
83
+ continue
84
+ k, v = line.split(":", 1)
85
+ v = v.strip()
86
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
87
+ v = v[1:-1]
88
+ meta[k.strip()] = v
89
+ return meta, body.strip()
90
+
91
+
92
+ def validate_note(meta: dict, body: str) -> list[str]:
93
+ """Return a list of problems with a note (empty list = valid). Used to keep
94
+ malformed model output out of the vault."""
95
+ errors = []
96
+ if not meta.get("id"):
97
+ errors.append("missing id")
98
+ layer = meta.get("layer")
99
+ if layer and str(layer).upper() not in ("A", "B"):
100
+ errors.append(f"invalid layer: {layer}")
101
+ if not body or not body.strip():
102
+ errors.append("empty body")
103
+ return errors
104
+
105
+
106
+ def write_note(path: Path, meta: dict, body: str) -> None:
107
+ lines = ["---"]
108
+ for k, v in meta.items():
109
+ v = str(v)
110
+ if any(c in v for c in ':"#') or v == "":
111
+ v = '"' + v.replace('"', "'") + '"'
112
+ lines.append(f"{k}: {v}")
113
+ lines.append("---\n")
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ path.write_text("\n".join(lines) + "\n" + body.strip() + "\n")
cogmem/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Cognitive Memory — tunable config
3
+
4
+ Thresholds that self-regulation adjusts live, kept in config.json so the recall
5
+ path reads current values without code edits and `cogmem tune` can rewrite them.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ CONFIG = Path(__file__).resolve().parent / "config.json"
12
+
13
+ # LLM ids per pipeline role and the local recall models, in one place so upgrades
14
+ # don't need code edits and a config.json can override any of them.
15
+ _MODELS = {
16
+ "detect": "claude-haiku-4-5-20251001", # cheap signal check, every session
17
+ "extract": "claude-sonnet-4-6", # rule extraction, only on signal
18
+ "judge": "claude-haiku-4-5-20251001", # feedback verdicts
19
+ "consolidate": "claude-sonnet-4-6", # dedup classification
20
+ "selfmodel": "claude-sonnet-4-6",
21
+ "projectstate": "claude-sonnet-4-6",
22
+ "usermodel": "claude-sonnet-4-6",
23
+ "narrative": "claude-sonnet-4-6",
24
+ "artifacts": "claude-sonnet-4-6",
25
+ "eval_gen": "claude-haiku-4-5-20251001",
26
+ }
27
+ _EMBED_MODEL = "BAAI/bge-small-en-v1.5"
28
+ _RERANK_MODEL = "Xenova/ms-marco-MiniLM-L-6-v2"
29
+
30
+ DEFAULTS = {
31
+ "recall_floor": 0.62,
32
+ "recall_gap": 6.0,
33
+ "provenance_enforce": False,
34
+ "keychain": False,
35
+ "witness_did": None,
36
+ "user_name": "the user",
37
+ "models": _MODELS,
38
+ "embed_model": _EMBED_MODEL,
39
+ "rerank_model": _RERANK_MODEL,
40
+ }
41
+
42
+
43
+ def load() -> dict:
44
+ if CONFIG.exists():
45
+ try:
46
+ return {**DEFAULTS, **json.loads(CONFIG.read_text())}
47
+ except (json.JSONDecodeError, OSError):
48
+ return dict(DEFAULTS)
49
+ return dict(DEFAULTS)
50
+
51
+
52
+ def model(role: str) -> str:
53
+ """LLM id for a pipeline role; a partial "models" map in config.json overrides
54
+ individual roles, falling back to the built-in default for any role it omits."""
55
+ return load().get("models", {}).get(role, _MODELS[role])
56
+
57
+
58
+ def embed_model() -> str:
59
+ return load().get("embed_model", _EMBED_MODEL)
60
+
61
+
62
+ def rerank_model() -> str:
63
+ return load().get("rerank_model", _RERANK_MODEL)
64
+
65
+
66
+ def save(cfg: dict) -> None:
67
+ CONFIG.write_text(json.dumps(cfg, indent=2))