clawmem 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,66 +1,148 @@
1
1
  /**
2
- * ClawMem ContextEngineOpenClaw integration
2
+ * ClawMem OpenClaw Plugin Event handlers (§14.3 pure-memory migration)
3
3
  *
4
- * Implements the ContextEngine interface for OpenClaw's plugin system.
5
- * Phase 1: thin shim that shells out to `clawmem hook <name>` for lifecycle operations.
4
+ * After the §14.3 migration to `kind: "memory"`, ClawMem no longer
5
+ * implements the `ContextEngine` interface. Lifecycle work now hangs off
6
+ * `PluginHookName` events via `api.on()`, with state owned by
7
+ * `session-state.ts` instead of an engine instance.
6
8
  *
7
- * Architecture (per GPT 5.4 High review):
8
- * - assemble(): minimal pass-through (real retrieval happens in before_prompt_build hook)
9
- * - afterTurn(): shells out to decision-extractor, handoff-generator, feedback-loop
10
- * - compact(): shells out to precompact-extract, then delegates to OpenClaw runtime compactor
11
- * - ingest(): no-op (ClawMem captures at turn boundaries, not per-message)
12
- * - bootstrap(): session registration only (context injection via before_prompt_build hook)
9
+ * Critical correctness contract for precompact:
10
+ *
11
+ * `agent_end` (the typed PluginHookName event) is FIRE-AND-FORGET in
12
+ * OpenClaw core (`src/agents/pi-embedded-runner/run/attempt.ts:2226-2249`,
13
+ * literal comment "This is fire-and-forget, so we don't await"). The
14
+ * only awaited slot in the per-turn lifecycle that runs BEFORE compaction
15
+ * can fire is `before_prompt_build` (awaited at `attempt.ts:1661` because
16
+ * its return value `prependContext` is used to build the final prompt).
17
+ *
18
+ * Therefore precompact-extract MUST run inside `handleBeforePromptBuild`,
19
+ * gated by the proximity heuristic in `compaction-threshold.ts`. The
20
+ * `handleAgentEnd` extractors (decision-extractor, handoff-generator,
21
+ * feedback-loop) are eventually-consistent vault writes and can tolerate
22
+ * the fire-and-forget context. `handleBeforeCompaction` exists only as
23
+ * a fire-and-forget defense-in-depth fallback for the rare case where
24
+ * `before_prompt_build` was skipped.
13
25
  */
14
26
 
15
- import type { ClawMemConfig } from "./shell.js";
16
- import { execHook, parseHookOutput, extractContext } from "./shell.js";
27
+ import type { ClawMemConfig, ShellResult } from "./shell.js";
28
+ import {
29
+ execHook as realExecHook,
30
+ parseHookOutput as realParseHookOutput,
31
+ extractContext as realExtractContext,
32
+ } from "./shell.js";
33
+ import {
34
+ setBootstrapContext,
35
+ takeBootstrapContext,
36
+ markSessionSurfaced,
37
+ isSessionSurfaced,
38
+ clearSessionState,
39
+ } from "./session-state.js";
17
40
 
18
41
  // =============================================================================
19
- // Types (matching OpenClaw's ContextEngine interface without importing it)
42
+ // Test seam swappable hook execution
20
43
  // =============================================================================
