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.
- package/AGENTS.md +11 -4
- package/CLAUDE.md +11 -4
- package/README.md +37 -21
- package/SKILL.md +16 -6
- package/package.json +2 -2
- package/src/clawmem.ts +150 -23
- package/src/hermes/__init__.py +41 -2
- package/src/openclaw/compaction-threshold.ts +166 -0
- package/src/openclaw/engine.ts +520 -241
- package/src/openclaw/index.ts +151 -140
- package/src/openclaw/openclaw.plugin.json +4 -1
- package/src/openclaw/package.json +9 -0
- package/src/openclaw/session-state.ts +55 -0
- package/src/openclaw/transcript-resolver.ts +441 -0
package/src/openclaw/engine.ts
CHANGED
|
@@ -1,66 +1,148 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClawMem
|
|
2
|
+
* ClawMem OpenClaw Plugin — Event handlers (§14.3 pure-memory migration)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
42
|
+
// Test seam — swappable hook execution
|
|
20
43
|
// =============================================================================
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
70
|
+
const defaultHookRunner: HookRunner = {
|
|
71
|
+
execHook: realExecHook,
|
|
72
|
+
parseHookOutput: realParseHookOutput,
|
|
73
|
+
extractContext: realExtractContext,
|
|
33
74
|
};
|
|
34
75
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
153
|
+
// Prompt cleaning (strips OpenClaw noise from the user prompt before search)
|
|
72
154
|
// =============================================================================
|
|
73
155
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// Event payload shapes (mirror PluginHookName event types from OpenClaw core)
|
|
183
|
+
// =============================================================================
|
|
83
184
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
185
|
+
export type BeforePromptBuildEvent = {
|
|
186
|
+
prompt: string;
|
|
187
|
+
messages?: unknown[];
|
|
188
|
+
};
|
|
88
189
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
190
|
+
export type BeforePromptBuildContext = {
|
|
191
|
+
sessionId?: string;
|
|
192
|
+
sessionKey?: string;
|
|
193
|
+
workspaceDir?: string;
|
|
194
|
+
agentId?: string;
|
|
195
|
+
};
|
|
93
196
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
232
|
+
export type BeforeCompactionContext = {
|
|
233
|
+
sessionKey?: string;
|
|
234
|
+
};
|
|
133
235
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
242
|
+
export type SessionStartContext = {
|
|
243
|
+
agentId?: string;
|
|
244
|
+
sessionId: string;
|
|
245
|
+
sessionKey?: string;
|
|
246
|
+
};
|
|
146
247
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
257
|
+
export type BeforeResetEvent = {
|
|
258
|
+
sessionFile?: string;
|
|
259
|
+
messages?: unknown[];
|
|
260
|
+
reason?: string;
|
|
261
|
+
};
|
|
160
262
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
// decisions, handoff notes, and confidence boosts.
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
269
|
+
// =============================================================================
|
|
270
|
+
// Handler implementations
|
|
271
|
+
// =============================================================================
|
|
179
272
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
tokenBudget?: number;
|
|
365
|
+
sessionFile?: string;
|
|
366
|
+
messages?: unknown[];
|
|
236
367
|
force?: boolean;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
444
|
+
const hookInput: Record<string, unknown> = {
|
|
445
|
+
session_id: ctx.sessionId,
|
|
446
|
+
transcript_path: sessionFile,
|
|
447
|
+
};
|
|
283
448
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
}
|