diff --git a/apps/claude-code-plugin/README.md b/apps/claude-code-plugin/README.md index 8b2cb24f0..c683b3a33 100644 --- a/apps/claude-code-plugin/README.md +++ b/apps/claude-code-plugin/README.md @@ -18,6 +18,12 @@ This directory still contains the plugin itself (`.claude-plugin/`, `hooks/`, claude --plugin-dir /path/to/powermem/apps/claude-code-plugin ``` +Git/marketplace installs and release plugin zips include prebuilt native hook +binaries under `hooks/bin/`. Developers can refresh them with +`make build-claude-hook` from the repository root, or +`bash apps/claude-code-plugin/scripts/package-plugin.sh` before loading a local +source directory directly. + ## Marketplace install Once the PowerMem marketplace entry is available, install the Claude Code plugin diff --git a/apps/claude-code-plugin/SETUP.md b/apps/claude-code-plugin/SETUP.md index 141a31a13..c80007993 100644 --- a/apps/claude-code-plugin/SETUP.md +++ b/apps/claude-code-plugin/SETUP.md @@ -178,8 +178,9 @@ state and either skip, reuse, or refresh it instead of failing or duplicating wo 1. DETECT CONTEXT. The current directory is the PowerMem source tree if a pyproject.toml here has name = "powermem" (or src/powermem/ and apps/claude-code-plugin/ both exist). Tell me which path you will take: - - SOURCE -> build & deploy from this checkout and install the Claude Code - plugin GLOBALLY in HTTP mode (hooks -> REST; needs Go 1.22+). + - SOURCE -> deploy from this checkout and install the Claude Code plugin + GLOBALLY in HTTP mode (hooks -> REST; rebuild hook binaries + only when refreshing them from source changes). - PYPI/MCP -> install PowerMem from PyPI with uv and connect via the powermem-mcp server (the plugin itself is NOT on PyPI). @@ -421,10 +422,12 @@ writing. Never silently patch `.env`.** immediately (do NOT wait for it — model download starts in parallel too): make build-dashboard >> /tmp/powermem-dashboard-build.log 2>&1 & DASHBOARD_BUILD_PID=$! - - Build the hook binaries FIRST — they get copied into Claude's plugin cache at - install time, so they must exist on disk before step "install": - if Go 1.22+ is present: make build-claude-hook - else tell me, and offer to install Go or fall back to the PYPI/MCP path below. + - Confirm the hook binaries are present before install, because they get copied + into Claude's plugin cache at install time: + normal Git/marketplace install: use the committed hooks/bin/ binaries. + if hook source changed and Go 1.22+ is present: make build-claude-hook + if refreshed binaries are needed but Go is absent: offer to install Go or + fall back to the PYPI/MCP path below. - Ensure the plugin's root .mcp.json stays empty ({}) — default HTTP mode. - STAGE the plugin into a stable, Claude-owned location so the marketplace does NOT depend on this checkout — you can move or delete the repo afterwards and @@ -434,8 +437,8 @@ writing. Never silently patch `.env`.** mkdir -p "$DEST" rsync -a --delete "/apps/claude-code-plugin/" "$DEST/" # no rsync? rm -rf "$DEST" && cp -a "/apps/claude-code-plugin/." "$DEST/" - The binaries from `make build-claude-hook` must already be on disk before this - copy. Re-copy on every re-run so the staged dir tracks your latest build. + The committed `hooks/bin/` binaries are already on disk before this copy. + Re-copy on every re-run so the staged dir tracks your latest build. - Register the marketplace from the STAGED dir (it ships .claude-plugin/marketplace.json) — never from the repo: claude plugin marketplace add "$DEST" @@ -607,7 +610,7 @@ writing. Never silently patch `.env`.** (~/.claude/marketplaces/powermem — independent of this repo), the server URL, how memory is wired (HTTP hooks vs MCP tools — recall is auto-injected on UserPromptSubmit, not a - tool the model calls; writes happen on SessionEnd/PostCompact), confirmation that + tool the model calls; writes happen on configured hook events), confirmation that it is enabled globally, and the fact that I just run `claude` (or `claude -p`) with nothing extra. Note: the background server does not survive a reboot — offer to set up a systemd user service for autostart. @@ -682,14 +685,17 @@ make build-claude-hook #### [E005] Storage Backend Initialization **Problem**: 503 errors on API calls despite server health -**Fix**: the Claude Code plugin defaults to embedded OceanBase/seekdb. Stop the -managed server, remove stale seekdb data only if you accept deleting local memories, and +**Fix**: the Claude Code plugin defaults to local SQLite. Stop the managed +server, remove stale SQLite data only if you accept deleting local memories, and restart init: ```bash sh "$CLAUDE_PLUGIN_ROOT/scripts/stop.sh" -rm -rf "$HOME/.powermem/seekdb_data" +rm -f "$HOME/.powermem/powermem.db" "$HOME/.powermem/powermem.db-"* sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" ``` +If you explicitly set `POWERMEM_INIT_DATABASE_PROVIDER=oceanbase`, use the +OceanBase/seekdb troubleshooting path instead and remove `seekdb_data` only when +data loss is acceptable. #### [E006] Model Download Timeout **Problem**: Server hangs or reports "timed out thrown while requesting HEAD" on startup. @@ -858,8 +864,9 @@ python -c "import pyseekdb" 2>&1 # should produce no output ## PRE-CHECK & PREREQUISITES 1. **Verify Python version**: `python3 --version` (must be >= 3.11, see [E011]) -2. **Verify Go version**: `go version` (must be 1.22+) -3. **Verify uv**: `uv --version` (install it with [E012] if missing) +2. **Verify uv**: `uv --version` (install it with [E012] if missing) +3. **Verify hook binaries**: committed `hooks/bin/` binaries should already be present; + Go 1.22+ is only needed when refreshing them from hook source changes. 4. **Check mirror access**: if using an internal mirror, verify it has `pyobvector`, `pyseekdb`, and `onnxruntime`; see [E013] if not. @@ -877,8 +884,9 @@ POWERMEM_PYTHON="$VIRTUAL_ENV/bin/python" # Install everything with ALL required extras uv pip install --python "$POWERMEM_PYTHON" -e '.[server,seekdb]' -# Build and stage Claude hooks -make build-claude-hook +# Git/marketplace installs use committed hook binaries. +# Optional after hook source changes: +# make build-claude-hook # Register marketplace DEST="$HOME/.claude/marketplaces/powermem" diff --git a/apps/claude-code-plugin/cmd/powermem-hook/main.go b/apps/claude-code-plugin/cmd/powermem-hook/main.go index 1b73f4df5..c95a9964f 100644 --- a/apps/claude-code-plugin/cmd/powermem-hook/main.go +++ b/apps/claude-code-plugin/cmd/powermem-hook/main.go @@ -1,10 +1,12 @@ -// powermem-hook: Claude Code hook — stdin JSON (SessionEnd / PostCompact) → background HTTP POST to PowerMem. +// powermem-hook: Claude Code hook stdin JSON -> PowerMem HTTP API. // Cross-platform; zero runtime deps beyond the single binary. package main import ( "bufio" "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -12,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -21,6 +24,9 @@ import ( const defaultPowerMemBaseURL = "http://localhost:8848" const workerPayloadPathEnv = "POWERMEM_WORKER_PAYLOAD_PATH" +const preparedParentScrubReportKey = "_powermem_parent_scrub_report" +const lifecycleMetaMaxChars = 512 +const lifecycleDescriptionMaxChars = 2000 type workerHandoffPayload struct { TranscriptPath string `json:"transcript_path,omitempty"` @@ -39,6 +45,15 @@ func main() { case "worker-compact": workerCompact() return + case "worker-precompact": + workerPreCompact() + return + case "worker-tool-event": + workerToolEvent() + return + case "worker-lifecycle": + workerLifecycleEvent() + return case "worker-file": workerFile() return @@ -76,23 +91,34 @@ func spawnWorker(mode string, envExtra map[string]string) bool { return cmd.Start() == nil } -func spawnPayloadWorker(mode string, payload workerHandoffPayload) { - b, err := json.Marshal(payload) +func writeWorkerPayloadFile(payload any) (string, bool) { + f, err := os.CreateTemp("", "powermem-hook-payload-*.json") if err != nil { - return + return "", false } - f, err := os.CreateTemp("", "powermem-hook-worker-*.json") - if err != nil { - return + name := f.Name() + encErr := json.NewEncoder(f).Encode(payload) + closeErr := f.Close() + if encErr != nil || closeErr != nil { + _ = os.Remove(name) + return "", false } - path := f.Name() - if _, err := f.Write(b); err != nil { - _ = f.Close() - _ = os.Remove(path) + return name, true +} + +func spawnPayloadWorker(mode string, payload workerHandoffPayload) { + path, ok := writeWorkerPayloadFile(payload) + if !ok { return } - if err := f.Close(); err != nil { + if !spawnWorker(mode, map[string]string{workerPayloadPathEnv: path}) { _ = os.Remove(path) + } +} + +func spawnMapPayloadWorker(mode string, payload map[string]any) { + path, ok := writeWorkerPayloadFile(payload) + if !ok { return } if !spawnWorker(mode, map[string]string{workerPayloadPathEnv: path}) { @@ -117,6 +143,23 @@ func readWorkerPayload() (workerHandoffPayload, bool) { return payload, true } +func readMapWorkerPayload() map[string]any { + path := strings.TrimSpace(os.Getenv(workerPayloadPathEnv)) + if path == "" { + return nil + } + b, err := os.ReadFile(path) + _ = os.Remove(path) + if err != nil { + return nil + } + var payload map[string]any + if json.Unmarshal(b, &payload) != nil { + return nil + } + return payload +} + func stdinHook() { raw, err := io.ReadAll(os.Stdin) if err != nil || len(bytes.TrimSpace(raw)) == 0 { @@ -132,6 +175,8 @@ func stdinHook() { cwd, _ := payload["cwd"].(string) switch event { + case "SessionStart": + handleSessionStart(payload) case "UserPromptSubmit": handleUserPromptSubmit(payload) case "SessionEnd": @@ -183,9 +228,105 @@ func stdinHook() { env[parentScrubReportEnv] = encoded } spawnWorker("worker-compact", env) + case "PreCompact": + if !capturePreCompact() { + return + } + tp, _ := payload["transcript_path"].(string) + if tp == "" { + return + } + if st, err := os.Stat(tp); err != nil || st.IsDir() { + return + } + snapshot, err := readTranscriptTail(tp, maxPreCompactChars(), maxPreCompactLines()) + if err != nil || strings.TrimSpace(snapshot.Text) == "" { + return + } + cfg := loadHookPrivacyConfig() + scrubbedText, report := scrubText(snapshot.Text, cfg) + if shouldBlockWrite(cfg, report) || strings.TrimSpace(scrubbedText) == "" { + return + } + payload["_powermem_precompact_text"] = scrubbedText + payload["_powermem_precompact_start_byte"] = snapshot.StartByte + payload["_powermem_precompact_end_byte"] = snapshot.EndByte + payload["_powermem_precompact_max_chars"] = maxPreCompactChars() + payload["_powermem_precompact_max_lines"] = maxPreCompactLines() + handoff := prepareMapPayloadHandoff(payload, report) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-precompact", handoff) + case "PostToolUse": + if !captureToolSuccess() { + return + } + if !toolEventAllowed(toolNameFromPayload(payload)) { + return + } + handoff := prepareToolEventHandoff(payload) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-tool-event", handoff) + case "PostToolUseFailure": + if !captureToolFailures() { + return + } + if isInterruptPayload(payload) && !captureInterrupts() { + return + } + handoff := prepareToolFailureHandoff(payload) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-tool-event", handoff) + case "Stop": + if !captureStopRollup() || boolField(payload, "stop_hook_active", false) { + return + } + handoff := prepareStopRollupHandoff(payload) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-tool-event", handoff) + case "SubagentStart", "SubagentStop": + if !captureSubagents() { + return + } + handoff := prepareMapPayloadHandoff(payload, scrubReport{}) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-lifecycle", handoff) + case "TaskCreated", "TaskCompleted": + if !captureTasks() { + return + } + handoff := prepareMapPayloadHandoff(payload, scrubReport{}) + if handoff == nil { + return + } + spawnMapPayloadWorker("worker-lifecycle", handoff) } } +func envInt(name string, def int, min int, max int) int { + s := strings.TrimSpace(os.Getenv(name)) + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil || n < min { + return def + } + if max > 0 && n > max { + return max + } + return n +} + func maxHookChars() int { s := strings.TrimSpace(os.Getenv("POWERMEM_HOOK_MAX_CHARS")) if s == "" { @@ -216,6 +357,84 @@ func inferCompact() bool { } } +func capturePreCompact() bool { + return envBool("POWERMEM_CAPTURE_PRECOMPACT", true) +} + +func inferPreCompact() bool { + return envBool("POWERMEM_INFER_PRECOMPACT", false) +} + +func maxPreCompactChars() int { + return envInt("POWERMEM_PRECOMPACT_MAX_CHARS", 120000, 500, 900000) +} + +func maxPreCompactLines() int { + return envInt("POWERMEM_PRECOMPACT_TAIL_LINES", 200, 1, 10000) +} + +func captureToolSuccess() bool { + return envBool("POWERMEM_CAPTURE_TOOL_SUCCESS", true) +} + +func inferToolEvents() bool { + return envBool("POWERMEM_INFER_TOOL_EVENTS", false) +} + +func maxToolEventChars() int { + return envInt("POWERMEM_TOOL_EVENT_MAX_CHARS", 6000, 500, 120000) +} + +func captureToolFailures() bool { + return envBool("POWERMEM_CAPTURE_TOOL_FAILURES", true) +} + +func captureInterrupts() bool { + return envBool("POWERMEM_CAPTURE_INTERRUPTS", false) +} + +func maxToolFailureChars() int { + return envInt("POWERMEM_TOOL_FAILURE_MAX_CHARS", 6000, 500, 120000) +} + +func inferToolFailures() bool { + return envBool("POWERMEM_INFER_TOOL_FAILURES", false) +} + +func captureStopRollup() bool { + return envBool("POWERMEM_CAPTURE_STOP_ROLLUP", false) +} + +func maxStopChars() int { + return envInt("POWERMEM_STOP_MAX_CHARS", 3000, 500, 120000) +} + +func inferStop() bool { + return envBool("POWERMEM_INFER_STOP", false) +} + +func captureSubagents() bool { + return envBool("POWERMEM_CAPTURE_SUBAGENTS", true) +} + +func captureTasks() bool { + return envBool("POWERMEM_CAPTURE_TASKS", true) +} + +func inferLifecycleEvent(eventName string) bool { + if envBool("POWERMEM_INFER_LIFECYCLE_EVENTS", false) { + return true + } + switch eventName { + case "SubagentStop": + return envBool("POWERMEM_INFER_SUBAGENT_STOP", false) + case "TaskCompleted": + return envBool("POWERMEM_INFER_TASK_COMPLETED", false) + default: + return false + } +} + func inferFile() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_INFER_FILE"))) { case "1", "true", "yes": @@ -225,6 +444,43 @@ func inferFile() bool { } } +func splitCSVSet(raw string) map[string]bool { + out := map[string]bool{} + for _, part := range strings.Split(raw, ",") { + item := strings.TrimSpace(part) + if item != "" { + out[item] = true + } + } + return out +} + +func defaultToolIncludeSet() map[string]bool { + return map[string]bool{ + "Write": true, + "Edit": true, + "MultiEdit": true, + "Bash": true, + "Agent": true, + "ExitPlanMode": true, + } +} + +func toolEventAllowed(toolName string) bool { + if toolName == "" { + toolName = "unknown" + } + exclude := splitCSVSet(os.Getenv("POWERMEM_TOOL_SUCCESS_EXCLUDE")) + if exclude["*"] || exclude[toolName] { + return false + } + include := splitCSVSet(os.Getenv("POWERMEM_TOOL_SUCCESS_INCLUDE")) + if len(include) == 0 { + include = defaultToolIncludeSet() + } + return include["*"] || include[toolName] +} + func promptSearchEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_PROMPT_SEARCH"))) { case "0", "false", "no", "off": @@ -271,6 +527,70 @@ func promptSearchMaxContextChars() int { return n } +func sessionStartSearchEnabled() bool { + return envBool("POWERMEM_SESSION_START_SEARCH", true) +} + +func sessionStartLimit() int { + return envInt("POWERMEM_SESSION_START_LIMIT", 6, 1, 30) +} + +func sessionStartMaxContextChars() int { + return envInt("POWERMEM_SESSION_START_MAX_CHARS", 16000, 500, 120000) +} + +func buildSessionStartQuery(payload map[string]any) string { + parts := []string{} + for _, key := range []string{"session_title", "source", "agent_type", "cwd"} { + if value := stringField(payload, key); value != "" { + parts = append(parts, key+": "+value) + } + } + return strings.Join(parts, "\n") +} + +func handleSessionStart(payload map[string]any) { + if !sessionStartSearchEnabled() { + return + } + query := strings.TrimSpace(buildSessionStartQuery(payload)) + if query == "" { + return + } + cfg := loadHookPrivacyConfig() + var ok bool + query, ok = scrubPromptForSearch(query, cfg) + if !ok { + return + } + ctx, err := searchMemories(query, sessionStartLimit()) + if err != nil || strings.TrimSpace(ctx) == "" { + return + } + if cfg.Enabled { + var report scrubReport + ctx, report = scrubText(ctx, cfg) + if shouldBlockWrite(cfg, report) || strings.TrimSpace(ctx) == "" { + return + } + } + maxC := sessionStartMaxContextChars() + if len(ctx) > maxC { + ctx = ctx[:maxC] + "\n…" + } + out := map[string]any{ + "hookSpecificOutput": map[string]any{ + "hookEventName": "SessionStart", + "additionalContext": ctx, + }, + } + b, err := json.Marshal(out) + if err != nil { + return + } + _, _ = os.Stdout.Write(b) +} + func handleUserPromptSubmit(payload map[string]any) { if !promptSearchEnabled() { return @@ -315,10 +635,14 @@ func handleUserPromptSubmit(payload map[string]any) { } func searchMemoriesForPrompt(query string) (string, error) { + return searchMemories(query, promptSearchLimit()) +} + +func searchMemories(query string, limit int) (string, error) { base := baseURL() body := map[string]any{ "query": query, - "limit": promptSearchLimit(), + "limit": limit, } userID := searchBodyUserID() agentID := searchBodyAgentID() @@ -382,7 +706,7 @@ func formatSearchResults(respBody []byte) (string, error) { return "", nil } var b strings.Builder - b.WriteString("## PowerMem (retrieved for this prompt)\n\nRelevant long-term memories from PowerMem; use if they help answer the user. Ignore if unrelated.\n\n") + b.WriteString("## PowerMem (retrieved for this context)\n\nRelevant long-term memories from PowerMem; use if they help with the current Claude Code context. Ignore if unrelated.\n\n") for i, el := range results { m, ok := el.(map[string]any) if !ok { @@ -564,6 +888,1006 @@ func workerCompact() { } } +type transcriptTailSnapshot struct { + Text string + StartByte int64 + EndByte int64 +} + +func transcriptFingerprint(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + sum := sha256.Sum256([]byte(abs)) + return hex.EncodeToString(sum[:])[:16] +} + +func readTranscriptTail(path string, maxChars int, maxLines int) (transcriptTailSnapshot, error) { + st, err := os.Stat(path) + if err != nil { + return transcriptTailSnapshot{}, err + } + size := st.Size() + if size <= 0 { + return transcriptTailSnapshot{}, nil + } + window := int64(maxChars*4 + 64*1024) + if window < int64(maxChars) { + window = int64(maxChars) + } + start := int64(0) + if size > window { + start = size - window + } + f, err := os.Open(path) + if err != nil { + return transcriptTailSnapshot{}, err + } + defer f.Close() + if _, err := f.Seek(start, io.SeekStart); err != nil { + return transcriptTailSnapshot{}, err + } + data, err := io.ReadAll(f) + if err != nil { + return transcriptTailSnapshot{}, err + } + if start > 0 { + if idx := bytes.IndexByte(data, '\n'); idx >= 0 && idx+1 < len(data) { + start += int64(idx + 1) + data = data[idx+1:] + } + } + if maxLines > 0 { + lineStarts := []int{0} + for i, b := range data { + if b == '\n' && i+1 < len(data) { + lineStarts = append(lineStarts, i+1) + } + } + if len(lineStarts) > maxLines { + lineStart := lineStarts[len(lineStarts)-maxLines] + start += int64(lineStart) + data = data[lineStart:] + } + } + if len(data) > maxChars { + trim := len(data) - maxChars + if idx := bytes.IndexByte(data[trim:], '\n'); idx >= 0 && trim+idx+1 < len(data) { + trim += idx + 1 + } + start += int64(trim) + data = data[trim:] + } + text := strings.TrimSpace(string(data)) + if text == "" { + return transcriptTailSnapshot{}, nil + } + return transcriptTailSnapshot{Text: text, StartByte: start, EndByte: size}, nil +} + +func workerPreCompact() { + payload := readMapWorkerPayload() + if payload == nil { + return + } + parentReport := decodeScrubReport(stringField(payload, preparedParentScrubReportKey)) + delete(payload, preparedParentScrubReportKey) + path := stringField(payload, "transcript_path") + if path == "" { + return + } + text := stringField(payload, "_powermem_precompact_text") + if strings.TrimSpace(text) == "" { + return + } + startByte := int64Field(payload, "_powermem_precompact_start_byte") + endByte := int64Field(payload, "_powermem_precompact_end_byte") + maxC := intField(payload, "_powermem_precompact_max_chars", maxPreCompactChars()) + maxL := intField(payload, "_powermem_precompact_max_lines", maxPreCompactLines()) + sid := stringField(payload, "session_id") + cwd := stringField(payload, "cwd") + trigger := stringField(payload, "trigger") + runID := sid + content := fmt.Sprintf("Claude Code pre-compact context snapshot (session_id=%s, cwd=%s, trigger=%s)\n\n%s", sid, cwd, trigger, text) + meta := map[string]any{ + "source": "claude-code-hook", + "kind": "pre-compact-snapshot", + "event_name": "PreCompact", + "session_id": sid, + "cwd": cwd, + "compact_trigger": trigger, + "transcript_path": path, + "transcript_path_fingerprint": transcriptFingerprint(path), + "start_byte_offset": startByte, + "end_byte_offset": endByte, + "max_chars": maxC, + "max_lines": maxL, + "schema_version": 1, + "scrub_mode": hookScrubEnabled(), + "infer_mode": inferPreCompact(), + } + if custom := stringField(payload, "custom_instructions"); custom != "" { + meta["custom_instructions"] = custom + } + if err := postMemoryWithScrubReport(content, meta, &runID, inferPreCompact(), parentReport); err != nil { + os.Exit(1) + } +} + +func prepareToolEventHandoff(payload map[string]any) map[string]any { + parentReport, ok := scrubPayloadReportForHandoff(payload) + if !ok { + return nil + } + content, meta, runID, infer, ok := buildToolEventPost(payload) + if !ok { + return nil + } + return prepareMemoryPostHandoffWithReport(content, meta, runID, infer, parentReport) +} + +func prepareMemoryPostHandoff(content string, meta map[string]any, runID string, infer bool) map[string]any { + return prepareMemoryPostHandoffWithReport(content, meta, runID, infer, scrubReport{}) +} + +func prepareMemoryPostHandoffWithReport(content string, meta map[string]any, runID string, infer bool, parentReport scrubReport) map[string]any { + if strings.TrimSpace(content) == "" || meta == nil { + return nil + } + if shouldBlockWrite(loadHookPrivacyConfig(), parentReport) { + return nil + } + scrubbedContent, contentReport, ok := scrubTextForHandoffWithReport(content) + if !ok || strings.TrimSpace(scrubbedContent) == "" { + return nil + } + parentReport.merge(contentReport) + scrubbedRunID, runIDReport, ok := scrubTextForHandoffWithReport(runID) + if !ok { + return nil + } + parentReport.merge(runIDReport) + scrubbedMeta, metaReport, ok := scrubValueForHandoffWithReport(meta) + if !ok { + return nil + } + parentReport.merge(metaReport) + handoff := map[string]any{ + "_powermem_content": scrubbedContent, + "_powermem_metadata": scrubbedMeta, + "_powermem_run_id": scrubbedRunID, + "_powermem_infer": infer, + } + if encoded := encodeScrubReport(parentReport); encoded != "" { + handoff[preparedParentScrubReportKey] = encoded + } + return handoff +} + +func prepareToolFailureHandoff(payload map[string]any) map[string]any { + parentReport, ok := scrubPayloadReportForHandoff(payload) + if !ok { + return nil + } + content, meta, runID, infer, ok := buildToolFailurePost(payload) + if !ok { + return nil + } + return prepareMemoryPostHandoffWithReport(content, meta, runID, infer, parentReport) +} + +func prepareStopRollupHandoff(payload map[string]any) map[string]any { + parentReport, ok := scrubPayloadReportForHandoff(payload) + if !ok { + return nil + } + content, meta, runID, infer, ok := buildStopRollupPost(payload) + if !ok { + return nil + } + return prepareMemoryPostHandoffWithReport(content, meta, runID, infer, parentReport) +} + +func preparedToolEventPost(payload map[string]any) (string, map[string]any, string, bool, bool) { + content := stringField(payload, "_powermem_content") + meta := nestedMap(payload["_powermem_metadata"]) + if content == "" || meta == nil { + return "", nil, "", false, false + } + runID := stringField(payload, "_powermem_run_id") + infer := boolField(payload, "_powermem_infer", inferToolEvents()) + return content, meta, runID, infer, true +} + +func isInterruptPayload(payload map[string]any) bool { + if payload == nil { + return false + } + if boolField(payload, "is_interrupt", false) || boolField(payload, "interrupted", false) { + return true + } + raw := strings.ToLower(firstString(payload, "error_type", "status", "reason")) + return strings.Contains(raw, "interrupt") +} + +func boolField(m map[string]any, key string, def bool) bool { + if m == nil { + return def + } + switch x := m[key].(type) { + case bool: + return x + case string: + return envBoolValue(x, def) + default: + return def + } +} + +func envBool(name string, def bool) bool { + return envBoolValue(os.Getenv(name), def) +} + +func envBoolValue(raw string, def bool) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return def + } +} + +func hookScrubEnabled() bool { + return loadHookPrivacyConfig().Enabled +} + +func scrubTextForHandoff(s string) (string, bool) { + out, _, ok := scrubTextForHandoffWithReport(s) + return out, ok +} + +func scrubTextForHandoffWithReport(s string) (string, scrubReport, bool) { + cfg := loadHookPrivacyConfig() + if !cfg.Enabled { + return s, scrubReport{}, true + } + out, report := scrubText(s, cfg) + if shouldBlockWrite(cfg, report) { + return "", report, false + } + return out, report, true +} + +func scrubValueForHandoff(v any) any { + out, _, ok := scrubValueForHandoffWithReport(v) + if !ok { + return nil + } + return out +} + +func scrubValueForHandoffWithReport(v any) (any, scrubReport, bool) { + cfg := loadHookPrivacyConfig() + if !cfg.Enabled { + return v, scrubReport{}, true + } + scrubbed, report := scrubMetadataValue("payload", v, cfg) + if shouldBlockWrite(cfg, report) { + return nil, report, false + } + return scrubbed, report, true +} + +func scrubPayloadReportForHandoff(v any) (scrubReport, bool) { + cfg := loadHookPrivacyConfig() + if !cfg.Enabled { + return scrubReport{}, true + } + _, report := scrubMetadataValue("payload", v, cfg) + if shouldBlockWrite(cfg, report) { + return report, false + } + return report, true +} + +func prepareMapPayloadHandoff(payload map[string]any, parentReport scrubReport) map[string]any { + if payload == nil { + return nil + } + if shouldBlockWrite(loadHookPrivacyConfig(), parentReport) { + return nil + } + scrubbed, report, ok := scrubValueForHandoffWithReport(payload) + if !ok { + return nil + } + parentReport.merge(report) + handoff, ok := scrubbed.(map[string]any) + if !ok { + return nil + } + if encoded := encodeScrubReport(parentReport); encoded != "" { + handoff[preparedParentScrubReportKey] = encoded + } + return handoff +} + +func buildToolEventPost(payload map[string]any) (string, map[string]any, string, bool, bool) { + if payload == nil { + return "", nil, "", false, false + } + toolName := toolNameFromPayload(payload) + if toolName == "" { + toolName = "unknown" + } + if !toolEventAllowed(toolName) { + return "", nil, "", false, false + } + sid := stringField(payload, "session_id") + cwd := stringField(payload, "cwd") + toolUseID := firstString(payload, "tool_use_id", "toolUseID", "toolUseId", "id") + input := firstAny(payload, "tool_input", "input", "toolInput") + response := firstAny(payload, "tool_response", "response", "toolResponse", "result") + maxC := maxToolEventChars() + inputSummary := summarizeToolInput(toolName, input, maxC/2) + responseSummary := summarizeToolResponse(toolName, response, maxC/2) + paths := extractPaths(input, 20) + eventID := "" + if sid != "" && toolUseID != "" { + eventID = "claude-code:" + sid + ":" + toolUseID + } + status := firstString(payload, "status") + if status == "" { + status = "success" + } + content := fmt.Sprintf("Claude Code tool event (tool=%s, status=%s, session_id=%s, cwd=%s)\n\nInput summary:\n%s\n\nResponse summary:\n%s", toolName, status, sid, cwd, inputSummary, responseSummary) + if len(content) > maxC { + content = content[:maxC] + "\n..." + } + runID := sid + meta := map[string]any{ + "source": "claude-code-hook", + "kind": "post-tool-use", + "event_name": "PostToolUse", + "event_id": eventID, + "session_id": sid, + "cwd": cwd, + "tool_name": toolName, + "tool_use_id": toolUseID, + "status": status, + "success": true, + "input_summary": inputSummary, + "response_summary": responseSummary, + "affected_paths": paths, + "schema_version": 1, + "scrub_mode": hookScrubEnabled(), + "infer_mode": inferToolEvents(), + } + if toolName == "Agent" { + if responseMap := nestedMap(response); responseMap != nil { + copyStringMeta(meta, responseMap, "agent_id", "agentId", "agent_id", "subagent_id", "subagentId") + copyStringMeta(meta, responseMap, "agent_type", "agentType", "agent_type", "subagent_type", "subagentType") + copyStringMeta(meta, responseMap, "agent_status", "status") + if usage := firstAny(responseMap, "token_usage", "tokenUsage", "usage"); usage != nil { + meta["token_usage"] = scrubValueForHandoff(usage) + } + } + } + if commandClass := classifyCommand(commandFromToolInput(input)); commandClass != "" { + meta["command_class"] = commandClass + } + if exitCode, ok := numericField(response, "exit_code", "exitCode", "code"); ok { + meta["exit_code"] = exitCode + } + if duration, ok := numericField(payload, "duration_ms", "durationMs", "duration"); ok { + meta["duration_ms"] = duration + } + return content, meta, runID, inferToolEvents(), true +} + +func buildToolFailurePost(payload map[string]any) (string, map[string]any, string, bool, bool) { + if payload == nil { + return "", nil, "", false, false + } + toolName := toolNameFromPayload(payload) + if toolName == "" { + toolName = "unknown" + } + sid := stringField(payload, "session_id") + cwd := stringField(payload, "cwd") + toolUseID := firstString(payload, "tool_use_id", "toolUseID", "toolUseId", "id") + input := firstAny(payload, "tool_input", "input", "toolInput") + response := firstAny(payload, "tool_response", "response", "toolResponse", "result") + errorType := firstString(payload, "error_type", "errorType", "status", "reason") + errorMessage := firstString(payload, "error_message", "errorMessage", "message", "error") + if errorMessage == "" { + errorMessage = textField(nestedMap(response), "stderr") + } + if errorMessage == "" { + errorMessage = textFromAny(response) + } + if toolName == "unknown" && sid == "" && toolUseID == "" && strings.TrimSpace(errorMessage) == "" { + return "", nil, "", false, false + } + maxC := maxToolFailureChars() + inputSummary := summarizeToolInput(toolName, input, maxC/2) + errorSummary := truncateText(errorMessage, maxC/2) + if errorSummary == "" { + errorSummary = "shape=" + valueShape(response) + } + paths := extractPaths(input, 20) + status := "failure" + content := fmt.Sprintf("Claude Code tool failure (tool=%s, status=%s, session_id=%s, cwd=%s)\n\nInput summary:\n%s\n\nError summary:\n%s", toolName, status, sid, cwd, inputSummary, errorSummary) + if len(content) > maxC { + content = content[:maxC] + "\n..." + } + runID := sid + meta := map[string]any{ + "source": "claude-code-hook", + "kind": "post-tool-use-failure", + "event_name": "PostToolUseFailure", + "session_id": sid, + "cwd": cwd, + "tool_name": toolName, + "tool_use_id": toolUseID, + "status": status, + "success": false, + "is_interrupt": isInterruptPayload(payload), + "error_type": errorType, + "error_message": errorSummary, + "input_summary": inputSummary, + "affected_paths": paths, + "schema_version": 1, + "scrub_mode": hookScrubEnabled(), + "infer_mode": inferToolFailures(), + } + if commandClass := classifyCommand(commandFromToolInput(input)); commandClass != "" { + meta["command_class"] = commandClass + } + if exitCode, ok := numericField(response, "exit_code", "exitCode", "code"); ok { + meta["exit_code"] = exitCode + } + if duration, ok := numericField(payload, "duration_ms", "durationMs", "duration"); ok { + meta["duration_ms"] = duration + } + return content, meta, runID, inferToolFailures(), true +} + +func buildStopRollupPost(payload map[string]any) (string, map[string]any, string, bool, bool) { + if payload == nil { + return "", nil, "", false, false + } + sid := stringField(payload, "session_id") + cwd := stringField(payload, "cwd") + finalMessage := firstString(payload, "last_assistant_message", "assistant_message", "message", "summary") + if finalMessage == "" { + finalMessage = textField(payload, "transcript_tail") + } + finalMessage = truncateText(finalMessage, maxStopChars()) + if strings.TrimSpace(finalMessage) == "" { + return "", nil, "", false, false + } + changed := firstAny(payload, "changed_files", "edited_files", "affected_paths") + background := firstAny(payload, "background_tasks", "session_crons") + runID := sid + content := fmt.Sprintf("Claude Code stop rollup (session_id=%s, cwd=%s)\n\nFinal assistant message preview:\n%s", sid, cwd, finalMessage) + meta := map[string]any{ + "source": "claude-code-hook", + "kind": "stop-rollup", + "event_name": "Stop", + "session_id": sid, + "cwd": cwd, + "success": true, + "stop_hook_active": boolField(payload, "stop_hook_active", false), + "schema_version": 1, + "scrub_mode": hookScrubEnabled(), + "infer_mode": inferStop(), + } + if changed != nil { + meta["changed_files"] = scrubValueForHandoff(changed) + } + if background != nil { + meta["background_tasks"] = scrubValueForHandoff(background) + } + return content, meta, runID, inferStop(), true +} + +func workerToolEvent() { + payload := readMapWorkerPayload() + if payload == nil { + return + } + parentReport := decodeScrubReport(stringField(payload, preparedParentScrubReportKey)) + content, meta, runID, infer, ok := preparedToolEventPost(payload) + if !ok { + content, meta, runID, infer, ok = buildToolEventPost(payload) + } + if !ok { + return + } + if err := postMemoryWithScrubReport(content, meta, &runID, infer, parentReport); err != nil { + os.Exit(1) + } +} + +func workerLifecycleEvent() { + payload := readMapWorkerPayload() + if payload == nil { + return + } + parentReport := decodeScrubReport(stringField(payload, preparedParentScrubReportKey)) + delete(payload, preparedParentScrubReportKey) + content, meta, runID, infer, parentReport, ok := buildLifecycleEventPost(payload, parentReport) + if !ok { + return + } + if err := postMemoryWithScrubReport(content, meta, &runID, infer, parentReport); err != nil { + os.Exit(1) + } +} + +func buildLifecycleEventPost(payload map[string]any, parentReport scrubReport) (string, map[string]any, string, bool, scrubReport, bool) { + eventName := stringField(payload, "hook_event_name") + if eventName == "" { + return "", nil, "", false, parentReport, false + } + sid := stringField(payload, "session_id") + cwd := stringField(payload, "cwd") + runID := sid + kind := eventKind(eventName) + content := lifecycleContent(eventName, sid, cwd, payload) + infer := inferLifecycleEvent(eventName) + meta := map[string]any{ + "source": "claude-code-hook", + "kind": kind, + "event_name": eventName, + "session_id": sid, + "cwd": cwd, + "schema_version": 1, + "scrub_mode": hookScrubEnabled(), + "infer_mode": infer, + } + copyBoundedStringMeta(meta, payload, "agent_id", lifecycleMetaMaxChars, "agent_id", "agentId", "subagent_id", "subagentId") + copyBoundedStringMeta(meta, payload, "agent_type", lifecycleMetaMaxChars, "agent_type", "agentType", "subagent_type", "subagentType") + copyBoundedStringMeta(meta, payload, "task_id", lifecycleMetaMaxChars, "task_id", "taskId") + copyBoundedStringMeta(meta, payload, "task_subject", lifecycleMetaMaxChars, "task_subject", "taskSubject") + copyBoundedStringMeta(meta, payload, "task_description", lifecycleDescriptionMaxChars, "task_description", "taskDescription") + copyBoundedStringMeta(meta, payload, "teammate_name", lifecycleMetaMaxChars, "teammate_name", "teammateName") + copyBoundedStringMeta(meta, payload, "team_name", lifecycleMetaMaxChars, "team_name", "teamName") + copyBoundedStringMeta(meta, payload, "tool_use_id", lifecycleMetaMaxChars, "tool_use_id", "toolUseID", "toolUseId") + copyBoundedStringMeta(meta, payload, "status", lifecycleMetaMaxChars, "status") + copyBoundedStringMeta(meta, payload, "transcript_path", lifecycleMetaMaxChars, "transcript_path", "transcriptPath") + copyBoundedStringMeta(meta, payload, "agent_transcript_path", lifecycleMetaMaxChars, "agent_transcript_path", "agentTranscriptPath") + if usage := lifecycleTokenUsage(firstAny(payload, "token_usage", "tokenUsage", "usage")); len(usage) > 0 { + meta["token_usage"] = usage + } + return content, meta, runID, infer, parentReport, true +} + +func stringFromAny(v any) string { + switch x := v.(type) { + case string: + return x + case fmt.Stringer: + return x.String() + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + return strconv.FormatBool(x) + default: + return "" + } +} + +func stringField(m map[string]any, key string) string { + if m == nil { + return "" + } + return strings.TrimSpace(stringFromAny(m[key])) +} + +func firstString(m map[string]any, keys ...string) string { + for _, key := range keys { + if s := stringField(m, key); s != "" { + return s + } + } + return "" +} + +func firstAny(m map[string]any, keys ...string) any { + for _, key := range keys { + if v, ok := m[key]; ok { + return v + } + } + return nil +} + +func int64Field(m map[string]any, key string) int64 { + if m == nil { + return 0 + } + n, _ := int64FromAny(m[key]) + return n +} + +func intField(m map[string]any, key string, def int) int { + if m == nil { + return def + } + n, ok := int64FromAny(m[key]) + if !ok { + return def + } + return int(n) +} + +func int64FromAny(v any) (int64, bool) { + switch x := v.(type) { + case float64: + return int64(x), true + case int: + return int64(x), true + case int64: + return x, true + case json.Number: + n, err := x.Int64() + if err == nil { + return n, true + } + f, err := strconv.ParseFloat(x.String(), 64) + if err != nil { + return 0, false + } + return int64(f), true + case string: + n, err := strconv.ParseInt(strings.TrimSpace(x), 10, 64) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +func nestedMap(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +func toolNameFromPayload(payload map[string]any) string { + if name := firstString(payload, "tool_name", "toolName", "name"); name != "" { + return name + } + for _, key := range []string{"tool", "tool_use", "toolUse"} { + if m := nestedMap(payload[key]); m != nil { + if name := firstString(m, "name", "tool_name", "toolName"); name != "" { + return name + } + } + } + return "" +} + +func valueShape(v any) string { + switch x := v.(type) { + case nil: + return "null" + case string: + return "string" + case float64: + return "number" + case bool: + return "bool" + case []any: + return fmt.Sprintf("array[%d]", len(x)) + case map[string]any: + keys := make([]string, 0, len(x)) + for key := range x { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) > 12 { + keys = append(keys[:12], "...") + } + return "object{" + strings.Join(keys, ",") + "}" + default: + return fmt.Sprintf("%T", v) + } +} + +func truncateText(s string, max int) string { + scrubbed, ok := scrubTextForHandoff(s) + if !ok { + return "" + } + s = strings.TrimSpace(scrubbed) + if max > 0 && len(s) > max { + return s[:max] + "\n..." + } + return s +} + +func textFromAny(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case []any: + return flattenContent(x) + case map[string]any: + if content, ok := x["content"]; ok { + return flattenContent(content) + } + for _, key := range []string{"text", "summary", "result", "message", "last_assistant_message", "prompt"} { + if s := stringField(x, key); s != "" { + return s + } + } + b, err := json.Marshal(x) + if err != nil { + return fmt.Sprint(x) + } + return string(b) + default: + return stringFromAny(v) + } +} + +func textField(m map[string]any, key string) string { + if m == nil { + return "" + } + return strings.TrimSpace(textFromAny(m[key])) +} + +func summarizeToolInput(toolName string, input any, maxChars int) string { + m := nestedMap(input) + switch toolName { + case "Write", "Edit", "MultiEdit": + paths := extractPaths(input, 10) + parts := []string{"operation=" + toolName} + if len(paths) > 0 { + parts = append(parts, "paths="+strings.Join(paths, ", ")) + } + if edits, ok := m["edits"].([]any); ok { + parts = append(parts, fmt.Sprintf("edit_count=%d", len(edits))) + } + if content := stringField(m, "content"); content != "" { + parts = append(parts, fmt.Sprintf("content_chars=%d", len(content))) + } + return strings.Join(parts, "; ") + case "Bash": + cmd := commandFromToolInput(input) + parts := []string{"command_class=" + classifyCommand(cmd)} + if cmd != "" { + parts = append(parts, "command="+truncateText(cmd, maxChars)) + } + return strings.Join(parts, "; ") + case "Agent": + parts := []string{} + for _, key := range []string{"agent_type", "agentType", "description", "task", "prompt"} { + if s := stringField(m, key); s != "" { + parts = append(parts, key+"="+truncateText(s, maxChars/2)) + } + } + if len(parts) == 0 { + return "shape=" + valueShape(input) + } + return strings.Join(parts, "; ") + case "ExitPlanMode": + for _, key := range []string{"plan", "content", "text"} { + if s := stringField(m, key); s != "" { + return "plan=" + truncateText(s, maxChars) + } + } + } + return "shape=" + valueShape(input) +} + +func summarizeToolResponse(toolName string, response any, maxChars int) string { + m := nestedMap(response) + if m == nil { + if s := stringFromAny(response); s != "" { + return truncateText(s, maxChars) + } + return "shape=" + valueShape(response) + } + parts := []string{"shape=" + valueShape(response)} + for _, key := range []string{"status", "exit_code", "exitCode"} { + if s := stringField(m, key); s != "" { + parts = append(parts, key+"="+s) + } + } + switch toolName { + case "Bash": + for _, key := range []string{"stdout", "stderr", "output"} { + if s := stringField(m, key); s != "" { + parts = append(parts, key+"="+truncateText(s, maxChars/2)) + } + } + case "Agent": + for _, key := range []string{"agentId", "agent_id", "summary", "result", "last_assistant_message", "message", "content"} { + if s := textField(m, key); s != "" { + parts = append(parts, key+"="+truncateText(s, maxChars/2)) + } + } + default: + for _, key := range []string{"summary", "message"} { + if s := stringField(m, key); s != "" { + parts = append(parts, key+"="+truncateText(s, maxChars/2)) + } + } + } + return strings.Join(parts, "; ") +} + +func commandFromToolInput(input any) string { + m := nestedMap(input) + if m == nil { + return "" + } + return firstString(m, "command", "cmd") +} + +func classifyCommand(cmd string) string { + fields := strings.Fields(strings.TrimSpace(cmd)) + if len(fields) == 0 { + return "" + } + first := filepath.Base(fields[0]) + switch first { + case "go", "pytest", "python", "python3", "npm", "pnpm", "yarn", "make": + for _, f := range fields[1:] { + if strings.Contains(f, "test") || strings.Contains(f, "pytest") { + return "test" + } + if strings.Contains(f, "build") { + return "build" + } + } + if first == "pytest" { + return "test" + } + return "build" + case "git": + return "git" + default: + return "other" + } +} + +func extractPaths(v any, limit int) []string { + seen := map[string]bool{} + var out []string + var walk func(any) + walk = func(cur any) { + if len(out) >= limit { + return + } + switch x := cur.(type) { + case map[string]any: + for key, val := range x { + lower := strings.ToLower(key) + if lower == "path" || lower == "file_path" || lower == "filepath" || lower == "notebook_path" { + if s := strings.TrimSpace(stringFromAny(val)); s != "" && !seen[s] { + seen[s] = true + scrubbed, ok := scrubTextForHandoff(s) + if ok { + out = append(out, scrubbed) + } + if len(out) >= limit { + return + } + } + } + walk(val) + } + case []any: + for _, el := range x { + walk(el) + } + } + } + walk(v) + return out +} + +func numericField(v any, keys ...string) (any, bool) { + m := nestedMap(v) + if m == nil { + return nil, false + } + for _, key := range keys { + switch x := m[key].(type) { + case float64: + if x == float64(int64(x)) { + return int64(x), true + } + return x, true + case int: + return x, true + case int64: + return x, true + case json.Number: + return x.String(), true + } + } + return nil, false +} + +func eventKind(eventName string) string { + var b strings.Builder + for i, r := range eventName { + if i > 0 && r >= 'A' && r <= 'Z' { + b.WriteByte('-') + } + b.WriteByte(byte(strings.ToLower(string(r))[0])) + } + return b.String() +} + +func lifecycleContent(eventName string, sid string, cwd string, payload map[string]any) string { + parts := []string{ + fmt.Sprintf("Claude Code lifecycle event (event=%s, session_id=%s, cwd=%s)", eventName, sid, cwd), + } + for _, key := range []string{"agent_id", "agentId", "subagent_id", "subagentId", "agent_type", "agentType", "task_id", "taskId", "task_subject", "taskSubject", "task_description", "taskDescription", "teammate_name", "teammateName", "team_name", "teamName", "status", "description", "subject"} { + if s := stringField(payload, key); s != "" { + parts = append(parts, key+": "+truncateText(s, 2000)) + } + } + return strings.Join(parts, "\n") +} + +func copyStringMeta(meta map[string]any, payload map[string]any, metaKey string, sourceKeys ...string) { + if value := firstString(payload, sourceKeys...); value != "" { + meta[metaKey] = value + } +} + +func copyBoundedStringMeta(meta map[string]any, payload map[string]any, metaKey string, maxChars int, sourceKeys ...string) { + if value := firstString(payload, sourceKeys...); value != "" { + value = truncateText(value, maxChars) + if value != "" { + meta[metaKey] = value + } + } +} + +func lifecycleTokenUsage(v any) map[string]any { + m := nestedMap(v) + if m == nil { + return nil + } + out := map[string]any{} + for _, key := range []string{ + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "total_tokens", + } { + if value, ok := numericField(m, key); ok { + out[key] = value + } + } + if len(out) == 0 { + return nil + } + return out +} + func maxFileChars() int { s := strings.TrimSpace(os.Getenv("POWERMEM_HOOK_MAX_CHARS")) if s == "" { diff --git a/apps/claude-code-plugin/cmd/powermem-hook/main_test.go b/apps/claude-code-plugin/cmd/powermem-hook/main_test.go new file mode 100644 index 000000000..974e82e8a --- /dev/null +++ b/apps/claude-code-plugin/cmd/powermem-hook/main_test.go @@ -0,0 +1,569 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const unitSecret = "Bearer unitsecret123" + +func setHookScrubForTest(t *testing.T, enabled bool) { + t.Helper() + if enabled { + t.Setenv("POWERMEM_HOOK_SCRUB", "1") + } else { + t.Setenv("POWERMEM_HOOK_SCRUB", "0") + } +} + +func requireNoUnitSecret(t *testing.T, v any) { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal value: %v", err) + } + if strings.Contains(string(b), unitSecret) { + t.Fatalf("value leaked unit secret: %s", b) + } +} + +func TestHookScrubConfigDefaultsOn(t *testing.T) { + t.Setenv("POWERMEM_HOOK_SCRUB", "") + if !loadHookPrivacyConfig().Enabled { + t.Fatal("empty scrub setting should default to enabled") + } + t.Setenv("POWERMEM_HOOK_SCRUB", "unexpected") + if !loadHookPrivacyConfig().Enabled { + t.Fatal("unknown scrub setting should default to enabled") + } + for _, raw := range []string{"0", "false", "no", "off", " OFF "} { + t.Run("disable_"+strings.TrimSpace(raw), func(t *testing.T) { + t.Setenv("POWERMEM_HOOK_SCRUB", raw) + if loadHookPrivacyConfig().Enabled { + t.Fatalf("scrub setting %q should disable scrubbing", raw) + } + }) + } + for _, raw := range []string{"1", "true", "yes", "on", " YES "} { + t.Run("enable_"+strings.TrimSpace(raw), func(t *testing.T) { + t.Setenv("POWERMEM_HOOK_SCRUB", raw) + if !loadHookPrivacyConfig().Enabled { + t.Fatalf("scrub setting %q should enable scrubbing", raw) + } + }) + } +} + +func TestToolEventAllowedIncludeExcludePrecedence(t *testing.T) { + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "") + if !toolEventAllowed("Bash") { + t.Fatal("Bash should be included by default") + } + if toolEventAllowed("NotebookEdit") { + t.Fatal("unknown tools should not be included by default") + } + + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "*") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "Bash") + if toolEventAllowed("Bash") { + t.Fatal("exclude should win over wildcard include") + } + if !toolEventAllowed("NotebookEdit") { + t.Fatal("wildcard include should allow otherwise unknown tools") + } + + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "*") + if toolEventAllowed("Write") { + t.Fatal("wildcard exclude should disable all tool capture") + } +} + +func TestPrepareToolEventHandoffScrubsAndDropsRawPayload(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "") + t.Setenv("POWERMEM_TOOL_EVENT_MAX_CHARS", "900") + t.Setenv("POWERMEM_INFER_TOOL_EVENTS", "0") + + payload := map[string]any{ + "hook_event_name": "PostToolUse", + "session_id": "session-unit", + "cwd": "/workspace/project", + "tool_name": "Bash", + "tool_use_id": "tool-bash-unit", + "duration_ms": float64(321), + "tool_input": map[string]any{ + "command": "pytest tests/unit --token " + unitSecret, + }, + "tool_response": map[string]any{ + "exit_code": float64(0), + "stdout": "all good " + unitSecret, + "stderr": "", + }, + } + + handoff := prepareToolEventHandoff(payload) + if handoff == nil { + t.Fatal("expected a prepared handoff") + } + if _, ok := handoff["tool_input"]; ok { + t.Fatal("prepared handoff should not include raw tool_input") + } + if _, ok := handoff["tool_response"]; ok { + t.Fatal("prepared handoff should not include raw tool_response") + } + requireNoUnitSecret(t, handoff) + + content, meta, runID, infer, ok := preparedToolEventPost(handoff) + if !ok { + t.Fatal("expected worker to read prepared post payload") + } + if infer { + t.Fatal("tool event infer should default to false") + } + if runID != "session-unit" { + t.Fatalf("unexpected run id: %q", runID) + } + if content == "" || !strings.Contains(content, "command_class=test") { + t.Fatalf("unexpected content summary: %q", content) + } + if meta["event_id"] != "claude-code:session-unit:tool-bash-unit" { + t.Fatalf("unexpected event id: %v", meta["event_id"]) + } + if meta["command_class"] != "test" { + t.Fatalf("unexpected command class: %v", meta["command_class"]) + } + if meta["exit_code"] != int64(0) { + t.Fatalf("unexpected exit code: %#v", meta["exit_code"]) + } + if meta["duration_ms"] != int64(321) { + t.Fatalf("unexpected duration: %#v", meta["duration_ms"]) + } + requireNoUnitSecret(t, content) + requireNoUnitSecret(t, meta) +} + +func TestPrepareToolEventHandoffBlocksRawPayloadSecret(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_HOOK_SECRET_ACTION", "block") + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "") + + handoff := prepareToolEventHandoff(map[string]any{ + "hook_event_name": "PostToolUse", + "session_id": "session-block", + "cwd": "/workspace/project", + "tool_name": "Bash", + "tool_use_id": "tool-block-unit", + "tool_input": map[string]any{ + "command": "pytest tests/unit --token " + unitSecret, + }, + "tool_response": map[string]any{ + "exit_code": float64(0), + "stdout": "ok", + }, + }) + if handoff != nil { + t.Fatalf("secret_action=block should skip handoff with raw payload secret: %#v", handoff) + } +} + +func TestPrepareMapPayloadHandoffScrubsBeforeWorkerPayloadFile(t *testing.T) { + setHookScrubForTest(t, true) + + handoff := prepareMapPayloadHandoff(map[string]any{ + "hook_event_name": "TaskCompleted", + "session_id": "session-lifecycle", + "cwd": "/workspace/project", + "transcript_path": "/workspace/project/transcript.jsonl", + "task_id": "task-unit", + "task_subject": "Review handoff behavior", + "task_description": "Validate " + unitSecret + " is scrubbed before writing payload files.", + "agent_transcript_path": "/workspace/project/agent-transcript.jsonl", + }, scrubReport{}) + if handoff == nil { + t.Fatal("expected scrubbed lifecycle handoff") + } + requireNoUnitSecret(t, handoff) + b, err := json.Marshal(handoff) + if err != nil { + t.Fatalf("marshal handoff: %v", err) + } + if strings.Contains(string(b), "/workspace/project") { + t.Fatalf("handoff leaked absolute path before worker payload file: %s", b) + } + if _, ok := handoff[preparedParentScrubReportKey]; !ok { + t.Fatalf("expected parent scrub report in handoff: %#v", handoff) + } +} + +func TestPrepareMapPayloadHandoffBlocksBeforeWorkerPayloadFile(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_HOOK_SECRET_ACTION", "block") + + handoff := prepareMapPayloadHandoff(map[string]any{ + "hook_event_name": "TaskCreated", + "session_id": "session-block", + "task_id": "task-block", + "task_subject": "Block raw payload secret", + "task_description": "Do not write " + unitSecret + " to a worker payload file.", + }, scrubReport{}) + if handoff != nil { + t.Fatalf("secret_action=block should skip worker handoff before temp file write: %#v", handoff) + } +} + +func TestBuildToolEventPostCapturesAgentResponseLink(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "") + + content, meta, runID, infer, ok := buildToolEventPost(map[string]any{ + "session_id": "session-agent", + "cwd": "/workspace/project", + "tool_name": "Agent", + "tool_use_id": "tool-agent-unit", + "tool_input": map[string]any{ + "agent_type": "reviewer", + "description": "Review hook metadata.", + "prompt": "Check for " + unitSecret, + }, + "tool_response": map[string]any{ + "agentId": "agent-unit-1", + "agentType": "reviewer", + "status": "completed", + "content": []any{ + map[string]any{"type": "text", "text": "Review completed with " + unitSecret}, + }, + "usage": map[string]any{"input_tokens": float64(12), "output_tokens": float64(5)}, + }, + }) + if !ok { + t.Fatal("expected Agent tool event to be captured") + } + if infer { + t.Fatal("Agent tool event infer should default to false") + } + if runID != "session-agent" { + t.Fatalf("unexpected run id: %q", runID) + } + if meta["agent_id"] != "agent-unit-1" { + t.Fatalf("expected response agent id, got %v", meta["agent_id"]) + } + if meta["agent_type"] != "reviewer" { + t.Fatalf("expected response agent type, got %v", meta["agent_type"]) + } + if meta["agent_status"] != "completed" { + t.Fatalf("expected response agent status, got %v", meta["agent_status"]) + } + if !strings.Contains(meta["response_summary"].(string), "Review completed") { + t.Fatalf("Agent response summary did not include content blocks: %v", meta["response_summary"]) + } + if !strings.Contains(content, "Review completed") { + t.Fatalf("content did not include Agent response summary: %q", content) + } + requireNoUnitSecret(t, content) + requireNoUnitSecret(t, meta) +} + +func TestBuildSessionStartQueryUsesBoundedMetadata(t *testing.T) { + query := buildSessionStartQuery(map[string]any{ + "session_title": "No-LLM hook work", + "source": "startup", + "agent_type": "coder", + "cwd": "/workspace/project", + "ignored": "not included", + }) + for _, want := range []string{ + "session_title: No-LLM hook work", + "source: startup", + "agent_type: coder", + "cwd: /workspace/project", + } { + if !strings.Contains(query, want) { + t.Fatalf("query %q did not include %q", query, want) + } + } + if strings.Contains(query, "ignored") { + t.Fatalf("query included unexpected metadata: %q", query) + } +} + +func TestSessionStartQueryScrubsPathBeforeSearch(t *testing.T) { + setHookScrubForTest(t, true) + query := buildSessionStartQuery(map[string]any{ + "session_title": "No-LLM hook work", + "source": "startup", + "agent_type": "coder", + "cwd": "/workspace/project", + }) + scrubbed, ok := scrubPromptForSearch(query, loadHookPrivacyConfig()) + if !ok { + t.Fatal("session start metadata without secrets should remain searchable") + } + if strings.Contains(scrubbed, "/workspace/project") { + t.Fatalf("scrubbed query leaked absolute path: %q", scrubbed) + } + if !strings.Contains(scrubbed, "cwd: project") { + t.Fatalf("scrubbed query did not keep a useful cwd basename: %q", scrubbed) + } +} + +func TestBuildToolFailurePostCapturesStructuredFailure(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_TOOL_FAILURE_MAX_CHARS", "900") + t.Setenv("POWERMEM_INFER_TOOL_FAILURES", "0") + + content, meta, runID, infer, ok := buildToolFailurePost(map[string]any{ + "session_id": "session-failure", + "cwd": "/workspace/project", + "tool_name": "Bash", + "tool_use_id": "tool-failure-unit", + "duration_ms": float64(456), + "tool_input": map[string]any{ + "command": "pytest tests/unit --token " + unitSecret, + }, + "tool_response": map[string]any{ + "exit_code": float64(2), + "stderr": "failed with " + unitSecret, + }, + "error_type": "non_zero_exit", + "error_message": "pytest failed with " + unitSecret, + }) + if !ok { + t.Fatal("expected tool failure post") + } + if infer { + t.Fatal("tool failure infer should default to false") + } + if runID != "session-failure" { + t.Fatalf("unexpected run id: %q", runID) + } + if meta["event_name"] != "PostToolUseFailure" { + t.Fatalf("unexpected event name: %v", meta["event_name"]) + } + if meta["success"] != false { + t.Fatalf("expected success=false, got %v", meta["success"]) + } + if meta["command_class"] != "test" { + t.Fatalf("unexpected command class: %v", meta["command_class"]) + } + if meta["exit_code"] != int64(2) { + t.Fatalf("unexpected exit code: %#v", meta["exit_code"]) + } + requireNoUnitSecret(t, content) + requireNoUnitSecret(t, meta) +} + +func TestToolFailureInterruptsAreSkippedByDefaultInDispatcherGate(t *testing.T) { + payload := map[string]any{ + "hook_event_name": "PostToolUseFailure", + "tool_name": "Bash", + "is_interrupt": true, + } + t.Setenv("POWERMEM_CAPTURE_INTERRUPTS", "") + if !isInterruptPayload(payload) { + t.Fatal("expected interrupt payload") + } + if captureInterrupts() { + t.Fatal("interrupt capture should default to disabled") + } + t.Setenv("POWERMEM_CAPTURE_INTERRUPTS", "1") + if !captureInterrupts() { + t.Fatal("interrupt capture should be enabled by env") + } +} + +func TestBuildStopRollupPostBoundsAndScrubsFinalMessage(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_STOP_MAX_CHARS", "500") + t.Setenv("POWERMEM_INFER_STOP", "0") + + content, meta, runID, infer, ok := buildStopRollupPost(map[string]any{ + "session_id": "session-stop", + "cwd": "/workspace/project", + "last_assistant_message": "Implemented tests with " + unitSecret, + "changed_files": []any{"apps/claude-code-plugin/cmd/powermem-hook/main.go"}, + "background_tasks": []any{"none"}, + }) + if !ok { + t.Fatal("expected stop rollup") + } + if infer { + t.Fatal("stop infer should default to false") + } + if runID != "session-stop" { + t.Fatalf("unexpected run id: %q", runID) + } + if meta["kind"] != "stop-rollup" { + t.Fatalf("unexpected kind: %v", meta["kind"]) + } + if meta["event_name"] != "Stop" { + t.Fatalf("unexpected event name: %v", meta["event_name"]) + } + requireNoUnitSecret(t, content) + requireNoUnitSecret(t, meta) + + _, _, _, _, ok = buildStopRollupPost(map[string]any{ + "session_id": "session-empty-stop", + "last_assistant_message": " ", + }) + if ok { + t.Fatal("empty stop rollup should no-op") + } +} + +func TestBuildToolEventPostUnknownToolShapeOnlyWithWildcard(t *testing.T) { + setHookScrubForTest(t, true) + t.Setenv("POWERMEM_TOOL_SUCCESS_INCLUDE", "*") + t.Setenv("POWERMEM_TOOL_SUCCESS_EXCLUDE", "") + + _, meta, _, _, ok := buildToolEventPost(map[string]any{ + "session_id": "session-unknown", + "tool_name": "UnlistedTool", + "tool_use_id": "tool-unknown-unit", + "tool_input": []any{"raw", unitSecret}, + "tool_response": []any{"ok", unitSecret}, + }) + if !ok { + t.Fatal("wildcard include should capture unknown tool") + } + if meta["input_summary"] != "shape=array[2]" { + t.Fatalf("unexpected input summary: %v", meta["input_summary"]) + } + if meta["response_summary"] != "shape=array[2]" { + t.Fatalf("unexpected response summary: %v", meta["response_summary"]) + } + requireNoUnitSecret(t, meta) +} + +func TestReadTranscriptTailAppliesLineAndCharBounds(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + lines := []string{ + `{"type":"user","message":{"content":"old context"}}`, + `{"type":"assistant","message":{"content":"middle context"}}`, + `{"type":"assistant","message":{"content":"recent context"}}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write transcript: %v", err) + } + + snapshot, err := readTranscriptTail(path, 200, 1) + if err != nil { + t.Fatalf("read transcript tail: %v", err) + } + if !strings.Contains(snapshot.Text, "recent context") { + t.Fatalf("tail did not include latest line: %q", snapshot.Text) + } + if strings.Contains(snapshot.Text, "old context") || strings.Contains(snapshot.Text, "middle context") { + t.Fatalf("line bound was not applied: %q", snapshot.Text) + } + if snapshot.StartByte <= 0 { + t.Fatalf("expected non-zero start offset, got %d", snapshot.StartByte) + } + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat transcript: %v", err) + } + if snapshot.EndByte != st.Size() { + t.Fatalf("expected end offset %d, got %d", st.Size(), snapshot.EndByte) + } + + snapshot, err = readTranscriptTail(path, 40, 10) + if err != nil { + t.Fatalf("read char-bounded transcript tail: %v", err) + } + if len(snapshot.Text) > 40 { + t.Fatalf("char bound was not applied: len=%d text=%q", len(snapshot.Text), snapshot.Text) + } +} + +func TestEventKindUsesHookEventName(t *testing.T) { + cases := map[string]string{ + "SubagentStop": "subagent-stop", + "TaskCompleted": "task-completed", + "PreCompact": "pre-compact", + } + for input, want := range cases { + if got := eventKind(input); got != want { + t.Fatalf("eventKind(%q) = %q, want %q", input, got, want) + } + } +} + +func TestLifecycleContentCapturesTaskSchemaFields(t *testing.T) { + content := lifecycleContent("TaskCompleted", "session-task", "/workspace/project", map[string]any{ + "task_id": "task-001", + "task_subject": "Implement user authentication", + "task_description": "Add login and signup endpoints", + "teammate_name": "implementer", + }) + for _, want := range []string{ + "task_id: task-001", + "task_subject: Implement user authentication", + "task_description: Add login and signup endpoints", + "teammate_name: implementer", + } { + if !strings.Contains(content, want) { + t.Fatalf("lifecycle content %q did not include %q", content, want) + } + } +} + +func TestBuildLifecycleEventPostUsesBoundedAllowlistedMetadata(t *testing.T) { + setHookScrubForTest(t, true) + longMessage := strings.Repeat("assistant-details-", 400) + unitSecret + longDescription := strings.Repeat("task-description-", 300) + unitSecret + + content, meta, runID, infer, _, ok := buildLifecycleEventPost(map[string]any{ + "hook_event_name": "SubagentStop", + "session_id": "session-lifecycle", + "cwd": "/workspace/project", + "task_id": "task-001", + "task_description": longDescription, + "last_assistant_message": longMessage, + "background_tasks": []any{longMessage}, + "token_usage": map[string]any{ + "input_tokens": float64(12), + "raw_text": longMessage, + }, + }, scrubReport{}) + if !ok { + t.Fatal("expected lifecycle event post") + } + if infer { + t.Fatal("lifecycle infer should default to false") + } + if runID != "session-lifecycle" { + t.Fatalf("unexpected run id: %q", runID) + } + if _, ok := meta["raw_payload"]; ok { + t.Fatalf("lifecycle metadata should not include raw_payload: %#v", meta["raw_payload"]) + } + if _, ok := meta["last_assistant_message"]; ok { + t.Fatal("lifecycle metadata should not copy assistant message fields") + } + desc, ok := meta["task_description"].(string) + if !ok || desc == "" { + t.Fatalf("expected bounded task_description, got %#v", meta["task_description"]) + } + if len(desc) > lifecycleDescriptionMaxChars+4 { + t.Fatalf("task_description was not bounded: len=%d", len(desc)) + } + usage, ok := meta["token_usage"].(map[string]any) + if !ok { + t.Fatalf("expected token usage map, got %#v", meta["token_usage"]) + } + if usage["input_tokens"] != int64(12) { + t.Fatalf("unexpected token usage: %#v", usage) + } + if _, ok := usage["raw_text"]; ok { + t.Fatalf("token usage should only keep numeric token fields: %#v", usage) + } + requireNoUnitSecret(t, content) + requireNoUnitSecret(t, meta) +} diff --git a/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-amd64 b/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-amd64 index 70d1cf6d7..dc395d8cd 100755 Binary files a/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-amd64 and b/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-amd64 differ diff --git a/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-arm64 b/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-arm64 index 2875b137f..bc1ac1465 100755 Binary files a/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-arm64 and b/apps/claude-code-plugin/hooks/bin/powermem-hook-darwin-arm64 differ diff --git a/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-amd64 b/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-amd64 index 1b433a72c..4d5f58488 100755 Binary files a/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-amd64 and b/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-amd64 differ diff --git a/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-arm64 b/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-arm64 index 617d6a4dc..276b748df 100755 Binary files a/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-arm64 and b/apps/claude-code-plugin/hooks/bin/powermem-hook-linux-arm64 differ diff --git a/apps/claude-code-plugin/hooks/bin/powermem-hook-windows-amd64.exe b/apps/claude-code-plugin/hooks/bin/powermem-hook-windows-amd64.exe index 661e019d2..071c54396 100755 Binary files a/apps/claude-code-plugin/hooks/bin/powermem-hook-windows-amd64.exe and b/apps/claude-code-plugin/hooks/bin/powermem-hook-windows-amd64.exe differ diff --git a/apps/claude-code-plugin/hooks/hooks.json b/apps/claude-code-plugin/hooks/hooks.json index 7b4ea7f2e..389a7dc81 100644 --- a/apps/claude-code-plugin/hooks/hooks.json +++ b/apps/claude-code-plugin/hooks/hooks.json @@ -1,6 +1,17 @@ { - "description": "Push Claude Code session transcripts (SessionEnd) and compact summaries (PostCompact) to PowerMem via HTTP. UserPromptSubmit: semantic search injects context by default; set POWERMEM_PROMPT_SEARCH=0 (or false/no/off) to disable. Uses native binaries under hooks/bin. macOS/Linux: sh launcher. Windows without sh: merge hooks/hooks.windows.example.json. POWERMEM_BASE_URL defaults to http://localhost:8848 if unset.", + "description": "Push Claude Code prompt, transcript, compact, tool, subagent, and task events to PowerMem via HTTP. UserPromptSubmit: semantic search injects context by default; set POWERMEM_PROMPT_SEARCH=0 (or false/no/off) to disable. Uses native binaries under hooks/bin. macOS/Linux: sh launcher. Windows without sh: merge hooks/hooks.windows.example.json. POWERMEM_BASE_URL defaults to http://localhost:8848 if unset.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"", + "timeout": 120 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ @@ -32,6 +43,87 @@ } ] } + ], + "PreCompact": [ + { + "matcher": "auto|manual", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "TaskCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } ] } } diff --git a/apps/claude-code-plugin/hooks/hooks.windows.example.json b/apps/claude-code-plugin/hooks/hooks.windows.example.json index 03ed28dfc..4a46428b9 100644 --- a/apps/claude-code-plugin/hooks/hooks.windows.example.json +++ b/apps/claude-code-plugin/hooks/hooks.windows.example.json @@ -1,6 +1,17 @@ { "description": "Windows: use this hook command shape if `sh` is not available. Merge into ~/.claude/settings.json or project .claude/settings.json under the same hook events. POWERMEM_BASE_URL defaults to http://localhost:8848 if unset.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"", + "timeout": 120 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ @@ -32,6 +43,87 @@ } ] } + ], + "PreCompact": [ + { + "matcher": "auto|manual", + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "TaskCreated": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } ] } } diff --git a/apps/claude-code-plugin/init-flow.svg b/apps/claude-code-plugin/init-flow.svg index 5bbc414f4..e24021c97 100644 --- a/apps/claude-code-plugin/init-flow.svg +++ b/apps/claude-code-plugin/init-flow.svg @@ -212,7 +212,7 @@ 1. Claude Code 触发 hook - UserPromptSubmit / SessionEnd / PostCompact + UserPromptSubmit / hook write events 2. run-hook.sh 读取地址配置 diff --git a/apps/claude-code-plugin/uv-init-flow.svg b/apps/claude-code-plugin/uv-init-flow.svg index d2adc6af8..78f7ffbe9 100644 --- a/apps/claude-code-plugin/uv-init-flow.svg +++ b/apps/claude-code-plugin/uv-init-flow.svg @@ -127,7 +127,7 @@ hooks 自动生效 UserPromptSubmit:自动 recall - SessionEnd/PostCompact:自动 save + hook write events:自动 save 完成 diff --git a/apps/claude-code-plugin/watcher/README.md b/apps/claude-code-plugin/watcher/README.md index b215345f7..332fb53c5 100644 --- a/apps/claude-code-plugin/watcher/README.md +++ b/apps/claude-code-plugin/watcher/README.md @@ -1,6 +1,9 @@ # Workspace file watcher (optional) The poller lives in the same **native binary** as the Claude hooks (no Python). +Git/marketplace installs and release plugin zips include this binary under +`hooks/bin/`. Run `make build-claude-hook` from the repository root only when +refreshing the binary from hook source changes. From the plugin root. `POWERMEM_BASE_URL` defaults to `http://localhost:8848` if unset (optional `POWERMEM_API_KEY`): diff --git a/docs/integrations/claude_code.md b/docs/integrations/claude_code.md index 2494d972d..a3f6851fa 100644 --- a/docs/integrations/claude_code.md +++ b/docs/integrations/claude_code.md @@ -33,14 +33,14 @@ Prefer to wire it by hand? The full plugin reference below covers every option. |--------|----------------|-------| | Claude Code | No | | | MCP tools | No | **Off by default** (HTTP mode). Run `apply-connection-mode.sh mcp` to enable. | -| **Hooks** (transcript / compact → HTTP API) | **No** | Native binaries under `hooks/bin/` + `run-hook.sh` (macOS/Linux) or PowerShell on Windows. **`POWERMEM_BASE_URL` defaults to `http://localhost:8848`.** | +| **Hooks** (event-driven writes/search → HTTP API) | **No** | Git/marketplace installs and release plugin zips include native binaries under `hooks/bin/` + `run-hook.sh` (macOS/Linux) or PowerShell on Windows. **`POWERMEM_BASE_URL` defaults to `http://localhost:8848`.** | | Optional **file poller** | No | Same binary: `sh hooks/run-hook.sh poll` — see [watcher/README.md](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/watcher/README.md). | **macOS / Linux:** default `hooks/hooks.json` runs `sh …/run-hook.sh`. POSIX `sh` is always present. -**Windows (native, no Git Bash):** if `sh` is missing, merge the commands from [`hooks/hooks.windows.example.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.windows.example.json) into your Claude `settings.json` so hooks call `powershell.exe -File …/run-hook.ps1`. The zip includes `hooks/bin/powermem-hook-windows-amd64.exe` (add `windows/arm64` to the build script if you need it). +**Windows (native, no Git Bash):** if `sh` is missing, merge the commands from [`hooks/hooks.windows.example.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.windows.example.json) into your Claude `settings.json` so hooks call `powershell.exe -File …/run-hook.ps1`. Git/marketplace installs and release zips include `hooks/bin/powermem-hook-windows-amd64.exe` (add `windows/arm64` to the build script if you need it). -**Rebuilding binaries** (developers / CI): Go **1.22+**, then `bash scripts/build-hook-binaries.sh` or `make build-claude-hook` from the repo root. `make package-claude-plugin` builds them automatically before zipping. +**Rebuilding binaries** (developers / CI): Go **1.22+**, then `make build-claude-hook` or `bash apps/claude-code-plugin/scripts/build-hook-binaries.sh` from the repo root. Commit refreshed `hooks/bin/powermem-hook-*` binaries when hook code changes so Git/marketplace installs remain runnable. `make package-claude-plugin` also rebuilds them automatically before zipping. ## Prerequisites @@ -50,7 +50,7 @@ Prefer to wire it by hand? The full plugin reference below covers every option. ## Manual Installation -Set up the integration **from source** — this is **HTTP mode** (the default): hooks push transcripts to the REST API and inject search results per turn, with no in-chat tools. +Set up the integration **from source** — this is **HTTP mode** (the default): hooks send event-driven writes/search to the REST API and inject search results per turn, with no in-chat tools. ### Step 1 — Download the source @@ -63,10 +63,11 @@ cd powermem Copy the template and set your Anthropic credential. For direct Anthropic API access use `LLM_API_KEY`; for a Claude Code-style bearer-token gateway use -`LLM_AUTH_TOKEN` together with `ANTHROPIC_LLM_BASE_URL`. Storage defaults to the -embedded **seekdb** database (no separate database), and the embedder to a local +`LLM_AUTH_TOKEN` together with `ANTHROPIC_LLM_BASE_URL`. Storage defaults to a +local **SQLite** database (no separate service), and the embedder to a local `sentence-transformers/all-MiniLM-L6-v2` model (no API key, auto-downloaded on -first use). +first use). Set `DATABASE_PROVIDER=oceanbase` / the OceanBase settings when you +want the embedded seekdb path instead. ```bash cp .env.example .env @@ -101,18 +102,20 @@ export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" uv --version ``` -### Step 4 — Install PowerMem and build the hook binaries +### Step 4 — Install PowerMem -`uv pip install -e '.[server,seekdb]'` provides the `powermem-server` and -`pmem` commands plus the zero-config local seekdb path and local embedder. -`make build-claude-hook` compiles the native Go hook binaries (requires -**Go 1.22+**): +`uv pip install -e '.[server,extras]'` provides the `powermem-server` and +`pmem` commands plus the zero-config local SQLite path and local embedder. +Git/marketplace installs include native hook binaries under +`apps/claude-code-plugin/hooks/bin/`, so Go is only needed when refreshing those +binaries from hook source changes: ```bash uv venv venv --python python3.11 source venv/bin/activate -uv pip install --python "$VIRTUAL_ENV/bin/python" -e '.[server,seekdb]' -make build-claude-hook # outputs apps/claude-code-plugin/hooks/bin/ +uv pip install --python "$VIRTUAL_ENV/bin/python" -e '.[server,extras]' +# Optional after hook source changes: +# make build-claude-hook # refreshes apps/claude-code-plugin/hooks/bin/ ``` ### Step 5 — Start the HTTP API server @@ -156,8 +159,10 @@ When the PowerMem marketplace entry is available, install it with: The marketplace step installs the Claude Code plugin connector. The `/memory-powermem:init` step prepares the PowerMem backend by ensuring `uv`, then -starts it with the uvx-style launcher -`uvx --from 'powermem[server,seekdb]' powermem-server`. The PyPI release must +starts it with the uvx-style launcher. The package spec depends on the storage +backend: the default SQLite path uses `powermem[server,extras]` (pulls +`sentence-transformers` for the local `huggingface` embedder), while the +OceanBase/seekdb path uses `powermem[server,seekdb]`. The PyPI release must include the backend features required by the plugin, including the default local embedding dependencies. If `uv` is missing, init installs it automatically: non-CN networks use the official Astral installer, while CN networks use the USTC @@ -175,7 +180,12 @@ passes it to `uvx --from`: ``` ```bash +# Default SQLite path +POWERMEM_INIT_PACKAGE='powermem[server,extras] @ git+https://github.com/oceanbase/powermem.git@' \ + sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" +# OceanBase/seekdb path POWERMEM_INIT_PACKAGE='powermem[server,seekdb] @ git+https://github.com/oceanbase/powermem.git@' \ + POWERMEM_INIT_DATABASE_PROVIDER=oceanbase \ sh "$CLAUDE_PLUGIN_ROOT/scripts/init.sh" ``` @@ -222,7 +232,7 @@ How you remove the plugin depends on how you enabled it: | **Zip / copied folder** | Delete the unzipped directory. Stop using `--plugin-dir` pointing at it. | | **Git clone / repo path** | Stop using `--plugin-dir` for that path; remove the clone if you no longer need it. | | **Marketplace / built-in plugin UI** | Run `/plugin uninstall memory-powermem@powermem`, then `/reload-plugins`. To remove the marketplace entry as well, run `/plugin marketplace remove powermem`. | -| **You merged [`hooks/hooks.windows.example.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.windows.example.json) into `settings.json`** | Edit `~/.claude/settings.json` or `.claude/settings.json` in the project and remove the `UserPromptSubmit` / `SessionEnd` / `PostCompact` hook entries that call `run-hook.ps1` (or restore a backup). Otherwise hooks keep running even after the plugin folder is deleted. | +| **You merged [`hooks/hooks.windows.example.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.windows.example.json) into `settings.json`** | Edit `~/.claude/settings.json` or `.claude/settings.json` in the project and remove every PowerMem hook entry that calls `run-hook.ps1` (or restore a backup). Otherwise hooks keep running even after the plugin folder is deleted. | The hook binary only **writes** to your PowerMem server; it does not install a system daemon. No separate “service uninstall” is required. @@ -231,7 +241,7 @@ The hook binary only **writes** to your PowerMem server; it does not install a s | Install style | Update steps | |---------------|--------------| | **Zip** | Download the new `.zip`, replace the old folder (delete the previous `powermem-claude-code-plugin` tree, unzip the new one to the same or a new path), then start Claude with `--plugin-dir` pointing at the new folder. | -| **Repo / `git`** | `git pull` (or fetch the release you want), run `make package-claude-plugin` or `bash scripts/package-plugin.sh` if you need a fresh zip, then restart Claude Code. | +| **Repo / `git`** | `git pull` (or fetch the release you want), run `make package-claude-plugin` or `bash apps/claude-code-plugin/scripts/package-plugin.sh` if you need a fresh zip, then restart Claude Code. | | **Marketplace** | Run `/plugin uninstall memory-powermem@powermem`, reinstall from the marketplace, then run `/reload-plugins`. If the backend package changed, re-run `/memory-powermem:init` so uvx resolves the new PyPI release. | After updating, restart the Claude Code session (or the whole app) so MCP config, skills, and hooks reload. @@ -291,19 +301,26 @@ Ensure PowerMem is installed (`uv pip install --python "$VIRTUAL_ENV/bin/python" ### HTTP mode: REST only (standard) -This is the **default** root `.mcp.json`. Claude has **no** PowerMem MCP tools; skills that reference those tools have nothing to call. **Hooks** still send transcripts / compact summaries to `POST /api/v1/memories`. To reset after trying MCP: `bash scripts/apply-connection-mode.sh http`. +This is the **default** root `.mcp.json`. Claude has **no** PowerMem MCP tools; skills that reference those tools have nothing to call. **Hooks** still send event-driven writes such as session transcripts, compact snapshots, tool outcomes, and lifecycle events to `POST /api/v1/memories`. To reset after trying MCP: `bash scripts/apply-connection-mode.sh http`. ### Seamless recording (hooks + HTTP API) -The plugin ships [`hooks/hooks.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.json), [`hooks/run-hook.sh`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/run-hook.sh), and **native** `hooks/bin/powermem-hook-*` (built from [`cmd/powermem-hook`](https://github.com/oceanbase/powermem/tree/main/apps/claude-code-plugin/cmd/powermem-hook/)). When the plugin is enabled, Claude Code merges these hooks: +The plugin ships [`hooks/hooks.json`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/hooks.json), [`hooks/run-hook.sh`](https://github.com/oceanbase/powermem/blob/main/apps/claude-code-plugin/hooks/run-hook.sh), and **native** `hooks/bin/powermem-hook-*` binaries built from [`cmd/powermem-hook`](https://github.com/oceanbase/powermem/tree/main/apps/claude-code-plugin/cmd/powermem-hook/). When the plugin is enabled, Claude Code merges these hooks: | Hook | What happens | |------|----------------| +| `SessionStart` | By default, **`POST …/api/v1/memories/search`** with bounded session metadata such as `cwd`, `session_title`, `source`, and `agent_type`; hits are injected as **additional context** before the first turn. Set **`POWERMEM_SESSION_START_SEARCH=0`** to disable. | | `UserPromptSubmit` | By default, **`POST …/api/v1/memories/search`** with the submitted `prompt`; top results are injected as **additional context** for that turn ([Claude Code hooks](https://code.claude.com/docs/en/hooks#userpromptsubmit)). Set **`POWERMEM_PROMPT_SEARCH=0`** (or `false` / `no` / `off`) to skip search (hook still registered; overhead is small when disabled). | | `SessionEnd` | Full **transcript** from `transcript_path` (parsed JSONL: user/assistant/summary lines) → **`POST …/api/v1/memories`**. | | `PostCompact` | The **`compact_summary`** field after `/compact` or auto-compact → **`POST …/api/v1/memories`**. | +| `PreCompact` | Bounded tail snapshot from `transcript_path` before compaction → **`POST …/api/v1/memories`**, with transcript fingerprint and byte offsets for auditability. | +| `PostToolUse` | Structured summaries for high-signal successful tools (`Write`, `Edit`, `MultiEdit`, `Bash`, `Agent`, `ExitPlanMode` by default) → **`POST …/api/v1/memories`**. Content and metadata are scrubbed, and deterministic event IDs are included when `session_id` and `tool_use_id` are available. | +| `PostToolUseFailure` | Structured summaries for failed tools → **`POST …/api/v1/memories`** with `success=false`, error metadata, bounded scrubbed summaries, and interrupts skipped by default. | +| `Stop` | Optional lightweight per-turn rollup after the main agent finishes responding → **`POST …/api/v1/memories`**. Disabled by default because it can run frequently; set **`POWERMEM_CAPTURE_STOP_ROLLUP=1`** to enable. | +| `SubagentStart` / `SubagentStop` | Lifecycle observations keyed by the hook event name, not by `transcript_path`; metadata keeps bounded allowlisted link fields, not the full raw lifecycle payload. | +| `TaskCreated` / `TaskCompleted` | Task lifecycle observations keyed by the hook event name, with link fields such as `session_id`, `task_id`, and `tool_use_id` when present. | -**Write** hooks use `POST {POWERMEM_BASE_URL}/api/v1/memories`. **Prompt search** uses `POST {POWERMEM_BASE_URL}/api/v1/memories/search`. Neither path requires MCP. +**Write** hooks use `POST {POWERMEM_BASE_URL}/api/v1/memories`. **Prompt and session-start search** use `POST {POWERMEM_BASE_URL}/api/v1/memories/search`. Neither path requires MCP. Optional environment variables (where you launch Claude Code): @@ -324,8 +341,32 @@ Optional environment variables (where you launch Claude Code): | `POWERMEM_PROMPT_SEARCH` | No | **Default: on** — injects semantic search results on every user prompt via `UserPromptSubmit`. Set **`0`** / **`false`** / **`no`** / **`off`** to disable. | | `POWERMEM_PROMPT_SEARCH_LIMIT` | No | Max memories returned per prompt (default **8**, cap **30**). | | `POWERMEM_PROMPT_SEARCH_MAX_CHARS` | No | Cap on injected context string (default **24000**). | - -The hook scrubber runs before `SessionEnd`, `PostCompact`, workspace-file writes, prompt search, and the `PostCompact` detached-worker environment handoff. Write metadata includes a `privacy` object with the active level, path mode, action, and redaction counts; original matched values are not recorded there. +| `POWERMEM_SESSION_START_SEARCH` | No | **Default: on** — injects semantic search results at `SessionStart` using bounded session metadata. Set **`0`** / **`false`** / **`no`** / **`off`** to disable. | +| `POWERMEM_SESSION_START_LIMIT` | No | Max memories returned for `SessionStart` search (default **6**, cap **30**). | +| `POWERMEM_SESSION_START_MAX_CHARS` | No | Cap on `SessionStart` injected context string (default **16000**). | +| `POWERMEM_CAPTURE_PRECOMPACT` | No | Set `0` / `false` / `no` / `off` to disable `PreCompact` snapshots (default on). | +| `POWERMEM_PRECOMPACT_MAX_CHARS` | No | Max transcript tail characters for `PreCompact` snapshots (default **120000**). | +| `POWERMEM_PRECOMPACT_TAIL_LINES` | No | Max transcript tail lines for `PreCompact` snapshots (default **200**). | +| `POWERMEM_INFER_PRECOMPACT` | No | Set `1` to enable server-side infer for `PreCompact` snapshots (default off). | +| `POWERMEM_CAPTURE_TOOL_SUCCESS` | No | Set `0` / `false` / `no` / `off` to disable `PostToolUse` capture (default on). | +| `POWERMEM_TOOL_SUCCESS_INCLUDE` | No | Comma-separated allowed tool names. Defaults to `Write,Edit,MultiEdit,Bash,Agent,ExitPlanMode`; `*` allows all tools. | +| `POWERMEM_TOOL_SUCCESS_EXCLUDE` | No | Comma-separated denied tool names. Exclude wins over include; `*` disables all tool success capture. | +| `POWERMEM_TOOL_EVENT_MAX_CHARS` | No | Max characters for a structured tool event memory (default **6000**). | +| `POWERMEM_INFER_TOOL_EVENTS` | No | Set `1` to enable server-side infer for tool event memories (default off). | +| `POWERMEM_CAPTURE_TOOL_FAILURES` | No | Set `0` / `false` / `no` / `off` to disable `PostToolUseFailure` capture (default on). | +| `POWERMEM_CAPTURE_INTERRUPTS` | No | Set `1` to capture interrupted tool events; interrupts are skipped by default. | +| `POWERMEM_TOOL_FAILURE_MAX_CHARS` | No | Max characters for a structured tool failure memory (default **6000**). | +| `POWERMEM_INFER_TOOL_FAILURES` | No | Set `1` to enable server-side infer for failed tool memories (default off). | +| `POWERMEM_CAPTURE_STOP_ROLLUP` | No | Set `1` to enable lightweight `Stop` rollup capture (default off). | +| `POWERMEM_STOP_MAX_CHARS` | No | Max characters for the `Stop` final-message preview (default **3000**). | +| `POWERMEM_INFER_STOP` | No | Set `1` to enable server-side infer for `Stop` rollups (default off). | +| `POWERMEM_CAPTURE_SUBAGENTS` | No | Set `0` / `false` / `no` / `off` to disable subagent lifecycle capture (default on). | +| `POWERMEM_CAPTURE_TASKS` | No | Set `0` / `false` / `no` / `off` to disable task lifecycle capture (default on). | +| `POWERMEM_INFER_LIFECYCLE_EVENTS` | No | Set `1` to enable server-side infer for all lifecycle events (default off). | +| `POWERMEM_INFER_SUBAGENT_STOP` | No | Set `1` to enable infer for `SubagentStop` when the generic lifecycle infer flag is off. | +| `POWERMEM_INFER_TASK_COMPLETED` | No | Set `1` to enable infer for `TaskCompleted` when the generic lifecycle infer flag is off. | + +The hook scrubber runs before hook writes/searches, including session, compact, tool, stop, lifecycle, prompt-search, workspace-file, and detached-worker handoff paths. Write metadata includes a `privacy` object with the active level, path mode, action, and redaction counts; original matched values are not recorded there. **SessionEnd timeout:** Claude Code defaults to a short timeout for `SessionEnd` hooks. The hook **returns immediately** and uploads in a **detached worker process**, so large transcripts still upload without blocking exit. If you ever switch to a synchronous upload inside the hook, raise `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` (see [Claude Code hooks – SessionEnd](https://code.claude.com/docs/en/hooks#sessionend)). @@ -333,15 +374,16 @@ The hook scrubber runs before `SessionEnd`, `PostCompact`, workspace-file writes What you see is often **expected**: -1. **Default HTTP mode** — There are **no** PowerMem MCP tools during chat, so Claude does **not** call `/mcp` on each message. **`POST /api/v1/memories`** (writes) still come from **`SessionEnd`** / **`PostCompact`**, not every reply. By default, **`POST /api/v1/memories/search`** runs **on each user message** via `UserPromptSubmit`; set **`POWERMEM_PROMPT_SEARCH=0`** to turn that off. -2. **Not every hook is per-turn** — `SessionEnd` runs when the **session ends** (quit, `/clear`, `/resume` switch, etc.). `PostCompact` runs after **manual or auto compact**, not after every reply. +1. **Default HTTP mode** — There are **no** PowerMem MCP tools during chat, so Claude does **not** call `/mcp` on each message. **`POST /api/v1/memories/search`** runs on each user message via `UserPromptSubmit` by default; set **`POWERMEM_PROMPT_SEARCH=0`** to turn that off. +2. **Write hooks are event-driven** — **`POST /api/v1/memories`** writes can come from `SessionEnd`, `PostCompact`, `PreCompact`, default-on tool success/failure captures, and default-on subagent/task lifecycle events. They still do not run after every assistant reply unless you opt into `Stop` rollups with **`POWERMEM_CAPTURE_STOP_ROLLUP=1`**. 3. **Those GETs** (`/system/status`, `/memories/stats`, …) usually come from another client (e.g. **PowerMem VS Code extension** dashboard), not from Claude Code hooks. **How to verify hooks:** -- **End the Claude Code session** (exit the CLI session that used `--plugin-dir`), then check server logs for **`POST /api/v1/memories`** (the worker runs shortly after exit). -- Or trigger **`/compact`** (or wait for auto-compact) and look for a compact-summary write. -- In Claude Code, type **`/hooks`** and confirm `UserPromptSubmit` (if present) / `SessionEnd` / `PostCompact` list this plugin’s command (see [hooks menu](https://code.claude.com/docs/en/hooks#the-hooks-menu)). +- **End the Claude Code session** (exit the CLI session that used `--plugin-dir`), then check server logs for a `SessionEnd` **`POST /api/v1/memories`** write (the worker runs shortly after exit). +- Or trigger **`/compact`** (or wait for auto-compact) and look for `PreCompact` / `PostCompact` writes. +- Run a high-signal tool such as `Bash`, `Write`, or `Edit` and look for `PostToolUse` or `PostToolUseFailure` writes. +- In Claude Code, type **`/hooks`** and confirm the hook events in the table above list this plugin’s command (see [hooks menu](https://code.claude.com/docs/en/hooks#the-hooks-menu)). **If you want traffic during the conversation:** @@ -366,7 +408,7 @@ See [watcher/README.md](https://github.com/oceanbase/powermem/blob/main/apps/cla - **Default (HTTP mode):** Hooks capture to REST automatically; no PowerMem tools in chat. **Per-prompt semantic retrieval is on by default** (see [Seamless recording](#seamless-recording-hooks--http-api)); set **`POWERMEM_PROMPT_SEARCH=0`** to disable. - **MCP mode:** Run `apply-connection-mode.sh mcp`, then PowerMem tools appear; use **/memory-powermem:remember** / **recall** with real tool backing. Per-prompt injection stays **on by default**; set **`POWERMEM_PROMPT_SEARCH=0`** if you only want explicit MCP tool use. -- In **both** modes, transcript/compact hooks write to REST (`POWERMEM_BASE_URL`, default `http://localhost:8848`) without the model calling tools. +- In **both** modes, event-driven write hooks send session, compact, tool, and lifecycle memories to REST (`POWERMEM_BASE_URL`, default `http://localhost:8848`) without the model calling tools. ## Links diff --git a/tests/regression/fixtures/claude_hook/payloads/post_tool_use_agent.json b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_agent.json new file mode 100644 index 000000000..a6f179d8e --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_agent.json @@ -0,0 +1,27 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "session-agent-1041", + "cwd": "/workspace/project", + "tool_name": "Agent", + "tool_use_id": "tool-agent-1", + "tool_input": { + "agent_type": "reviewer", + "description": "Review the Claude Code hook event capture.", + "prompt": "Check Agent and SubagentStop payloads without leaking Bearer sentinelsecret1049." + }, + "tool_response": { + "agentId": "agent-response-1045", + "agentType": "reviewer", + "status": "completed", + "content": [ + { + "type": "text", + "text": "Review completed with no blockers and Bearer redactme1049 scrubbed." + } + ], + "usage": { + "input_tokens": 100, + "output_tokens": 42 + } + } +} diff --git a/tests/regression/fixtures/claude_hook/payloads/post_tool_use_bash.json b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_bash.json new file mode 100644 index 000000000..34324b2e2 --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_bash.json @@ -0,0 +1,16 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "session-tool-1041", + "cwd": "/workspace/project", + "tool_name": "Bash", + "tool_use_id": "tool-bash-1", + "duration_ms": 1234, + "tool_input": { + "command": "pytest tests/regression/test_claude_hook_no_llm.py --token Bearer sentinelsecret1049" + }, + "tool_response": { + "exit_code": 0, + "stdout": "all tests passed with Bearer redactme1049", + "stderr": "" + } +} diff --git a/tests/regression/fixtures/claude_hook/payloads/post_tool_use_failure_bash.json b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_failure_bash.json new file mode 100644 index 000000000..93b26cc2c --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_failure_bash.json @@ -0,0 +1,17 @@ +{ + "hook_event_name": "PostToolUseFailure", + "session_id": "session-failure-1042", + "cwd": "/workspace/powermem", + "tool_name": "Bash", + "tool_use_id": "tool-failure-1042", + "duration_ms": 912, + "tool_input": { + "command": "pytest tests/unit --token Bearer sentinelsecret1049" + }, + "tool_response": { + "exit_code": 2, + "stderr": "pytest failed with Bearer sentinelsecret1049" + }, + "error_type": "non_zero_exit", + "error_message": "pytest failed with Bearer sentinelsecret1049" +} diff --git a/tests/regression/fixtures/claude_hook/payloads/post_tool_use_write.json b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_write.json new file mode 100644 index 000000000..f24e0fc85 --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/post_tool_use_write.json @@ -0,0 +1,14 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "session-write-1041", + "cwd": "/workspace/project", + "tool_name": "Write", + "tool_use_id": "tool-write-1", + "tool_input": { + "file_path": "src/new_feature.py", + "content": "def leaked_token():\n return 'Bearer sentinelsecret1049'\n" + }, + "tool_response": { + "message": "Updated src/new_feature.py with Bearer redactme1049 scrubbed." + } +} diff --git a/tests/regression/fixtures/claude_hook/payloads/pre_compact.json b/tests/regression/fixtures/claude_hook/payloads/pre_compact.json new file mode 100644 index 000000000..67b833772 --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/pre_compact.json @@ -0,0 +1,8 @@ +{ + "hook_event_name": "PreCompact", + "session_id": "session-precompact-1044", + "cwd": "/workspace/project", + "trigger": "auto", + "custom_instructions": "Preserve the latest implementation plan.", + "transcript_path": "__TRANSCRIPT_PATH__" +} diff --git a/tests/regression/fixtures/claude_hook/payloads/session_start.json b/tests/regression/fixtures/claude_hook/payloads/session_start.json new file mode 100644 index 000000000..bd1d63165 --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/session_start.json @@ -0,0 +1,8 @@ +{ + "hook_event_name": "SessionStart", + "session_id": "session-start-1040", + "cwd": "/workspace/powermem", + "session_title": "Hook session bootstrap", + "source": "startup", + "agent_type": "coding-agent" +} diff --git a/tests/regression/fixtures/claude_hook/payloads/stop_rollup.json b/tests/regression/fixtures/claude_hook/payloads/stop_rollup.json new file mode 100644 index 000000000..a9d56401d --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/stop_rollup.json @@ -0,0 +1,13 @@ +{ + "hook_event_name": "Stop", + "session_id": "session-stop-1043", + "cwd": "/workspace/powermem", + "last_assistant_message": "Implemented hook coverage and ran tests with Bearer sentinelsecret1049", + "changed_files": [ + "apps/claude-code-plugin/cmd/powermem-hook/main.go" + ], + "background_tasks": [ + "none" + ], + "stop_hook_active": false +} diff --git a/tests/regression/fixtures/claude_hook/payloads/subagent_stop.json b/tests/regression/fixtures/claude_hook/payloads/subagent_stop.json new file mode 100644 index 000000000..9e37f328b --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/subagent_stop.json @@ -0,0 +1,15 @@ +{ + "hook_event_name": "SubagentStop", + "session_id": "session-lifecycle-1045", + "cwd": "/workspace/project", + "transcript_path": "/workspace/project/parent-transcript.jsonl", + "agent_transcript_path": "/workspace/project/agent-transcript.jsonl", + "agent_id": "agent-1045", + "agent_type": "reviewer", + "status": "completed", + "usage": { + "input_tokens": 100, + "output_tokens": 42 + }, + "result": "Subagent completed without leaking Bearer sentinelsecret1049." +} diff --git a/tests/regression/fixtures/claude_hook/payloads/task_completed.json b/tests/regression/fixtures/claude_hook/payloads/task_completed.json new file mode 100644 index 000000000..f5fdc4fe3 --- /dev/null +++ b/tests/regression/fixtures/claude_hook/payloads/task_completed.json @@ -0,0 +1,12 @@ +{ + "hook_event_name": "TaskCompleted", + "session_id": "session-task-1045", + "cwd": "/workspace/project", + "transcript_path": "/workspace/project/parent-transcript.jsonl", + "task_id": "task-1045", + "task_subject": "Review Claude hook lifecycle capture", + "task_description": "Verify task lifecycle fields without leaking Bearer sentinelsecret1049.", + "teammate_name": "implementer", + "team_name": "session-team-1045", + "status": "completed" +} diff --git a/tests/regression/test_claude_hook_no_llm.py b/tests/regression/test_claude_hook_no_llm.py index b46972dad..9ba0ae00d 100644 --- a/tests/regression/test_claude_hook_no_llm.py +++ b/tests/regression/test_claude_hook_no_llm.py @@ -204,7 +204,6 @@ def hook_env(self, tmp_path: Path, **overrides: str) -> dict[str, str]: "POWERMEM_INFER_COMPACT": "0", "POWERMEM_INFER_FILE": "0", "POWERMEM_PROMPT_SEARCH": "1", - "POWERMEM_HOOK_SCRUB": "1", "POWERMEM_DATA_DIR": str(tmp_path / "powermem-data"), "POWERMEM_USER_ID": "hook-user", "POWERMEM_AGENT_ID": "hook-agent", @@ -325,6 +324,42 @@ def test_user_prompt_submit_can_disable_search(self) -> None: self.assert_no_request("/api/v1/memories/search") self.assert_no_sentinel(result.stdout, result.stderr) + def test_session_start_searches_and_injects_context(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + payload = load_fixture_json("payloads/session_start.json") + result = self.run_hook(payload, Path(raw_tmp)) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories/search") + + self.assertEqual(request.headers.get("x-api-key"), "hook-api-key") + self.assertEqual(request.body["user_id"], "hook-user") + self.assertEqual(request.body["agent_id"], "hook-agent") + self.assertEqual(request.body["limit"], 6) + self.assertIn("session_title: Hook session bootstrap", request.body["query"]) + self.assertIn("cwd: powermem", request.body["query"]) + self.assertNotIn("/workspace/powermem", request.body["query"]) + + output = json.loads(result.stdout) + hook_output = output["hookSpecificOutput"] + self.assertEqual(hook_output["hookEventName"], "SessionStart") + self.assertIn("isolated hook regression suite", hook_output["additionalContext"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_session_start_can_disable_search(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + payload = load_fixture_json("payloads/session_start.json") + result = self.run_hook( + payload, + Path(raw_tmp), + POWERMEM_SESSION_START_SEARCH="0", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + self.assert_no_request("/api/v1/memories/search") + self.assert_no_sentinel(result.stdout, result.stderr) + def test_session_end_posts_transcript_and_ignores_bad_transcripts(self) -> None: with tempfile.TemporaryDirectory() as raw_tmp: tmp_path = Path(raw_tmp) @@ -416,6 +451,307 @@ def test_top_level_identity_fields_are_scrubbed(self) -> None: self.assertNotEqual(request.body["user_id"], f"user-{FAKE_TOKEN}") self.assertNotEqual(request.body["agent_id"], f"agent-{SENTINEL}") + def test_post_tool_use_bash_posts_structured_scrubbed_event(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/post_tool_use_bash.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="post-tool-use") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-tool-1041") + self.assertEqual(metadata["event_name"], "PostToolUse") + self.assertEqual(metadata["event_id"], "claude-code:session-tool-1041:tool-bash-1") + self.assertEqual(metadata["tool_name"], "Bash") + self.assertEqual(metadata["tool_use_id"], "tool-bash-1") + self.assertEqual(metadata["command_class"], "test") + self.assertEqual(metadata["exit_code"], 0) + self.assertIn("Input summary", request.body["content"]) + self.assertIn("Response summary", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_post_tool_use_agent_preserves_response_agent_link(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/post_tool_use_agent.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="post-tool-use") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(metadata["tool_name"], "Agent") + self.assertEqual(metadata["tool_use_id"], "tool-agent-1") + self.assertEqual(metadata["agent_id"], "agent-response-1045") + self.assertEqual(metadata["agent_type"], "reviewer") + self.assertEqual(metadata["agent_status"], "completed") + self.assertIn("Review completed", metadata["response_summary"]) + self.assertIn("Review completed", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_post_tool_use_write_summarizes_paths_without_content_leak(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/post_tool_use_write.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="post-tool-use") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertEqual(metadata["tool_name"], "Write") + self.assertEqual(metadata["affected_paths"], ["src/new_feature.py"]) + self.assertIn("content_chars=", metadata["input_summary"]) + self.assertNotIn("def leaked_token", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_post_tool_use_wildcard_unknown_tool_records_shape_only(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = { + "hook_event_name": "PostToolUse", + "session_id": "session-tool-unknown", + "cwd": "/workspace/project", + "tool_name": "UnlistedTool", + "tool_use_id": "tool-unknown-1", + "tool_input": ["unexpected", {"token": SENTINEL}], + "tool_response": ["ok", FAKE_TOKEN], + } + result = self.run_hook( + payload, + tmp_path, + POWERMEM_TOOL_SUCCESS_INCLUDE="*", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="post-tool-use") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertEqual(metadata["tool_name"], "UnlistedTool") + self.assertEqual(metadata["input_summary"], "shape=array[2]") + self.assertEqual(metadata["response_summary"], "shape=array[2]") + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_post_tool_use_exclude_wins_and_capture_can_disable(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/post_tool_use_bash.json") + + result = self.run_hook( + payload, + tmp_path, + POWERMEM_TOOL_SUCCESS_INCLUDE="Bash,Write", + POWERMEM_TOOL_SUCCESS_EXCLUDE="Bash", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.wait_for_no_hook_workers() + self.assert_no_request("/api/v1/memories") + + result = self.run_hook( + payload, + tmp_path, + POWERMEM_CAPTURE_TOOL_SUCCESS="0", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.wait_for_no_hook_workers() + self.assert_no_request("/api/v1/memories") + self.assert_no_sentinel(result.stdout, result.stderr) + + def test_post_tool_use_failure_posts_structured_failure(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/post_tool_use_failure_bash.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request( + "/api/v1/memories", + kind="post-tool-use-failure", + ) + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-failure-1042") + self.assertEqual(metadata["event_name"], "PostToolUseFailure") + self.assertEqual(metadata["tool_name"], "Bash") + self.assertEqual(metadata["tool_use_id"], "tool-failure-1042") + self.assertEqual(metadata["success"], False) + self.assertEqual(metadata["is_interrupt"], False) + self.assertEqual(metadata["error_type"], "non_zero_exit") + self.assertEqual(metadata["command_class"], "test") + self.assertEqual(metadata["exit_code"], 2) + self.assertIn("Error summary", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + self.server.clear() + interrupt_payload = load_fixture_json("payloads/post_tool_use_failure_bash.json") + interrupt_payload["is_interrupt"] = True + interrupt_result = self.run_hook(interrupt_payload, tmp_path) + self.assertEqual(interrupt_result.returncode, 0, interrupt_result.stderr) + self.wait_for_no_hook_workers() + self.assert_no_request("/api/v1/memories") + + def test_stop_rollup_posts_when_enabled_and_skips_recursion(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/stop_rollup.json") + + disabled_result = self.run_hook(payload, tmp_path) + self.assertEqual(disabled_result.returncode, 0, disabled_result.stderr) + self.wait_for_no_hook_workers() + self.assert_no_request("/api/v1/memories") + + enabled_result = self.run_hook( + payload, + tmp_path, + POWERMEM_CAPTURE_STOP_ROLLUP="1", + ) + self.assertEqual(enabled_result.returncode, 0, enabled_result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="stop-rollup") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-stop-1043") + self.assertEqual(metadata["event_name"], "Stop") + self.assertEqual(metadata["session_id"], "session-stop-1043") + self.assertIn("Final assistant message preview", request.body["content"]) + self.assert_no_sentinel(request.body, enabled_result.stdout, enabled_result.stderr) + + self.server.clear() + recursive_payload = load_fixture_json("payloads/stop_rollup.json") + recursive_payload["stop_hook_active"] = True + recursive_result = self.run_hook( + recursive_payload, + tmp_path, + POWERMEM_CAPTURE_STOP_ROLLUP="1", + ) + self.assertEqual(recursive_result.returncode, 0, recursive_result.stderr) + self.wait_for_no_hook_workers() + self.assert_no_request("/api/v1/memories") + + def test_pre_compact_posts_tail_snapshot_with_offsets(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + transcript = tmp_path / "transcript.jsonl" + transcript.write_text( + "\n".join( + [ + json.JSONEncoder().encode( + {"type": "user", "message": {"content": "old context"}} + ), + json.JSONEncoder().encode( + { + "type": "assistant", + "message": { + "content": f"recent implementation plan {SENTINEL}" + }, + } + ), + ] + ) + + "\n", + encoding="utf-8", + ) + payload = load_fixture_json("payloads/pre_compact.json") + payload["transcript_path"] = str(transcript) + + result = self.run_hook( + payload, + tmp_path, + POWERMEM_PRECOMPACT_MAX_CHARS="500", + POWERMEM_PRECOMPACT_TAIL_LINES="1", + ) + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request( + "/api/v1/memories", + kind="pre-compact-snapshot", + ) + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-precompact-1044") + self.assertEqual(metadata["event_name"], "PreCompact") + self.assertEqual(metadata["compact_trigger"], "auto") + self.assertIn("transcript_path_fingerprint", metadata) + self.assertGreater(metadata["end_byte_offset"], metadata["start_byte_offset"]) + self.assertEqual(metadata["max_chars"], 500) + self.assertEqual(metadata["max_lines"], 1) + self.assertIn("recent implementation plan", request.body["content"]) + self.assertNotIn("old context", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_lifecycle_event_uses_event_name_as_kind_source(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/subagent_stop.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="subagent-stop") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-lifecycle-1045") + self.assertEqual(metadata["kind"], "subagent-stop") + self.assertEqual(metadata["event_name"], "SubagentStop") + self.assertEqual(metadata["agent_id"], "agent-1045") + self.assertEqual(metadata["agent_type"], "reviewer") + self.assertNotIn("tool_use_id", metadata) + self.assertNotIn("raw_payload", metadata) + self.assertNotIn("payload_fields", metadata) + self.assertNotIn("payload_summary", metadata) + self.assertEqual( + metadata["agent_transcript_path"], + Path(payload["agent_transcript_path"]).name, + ) + self.assertNotIn("/workspace/project", metadata["agent_transcript_path"]) + self.assertIn("SubagentStop", request.body["content"]) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + + def test_task_lifecycle_records_task_schema_fields(self) -> None: + with tempfile.TemporaryDirectory() as raw_tmp: + tmp_path = Path(raw_tmp) + payload = load_fixture_json("payloads/task_completed.json") + result = self.run_hook(payload, tmp_path) + + self.assertEqual(result.returncode, 0, result.stderr) + request = self.wait_for_request("/api/v1/memories", kind="task-completed") + self.wait_for_no_hook_workers() + + metadata = request.body["metadata"] + self.assertFalse(request.body["infer"]) + self.assertEqual(request.body["run_id"], "session-task-1045") + self.assertEqual(metadata["kind"], "task-completed") + self.assertEqual(metadata["event_name"], "TaskCompleted") + self.assertEqual(metadata["task_id"], "task-1045") + self.assertEqual( + metadata["task_subject"], + "Review Claude hook lifecycle capture", + ) + self.assertIn("Verify task lifecycle fields", metadata["task_description"]) + self.assertEqual(metadata["teammate_name"], "implementer") + self.assertIn("task_subject", request.body["content"]) + self.assertIn("Review Claude hook lifecycle capture", request.body["content"]) + self.assertIn("task_description", request.body["content"]) + self.assertIn("Verify task lifecycle fields", request.body["content"]) + self.assertNotIn("raw_payload", metadata) + self.assertNotIn("payload_fields", metadata) + self.assertNotIn("payload_summary", metadata) + self.assertNotIn("/workspace/project", str(metadata)) + self.assert_no_sentinel(request.body, result.stdout, result.stderr) + if __name__ == "__main__": unittest.main(verbosity=2)