Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/public/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| `CLAUDE_MEM_WORKER_HOST` | `127.0.0.1` | Worker service host address |
| `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data root — every other path (database, chroma, logs, settings.json, worker.pid) derives from this |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
| `CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS` | `false` | When `true`, skip **every** subagent observation (any tool call carrying an `agentId`). See [Subagent & Dynamic Workflow filtering](#subagent--dynamic-workflow-filtering). |
| `CLAUDE_MEM_SKIP_AGENT_TYPES` | _(empty)_ | Comma-separated `agent_type` values to skip, e.g. `workflow-subagent,Explore,Plan`. Main-session observations are unaffected. |

### Subagent & Dynamic Workflow filtering

Claude Code [Dynamic Workflows](https://code.claude.com/docs/en/workflows) fan a single
prompt out to tens-to-hundreds of parallel subagents (`agent_type: workflow-subagent`).
Every tool call by every subagent fires a `PostToolUse` hook, so claude-mem creates one
provider-analyzed observation per call — a single run can emit **hundreds-to-thousands**
of low-signal observations. That can exhaust the configured provider's free-tier quota
(HTTP 429) and drop the rest of the run, including valuable main-session work.

Two settings let you filter these out **before** any worker round-trip or provider call.
Both default to off, so existing behavior is preserved.

- **Drop all subagent noise (recommended for heavy workflow users):**

```json
{ "CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS": "true" }
```

This is the robust lever — it keys off the presence of an `agentId`, so it catches
every subagent regardless of type. Main-session observations are kept.

- **Surgically drop specific agent types** (keep some subagents, drop others):

```json
{ "CLAUDE_MEM_SKIP_AGENT_TYPES": "workflow-subagent" }
```

Add a comma-separated list of `agent_type` values. The two settings combine as a
union: an observation is skipped if the global toggle matches **or** its `agent_type`
is in the list.

> Subagent **summaries** are already skipped automatically; these settings add the same
> control for **observations**. Durable cross-session knowledge generally comes from the
> main session, so dropping ephemeral subagent steps usually improves signal-to-noise.

### Gemini Provider Settings

Expand Down
14 changes: 7 additions & 7 deletions plugin/scripts/context-generator.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugin/scripts/mcp-server.cjs

Large diffs are not rendered by default.

262 changes: 139 additions & 123 deletions plugin/scripts/server-beta-service.cjs

Large diffs are not rendered by default.

340 changes: 170 additions & 170 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions plugin/ui/viewer-bundle.js

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions src/cli/handlers/observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { shouldTrackProject } from '../../shared/should-track-project.js';
import { shouldSkipAgentObservation } from '../../shared/should-skip-agent-observation.js';
import { loadFromFileOnce } from '../../shared/hook-settings.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
import { resolveRuntimeContext, logServerBetaFallback } from '../../services/hooks/runtime-selector.js';
import { isServerBetaClientError } from '../../services/hooks/server-beta-client.js';
Expand Down Expand Up @@ -60,6 +62,20 @@ export const observationHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}

// #2736 — drop subagent observations BEFORE any worker HTTP call or provider
// request. Placed ahead of the runtime branch so it covers both the worker
// and server-beta runtimes. Saves the round-trip and the provider tokens,
// and prevents Dynamic Workflows fan-out from exhausting provider quota.
const skip = shouldSkipAgentObservation(input.agentId, input.agentType, loadFromFileOnce());
if (skip.skip) {
logger.debug('HOOK', `Skipping observation: ${skip.reason}`, {
toolName,
agentId: input.agentId,
agentType: input.agentType,
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}

const runtime = resolveRuntimeContext();
if (runtime.runtime === 'server-beta') {
try {
Expand Down
10 changes: 10 additions & 0 deletions src/services/worker/http/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SessionEventBroadcaster } from '../events/SessionEventBroadcaster.
import type { ParsedSummary } from '../../../sdk/parser.js';
import { stripMemoryTagsFromJson } from '../../../utils/tag-stripping.js';
import { isProjectExcluded } from '../../../utils/project-filter.js';
import { shouldSkipAgentObservation } from '../../../shared/should-skip-agent-observation.js';
import { SettingsDefaultsManager } from '../../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../../shared/paths.js';
import { getProjectContext } from '../../../utils/project-name.js';
Expand Down Expand Up @@ -114,6 +115,15 @@ export async function ingestObservation(payload: ObservationPayload): Promise<In
return { ok: true, status: 'skipped', reason: 'tool_excluded' };
}

// #2736 — defense in depth: the hook handler already filters subagent
// observations before this HTTP call, but skip again here so any non-hook
// caller (direct API, future ingestion paths) is filtered before the
// queueObservation → provider request below.
const agentSkip = shouldSkipAgentObservation(payload.agentId, payload.agentType, settings);
if (agentSkip.skip) {
return { ok: true, status: 'skipped', reason: agentSkip.reason! };
}
Comment on lines +123 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When skip is true the reason field is always populated, but the AgentSkipDecision type declares it as optional (reason?: AgentSkipReason). The non-null assertion silences TypeScript here but will throw at runtime if the invariant is ever broken by a future change to shouldSkipAgentObservation. Replacing with ?? 'agent_skip' makes the fallback explicit.

Suggested change
if (agentSkip.skip) {
return { ok: true, status: 'skipped', reason: agentSkip.reason! };
}
if (agentSkip.skip) {
return { ok: true, status: 'skipped', reason: agentSkip.reason ?? 'agent_skip' };
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/services/worker/http/shared.ts
Line: 123-125

Comment:
When `skip` is `true` the `reason` field is always populated, but the `AgentSkipDecision` type declares it as optional (`reason?: AgentSkipReason`). The non-null assertion silences TypeScript here but will throw at runtime if the invariant is ever broken by a future change to `shouldSkipAgentObservation`. Replacing with `?? 'agent_skip'` makes the fallback explicit.

```suggestion
  if (agentSkip.skip) {
    return { ok: true, status: 'skipped', reason: agentSkip.reason ?? 'agent_skip' };
  }
