Skip to content

Commit e00582b

Browse files
skymanbpclaude
andcommitted
release: v2.5.0 — audit every shipped surface, then repair what it found
Not a feature release. Twelve agents exercised the six user-facing surfaces by RUNNING them rather than reading them; four more then attacked the fixes. ~134 defects closed across 41 files. Every number below was measured. Three surfaces did not work at all: - MCP could not survive a non-ASCII character under this host's default codec (writes stored mojibake or failed; a strict codec killed the process with no response). A 4301-digit int or ~3000 levels of nesting — both reachable through an advertised tool argument, before validation — escaped the too-narrow `except json.JSONDecodeError` and killed the server. `params: null`, which many clients send for "no params", consumed the id and never answered. - The web viewer answered ZERO requests: one idle TCP pre-connect wedged a single-threaded HTTPServer with no handler timeout, and `webbrowser.open` triggers exactly that. It also sent `Access-Control-Allow-Origin: *`, and anything POSTed is injected into the next session — a prompt-injection channel — while POST itself rewrote the WRONG project's MEMORY.md. - The standalone installer shipped NO user-facing surfaces: `~/.claude` held `hooks/` and `settings.json` and nothing else. With no plan-refiner agent, PLAN.md could never be populated — the v2.2 headline feature was dead on that layout. It also crashed AFTER copying files on any settings.json it could not parse (6 of 9 realistic shapes), leaving a half-installed tree. Data integrity: - Cross-project contamination. `_find_transcript_dir` matched on a SUBSTRING of the project basename; on this machine `core` matched 131 of 179 slug directories. A fixture seeded with 5 memories finished with 32 after a 278,700-record foreign transcript was ingested. Path mangling also missed `_` and `.` (0 of 179 real dirs contain either), so any such path fell into that branch by construction. Fuzzy matching deleted; ownership now proved from the transcript's own `cwd`. - The privacy filter failed OPEN above 100 tags — `<private>` content reached the API call and the DB. 16,000 unterminated tags took re.sub 9,517 ms and leaked the tail. Now a linear `str.find` scan, uncapped, failing CLOSED. - `is_private` was computed after `_truncate_output` replaced a Read body with "(file content)", so a private file's path reached the API anyway. - `/cc-mem sql` exited 0 while permanently committing `DROP TABLE`. Hooks: - The v2.2 live-plan anchor had never fired through its own hook: PostToolUse exited on the observation gate before reaching the plan block, and drift counters varied silently by mode. Plan control is not observation. - Hooks with hard host timeouts did not bound their LLM wall-clock, though `call_llm`'s docstring requires it. session_start overran its 15s budget with the SHIPPED DEFAULT config (2 candidates x 20s); stop.py was measured being killed at 24.96s against 22s. `call_llm` gains an absolute `deadline` that clamps each leg to the time remaining — stronger than the arithmetic, because urlopen(timeout=) covers neither DNS nor the TLS handshake (a SUCCESSFUL leg measured 1.48x its nominal timeout). Added: - tests/test_surfaces.py — first automated coverage for MCP, the web viewer and the installer's settings.json matrix. All three had none, which is why these defects shipped. Now a third release gate. - cc_memory/core/version.py — single source, importable under BOTH the nested and flat layouts (`import cc_memory` raises under flat, so the obvious approach could not work). Twelve literals across five files removed. - excluded_projects now works; config.json stripped to keys code reads. Gates: compileall - tomllib 2.5.0 - smoke 79 [OK] rc=0 - carryover 14 passed - surfaces PASSED - i18n 3 in-sync - 6 version sites agree. Both exes rebuilt; installer PE subsystem verified 2 (GUI) -> 3 (CONSOLE), so --cli is visible and returns an exit code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5cfcd4d commit e00582b

41 files changed

Lines changed: 9059 additions & 1348 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "cc-memory",
1313
"source": "./",
1414
"description": "Persistent memory: reconcile-on-write + LLM-judged semantic de-dup (no more stacked OR reworded duplicates), forced PROGRESS.md handoff with per-session annotation, live PLAN.md anchor, injection observability, FTS5 search, AI-judged extraction with Haiku + Ollama fallback.",
15-
"version": "2.4.3",
15+
"version": "2.5.0",
1616
"author": {"name": "skymanbp"},
1717
"repository": "https://github.com/skymanbp/cc-memory",
1818
"license": "MIT",

