Initial commit

This commit is contained in:
2025-12-14 21:59:59 +00:00
commit 9c2a1a795a
126 changed files with 21728 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
// Package main provides the post-tool-use hook entry point.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// Input is the hook input from Claude Code.
type Input struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput interface{} `json:"tool_input"`
ToolResponse interface{} `json:"tool_response"`
ToolUseID string `json:"tool_use_id"`
}
func main() {
// Skip if this is an internal call (from SDK processor)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
hooks.WriteResponse("PostToolUse", true)
return
}
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
hooks.WriteError("PostToolUse", err)
os.Exit(1)
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
hooks.WriteError("PostToolUse", err)
os.Exit(1)
}
// Ensure worker is running
port, err := hooks.EnsureWorkerRunning()
if err != nil {
hooks.WriteError("PostToolUse", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
// Generate project ID from CWD (same logic as user-prompt hook)
project := hooks.ProjectIDWithName(input.CWD)
// Send observation to worker
_, err = hooks.POST(port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": input.SessionID,
"project": project,
"tool_name": input.ToolName,
"tool_input": input.ToolInput,
"tool_response": input.ToolResponse,
"cwd": input.CWD,
})
if err != nil {
hooks.WriteError("PostToolUse", err)
os.Exit(1)
}
hooks.WriteResponse("PostToolUse", true)
}
+171
View File
@@ -0,0 +1,171 @@
// Package main provides the session-start hook entry point.
package main
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// Input is the hook input from Claude Code.
type Input struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
Source string `json:"source"` // "startup", "resume", "clear", "compact"
}
// Observation represents an observation from the API.
type Observation struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Narrative string `json:"narrative"`
Facts []string `json:"facts"`
}
func main() {
// Skip if this is an internal call (from SDK processor)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
hooks.WriteResponse("SessionStart", true)
return
}
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
hooks.WriteError("SessionStart", err)
os.Exit(1)
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
hooks.WriteError("SessionStart", err)
os.Exit(1)
}
// Ensure worker is running
port, err := hooks.EnsureWorkerRunning()
if err != nil {
hooks.WriteError("SessionStart", err)
os.Exit(1)
}
// Generate unique project ID from CWD (dirname_hash format)
project := hooks.ProjectIDWithName(input.CWD)
// Fetch observations for context injection
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
url.QueryEscape(project),
url.QueryEscape(input.CWD))
result, err := hooks.GET(port, endpoint)
if err != nil {
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: context fetch failed: %v\n", err)
hooks.WriteResponse("SessionStart", true)
return
}
// Parse observations from response
obsData, ok := result["observations"].([]interface{})
if !ok || len(obsData) == 0 {
// No observations - just continue normally
hooks.WriteResponse("SessionStart", true)
return
}
// Get full_count from response (how many observations get full detail)
fullCount := 25 // default
if fc, ok := result["full_count"].(float64); ok && fc > 0 {
fullCount = int(fc)
}
// Show count to user via stderr
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Injecting %d observations from project memory (%d detailed, %d condensed)\n",
len(obsData), min(fullCount, len(obsData)), max(0, len(obsData)-fullCount))
// Build context string
contextBuilder := "<claude-mnemonic-context>\n"
contextBuilder += fmt.Sprintf("# Project Memory (%d observations)\n", len(obsData))
contextBuilder += "Use this knowledge to answer questions without re-exploring the codebase.\n\n"
for i, o := range obsData {
obs, ok := o.(map[string]interface{})
if !ok {
continue
}
title := getString(obs, "title")
obsType := getString(obs, "type")
// First `fullCount` observations get full detail, rest are condensed
if i < fullCount {
// Full detail: include narrative and facts
narrative := getString(obs, "narrative")
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
if narrative != "" {
contextBuilder += narrative + "\n"
}
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
for _, f := range facts {
if fact, ok := f.(string); ok && fact != "" {
contextBuilder += fmt.Sprintf("- %s\n", fact)
}
}
}
contextBuilder += "\n"
} else {
// Condensed: just title and subtitle (one line)
subtitle := getString(obs, "subtitle")
if subtitle != "" {
contextBuilder += fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
} else {
contextBuilder += fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
}
}
}
contextBuilder += "</claude-mnemonic-context>\n"
// Output context as JSON with additionalContext field
response := map[string]interface{}{
"continue": true,
"hookSpecificOutput": map[string]interface{}{
"hookEventName": "SessionStart",
"additionalContext": contextBuilder,
},
}
_ = json.NewEncoder(os.Stdout).Encode(response)
os.Exit(0)
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+159
View File
@@ -0,0 +1,159 @@
// Package main provides the stop hook entry point.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// Input is the hook input from Claude Code.
type Input struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
StopHookActive bool `json:"stop_hook_active"`
TranscriptPath string `json:"transcript_path"`
}
// TranscriptMessage represents a message in the transcript JSONL file.
type TranscriptMessage struct {
Type string `json:"type"`
Message struct {
Role string `json:"role"`
Content any `json:"content"` // Can be string or array
} `json:"message"`
}
// extractTextContent extracts text content from message content (handles both string and array formats).
func extractTextContent(content any) string {
switch v := content.(type) {
case string:
return v
case []interface{}:
var texts []string
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
if m["type"] == "text" {
if text, ok := m["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
return ""
}
// parseTranscript reads the transcript file and extracts the last user and assistant messages.
func parseTranscript(path string) (lastUser, lastAssistant string) {
// Expand ~ to home directory
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err == nil {
path = strings.Replace(path, "~", home, 1)
}
}
file, err := os.Open(path) // #nosec G304 -- path is from internal conversation file location
if err != nil {
return "", ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Increase buffer size for large messages
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
var msg TranscriptMessage
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
continue
}
if msg.Type == "message" {
text := extractTextContent(msg.Message.Content)
if text == "" {
continue
}
switch msg.Message.Role {
case "user":
lastUser = text
case "assistant":
lastAssistant = text
}
}
}
return lastUser, lastAssistant
}
func main() {
// Skip if this is an internal call (from SDK processor)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
hooks.WriteResponse("Stop", true)
return
}
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
hooks.WriteError("Stop", err)
os.Exit(1)
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
hooks.WriteError("Stop", err)
os.Exit(1)
}
// Ensure worker is running
port, err := hooks.EnsureWorkerRunning()
if err != nil {
hooks.WriteError("Stop", err)
os.Exit(1)
}
// Find session
result, err := hooks.GET(port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", input.SessionID))
if err != nil || result == nil {
// Session might not exist, that's OK
hooks.WriteResponse("Stop", true)
return
}
sessionID, ok := result["id"].(float64)
if !ok {
hooks.WriteResponse("Stop", true)
return
}
// Parse transcript to get last messages for summary context
lastUser, lastAssistant := "", ""
if input.TranscriptPath != "" {
lastUser, lastAssistant = parseTranscript(input.TranscriptPath)
}
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
// Request summary with message context from transcript
_, err = hooks.POST(port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
"lastUserMessage": lastUser,
"lastAssistantMessage": lastAssistant,
})
if err != nil {
fmt.Fprintf(os.Stderr, "[stop] Warning: summary request failed: %v\n", err)
}
hooks.WriteResponse("Stop", true)
}
+67
View File
@@ -0,0 +1,67 @@
// Package main provides the subagent-stop hook entry point.
// This hook fires when a Task/subagent completes, capturing observations from subagent work.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// Input is the hook input from Claude Code.
type Input struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
StopHookActive bool `json:"stop_hook_active"`
}
func main() {
// Skip if this is an internal call (from SDK processor)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
hooks.WriteResponse("SubagentStop", true)
return
}
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
hooks.WriteError("SubagentStop", err)
os.Exit(1)
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
hooks.WriteError("SubagentStop", err)
os.Exit(1)
}
// Ensure worker is running
port, err := hooks.EnsureWorkerRunning()
if err != nil {
hooks.WriteError("SubagentStop", err)
os.Exit(1)
}
// Generate unique project ID from CWD
project := hooks.ProjectIDWithName(input.CWD)
fmt.Fprintf(os.Stderr, "[subagent-stop] Subagent completed in project %s\n", project)
// Notify worker that a subagent completed
// This can trigger processing of any queued observations from the subagent
_, err = hooks.POST(port, "/api/sessions/subagent-complete", map[string]interface{}{
"claudeSessionId": input.SessionID,
"project": project,
})
if err != nil {
// Non-fatal - just log warning
fmt.Fprintf(os.Stderr, "[subagent-stop] Warning: failed to notify worker: %v\n", err)
}
hooks.WriteResponse("SubagentStop", true)
}
+166
View File
@@ -0,0 +1,166 @@
// Package main provides the user-prompt hook entry point.
package main
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
// Input is the hook input from Claude Code.
type Input struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
PermissionMode string `json:"permission_mode"`
HookEventName string `json:"hook_event_name"`
Prompt string `json:"prompt"`
}
func main() {
// Skip if this is an internal call (from SDK processor)
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
hooks.WriteResponse("UserPromptSubmit", true)
return
}
// Read input from stdin
inputData, err := io.ReadAll(os.Stdin)
if err != nil {
hooks.WriteError("UserPromptSubmit", err)
os.Exit(1)
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
hooks.WriteError("UserPromptSubmit", err)
os.Exit(1)
}
// Ensure worker is running
port, err := hooks.EnsureWorkerRunning()
if err != nil {
hooks.WriteError("UserPromptSubmit", err)
os.Exit(1)
}
// Generate unique project ID from CWD
project := hooks.ProjectIDWithName(input.CWD)
// Search for relevant observations based on the prompt
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
url.QueryEscape(project),
url.QueryEscape(input.Prompt),
url.QueryEscape(input.CWD))
var contextToInject string
var observationCount int
searchResult, _ := hooks.GET(port, searchURL)
if observations, ok := searchResult["observations"].([]interface{}); ok && len(observations) > 0 {
// Limit to top 5 most relevant observations
maxObs := 5
if len(observations) < maxObs {
maxObs = len(observations)
}
observations = observations[:maxObs]
observationCount = len(observations)
// Build context from search results
var contextBuilder string
contextBuilder = "<relevant-memory>\n"
contextBuilder += "# Relevant Knowledge From Previous Sessions\n"
contextBuilder += "IMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
for i, obs := range observations {
if obsMap, ok := obs.(map[string]interface{}); ok {
title := ""
if t, ok := obsMap["title"].(string); ok {
title = t
}
obsType := ""
if t, ok := obsMap["type"].(string); ok {
obsType = t
}
// Start observation block
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
// Add facts first (most concise answers)
if facts, ok := obsMap["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
for _, fact := range facts {
if factStr, ok := fact.(string); ok {
contextBuilder += fmt.Sprintf("- %s\n", factStr)
}
}
contextBuilder += "\n"
}
// Add narrative if present
if narrative, ok := obsMap["narrative"].(string); ok && narrative != "" {
contextBuilder += narrative + "\n\n"
}
}
}
contextBuilder += "</relevant-memory>\n"
contextToInject = contextBuilder
}
// Initialize session with matched observations count
result, err := hooks.POST(port, "/api/sessions/init", map[string]interface{}{
"claudeSessionId": input.SessionID,
"project": project,
"prompt": input.Prompt,
"matchedObservations": observationCount,
})
if err != nil {
hooks.WriteError("UserPromptSubmit", err)
os.Exit(1)
}
// Check if skipped due to privacy
if skipped, ok := result["skipped"].(bool); ok && skipped {
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
hooks.WriteResponse("UserPromptSubmit", true)
return
}
sessionID := int64(result["sessionDbId"].(float64))
promptNumber := int(result["promptNumber"].(float64))
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
// Start SDK agent
_, err = hooks.POST(port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
"userPrompt": input.Prompt,
"promptNumber": promptNumber,
})
if err != nil {
hooks.WriteError("UserPromptSubmit", err)
os.Exit(1)
}
// Output results - stdout with exit 0 adds context to Claude's prompt
if observationCount > 0 {
// Show match count to user via stderr
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Found %d relevant memories for this prompt\n", observationCount)
// Output context as JSON with additionalContext field
response := map[string]interface{}{
"continue": true,
"hookSpecificOutput": map[string]interface{}{
"hookEventName": "UserPromptSubmit",
"additionalContext": contextToInject,
},
}
_ = json.NewEncoder(os.Stdout).Encode(response)
os.Exit(0)
} else {
hooks.WriteResponse("UserPromptSubmit", true)
}
}