```

How can I resolve this? If you propose a fix, please make it concise.


const fileOperationTools = new Set(['Edit', 'Write', 'Read', 'NotebookEdit']);
if (fileOperationTools.has(payload.toolName) && payload.toolInput && typeof payload.toolInput === 'object') {
const input = payload.toolInput as { file_path?: string; notebook_path?: string };
Expand Down
6 changes: 5 additions & 1 deletion src/shared/SettingsDefaultsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export interface SettingsDefaults {
CLAUDE_MEM_WORKER_PORT: string;
CLAUDE_MEM_WORKER_HOST: string;
CLAUDE_MEM_SKIP_TOOLS: string;
CLAUDE_MEM_PROVIDER: string;
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: string; // #2736 — skip ALL subagent observations (any hook event carrying an agentId)
CLAUDE_MEM_SKIP_AGENT_TYPES: string; // #2736 — comma-separated agent_type values to skip (e.g. workflow-subagent,Explore)
CLAUDE_MEM_PROVIDER: string;
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
CLAUDE_MEM_GEMINI_API_KEY: string;
CLAUDE_MEM_GEMINI_MODEL: string;
Expand Down Expand Up @@ -89,6 +91,8 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_PORT: String(37700 + ((process.getuid?.() ?? 77) % 100)),
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false', // #2736 — default off preserves current behavior; set 'true' to skip every subagent observation (recommended for heavy Dynamic Workflows users)
CLAUDE_MEM_SKIP_AGENT_TYPES: '', // #2736 — default empty preserves current behavior; recommended value 'workflow-subagent' to drop Dynamic Workflows fan-out noise
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
Expand Down
75 changes: 75 additions & 0 deletions src/shared/should-skip-agent-observation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Shared, pure decision helper for skipping subagent observations (issue #2736).
//
// Two filtering points call this with the SAME logic so behavior can't drift:
// 1. The PostToolUse hook handler (src/cli/handlers/observation.ts) — runs in
// the short-lived hook process BEFORE any worker HTTP call or provider
// request, so a skipped observation costs nothing. This covers both the
// `worker` and `server-beta` runtimes because it sits ahead of the runtime
// branch.
// 2. The worker ingest path (src/services/worker/http/shared.ts) — defense in
// depth for any caller that reaches the worker without going through the
// hook handler (direct API callers, future ingestion paths).
//
// Motivation: Claude Code Dynamic Workflows fan a single prompt out to
// tens-to-hundreds of parallel subagents (agent_type `workflow-subagent`), each
// tool call firing a PostToolUse hook → one provider-analyzed observation. A
// single run can emit hundreds-to-thousands of low-signal observations, which
// exhausts the configured provider's quota (HTTP 429) and trips the restart
// guard, dropping the rest of the run — including valuable main-session work.

export type AgentSkipReason = 'subagent_observation' | 'agent_type_excluded';

export interface AgentSkipSettings {
/** When 'true', skip every observation carrying an agentId (any subagent). */
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: string;
/** Comma-separated agent_type values to skip (e.g. "workflow-subagent,Explore"). */
CLAUDE_MEM_SKIP_AGENT_TYPES: string;
}

export interface AgentSkipDecision {
skip: boolean;
reason?: AgentSkipReason;
}

const NO_SKIP: AgentSkipDecision = { skip: false };

/** Parse a comma-separated agent_type skip list into a trimmed, de-duped set. */
export function parseSkipAgentTypes(raw: string | undefined | null): Set<string> {
return new Set(
(raw ?? '')
.split(',')
.map(t => t.trim())
.filter(Boolean)
);
}

/**
* Decide whether an observation should be skipped based on its agent context.
*
* Union semantics (simpler and strictly safer than the issue's "priority"
* framing — the global toggle is a superset of any per-type list):
* - If CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' AND an agentId is
* present → skip (the robust lever; independent of the exact type string).
* - Else if agentType is in CLAUDE_MEM_SKIP_AGENT_TYPES → skip (surgical).
*
* Defaults preserve current behavior: with the global toggle off and an empty
* skip list, this never skips.
*/
export function shouldSkipAgentObservation(
agentId: string | undefined | null,
agentType: string | undefined | null,
settings: AgentSkipSettings,
): AgentSkipDecision {
if (settings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' && agentId) {
return { skip: true, reason: 'subagent_observation' };
Comment on lines +55 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Per-type list can silently drop main-session observations

The global toggle (CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS) correctly gates on agentId, so it is provably safe for main-session events. The per-type list path (CLAUDE_MEM_SKIP_AGENT_TYPES) checks only agentType — no agentId is required — so any main-session observation that carries a matching agentType (without an agentId) is dropped. The test at line 71 of the test file explicitly validates this: shouldSkipAgentObservation(undefined, 'workflow-subagent', s).skip === true. This contradicts the acceptance criterion "Main-session observations are unaffected" and the docs' claim under CLAUDE_MEM_SKIP_AGENT_TYPES. If Claude Code ever emits agentType on a main-session hook payload (even without agentId), those observations would be silently lost.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/shared/should-skip-agent-observation.ts
Line: 55-64

Comment:
**Per-type list can silently drop main-session observations**

The global toggle (`CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS`) correctly gates on `agentId`, so it is provably safe for main-session events. The per-type list path (`CLAUDE_MEM_SKIP_AGENT_TYPES`) checks only `agentType` — no `agentId` is required — so any main-session observation that carries a matching `agentType` (without an `agentId`) is dropped. The test at line 71 of the test file explicitly validates this: `shouldSkipAgentObservation(undefined, 'workflow-subagent', s).skip === true`. This contradicts the acceptance criterion "Main-session observations are unaffected" and the docs' claim under `CLAUDE_MEM_SKIP_AGENT_TYPES`. If Claude Code ever emits `agentType` on a main-session hook payload (even without `agentId`), those observations would be silently lost.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}

if (agentType) {
const skipTypes = parseSkipAgentTypes(settings.CLAUDE_MEM_SKIP_AGENT_TYPES);
if (skipTypes.has(agentType)) {
return { skip: true, reason: 'agent_type_excluded' };
}
}

return NO_SKIP;
}
2 changes: 2 additions & 0 deletions src/ui/viewer/constants/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ export const DEFAULT_SETTINGS = {
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',

CLAUDE_MEM_EXCLUDED_PROJECTS: '',
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false',
CLAUDE_MEM_SKIP_AGENT_TYPES: '',
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]',
} as const;
176 changes: 176 additions & 0 deletions tests/cli/handlers/observation-subagent-skip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, spyOn, mock } from 'bun:test';

// Capture real exports before mock.module mutates the live namespace, then
// re-register the snapshots in afterAll so these mocks do not leak into later
// test files (bun's mock.module is process-global; mock.restore() does NOT undo it).
import * as realHookSettings from '../../../src/shared/hook-settings.js';
import * as realWorkerUtils from '../../../src/shared/worker-utils.js';
import * as realRuntimeSelector from '../../../src/services/hooks/runtime-selector.js';
const realHookSettingsSnapshot = { ...realHookSettings };
const realWorkerUtilsSnapshot = { ...realWorkerUtils };
const realRuntimeSelectorSnapshot = { ...realRuntimeSelector };

// Mutable settings the handler sees via loadFromFileOnce() (used by both
// shouldTrackProject and shouldSkipAgentObservation). Tests reset it per case.
let mockSettings: Record<string, string> = {
CLAUDE_MEM_EXCLUDED_PROJECTS: '',
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false',
CLAUDE_MEM_SKIP_AGENT_TYPES: '',
};

mock.module('../../../src/shared/hook-settings.js', () => ({
loadFromFileOnce: () => mockSettings,
}));

const workerCallLog: Array<{ path: string; method: string; body: unknown }> = [];
mock.module('../../../src/shared/worker-utils.js', () => ({
executeWithWorkerFallback: (path: string, method: string, body: unknown) => {
workerCallLog.push({ path, method, body });
return Promise.resolve({ status: 'queued' });
},
isWorkerFallback: () => false,
}));

// Mutable runtime context so individual cases can flip between the `worker` and
// `server-beta` runtimes. The skip check must run BEFORE this branch, so a
// skipped subagent observation must reach neither dispatchToWorker nor recordEvent.
const recordEventLog: Array<unknown> = [];
let mockRuntime: Record<string, unknown> = { runtime: 'worker' };
const serverBetaRuntime = () => ({
runtime: 'server-beta',
projectId: 'proj-test',
serverBaseUrl: 'http://127.0.0.1:0',
client: {
recordEvent: (evt: unknown) => {
recordEventLog.push(evt);
return Promise.resolve();
},
},
});
mock.module('../../../src/services/hooks/runtime-selector.js', () => ({
resolveRuntimeContext: () => mockRuntime,
logServerBetaFallback: () => {},
}));

import { logger } from '../../../src/utils/logger.js';

let loggerSpies: ReturnType<typeof spyOn>[] = [];

beforeEach(() => {
workerCallLog.length = 0;
recordEventLog.length = 0;
mockRuntime = { runtime: 'worker' };
mockSettings = {
CLAUDE_MEM_EXCLUDED_PROJECTS: '',
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false',
CLAUDE_MEM_SKIP_AGENT_TYPES: '',
};
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'dataIn').mockImplementation(() => {}),
];
});

afterEach(() => {
loggerSpies.forEach(spy => spy.mockRestore());
});

afterAll(() => {
mock.module('../../../src/shared/hook-settings.js', () => realHookSettingsSnapshot);
mock.module('../../../src/shared/worker-utils.js', () => realWorkerUtilsSnapshot);
mock.module('../../../src/services/hooks/runtime-selector.js', () => realRuntimeSelectorSnapshot);
});

const baseInput = (over: Record<string, unknown> = {}) => ({
sessionId: 'session-abc',
cwd: '/tmp',
platform: 'claude-code',
toolName: 'Bash',
toolInput: { command: 'ls' },
toolResponse: { stdout: '' },
...over,
});

describe('observationHandler — subagent observation filtering (#2736)', () => {
it('dispatches to the worker for a main-session observation (defaults)', async () => {
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(baseInput());
expect(result.continue).toBe(true);
expect(workerCallLog.length).toBe(1);
expect(workerCallLog[0].path).toBe('/api/sessions/observations');
});

it('dispatches subagent observations by default (no silent behavior change)', async () => {
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(
baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' })
);
expect(result.continue).toBe(true);
expect(workerCallLog.length).toBe(1);
});

it('skips ALL subagent observations when CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS=true', async () => {
mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true';
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(
baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' })
);
expect(result.continue).toBe(true);
expect(result.exitCode).toBe(0);
expect(workerCallLog.length).toBe(0); // no HTTP round-trip, no provider call
});

it('does NOT skip the main session when the global toggle is on', async () => {
mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true';
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(baseInput()); // no agentId
expect(result.continue).toBe(true);
expect(workerCallLog.length).toBe(1);
});

it('skips only the listed agent_type values', async () => {
mockSettings.CLAUDE_MEM_SKIP_AGENT_TYPES = 'workflow-subagent,Explore';
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');

const skipped = await observationHandler.execute(
baseInput({ agentId: 'a', agentType: 'workflow-subagent' })
);
expect(skipped.continue).toBe(true);
expect(workerCallLog.length).toBe(0);

const kept = await observationHandler.execute(
baseInput({ agentId: 'b', agentType: 'Plan' })
);
expect(kept.continue).toBe(true);
expect(workerCallLog.length).toBe(1);
});

// The skip check sits AHEAD of the runtime branch, so it must protect the
// server-beta runtime too — not just the worker dispatch. These cases would
// fail if the check were ever moved down into the worker-only branch.
it('skips before the server-beta runtime branch — recordEvent is never called', async () => {
mockRuntime = serverBetaRuntime();
mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true';
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(
baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' })
);
expect(result.continue).toBe(true);
expect(result.exitCode).toBe(0);
expect(recordEventLog.length).toBe(0); // never reached the provider via server-beta
expect(workerCallLog.length).toBe(0);
});

it('still records main-session observations on the server-beta runtime', async () => {
mockRuntime = serverBetaRuntime();
mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true';
const { observationHandler } = await import('../../../src/cli/handlers/observation.js');
const result = await observationHandler.execute(baseInput()); // no agentId
expect(result.continue).toBe(true);
expect(recordEventLog.length).toBe(1);
expect(workerCallLog.length).toBe(0);
});
});
Loading