Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a16b25275e | |||
| abffce6424 | |||
| c948a7778b | |||
| bd1fe5995f | |||
| 6791069bca | |||
| 3e6add90de | |||
| d3331d1e22 | |||
| bd619229b2 | |||
| 182097ef1c | |||
| 0b7ecedcd7 | |||
| da01e4bba0 | |||
| 7c3bfadd5e | |||
| a8bb625513 |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.8",
|
||||
"version": "9.0.12",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Bugfix Plan: Observer Sessions Authentication Failure
|
||||
|
||||
## Problem Summary
|
||||
|
||||
Observer sessions fail with "Invalid API key · Please run /login" because the `CLAUDE_CONFIG_DIR` environment variable is being set to an isolated directory (`~/.claude-mem/observer-config/`) that lacks authentication credentials.
|
||||
|
||||
## Root Cause
|
||||
|
||||
**File:** `src/services/worker/ProcessRegistry.ts` (lines 207-211)
|
||||
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env,
|
||||
CLAUDE_CONFIG_DIR: OBSERVER_CONFIG_DIR // <-- This isolates auth credentials!
|
||||
};
|
||||
```
|
||||
|
||||
This was added in Issue #832 to prevent observer sessions from polluting the `claude --resume` list. However, it also isolates the authentication credentials, breaking the SDK's ability to authenticate with the Anthropic API.
|
||||
|
||||
## Evidence
|
||||
|
||||
1. Running Claude with alternate config dir reproduces the error:
|
||||
```bash
|
||||
CLAUDE_CONFIG_DIR=/tmp/test-claude claude --print "hello"
|
||||
# Output: Invalid API key · Please run /login
|
||||
```
|
||||
|
||||
2. The observer config directory exists but only has cached feature flags, no authentication:
|
||||
- `~/.claude-mem/observer-config/.claude.json` - feature flags only
|
||||
- No credentials copied from main `~/.claude/` directory
|
||||
|
||||
## Solution
|
||||
|
||||
The fix must allow authentication while still isolating session history. Claude Code stores different data types in `CLAUDE_CONFIG_DIR`:
|
||||
- Authentication credentials (needed)
|
||||
- Session history/resume list (should be isolated)
|
||||
- Feature flags and settings (can be shared or isolated)
|
||||
|
||||
**Approach:** Do NOT override `CLAUDE_CONFIG_DIR`. Instead, find an alternative solution for Issue #832.
|
||||
|
||||
### Alternative Approaches for Session Isolation
|
||||
|
||||
1. **Use `--no-resume` flag** (if SDK supports it) - Prevent observer sessions from being resumable
|
||||
2. **Accept pollution** - Observer sessions in resume list may be acceptable tradeoff
|
||||
3. **Post-hoc cleanup** - Clean up observer session entries from history after completion
|
||||
4. **SDK parameter** - Check if SDK has a session isolation option that doesn't affect auth
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Documentation Discovery
|
||||
|
||||
### Objective
|
||||
Understand SDK options for session isolation without breaking authentication.
|
||||
|
||||
### Tasks
|
||||
1. Read SDK documentation/source for:
|
||||
- Available `query()` options
|
||||
- Session isolation mechanisms
|
||||
- Authentication handling
|
||||
|
||||
2. Read Issue #832 context:
|
||||
- What was the original problem?
|
||||
- How bad was the pollution?
|
||||
- Are there alternative solutions mentioned?
|
||||
|
||||
### Verification
|
||||
- [ ] List all `query()` options available
|
||||
- [ ] Identify if `--no-resume` or equivalent exists
|
||||
- [ ] Document the tradeoffs
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix Authentication
|
||||
|
||||
### Objective
|
||||
Remove the `CLAUDE_CONFIG_DIR` override to restore authentication.
|
||||
|
||||
### File to Modify
|
||||
`src/services/worker/ProcessRegistry.ts`
|
||||
|
||||
### Change
|
||||
Remove lines 207-211 that override `CLAUDE_CONFIG_DIR`:
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env,
|
||||
CLAUDE_CONFIG_DIR: OBSERVER_CONFIG_DIR
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const isolatedEnv = {
|
||||
...spawnOptions.env
|
||||
// CLAUDE_CONFIG_DIR removed - observer sessions need access to auth credentials
|
||||
// Session isolation addressed via [alternative approach]
|
||||
};
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] Build succeeds: `npm run build`
|
||||
- [ ] Observer sessions authenticate successfully
|
||||
- [ ] Observations are saved to database
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Address Session Isolation (Issue #832)
|
||||
|
||||
### Objective
|
||||
Find alternative solution to prevent observer sessions from polluting `claude --resume` list.
|
||||
|
||||
### Options to Evaluate
|
||||
|
||||
1. **Option A: Accept the tradeoff**
|
||||
- Observer sessions appear in resume list but users can ignore them
|
||||
- No code changes needed beyond Phase 1
|
||||
|
||||
2. **Option B: Use isSynthetic flag**
|
||||
- If SDK has a flag to mark sessions as non-resumable, use it
|
||||
- Requires SDK documentation review
|
||||
|
||||
3. **Option C: Post-processing cleanup**
|
||||
- After session ends, remove observer entries from history
|
||||
- More complex, may have race conditions
|
||||
|
||||
### Decision Point
|
||||
After Phase 0 documentation review, choose the appropriate option.
|
||||
|
||||
### Verification
|
||||
- [ ] Chosen approach documented
|
||||
- [ ] If code changes made, tests pass
|
||||
- [ ] Observer sessions either isolated OR tradeoff accepted
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Testing
|
||||
|
||||
### Manual Tests
|
||||
1. Start a new Claude Code session with the plugin
|
||||
2. Verify observations are being saved (check logs)
|
||||
3. Check that no "Invalid API key" errors appear
|
||||
4. Verify `claude --resume` behavior (acceptable level of observer entries)
|
||||
|
||||
### Verification Checklist
|
||||
- [ ] `npm run build` succeeds
|
||||
- [ ] Worker service starts without errors
|
||||
- [ ] Observations save to database
|
||||
- [ ] No authentication errors in logs
|
||||
- [ ] Issue #832 regression acceptable or addressed
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. **DO NOT** add `ANTHROPIC_API_KEY` to environment - authentication is handled by Claude Code's built-in credential management
|
||||
2. **DO NOT** copy credential files to observer config dir - credentials may be in keychain or other secure storage
|
||||
3. **DO NOT** try to "fix" authentication by adding API key loading - that creates Issue #588 (unexpected API charges)
|
||||
|
||||
---
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/worker/ProcessRegistry.ts` | Contains the problematic `CLAUDE_CONFIG_DIR` override |
|
||||
| `src/shared/paths.ts` | Defines `OBSERVER_CONFIG_DIR` constant |
|
||||
| `src/services/worker/SDKAgent.ts` | Uses `createPidCapturingSpawn` which sets the env |
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
**Low Risk:** Removing the `CLAUDE_CONFIG_DIR` override is a simple, targeted change.
|
||||
|
||||
**Regression Risk (Issue #832):** Observer sessions may appear in `claude --resume` list again. This is a cosmetic issue vs. complete authentication failure, so the tradeoff favors removing the override.
|
||||
+85
-29
@@ -2,6 +2,91 @@
|
||||
|
||||
All notable changes to claude-mem.
|
||||
|
||||
## [v9.0.11] - 2026-01-28
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Observer Session Isolation (#837)
|
||||
Observer sessions created by claude-mem were polluting the `claude --resume` list, cluttering it with internal plugin sessions that users never intend to resume. In one user's case, 74 observer sessions out of ~220 total (34% noise).
|
||||
|
||||
**Solution**: Observer processes now use a dedicated config directory (`~/.claude-mem/observer-config/`) to isolate their session files from user sessions.
|
||||
|
||||
Thanks to @Glucksberg for this fix! Fixes #832.
|
||||
|
||||
### Stale memory_session_id Crash Prevention (#839)
|
||||
After a worker restart, stale `memory_session_id` values in the database could cause crashes when attempting to resume SDK conversations. The existing guard didn't protect against this because session data was loaded from the database.
|
||||
|
||||
**Solution**: Clear `memory_session_id` when loading sessions from the database (not from cache). The key insight: if a session isn't in memory, any database `memory_session_id` is definitely stale.
|
||||
|
||||
Thanks to @bigph00t for this fix! Fixes #817.
|
||||
|
||||
---
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.10...v9.0.11
|
||||
|
||||
## [v9.0.10] - 2026-01-26
|
||||
|
||||
## Bug Fix
|
||||
|
||||
**Fixed path format mismatch causing folder CLAUDE.md files to show "No recent activity" (#794)** - Thanks @bigph00t!
|
||||
|
||||
The folder-level CLAUDE.md generation was failing to find observations due to a path format mismatch between how API queries used absolute paths and how the database stored relative paths. The `isDirectChild()` function's simple prefix match always returned false in these cases.
|
||||
|
||||
**Root cause:** PR #809 (v9.0.9) only masked this bug by skipping file creation when "no activity" was detected. Since ALL folders were affected, this prevented file creation entirely. This PR provides the actual fix.
|
||||
|
||||
**Changes:**
|
||||
- Added new shared module `src/shared/path-utils.ts` with robust path normalization and matching utilities
|
||||
- Updated `SessionSearch.ts`, `regenerate-claude-md.ts`, and `claude-md-utils.ts` to use shared path utilities
|
||||
- Added comprehensive test coverage (61 new tests) for path matching edge cases
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
|
||||
## [v9.0.9] - 2026-01-26
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Prevent Creation of Empty CLAUDE.md Files (#809)
|
||||
|
||||
Previously, claude-mem would create new `CLAUDE.md` files in project directories even when there was no activity to display, cluttering codebases with empty context files showing only "*No recent activity*".
|
||||
|
||||
**What changed:** The `updateFolderClaudeMdFiles` function now checks if the formatted content contains no activity before writing. If a `CLAUDE.md` file doesn't already exist and there's nothing to show, it will be skipped entirely. Existing files will still be updated to reflect "No recent activity" if that's the current state.
|
||||
|
||||
**Impact:** Cleaner project directories - only folders with actual activity will have `CLAUDE.md` context files created.
|
||||
|
||||
Thanks to @maxmillienjr for this contribution!
|
||||
|
||||
## [v9.0.8] - 2026-01-26
|
||||
|
||||
## Fix: Prevent Zombie Process Accumulation (Issue #737)
|
||||
|
||||
This release fixes a critical issue where Claude haiku subprocesses spawned by the SDK weren't terminating properly, causing zombie process accumulation. One user reported 155 processes consuming 51GB RAM.
|
||||
|
||||
### Root Causes Addressed
|
||||
- SDK's SpawnedProcess interface hides subprocess PIDs
|
||||
- `deleteSession()` didn't verify subprocess exit
|
||||
- `abort()` was fire-and-forget with no confirmation
|
||||
- No mechanism to track or clean up orphaned processes
|
||||
|
||||
### Solution
|
||||
- **ProcessRegistry module**: Tracks spawned Claude subprocesses via PID
|
||||
- **Custom spawn**: Uses SDK's `spawnClaudeCodeProcess` option to capture PIDs
|
||||
- **Signal propagation**: Passes signal parameter to enable AbortController integration
|
||||
- **Graceful shutdown**: Waits for subprocess exit in `deleteSession()` with 5s timeout
|
||||
- **SIGKILL escalation**: Force-kills processes that don't exit gracefully
|
||||
- **Orphan reaper**: Safety net running every 5 minutes to clean up any missed processes
|
||||
- **Race detection**: Warns about multiple processes per session (race condition indicator)
|
||||
|
||||
### Files Changed
|
||||
- `src/services/worker/ProcessRegistry.ts` (new): PID registry and reaper
|
||||
- `src/services/worker/SDKAgent.ts`: Use custom spawn to capture PIDs
|
||||
- `src/services/worker/SessionManager.ts`: Verify subprocess exit on delete
|
||||
- `src/services/worker-service.ts`: Start/stop orphan reaper
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.7...v9.0.8
|
||||
|
||||
Fixes #737
|
||||
|
||||
## [v9.0.6] - 2026-01-22
|
||||
|
||||
## Windows Console Popup Fix
|
||||
@@ -1244,32 +1329,3 @@ This represents a major reliability improvement for Windows users, eliminating c
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.6...v7.3.7
|
||||
|
||||
## [v7.3.6] - 2025-12-17
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Enhanced SDKAgent response handling and message processing
|
||||
|
||||
## [v7.3.5] - 2025-12-17
|
||||
|
||||
## What's Changed
|
||||
* fix(windows): solve zombie port problem with wrapper architecture by @ToxMox in https://github.com/thedotmack/claude-mem/pull/372
|
||||
* chore: bump version to 7.3.5 by @thedotmack in https://github.com/thedotmack/claude-mem/pull/375
|
||||
|
||||
## New Contributors
|
||||
* @ToxMox made their first contribution in https://github.com/thedotmack/claude-mem/pull/372
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.4...v7.3.5
|
||||
|
||||
## [v7.3.4] - 2025-12-17
|
||||
|
||||
Patch release for bug fixes and minor improvements
|
||||
|
||||
## [v7.3.3] - 2025-12-16
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Remove all better-sqlite3 references from codebase (#357)
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.3.2...v7.3.3
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.8",
|
||||
"version": "9.0.12",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "9.0.8",
|
||||
"version": "9.0.12",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "9.0.8",
|
||||
"version": "9.0.12",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -43,8 +43,10 @@ interface ObservationRow {
|
||||
discovery_tokens: number | null;
|
||||
}
|
||||
|
||||
// Import shared formatting utilities
|
||||
// Import shared utilities
|
||||
import { formatTime, groupByDate } from '../src/shared/timeline-formatting.js';
|
||||
import { isDirectChild } from '../src/shared/path-utils.js';
|
||||
import { replaceTaggedContent } from '../src/utils/claude-md-utils.js';
|
||||
|
||||
// Type icon map (matches ModeManager)
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
@@ -135,19 +137,6 @@ function walkDirectoriesWithIgnore(dir: string, folders: Set<string>, depth: num
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder)
|
||||
* @param filePath - File path like "src/services/foo.ts"
|
||||
* @param folderPath - Folder path like "src/services"
|
||||
* @returns true if file is directly in folder, false if in a subfolder
|
||||
*/
|
||||
function isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
if (!filePath.startsWith(folderPath + '/')) return false;
|
||||
const remainder = filePath.slice(folderPath.length + 1);
|
||||
// If remainder contains a slash, it's in a subfolder
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an observation has any files that are direct children of the folder
|
||||
*/
|
||||
@@ -288,37 +277,27 @@ function formatObservationsForClaudeMd(observations: ObservationRow[], folderPat
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file with tagged content preservation
|
||||
* Note: For the CLI regenerate tool, we DO create directories since the user
|
||||
* explicitly requested regeneration. This differs from the runtime behavior
|
||||
* which only writes to existing folders.
|
||||
*/
|
||||
function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
function writeClaudeMdToFolderForRegenerate(folderPath: string, newContent: string): void {
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const tempFile = `${claudeMdPath}.tmp`;
|
||||
|
||||
// For regenerate CLI, we create the folder if needed
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
// Read existing content if file exists
|
||||
let existingContent = '';
|
||||
if (existsSync(claudeMdPath)) {
|
||||
existingContent = readFileSync(claudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
const startTag = '<claude-mem-context>';
|
||||
const endTag = '</claude-mem-context>';
|
||||
|
||||
let finalContent: string;
|
||||
if (!existingContent) {
|
||||
finalContent = `${startTag}\n${newContent}\n${endTag}`;
|
||||
} else {
|
||||
const startIdx = existingContent.indexOf(startTag);
|
||||
const endIdx = existingContent.indexOf(endTag);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
finalContent = existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
} else {
|
||||
finalContent = existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`;
|
||||
}
|
||||
}
|
||||
// Use shared utility to preserve user content outside tags
|
||||
const finalContent = replaceTaggedContent(existingContent, newContent);
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
writeFileSync(tempFile, finalContent);
|
||||
renameSync(tempFile, claudeMdPath);
|
||||
}
|
||||
@@ -450,7 +429,7 @@ function regenerateFolder(
|
||||
|
||||
// Format using relative path for display, write to absolute path
|
||||
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
|
||||
writeClaudeMdToFolder(absoluteFolder, formatted);
|
||||
writeClaudeMdToFolderForRegenerate(absoluteFolder, formatted);
|
||||
|
||||
return { success: true, observationCount: observations.length };
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
|
||||
import { TableNameRow } from '../../types/database.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { isDirectChild } from '../../shared/path-utils.js';
|
||||
import {
|
||||
ObservationSearchResult,
|
||||
SessionSummarySearchResult,
|
||||
@@ -336,15 +337,6 @@ export class SessionSearch {
|
||||
return this.db.prepare(sql).all(...params) as ObservationSearchResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder)
|
||||
*/
|
||||
private isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
if (!filePath.startsWith(folderPath + '/')) return false;
|
||||
const remainder = filePath.slice(folderPath.length + 1);
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an observation has any files that are direct children of the folder
|
||||
*/
|
||||
@@ -354,7 +346,7 @@ export class SessionSearch {
|
||||
try {
|
||||
const files = JSON.parse(filesJson);
|
||||
if (Array.isArray(files)) {
|
||||
return files.some(f => this.isDirectChild(f, folderPath));
|
||||
return files.some(f => isDirectChild(f, folderPath));
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
@@ -372,7 +364,7 @@ export class SessionSearch {
|
||||
try {
|
||||
const files = JSON.parse(filesJson);
|
||||
if (Array.isArray(files)) {
|
||||
return files.some(f => this.isDirectChild(f, folderPath));
|
||||
return files.some(f => isDirectChild(f, folderPath));
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
|
||||
@@ -187,6 +187,9 @@ export async function reapOrphanedProcesses(activeSessionIds: Set<number>): Prom
|
||||
*
|
||||
* The SDK's spawnClaudeCodeProcess option allows us to intercept subprocess
|
||||
* creation and capture the PID before the SDK hides it.
|
||||
*
|
||||
* NOTE: Session isolation is handled via the `cwd` option in SDKAgent.ts,
|
||||
* NOT via CLAUDE_CONFIG_DIR (which breaks authentication).
|
||||
*/
|
||||
export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
return (spawnOptions: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { USER_SETTINGS_PATH, OBSERVER_SESSIONS_DIR, ensureDir } from '../../shared/paths.js';
|
||||
import type { ActiveSession, SDKUserMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { processAgentResponse, type WorkerRef } from './agents/index.js';
|
||||
@@ -101,10 +101,15 @@ export class SDKAgent {
|
||||
// Run Agent SDK query loop
|
||||
// Only resume if we have a captured memory session ID
|
||||
// Use custom spawn to capture PIDs for zombie process cleanup (Issue #737)
|
||||
// Use dedicated cwd to isolate observer sessions from user's `claude --resume` list
|
||||
ensureDir(OBSERVER_SESSIONS_DIR);
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
// Isolate observer sessions - they'll appear under project "observer-sessions"
|
||||
// instead of polluting user's actual project resume lists
|
||||
cwd: OBSERVER_SESSIONS_DIR,
|
||||
// Only resume if BOTH: (1) we have a memorySessionId AND (2) this isn't the first prompt
|
||||
// On worker restart, memorySessionId may exist from a previous SDK session but we
|
||||
// need to start fresh since the SDK context was lost
|
||||
|
||||
@@ -106,6 +106,15 @@ export class SessionManager {
|
||||
memory_session_id: dbSession.memory_session_id
|
||||
});
|
||||
|
||||
// Log warning if we're discarding a stale memory_session_id (Issue #817)
|
||||
if (dbSession.memory_session_id) {
|
||||
logger.warn('SESSION', `Discarding stale memory_session_id from previous worker instance (Issue #817)`, {
|
||||
sessionDbId,
|
||||
staleMemorySessionId: dbSession.memory_session_id,
|
||||
reason: 'SDK context lost on worker restart - will capture new ID'
|
||||
});
|
||||
}
|
||||
|
||||
// Use currentUserPrompt if provided, otherwise fall back to database (first prompt)
|
||||
const userPrompt = currentUserPrompt || dbSession.user_prompt;
|
||||
|
||||
@@ -124,11 +133,15 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
// Create active session
|
||||
// Load memorySessionId from database if previously captured (enables resume across restarts)
|
||||
// CRITICAL: Do NOT load memorySessionId from database here (Issue #817)
|
||||
// When creating a new in-memory session, any database memory_session_id is STALE
|
||||
// because the SDK context was lost when the worker restarted. The SDK agent will
|
||||
// capture a new memorySessionId on the first response and persist it.
|
||||
// Loading stale memory_session_id causes "No conversation found" crashes on resume.
|
||||
session = {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || null,
|
||||
memorySessionId: null, // Always start fresh - SDK will capture new ID
|
||||
project: dbSession.project,
|
||||
userPrompt,
|
||||
pendingMessages: [],
|
||||
@@ -143,10 +156,11 @@ export class SessionManager {
|
||||
currentProvider: null // Will be set when generator starts
|
||||
};
|
||||
|
||||
logger.debug('SESSION', 'Creating new session object', {
|
||||
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || '(none - fresh session)',
|
||||
dbMemorySessionId: dbSession.memory_session_id || '(none in DB)',
|
||||
memorySessionId: '(cleared - will capture fresh from SDK)',
|
||||
lastPromptNumber: promptNumber || this.dbManager.getSessionStore().getPromptNumberFromUserPrompts(dbSession.content_session_id)
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Shared path utilities for CLAUDE.md file generation
|
||||
*
|
||||
* These utilities handle path normalization and matching, particularly
|
||||
* for comparing absolute and relative paths in folder CLAUDE.md generation.
|
||||
*
|
||||
* @see Issue #794 - Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize path separators to forward slashes, collapse consecutive slashes,
|
||||
* and remove trailing slashes.
|
||||
*
|
||||
* @example
|
||||
* normalizePath('app\\api\\router.py') // 'app/api/router.py'
|
||||
* normalizePath('app//api///router.py') // 'app/api/router.py'
|
||||
* normalizePath('app/api/') // 'app/api'
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a direct child of a folder (not in a subfolder).
|
||||
*
|
||||
* Handles path format mismatches where folderPath may be absolute but
|
||||
* filePath is stored as relative in the database.
|
||||
*
|
||||
* NOTE: This uses suffix matching which assumes both paths are relative to
|
||||
* the same project root. It may produce false positives if used across
|
||||
* different project roots, but this is mitigated by project-scoped queries.
|
||||
*
|
||||
* @param filePath - Path to the file (e.g., "app/api/router.py" or "/Users/x/project/app/api/router.py")
|
||||
* @param folderPath - Path to the folder (e.g., "app/api" or "/Users/x/project/app/api")
|
||||
* @returns true if file is directly in folder, false if in a subfolder or different folder
|
||||
*
|
||||
* @example
|
||||
* // Same format (both relative)
|
||||
* isDirectChild('app/api/router.py', 'app/api') // true
|
||||
* isDirectChild('app/api/v1/router.py', 'app/api') // false (in subfolder)
|
||||
*
|
||||
* @example
|
||||
* // Mixed format (absolute folder, relative file) - fixes #794
|
||||
* isDirectChild('app/api/router.py', '/Users/dev/project/app/api') // true
|
||||
*/
|
||||
export function isDirectChild(filePath: string, folderPath: string): boolean {
|
||||
const normFile = normalizePath(filePath);
|
||||
const normFolder = normalizePath(folderPath);
|
||||
|
||||
// Strategy 1: Direct prefix match (both paths in same format)
|
||||
if (normFile.startsWith(normFolder + '/')) {
|
||||
const remainder = normFile.slice(normFolder.length + 1);
|
||||
return !remainder.includes('/');
|
||||
}
|
||||
|
||||
// Strategy 2: Handle absolute folderPath with relative filePath
|
||||
// e.g., folderPath="/Users/x/project/app/api" and filePath="app/api/router.py"
|
||||
const folderSegments = normFolder.split('/');
|
||||
const fileSegments = normFile.split('/');
|
||||
|
||||
if (fileSegments.length < 2) return false; // Need at least folder/file
|
||||
|
||||
const fileDir = fileSegments.slice(0, -1).join('/'); // Directory part of file
|
||||
const fileName = fileSegments[fileSegments.length - 1]; // Actual filename
|
||||
|
||||
// Check if folder path ends with the file's directory path
|
||||
if (normFolder.endsWith('/' + fileDir) || normFolder === fileDir) {
|
||||
// File is a direct child (no additional subdirectories)
|
||||
return !fileName.includes('/');
|
||||
}
|
||||
|
||||
// Check if file's directory is contained at the end of folder path
|
||||
// by progressively checking suffixes
|
||||
for (let i = 0; i < folderSegments.length; i++) {
|
||||
const folderSuffix = folderSegments.slice(i).join('/');
|
||||
if (folderSuffix === fileDir) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const VECTOR_DB_DIR = join(DATA_DIR, 'vector-db');
|
||||
|
||||
// Observer sessions directory - used as cwd for SDK queries
|
||||
// Sessions here won't appear in user's `claude --resume` for their actual projects
|
||||
export const OBSERVER_SESSIONS_DIR = join(DATA_DIR, 'observer-sessions');
|
||||
|
||||
// Claude integration paths
|
||||
export const CLAUDE_SETTINGS_PATH = join(CLAUDE_CONFIG_DIR, 'settings.json');
|
||||
export const CLAUDE_COMMANDS_DIR = join(CLAUDE_CONFIG_DIR, 'commands');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* <claude-mem-context> tags.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { logger } from './logger.js';
|
||||
@@ -76,8 +76,8 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
return existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
}
|
||||
|
||||
// If no tags exist, append tagged content at end
|
||||
@@ -86,17 +86,22 @@ export function replaceTaggedContent(existingContent: string, newContent: string
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file to folder with atomic writes.
|
||||
* Creates directory structure if needed.
|
||||
* Only writes to existing folders; skips non-existent paths to prevent
|
||||
* creating spurious directory structures from malformed paths.
|
||||
*
|
||||
* @param folderPath - Absolute path to the folder
|
||||
* @param folderPath - Absolute path to the folder (must already exist)
|
||||
* @param newContent - Content to write inside tags
|
||||
*/
|
||||
export function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const tempFile = `${claudeMdPath}.tmp`;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
// Only write to folders that already exist - never create new directories
|
||||
// This prevents creating spurious folder structures from malformed paths
|
||||
if (!existsSync(folderPath)) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping non-existent folder', { folderPath });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read existing content if file exists
|
||||
let existingContent = '';
|
||||
@@ -320,6 +325,18 @@ export async function updateFolderClaudeMdFiles(
|
||||
}
|
||||
|
||||
const formatted = formatTimelineForClaudeMd(result.content[0].text);
|
||||
|
||||
// Fix for #794: Don't create new CLAUDE.md files if there's no activity
|
||||
// But update existing ones to show "No recent activity" if they already exist
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const hasNoActivity = formatted.includes('*No recent activity*');
|
||||
const fileExists = existsSync(claudeMdPath);
|
||||
|
||||
if (hasNoActivity && !fileExists) {
|
||||
logger.debug('FOLDER_INDEX', 'Skipping empty CLAUDE.md creation', { folderPath });
|
||||
continue;
|
||||
}
|
||||
|
||||
writeClaudeMdToFolder(folderPath, formatted);
|
||||
|
||||
logger.debug('FOLDER_INDEX', 'Updated CLAUDE.md', { folderPath });
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isDirectChild, normalizePath } from '../../../src/shared/path-utils.js';
|
||||
|
||||
/**
|
||||
* Tests for path matching logic, specifically the isDirectChild() algorithm
|
||||
* Covers fix for issue #794: Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*
|
||||
* These tests validate the shared path-utils module which is used by:
|
||||
* - SessionSearch.ts (runtime folder CLAUDE.md generation)
|
||||
* - regenerate-claude-md.ts (CLI regeneration tool)
|
||||
*/
|
||||
|
||||
describe('isDirectChild path matching', () => {
|
||||
describe('same path format', () => {
|
||||
test('returns true for direct child with relative paths', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for direct child with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with relative paths', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', 'app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for unrelated paths', () => {
|
||||
expect(isDirectChild('lib/utils/helper.py', 'app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed path formats (absolute folder, relative file) - fixes #794', () => {
|
||||
test('returns true when absolute folder ends with relative file directory', () => {
|
||||
// This is the exact bug case from #794
|
||||
expect(isDirectChild('app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for deeply nested folder match', () => {
|
||||
expect(isDirectChild('src/components/Button.tsx', '/home/user/project/src/components')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory of matched folder', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when file path does not match folder suffix', () => {
|
||||
expect(isDirectChild('lib/api/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path normalization', () => {
|
||||
test('handles Windows backslash paths', () => {
|
||||
expect(isDirectChild('app\\api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles mixed slashes', () => {
|
||||
expect(isDirectChild('app/api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles trailing slashes on folder path', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api/')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles double slashes (path normalization bug)', () => {
|
||||
expect(isDirectChild('app//api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('collapses multiple consecutive slashes', () => {
|
||||
expect(isDirectChild('app///api///router.py', 'app//api//')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('returns false for single segment file path', () => {
|
||||
expect(isDirectChild('router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty paths', () => {
|
||||
expect(isDirectChild('', 'app/api')).toBe(false);
|
||||
expect(isDirectChild('app/api/router.py', '')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles root-level folders', () => {
|
||||
expect(isDirectChild('src/file.ts', '/project/src')).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents false positive from partial segment match', () => {
|
||||
// "api" folder should not match "api-v2" folder
|
||||
expect(isDirectChild('app/api-v2/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles similar folder names correctly', () => {
|
||||
// "components" should not match "components-old"
|
||||
expect(isDirectChild('src/components-old/Button.tsx', '/project/src/components')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('converts backslashes to forward slashes', () => {
|
||||
expect(normalizePath('app\\api\\router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('collapses consecutive slashes', () => {
|
||||
expect(normalizePath('app//api///router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('removes trailing slashes', () => {
|
||||
expect(normalizePath('app/api/')).toBe('app/api');
|
||||
expect(normalizePath('app/api///')).toBe('app/api');
|
||||
});
|
||||
|
||||
test('handles Windows UNC paths', () => {
|
||||
expect(normalizePath('\\\\server\\share\\file.txt')).toBe('/server/share/file.txt');
|
||||
});
|
||||
|
||||
test('preserves leading slash for absolute paths', () => {
|
||||
expect(normalizePath('/Users/dev/project')).toBe('/Users/dev/project');
|
||||
});
|
||||
});
|
||||
@@ -147,8 +147,22 @@ describe('formatTimelineForClaudeMd', () => {
|
||||
});
|
||||
|
||||
describe('writeClaudeMdToFolder', () => {
|
||||
it('should create CLAUDE.md in new folder', () => {
|
||||
const folderPath = join(tempDir, 'new-folder');
|
||||
it('should skip non-existent folders (fix for spurious directory creation)', () => {
|
||||
const folderPath = join(tempDir, 'non-existent-folder');
|
||||
const content = '# Recent Activity\n\nTest content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Folder and CLAUDE.md should NOT be created
|
||||
expect(existsSync(folderPath)).toBe(false);
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should create CLAUDE.md in existing folder', () => {
|
||||
const folderPath = join(tempDir, 'existing-folder');
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
const content = '# Recent Activity\n\nTest content';
|
||||
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
@@ -180,20 +194,22 @@ describe('writeClaudeMdToFolder', () => {
|
||||
expect(fileContent).not.toContain('Old content');
|
||||
});
|
||||
|
||||
it('should create nested directories', () => {
|
||||
it('should not create nested directories (fix for spurious directory creation)', () => {
|
||||
const folderPath = join(tempDir, 'deep', 'nested', 'folder');
|
||||
const content = 'Nested content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Nested directories should NOT be created
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(true);
|
||||
expect(existsSync(join(tempDir, 'deep'))).toBe(true);
|
||||
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
expect(existsSync(join(tempDir, 'deep'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not leave .tmp file after write (atomic write)', () => {
|
||||
const folderPath = join(tempDir, 'atomic-test');
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
const content = 'Atomic write test';
|
||||
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
@@ -218,6 +234,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
it('should fetch timeline and write CLAUDE.md', async () => {
|
||||
const folderPath = join(tempDir, 'api-test');
|
||||
mkdirSync(folderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
const filePath = join(folderPath, 'test.ts');
|
||||
|
||||
const apiResponse = {
|
||||
@@ -412,6 +429,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
it('should write CLAUDE.md to resolved projectRoot path', async () => {
|
||||
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
|
||||
mkdirSync(subfolderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
|
||||
const apiResponse = {
|
||||
content: [{
|
||||
|
||||
Reference in New Issue
Block a user