mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2084a28548 | ||
|
|
6541d63f68 | ||
|
|
c331899040 | ||
|
|
99850838e8 | ||
|
|
d8a4939201 | ||
|
|
50f078bc6a | ||
|
|
8ff08732c6 | ||
|
|
433d165f17 | ||
|
|
378e7dc489 | ||
|
|
c2c31c8c5d |
@@ -266,6 +266,9 @@ archives:
|
||||
- src: plugin/hooks/hooks.json
|
||||
dst: hooks
|
||||
strip_parent: true
|
||||
- src: plugin/commands/*
|
||||
dst: commands
|
||||
strip_parent: true
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
|
||||
@@ -138,10 +138,13 @@ install: build stop-worker
|
||||
@# Install to marketplaces directory (for direct installs)
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
||||
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
@# Copy slash commands if they exist
|
||||
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
|
||||
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
|
||||
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
|
||||
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
|
||||
|
||||
@@ -17,11 +17,50 @@ type Input struct {
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
}
|
||||
|
||||
// skipTools lists tools that never produce useful observations.
|
||||
// Skip the HTTP call entirely for these to reduce overhead during heavy tool usage.
|
||||
var skipTools = map[string]bool{
|
||||
// Internal tracking tools (but NOT TodoWrite - it captures planned work)
|
||||
"Task": true,
|
||||
"TaskOutput": true,
|
||||
|
||||
// File discovery tools (just listings, no insights)
|
||||
"Glob": true,
|
||||
"ListDir": true,
|
||||
"LS": true,
|
||||
"KillShell": true,
|
||||
|
||||
// Question/interaction tools (no code insights)
|
||||
"AskUserQuestion": true,
|
||||
|
||||
// Plan mode tools (planning, not execution)
|
||||
"EnterPlanMode": true,
|
||||
"ExitPlanMode": true,
|
||||
|
||||
// Skill/command execution (meta-operations)
|
||||
"Skill": true,
|
||||
"SlashCommand": true,
|
||||
|
||||
// Read is high-volume and rarely produces insights worth the overhead
|
||||
// The processor would skip most reads anyway after filtering
|
||||
"Read": true,
|
||||
|
||||
// Search tools are for finding, not modifying
|
||||
"Grep": true,
|
||||
"WebSearch": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
hooks.RunHook("PostToolUse", handlePostToolUse)
|
||||
}
|
||||
|
||||
func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
// Skip HTTP call entirely for tools that never produce useful observations.
|
||||
// This significantly reduces overhead during heavy tool usage.
|
||||
if skipTools[input.ToolName] {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
|
||||
|
||||
// Send observation to worker
|
||||
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/sugarme/tokenizer v0.3.0
|
||||
github.com/yalue/onnxruntime_go v1.21.0
|
||||
github.com/yalue/onnxruntime_go v1.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +28,6 @@ require (
|
||||
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -44,15 +44,15 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
||||
github.com/yalue/onnxruntime_go v1.21.0 h1:DdtvfY7OP5gR8mwPDqAOAQckf+KcI30hPNJL8hQaYWI=
|
||||
github.com/yalue/onnxruntime_go v1.21.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
|
||||
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -272,6 +272,17 @@ var Migrations = []Migration{
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 18,
|
||||
Name: "user_prompts_unique_constraint",
|
||||
SQL: `
|
||||
-- Add unique constraint to prevent duplicate prompts
|
||||
-- This fixes a bug where the user-prompt hook could fire multiple times
|
||||
-- creating duplicate prompt records with incrementing numbers
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_prompts_session_number_unique
|
||||
ON user_prompts(claude_session_id, prompt_number);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
// MigrationManager handles database schema migrations.
|
||||
|
||||
@@ -32,11 +32,15 @@ func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
||||
}
|
||||
|
||||
// SaveUserPromptWithMatches saves a user prompt with matched observation count.
|
||||
// Uses INSERT OR IGNORE to be idempotent - duplicate (session, prompt_number) pairs are silently ignored.
|
||||
// This prevents duplicate prompts when the user-prompt hook fires multiple times.
|
||||
func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Use INSERT OR IGNORE for idempotency - if (claude_session_id, prompt_number) already exists,
|
||||
// the insert is silently ignored. This handles concurrent/duplicate hook invocations.
|
||||
const query = `
|
||||
INSERT INTO user_prompts
|
||||
INSERT OR IGNORE INTO user_prompts
|
||||
(claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
@@ -51,6 +55,17 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
// If id is 0, the insert was ignored (duplicate) - fetch the existing ID
|
||||
if id == 0 {
|
||||
const selectQuery = `SELECT id FROM user_prompts WHERE claude_session_id = ? AND prompt_number = ?`
|
||||
row := s.store.QueryRowContext(ctx, selectQuery, claudeSessionID, promptNumber)
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Return existing ID without triggering cleanup (already handled when first inserted)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Cleanup old prompts beyond the global limit (async to not block handler)
|
||||
go func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -184,6 +199,31 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([
|
||||
return scanPromptWithSessionRows(rows)
|
||||
}
|
||||
|
||||
// FindRecentPromptByText finds a prompt with the same text for a session within the last few seconds.
|
||||
// This is used to detect duplicate hook invocations.
|
||||
// Returns (promptID, promptNumber, found).
|
||||
func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) {
|
||||
// Look for an existing prompt with the same text within the time window
|
||||
// This catches duplicate hook invocations that happen in quick succession
|
||||
const query = `
|
||||
SELECT id, prompt_number FROM user_prompts
|
||||
WHERE claude_session_id = ? AND prompt_text = ?
|
||||
AND created_at_epoch > ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
cutoff := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
|
||||
|
||||
var id int64
|
||||
var promptNumber int
|
||||
err := s.store.QueryRowContext(ctx, query, claudeSessionID, promptText, cutoff).Scan(&id, &promptNumber)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return id, promptNumber, true
|
||||
}
|
||||
|
||||
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
|
||||
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
const query = `
|
||||
|
||||
+114
-53
@@ -27,7 +27,7 @@ const (
|
||||
GitHubRepo = "lukaszraczylo/claude-mnemonic"
|
||||
ReleasesAPI = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest"
|
||||
CheckInterval = 24 * time.Hour
|
||||
MaxExtractedSize = 100 * 1024 * 1024 // 100MB max per extracted file
|
||||
MaxExtractedSize = 250 * 1024 * 1024 // 250MB max per extracted file
|
||||
RestartDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
@@ -49,22 +49,36 @@ type Asset struct {
|
||||
|
||||
// UpdateInfo contains information about an available update.
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
ChecksumsURL string `json:"checksums_url,omitempty"`
|
||||
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
ChecksumsURL string `json:"checksums_url,omitempty"`
|
||||
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
|
||||
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
||||
}
|
||||
|
||||
// InstallScriptURL is the URL to the remote installation script.
|
||||
const InstallScriptURL = "https://raw.githubusercontent.com/" + GitHubRepo + "/main/scripts/install.sh"
|
||||
|
||||
// GetManualUpdateCommand returns the curl command for manual update.
|
||||
// If version is empty, it installs the latest version.
|
||||
func GetManualUpdateCommand(version string) string {
|
||||
if version == "" {
|
||||
return fmt.Sprintf("curl -sSL %s | bash", InstallScriptURL)
|
||||
}
|
||||
return fmt.Sprintf("curl -sSL %s | bash -s -- %s", InstallScriptURL, version)
|
||||
}
|
||||
|
||||
// UpdateStatus represents the current update status.
|
||||
type UpdateStatus struct {
|
||||
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails
|
||||
}
|
||||
|
||||
// Updater handles self-updates.
|
||||
@@ -112,9 +126,10 @@ func (u *Updater) setError(err error) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.status = UpdateStatus{
|
||||
State: "error",
|
||||
Message: "Update failed",
|
||||
Error: err.Error(),
|
||||
State: "error",
|
||||
Message: "Update failed",
|
||||
Error: err.Error(),
|
||||
ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +184,9 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
|
||||
// Compare versions
|
||||
info.Available = isNewerVersion(info.LatestVersion, u.currentVersion)
|
||||
|
||||
// Always include manual update command as an alternative option
|
||||
info.ManualUpdateCommand = GetManualUpdateCommand("v" + info.LatestVersion)
|
||||
|
||||
if info.Available {
|
||||
// Find download URLs for current platform
|
||||
platform := getPlatform()
|
||||
@@ -451,57 +469,100 @@ func (u *Updater) extractTarGz(archivePath, destDir string) error {
|
||||
}
|
||||
|
||||
func (u *Updater) replaceBinaries(extractDir string) error {
|
||||
// Files to replace
|
||||
binaries := []struct {
|
||||
src string
|
||||
dest string
|
||||
}{
|
||||
{"worker", filepath.Join(u.installDir, "worker")},
|
||||
{"mcp-server", filepath.Join(u.installDir, "mcp-server")},
|
||||
{"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")},
|
||||
{"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")},
|
||||
{"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")},
|
||||
{"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")},
|
||||
{"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")},
|
||||
// Binary files to replace
|
||||
binaryFiles := []string{
|
||||
"worker",
|
||||
"mcp-server",
|
||||
"hooks/session-start",
|
||||
"hooks/user-prompt",
|
||||
"hooks/post-tool-use",
|
||||
"hooks/stop",
|
||||
"hooks/subagent-stop",
|
||||
"hooks/statusline",
|
||||
}
|
||||
|
||||
for _, b := range binaries {
|
||||
src := filepath.Join(extractDir, b.src)
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
continue // Skip if not in archive
|
||||
}
|
||||
// Get all install directories (marketplaces and cache directories)
|
||||
installDirs := u.getInstallDirectories()
|
||||
|
||||
// Backup existing binary
|
||||
if _, err := os.Stat(b.dest); err == nil {
|
||||
backup := b.dest + ".bak"
|
||||
if err := os.Rename(b.dest, backup); err != nil {
|
||||
return fmt.Errorf("failed to backup %s: %w", b.dest, err)
|
||||
for _, installDir := range installDirs {
|
||||
log.Debug().Str("dir", installDir).Msg("Installing binaries to directory")
|
||||
|
||||
for _, binaryFile := range binaryFiles {
|
||||
src := filepath.Join(extractDir, binaryFile)
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
continue // Skip if not in archive
|
||||
}
|
||||
defer func(backup, dest string) {
|
||||
// Clean up backup on success, restore on failure
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
_ = os.Remove(backup)
|
||||
|
||||
dest := filepath.Join(installDir, binaryFile)
|
||||
|
||||
// Backup existing binary
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
backup := dest + ".bak"
|
||||
if err := os.Rename(dest, backup); err != nil {
|
||||
log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway")
|
||||
} else {
|
||||
_ = os.Rename(backup, dest)
|
||||
defer func(backup, dest string) {
|
||||
// Clean up backup on success, restore on failure
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
_ = os.Remove(backup)
|
||||
} else {
|
||||
_ = os.Rename(backup, dest)
|
||||
}
|
||||
}(backup, dest)
|
||||
}
|
||||
}(backup, b.dest)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy new binary
|
||||
if err := copyFile(src, b.dest); err != nil {
|
||||
return fmt.Errorf("failed to install %s: %w", b.dest, err)
|
||||
}
|
||||
// Copy new binary
|
||||
if err := copyFile(src, dest); err != nil {
|
||||
return fmt.Errorf("failed to install %s: %w", dest, err)
|
||||
}
|
||||
|
||||
// Make executable
|
||||
// #nosec G302 -- executables require 0755 permissions
|
||||
if err := os.Chmod(b.dest, 0755); err != nil {
|
||||
return fmt.Errorf("failed to chmod %s: %w", b.dest, err)
|
||||
// Make executable
|
||||
// #nosec G302 -- executables require 0755 permissions
|
||||
if err := os.Chmod(dest, 0755); err != nil {
|
||||
return fmt.Errorf("failed to chmod %s: %w", dest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getInstallDirectories returns all directories where binaries should be installed.
|
||||
// This includes the marketplaces directory and any cache directories.
|
||||
func (u *Updater) getInstallDirectories() []string {
|
||||
dirs := []string{u.installDir}
|
||||
|
||||
// Also check cache directories where Claude Code looks for plugins
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return dirs
|
||||
}
|
||||
|
||||
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
|
||||
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
|
||||
entries, err := os.ReadDir(cacheBase)
|
||||
if err != nil {
|
||||
return dirs
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
cacheDir := filepath.Join(cacheBase, entry.Name())
|
||||
// Only add if it's different from installDir and contains a worker binary
|
||||
if cacheDir != u.installDir {
|
||||
workerPath := filepath.Join(cacheDir, "worker")
|
||||
if _, err := os.Stat(workerPath); err == nil {
|
||||
dirs = append(dirs, cacheDir)
|
||||
log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
|
||||
|
||||
@@ -159,7 +159,13 @@ type SessionInitResponse struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
|
||||
// If the same prompt text is seen within this window, it's considered a duplicate hook invocation.
|
||||
const DuplicatePromptWindowSeconds = 10
|
||||
|
||||
// handleSessionInit handles session initialization from user-prompt hook.
|
||||
// This handler is idempotent - duplicate requests within a short time window
|
||||
// return the existing prompt data without creating duplicates.
|
||||
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
|
||||
var req SessionInitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -182,8 +188,31 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean prompt and create session
|
||||
// Clean prompt
|
||||
cleanedPrompt := privacy.Clean(req.Prompt)
|
||||
|
||||
// DUPLICATE DETECTION: Check if this exact prompt was already saved recently.
|
||||
// This prevents the bug where the hook fires multiple times for the same user action,
|
||||
// creating many duplicate prompts with incrementing numbers.
|
||||
if existingID, existingNum, found := s.promptStore.FindRecentPromptByText(r.Context(), req.ClaudeSessionID, cleanedPrompt, DuplicatePromptWindowSeconds); found {
|
||||
// Get or create session (idempotent)
|
||||
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
||||
|
||||
log.Debug().
|
||||
Int64("sessionId", sessionID).
|
||||
Int("promptNumber", existingNum).
|
||||
Int64("promptId", existingID).
|
||||
Msg("Duplicate prompt detected - returning existing")
|
||||
|
||||
// Return existing prompt data without incrementing or saving again
|
||||
writeJSON(w, SessionInitResponse{
|
||||
SessionDBID: sessionID,
|
||||
PromptNumber: existingNum,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create session (idempotent)
|
||||
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -995,7 +1024,7 @@ func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateRestart restarts the worker with the new binary.
|
||||
// handleUpdateRestart restarts the worker with the new binary (after update).
|
||||
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.updater.GetStatus()
|
||||
if status.State != "done" {
|
||||
@@ -1021,3 +1050,29 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleRestart restarts the worker process (general restart, not tied to update).
|
||||
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info().Msg("Manual restart requested via API")
|
||||
|
||||
// Send response before restarting
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Restarting worker...",
|
||||
"version": s.version,
|
||||
})
|
||||
|
||||
// Flush the response
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// Restart in background after response is sent
|
||||
go func() {
|
||||
// Small delay to ensure response is sent
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := s.updater.Restart(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to restart worker")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -642,25 +642,57 @@ Your response:`,
|
||||
// rather than actual user work. These summaries should be filtered out.
|
||||
func isSelfReferentialSummary(summary *models.ParsedSummary) bool {
|
||||
// Combine all summary fields for checking
|
||||
content := strings.ToLower(summary.Request + " " + summary.Completed + " " + summary.Learned + " " + summary.NextSteps)
|
||||
content := strings.ToLower(summary.Request + " " + summary.Completed + " " + summary.Learned + " " + summary.NextSteps + " " + summary.Investigated + " " + summary.Notes)
|
||||
|
||||
// Indicators that the summary is about the memory agent, not user work
|
||||
selfReferentialPhrases := []string{
|
||||
// Agent references
|
||||
"memory extraction",
|
||||
"memory agent",
|
||||
"extraction agent",
|
||||
"hook execution",
|
||||
"hook mechanism",
|
||||
// Session meta-state
|
||||
"session initialization",
|
||||
"session setup",
|
||||
"session has just started",
|
||||
"session just started",
|
||||
"agent initialization",
|
||||
"no technical learnings",
|
||||
"no code or project work",
|
||||
// Waiting states
|
||||
"waiting for the user",
|
||||
"waiting for user",
|
||||
"awaiting actual",
|
||||
"awaiting claude code",
|
||||
"awaiting tool",
|
||||
"awaiting user",
|
||||
// Meta checkpoint references
|
||||
"progress checkpoint",
|
||||
"checkpoint request",
|
||||
// Common no-work phrases
|
||||
"no work has been completed",
|
||||
"no work completed",
|
||||
"no work done",
|
||||
"no actual work",
|
||||
"nothing has been completed",
|
||||
"nothing completed",
|
||||
// Role/guideline parroting
|
||||
"role definition",
|
||||
"operational guidelines",
|
||||
"providing role",
|
||||
"providing guidelines",
|
||||
// System prompt echoes
|
||||
"extract meaningful observations",
|
||||
"meaningful learnings",
|
||||
"analyze tool executions",
|
||||
"observations for future sessions",
|
||||
// Empty session indicators
|
||||
"empty session",
|
||||
"no substantive work",
|
||||
"no meaningful work",
|
||||
"just beginning",
|
||||
"just begun",
|
||||
}
|
||||
|
||||
matchCount := 0
|
||||
@@ -685,20 +717,33 @@ func hasMeaningfulContent(assistantMsg string) bool {
|
||||
|
||||
lowerMsg := strings.ToLower(assistantMsg)
|
||||
|
||||
// Skip messages that are primarily about system/hook status
|
||||
// Skip messages that are primarily about system/hook status or meta-instructions
|
||||
skipIndicators := []string{
|
||||
// System/hook markers
|
||||
"hook success",
|
||||
"callback hook",
|
||||
"session start",
|
||||
"sessionstart",
|
||||
"system-reminder",
|
||||
// Agent self-references
|
||||
"memory extraction agent",
|
||||
"memory agent",
|
||||
"extraction agent",
|
||||
// No-work indicators
|
||||
"no technical learnings",
|
||||
"waiting for",
|
||||
"waiting to",
|
||||
"no code or project work",
|
||||
"no substantive",
|
||||
"no work has been completed",
|
||||
"no work done",
|
||||
"awaiting tool",
|
||||
"awaiting user",
|
||||
// Meta-instruction echoes
|
||||
"role definition",
|
||||
"operational guidelines",
|
||||
"analyze tool executions",
|
||||
"extract meaningful observations",
|
||||
}
|
||||
|
||||
skipCount := 0
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
func TestIsSelfReferentialSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
summary *models.ParsedSummary
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "meta summary about memory agent role",
|
||||
summary: &models.ParsedSummary{
|
||||
Request: "Memory extraction agent role - analyze tool executions and extract meaningful observations for future sessions",
|
||||
Completed: "No work has been completed yet. The session has just started with the user providing role definition and operational guidelines.",
|
||||
Learned: "The system expects observations to be created from meaningful learnings during Claude Code sessions, with focus on decisions, bugs fixed, patterns discovered, project structure changes, and code modifications.",
|
||||
NextSteps: "Awaiting tool executions or user requests that contain actual work performed in a Claude Code session.",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "legitimate summary about code changes",
|
||||
summary: &models.ParsedSummary{
|
||||
Request: "Fix authentication bug in login handler",
|
||||
Completed: "Updated the auth middleware to properly validate JWT tokens and fixed the session expiry check.",
|
||||
Learned: "The JWT library requires explicit algorithm validation to prevent token substitution attacks.",
|
||||
NextSteps: "Add unit tests for the authentication flow.",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "awaiting user summary",
|
||||
summary: &models.ParsedSummary{
|
||||
Request: "Session initialization",
|
||||
Completed: "No work completed yet.",
|
||||
Learned: "Awaiting user input to begin work.",
|
||||
NextSteps: "Waiting for the user to provide instructions.",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "summary about refactoring",
|
||||
summary: &models.ParsedSummary{
|
||||
Request: "Refactor database connection pooling",
|
||||
Completed: "Implemented connection pooling using pgxpool with max 10 connections.",
|
||||
Learned: "pgxpool automatically handles connection reuse and health checks.",
|
||||
NextSteps: "Run benchmarks to verify performance improvement.",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "meta summary with extraction agent mention",
|
||||
summary: &models.ParsedSummary{
|
||||
Request: "Extraction agent initialization",
|
||||
Completed: "No substantive work has been done.",
|
||||
Learned: "The memory extraction agent analyzes tool executions.",
|
||||
NextSteps: "Awaiting tool results to extract observations.",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isSelfReferentialSummary(tt.summary)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isSelfReferentialSummary() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasMeaningfulContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "too short content",
|
||||
content: "Hello world",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "meta content about memory agent",
|
||||
content: `This is the memory extraction agent role definition.
|
||||
The system expects you to analyze tool executions and extract meaningful observations.
|
||||
No work has been completed yet. Awaiting tool results from the user's session.`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "legitimate code discussion",
|
||||
content: `I've updated the handler.go file to fix the authentication bug.
|
||||
The function validateToken() was not checking token expiry correctly.
|
||||
I've added a check for exp claim and implemented proper error handling.
|
||||
The changes have been tested and the build passes.`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "hook status messages",
|
||||
content: `SessionStart:Callback hook success: Success
|
||||
The memory agent is waiting for user input.
|
||||
System-reminder about available tools.
|
||||
No substantive work performed yet.`,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := hasMeaningfulContent(tt.content)
|
||||
if result != tt.expected {
|
||||
t.Errorf("hasMeaningfulContent() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -635,6 +635,9 @@ func (s *Service) setupRoutes() {
|
||||
s.router.Get("/api/update/status", s.handleUpdateStatus)
|
||||
s.router.Post("/api/update/restart", s.handleUpdateRestart)
|
||||
|
||||
// General restart endpoint (works before DB is ready)
|
||||
s.router.Post("/api/restart", s.handleRestart)
|
||||
|
||||
// Selfcheck endpoint (works before DB is ready - checks all components)
|
||||
s.router.Get("/api/selfcheck", s.handleSelfCheck)
|
||||
|
||||
|
||||
+10
-10
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"name": "claude-mnemonic",
|
||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.22",
|
||||
"author": {
|
||||
"name": "lukaszraczylo",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
@@ -23,25 +23,25 @@
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"releases": {
|
||||
"latest": "0.6.4",
|
||||
"latest": "0.6.22",
|
||||
"versions": {
|
||||
"0.6.4": {
|
||||
"releaseDate": "2025-12-16",
|
||||
"0.6.22": {
|
||||
"releaseDate": "2025-12-17",
|
||||
"downloads": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_darwin_arm64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.22/claude-mnemonic_0.6.22_darwin_arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "5bb082901e8b4f4751d011675a235a5ed25403204732e09842b5ae75136f65ae"
|
||||
"sha256": "19059371bcdc913c0fca1e42461f8f2477af0419046c5f9f1e521ea101def536"
|
||||
},
|
||||
"linux-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_linux_amd64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.22/claude-mnemonic_0.6.22_linux_amd64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "dee774fbc39e78ee6ff3836403c2901471dd0d417f62e214088f6b8285b3b23d"
|
||||
"sha256": "dfeea1a63ebbb85a363ea59b85349d98dad34353b131aa33ccf3b599ed1a89c8"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_windows_amd64.zip",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.22/claude-mnemonic_0.6.22_windows_amd64.zip",
|
||||
"format": "zip",
|
||||
"sha256": "e3973bc1107f9cc92cf95697c62138b4cab4d0d4e00f836dfc4592ca84bbdab7"
|
||||
"sha256": "1c9357d3655a00ec840783efc5f7f25bbe320ce75b07200e54806f44c8a5d641"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Restart Claude Mnemonic Worker
|
||||
|
||||
Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Call the restart API endpoint using curl:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:37777/api/restart
|
||||
```
|
||||
|
||||
2. Wait a moment for the worker to restart (typically 1-2 seconds)
|
||||
|
||||
3. Verify the worker is running by checking the version:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:37777/api/version
|
||||
```
|
||||
|
||||
4. Report the result to the user, including the version number from the response.
|
||||
|
||||
If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors.
|
||||
@@ -176,6 +176,7 @@ download_release() {
|
||||
info "Installing to ${INSTALL_DIR}..."
|
||||
mkdir -p "$INSTALL_DIR/hooks"
|
||||
mkdir -p "$INSTALL_DIR/.claude-plugin"
|
||||
mkdir -p "$INSTALL_DIR/commands"
|
||||
|
||||
# Copy binaries
|
||||
cp "$tmp_dir/worker" "$INSTALL_DIR/"
|
||||
@@ -185,6 +186,11 @@ download_release() {
|
||||
# Copy plugin configuration
|
||||
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
|
||||
|
||||
# Copy slash commands if they exist in the release
|
||||
if [[ -d "$tmp_dir/commands" ]]; then
|
||||
cp -r "$tmp_dir/commands/"* "$INSTALL_DIR/commands/" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Make binaries executable
|
||||
chmod +x "$INSTALL_DIR/worker"
|
||||
chmod +x "$INSTALL_DIR/mcp-server"
|
||||
@@ -230,6 +236,7 @@ register_plugin() {
|
||||
# Copy files to cache directory
|
||||
mkdir -p "$cache_path/.claude-plugin"
|
||||
mkdir -p "$cache_path/hooks"
|
||||
mkdir -p "$cache_path/commands"
|
||||
cp -r "$INSTALL_DIR/"* "$cache_path/" 2>/dev/null || true
|
||||
|
||||
# Register in installed_plugins.json
|
||||
|
||||
@@ -45,6 +45,7 @@ if command -v jq &> /dev/null; then
|
||||
# Ensure cache directory exists and copy plugin files
|
||||
mkdir -p "$CACHE_PATH/.claude-plugin"
|
||||
mkdir -p "$CACHE_PATH/hooks"
|
||||
mkdir -p "$CACHE_PATH/commands"
|
||||
|
||||
# Copy files from marketplace to cache
|
||||
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.6.5-25-g372942e-dirty",
|
||||
"version": "v0.6.15-2-gd8a4939-dirty",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.6.5-25-g372942e-dirty",
|
||||
"version": "v0.6.15-2-gd8a4939-dirty",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mnemonic-dashboard",
|
||||
"version": "v0.6.5-25-g372942e-dirty",
|
||||
"version": "v0.6.15-2-gd8a4939-dirty",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+25
-1
@@ -7,7 +7,7 @@ import Timeline from '@/components/Timeline.vue'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
|
||||
// Composables
|
||||
const { isConnected, isProcessing, queueDepth } = useSSE()
|
||||
const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE()
|
||||
const { stats } = useStats()
|
||||
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
|
||||
const { health } = useHealth()
|
||||
@@ -33,6 +33,17 @@ const {
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- Reconnection Banner -->
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-if="isReconnecting"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-amber-500/90 backdrop-blur-sm text-black px-4 py-2 text-center text-sm font-medium shadow-lg"
|
||||
>
|
||||
<i class="fas fa-sync-alt fa-spin mr-2" />
|
||||
Connection lost. Reconnecting<span v-if="reconnectCountdown > 0"> in {{ reconnectCountdown }}s</span>...
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Header -->
|
||||
<Header
|
||||
:is-connected="isConnected"
|
||||
@@ -94,3 +105,16 @@ const {
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { SSEEvent } from '@/types'
|
||||
|
||||
// Singleton state - shared across all useSSE() calls
|
||||
const isConnected = ref(false)
|
||||
const isReconnecting = ref(false)
|
||||
const reconnectCountdown = ref(0)
|
||||
const isProcessing = ref(false)
|
||||
const queueDepth = ref(0)
|
||||
const lastEvent = ref<SSEEvent | null>(null)
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
let countdownInterval: number | null = null
|
||||
let connectionCount = 0
|
||||
let reconnectAttempt = 0
|
||||
|
||||
@@ -24,6 +27,28 @@ function getBackoffDelay(): number {
|
||||
return Math.floor(baseDelay + jitter)
|
||||
}
|
||||
|
||||
function startCountdown(delayMs: number) {
|
||||
reconnectCountdown.value = Math.ceil(delayMs / 1000)
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
}
|
||||
countdownInterval = window.setInterval(() => {
|
||||
reconnectCountdown.value = Math.max(0, reconnectCountdown.value - 1)
|
||||
if (reconnectCountdown.value <= 0 && countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
reconnectCountdown.value = 0
|
||||
}
|
||||
|
||||
export function useSSE() {
|
||||
|
||||
const connect = () => {
|
||||
@@ -36,6 +61,8 @@ export function useSSE() {
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
stopCountdown()
|
||||
reconnectAttempt = 0 // Reset backoff on successful connection
|
||||
console.log('[SSE] Connected')
|
||||
}
|
||||
@@ -62,6 +89,7 @@ export function useSSE() {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnected.value = false
|
||||
isReconnecting.value = true
|
||||
eventSource?.close()
|
||||
eventSource = null
|
||||
|
||||
@@ -70,6 +98,7 @@ export function useSSE() {
|
||||
reconnectAttempt++
|
||||
console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`)
|
||||
|
||||
startCountdown(delay)
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
@@ -81,11 +110,13 @@ export function useSSE() {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
stopCountdown()
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
}
|
||||
|
||||
// Handle page unload/refresh to ensure SSE connection is closed immediately
|
||||
@@ -132,6 +163,8 @@ export function useSSE() {
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
reconnectCountdown,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
lastEvent,
|
||||
|
||||
Reference in New Issue
Block a user