21
-
22
- type ContextEngineInfo = {
23
- id: string;
24
- name: string;
25
- version?: string;
26
- ownsCompaction?: boolean;
44
+ //
45
+ // Bun's `mock.module` is process-wide (not file-scoped) and leaks across
46
+ // test files in the same `bun test` invocation, contaminating shell-utility
47
+ // tests that need the real implementations. Instead of fighting the module
48
+ // mock semantics, the handlers route all hook execution through a small
49
+ // indirection that tests can swap via `setHookRunnerForTests`. Production
50
+ // callers never touch the swap — the default delegates to the real
51
+ // shell.ts implementations.
52
+
53
+ export type ExecHookFn = (
54
+ cfg: ClawMemConfig,
55
+ hookName: string,
56
+ input: Record<string, unknown>,
57
+ timeout?: number,
58
+ ) => Promise<ShellResult>;
59
+
60
+ export type ParseHookOutputFn = (stdout: string) => Record<string, unknown> | null;
61
+
62
+ export type ExtractContextFn = (hookOutput: Record<string, unknown> | null) => string;
63
+
64
+ type HookRunner = {
65
+ execHook: ExecHookFn;
66
+ parseHookOutput: ParseHookOutputFn;
67
+ extractContext: ExtractContextFn;
27
68
  };
28
69
 
29
- type AssembleResult = {
30
- messages: unknown[];
31
- estimatedTokens: number;
32
- systemPromptAddition?: string;
70
+ const defaultHookRunner: HookRunner = {
71
+ execHook: realExecHook,
72
+ parseHookOutput: realParseHookOutput,
73
+ extractContext: realExtractContext,
33
74
  };
34
75
 
35
- type CompactResult = {
36
- ok: boolean;
37
- compacted: boolean;
38
- reason?: string;
39
- result?: {
40
- summary?: string;
41
- firstKeptEntryId?: string;
42
- tokensBefore: number;
43
- tokensAfter?: number;
44
- details?: unknown;
76
+ let activeHookRunner: HookRunner = defaultHookRunner;
77
+
78
+ /**
79
+ * Test-only seam: swap the default shell-out runner for a stub. Production
80
+ * code MUST NOT call this. Tests should restore the default in `afterEach`
81
+ * via `restoreHookRunnerForTests()`.
82
+ */
83
+ export function setHookRunnerForTests(runner: Partial<HookRunner>): void {
84
+ activeHookRunner = {
85
+ execHook: runner.execHook ?? defaultHookRunner.execHook,
86
+ parseHookOutput: runner.parseHookOutput ?? defaultHookRunner.parseHookOutput,
87
+ extractContext: runner.extractContext ?? defaultHookRunner.extractContext,
45
88
  };
46
- };
89
+ }
47
90
 
48
- type IngestResult = { ingested: boolean };
49
- type IngestBatchResult = { ingestedCount: number };
50
- type BootstrapResult = {
51
- bootstrapped: boolean;
52
- importedMessages?: number;
53
- reason?: string;
54
- };
91
+ /**
92
+ * Test-only seam: restore the default shell-out runner.
93
+ */
94
+ export function restoreHookRunnerForTests(): void {
95
+ activeHookRunner = defaultHookRunner;
96
+ }
97
+
98
+ // =============================================================================
99
+ // Test seam — swappable transcript-path resolver
100
+ // =============================================================================
101
+ //
102
+ // Mirrors the shell-runner indirection above but for the transcript-path
103
+ // resolver. Tests need to inject a stub that returns deterministic paths
104
+ // without touching the real OpenClaw state directory on disk.
105
+
106
+ export type ResolveSessionFileFn = (params: {
107
+ sessionId?: string;
108
+ agentId?: string;
109
+ sessionKey?: string;
110
+ }) => string | undefined;
111
+
112
+ const defaultResolveSessionFile: ResolveSessionFileFn = (params) =>
113
+ resolveOpenClawSessionFile(params);
114
+
115
+ let activeResolveSessionFile: ResolveSessionFileFn = defaultResolveSessionFile;
55
116
 
56
- type SubagentSpawnPreparation = { rollback: () => void | Promise<void> };
57
- type SubagentEndReason = "deleted" | "completed" | "swept" | "released";
117
+ /**
118
+ * Test-only seam: swap the default transcript-path resolver. Production
119
+ * code MUST NOT call this. Tests should restore the default in `afterEach`
120
+ * via `restoreSessionFileResolverForTests()`.
121
+ */
122
+ export function setSessionFileResolverForTests(resolver: ResolveSessionFileFn): void {
123
+ activeResolveSessionFile = resolver;
124
+ }
125
+
126
+ /**
127
+ * Test-only seam: restore the default transcript-path resolver.
128
+ */
129
+ export function restoreSessionFileResolverForTests(): void {
130
+ activeResolveSessionFile = defaultResolveSessionFile;
131
+ }
132
+ import {
133
+ estimateTokensFromMessages,
134
+ isWithinPrecompactProximity,
135
+ resolveCompactionThreshold,
136
+ resolveProximityRatio,
137
+ type CompactionThresholdConfig,
138
+ } from "./compaction-threshold.js";
139
+ import { resolveOpenClawSessionFile } from "./transcript-resolver.js";
58
140
 
59
141
  // =============================================================================
60
- // Logger interface
142
+ // Logger interface (mirrors the OpenClaw plugin api.logger shape)
61
143
  // =============================================================================
62
144
 
63
- type Logger = {
145
+ export type Logger = {
64
146
  debug?: (msg: string) => void;
65
147
  info: (msg: string) => void;
66
148
  warn: (msg: string) => void;
@@ -68,238 +150,435 @@ type Logger = {
68
150
  };
69
151
 
70
152
  // =============================================================================
71
- // ClawMem ContextEngine
153
+ // Prompt cleaning (strips OpenClaw noise from the user prompt before search)
72
154
  // =============================================================================
73
155
 
74
- export class ClawMemContextEngine {
75
- readonly info: ContextEngineInfo = {
76
- id: "clawmem",
77
- name: "ClawMem Memory System",
78
- version: "0.2.0",
79
- ownsCompaction: false, // Delegate compaction to OpenClaw runtime
80
- };
156
+ /**
157
+ * Strip OpenClaw-specific noise from the user prompt before using it as a
158
+ * search query. Gateway prompts contain metadata, system events, timestamps,
159
+ * and previously injected context that degrade embedding/BM25 quality.
160
+ */
161
+ export function cleanPromptForSearch(prompt: string): string {
162
+ let cleaned = prompt;
163
+ // Strip previously injected vault-context (avoid re-searching our own output)
164
+ cleaned = cleaned.replace(/<vault-context>[\s\S]*?<\/vault-context>/g, "");
165
+ cleaned = cleaned.replace(/<vault-routing>[\s\S]*?<\/vault-routing>/g, "");
166
+ cleaned = cleaned.replace(/<vault-session>[\s\S]*?<\/vault-session>/g, "");
167
+ // Strip OpenClaw sender metadata block
168
+ cleaned = cleaned.replace(/Sender\s*\(untrusted metadata\)\s*:\s*```json\n[\s\S]*?```/g, "");
169
+ cleaned = cleaned.replace(/Sender\s*\(untrusted metadata\)\s*:\s*\{[\s\S]*?\}\s*/g, "");
170
+ // Strip OpenClaw runtime context blocks
171
+ cleaned = cleaned.replace(/OpenClaw runtime context \(internal\):[\s\S]*?(?=\n\n|\n?$)/g, "");
172
+ // Strip "System: ..." single-line event entries
173
+ cleaned = cleaned.replace(/^System:.*$/gm, "");
174
+ // Strip timestamp prefixes e.g. "[Sat 2026-03-14 16:19 GMT+8] "
175
+ cleaned = cleaned.replace(/^\[.*?GMT[+-]\d+\]\s*/gm, "");
176
+ // Collapse excessive whitespace
177
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trim();
178
+ return cleaned || prompt;
179
+ }
81
180
 
82
- private bootstrapContexts = new Map<string, string>();
181
+ // =============================================================================
182
+ // Event payload shapes (mirror PluginHookName event types from OpenClaw core)
183
+ // =============================================================================
83
184
 
84
- constructor(
85
- private readonly cfg: ClawMemConfig,
86
- private readonly logger: Logger,
87
- ) {}
185
+ export type BeforePromptBuildEvent = {
186
+ prompt: string;
187
+ messages?: unknown[];
188
+ };
88
189
 
89
- // ---------------------------------------------------------------------------
90
- // bootstrap — register session, no context injection (P1 finding: bootstrap
91
- // return type has no prompt injection field)
92
- // ---------------------------------------------------------------------------
190
+ export type BeforePromptBuildContext = {
191
+ sessionId?: string;
192
+ sessionKey?: string;
193
+ workspaceDir?: string;
194
+ agentId?: string;
195
+ };
93
196
 
94
- async bootstrap(params: {
95
- sessionId: string;
96
- sessionKey?: string;
97
- sessionFile: string;
98
- }): Promise<BootstrapResult> {
99
- // Fire session-bootstrap hook and cache returned context for first-turn
100
- // injection via before_prompt_build. Running it here (once) avoids the
101
- // duplicate invocation that previously existed in the prompt hook.
102
- const result = await execHook(this.cfg, "session-bootstrap", {
103
- session_id: params.sessionId,
104
- transcript_path: params.sessionFile,
105
- });
197
+ export type BeforePromptBuildResult = {
198
+ prependContext?: string;
199
+ systemPrompt?: string;
200
+ prependSystemContext?: string;
201
+ appendSystemContext?: string;
202
+ };
106
203
 
107
- if (result.exitCode === 0) {
108
- const parsed = parseHookOutput(result.stdout);
109
- const ctx = extractContext(parsed);
110
- if (ctx) {
111
- this.bootstrapContexts.set(params.sessionId, ctx);
112
- }
113
- } else {
114
- this.logger.warn(`clawmem: bootstrap hook failed: ${result.stderr}`);
115
- }
204
+ export type AgentEndEvent = {
205
+ messages: unknown[];
206
+ success: boolean;
207
+ error?: string;
208
+ durationMs?: number;
209
+ };
116
210
 
117
- return { bootstrapped: true };
118
- }
211
+ export type AgentEndContext = {
212
+ runId?: string;
213
+ agentId?: string;
214
+ sessionKey?: string;
215
+ sessionId?: string;
216
+ workspaceDir?: string;
217
+ modelProviderId?: string;
218
+ modelId?: string;
219
+ messageProvider?: string;
220
+ trigger?: string;
221
+ channelId?: string;
222
+ };
119
223
 
120
- /**
121
- * Consume cached bootstrap context for a session (one-shot).
122
- * Returns the context string and removes it from cache.
123
- */
124
- takeBootstrapContext(sessionId: string): string | undefined {
125
- const ctx = this.bootstrapContexts.get(sessionId);
126
- if (ctx) this.bootstrapContexts.delete(sessionId);
127
- return ctx;
128
- }
224
+ export type BeforeCompactionEvent = {
225
+ messageCount: number;
226
+ compactingCount?: number;
227
+ tokenCount?: number;
228
+ messages?: unknown[];
229
+ sessionFile?: string;
230
+ };
129
231
 
130
- // ---------------------------------------------------------------------------
131
- // ingest — no-op (ClawMem captures at turn boundaries via afterTurn)
132
- // ---------------------------------------------------------------------------
232
+ export type BeforeCompactionContext = {
233
+ sessionKey?: string;
234
+ };
133
235
 
134
- async ingest(_params: {
135
- sessionId: string;
136
- sessionKey?: string;
137
- message: unknown;
138
- isHeartbeat?: boolean;
139
- }): Promise<IngestResult> {
140
- return { ingested: false };
141
- }
236
+ export type SessionStartEvent = {
237
+ sessionId: string;
238
+ sessionKey?: string;
239
+ resumedFrom?: string;
240
+ };
142
241
 
143
- // ---------------------------------------------------------------------------
144
- // ingestBatch — no-op
145
- // ---------------------------------------------------------------------------
242
+ export type SessionStartContext = {
243
+ agentId?: string;
244
+ sessionId: string;
245
+ sessionKey?: string;
246
+ };
146
247
 
147
- async ingestBatch?(_params: {
148
- sessionId: string;
149
- sessionKey?: string;
150
- messages: unknown[];
151
- isHeartbeat?: boolean;
152
- }): Promise<IngestBatchResult> {
153
- return { ingestedCount: 0 };
154
- }
248
+ export type SessionEndEvent = {
249
+ sessionId: string;
250
+ sessionKey?: string;
251
+ messageCount: number;
252
+ durationMs?: number;
253
+ reason?: string;
254
+ sessionFile?: string;
255
+ };
155
256
 
156
- // ---------------------------------------------------------------------------
157
- // assemble — minimal pass-through (P1 finding: assemble() lacks the user
158
- // prompt, so retrieval must happen in before_prompt_build hook instead)
159
- // ---------------------------------------------------------------------------
257
+ export type BeforeResetEvent = {
258
+ sessionFile?: string;
259
+ messages?: unknown[];
260
+ reason?: string;
261
+ };
160
262
 
161
- async assemble(params: {
162
- sessionId: string;
163
- sessionKey?: string;
164
- messages: unknown[];
165
- tokenBudget?: number;
166
- }): Promise<AssembleResult> {
167
- // Pass-through: retrieval already injected via before_prompt_build hook
168
- return {
169
- messages: params.messages,
170
- estimatedTokens: 0, // Caller handles estimation
171
- };
172
- }
263
+ export type BeforeResetContext = {
264
+ sessionId?: string;
265
+ sessionKey?: string;
266
+ agentId?: string;
267
+ };
173
268
 
174
- // ---------------------------------------------------------------------------
175
- // afterTurn — run extraction hooks (decision-extractor, handoff-generator,
176
- // feedback-loop) in parallel. These process the completed turn and persist
177
- // decisions, handoff notes, and confidence boosts.
178
- // ---------------------------------------------------------------------------
269
+ // =============================================================================
270
+ // Handler implementations
271
+ // =============================================================================
179
272
 
180
- async afterTurn(params: {
181
- sessionId: string;
182
- sessionKey?: string;
183
- sessionFile: string;
184
- messages: unknown[];
185
- prePromptMessageCount: number;
186
- autoCompactionSummary?: string;
187
- isHeartbeat?: boolean;
188
- tokenBudget?: number;
189
- }): Promise<void> {
190
- // Skip extraction for heartbeat turns
191
- if (params.isHeartbeat) return;
192
-
193
- // Skip if too few messages (no meaningful content to extract)
194
- const newMessages = params.messages.length - params.prePromptMessageCount;
195
- if (newMessages < 2) return;
196
-
197
- const hookInput = {
198
- session_id: params.sessionId,
199
- transcript_path: params.sessionFile,
200
- };
201
-
202
- // Fire all three Stop hooks in parallel (independent operations)
203
- const [decisionResult, handoffResult, feedbackResult] = await Promise.allSettled([
204
- execHook(this.cfg, "decision-extractor", hookInput),
205
- execHook(this.cfg, "handoff-generator", hookInput),
206
- execHook(this.cfg, "feedback-loop", hookInput),
207
- ]);
208
-
209
- // Log failures but don't throw (fail-open)
210
- for (const [name, result] of [
211
- ["decision-extractor", decisionResult],
212
- ["handoff-generator", handoffResult],
213
- ["feedback-loop", feedbackResult],
214
- ] as const) {
215
- if (result.status === "rejected") {
216
- this.logger.warn(`clawmem: afterTurn ${name} failed: ${result.reason}`);
217
- } else if (result.value.exitCode !== 0) {
218
- this.logger.warn(`clawmem: afterTurn ${name} error: ${result.value.stderr}`);
219
- }
273
+ /**
274
+ * before_prompt_build handler — the load-bearing path.
275
+ *
276
+ * Two responsibilities, both synchronous (this handler's promise is awaited
277
+ * by core at `attempt.ts:1661`):
278
+ *
279
+ * 1. Surface relevant memory context via `context-surfacing` hook (and
280
+ * consume cached bootstrap context on the first turn for the session).
281
+ *
282
+ * 2. PRE-EMPTIVE PRECOMPACT: if the messages buffer is at or above the
283
+ * proximity ratio of the compaction threshold, run precompact-extract
284
+ * synchronously. This guarantees `precompact-state.md` is written
285
+ * BEFORE the LLM call begins on this turn — and the LLM call is what
286
+ * can trigger compaction. There is no race because `before_prompt_build`
287
+ * is awaited and runs strictly before any compaction trigger.
288
+ *
289
+ * Returns a `prependContext` so OpenClaw can insert the surfaced context
290
+ * into the effective prompt at `attempt.ts:1670`.
291
+ */
292
+ export async function handleBeforePromptBuild(
293
+ cfg: ClawMemConfig,
294
+ thresholdCfg: CompactionThresholdConfig,
295
+ logger: Logger,
296
+ event: BeforePromptBuildEvent,
297
+ ctx: BeforePromptBuildContext,
298
+ ): Promise<BeforePromptBuildResult | undefined> {
299
+ if (!event.prompt || event.prompt.length < 5) return undefined;
300
+
301
+ const sessionId = ctx.sessionId || "unknown";
302
+ const isFirstTurn = !isSessionSurfaced(sessionId);
303
+
304
+ let context = "";
305
+
306
+ // First turn: consume cached bootstrap context (set during session_start)
307
+ if (isFirstTurn) {
308
+ markSessionSurfaced(sessionId);
309
+ const bootstrapContext = takeBootstrapContext(sessionId);
310
+ if (bootstrapContext) {
311
+ context += bootstrapContext + "\n\n";
220
312
  }
221
313
  }
222
314
 
223
- // ---------------------------------------------------------------------------
224
- // compact run precompact-extract for state preservation, then delegate
225
- // to OpenClaw's built-in runtime compactor. ownsCompaction=false means we
226
- // don't own the algorithm — but v2026.3.30 requires engines to explicitly
227
- // delegate via delegateCompactionToRuntime() instead of returning
228
- // compacted:false and hoping for legacy fallback (which no longer exists).
229
- // ---------------------------------------------------------------------------
315
+ // Every turn: prompt-aware retrieval via context-surfacing hook
316
+ const searchPrompt = cleanPromptForSearch(event.prompt);
317
+ const surfacingResult = await activeHookRunner.execHook(cfg, "context-surfacing", {
318
+ session_id: sessionId,
319
+ prompt: searchPrompt,
320
+ });
321
+
322
+ if (surfacingResult.exitCode === 0) {
323
+ const parsed = activeHookRunner.parseHookOutput(surfacingResult.stdout);
324
+ const surfacedContext = activeHookRunner.extractContext(parsed);
325
+ if (surfacedContext) {
326
+ context += surfacedContext;
327
+ }
328
+ } else {
329
+ logger.warn(`clawmem: context-surfacing failed: ${surfacingResult.stderr}`);
330
+ }
331
+
332
+ // PRE-EMPTIVE PRECOMPACT (synchronous, awaited)
333
+ // This is the load-bearing correctness path — must run BEFORE the LLM call
334
+ // that could trigger compaction on this turn. Resolve the transcript path
335
+ // via the resolver, which consults sessions.json (authoritative) and falls
336
+ // back to filesystem probing. Pass sessionKey so the resolver can do exact
337
+ // store lookups when available.
338
+ const sessionFile = activeResolveSessionFile({
339
+ sessionId,
340
+ agentId: ctx.agentId,
341
+ sessionKey: ctx.sessionKey,
342
+ });
343
+ await maybeRunPrecompactExtract(cfg, thresholdCfg, logger, {
344
+ sessionId,
345
+ sessionFile,
346
+ messages: event.messages,
347
+ });
348
+
349
+ if (!context.trim()) return undefined;
350
+ return { prependContext: context.trim() };
351
+ }
230
352
 
231
- async compact(params: {
353
+ /**
354
+ * Run precompact-extract synchronously when the proximity heuristic indicates
355
+ * compaction is imminent. Idempotent — over-firing is cheap (regex-only).
356
+ *
357
+ * Exported for the `before_compaction` defense-in-depth fallback handler.
358
+ */
359
+ export async function maybeRunPrecompactExtract(
360
+ cfg: ClawMemConfig,
361
+ thresholdCfg: CompactionThresholdConfig,
362
+ logger: Logger,
363
+ params: {
232
364
  sessionId: string;
233
- sessionKey?: string;
234
- sessionFile: string;
235
- tokenBudget?: number;
365
+ sessionFile?: string;
366
+ messages?: unknown[];
236
367
  force?: boolean;
237
- currentTokenCount?: number;
238
- compactionTarget?: "budget" | "threshold";
239
- customInstructions?: string;
240
- runtimeContext?: Record<string, unknown>;
241
- }): Promise<CompactResult> {
242
- // Run precompact-extract to preserve state before compaction
243
- const extractResult = await execHook(this.cfg, "precompact-extract", {
244
- session_id: params.sessionId,
245
- transcript_path: params.sessionFile,
368
+ },
369
+ ): Promise<void> {
370
+ const force = params.force === true;
371
+ if (!force) {
372
+ const estimatedTokens = estimateTokensFromMessages(params.messages);
373
+ const threshold = resolveCompactionThreshold(thresholdCfg);
374
+ const proximityRatio = resolveProximityRatio(thresholdCfg);
375
+ const within = isWithinPrecompactProximity({
376
+ estimatedTokens,
377
+ threshold,
378
+ proximityRatio,
246
379
  });
380
+ if (!within) return;
381
+ logger.debug?.(
382
+ `clawmem: precompact gate fired (tokens=${estimatedTokens} threshold=${threshold} ratio=${proximityRatio})`,
383
+ );
384
+ }
247
385
 
248
- if (extractResult.exitCode !== 0) {
249
- this.logger.warn(`clawmem: precompact-extract failed: ${extractResult.stderr}`);
250
- }
251
-
252
- // Delegate actual compaction to OpenClaw's built-in runtime compactor.
253
- // Lazy import avoids requiring openclaw as a build dependency — the SDK
254
- // path is resolved at runtime by OpenClaw's plugin loader alias system.
255
- try {
256
- const { delegateCompactionToRuntime } = await import("openclaw/plugin-sdk/core");
257
- return await delegateCompactionToRuntime(params as any);
258
- } catch (err) {
259
- this.logger.warn(`clawmem: delegateCompactionToRuntime failed: ${String(err)}`);
260
- return {
261
- ok: false,
262
- compacted: false,
263
- reason: `delegation-failed: ${String(err)}`,
264
- };
265
- }
386
+ // Hard precondition for the underlying hook: precompact-extract validates
387
+ // `transcript_path` and returns empty if missing. Skip the shell-out
388
+ // entirely when the resolver could not find a session file (fail-open).
389
+ if (!params.sessionFile) {
390
+ logger.debug?.(
391
+ `clawmem: precompact-extract skipped no transcript path resolved for session=${params.sessionId}`,
392
+ );
393
+ return;
266
394
  }
267
395
 
268
- // ---------------------------------------------------------------------------
269
- // prepareSubagentSpawn — placeholder for future per-subagent memory scoping
270
- // ---------------------------------------------------------------------------
396
+ const result = await activeHookRunner.execHook(cfg, "precompact-extract", {
397
+ session_id: params.sessionId,
398
+ transcript_path: params.sessionFile,
399
+ });
400
+
401
+ if (result.exitCode !== 0) {
402
+ logger.warn(`clawmem: precompact-extract failed: ${result.stderr}`);
403
+ }
404
+ }
271
405
 
272
- async prepareSubagentSpawn?(_params: {
273
- parentSessionKey: string;
274
- childSessionKey: string;
275
- ttlMs?: number;
276
- }): Promise<SubagentSpawnPreparation | undefined> {
277
- return undefined;
406
+ /**
407
+ * agent_end handler — eventually-consistent vault writes.
408
+ *
409
+ * Runs decision-extractor, handoff-generator, and feedback-loop in parallel
410
+ * to persist the just-finished turn's observations. These writes are
411
+ * idempotent via `saveMemory()` dedup and do NOT need to complete before
412
+ * the next turn starts, so the fire-and-forget context at
413
+ * `attempt.ts:2226-2249` is acceptable for them. (precompact-extract is
414
+ * NOT in this handler — it lives in `handleBeforePromptBuild` for
415
+ * correctness reasons documented at the top of this file.)
416
+ */
417
+ export async function handleAgentEnd(
418
+ cfg: ClawMemConfig,
419
+ logger: Logger,
420
+ event: AgentEndEvent,
421
+ ctx: AgentEndContext,
422
+ ): Promise<void> {
423
+ if (!ctx.sessionId) return;
424
+ // Skip when there's no meaningful content (heartbeats, empty turns)
425
+ if (!Array.isArray(event.messages) || event.messages.length < 2) return;
426
+
427
+ // PluginHookAgentEndEvent does NOT carry sessionFile — resolve the
428
+ // transcript path via the resolver. Pass sessionKey so the resolver
429
+ // can do an exact sessions.json lookup when available, which is the
430
+ // only authoritative way to disambiguate base vs topic-scoped
431
+ // transcripts when both coexist for the same sessionId.
432
+ const sessionFile = activeResolveSessionFile({
433
+ sessionId: ctx.sessionId,
434
+ agentId: ctx.agentId,
435
+ sessionKey: ctx.sessionKey,
436
+ });
437
+ if (!sessionFile) {
438
+ logger.debug?.(
439
+ `clawmem: agent_end extractors skipped — no transcript path resolved for session=${ctx.sessionId}`,
440
+ );
441
+ return;
278
442
  }
279
443
 
280
- // ---------------------------------------------------------------------------
281
- // onSubagentEnded — placeholder
282
- // ---------------------------------------------------------------------------
444
+ const hookInput: Record<string, unknown> = {
445
+ session_id: ctx.sessionId,
446
+ transcript_path: sessionFile,
447
+ };
283
448
 
284
- async onSubagentEnded?(_params: {
285
- childSessionKey: string;
286
- reason: SubagentEndReason;
287
- }): Promise<void> {
288
- // No-op for now
449
+ const [decisionResult, handoffResult, feedbackResult] = await Promise.allSettled([
450
+ activeHookRunner.execHook(cfg, "decision-extractor", hookInput),
451
+ activeHookRunner.execHook(cfg, "handoff-generator", hookInput),
452
+ activeHookRunner.execHook(cfg, "feedback-loop", hookInput),
453
+ ]);
454
+
455
+ for (const [name, result] of [
456
+ ["decision-extractor", decisionResult],
457
+ ["handoff-generator", handoffResult],
458
+ ["feedback-loop", feedbackResult],
459
+ ] as const) {
460
+ if (result.status === "rejected") {
461
+ logger.warn(`clawmem: agent_end ${name} failed: ${String(result.reason)}`);
462
+ } else if (result.value.exitCode !== 0) {
463
+ logger.warn(`clawmem: agent_end ${name} error: ${result.value.stderr}`);
464
+ }
289
465
  }
466
+ }
290
467
 
291
- // ---------------------------------------------------------------------------
292
- // disposecleanup
293
- // ---------------------------------------------------------------------------
468
+ /**
469
+ * before_compaction handler defense-in-depth fallback only.
470
+ *
471
+ * Fire-and-forget at the OpenClaw call site (`pi-embedded-subscribe.handlers
472
+ * .compaction.ts:25-38`), so this handler races with compaction and offers
473
+ * no correctness guarantee on its own. It exists to catch the rare case
474
+ * where `before_prompt_build` did not fire the precompact gate (e.g., the
475
+ * proximity heuristic missed a sudden token-count jump). Forces precompact
476
+ * regardless of proximity since by the time this runs, compaction is
477
+ * already happening.
478
+ */
479
+ export async function handleBeforeCompaction(
480
+ cfg: ClawMemConfig,
481
+ thresholdCfg: CompactionThresholdConfig,
482
+ logger: Logger,
483
+ event: BeforeCompactionEvent,
484
+ ctx: BeforeCompactionContext,
485
+ ): Promise<void> {
486
+ // We can't reliably extract sessionId here — beforeCompactionEvent doesn't
487
+ // carry it. Use sessionKey (or the sessionFile path stem) as a best-effort
488
+ // fallback for the precompact-extract hook. precompact-extract reads the
489
+ // transcript from the path so the session_id field is informational only.
490
+ const sessionId = ctx.sessionKey || "compaction-fallback";
491
+ await maybeRunPrecompactExtract(cfg, thresholdCfg, logger, {
492
+ sessionId,
493
+ sessionFile: event.sessionFile,
494
+ messages: event.messages,
495
+ force: true,
496
+ });
497
+ }
294
498
 
295
- /**
296
- * Clean up per-session state. Called from session_end and before_reset hooks.
297
- */
298
- clearSession(sessionId: string): void {
299
- this.bootstrapContexts.delete(sessionId);
499
+ /**
500
+ * session_start handler fires the `session-bootstrap` hook to gather
501
+ * first-turn context and caches it for one-shot consumption by the next
502
+ * `before_prompt_build` for this session.
503
+ */
504
+ export async function handleSessionStart(
505
+ cfg: ClawMemConfig,
506
+ logger: Logger,
507
+ event: SessionStartEvent,
508
+ _ctx: SessionStartContext,
509
+ ): Promise<void> {
510
+ const result = await activeHookRunner.execHook(cfg, "session-bootstrap", {
511
+ session_id: event.sessionId,
512
+ });
513
+
514
+ if (result.exitCode === 0) {
515
+ const parsed = activeHookRunner.parseHookOutput(result.stdout);
516
+ const bootstrapCtx = activeHookRunner.extractContext(parsed);
517
+ if (bootstrapCtx) {
518
+ setBootstrapContext(event.sessionId, bootstrapCtx);
519
+ }
520
+ } else {
521
+ logger.warn(`clawmem: session-bootstrap failed: ${result.stderr}`);
300
522
  }
301
523
 
302
- async dispose(): Promise<void> {
303
- this.bootstrapContexts.clear();
524
+ logger.info(`clawmem: session started ${event.sessionId}`);
525
+ }
526
+
527
+ /**
528
+ * session_end handler — clears per-session state.
529
+ */
530
+ export function handleSessionEnd(
531
+ logger: Logger,
532
+ event: SessionEndEvent,
533
+ ): void {
534
+ clearSessionState(event.sessionId);
535
+ logger.info(`clawmem: session ended ${event.sessionId} (${event.messageCount} messages)`);
536
+ }
537
+
538
+ /**
539
+ * before_reset handler — runs extraction one last time before the session
540
+ * clears, then wipes the per-session state. Mirrors the prior implementation's
541
+ * "safety net for /new and /reset" behavior.
542
+ */
543
+ export async function handleBeforeReset(
544
+ cfg: ClawMemConfig,
545
+ logger: Logger,
546
+ event: BeforeResetEvent,
547
+ ctx: BeforeResetContext,
548
+ ): Promise<void> {
549
+ if (!ctx.sessionId) return;
550
+
551
+ // Prefer the explicit sessionFile from the event payload (PluginHookBeforeResetEvent
552
+ // DOES carry it). Fall back to resolving via OpenClaw's canonical layout
553
+ // when the event payload is missing the field. Skip the extractor sweep
554
+ // entirely when neither path is available — extractors silently no-op
555
+ // without transcript_path anyway.
556
+ const sessionFile =
557
+ event.sessionFile ??
558
+ activeResolveSessionFile({
559
+ sessionId: ctx.sessionId,
560
+ agentId: ctx.agentId,
561
+ sessionKey: ctx.sessionKey,
562
+ });
563
+ if (!sessionFile) {
564
+ clearSessionState(ctx.sessionId);
565
+ logger.debug?.(
566
+ `clawmem: before_reset extractor sweep skipped — no transcript path resolved for session=${ctx.sessionId}`,
567
+ );
568
+ return;
304
569
  }
570
+
571
+ const hookInput: Record<string, unknown> = {
572
+ session_id: ctx.sessionId,
573
+ transcript_path: sessionFile,
574
+ };
575
+
576
+ await Promise.allSettled([
577
+ activeHookRunner.execHook(cfg, "decision-extractor", hookInput),
578
+ activeHookRunner.execHook(cfg, "handoff-generator", hookInput),
579
+ activeHookRunner.execHook(cfg, "feedback-loop", hookInput),
580
+ ]);
581
+
582
+ clearSessionState(ctx.sessionId);
583
+ logger.info(`clawmem: before_reset cleanup for ${ctx.sessionId}`);
305
584
  }