mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
fix: hard-exclude observer-sessions from hooks; backfill bundle migrations
Stop hook + SessionEnd hook were storing the SDK observer's own
init/continuation/summary prompts in user_prompts, leaking into the
viewer (meta-observation regression). 25 such rows accumulated.
- shouldTrackProject: hard-reject OBSERVER_SESSIONS_DIR (and its subtree)
before consulting user-configured exclusion globs.
- summarize.ts (Stop) and session-complete.ts (SessionEnd): early-return
when shouldTrackProject(cwd) is false, so the observer's own hooks
cannot bootstrap the worker or queue a summary against the meta-session.
- SessionRoutes: cap user-prompt body at 256 KiB at the session-init
boundary so a runaway observer prompt cannot blow up storage.
- SessionStore: add migration 29 (UNIQUE(memory_session_id, content_hash)
on observations) inline so bundled artifacts (worker-service.cjs,
context-generator.cjs) stay schema-consistent — without it, the
ON CONFLICT clause in observation inserts throws.
- spawnSdkProcess: stdio[stdin] from 'ignore' to 'pipe' so the
supervisor can actually feed the observer's stdin.
Also rebuilds plugin/scripts/{worker-service,context-generator}.cjs.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,12 +13,19 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
import { shouldTrackProject } from '../../shared/should-track-project.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
const { sessionId } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
// Same OBSERVER_SESSIONS_DIR exclusion as the rest of the hook surface —
|
||||
// the observer's child Claude Code must never call /api/sessions/complete.
|
||||
if (input.cwd && !shouldTrackProject(input.cwd)) {
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
return { continue: true, suppressOutput: true };
|
||||
|
||||
@@ -13,6 +13,7 @@ import { logger } from '../../utils/logger.js';
|
||||
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
import { shouldTrackProject } from '../../shared/should-track-project.js';
|
||||
|
||||
interface SessionEndResponse {
|
||||
ok: boolean;
|
||||
@@ -22,6 +23,13 @@ interface SessionEndResponse {
|
||||
|
||||
export const summarizeHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Skip Stop hook entirely when firing from an excluded project (notably
|
||||
// OBSERVER_SESSIONS_DIR). Without this, the SDK observer's own Stop hook
|
||||
// queues summaries against its meta-session and triggers a recovery loop.
|
||||
if (input.cwd && !shouldTrackProject(input.cwd)) {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
// Skip summaries in subagent context — subagents do not own the session summary.
|
||||
// Gate on agentId only: that field is present exclusively for Task-spawned subagents.
|
||||
// agentType alone (no agentId) indicates `--agent`-started main sessions, which still
|
||||
|
||||
@@ -73,6 +73,7 @@ export class SessionStore {
|
||||
this.addObservationModelColumns();
|
||||
this.ensureMergedIntoProjectColumns();
|
||||
this.addObservationSubagentColumns();
|
||||
this.addObservationsUniqueContentHashIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1037,6 +1038,47 @@ export class SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add UNIQUE(memory_session_id, content_hash) on observations (migration 29).
|
||||
* Mirrors MigrationRunner.addObservationsUniqueContentHashIndex so bundled
|
||||
* artifacts that embed SessionStore (e.g. worker-service.cjs, context-generator.cjs)
|
||||
* stay schema-consistent. Without this, INSERT … ON CONFLICT(memory_session_id,
|
||||
* content_hash) DO NOTHING throws "ON CONFLICT clause does not match any
|
||||
* PRIMARY KEY or UNIQUE constraint" and every observation insert fails.
|
||||
*/
|
||||
private addObservationsUniqueContentHashIndex(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(29) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
|
||||
const hasMem = obsCols.some(c => c.name === 'memory_session_id');
|
||||
const hasHash = obsCols.some(c => c.name === 'content_hash');
|
||||
if (!hasMem || !hasHash) {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(29, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
this.db.run(`
|
||||
DELETE FROM observations
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM observations
|
||||
GROUP BY memory_session_id, content_hash
|
||||
)
|
||||
`);
|
||||
this.db.run(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_observations_session_hash
|
||||
ON observations(memory_session_id, content_hash)
|
||||
`);
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(29, new Date().toISOString());
|
||||
this.db.run('COMMIT');
|
||||
} catch (error) {
|
||||
this.db.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
|
||||
@@ -29,6 +29,8 @@ import { getProjectContext } from '../../../../utils/project-name.js';
|
||||
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
|
||||
import { RestartGuard } from '../../RestartGuard.js';
|
||||
|
||||
const MAX_USER_PROMPT_BYTES = 256 * 1024;
|
||||
|
||||
export class SessionRoutes extends BaseRouteHandler {
|
||||
private spawnInProgress = new Map<number, boolean>();
|
||||
private crashRecoveryScheduled = new Set<number>();
|
||||
@@ -929,10 +931,22 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
// Only contentSessionId is truly required — Cursor and other platforms
|
||||
// may omit prompt/project in their payload (#838, #1049)
|
||||
const project = req.body.project || 'unknown';
|
||||
const prompt = req.body.prompt || '[media prompt]';
|
||||
let prompt = req.body.prompt || '[media prompt]';
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
const customTitle = req.body.customTitle || undefined;
|
||||
|
||||
const promptByteLength = Buffer.byteLength(prompt, 'utf8');
|
||||
if (promptByteLength > MAX_USER_PROMPT_BYTES) {
|
||||
logger.warn('HTTP', 'SessionRoutes: oversized prompt truncated at session-init boundary', {
|
||||
project,
|
||||
contentSessionId,
|
||||
promptByteLength,
|
||||
maxBytes: MAX_USER_PROMPT_BYTES,
|
||||
preview: prompt.slice(0, 200)
|
||||
});
|
||||
prompt = Buffer.from(prompt).subarray(0, MAX_USER_PROMPT_BYTES).toString('utf8');
|
||||
}
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
|
||||
contentSessionId,
|
||||
project,
|
||||
|
||||
@@ -13,14 +13,23 @@
|
||||
|
||||
import { isProjectExcluded } from '../utils/project-filter.js';
|
||||
import { loadFromFileOnce } from './hook-settings.js';
|
||||
import { OBSERVER_SESSIONS_DIR } from './paths.js';
|
||||
|
||||
/**
|
||||
* @returns true when the project at `cwd` is NOT excluded from claude-mem
|
||||
* tracking, i.e., the hook should proceed; false when the project
|
||||
* matches one of the exclusion globs.
|
||||
*
|
||||
* Hard-excludes OBSERVER_SESSIONS_DIR: the SDK agent spawns Claude Code with
|
||||
* that cwd, and its hooks must never feed the worker — otherwise the observer's
|
||||
* own init/continuation/summary prompts end up stored as `user_prompts` and
|
||||
* leak into the viewer (meta-observation).
|
||||
*/
|
||||
export function shouldTrackProject(cwd: string): boolean {
|
||||
if (!cwd) return true;
|
||||
if (cwd === OBSERVER_SESSIONS_DIR || cwd.startsWith(OBSERVER_SESSIONS_DIR + '/')) {
|
||||
return false;
|
||||
}
|
||||
const settings = loadFromFileOnce();
|
||||
return !isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS);
|
||||
}
|
||||
|
||||
@@ -670,7 +670,7 @@ export function spawnSdkProcess(
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: options.signal,
|
||||
windowsHide: true,
|
||||
})
|
||||
@@ -678,7 +678,7 @@ export function spawnSdkProcess(
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal: options.signal,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user