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 +7 -0
- cogmem/acquire.py +226 -0
- cogmem/artifacts.py +99 -0
- cogmem/cli.py +169 -0
- cogmem/common.py +115 -0
- cogmem/config.py +67 -0
- cogmem/consolidate.py +216 -0
- cogmem/daemon.py +158 -0
- cogmem/eval.py +217 -0
- cogmem/feedback.py +193 -0
- cogmem/guard.py +80 -0
- cogmem/hooks/cogmem-activate.sh +117 -0
- cogmem/hooks/cogmem-capture-pipeline.sh +76 -0
- cogmem/hooks/cogmem-capture.sh +57 -0
- cogmem/hooks/cogmem-context.sh +23 -0
- cogmem/hooks/cogmem-guard.sh +44 -0
- cogmem/hooks/cogmem-recall.sh +78 -0
- cogmem/index.py +57 -0
- cogmem/indexstore.py +121 -0
- cogmem/mcp_server.py +456 -0
- cogmem/metrics.py +243 -0
- cogmem/migrate.py +105 -0
- cogmem/narrative.py +134 -0
- cogmem/note.py +46 -0
- cogmem/projectstate.py +171 -0
- cogmem/provenance.py +1224 -0
- cogmem/recall.py +72 -0
- cogmem/review.py +147 -0
- cogmem/selfmodel.py +163 -0
- cogmem/softbinding.py +268 -0
- cogmem/test_cogmem.py +937 -0
- cogmem/test_mcp.py +131 -0
- cogmem/test_provenance.py +674 -0
- cogmem/test_softbinding.py +184 -0
- cogmem/tune.py +71 -0
- cogmem/usermodel.py +86 -0
- cogmem/wire_hooks.py +53 -0
- cogmem-2.7.0.dist-info/METADATA +291 -0
- cogmem-2.7.0.dist-info/RECORD +43 -0
- cogmem-2.7.0.dist-info/WHEEL +5 -0
- cogmem-2.7.0.dist-info/entry_points.txt +2 -0
- cogmem-2.7.0.dist-info/licenses/LICENSE +201 -0
- cogmem-2.7.0.dist-info/top_level.txt +1 -0
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))
|