.claude-plugin/plugin.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cc-memory",
3-
"version": "2.4.3",
3+
"version": "2.5.0",
44
"description": "Persistent memory for Claude Code — automatic save/restore across compactions and sessions. Anti-patch reconcile-on-write + LLM-judged semantic de-duplication, forced PROGRESS.md handoff with per-session annotation, live PLAN.md anchor, injection observability, FTS5 search, AI-judged extraction with Haiku + local Ollama fallback.",
55
"author": {
66
"name": "skymanbp",
@@ -9,5 +9,11 @@
99
"homepage": "https://github.com/skymanbp/cc-memory",
1010
"repository": "https://github.com/skymanbp/cc-memory",
1111
"license": "MIT",
12-
"keywords": ["memory", "persistence", "context", "handoff", "compact", "sqlite", "fts5", "anti-patch"]
12+
"keywords": ["memory", "persistence", "context", "handoff", "compact", "sqlite", "fts5", "anti-patch"],
13+
"mcpServers": {
14+
"cc-memory": {
15+
"command": "python3",
16+
"args": ["${CLAUDE_PLUGIN_ROOT}/cc_memory/mcp/server.py"]
17+
}
18+
}
1319
}

CHANGELOG.md

Lines changed: 247 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 219 additions & 22 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 349 additions & 34 deletions
Large diffs are not rendered by default.

README.zh.md

Lines changed: 295 additions & 32 deletions
Large diffs are not rendered by default.

build_exe.py

