cogsession 0.1.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.
cogsession/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """CogSession — Session memory system for AI coding agents."""
2
+ __version__ = "0.1.0"
cogsession/claims.py ADDED
@@ -0,0 +1,276 @@
1
+ """
2
+ cogsession/claims.py
3
+
4
+ Checkable claims: store the command that *proved* something, then re-run it.
5
+
6
+ Everything else in this package records what was believed at a moment —
7
+ decisions, dead ends, assumptions. None of it is ever re-checked, so the most
8
+ expensive recurring failure is not a wrong decision. It is a right one that
9
+ quietly stopped being true:
10
+
11
+ - a pull request description explaining a schema the code no longer has
12
+ - a source comment naming a constraint that moved
13
+ - a planning document one day old, read as current
14
+ - a test asserting a shape the implementation dropped
15
+ - a docstring contradicting its own function
16
+
17
+ Each reduces to one sentence: *something was true when it was written and
18
+ stopped being true.* A model cannot notice that from a transcript, and a human
19
+ notices it in review, which is the expensive place.
20
+
21
+ So a claim carries its own proof. Not a natural-language description of how to
22
+ check it — a command, its expected output, and the commit it held at. When the
23
+ files it touches change, re-run it. What comes back is not "you decided X" but
24
+ "what you wrote about X is now false", which is the only version that is
25
+ actionable.
26
+
27
+ ## On running stored commands
28
+
29
+ Verification executes shell commands from `claims.json`. That is the same trust
30
+ boundary as `.git/hooks`: local, developer-owned, not shared. Two guards make
31
+ that boundary explicit rather than assumed:
32
+
33
+ - a claims file **tracked by git** is never executed, because a tracked file
34
+ can arrive from someone else
35
+ - every command runs with a timeout and its output is compared, never
36
+ interpreted
37
+
38
+ Keep commands to cheap, read-only checks — `grep -c`, `test -f`, a fast unit
39
+ test. A claim is a tripwire, not a build step.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import subprocess
46
+ from dataclasses import asdict, dataclass, field
47
+ from datetime import datetime, timezone
48
+ from pathlib import Path
49
+ from typing import Optional
50
+
51
+ CLAIMS_FILE = "claims.json"
52
+ DEFAULT_TIMEOUT = 20
53
+ MAX_OUTPUT = 2000
54
+
55
+
56
+ def _now() -> str:
57
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
58
+
59
+
60
+ @dataclass
61
+ class Claim:
62
+ """One assertion, with the command that proves it.
63
+
64
+ `expect` is compared against stripped stdout. Leave it None to mean "the
65
+ command must merely succeed", which suits `test -f` or a test invocation
66
+ where the exit code is the whole signal.
67
+ """
68
+
69
+ claim: str
70
+ verified_by: str
71
+ expect: Optional[str] = None
72
+ watches: list = field(default_factory=list) # paths whose change re-checks
73
+ asserted_in: str = "" # where the claim was made
74
+ at_commit: str = ""
75
+ recorded_at: str = field(default_factory=_now)
76
+ last_status: str = "unverified" # holds | broken | error | unverified
77
+ last_checked: str = ""
78
+ last_output: str = ""
79
+
80
+ def to_dict(self) -> dict:
81
+ return asdict(self)
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict) -> "Claim":
85
+ known = {f for f in cls.__dataclass_fields__}
86
+ return cls(**{k: v for k, v in data.items() if k in known})
87
+
88
+
89
+ @dataclass
90
+ class CheckResult:
91
+ claim: Claim
92
+ status: str # holds | broken | error
93
+ detail: str
94
+
95
+ @property
96
+ def broken(self) -> bool:
97
+ return self.status != "holds"
98
+
99
+
100
+ class ClaimStore:
101
+ """Claims for one session, persisted as `claims.json` in its folder."""
102
+
103
+ def __init__(self, project_root: Path, session_id: str):
104
+ self.root = Path(project_root)
105
+ self.session_id = session_id
106
+ self.path = self.root / ".cogsessions" / session_id / CLAIMS_FILE
107
+
108
+ # ── Persistence ────────────────────────────────────────────────────
109
+
110
+ def load(self) -> list[Claim]:
111
+ if not self.path.exists():
112
+ return []
113
+ try:
114
+ raw = json.loads(self.path.read_text())
115
+ except (OSError, ValueError):
116
+ return []
117
+ return [Claim.from_dict(c) for c in raw.get("claims", []) if isinstance(c, dict)]
118
+
119
+ def save(self, claims: list[Claim]) -> None:
120
+ self.path.parent.mkdir(parents=True, exist_ok=True)
121
+ self.path.write_text(
122
+ json.dumps(
123
+ {"session_id": self.session_id, "updated_at": _now(),
124
+ "claims": [c.to_dict() for c in claims]},
125
+ indent=2,
126
+ )
127
+ )
128
+
129
+ def add(self, claim: Claim) -> Claim:
130
+ """Record a claim, replacing any earlier one with the same text.
131
+
132
+ Replacing rather than appending is deliberate: re-asserting something
133
+ means the current wording is what should be checked, and a store with
134
+ three versions of one claim cannot say which is live.
135
+ """
136
+ claims = [c for c in self.load() if c.claim != claim.claim]
137
+ claims.append(claim)
138
+ self.save(claims)
139
+ return claim
140
+
141
+ # ── Verification ───────────────────────────────────────────────────
142
+
143
+ def verify(self, only_if_changed: bool = False) -> list[CheckResult]:
144
+ """Re-run each claim's proof. Returns one result per claim checked."""
145
+ if _is_git_tracked(self.root, self.path):
146
+ return []
147
+
148
+ claims = self.load()
149
+ results: list[CheckResult] = []
150
+ for claim in claims:
151
+ if only_if_changed and not self._touched(claim):
152
+ continue
153
+ result = run_check(self.root, claim)
154
+ claim.last_status = result.status
155
+ claim.last_checked = _now()
156
+ claim.last_output = result.detail[:MAX_OUTPUT]
157
+ results.append(result)
158
+ if results:
159
+ self.save(claims)
160
+ return results
161
+
162
+ def _touched(self, claim: Claim) -> bool:
163
+ """Has anything this claim watches changed since it was recorded?
164
+
165
+ No commit and no watch list means "always check": a claim that cannot
166
+ say what would invalidate it is exactly the one worth re-running.
167
+ """
168
+ if not claim.at_commit:
169
+ return True
170
+ changed = _changed_since(self.root, claim.at_commit)
171
+ if changed is None:
172
+ return True
173
+ if not claim.watches:
174
+ return True
175
+ return any(
176
+ any(path == w or path.startswith(w.rstrip("/") + "/") for w in claim.watches)
177
+ for path in changed
178
+ )
179
+
180
+
181
+ def run_check(project_root: Path, claim: Claim) -> CheckResult:
182
+ """Execute one claim's command and compare against `expect`."""
183
+ try:
184
+ proc = subprocess.run(
185
+ claim.verified_by,
186
+ shell=True,
187
+ cwd=str(project_root),
188
+ capture_output=True,
189
+ text=True,
190
+ timeout=DEFAULT_TIMEOUT,
191
+ )
192
+ except subprocess.TimeoutExpired:
193
+ return CheckResult(claim, "error", f"timed out after {DEFAULT_TIMEOUT}s")
194
+ except OSError as exc:
195
+ return CheckResult(claim, "error", f"could not run: {exc}")
196
+
197
+ got = (proc.stdout or "").strip()
198
+
199
+ if claim.expect is None:
200
+ if proc.returncode == 0:
201
+ return CheckResult(claim, "holds", "command succeeded")
202
+ return CheckResult(
203
+ claim, "broken",
204
+ f"exit {proc.returncode}: {(proc.stderr or got).strip()[:200]}",
205
+ )
206
+
207
+ if got == claim.expect.strip():
208
+ return CheckResult(claim, "holds", f"got {got!r}")
209
+ return CheckResult(
210
+ claim, "broken", f"expected {claim.expect.strip()!r}, got {got!r}"
211
+ )
212
+
213
+
214
+ def format_report(results: list[CheckResult]) -> str:
215
+ """One short block, and only when something moved.
216
+
217
+ Silence when everything holds is the point. A report that appears every
218
+ session becomes furniture, and furniture is not read.
219
+ """
220
+ if not results:
221
+ return ""
222
+ broken = [r for r in results if r.broken]
223
+ if not broken:
224
+ return ""
225
+
226
+ held = len(results) - len(broken)
227
+ lines = [
228
+ f"[CogSession] {len(broken)} claim(s) no longer hold"
229
+ + (f", {held} still do" if held else "")
230
+ + ":"
231
+ ]
232
+ for r in broken:
233
+ lines.append(f" ✗ {r.claim.claim}")
234
+ lines.append(f" {r.detail}")
235
+ if r.claim.asserted_in:
236
+ lines.append(f" asserted in: {r.claim.asserted_in}")
237
+ lines.append(f" proof: {r.claim.verified_by}")
238
+ return "\n".join(lines)
239
+
240
+
241
+ # ── git helpers ────────────────────────────────────────────────────────
242
+
243
+
244
+ def _git(project_root: Path, *args: str) -> Optional[subprocess.CompletedProcess]:
245
+ try:
246
+ return subprocess.run(
247
+ ["git", "-C", str(project_root), *args],
248
+ capture_output=True, text=True, timeout=10,
249
+ )
250
+ except (OSError, subprocess.SubprocessError):
251
+ return None
252
+
253
+
254
+ def _is_git_tracked(project_root: Path, path: Path) -> bool:
255
+ """Refuse to execute a claims file that could have come from someone else."""
256
+ proc = _git(project_root, "ls-files", "--error-unmatch", str(path))
257
+ return bool(proc and proc.returncode == 0)
258
+
259
+
260
+ def _changed_since(project_root: Path, commit: str) -> Optional[list[str]]:
261
+ """Repo-relative paths changed since `commit`, including uncommitted work.
262
+
263
+ None means git could not answer, which callers treat as "check anyway".
264
+ Failing towards checking is right for a tripwire: a missed re-check is a
265
+ stale claim believed, while an extra one costs a `grep`.
266
+ """
267
+ diff = _git(project_root, "diff", "--name-only", commit)
268
+ if diff is None or diff.returncode != 0:
269
+ return None
270
+ paths = {p for p in diff.stdout.splitlines() if p}
271
+ status = _git(project_root, "status", "--porcelain")
272
+ if status and status.returncode == 0:
273
+ for line in status.stdout.splitlines():
274
+ if len(line) > 3:
275
+ paths.add(line[3:].strip().split(" -> ")[-1])
276
+ return sorted(paths)
cogsession/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """
2
+ cogsession/cli.py
3
+
4
+ `cogsession install` — wire the hooks into Claude Code after a package install.
5
+
6
+ The MCP server gives an agent eleven tools it can call. The hooks are what make
7
+ CogSession work *without being asked*: the session opening itself, the journal
8
+ recording as work happens, claims re-checked when a session starts. Those live
9
+ in `~/.claude/settings.json`, which a package install cannot write on its own.
10
+
11
+ So this is the second half of `pip install cogsession`. Without it you get the
12
+ tools and none of the recording, which is a quieter and worse product than the
13
+ one described in the README.
14
+
15
+ It is idempotent, backs up the settings file first, and prints what it changed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from cogsession.installer import HOOKS, hook_command, is_installed_on_path, merge_settings
27
+
28
+ MCP_SERVER_NAME = "cogsession"
29
+
30
+
31
+ def _register_mcp_server(repo_dir: Path | None) -> str:
32
+ """Register the MCP server with the Claude Code CLI, if it is present.
33
+
34
+ A missing `claude` binary is not an error. Plenty of people wire MCP servers
35
+ up by hand or use another client, and failing the whole install over an
36
+ optional convenience would be wrong.
37
+ """
38
+ claude = shutil.which("claude")
39
+ if claude is None:
40
+ return " ~ `claude` not on PATH — register the MCP server yourself (see README)"
41
+
42
+ if repo_dir is not None:
43
+ command = [claude, "mcp", "add", MCP_SERVER_NAME, "--",
44
+ "uv", "--directory", str(repo_dir), "run", MCP_SERVER_NAME]
45
+ else:
46
+ command = [claude, "mcp", "add", MCP_SERVER_NAME, "--", MCP_SERVER_NAME]
47
+
48
+ try:
49
+ result = subprocess.run(command, capture_output=True, text=True, timeout=30)
50
+ except (OSError, subprocess.SubprocessError) as exc:
51
+ return f" ~ could not register the MCP server: {exc}"
52
+
53
+ if result.returncode == 0:
54
+ return f" ✓ MCP server registered as `{MCP_SERVER_NAME}`"
55
+ # Already-registered is the common non-zero case and is not a failure.
56
+ detail = (result.stderr or result.stdout).strip().splitlines()
57
+ return f" ~ MCP server not registered: {detail[0] if detail else 'unknown reason'}"
58
+
59
+
60
+ def install(argv: list[str] | None = None) -> int:
61
+ parser = argparse.ArgumentParser(
62
+ prog="cogsession install",
63
+ description="Wire CogSession's hooks into Claude Code.",
64
+ )
65
+ parser.add_argument(
66
+ "--repo",
67
+ type=Path,
68
+ default=None,
69
+ metavar="DIR",
70
+ help="Run the hooks from this checkout via uv, instead of the installed "
71
+ "console script. Use when working on CogSession itself.",
72
+ )
73
+ parser.add_argument(
74
+ "--claude-dir",
75
+ type=Path,
76
+ default=Path.home() / ".claude",
77
+ metavar="DIR",
78
+ help="Claude Code config directory (default: ~/.claude)",
79
+ )
80
+ parser.add_argument(
81
+ "--no-statusline",
82
+ action="store_true",
83
+ help="Do not offer to set the status line.",
84
+ )
85
+ parser.add_argument(
86
+ "--no-mcp",
87
+ action="store_true",
88
+ help="Only write the hooks; skip registering the MCP server.",
89
+ )
90
+ args = parser.parse_args(argv)
91
+
92
+ repo_dir = args.repo.resolve() if args.repo else None
93
+
94
+ if repo_dir is None and not is_installed_on_path():
95
+ print(
96
+ "cogsession-hook is not on PATH, so the hooks would not be able to run.\n"
97
+ "Either install the package (`pip install cogsession`) or point at a\n"
98
+ "checkout with `cogsession install --repo /path/to/cogsession`.",
99
+ file=sys.stderr,
100
+ )
101
+ return 1
102
+
103
+ settings = merge_settings(
104
+ args.claude_dir, repo_dir, set_statusline=not args.no_statusline
105
+ )
106
+
107
+ print("CogSession installed.\n")
108
+ print(f" ✓ {len(HOOKS)} hooks written to {settings}")
109
+ print(f" they run: {hook_command('<event>', repo_dir)}")
110
+
111
+ if not args.no_mcp:
112
+ print(_register_mcp_server(repo_dir))
113
+
114
+ print(
115
+ "\n Add `.cogsessions/` to your .gitignore — that is where sessions are kept,\n"
116
+ " and it is yours, not something to commit.\n"
117
+ "\n Open a new session to pick it up. Existing sessions loaded their\n"
118
+ " settings at start and will not see these hooks."
119
+ )
120
+ return 0
121
+
122
+
123
+ def main(argv: list[str] | None = None) -> int:
124
+ """Entry point for the `cogsession-admin` console script."""
125
+ argv = list(sys.argv[1:] if argv is None else argv)
126
+ if argv and argv[0] == "install":
127
+ return install(argv[1:])
128
+
129
+ print(
130
+ "Usage: cogsession-admin install [options]\n"
131
+ "\n"
132
+ " install Wire CogSession's hooks into Claude Code.\n"
133
+ "\n"
134
+ "The MCP server itself is the `cogsession` command, which an MCP client\n"
135
+ "launches for you — it is not meant to be run by hand.",
136
+ file=sys.stderr,
137
+ )
138
+ return 0 if not argv else 1
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())
cogsession/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ cogsession/config.py
3
+
4
+ All settings for CogSession.
5
+ Project-level config lives in .cogsession.json at project root.
6
+ Global config lives in ~/.cogsession/config.json
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ # ── Global defaults ───────────────────────────────────────────────────
16
+ SESSIONS_DIR_NAME = ".cogsessions"
17
+ CONFIG_FILE_NAME = ".cogsession.json"
18
+
19
+ # Where the handoff is delivered so the next session auto-loads it. Deliberately
20
+ # the .local variant: it is auto-loaded like CLAUDE.md but is not committed, so
21
+ # session state never enters a shared source file or dirties the working tree.
22
+ HANDOFF_TARGET_NAME = "CLAUDE.local.md"
23
+
24
+
25
+ DEFAULT_CONFIG = {
26
+ # Master switch — set false to completely disable
27
+ "enabled": True,
28
+
29
+ # Individual feature toggles — user can pick what they want
30
+ "features": {
31
+ "dead_ends": True, # Track approaches that failed
32
+ "assumptions": True, # Track what agent assumed but didn't verify
33
+ "token_map": True, # Tag decisions with context% when made
34
+ "error_graveyard": True, # Log errors + how they were resolved
35
+ "environment": True, # Snapshot run commands, ports, env vars
36
+ "file_cache": True, # Cache file reads to avoid duplicates
37
+ "danger_zones": True, # Flag files/areas that need care
38
+ "auto_diagram": True, # Generate Mermaid architecture diagram
39
+ "auto_handoff": True, # Auto-write handoff to CLAUDE.local.md (never a tracked file)
40
+ "session_search": True, # SQLite FTS across all sessions
41
+ "claim_checks": True, # Re-run the proof behind a recorded claim
42
+ },
43
+
44
+ # Context % thresholds
45
+ "thresholds": {
46
+ "warn_at": 65, # First gentle warning
47
+ "alert_at": 75, # Strong warning + suggestion
48
+ "checkpoint_at": 80, # Auto-checkpoint fires
49
+ },
50
+
51
+ # How often to auto-save running state (every N tool calls)
52
+ "autosave_every_n_tools": 10,
53
+
54
+ # Files/paths to exclude from tracking
55
+ "exclude": [
56
+ "*.log", ".env", ".env.*",
57
+ "node_modules/", "__pycache__/",
58
+ ".git/", "*.pyc", "dist/", "build/"
59
+ ],
60
+
61
+ # Where session folder lives relative to project root
62
+ "session_dir": ".cogsessions",
63
+ }
64
+
65
+
66
+ def load_project_config(project_root: Path) -> dict:
67
+ """Load .cogsession.json from project root, merged with defaults."""
68
+ config = DEFAULT_CONFIG.copy()
69
+ config_file = project_root / CONFIG_FILE_NAME
70
+
71
+ if config_file.exists():
72
+ try:
73
+ user_config = json.loads(config_file.read_text())
74
+ # Deep merge features
75
+ if "features" in user_config:
76
+ config["features"].update(user_config["features"])
77
+ if "thresholds" in user_config:
78
+ config["thresholds"].update(user_config["thresholds"])
79
+ # Top-level overrides
80
+ for k, v in user_config.items():
81
+ if k not in ("features", "thresholds"):
82
+ config[k] = v
83
+ except Exception as e:
84
+ print(f"[CogSession] Config parse error: {e}, using defaults", file=sys.stderr, flush=True)
85
+
86
+ return config
87
+
88
+
89
+ def is_enabled(project_root: Path) -> bool:
90
+ """Quick check — is cogsession enabled for this project?"""
91
+ # Global disable via env var
92
+ if os.getenv("COGSESSION_DISABLED"):
93
+ return False
94
+
95
+ config = load_project_config(project_root)
96
+ return config.get("enabled", True)
97
+
98
+
99
+ def feature_enabled(project_root: Path, feature: str) -> bool:
100
+ """Is a specific feature enabled?"""
101
+ config = load_project_config(project_root)
102
+ return config.get("features", {}).get(feature, True)
@@ -0,0 +1,96 @@
1
+ """
2
+ cogsession/distiller.py
3
+
4
+ Observation into memory.
5
+ Deterministic tier: extracts touched files, commands, test results, error text without LLM.
6
+ Inferential tier: best-effort LLM extraction for handoff prose/decisions.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Dict, Any, List
11
+ import json
12
+ from datetime import datetime, timezone
13
+
14
+ from cogsession.session.models import Session, LogEntry, DeadEnd
15
+
16
+
17
+ class Distiller:
18
+ """Pure functions converting transcript & hook observations into structured memory."""
19
+
20
+ @staticmethod
21
+ def process_deterministic_tool_use(
22
+ session: Session, tool_name: str, tool_input: Dict[str, Any], tool_response: Dict[str, Any]
23
+ ) -> None:
24
+ """Deterministic tier — runs on every tool call."""
25
+ ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
26
+
27
+ if tool_name == "Read":
28
+ fp = tool_input.get("file_path", "")
29
+ if fp and fp not in session.files_read:
30
+ session.files_read.append(fp)
31
+ session.append_log(LogEntry(type="file_read", content=fp))
32
+
33
+ elif tool_name in ("Write", "Edit", "MultiEdit"):
34
+ fp = tool_input.get("file_path", "")
35
+ if fp:
36
+ if tool_name == "Write" and fp not in session.files_created:
37
+ session.files_created.append(fp)
38
+ elif fp not in session.files_edited:
39
+ session.files_edited.append(fp)
40
+ session.append_log(LogEntry(type="file_edit", content=fp))
41
+
42
+ elif tool_name == "Bash":
43
+ cmd = tool_input.get("command", "")
44
+ exit_code = tool_response.get("exit_code", 0)
45
+ output = tool_response.get("output", "")
46
+
47
+ # Log git operations
48
+ if cmd.startswith("git "):
49
+ session.append_log(LogEntry(type="git_op", content=cmd))
50
+
51
+ # Log test runs
52
+ if any(test_runner in cmd for test_runner in ("pytest", "npm test", "jest", "go test")):
53
+ session.append_log(LogEntry(type="test_run", content=cmd))
54
+ if "passed" in output or "failed" in output:
55
+ # Update environment snapshot counts if present
56
+ import re
57
+
58
+ pass_match = re.search(r"(\d+)\s+passed", output)
59
+ fail_match = re.search(r"(\d+)\s+failed", output)
60
+ if pass_match:
61
+ session.environment.test_passing = int(pass_match.group(1))
62
+ if fail_match:
63
+ session.environment.test_failing = int(fail_match.group(1))
64
+ session.environment.test_command = cmd
65
+
66
+ # Extract tool errors
67
+ if exit_code != 0 and output:
68
+ error_type = "general_error"
69
+ if "ModuleNotFoundError" in output or "ImportError" in output:
70
+ error_type = "import_error"
71
+ elif "SyntaxError" in output:
72
+ error_type = "syntax_error"
73
+ elif "PermissionError" in output or "Permission denied" in output:
74
+ error_type = "permission_error"
75
+ elif "FileNotFoundError" in output:
76
+ error_type = "file_not_found"
77
+
78
+ session.append_log(
79
+ LogEntry(
80
+ type="error",
81
+ content=cmd[:100],
82
+ context_pct=session.token_pct_at_close,
83
+ )
84
+ )
85
+
86
+ # Append dead end preview if command failed twice or produced obvious error
87
+ if exit_code != 0 and len(output) > 20:
88
+ session.dead_ends.append(
89
+ DeadEnd(
90
+ timestamp=ts,
91
+ tried=cmd[:100],
92
+ why_failed=output[:150],
93
+ use_instead="",
94
+ context_pct=session.token_pct_at_close,
95
+ )
96
+ )