get-claudia 1.56.1 → 1.57.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.57.0 (2026-05-13)
6
+
7
+ ### The Curated Memory Release
8
+
9
+ Five PRs that complete one thesis: **curated, judgment-driven memory capture, enforced at prompt time and persisted across sessions.** Claudia now catches the user's intent when it matters, persists canonical facts as they emerge, and writes a daily session summary so context survives across days.
10
+
11
+ #### Fixed
12
+ - **PostToolUse hook actually runs (#38)** -- The hook was reading `os.environ.get("CLAUDE_TOOL_NAME")`, which Claude Code never sets. Every install since the hook landed had been silently no-op'ing, so `~/.claudia/observations.jsonl` was never written. The hook now reads its payload from stdin per the documented hook contract. Includes a sibling fix to the legacy `claudia/.claude/hooks/post-tool-capture.py` for codebase consistency.
13
+
14
+ #### Added
15
+ - **Memory-commitment rule (#39)** -- A new always-active rule (`template-v2/.claude/rules/memory-commitment.md`) codifies when to save canonical facts immediately via `memory_remember` / `memory_batch` rather than batching to end-of-session reflection. Trigger phrases include "lock this in," "remember this," "this is canonical." Substantive-artifact discipline: at the end of producing a multi-file artifact, do a memory commitment pass and save the canonical facts as one bundled `memory_batch` call.
16
+ - **UserPromptSubmit hook with intent detection (#42)** -- A new hook (`template-v2/.claude/hooks/user-prompt-capture.py`) inspects the user's prompt at submit time and injects reminder context for two trigger classes. Class 1: canonical-fact phrases ("lock this in," "remember this," etc.) tell the agent to save immediately rather than wait for `/meditate`. Class 2: destructive command patterns (`rm -rf`, `git push --force`, `DROP TABLE`, etc.) trigger a "verify before acting" reminder per the safety-first principle. Destructive patterns are surfaced to the model as human-readable labels (`rm -rf (recursive delete)`), not raw regex, so the agent can reason about them clearly.
17
+ - **Daily session summary system (#40)** -- A new SessionEnd hook (`template-v2/.claude/hooks/session-summary.py`) writes a per-session markdown summary to `~/.claudia/sessions/YYYY-MM-DD/NN-slug.md` covering opening prompt, files touched, external actions, and find-this-again references. SessionStart now surfaces a 3-day digest of recent sessions via the existing health-check hook, so future-Claudia knows what past-Claudia worked on. PostToolUse hook gained `file_path` extraction for Write/Edit/MultiEdit/NotebookEdit and `external_action` labels for git push, gh repo create, vercel/netlify deploy, supabase db push, and direct MCP sends.
18
+ - **Explicit upgrade messaging (#50)** -- The installer now names `~/.claudia/` explicitly after an upgrade and lists what is preserved (entities, relationships, reflections, embeddings) instead of the generic "data preserved" phrasing. Users care about their accumulated memory graph; the previous wording did not signal that the database is safe.
19
+
20
+ #### Changed
21
+ - **External-action detection uses word-boundary regex (#40)** -- Previously a substring match, so `echo "git push for testing"` falsely fired the `external_action` flag. The new patterns anchor on command separators (line start, `;`, `&&`, `|`, `(`) and skip transparent prefixes (`sudo`, `nohup`, `time`, `env`). False positives on echoed/quoted strings are eliminated; real commands still fire.
22
+ - **PostToolUse output truncation 200 -> 300 chars (#40)** -- Room for the richer output context that includes `file_path` and `external_action` labels alongside the truncated stdout/stderr.
23
+
24
+ #### Stats
25
+ - 41 new hook tests in `tests/hooks/` (stdlib `unittest`, zero new dependencies), all passing in ~1.5s
26
+ - TDD sensitivity proofs for every behavior change: tests fail on the un-modified hook, pass after the fix
27
+ - 5 PRs merged, 0 regressions
28
+
29
+ #### Notes
30
+ - The brief that drove this chain emphasized one principle: **trust the existing user-file preservation policy (commit `efce9f2`)** rather than inventing a new upgrade framework. The installer's behavior didn't change; only the messaging did.
31
+ - The four hook PRs each landed with their own automated tests and TDD sensitivity proofs. The legacy `claudia/` subdirectory was kept in sync with the canonical `template-v2/` to avoid maintenance drift.
32
+
33
+ ---
34
+
5
35
  ## 1.56.1 (2026-04-11)
6
36
 
7
37
  ### Preserve User-Modified Skills on Upgrade
package/bin/index.js CHANGED
@@ -877,7 +877,10 @@ async function main() {
877
877
  }
878
878
 
879
879
  console.log('');
880
- console.log(` ${colors.cyan}✓${colors.reset} Framework updated (data preserved)`);
880
+ console.log(` ${colors.cyan}✓${colors.reset} Framework updated`);
881
+ console.log(` • Your memory at ${colors.bold}~/.claudia/${colors.reset} is preserved (entities, relationships, reflections, embeddings).`);
882
+ console.log(` • Skills and hooks refreshed; any modifications you chose to keep were respected.`);
883
+ console.log(` • Restart Claude Code for changes to take effect.`);
881
884
  }
882
885
 
883
886
  // Self-heal: strip CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS from settings (#24)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.56.1",
3
+ "version": "1.57.0",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -1,37 +1,137 @@
1
1
  #!/usr/bin/env python3
2
2
  """PostToolUse hook: passively captures tool invocations to a JSONL file.
3
3
 
4
+ Reads hook input from stdin (Claude Code's actual contract), not env vars.
4
5
  Writes observations to ~/.claudia/observations.jsonl for later ingestion
5
- by the memory daemon. Designed to complete in ~1ms (file append only).
6
+ by the memory daemon and by the session-summary generator.
7
+
8
+ Designed to complete in <50ms (file append only, no network).
9
+
10
+ Captured per-tool-call:
11
+ - tool name + truncated input/output
12
+ - file paths touched (for Write/Edit/MultiEdit/NotebookEdit)
13
+ - external actions (git push, gh repo create, vercel deploy, etc.)
14
+ - session_id for linking observations to a session
6
15
  """
7
16
 
8
17
  import json
9
- import os
18
+ import re
10
19
  import sys
11
20
  import time
12
21
  from pathlib import Path
13
22
 
23
+ # Skip noisy or read-only tools that don't need observation
14
24
  SKIP_PREFIXES = ("memory_", "memory.", "mcp__plugin_episodic", "cognitive.")
15
- SKIP_NAMES = {"Read", "Glob", "Grep", "LS", "ListMcpResourcesTool", "ReadMcpResourceTool"}
25
+ SKIP_NAMES = {
26
+ "Read", "Glob", "Grep", "LS", "TodoRead",
27
+ "ListMcpResourcesTool", "ReadMcpResourceTool",
28
+ "TaskList", "TaskGet", "TaskOutput", "ToolSearch",
29
+ }
30
+
31
+ # Bash command patterns that signal an "external action" worth flagging in
32
+ # the daily summary. Anchored so the candidate command must appear at the
33
+ # start of the line or after a shell separator (`;`, `&&`, `|`, `\n`, `(`),
34
+ # with optional transparent prefixes (`sudo`, `nohup`, `time`, `env VAR=`).
35
+ # This rejects echo'd test JSON ("git push for testing") and prefixed
36
+ # look-alikes ("git pushd", "ungit push") while still firing on real
37
+ # invocations including chained ones (`cd foo && git push`).
38
+ _BASH_CMD_ANCHOR = (
39
+ r"(?:^|[;&|\n(])\s*"
40
+ r"(?:sudo\s+|nohup\s+|time\s+|env\s+\w+=\S+\s+)*"
41
+ )
42
+ EXTERNAL_ACTION_PATTERNS: list[tuple[str, re.Pattern]] = [
43
+ ("git push", re.compile(_BASH_CMD_ANCHOR + r"git\s+push\b")),
44
+ ("gh repo create", re.compile(_BASH_CMD_ANCHOR + r"gh\s+repo\s+create\b")),
45
+ ("gh pr create", re.compile(_BASH_CMD_ANCHOR + r"gh\s+pr\s+create\b")),
46
+ ("gh release", re.compile(_BASH_CMD_ANCHOR + r"gh\s+release\b")),
47
+ ("vercel --prod", re.compile(_BASH_CMD_ANCHOR + r"vercel\s+(?:[^\n]*\s)?--prod\b")),
48
+ ("vercel deploy", re.compile(_BASH_CMD_ANCHOR + r"vercel\s+deploy\b")),
49
+ ("netlify deploy", re.compile(_BASH_CMD_ANCHOR + r"netlify\s+deploy\b")),
50
+ ("supabase db push", re.compile(_BASH_CMD_ANCHOR + r"supabase\s+db\s+push\b")),
51
+ ("npx supabase", re.compile(_BASH_CMD_ANCHOR + r"npx\s+supabase\b")),
52
+ ]
53
+
54
+
55
+ def is_external_action(tool_name: str, tool_input) -> str | None:
56
+ """Return a short label if this tool call looks like an external action."""
57
+ if tool_name == "Bash":
58
+ cmd = (tool_input or {}).get("command", "") if tool_input else ""
59
+ if not cmd:
60
+ return None
61
+ for label, regex in EXTERNAL_ACTION_PATTERNS:
62
+ if regex.search(cmd):
63
+ return label
64
+ elif tool_name in {
65
+ "mcp__claudia-private-email__claudia_email_send",
66
+ "mcp__gmail__send_email", "mcp__gmail__draft_email",
67
+ }:
68
+ return "email send/draft"
69
+ elif tool_name in {
70
+ "mcp__google-calendar__create_event",
71
+ "mcp__google-calendar__update_event",
72
+ "mcp__google-calendar__delete_event",
73
+ }:
74
+ return "calendar event"
75
+ elif tool_name.startswith("mcp__claude_ai_Slack__slack_send"):
76
+ return "slack send"
77
+ return None
78
+
79
+
80
+ def extract_file_path(tool_name: str, tool_input: dict) -> str | None:
81
+ """For Write/Edit/MultiEdit/NotebookEdit, return the file path being modified."""
82
+ if tool_name in {"Write", "Edit", "MultiEdit", "NotebookEdit"}:
83
+ return (tool_input or {}).get("file_path")
84
+ return None
16
85
 
17
86
 
18
87
  def main():
19
- tool_name = os.environ.get("CLAUDE_TOOL_NAME", "")
88
+ # Claude Code passes hook payload as JSON on stdin.
89
+ try:
90
+ raw = sys.stdin.read()
91
+ if not raw.strip():
92
+ return
93
+ payload = json.loads(raw)
94
+ except (json.JSONDecodeError, OSError):
95
+ return
96
+
97
+ tool_name = payload.get("tool_name", "")
20
98
  if not tool_name:
21
99
  return
22
100
 
23
101
  if any(tool_name.startswith(p) for p in SKIP_PREFIXES) or tool_name in SKIP_NAMES:
24
102
  return
25
103
 
26
- tool_input = os.environ.get("CLAUDE_TOOL_INPUT", "")[:200]
27
- tool_output = os.environ.get("CLAUDE_TOOL_OUTPUT", "")[:200]
104
+ tool_input = payload.get("tool_input") or {}
105
+ tool_response = payload.get("tool_response") or {}
106
+ session_id = payload.get("session_id", "")
107
+
108
+ input_summary = json.dumps(tool_input)[:300] if tool_input else ""
109
+ output_summary = ""
110
+ if isinstance(tool_response, dict):
111
+ for key in ("stdout", "output", "result", "content"):
112
+ if key in tool_response:
113
+ val = tool_response[key]
114
+ output_summary = str(val)[:300] if val else ""
115
+ break
116
+ if not output_summary:
117
+ output_summary = json.dumps(tool_response)[:300]
118
+ else:
119
+ output_summary = str(tool_response)[:300]
120
+
121
+ file_path = extract_file_path(tool_name, tool_input)
122
+ external_action = is_external_action(tool_name, tool_input)
28
123
 
29
124
  observation = {
30
- "tool": tool_name,
31
- "input": tool_input,
32
- "output": tool_output,
33
125
  "ts": time.time(),
126
+ "session_id": session_id,
127
+ "tool": tool_name,
128
+ "input": input_summary,
129
+ "output": output_summary,
34
130
  }
131
+ if file_path:
132
+ observation["file_path"] = file_path
133
+ if external_action:
134
+ observation["external_action"] = external_action
35
135
 
36
136
  obs_file = Path.home() / ".claudia" / "observations.jsonl"
37
137
  obs_file.parent.mkdir(parents=True, exist_ok=True)
@@ -24,6 +24,50 @@ def _fetch_briefing():
24
24
  return None
25
25
 
26
26
 
27
+ def _recent_sessions_summary(max_days: int = 3) -> str:
28
+ """Return a compact recap of the last N days of session summaries.
29
+
30
+ Reads ~/.claudia/sessions/YYYY-MM-DD/INDEX.md files. Returns a short
31
+ multi-day digest showing what was worked on. Bounded to keep startup fast.
32
+
33
+ Empty string when no session history exists yet (fresh installs).
34
+ """
35
+ sessions_dir = Path.home() / ".claudia" / "sessions"
36
+ if not sessions_dir.exists():
37
+ return ""
38
+
39
+ date_folders = sorted(
40
+ [d for d in sessions_dir.iterdir() if d.is_dir() and d.name[:4].isdigit()],
41
+ reverse=True,
42
+ )[:max_days]
43
+
44
+ if not date_folders:
45
+ return ""
46
+
47
+ lines = []
48
+ for date_dir in date_folders:
49
+ summaries = sorted(date_dir.glob("[0-9][0-9]-*.md"))
50
+ if not summaries:
51
+ continue
52
+ topics = []
53
+ for f in summaries:
54
+ try:
55
+ first_line = f.read_text(encoding="utf-8").split("\n", 1)[0]
56
+ topic = first_line.lstrip("#").strip()
57
+ if "—" in topic:
58
+ topic = topic.split("—", 1)[1].strip()
59
+ topics.append(topic)
60
+ except OSError:
61
+ continue
62
+ if topics:
63
+ lines.append(f" {date_dir.name}: {' · '.join(topics[:5])}")
64
+
65
+ if not lines:
66
+ return ""
67
+
68
+ return "Recent sessions (from ~/.claudia/sessions/):\n" + "\n".join(lines)
69
+
70
+
27
71
  def _run_claudia(*args):
28
72
  """Run claudia CLI and return parsed JSON output, or None on failure."""
29
73
  claudia_bin = shutil.which("claudia")
@@ -65,11 +109,15 @@ def check_health():
65
109
 
66
110
  summary = " ".join(parts) if parts else "System ready."
67
111
  briefing = _fetch_briefing()
112
+ recent = _recent_sessions_summary()
113
+
114
+ sections = [f"Memory system healthy. {summary}"]
115
+ if recent:
116
+ sections.append("--- Recent Sessions ---\n" + recent)
68
117
  if briefing:
69
- output = f"Memory system healthy. {summary}\n\n--- Session Briefing ---\n{briefing}"
70
- else:
71
- output = f"Memory system healthy. {summary}"
72
- print(json.dumps({"additionalContext": output}))
118
+ sections.append("--- Session Briefing ---\n" + briefing)
119
+
120
+ print(json.dumps({"additionalContext": "\n\n".join(sections)}))
73
121
  return
74
122
 
75
123
  # CLI not available -- provide fallback guidance
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a daily session summary markdown file from observations.jsonl.
3
+
4
+ Designed to run from a SessionEnd hook OR manually for retrospective summaries.
5
+
6
+ Output path: ~/.claudia/sessions/YYYY-MM-DD/NN-slug.md
7
+ Plus an INDEX.md per day, auto-regenerated.
8
+
9
+ Usage:
10
+ session-summary.py # uses CLAUDE_SESSION_ID env var or stdin JSON
11
+ session-summary.py <session_id> # explicit session id
12
+ session-summary.py --rebuild-index # regenerate today's INDEX.md only
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import time
20
+ from collections import Counter
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ OBS_FILE = Path.home() / ".claudia" / "observations.jsonl"
25
+ SESSIONS_DIR = Path.home() / ".claudia" / "sessions"
26
+
27
+ # Words to filter out when deriving topic slugs
28
+ STOPWORDS = {
29
+ "the", "a", "an", "and", "or", "but", "if", "of", "to", "for", "with",
30
+ "in", "on", "at", "by", "is", "are", "was", "were", "be", "been", "being",
31
+ "i", "you", "we", "us", "they", "this", "that", "these", "those",
32
+ "have", "has", "had", "do", "does", "did", "can", "could", "will",
33
+ "would", "should", "may", "might", "must", "let", "let's", "ok", "okay",
34
+ "yes", "no", "now", "go", "ahead", "please", "thanks", "today",
35
+ "use", "make", "get", "see", "look", "want",
36
+ }
37
+
38
+
39
+ def load_observations(session_id: str = None) -> list[dict]:
40
+ """Load observations from the JSONL file, optionally filtered by session_id."""
41
+ if not OBS_FILE.exists():
42
+ return []
43
+ obs = []
44
+ try:
45
+ with open(OBS_FILE, "r", encoding="utf-8") as f:
46
+ for line in f:
47
+ line = line.strip()
48
+ if not line:
49
+ continue
50
+ try:
51
+ o = json.loads(line)
52
+ if session_id and o.get("session_id") != session_id:
53
+ continue
54
+ obs.append(o)
55
+ except json.JSONDecodeError:
56
+ continue
57
+ except OSError:
58
+ return []
59
+ return obs
60
+
61
+
62
+ def first_user_prompt(transcript_path: str) -> str | None:
63
+ """Try to extract the first user prompt from a transcript file (JSONL of turns)."""
64
+ if not transcript_path or not Path(transcript_path).exists():
65
+ return None
66
+ try:
67
+ with open(transcript_path, "r", encoding="utf-8") as f:
68
+ for line in f:
69
+ try:
70
+ turn = json.loads(line)
71
+ except json.JSONDecodeError:
72
+ continue
73
+ role = turn.get("role") or turn.get("type")
74
+ if role == "user":
75
+ content = turn.get("content") or turn.get("text") or ""
76
+ if isinstance(content, list):
77
+ for block in content:
78
+ if isinstance(block, dict) and block.get("type") == "text":
79
+ return block.get("text", "")[:500]
80
+ elif isinstance(content, str):
81
+ return content[:500]
82
+ except OSError:
83
+ return None
84
+ return None
85
+
86
+
87
+ def derive_topic_slug(observations: list[dict], first_prompt: str | None) -> str:
88
+ """Derive a 2-4 word topic slug from observations and first prompt."""
89
+ text_pool = []
90
+
91
+ if first_prompt:
92
+ text_pool.append(first_prompt)
93
+
94
+ for o in observations:
95
+ if o.get("file_path"):
96
+ parts = Path(o["file_path"]).parts
97
+ if len(parts) >= 2:
98
+ text_pool.append(parts[-2])
99
+
100
+ text_blob = " ".join(text_pool).lower()
101
+ words = re.findall(r"[a-z]{3,}", text_blob)
102
+ words = [w for w in words if w not in STOPWORDS]
103
+
104
+ if not words:
105
+ return "session"
106
+
107
+ counter = Counter(words)
108
+ top_words = [w for w, _ in counter.most_common(4)]
109
+ slug = "-".join(top_words[:3]) if top_words else "session"
110
+ return slug[:60]
111
+
112
+
113
+ def session_window(observations: list[dict]) -> tuple[float, float]:
114
+ """Return (start_ts, end_ts) from observation timestamps."""
115
+ if not observations:
116
+ return (time.time(), time.time())
117
+ timestamps = [o.get("ts", 0) for o in observations if o.get("ts")]
118
+ if not timestamps:
119
+ return (time.time(), time.time())
120
+ return (min(timestamps), max(timestamps))
121
+
122
+
123
+ def files_touched(observations: list[dict]) -> list[str]:
124
+ """Return unique file paths touched in this session, in order of first appearance."""
125
+ seen = []
126
+ seen_set = set()
127
+ for o in observations:
128
+ fp = o.get("file_path")
129
+ if fp and fp not in seen_set:
130
+ seen.append(fp)
131
+ seen_set.add(fp)
132
+ return seen
133
+
134
+
135
+ def external_actions(observations: list[dict]) -> list[dict]:
136
+ """Return observations flagged with external_action."""
137
+ return [
138
+ {
139
+ "ts": o.get("ts"),
140
+ "tool": o.get("tool"),
141
+ "action": o.get("external_action"),
142
+ "input": o.get("input", "")[:200],
143
+ }
144
+ for o in observations if o.get("external_action")
145
+ ]
146
+
147
+
148
+ def memory_entries_in_window(start_ts: float, end_ts: float) -> list[dict]:
149
+ """Best-effort fetch of memory entries created during the session window.
150
+
151
+ Relies on the claudia-memory daemon's HTTP endpoint if available.
152
+ Returns empty list if not reachable. The daemon does not currently
153
+ expose a /recent_memories endpoint; this is a placeholder for a
154
+ future enhancement.
155
+ """
156
+ try:
157
+ import urllib.request
158
+ url = f"http://localhost:3848/recent_memories?since={start_ts}&until={end_ts}"
159
+ req = urllib.request.Request(url)
160
+ with urllib.request.urlopen(req, timeout=2) as resp:
161
+ return json.loads(resp.read().decode("utf-8")).get("memories", [])
162
+ except Exception:
163
+ return []
164
+
165
+
166
+ def next_session_number(date_dir: Path) -> int:
167
+ """Return the next sequential session number for the day."""
168
+ if not date_dir.exists():
169
+ return 1
170
+ existing = []
171
+ for f in date_dir.glob("[0-9][0-9]-*.md"):
172
+ match = re.match(r"^(\d{2})-", f.name)
173
+ if match:
174
+ existing.append(int(match.group(1)))
175
+ return (max(existing) + 1) if existing else 1
176
+
177
+
178
+ def render_summary(
179
+ session_id: str,
180
+ date_str: str,
181
+ session_num: int,
182
+ topic_slug: str,
183
+ start_ts: float,
184
+ end_ts: float,
185
+ files: list[str],
186
+ actions: list[dict],
187
+ memories: list[dict],
188
+ first_prompt: str | None,
189
+ transcript_path: str | None,
190
+ ) -> str:
191
+ """Render the session summary markdown."""
192
+ duration_min = max(1, round((end_ts - start_ts) / 60))
193
+ started = datetime.fromtimestamp(start_ts, tz=timezone.utc).astimezone()
194
+ ended = datetime.fromtimestamp(end_ts, tz=timezone.utc).astimezone()
195
+
196
+ lines = [
197
+ f"# Session {session_num:02d} — {topic_slug.replace('-', ' ').title()}",
198
+ "",
199
+ f"**Date:** {date_str}",
200
+ f"**Started:** {started.strftime('%H:%M %Z')}",
201
+ f"**Ended:** {ended.strftime('%H:%M %Z')}",
202
+ f"**Duration:** ~{duration_min} min",
203
+ f"**Session ID:** `{session_id}`",
204
+ "",
205
+ ]
206
+
207
+ if first_prompt:
208
+ lines += [
209
+ "## Opening prompt",
210
+ "",
211
+ "> " + first_prompt.strip().split("\n")[0][:300],
212
+ "",
213
+ ]
214
+
215
+ lines += ["## Files touched", ""]
216
+ if files:
217
+ for f in files:
218
+ lines.append(f"- `{f}`")
219
+ else:
220
+ lines.append("- (none)")
221
+ lines.append("")
222
+
223
+ lines += ["## External actions", ""]
224
+ if actions:
225
+ for a in actions:
226
+ lines.append(f"- **{a.get('action')}** via `{a.get('tool')}` — `{a.get('input')[:120]}`")
227
+ else:
228
+ lines.append("- (none)")
229
+ lines.append("")
230
+
231
+ lines += ["## Memory entries created", ""]
232
+ if memories:
233
+ for m in memories[:30]:
234
+ mid = m.get("id") or m.get("memory_id") or "?"
235
+ content = (m.get("content") or "")[:200]
236
+ lines.append(f"- `mem-{mid}` — {content}")
237
+ else:
238
+ lines.append("- (none captured — memory daemon may not expose recent_memories endpoint)")
239
+ lines.append("")
240
+
241
+ if transcript_path:
242
+ lines += [
243
+ "## Find this again",
244
+ "",
245
+ f"- Transcript: `{transcript_path}`",
246
+ f"- Memory query: `\"{topic_slug.replace('-', ' ')}\"`",
247
+ "",
248
+ ]
249
+
250
+ lines += [
251
+ "---",
252
+ f"*Auto-generated by session-summary.py at {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')}*",
253
+ "",
254
+ ]
255
+
256
+ return "\n".join(lines)
257
+
258
+
259
+ def regenerate_index(date_dir: Path) -> None:
260
+ """Regenerate INDEX.md for a given day's folder."""
261
+ if not date_dir.exists():
262
+ return
263
+
264
+ sessions = []
265
+ for f in sorted(date_dir.glob("[0-9][0-9]-*.md")):
266
+ first_lines = f.read_text(encoding="utf-8").split("\n")[:6]
267
+ title = first_lines[0].lstrip("# ").strip() if first_lines else f.name
268
+ started = ""
269
+ for line in first_lines:
270
+ if line.startswith("**Started:**"):
271
+ started = line.replace("**Started:**", "").strip()
272
+ break
273
+ sessions.append((f.name, title, started))
274
+
275
+ date_str = date_dir.name
276
+ lines = [
277
+ f"# Sessions — {date_str}",
278
+ "",
279
+ f"*{len(sessions)} session(s) captured.*",
280
+ "",
281
+ "| # | Topic | Started | File |",
282
+ "|---|-------|---------|------|",
283
+ ]
284
+ for fname, title, started in sessions:
285
+ match = re.match(r"^(\d{2})-", fname)
286
+ num = match.group(1) if match else "??"
287
+ clean_title = title.split("—", 1)[1].strip() if "—" in title else title
288
+ lines.append(f"| {num} | {clean_title} | {started} | [`{fname}`](./{fname}) |")
289
+ lines += [
290
+ "",
291
+ f"*INDEX regenerated {datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %Z')}*",
292
+ "",
293
+ ]
294
+ (date_dir / "INDEX.md").write_text("\n".join(lines), encoding="utf-8")
295
+
296
+
297
+ def main():
298
+ if len(sys.argv) > 1 and sys.argv[1] == "--rebuild-index":
299
+ date_str = sys.argv[2] if len(sys.argv) > 2 else datetime.now().astimezone().strftime("%Y-%m-%d")
300
+ regenerate_index(SESSIONS_DIR / date_str)
301
+ print(json.dumps({"action": "rebuild-index", "date": date_str}))
302
+ return
303
+
304
+ session_id = ""
305
+ transcript_path = ""
306
+
307
+ if len(sys.argv) > 1:
308
+ session_id = sys.argv[1]
309
+ else:
310
+ session_id = os.environ.get("CLAUDE_SESSION_ID", "")
311
+ # Try stdin (SessionEnd hook contract)
312
+ if not session_id and not sys.stdin.isatty():
313
+ try:
314
+ raw = sys.stdin.read()
315
+ if raw.strip():
316
+ payload = json.loads(raw)
317
+ session_id = payload.get("session_id", "")
318
+ transcript_path = payload.get("transcript_path", "")
319
+ except (json.JSONDecodeError, OSError):
320
+ pass
321
+
322
+ if not session_id:
323
+ print(json.dumps({"error": "no session_id provided"}))
324
+ return
325
+
326
+ observations = load_observations(session_id)
327
+ if not observations:
328
+ print(json.dumps({"warning": "no observations for session", "session_id": session_id}))
329
+ return
330
+
331
+ start_ts, end_ts = session_window(observations)
332
+ date_str = datetime.fromtimestamp(start_ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d")
333
+ date_dir = SESSIONS_DIR / date_str
334
+ date_dir.mkdir(parents=True, exist_ok=True)
335
+
336
+ first_prompt = first_user_prompt(transcript_path) if transcript_path else None
337
+ topic_slug = derive_topic_slug(observations, first_prompt)
338
+
339
+ # Check if a summary for this session already exists; overwrite with latest data
340
+ existing_file = None
341
+ session_num = next_session_number(date_dir)
342
+ for existing in date_dir.glob("[0-9][0-9]-*.md"):
343
+ try:
344
+ content = existing.read_text(encoding="utf-8")
345
+ if f"`{session_id}`" in content:
346
+ existing_file = existing
347
+ match = re.match(r"^(\d{2})-", existing.name)
348
+ if match:
349
+ session_num = int(match.group(1))
350
+ break
351
+ except OSError:
352
+ continue
353
+
354
+ files = files_touched(observations)
355
+ actions = external_actions(observations)
356
+ memories = memory_entries_in_window(start_ts, end_ts)
357
+
358
+ summary = render_summary(
359
+ session_id=session_id,
360
+ date_str=date_str,
361
+ session_num=session_num,
362
+ topic_slug=topic_slug,
363
+ start_ts=start_ts,
364
+ end_ts=end_ts,
365
+ files=files,
366
+ actions=actions,
367
+ memories=memories,
368
+ first_prompt=first_prompt,
369
+ transcript_path=transcript_path,
370
+ )
371
+
372
+ out_file = date_dir / f"{session_num:02d}-{topic_slug}.md"
373
+
374
+ # If existing file had a different topic slug, remove the stale name
375
+ if existing_file and existing_file != out_file:
376
+ try:
377
+ existing_file.unlink()
378
+ except OSError:
379
+ pass
380
+
381
+ out_file.write_text(summary, encoding="utf-8")
382
+ regenerate_index(date_dir)
383
+
384
+ print(json.dumps({
385
+ "ok": True,
386
+ "session_id": session_id,
387
+ "file": str(out_file),
388
+ "files_touched": len(files),
389
+ "external_actions": len(actions),
390
+ "memories_in_window": len(memories),
391
+ "updated": existing_file is not None,
392
+ }))
393
+
394
+
395
+ if __name__ == "__main__":
396
+ try:
397
+ main()
398
+ except Exception as e:
399
+ print(json.dumps({"error": str(e)[:200]}))
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook: detect canonical-fact trigger phrases and destructive
3
+ verbs in user prompts, inject reminder context for the agent.
4
+
5
+ Reads hook payload from stdin (Claude Code hook contract).
6
+ Outputs JSON with additionalContext when triggers fire. Outputs nothing otherwise.
7
+
8
+ Designed to complete in <5ms (regex match + simple JSON output, no network).
9
+
10
+ Two trigger classes:
11
+ 1. Memory-commitment phrases ("lock this in", "remember this", "this is canonical")
12
+ -> Inject reminder for the agent to save the fact to memory immediately,
13
+ per the memory-commitment rule (which governs tool selection).
14
+ 2. Destructive operation patterns (rm -rf, drop table, force push, etc.)
15
+ -> Inject "verify before acting" reminder per Claudia's safety-first principle.
16
+
17
+ Both classes can fire on the same prompt; both messages are concatenated.
18
+ """
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+
24
+ # Trigger phrases that signal the user is asserting a canonical fact.
25
+ # Word boundaries (\b) used to avoid matching inside other words.
26
+ COMMITMENT_TRIGGERS = [
27
+ r"\block this in\b",
28
+ r"\bremember this\b",
29
+ r"\bthis is canonical\b",
30
+ r"\bthis is locked\b",
31
+ r"\bsave this for later\b",
32
+ r"\bimportant to remember\b",
33
+ r"\bfor the record\b",
34
+ r"\bdon'?t forget\b",
35
+ ]
36
+
37
+ # Destructive operation patterns. Narrow matches to limit false positives.
38
+ # Conversation about deletion ("how do I undo a delete?") should NOT fire these.
39
+ DESTRUCTIVE_PATTERNS = [
40
+ r"\brm\s+-rf\b",
41
+ r"\bdrop\s+(table|database|schema)\b",
42
+ r"\bgit\s+push\s+(?:-+f\b|--force\b)",
43
+ r"\bgit\s+reset\s+--hard\b",
44
+ r"\btruncate\s+table\b",
45
+ r"\bDELETE\s+FROM\b", # SQL all-caps signals intent
46
+ ]
47
+
48
+ # Human-readable labels for each destructive pattern. Keep keys in lockstep
49
+ # with DESTRUCTIVE_PATTERNS so the model never sees raw regex syntax.
50
+ PATTERN_LABELS = {
51
+ r"\brm\s+-rf\b": "rm -rf (recursive delete)",
52
+ r"\bdrop\s+(table|database|schema)\b": "DROP TABLE/DATABASE/SCHEMA",
53
+ r"\bgit\s+push\s+(?:-+f\b|--force\b)": "git push --force",
54
+ r"\bgit\s+reset\s+--hard\b": "git reset --hard",
55
+ r"\btruncate\s+table\b": "TRUNCATE TABLE",
56
+ r"\bDELETE\s+FROM\b": "DELETE FROM (SQL)",
57
+ }
58
+
59
+ # Fail-fast at import time if a pattern is missing a label (or vice versa).
60
+ assert set(DESTRUCTIVE_PATTERNS) == set(PATTERN_LABELS), (
61
+ "Every DESTRUCTIVE_PATTERN must have a matching PATTERN_LABELS entry"
62
+ )
63
+
64
+
65
+ def detect(text: str, patterns: list) -> list:
66
+ """Return list of pattern strings that matched in the text."""
67
+ return [p for p in patterns if re.search(p, text, re.IGNORECASE)]
68
+
69
+
70
+ def main():
71
+ try:
72
+ raw = sys.stdin.read()
73
+ if not raw.strip():
74
+ return
75
+ payload = json.loads(raw)
76
+ except (json.JSONDecodeError, OSError):
77
+ return
78
+
79
+ # Claude Code passes the user's prompt under "prompt" (newer) or "text" (older).
80
+ prompt = payload.get("prompt") or payload.get("text") or ""
81
+ if not prompt:
82
+ return
83
+
84
+ commitment_hits = detect(prompt, COMMITMENT_TRIGGERS)
85
+ destructive_hits = detect(prompt, DESTRUCTIVE_PATTERNS)
86
+
87
+ if not commitment_hits and not destructive_hits:
88
+ return
89
+
90
+ sections = []
91
+
92
+ if commitment_hits:
93
+ sections.append(
94
+ "**Canonical-fact trigger detected.** Per the memory-commitment rule, "
95
+ "save the canonical fact to memory immediately. Do not batch to "
96
+ "/meditate. After saving, continue the conversation normally and "
97
+ "briefly surface that the fact has been recorded so the user knows "
98
+ "it is recoverable in future sessions."
99
+ )
100
+
101
+ if destructive_hits:
102
+ # Show up to three matched patterns for context, using human-readable
103
+ # labels so raw regex syntax never reaches the model.
104
+ labels_list = ", ".join(
105
+ f"`{PATTERN_LABELS[p]}`" for p in destructive_hits[:3]
106
+ )
107
+ sections.append(
108
+ f"**Destructive operation pattern detected** ({labels_list}). "
109
+ "Per the safety-first principle, verify with the user before executing: "
110
+ "show what will happen (recipients, content, irreversible effects), ask "
111
+ "for explicit confirmation, then proceed only on a clear yes. Silence or "
112
+ "ambiguity means do not proceed."
113
+ )
114
+
115
+ output = {"additionalContext": "\n\n".join(sections)}
116
+ print(json.dumps(output))
117
+
118
+
119
+ if __name__ == "__main__":
120
+ try:
121
+ main()
122
+ except Exception:
123
+ pass # Never block Claude
@@ -1,11 +1,12 @@
1
1
  {
2
- "version": "1.56.1",
3
- "generated": "2026-04-11T14:57:44.529Z",
2
+ "version": "1.57.0",
3
+ "generated": "2026-05-13T09:56:45.377Z",
4
4
  "algorithm": "sha256",
5
5
  "files": {
6
6
  ".claude/rules/claudia-principles.md": "b1c5e33aeb33857f3485231e5ed34a441c3cc4568b69e3341be770963418a240",
7
7
  ".claude/rules/data-freshness.md": "052b3b8f3f489a54fff065b29e0ffc46bfad6da1c3a42386170034f298599233",
8
8
  ".claude/rules/memory-availability.md": "48309e2683b267c0a17ebf019fb3ee1116150d811b1d93b4ddb52fed89ae1fe3",
9
+ ".claude/rules/memory-commitment.md": "49eee330b56c6ca0b5f1e01550931c4eef3dcb3249e3d0e2380de3e8dbfe31a8",
9
10
  ".claude/rules/shell-compatibility.md": "565977bc04e269b3ce7d8a7963173df4f44bb9634692ea76abb1b64f6a67513e",
10
11
  ".claude/rules/trust-north-star.md": "0188b17c26b791cf597ce975bb75d40f543aa3d6b84b7edb1309b78530b3d43f",
11
12
  ".claude/skills/README.md": "f84c553ff5bc01f7a9a479b7358dbd16a60ee2ba11e01467fe1ed3d91e33e68e",
@@ -0,0 +1,92 @@
1
+ # Memory Commitment
2
+
3
+ This rule is always active. Follow it silently. Do not cite this file by name in conversation.
4
+
5
+ ---
6
+
7
+ ## The principle
8
+
9
+ **Save canonical facts to memory IMMEDIATELY when they emerge. Do not batch to /meditate.**
10
+
11
+ The memory database is what makes information recoverable across sessions. Artifacts on disk (markdown files, GitHub repos, PDFs) are not searchable from future sessions unless I happen to remember the path. Memory entries with proper entity links surface through both semantic search and entity browsing.
12
+
13
+ If a fact lives only in a file, it does not exist for future-me.
14
+
15
+ ---
16
+
17
+ ## Save now when ANY of these happen
18
+
19
+ 1. **User states a canonical fact** that is unlikely to change: hex code, URL, EIN, address, password location, locked decision, version number, identifier, credential location.
20
+
21
+ 2. **User shares substantive source material** (>500 words, a transcript, a document, a brief, a prompt, a strategy doc). File it via `memory_file` BEFORE extracting from it.
22
+
23
+ 3. **User overrides or corrects a stored memory or preference.** The correction is more important than the original. Save it with high importance and reference what it supersedes.
24
+
25
+ 4. **A new project, repo, entity, integration, credential, or tool is created** during the session. Create the entity, then attach facts about it.
26
+
27
+ 5. **User uses a trigger phrase**: "lock this in," "remember this," "this is canonical," "this is locked," "save this for later," "important to remember," "for the record," "don't forget."
28
+
29
+ 6. **A judgment-relevant decision is made** (priorities, escalations, overrides, surfacing rules, delegation preferences). These also feed `context/judgment.yaml` via /meditate, but the fact itself goes into memory immediately.
30
+
31
+ ---
32
+
33
+ ## The test
34
+
35
+ Before deciding "I'll save this later," ask:
36
+
37
+ > **If I came back tomorrow with no transcript, would I need this fact to do good work?**
38
+
39
+ If yes, save it now. The test is meant to be cheap to apply: when in doubt, save.
40
+
41
+ ---
42
+
43
+ ## How to save
44
+
45
+ | Need | Tool | Use when |
46
+ |------|------|----------|
47
+ | One fact | `memory_remember` | Single fact emerges in conversation |
48
+ | Bundled save (entity + facts + relationships) | `memory_batch` | Processing a substantive artifact, transcript, document, or multi-fact moment |
49
+ | Raw source material before extraction | `memory_file` | User shares a document, transcript, email, brief |
50
+ | New relationship between entities | `memory_relate` | Two existing entities connect in a new way |
51
+ | Verify or build on prior memory | `memory_recall` / `memory_about` | Before saving, check if a related memory exists to update instead of duplicate |
52
+
53
+ **Prefer `memory_batch` over multiple `memory_remember` calls.** One round-trip handles entity creation, fact-saves, and relationships together. Faster, cleaner, less likely to be skipped.
54
+
55
+ ---
56
+
57
+ ## What NOT to save
58
+
59
+ Per the data-freshness rule:
60
+
61
+ - Volatile counts, statuses, progress numbers ("13K subscribers," "9 interviews completed," "94% stall rate")
62
+ - Dated state snapshots that can be re-derived from source files
63
+ - Anything that should live in a context file or canonical source instead
64
+ - Information already documented in CLAUDE.md or auto-memory MEMORY.md
65
+
66
+ When you encounter a useful but volatile fact, save a **pointer** to where the canonical source lives, not the value itself. Example: instead of "subscriber count is 13,000," save "subscriber count lives on the live homepage; check there for current."
67
+
68
+ ---
69
+
70
+ ## Substantive-artifact discipline
71
+
72
+ When producing a substantive artifact (brand bible, multi-doc plan, comprehensive analysis, custom skill, deployed integration), end the artifact-production block with a memory commitment pass:
73
+
74
+ 1. List the canonical facts the artifact embodies.
75
+ 2. Call `memory_batch` to save them as one bundle, with proper entity links and source context referencing the artifact location.
76
+ 3. Mark the highest-leverage fact as `critical: true` only when it's a personal-identity-class fact (life motto, ethical lock, security-relevant rule).
77
+
78
+ The artifact lives on disk. The facts must live in memory too.
79
+
80
+ ---
81
+
82
+ ## Why this rule exists
83
+
84
+ A common failure mode: an agent produces a multi-file artifact (brand bible, integration setup, comprehensive plan) over the course of a session. The artifact contains canonical facts (color palettes, credentials, decisions, URLs). End-of-session reflection captures only high-level reflections, not the specific facts. Days later, when those facts are needed again, they exist only on disk and the agent has no way to surface them through memory queries.
85
+
86
+ Result: the same facts get re-elicited, re-decided, re-committed. The memory system was the substrate, but it was treated as the afterthought.
87
+
88
+ This rule prevents that pattern.
89
+
90
+ ---
91
+
92
+ *Memory is the substrate, not the afterthought. Save as you go.*
@@ -39,6 +39,32 @@
39
39
  }
40
40
  ]
41
41
  }
42
+ ],
43
+ "UserPromptSubmit": [
44
+ {
45
+ "matcher": "",
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-capture.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/user-prompt-capture.py\" 2>/dev/null || true",
50
+ "timeout": 3000,
51
+ "statusMessage": ""
52
+ }
53
+ ]
54
+ }
55
+ ],
56
+ "SessionEnd": [
57
+ {
58
+ "matcher": "",
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || python \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.py\" 2>/dev/null || true",
63
+ "timeout": 10000,
64
+ "statusMessage": "Generating daily session summary..."
65
+ }
66
+ ]
67
+ }
42
68
  ]
43
69
  }
44
70
  }
@@ -1,35 +0,0 @@
1
- # Dependencies
2
- node_modules/
3
-
4
- # OS files
5
- .DS_Store
6
- Thumbs.db
7
-
8
- # Editor files
9
- *.swp
10
- *.swo
11
- *~
12
- .idea/
13
- .vscode/
14
-
15
- # Environment files
16
- .env
17
- .env.local
18
- .env.*.local
19
-
20
- # Logs
21
- *.log
22
- npm-debug.log*
23
-
24
- # Sensitive files
25
- credentials.json
26
- *.pem
27
- *.key
28
-
29
- # Build artifacts
30
- dist/
31
- build/
32
-
33
- # Temporary files
34
- *.tmp
35
- *.temp