Lines changed: 99 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,30 @@
33
Build standalone exe files for cc-memory plugin.
44
55
Produces:
6-
dist/cc-memory-installer.exe (one-click install on any machine)
7-
dist/cc-memory-dashboard.exe (visual memory management)
6+
dist/cc-memory-installer.exe (one-click install on any machine, CONSOLE app)
7+
dist/cc-memory-dashboard.exe (visual memory management, windowed)
88
99
Requirements: pip install pyinstaller
1010
11-
Packaging: bundles the subpackage layout (cc_memory/{core,hooks,llm,cli,mcp,ui}/)
12-
into cc_memory_files/ so the installer can mirror it under ~/.claude/hooks/cc-memory/.
11+
Packaging: bundles three payloads.
12+
cc_memory_files/<subdir>/ the python package (mirrors ui/installer.py
13+
SUBPACKAGE_FILES), laid out FLAT under
14+
~/.claude/hooks/cc-memory/ by the installer.
15+
cc_memory_surfaces/<rel> commands/ agents/ skills/ — the user-facing Claude
16+
Code surfaces, installed into ~/.claude/. They are
17+
NOT part of the package, so without this bundle a
18+
standalone install ships no /cc-mem, no /ccm-load
19+
and no plan-refiner subagent (PLAN.md then can
20+
never be populated).
21+
cc_memory_meta/hooks.json the hook-timeout source of truth, read by
22+
installer._declared_hook_timeouts().
23+
24+
The installer is built WITHOUT --windowed: its --cli branch is the documented
25+
headless path, and a GUI-subsystem binary prints nothing, returns no exit code
26+
to the shell, and turns any unhandled exception into a modal dialog that hangs
27+
forever with no console to show it. Tk still opens fine from a console app.
1328
"""
29+
import re
1430
import shutil
1531
import subprocess
1632
import sys
@@ -27,7 +43,8 @@
2743
"": ["__init__.py", "config.json"],
2844
"core": ["__init__.py", "auth.py", "consolidate.py", "db.py",
2945
"encoding_setup.py", "extractor.py", "idle.py", "logger.py",
30-
"modes.py", "plan.py", "privacy.py", "progress.py"],
46+
"modes.py", "plan.py", "privacy.py", "progress.py",
47+
"version.py"],
3148
"hooks": ["__init__.py", "consolidate_async.py", "post_tool_use.py",
3249
"pre_compact.py", "session_start.py", "stop.py", "user_prompt.py"],
3350
"llm": ["__init__.py", "ccl_backend.py", "memory_writer.py"],
@@ -36,6 +53,31 @@
3653
"ui": ["__init__.py", "dashboard.py", "installer.py", "web_viewer.py"],
3754
}
3855

56+
# User-facing surfaces. Mirrors ui/installer.py SURFACE_FILES.
57+
SURFACE_FILES = [
58+
"commands/cc-mem.md",
59+
"agents/plan-refiner.md",
60+
"agents/plan-guardian.md",
61+
"skills/ccm-load/SKILL.md",
62+
"skills/save-memories/SKILL.md",
63+
]
64+
65+
# Non-package data the installer reads at runtime.
66+
META_FILES = ["hooks/hooks.json"]
67+
68+
69+
def _version():
70+
"""Read the canonical version. Never a literal in this file."""
71+
for probe in (SRC / "core" / "version.py", SRC / "__init__.py"):
72+
try:
73+
txt = probe.read_text(encoding="utf-8")
74+
except OSError:
75+
continue
76+
m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', txt, re.M)
77+
if m:
78+
return m.group(1)
79+
return "unknown"
80+
3981

4082
def _flat_file_list():
4183
"""Yield (src_path, dest_subdir_in_bundle) for every plugin file."""
@@ -49,89 +91,97 @@ def _flat_file_list():
4991
return pairs
5092

5193

52-
def _check_files():
53-
missing = [str(p) for p, _ in _flat_file_list() if not p.exists()]
94+
def _surface_file_list():
95+
"""Yield (src_path, dest_subdir_in_bundle) for every shipped surface."""
96+
pairs = []
97+
for rel in SURFACE_FILES:
98+
sub = Path(rel).parent.as_posix()
99+
pairs.append((ROOT / rel, f"cc_memory_surfaces/{sub}"))
100+
return pairs
101+
102+
103+
def _meta_file_list():
104+
return [(ROOT / rel, "cc_memory_meta") for rel in META_FILES]
105+
106+
107+
def _check_files(pairs):
108+
missing = [str(p) for p, _ in pairs if not p.exists()]
54109
if missing:
55110
print("ERROR: Missing files:")
56111
for m in missing:
57112
print(f" {m}")
58113
sys.exit(1)
59114

60115

116+
def _data_args(pairs):
117+
# PyInstaller --add-data syntax: "src;dest" on Windows, "src:dest" on Unix
118+
sep = ";" if sys.platform == "win32" else ":"
119+
args = []
120+
for src_path, dest in pairs:
121+
args.extend(["--add-data", f"{src_path}{sep}{dest}"])
122+
return args
123+
124+
125+
def _run(cmd, exe_name):
126+
print(f"Running PyInstaller with {cmd.count('--add-data')} bundled files...")
127+
result = subprocess.run(cmd, cwd=str(ROOT))
128+
if result.returncode != 0:
129+
print("\n[FAIL] Build failed")
130+
sys.exit(1)
131+
exe_path = DIST / exe_name
132+
if exe_path.exists():
133+
size_mb = exe_path.stat().st_size / 1024 / 1024
134+
print(f"\n[OK] Built: {exe_path} ({size_mb:.1f} MB)")
135+
136+
61137
def build_installer():
62138
print("=" * 50)
63-
print(" Building cc-memory-installer.exe (v2.4.3)")
139+
print(f" Building cc-memory-installer.exe (v{_version()})")
64140
print("=" * 50)
65141

66-
_check_files()
67-
68-
data_args = []
69-
for src_path, dest in _flat_file_list():
70-
# PyInstaller --add-data syntax: "src;dest" on Windows, "src:dest" on Unix
71-
sep = ";" if sys.platform == "win32" else ":"
72-
data_args.extend(["--add-data", f"{src_path}{sep}{dest}"])
142+
pairs = _flat_file_list() + _surface_file_list() + _meta_file_list()
143+
_check_files(pairs)
73144

74145
cmd = [
75146
sys.executable, "-m", "PyInstaller",
76147
"--onefile",
77-
"--windowed",
148+
# Console, NOT --windowed: the installer's --cli branch prints 20+ lines
149+
# and is the documented headless path. Tk is created on demand in main().
150+
"--console",
78151
"--name", "cc-memory-installer",
79152
"--icon", "NONE",
80153
"--hidden-import", "sqlite3",
81154
"--hidden-import", "_sqlite3",
82155
"--collect-all", "sqlite3",
83-
*data_args,
156+
*_data_args(pairs),
84157
str(SRC / "ui" / "installer.py"),
85158
]
86-
87-
print(f"Running PyInstaller with {len(data_args)//2} bundled files...")
88-
result = subprocess.run(cmd, cwd=str(ROOT))
89-
if result.returncode == 0:
90-
exe_path = DIST / "cc-memory-installer.exe"
91-
if exe_path.exists():
92-
size_mb = exe_path.stat().st_size / 1024 / 1024
93-
print(f"\n[OK] Built: {exe_path} ({size_mb:.1f} MB)")
94-
else:
95-
print("\n[FAIL] Build failed")
96-
sys.exit(1)
159+
_run(cmd, "cc-memory-installer.exe")
97160

98161

99162
def build_dashboard():
100163
print("=" * 50)
101-
print(" Building cc-memory-dashboard.exe (v2.4.3)")
164+
print(f" Building cc-memory-dashboard.exe (v{_version()})")
102165
print("=" * 50)
103166

104-
_check_files()
105-
106-
# Dashboard needs everything the installer does (it imports core + llm).
107-
data_args = []
108-
for src_path, dest in _flat_file_list():
109-
sep = ";" if sys.platform == "win32" else ":"
110-
data_args.extend(["--add-data", f"{src_path}{sep}{dest}"])
167+
# Dashboard needs everything the installer's package bundle has (it imports
168+
# core + llm), but none of the surfaces/meta — it never installs anything.
169+
pairs = _flat_file_list()
170+
_check_files(pairs)
111171

112172
cmd = [
113173
sys.executable, "-m", "PyInstaller",
114174
"--onefile",
115-
"--windowed",
175+
"--windowed", # pure GUI: correct here, wrong for the installer
116176
"--name", "cc-memory-dashboard",
117177
"--icon", "NONE",
118178
"--hidden-import", "sqlite3",
119179
"--hidden-import", "_sqlite3",
120180
"--collect-all", "sqlite3",
121-
*data_args,
181+
*_data_args(pairs),
122182
str(SRC / "ui" / "dashboard.py"),
123183
]
124-
125-
print(f"Running PyInstaller with {len(data_args)//2} bundled files...")
126-
result = subprocess.run(cmd, cwd=str(ROOT))
127-
if result.returncode == 0:
128-
exe_path = DIST / "cc-memory-dashboard.exe"
129-
if exe_path.exists():
130-
size_mb = exe_path.stat().st_size / 1024 / 1024
131-
print(f"\n[OK] Built: {exe_path} ({size_mb:.1f} MB)")
132-
else:
133-
print("\n[FAIL] Build failed")
134-
sys.exit(1)
184+
_run(cmd, "cc-memory-dashboard.exe")
135185

136186

137187
if __name__ == "__main__":

cc_memory/__init__.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
"""
22
cc-memory — Claude Code persistent memory plugin.
33
4+
v2.5.0: A readiness audit of every shipped surface, and the repair of what it
5+
found. Three surfaces did not work at all: the MCP server could not
6+
survive a non-ASCII character under a locale codec, the web viewer
7+
answered zero requests because one idle TCP pre-connect wedged it, and
8+
the standalone installer shipped NO user-facing surfaces — no /cc-mem,
9+
no skills, no subagents — so the v2.2 live-plan feature could never work
10+
on that layout. Meanwhile `_find_transcript_dir` matched on a *substring*
11+
of the project basename (131 of 179 real directories matched `core`), so
12+
one project's transcripts were being LLM-extracted into another project's
13+
database and re-injected forever after; a fixture seeded with 5 memories
14+
finished with 32. The v2.2 plan anchor had also never fired through its
15+
own hook, because PostToolUse exited on the observation gate before
16+
reaching the plan block.
17+
Also: the privacy filter failed OPEN above 100 tags (now a linear,
18+
uncapped, fail-closed scan); hooks with hard host timeouts now bound
19+
their LLM wall-clock with an absolute deadline, not just per-leg socket
20+
timeouts (`urlopen(timeout=)` covers neither DNS nor the TLS handshake —
21+
a *successful* leg was measured at 1.48x its nominal timeout); and the
22+
version string is single-sourced from `core/version.py`, which is
23+
importable under BOTH the nested and the flat install layouts.
24+
New: `tests/test_surfaces.py`, the first automated coverage for the MCP
25+
server, the web viewer and the installer's settings.json shape matrix —
26+
all three previously had none, which is why these defects shipped.
27+
428
v2.4.2: Bounded transcript reads. An unbounded full-file load in the PreCompact
529
hook killed compaction on long-lived projects — a 2.1 GiB transcript
630
parses at ~25 MiB/s (~88s) against a 120s budget, so the hook was
@@ -61,4 +85,6 @@
6185
PROGRESS.md forced-handoff replaces SESSION_HANDOFF.md.
6286
MEMORY.md auto-regenerates on every write.
6387
"""
64-
__version__ = "2.4.3"
88+
from .core.version import __version__ # single source: cc_memory/core/version.py
89+
90+
__all__ = ["__version__"]

0 commit comments

Comments
 (0)