Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Newman 0524fa83cd chore: bump version to 10.6.2
Publish to npm / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:14:09 -07:00
Alex Newman 4d7bec4d05 fix: stop spinner from spinning forever (#1440)
* fix: stop spinner from spinning forever due to orphaned DB messages

The activity spinner never stopped because isAnySessionProcessing() queried
ALL pending/processing messages in the database, including orphaned messages
from dead sessions that no generator would ever process.

Root cause: isAnySessionProcessing() used hasAnyPendingWork() which is a
global DB scan. Changed it to use getTotalQueueDepth() which only checks
sessions in the active in-memory Map.

Additional fixes:
- Add terminateSession() to enforce restart-or-terminate invariant
- Fix 3 zombie paths in .finally() handler that left sessions alive
- Clean up idle sessions from memory on successful completion
- Remove redundant bare isProcessing:true broadcast
- Replace inline require() with proper accessor
- Add 8 regression tests for session termination invariant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings — idle-timeout race, double broadcast, query amplification

- Move pendingCount check before idle-timeout termination to prevent
  abandoning fresh messages that arrive between idle abort and .finally()
- Move broadcastProcessingStatus() inside restart branch only — the else
  branch already broadcasts via removeSessionImmediate callback
- Compute queueDepth once in broadcastProcessingStatus() and derive
  isProcessing from it, eliminating redundant double iteration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:13:10 -07:00
Alex Newman 9f529a30f5 feat: strip <system_instruction> tags before DB storage (#1398)
* feat: strip <system_instruction> tags before database storage

Extends the existing tag-stripping mechanism (used for <private> and
<claude-mem-context>) to also filter Conductor-injected system instructions,
preventing them from being persisted in the observation database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: also strip <system-instruction> (hyphen variant) before DB storage

Conductor uses both <system_instruction> and <system-instruction> tag
formats. This adds the hyphen variant to the same stripping mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:08:25 -07:00
Alex Newman b34aff1aa2 docs: update CHANGELOG.md for v10.6.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:37:01 -07:00
14 changed files with 487 additions and 249 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "10.6.1",
"version": "10.6.2",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+15 -15
View File
@@ -2,6 +2,21 @@
All notable changes to claude-mem.
## [v10.6.1] - 2026-03-18
### New Features
- **Timeline Report Skill** — New `/timeline-report` skill generates narrative "Journey Into [Project]" reports from claude-mem's development history with token-aware economics
- **Git Worktree Detection** — Timeline report automatically detects git worktrees and uses parent project as data source
- **Compressed Context Output** — Markdown context injection compressed ~53% (tables → compact flat lines), reducing token overhead in session starts
- **Full Observation Fetch** — Added `full=true` parameter to `/api/context/inject` for fetching all observations
### Improvements
- Split `TimelineRenderer` into separate markdown/color rendering paths
- Fixed timestamp ditto marker leaking across session summary boundaries
### Security
- Removed arbitrary file write vulnerability (`dump_to_file` parameter)
## [v10.6.0] - 2026-03-18
## OpenClaw: System prompt context injection
@@ -1103,18 +1118,3 @@ Fixed an issue where the worker service startup wasn't producing proper JSON sta
- Removed obsolete error handling baseline file
## [v9.0.2] - 2026-01-10
## Bug Fixes
- **Windows Terminal Tab Accumulation (#625, #628)**: Fixed terminal tab accumulation on Windows by implementing graceful exit strategy. All expected failure scenarios (port conflicts, version mismatches, health check timeouts) now exit with code 0 instead of code 1.
- **Windows 11 Compatibility (#625)**: Replaced deprecated WMIC commands with PowerShell `Get-Process` and `Get-CimInstance` for process enumeration. WMIC is being removed from Windows 11.
## Maintenance
- **Removed Obsolete CLAUDE.md Files**: Cleaned up auto-generated CLAUDE.md files from `~/.claude/plans/` and `~/.claude/plugins/marketplaces/` directories.
---
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v9.0.1...v9.0.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.6.1",
"version": "10.6.2",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "10.6.1",
"version": "10.6.2",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "10.6.1",
"version": "10.6.2",
"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
+44 -23
View File
@@ -653,30 +653,26 @@ export class WorkerService {
// Do NOT restart after unrecoverable errors - prevents infinite loops
if (hadUnrecoverableError) {
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
sessionId: session.sessionDbId
});
this.broadcastProcessingStatus();
this.terminateSession(session.sessionDbId, 'unrecoverable_error');
return;
}
// Store for pending-count check below
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
// Idle timeout means no new work arrived for 3 minutes - don't restart
// No need to reset stale processing messages here — claimNextMessage() self-heals
if (session.idleTimedOut) {
logger.info('SYSTEM', 'Generator exited due to idle timeout, not restarting', {
sessionId: session.sessionDbId
});
session.idleTimedOut = false; // Reset flag
this.broadcastProcessingStatus();
return;
}
const pendingStore = this.sessionManager.getPendingMessageStore();
// Check if there's pending work that needs processing with a fresh AbortController
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
// Idle timeout means no new work arrived for 3 minutes - don't restart
// But check pendingCount first: a message may have arrived between idle
// abort and .finally(), and we must not abandon it
if (session.idleTimedOut) {
session.idleTimedOut = false; // Reset flag
if (pendingCount === 0) {
this.terminateSession(session.sessionDbId, 'idle_timeout');
return;
}
// Fall through to pending-work restart below
}
const MAX_PENDING_RESTARTS = 3;
if (pendingCount > 0) {
@@ -690,7 +686,7 @@ export class WorkerService {
consecutiveRestarts: session.consecutiveRestarts
});
session.consecutiveRestarts = 0;
this.broadcastProcessingStatus();
this.terminateSession(session.sessionDbId, 'max_restarts_exceeded');
return;
}
@@ -703,12 +699,13 @@ export class WorkerService {
session.abortController = new AbortController();
// Restart processor
this.startSessionProcessor(session, 'pending-work-restart');
this.broadcastProcessingStatus();
} else {
// Successful completion with no pending work — reset counter
// Successful completion with no pending work — clean up session
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
session.consecutiveRestarts = 0;
this.sessionManager.removeSessionImmediate(session.sessionDbId);
}
this.broadcastProcessingStatus();
});
}
@@ -784,6 +781,30 @@ export class WorkerService {
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
}
/**
* Terminate a session that will not restart.
* Enforces the restart-or-terminate invariant: every generator exit
* must either call startSessionProcessor() or terminateSession().
* No zombie sessions allowed.
*
* GENERATOR EXIT INVARIANT:
* .finally() restart? startSessionProcessor()
* no? terminateSession()
*/
private terminateSession(sessionDbId: number, reason: string): void {
const pendingStore = this.sessionManager.getPendingMessageStore();
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
logger.info('SYSTEM', 'Session terminated', {
sessionId: sessionDbId,
reason,
abandonedMessages: abandoned
});
// removeSessionImmediate fires onSessionDeletedCallback → broadcastProcessingStatus()
this.sessionManager.removeSessionImmediate(sessionDbId);
}
/**
* Process pending session queues
*/
@@ -907,8 +928,8 @@ export class WorkerService {
* Broadcast processing status change to SSE clients
*/
broadcastProcessingStatus(): void {
const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalActiveWork();
const isProcessing = queueDepth > 0;
const activeSessions = this.sessionManager.getActiveSessionCount();
logger.info('WORKER', 'Broadcasting processing status', {
+8 -7
View File
@@ -350,7 +350,7 @@ export class SessionManager {
this.sessions.delete(sessionDbId);
this.sessionQueues.delete(sessionDbId);
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
logger.info('SESSION', 'Session removed from active sessions', {
sessionId: sessionDbId,
project: session.project
});
@@ -402,10 +402,11 @@ export class SessionManager {
}
/**
* Check if any session has pending messages (for spinner tracking)
* Check if any active session has pending messages (for spinner tracking).
* Scoped to in-memory sessions only.
*/
hasPendingMessages(): boolean {
return this.getPendingStore().hasAnyPendingWork();
return this.getTotalQueueDepth() > 0;
}
/**
@@ -437,12 +438,12 @@ export class SessionManager {
}
/**
* Check if any session is actively processing (has pending messages OR active generator)
* Used for activity indicator to prevent spinner from stopping while SDK is processing
* Check if any active session has pending work.
* Scoped to in-memory sessions only orphaned DB messages from dead
* sessions must not keep the spinner spinning forever.
*/
isAnySessionProcessing(): boolean {
// hasAnyPendingWork checks for 'pending' OR 'processing'
return this.getPendingStore().hasAnyPendingWork();
return this.getTotalQueueDepth() > 0;
}
/**
@@ -33,12 +33,6 @@ export class SessionEventBroadcaster {
prompt
});
// Start activity indicator (work is about to begin)
this.sseBroadcaster.broadcast({
type: 'processing_status',
isProcessing: true
});
// Update processing status based on queue depth
this.workerService.broadcastProcessingStatus();
}
+8 -2
View File
@@ -1,11 +1,13 @@
/**
* Tag Stripping Utilities
*
* Implements the dual-tag system for meta-observation control:
* Implements the tag system for meta-observation control:
* 1. <claude-mem-context> - System-level tag for auto-injected observations
* (prevents recursive storage when context injection is active)
* 2. <private> - User-level tag for manual privacy control
* (allows users to mark content they don't want persisted)
* 3. <system_instruction> / <system-instruction> - Conductor-injected system instructions
* (should not be persisted to memory)
*
* EDGE PROCESSING PATTERN: Filter at hook layer before sending to worker/storage.
* This keeps the worker service simple and follows one-way data stream.
@@ -27,7 +29,9 @@ const MAX_TAG_COUNT = 100;
function countTags(content: string): number {
const privateCount = (content.match(/<private>/g) || []).length;
const contextCount = (content.match(/<claude-mem-context>/g) || []).length;
return privateCount + contextCount;
const systemInstructionCount = (content.match(/<system_instruction>/g) || []).length;
const systemInstructionHyphenCount = (content.match(/<system-instruction>/g) || []).length;
return privateCount + contextCount + systemInstructionCount + systemInstructionHyphenCount;
}
/**
@@ -49,6 +53,8 @@ function stripTagsInternal(content: string): string {
return content
.replace(/<claude-mem-context>[\s\S]*?<\/claude-mem-context>/g, '')
.replace(/<private>[\s\S]*?<\/private>/g, '')
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/g, '')
.replace(/<system-instruction>[\s\S]*?<\/system-instruction>/g, '')
.trim();
}
+69 -1
View File
@@ -1,7 +1,7 @@
/**
* Tag Stripping Utility Tests
*
* Tests the dual-tag privacy system for <private> and <claude-mem-context> tags.
* Tests the tag privacy system for <private>, <claude-mem-context>, and <system_instruction> tags.
* These tags enable users and the system to exclude content from memory storage.
*
* Sources:
@@ -257,6 +257,74 @@ finish`;
});
});
describe('system_instruction tag stripping', () => {
describe('basic system_instruction removal', () => {
it('should strip single <system_instruction> tag from prompt', () => {
const input = 'user content <system_instruction>injected instructions</system_instruction> more content';
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('user content more content');
});
it('should strip <system_instruction> mixed with <private> tags', () => {
const input = '<system_instruction>instructions</system_instruction> public <private>secret</private> end';
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('public end');
});
it('should return empty string for entirely <system_instruction> content', () => {
const input = '<system_instruction>entire prompt is system instructions</system_instruction>';
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('');
});
it('should strip <system_instruction> tags from JSON content', () => {
const jsonContent = JSON.stringify({
data: '<system_instruction>injected</system_instruction> real data'
});
const result = stripMemoryTagsFromJson(jsonContent);
const parsed = JSON.parse(result);
expect(parsed.data).toBe(' real data');
});
it('should strip multiline content within <system_instruction> tags', () => {
const input = `before
<system_instruction>
line one
line two
line three
</system_instruction>
after`;
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('before\n\nafter');
});
});
});
describe('system-instruction (hyphen variant) tag stripping', () => {
it('should strip single <system-instruction> tag from prompt', () => {
const input = 'user content <system-instruction>injected instructions</system-instruction> more content';
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('user content more content');
});
it('should strip both underscore and hyphen variants in same prompt', () => {
const input = '<system_instruction>underscore</system_instruction> middle <system-instruction>hyphen</system-instruction> end';
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('middle end');
});
it('should strip multiline <system-instruction> content', () => {
const input = `before
<system-instruction>
line one
line two
</system-instruction>
after`;
const result = stripMemoryTagsFromPrompt(input);
expect(result).toBe('before\n\nafter');
});
});
describe('privacy enforcement integration', () => {
it('should allow empty result to trigger privacy skip', () => {
// Simulates what SessionRoutes does with private-only prompts
+148
View File
@@ -326,4 +326,152 @@ describe('Zombie Agent Prevention', () => {
session.generatorPromise = null;
expect(session.generatorPromise).toBeNull();
});
describe('Session Termination Invariant', () => {
// Tests the restart-or-terminate invariant:
// When a generator exits without restarting, its messages must be
// marked abandoned and the session removed from the active Map.
test('should mark messages abandoned when session is terminated', () => {
const sessionId = createDbSession('content-terminate-1');
enqueueTestMessage(sessionId, 'content-terminate-1');
enqueueTestMessage(sessionId, 'content-terminate-1');
// Verify messages exist
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
expect(pendingStore.hasAnyPendingWork()).toBe(true);
// Terminate: mark abandoned (same as terminateSession does)
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(2);
// Spinner should stop: no pending work remains
expect(pendingStore.hasAnyPendingWork()).toBe(false);
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
});
test('should handle terminate with zero pending messages', () => {
const sessionId = createDbSession('content-terminate-empty');
// No messages enqueued
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
// Terminate with nothing to abandon
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(0);
// Still no pending work
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should be idempotent — double terminate marks zero on second call', () => {
const sessionId = createDbSession('content-terminate-idempotent');
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
// First terminate
const first = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(first).toBe(1);
// Second terminate — already failed, nothing to mark
const second = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(second).toBe(0);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should remove session from Map via removeSessionImmediate', () => {
const sessionId = createDbSession('content-terminate-map');
const session = createMockSession(sessionId, {
contentSessionId: 'content-terminate-map',
});
// Simulate the in-memory sessions Map
const sessions = new Map<number, ActiveSession>();
sessions.set(sessionId, session);
expect(sessions.has(sessionId)).toBe(true);
// Simulate removeSessionImmediate behavior
sessions.delete(sessionId);
expect(sessions.has(sessionId)).toBe(false);
});
test('should return hasAnyPendingWork false after all sessions terminated', () => {
// Create multiple sessions with messages
const sid1 = createDbSession('content-multi-term-1');
const sid2 = createDbSession('content-multi-term-2');
const sid3 = createDbSession('content-multi-term-3');
enqueueTestMessage(sid1, 'content-multi-term-1');
enqueueTestMessage(sid1, 'content-multi-term-1');
enqueueTestMessage(sid2, 'content-multi-term-2');
enqueueTestMessage(sid3, 'content-multi-term-3');
expect(pendingStore.hasAnyPendingWork()).toBe(true);
// Terminate all sessions
pendingStore.markAllSessionMessagesAbandoned(sid1);
pendingStore.markAllSessionMessagesAbandoned(sid2);
pendingStore.markAllSessionMessagesAbandoned(sid3);
// Spinner must stop
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should not affect other sessions when terminating one', () => {
const sid1 = createDbSession('content-isolate-1');
const sid2 = createDbSession('content-isolate-2');
enqueueTestMessage(sid1, 'content-isolate-1');
enqueueTestMessage(sid2, 'content-isolate-2');
// Terminate only session 1
pendingStore.markAllSessionMessagesAbandoned(sid1);
// Session 2 still has work
expect(pendingStore.getPendingCount(sid1)).toBe(0);
expect(pendingStore.getPendingCount(sid2)).toBe(1);
expect(pendingStore.hasAnyPendingWork()).toBe(true);
});
test('should mark both pending and processing messages as abandoned', () => {
const sessionId = createDbSession('content-mixed-status');
// Enqueue two messages
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
enqueueTestMessage(sessionId, 'content-mixed-status');
// Claim first message (transitions to 'processing')
const claimed = pendingStore.claimNextMessage(sessionId);
expect(claimed).not.toBeNull();
expect(claimed!.id).toBe(msgId1);
// Now we have 1 processing + 1 pending
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
// Terminate should mark BOTH as failed
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(abandoned).toBe(2);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
test('should enforce invariant: no pending work after terminate regardless of initial state', () => {
const sessionId = createDbSession('content-invariant');
// Create a complex initial state: some pending, some processing, some with stale timestamps
enqueueTestMessage(sessionId, 'content-invariant');
enqueueTestMessage(sessionId, 'content-invariant');
enqueueTestMessage(sessionId, 'content-invariant');
// Claim one (processing)
pendingStore.claimNextMessage(sessionId);
// Verify complex state
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
// THE INVARIANT: after terminate, hasAnyPendingWork MUST be false
pendingStore.markAllSessionMessagesAbandoned(sessionId);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
});
});
});