@xdarkicex/openclaw-memory-libravdb 1.9.9 → 1.9.10-beta.2

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.
@@ -204,11 +204,11 @@ function normalizeKernelContent(content, options = {}) {
204
204
  * code must explicitly import the symbol to call the hook.
205
205
  */
206
206
  export const FLUSH_ASYNC_INGESTION = Symbol("flushAsyncIngestion");
207
- let maxOptimizationMemoCacheSize = 1000;
207
+ let maxOptimizationMemoCacheSize = 50000;
208
208
  const metadataEnvelopeCache = new Map();
209
209
  const metadataEnvelopeRetainCache = new Map();
210
210
  export function setOptimizationMemoCacheSize(size) {
211
- maxOptimizationMemoCacheSize = size > 0 ? size : 1000;
211
+ maxOptimizationMemoCacheSize = size > 0 ? size : 50000;
212
212
  }
213
213
  /**
214
214
  * Evicts the oldest half of entries from a Map when it exceeds maxSize.
@@ -1750,17 +1750,32 @@ export function buildContextEngineFactory(runtime, cfg, logger = console) {
1750
1750
  // BeforeTurnKernel: semantic memory retrieval against the current user query.
1751
1751
  // Skip for automated triggers (heartbeat, cron, memory, overflow) — saves
1752
1752
  // an embedding call and RPC round trip on non-interactive turns.
1753
+ const btLog = cfg.beforeTurnDebug
1754
+ ? (msg) => logger.info?.(msg)
1755
+ : (_msg) => { };
1753
1756
  let beforeTurnPredictions = null;
1754
1757
  let beforeTurnQueryHint = null;
1755
- if (cfg.beforeTurnEnabled !== false && isInteractiveTrigger(sessionId)) {
1758
+ if (cfg.beforeTurnEnabled === false) {
1759
+ btLog(`BeforeTurnKernel disabled by config sessionId=${sessionId}`);
1760
+ }
1761
+ else if (!isInteractiveTrigger(sessionId)) {
1762
+ btLog(`BeforeTurnKernel skipped: non-interactive trigger sessionId=${sessionId}`);
1763
+ }
1764
+ else {
1756
1765
  beforeTurnQueryHint = extractQueryHint(messages, (text) => typeof text === "string" ? text.replace(OPENCLAW_LEADING_TIMESTAMP_PREFIX_RE, "").trim() : text);
1757
- if (beforeTurnQueryHint && !isNewUserTurn(messages)) {
1766
+ if (!beforeTurnQueryHint) {
1767
+ btLog(`BeforeTurnKernel skipped: no query hint extracted sessionId=${sessionId}`);
1768
+ }
1769
+ else if (!isNewUserTurn(messages)) {
1770
+ btLog(`BeforeTurnKernel skipped: not a new user turn sessionId=${sessionId}`);
1758
1771
  beforeTurnQueryHint = null;
1759
1772
  }
1760
1773
  if (beforeTurnQueryHint && isBeforeTurnCircuitOpen(sessionId)) {
1774
+ btLog(`BeforeTurnKernel skipped: circuit open sessionId=${sessionId}`);
1761
1775
  beforeTurnQueryHint = null;
1762
1776
  }
1763
1777
  if (beforeTurnQueryHint) {
1778
+ btLog(`BeforeTurnKernel calling sessionId=${sessionId} hint=${beforeTurnQueryHint.slice(0, 50)}`);
1764
1779
  // Include message count in cache key so identical queries
1765
1780
  // in different turns don't return stale predictions.
1766
1781
  const turnScopedHint = `${messages.length}:${beforeTurnQueryHint}`;
package/dist/index.js CHANGED
@@ -35657,11 +35657,11 @@ function normalizeKernelContent(content, options = {}) {
35657
35657
  });
35658
35658
  }
35659
35659
  var FLUSH_ASYNC_INGESTION = /* @__PURE__ */ Symbol("flushAsyncIngestion");
35660
- var maxOptimizationMemoCacheSize = 1e3;
35660
+ var maxOptimizationMemoCacheSize = 5e4;
35661
35661
  var metadataEnvelopeCache = /* @__PURE__ */ new Map();
35662
35662
  var metadataEnvelopeRetainCache = /* @__PURE__ */ new Map();
35663
35663
  function setOptimizationMemoCacheSize(size) {
35664
- maxOptimizationMemoCacheSize = size > 0 ? size : 1e3;
35664
+ maxOptimizationMemoCacheSize = size > 0 ? size : 5e4;
35665
35665
  }
35666
35666
  function evictOldestHalf(map, maxSize) {
35667
35667
  if (map.size < maxSize) return;
@@ -36984,20 +36984,31 @@ function buildContextEngineFactory(runtime, cfg, logger = console) {
36984
36984
  );
36985
36985
  }
36986
36986
  }
36987
+ const btLog = cfg.beforeTurnDebug ? (msg) => logger.info?.(msg) : (_msg) => {
36988
+ };
36987
36989
  let beforeTurnPredictions = null;
36988
36990
  let beforeTurnQueryHint = null;
36989
- if (cfg.beforeTurnEnabled !== false && isInteractiveTrigger(sessionId)) {
36991
+ if (cfg.beforeTurnEnabled === false) {
36992
+ btLog(`BeforeTurnKernel disabled by config sessionId=${sessionId}`);
36993
+ } else if (!isInteractiveTrigger(sessionId)) {
36994
+ btLog(`BeforeTurnKernel skipped: non-interactive trigger sessionId=${sessionId}`);
36995
+ } else {
36990
36996
  beforeTurnQueryHint = extractQueryHint(
36991
36997
  messages,
36992
36998
  (text) => typeof text === "string" ? text.replace(OPENCLAW_LEADING_TIMESTAMP_PREFIX_RE, "").trim() : text
36993
36999
  );
36994
- if (beforeTurnQueryHint && !isNewUserTurn(messages)) {
37000
+ if (!beforeTurnQueryHint) {
37001
+ btLog(`BeforeTurnKernel skipped: no query hint extracted sessionId=${sessionId}`);
37002
+ } else if (!isNewUserTurn(messages)) {
37003
+ btLog(`BeforeTurnKernel skipped: not a new user turn sessionId=${sessionId}`);
36995
37004
  beforeTurnQueryHint = null;
36996
37005
  }
36997
37006
  if (beforeTurnQueryHint && isBeforeTurnCircuitOpen(sessionId)) {
37007
+ btLog(`BeforeTurnKernel skipped: circuit open sessionId=${sessionId}`);
36998
37008
  beforeTurnQueryHint = null;
36999
37009
  }
37000
37010
  if (beforeTurnQueryHint) {
37011
+ btLog(`BeforeTurnKernel calling sessionId=${sessionId} hint=${beforeTurnQueryHint.slice(0, 50)}`);
37001
37012
  const turnScopedHint = `${messages.length}:${beforeTurnQueryHint}`;
37002
37013
  const cached = turnCache.get(sessionId, turnScopedHint);
37003
37014
  if (cached?.predictions) {
package/dist/types.d.ts CHANGED
@@ -131,6 +131,8 @@ export interface PluginConfig {
131
131
  grpcEndpointTlsMode?: "auto" | "tls" | "insecure";
132
132
  /** Whether BeforeTurnKernel retrieval is enabled. Default: true */
133
133
  beforeTurnEnabled?: boolean;
134
+ /** Enable verbose BeforeTurnKernel diagnostic logging. Default: false */
135
+ beforeTurnDebug?: boolean;
134
136
  /** Timeout in milliseconds for the BeforeTurnKernel gRPC call. Default: 5000 */
135
137
  beforeTurnTimeoutMs?: number;
136
138
  /** Timeout in milliseconds for the AssembleContextInternal gRPC call. Default: 30000 */
@@ -0,0 +1,386 @@
1
+ # Tool Call Loop Regression: v1.8.9 → v1.9.7
2
+
3
+ ## Symptoms
4
+ - LLM outputs verbatim text twice in a single turn
5
+ - LLM executes the same tool call (image gen) twice in a single turn
6
+ - **Intermittent** — does NOT reproduce on every turn; ~50% hit rate
7
+ - **Tool-call-gated** — only occurs on turns involving tool calls (image gen, file ops, etc.). Plain text conversations never trigger it.
8
+ - "Didn't used to happen" — confirmed clean in v1.8.7–1.8.9
9
+ - First observed after v1.9.0 perf rewrite + v1.9.1 live tool protocol changes
10
+ - Affects provider models broadly (MiniMax M2.7 confirmed); Qwen 3.5 32B INT4 was the target model for the live tool protocol harness
11
+
12
+ **Intermittent nature → race condition, not deterministic logic bug. Tool-only gating → the race specifically affects turns where the daemon's context replay contains tool protocol from the prior turn.**
13
+
14
+ ## Observations
15
+
16
+ ### O1: ~50% hit rate, tool calls only
17
+ Duplication happens roughly half the time on tool call turns. It never triggers on plain text conversations. This narrows the scope to the tool protocol code paths — specifically the cursor-based gates that only activate when `hasKernelToolCallBlock` or `isToolResultRole` match.
18
+
19
+ ### O2: Async ingestion timing window
20
+ The 50/50 split is consistent with a two-processor race: the LLM provider response time (variable, 2-8s) vs the daemon `afterTurn` ingestion time (variable, 0.5-3s). When the provider is slow and ingestion is fast, the async queue drains before the next `assemble()` — no race. When the provider is fast and the daemon is busy (compaction, embedding), the queue hasn't drained — race triggers.
21
+
22
+ ### O3: Regular conversation is immune
23
+ Text-only turns have no tool protocol to classify. The live tool protocol gates (`consumeLiveToolAtCursor`, `findLiveToolSourceInCurrentTurn`) are never entered. The provider replay dedup handles plain text correctly. The race only matters when tool protocol exists in the transcript.
24
+
25
+ ## Timeline
26
+
27
+ | Version | Key Change | Author |
28
+ |---------|-----------|--------|
29
+ | v1.8.9 | Last known-good | — |
30
+ | v1.9.0 | Perf rewrite: async ingestion, O(1) indexing, memoization caches | Juan |
31
+ | v1.9.1 | Live tool protocol preservation (`78da771`, 164 loc) | Juan |
32
+ | v1.9.4 | Provider replay dedup with source-index awareness | xDarkicex |
33
+ | v1.9.5 | Tool result annotation fix: `[^\n]*` → `\s*` | xDarkicex |
34
+ | v1.9.7 | Assemble deadline (30s) | xDarkicex |
35
+
36
+ ## Theory: Live Tool Protocol Harness Feeds Tool Output Back to Model
37
+
38
+ The primary suspect is commit `78da771` ("preserve live tool protocol during context assembly", v1.9.0 → v1.9.1). It added a cursor-guarded tool protocol preservation system that re-injects the current turn's tool calls and results from the source transcript into the assembled context, bypassing the provider replay dedup.
39
+
40
+ **Mechanism:** When the daemon's `assemble()` returns context messages that include historical tool protocol from a prior turn, the `consumeLiveToolAtCursor` and `findLiveToolSourceInCurrentTurn` functions match these against the source transcript. If they match, the original (unsanitized) source message is pushed into context **directly, bypassing the provider-replay dedup** (line 1921).
41
+
42
+ This works for Qwen 3.5 32B INT4 which needed full tool context to avoid orphaned tools. But for other models, injecting the full tool call JSON + tool result JSON from a prior turn into the next turn's context causes the model to treat it as a new instruction — re-executing the tool and repeating the output.
43
+
44
+ ## Code Comparison
45
+
46
+ ### Message Processing Loop
47
+
48
+ **v1.8.9** — Simple, flat. One tool gate, then provider replay or memory:
49
+ ```typescript
50
+ // v1.8.9 — src/context-engine.ts ~line 1525
51
+ if (Array.isArray(result.messages)) {
52
+ for (const message of result.messages) {
53
+ const content = normalizeKernelContent(message.content);
54
+ const historicalToolSource = getHistoricalToolSource(message.role, message.content, content);
55
+ let isRealTranscript = false;
56
+
57
+ if (sourceMessages) {
58
+ // O(n²) linear scan — simple, correct
59
+ isRealTranscript = sourceMessages.some((sm) => {
60
+ if (message.id && sm.id === message.id) return true;
61
+ if (sm.role === message.role && normalizeKernelContent(sm.content) === content) return true;
62
+ return false;
63
+ });
64
+ } else {
65
+ isRealTranscript = message.role === "user" || message.role === "assistant";
66
+ }
67
+
68
+ // SINGLE tool gate — preserve or drop, simple boolean
69
+ if (isLiveToolProtocolMessage(message, content, sourceMessages)) {
70
+ messages.push(preserveLiveToolProtocolMessage(message));
71
+ } else if (isRealTranscript && !historicalToolSource && isProviderReplayRole(message.role)) {
72
+ // Provider replay — NO dedup, simple push
73
+ const sanitizedContent = sanitizeToolCallPatterns(content, {
74
+ stripOpenClawDirectives: message.role === "assistant",
75
+ });
76
+ if (isHistoricalAssistantActionPromise(message.role, sanitizedContent)) {
77
+ continue;
78
+ }
79
+ // Push without dedup — works because daemon doesn't duplicate
80
+ messages.push({
81
+ role: message.role,
82
+ content: sanitizedContent,
83
+ ...(typeof message.id === "string" ? { id: message.id } : {}),
84
+ });
85
+ } else {
86
+ // Memory items only
87
+ if (content.trim().length > 0) {
88
+ const sanitizedContent = sanitizeToolCallPatterns(content, {
89
+ stripOpenClawDirectives: message.role !== "user",
90
+ });
91
+ if (sanitizedContent.trim().length > 0 &&
92
+ shouldRetainHistoricalToolMemory(message.role, historicalToolSource, sanitizedContent)) {
93
+ pushMemoryItem({ content: sanitizedContent, role: message.role, provenance: ... });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ **v1.9.7** — Complex, cursor-guarded. THREE tool gates, source-index-aware dedup:
102
+ ```typescript
103
+ // v1.9.7 — src/context-engine.ts ~line 1894
104
+ if (Array.isArray(result.messages)) {
105
+ const lastUserIndex = sourceMessages ? findLastUserMessageIndex(sourceMessages) : -1;
106
+ let liveSourceCursor = sourceMessages ? lastUserIndex + 1 : undefined;
107
+ let providerReplaySourceCursor: number | undefined = sourceMessages ? 0 : undefined;
108
+ for (const message of result.messages) {
109
+ const content = normalizeKernelContent(message.content);
110
+ const historicalToolSource = getHistoricalToolSource(message.role, message.content, content);
111
+ let isRealTranscript = false;
112
+ if (sourceMessages) {
113
+ isRealTranscript = findMatchingSourceMessageIndex(message, content, sourceMessages) >= 0;
114
+ } else {
115
+ isRealTranscript = message.role === "user" || message.role === "assistant";
116
+ }
117
+
118
+ // GATE 1: consumeLiveToolAtCursor — cursor-guarded, pushes source message directly
119
+ const liveToolProtocolSource = consumeLiveToolAtCursor(
120
+ message, content, sourceMessages, liveSourceCursor,
121
+ lastUserIndex >= 0 ? lastUserIndex : undefined,
122
+ );
123
+ if (liveToolProtocolSource) {
124
+ // ⚠️ PUSHES DIRECTLY — bypasses provider-replay dedup entirely (line 1921)
125
+ messages.push(preserveLiveToolProtocolMessage(liveToolProtocolSource.message));
126
+ liveSourceCursor = liveToolProtocolSource.index + 1;
127
+ }
128
+ // GATE 2: findLiveToolSourceInCurrentTurn — additional skip gate
129
+ else if (findLiveToolSourceInCurrentTurn(message, content, sourceMessages, undefined,
130
+ lastUserIndex >= 0 ? lastUserIndex : undefined) >= 0) {
131
+ // ⚠️ Silently skips — message is dropped
132
+ if (liveSourceCursor !== undefined && sourceMessages) {
133
+ const idx = findMatchingSourceMessageIndex(message, content, sourceMessages, liveSourceCursor);
134
+ if (idx >= liveSourceCursor) liveSourceCursor = idx + 1;
135
+ }
136
+ continue;
137
+ }
138
+ // GATE 3: Historical tool derived — filters assistant replies after tool protocol
139
+ else if (isRealTranscript && !historicalToolSource && isProviderReplayRole(message.role)) {
140
+ if (isHistoricalToolDerivedAssistantReply(message, content, sourceMessages)) {
141
+ // ... cursor advance, continue
142
+ }
143
+ const sanitizedContent = sanitizeToolCallPatterns(content, {
144
+ stripOpenClawDirectives: message.role === "assistant",
145
+ });
146
+ if (isHistoricalAssistantActionPromise(message.role, sanitizedContent)) {
147
+ // ... cursor advance, continue
148
+ }
149
+ // ⚠️ pushProviderReplayMessage — has dedup, but only for provider replay path
150
+ const providerReplaySourceIndex = sourceMessages
151
+ ? findMatchingSourceMessageIndex(message, content, sourceMessages, providerReplaySourceCursor)
152
+ : undefined;
153
+ pushProviderReplayMessage(
154
+ { role: message.role, content: sanitizedContent, ... },
155
+ providerReplaySourceIndex,
156
+ );
157
+ }
158
+ else {
159
+ // Memory items
160
+ // ...
161
+ }
162
+ }
163
+ }
164
+ ```
165
+
166
+ ### Live Tool Protocol Functions
167
+
168
+ **v1.8.9** — Simple boolean check:
169
+ ```typescript
170
+ function isLiveToolProtocolMessage(
171
+ message: { role: string; content?: unknown; id?: string },
172
+ normalizedContent: string,
173
+ sourceMessages: OpenClawCompatibleMessage[] | undefined,
174
+ ): boolean {
175
+ if (!sourceMessages) return false;
176
+ if (!isToolResultRole(message.role) && !hasKernelToolCallBlock(message.content)) return false;
177
+
178
+ const lastUserIndex = findLastUserMessageIndex(sourceMessages);
179
+ const sourceIndex = findMatchingSourceMessageIndex(
180
+ message, normalizedContent, sourceMessages, lastUserIndex + 1,
181
+ );
182
+ if (sourceIndex < 0) return false;
183
+ if (sourceIndex <= lastUserIndex) return false;
184
+ if (hasCompletedAssistantResponseAfter(sourceMessages, sourceIndex)) return false;
185
+ if (hasKernelToolCallBlock(message.content)) return true;
186
+ return hasLiveToolCallBefore(sourceMessages, lastUserIndex, sourceIndex, getToolResultCallId(message));
187
+ }
188
+ ```
189
+
190
+ **v1.9.7** — Cursor-guarded, returns source message + index for position tracking:
191
+ ```typescript
192
+ function findLiveToolSourceInCurrentTurn(
193
+ message: { role: string; content?: unknown; id?: string; [key: string]: unknown },
194
+ normalizedContent: string,
195
+ sourceMessages: OpenClawCompatibleMessage[] | undefined,
196
+ preferredStartIndex?: number,
197
+ providedLastUserIndex?: number,
198
+ ): number {
199
+ if (!sourceMessages) return -1;
200
+ // ⚠️ Allow assistant messages through — daemon flattens structured toolCall blocks into
201
+ // [tool:name] text, which no longer triggers hasKernelToolCallBlock
202
+ if (!isToolResultRole(message.role) && message.role !== "assistant" && !hasKernelToolCallBlock(message.content)) {
203
+ return -1;
204
+ }
205
+
206
+ const lastUserIndex = providedLastUserIndex ?? findLastUserMessageIndex(sourceMessages);
207
+ if (lastUserIndex < 0) return -1;
208
+ const searchStartIndex = preferredStartIndex === undefined
209
+ ? lastUserIndex + 1
210
+ : Math.max(lastUserIndex + 1, preferredStartIndex);
211
+ const sourceIndex = findMatchingSourceMessageIndex(message, content, sourceMessages, searchStartIndex);
212
+ if (sourceIndex < searchStartIndex) return -1;
213
+ if (hasCompletedAssistantResponseAfter(sourceMessages, sourceIndex)) return -1;
214
+
215
+ const sourceMessage = sourceMessages[sourceIndex];
216
+ if (!sourceMessage) return -1;
217
+ if (sourceMessage.role === "assistant" && hasKernelToolCallBlock(sourceMessage.content)) {
218
+ return sourceIndex;
219
+ }
220
+ if (isToolResultRole(sourceMessage.role)) {
221
+ const toolCallId = getToolResultCallId(sourceMessage) ?? getToolResultCallId(message);
222
+ if (hasLiveToolCallBefore(sourceMessages, lastUserIndex, sourceIndex, toolCallId)) {
223
+ return sourceIndex;
224
+ }
225
+ }
226
+ return -1;
227
+ }
228
+ ```
229
+
230
+ Key difference: v1.9.7 added `message.role !== "assistant"` as an allow condition — daemon-flattened `[tool:name]` text from assistant messages now passes through where v1.8.9's `hasKernelToolCallBlock` would have filtered them.
231
+
232
+ ## Theories
233
+
234
+ ### T0: Async ingestion races with next assemble() — cursor base shifts (HIGHEST)
235
+
236
+ v1.9.0 (commit `20dc976`, PR #329) made `afterTurn` ingestion asynchronous — it's enqueued via `enqueueAsyncIngestion()` instead of `await`ed inline. The daemon call that ingests the assistant's tool call response may complete AFTER the next `assemble()` starts.
237
+
238
+ When this race hits:
239
+ 1. Turn N finishes. LLM generated "An army of me" + `[tool:image_gen]`.
240
+ 2. `afterTurn` is enqueued but hasn't completed yet.
241
+ 3. Turn N+1 starts. User sends next message. `assemble()` runs.
242
+ 4. The source transcript passed to `normalizeAssembleResult` does NOT yet include the assistant's tool call response from turn N.
243
+ 5. `consumeLiveToolAtCursor` and `findLiveToolSourceInCurrentTurn` compute cursors against a stale transcript.
244
+ 6. When the async afterTurn FINALLY completes, the next `assemble()` NOW sees the tool protocol in the transcript — cursor behavior changes.
245
+ 7. Depending on provider latency and message cadence, the race hits ~30-50% of turns.
246
+
247
+ This explains the intermittency perfectly. Before v1.9.0, `afterTurn` was synchronous — the daemon call completed before the LLM response started streaming. The source transcript was always up-to-date. The cursor was always computed against a complete transcript.
248
+
249
+ **Evidence for T0:**
250
+ - Intermittent, ~50% hit rate → timing-dependent, not a code path always taken
251
+ - Only occurs on tool call turns (O1) → the race specifically affects cursor-based tool protocol classification
252
+ - Regular conversations are immune (O3) → `consumeLiveToolAtCursor` / `findLiveToolSourceInCurrentTurn` are only entered when tool protocol exists
253
+ - 50/50 split matches two-variable race window (O2) — provider latency 2-8s vs daemon ingest latency 0.5-3s
254
+ - v1.8.9 was synchronous → `afterTurn` always completed before next `assemble()` → no race, clean
255
+ - v1.9.0 introduced async ingestion → race introduced
256
+ - v1.9.1 added cursor-based tool protocol → race affects tool call classification specifically
257
+
258
+ ### T1: Daemon-flattened assistant tool calls leak through (HIGH)
259
+
260
+ In v1.9.7, the `findLiveToolSourceInCurrentTurn` function allows `message.role === "assistant"` through (line ~489). The comment says "Daemon flattens structured toolCall blocks into [tool:name] text, which no longer triggers hasKernelToolCallBlock."
261
+
262
+ When the daemon returns a previous turn's assistant response that contained a tool call, the flattened `[tool:image_gen]` text matches against the source transcript. The function finds the source message, gates 1 or 2 fire, and:
263
+ - Gate 1 pushes the original (unsanitized) source message directly
264
+ - Gate 2 drops it with `continue`
265
+
266
+ BUT: Gate 1 pushes **unsanitized** source messages. If the source message contains the full structured tool call JSON block, the model sees the complete tool call syntax and re-executes it.
267
+
268
+ **T1 is gated on T0** — the race determines whether the source transcript is in a state where this match occurs.
269
+
270
+ ### T2: consumeLiveToolAtCursor pushes message that provider replay also matches (MEDIUM)
271
+
272
+ Gate 1 pushes via `preserveLiveToolProtocolMessage` and advances `liveSourceCursor`. But if the SAME message appears again in the daemon's output (e.g., daemon returns it twice — once from session_raw, once from session_summary), the second occurrence won't match gate 1 (cursor advanced past it) but COULD fall through to gate 3 (provider replay) and get pushed again.
273
+
274
+ The `pushProviderReplayMessage` dedup should catch this, but only if the sanitized content matches exactly. If the source message and daemon message have different formats, the keys won't match.
275
+
276
+ **T2 is gated on T0** — the transcript state affects whether daemon returns duplicate entries.
277
+
278
+ ## Comparison: lossless-claw Tool Dedup Approach
279
+
280
+ The `lossless-claw` plugin (in `/tmp/lossless-claw`) implements a more robust approach to tool call deduplication that our plugin lacks.
281
+
282
+ ### What lossless-claw does differently
283
+
284
+ **1. Tool-use ID-based dedup** (`filterAssistantToolUseBlocks`, transcript-repair.ts:211-254)
285
+ ```typescript
286
+ function filterAssistantToolUseBlocks(msg, seenToolUseIds, options) {
287
+ for (const block of content) {
288
+ const id = extractToolCallId(rec);
289
+ if (isToolUse && id) {
290
+ if (dropAll || seenToolUseIds.has(id)) {
291
+ dropped.push({ id, reason: dropAll ? "terminal" : "duplicate" });
292
+ continue; // ← drops duplicate tool_use blocks by ID
293
+ }
294
+ if (record) seenToolUseIds.add(id);
295
+ }
296
+ kept.push(block);
297
+ }
298
+ }
299
+ ```
300
+ Tracks tool call IDs in a `Set`. If the same `tool_use` block ID appears in a second assistant message, it's dropped immediately. **Our plugin has no tool-ID-based dedup at all** — it relies on content hashing which fails when the same tool call is formatted differently between daemon-flattened and source-message forms.
301
+
302
+ **2. Tool-result ID-based dedup** (`pushToolResult`, transcript-repair.ts:311-322)
303
+ ```typescript
304
+ const pushToolResult = (msg) => {
305
+ const id = extractToolResultId(msg);
306
+ if (id && seenToolResultIds.has(id)) {
307
+ droppedDuplicateCount += 1; // ← drops duplicate tool results by ID
308
+ return;
309
+ }
310
+ if (id) seenToolResultIds.add(id);
311
+ out.push(msg);
312
+ };
313
+ ```
314
+ Duplicated tool results (same `toolCallId`) are dropped. Our plugin pushes tool results as provider replay messages with no tool-ID tracking — the content-based dedup key `${role}\0${content}` doesn't know about tool call IDs at all.
315
+
316
+ **3. Transcript repair** (`sanitizeToolUseResultPairing`, transcript-repair.ts:286-538)
317
+ - Moves `toolResult` messages directly after their matching assistant `tool_use` turn
318
+ - Inserts synthetic error `toolResult` for missing tool call IDs
319
+ - Drops orphaned `toolResult` messages with no matching `tool_use`
320
+ - Drops duplicate `toolResult` messages (same ID)
321
+ - Handles error/aborted turns via `dropAll: true, record: false` mode
322
+
323
+ Our plugin has **no transcript repair at all**. If the daemon returns tool results out of order or orphaned, our plugin passes them through as-is.
324
+
325
+ **4. Content-hash dedup with ordinal tracking** (`buildMessageContentDuplicateClusters`, assembler.ts:1198-1209)
326
+ ```typescript
327
+ function buildMessageContentDuplicateClusters(items) {
328
+ for (const item of items) {
329
+ const hash = hashText(item.text);
330
+ const existing = clusters.get(hash) ?? [];
331
+ existing.push(item);
332
+ clusters.set(hash, existing);
333
+ }
334
+ return formatDuplicateClusters(clusters, () => "message-content");
335
+ }
336
+ ```
337
+ Groups messages by content hash, then reports clusters with >1 item as duplicates. This is diagnostic-only (used for overflow reporting), not active dedup — but it surfaces when the same content appears multiple times.
338
+
339
+ **5. Replay-ID dedup** (`extractPlainToolReplayTextsById`, engine.ts:521-534)
340
+ ```typescript
341
+ function extractPlainToolReplayTextsById(message) {
342
+ const textsById = new Map();
343
+ const duplicateIds = new Set();
344
+ const addText = (replayId, text) => {
345
+ if (duplicateIds.has(replayId)) return; // ← already known duplicate
346
+ if (textsById.has(replayId)) {
347
+ textsById.delete(replayId); // ← second sighting = duplicate
348
+ duplicateIds.add(replayId);
349
+ return;
350
+ }
351
+ textsById.set(replayId, text);
352
+ };
353
+ // ... extracts tool replay texts keyed by toolCallId
354
+ }
355
+ ```
356
+ Tracks replay IDs. First occurrence is stored. Second occurrence marks it as duplicate and removes from output. Third+ occurrences are dropped silently. This is applied during `afterTurn` ingestion to prevent duplicate tool results from being stored.
357
+
358
+ ### Gap analysis: what our plugin is missing
359
+
360
+ | Capability | lossless-claw | libravdb-memory (ours) | Impact |
361
+ |---|---|---|---|
362
+ | Tool-use ID dedup | `filterAssistantToolUseBlocks` — `Set<string>` by ID | None — content-based only | Same tool call with different formatting leaks through |
363
+ | Tool-result ID dedup | `pushToolResult` — `Set<string>` by ID | None — content-based only | Same tool result appears twice in context |
364
+ | Transcript repair | `sanitizeToolUseResultPairing` — reorder, fill gaps, drop orphans | None | Out-of-order tool protocol confuses model |
365
+ | Content hash clusters | `buildMessageContentDuplicateClusters` — diagnostic | None | Can't detect when dedup fails |
366
+ | Replay-ID dedup | `extractPlainToolReplayTextsById` — at ingest time | None | Duplicate tool results stored in daemon |
367
+ | AfterTurn batch dedup | `deduplicateAfterTurnBatch` — tail/suffix match | None | Redundant ingestion of already-stored turns |
368
+
369
+ ### Why this matters for the looping bug
370
+
371
+ The live tool protocol system (`78da771`) injects source-format tool messages into context without tracking tool call IDs. If the daemon's context replay includes the same tool call message twice (once from `session_raw`, once from `session_summary`), both instances get pushed because:
372
+
373
+ 1. `consumeLiveToolAtCursor` pushes the first instance directly (bypasses dedup)
374
+ 2. The second instance falls through to provider replay path
375
+ 3. `pushProviderReplayMessage` checks `${role}\0${content}` — but the daemon-flattened `[tool:image_gen]` format differs from the source-format structured tool call JSON
376
+ 4. Keys don't match → both are pushed → model sees duplicate tool instruction
377
+
378
+ Lossless-claw would catch this because `filterAssistantToolUseBlocks` tracks the actual tool call ID, not the text representation.
379
+
380
+ ## Next Steps
381
+
382
+ 1. **Verify T0 (async race)**: Add a debug log at `assemble()` entry showing `sourceMessages.length`, `lastUserIndex`, and whether async ingestion queue has pending items. If duplicated turns correlate with stale transcript length (e.g., missing the last assistant response), T0 is confirmed.
383
+
384
+ 2. **Mitigation — drain async queue before assemble**: In `assemble()`, `await` the session's async ingestion queue before computing cursors. The `FLUSH_ASYNC_INGESTION` Symbol hook already exists. If blocking on ingest adds too much latency, add a short deadline (2s).
385
+
386
+ 3. **Hardening — make cursor computation tolerate stale transcripts**: If the source transcript is incomplete (async ingest pending), `findLiveToolSourceInCurrentTurn` and `consumeLiveToolAtCursor` should treat unconfirmed messages as historical, not live. Currently they treat anything findable after `lastUserIndex` as live.
@@ -0,0 +1,241 @@
1
+ # Subagent Memory Access — Design Analysis
2
+
3
+ ## Status
4
+
5
+ **Architectural gap identified.** `contextMode: "fork"` is dead code. Subagents cannot currently access parent session memories.
6
+
7
+ ---
8
+
9
+ ## Current Implementation
10
+
11
+ ### Subagent Token Budget
12
+
13
+ ```typescript
14
+ // context-engine.ts:2040-2044
15
+ function normalizeSubagentTokenBudget(value: unknown): number {
16
+ if (typeof value !== "number") return 8000;
17
+ if (!Number.isFinite(value) || value < 0) return 8000;
18
+ return Math.floor(value);
19
+ }
20
+ ```
21
+
22
+ - Default: 8000 tokens
23
+ - `0` disables `memory_expand` entirely
24
+ - Budget tracked by `childSessionKey` in `Map<string, BudgetEntry>`
25
+
26
+ ### prepareSubagentSpawn (Dead Code)
27
+
28
+ ```typescript
29
+ // context-engine.ts:3173-3205
30
+ async prepareSubagentSpawn(params: {
31
+ parentSessionKey: string;
32
+ childSessionKey: string;
33
+ contextMode?: "isolated" | "fork"; // DEFINED BUT NEVER USED
34
+ parentSessionId?: string;
35
+ parentSessionFile?: string;
36
+ childSessionId?: string;
37
+ childSessionFile?: string;
38
+ ttlMs?: number;
39
+ }) {
40
+ const budget = normalizeSubagentTokenBudget(cfg.subagentTokenBudget);
41
+ const key = subagentKey(params.childSessionKey);
42
+ subagentBudgets.set(key, { remaining: budget, total: budget, expiresAt: ... });
43
+ // contextMode is never read — no fork/isolate logic exists
44
+ }
45
+ ```
46
+
47
+ ### Session Isolation
48
+
49
+ ```typescript
50
+ // memory-runtime.ts:229-253
51
+ function resolveSearchCollections(cfg, userId, sessionId, corpus) {
52
+ if (corpus === "sessions") {
53
+ return sessionId ? [resolveSessionSearchCollection(cfg, sessionId)] : [];
54
+ }
55
+ // ...
56
+ // Subagent session collection = session:${subagentSessionId}
57
+ // Parent session collection = session:${parentSessionId}
58
+ // They are DIFFERENT collections — no cross-access
59
+ }
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Lossless-Claw Approach (Reference)
65
+
66
+ ### Session Key Pattern
67
+
68
+ ```
69
+ agent:<agentId>:session:<sessionId> // main agent
70
+ agent:<agentId>:subagent:<uuid> // subagent
71
+ ```
72
+
73
+ ### Parent Context Passing
74
+
75
+ ```typescript
76
+ // Subagent session key encodes parent agentId
77
+ const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`
78
+ ```
79
+
80
+ ### Delegation Grant System
81
+
82
+ Grants are scoped to specific conversation IDs and bound to the child session key.
83
+
84
+ ### Fork Mode Bootstrap
85
+
86
+ > "For forked child sessions, LCM treats a host-copied parent JSONL branch as a first-time bootstrap source and imports only the newest messages that fit within `bootstrapMaxTokens`."
87
+
88
+ ---
89
+
90
+ ## Required Implementation
91
+
92
+ ### 1. Session Key Format
93
+
94
+ Adopt lossless-claw pattern for consistent agent/session encoding:
95
+
96
+ ```
97
+ session-key:agent:<agentId>:session:<sessionId> // main agent
98
+ session-key:agent:<agentId>:subagent:<uuid> // subagent
99
+ ```
100
+
101
+ Or simpler (preserve current format but encode parent relationship):
102
+
103
+ ```
104
+ <current-session-key>:fork:<parent-session-key> // fork mode child
105
+ ```
106
+
107
+ ### 2. Implement contextMode: "fork"
108
+
109
+ When `contextMode === "fork"`:
110
+
111
+ 1. Store `parentSessionId` in subagent budget entry
112
+ 2. In `resolveSearchCollections()`, if fork mode and `sessionId` is a child:
113
+ - Include parent's session collection in search scope
114
+ 3. Subagent can bootstrap from parent's session_summary and session_raw
115
+
116
+ ```typescript
117
+ // In subagentBudgets Map
118
+ interface SubagentBudgetEntry {
119
+ remaining: number;
120
+ total: number;
121
+ expiresAt: number;
122
+ parentSessionId?: string; // Added for fork mode
123
+ contextMode: "isolated" | "fork";
124
+ }
125
+
126
+ // In resolveSearchCollections
127
+ if (entry.contextMode === "fork" && entry.parentSessionId) {
128
+ collections.push(resolveSessionSearchCollection(cfg, entry.parentSessionId));
129
+ }
130
+ ```
131
+
132
+ ### 3. Per-Agent Config
133
+
134
+ ```typescript
135
+ // openclaw.plugin.json configSchema addition
136
+ {
137
+ "agentOverrides": {
138
+ "type": "object",
139
+ "additionalProperties": true,
140
+ "description": "Per-agent configuration overrides keyed by agentId"
141
+ }
142
+ }
143
+ ```
144
+
145
+ Config resolution:
146
+
147
+ ```typescript
148
+ function resolveAgentConfig(cfg: PluginConfig, agentId?: string): Partial<PluginConfig> {
149
+ if (!agentId || !cfg.agentOverrides?.[agentId]) {
150
+ return {};
151
+ }
152
+ return cfg.agentOverrides[agentId];
153
+ }
154
+ ```
155
+
156
+ Usage in budget normalization:
157
+
158
+ ```typescript
159
+ function getEffectiveSubagentTokenBudget(cfg: PluginConfig, agentId?: string): number {
160
+ const overrides = resolveAgentConfig(cfg, agentId);
161
+ const value = overrides.subagentTokenBudget ?? cfg.subagentTokenBudget;
162
+ return normalizeSubagentTokenBudget(value);
163
+ }
164
+ ```
165
+
166
+ ### 4. Subagent Session Key Encoding
167
+
168
+ When OpenClaw spawns a subagent, it provides:
169
+ - `ctx.sessionKey` — the child's session key
170
+ - `ctx.agentId` — may be different from parent or undefined
171
+
172
+ We can detect subagent via:
173
+ - Session key suffix pattern (if OpenClaw encodes it)
174
+ - Parent-child relationship via lifecycle hooks
175
+
176
+ Current detection (not implemented):
177
+
178
+ ```typescript
179
+ // context-engine.ts
180
+ function isSubagentSessionKey(sessionKey: string): boolean {
181
+ // Pattern: detect if this is a subagent spawn vs main agent
182
+ // Depends on OpenClaw's session key format for subagents
183
+ }
184
+ ```
185
+
186
+ ---
187
+
188
+ ## OpenClaw Agent Flag
189
+
190
+ OpenClaw supports `--agent <id>` for multi-agent deployments. The plugin receives `agentId` via `OpenClawPluginToolContext`.
191
+
192
+ Current handling:
193
+ - `LIBRAVDB_AGENT_ID` env var for container/CI override
194
+ - `cfg.tenantId` for explicit DB isolation
195
+ - `resolveTenantKey()` priority: `tenantId` > `LIBRAVDB_AGENT_ID` > `userId`
196
+
197
+ For per-agent memory isolation:
198
+ - Different `--agent` values → different `agentId` → different collection namespaces
199
+ - Agent's subagents inherit same `agentId` in session key pattern
200
+
201
+ ---
202
+
203
+ ## Implementation Priority
204
+
205
+ | Priority | Item | Description |
206
+ |----------|------|-------------|
207
+ | P0 | Session key pattern | Define how parent/child relationship is encoded |
208
+ | P1 | contextMode: "fork" | Implement parent session access for fork mode |
209
+ | P2 | Per-agent config | Add `agentOverrides` to configSchema |
210
+ | P3 | Bootstrap token limit | Prevent parent import from exceeding budget |
211
+
212
+ ---
213
+
214
+ ## Files to Modify
215
+
216
+ - `src/context-engine.ts` — implement fork mode in `prepareSubagentSpawn` and `resolveSearchCollections`
217
+ - `src/memory-scopes.ts` — add subagent session key parsing
218
+ - `src/openclaw.plugin.json` — add `agentOverrides` to configSchema
219
+ - `src/plugin-runtime.ts` — pass agentId to config resolution
220
+ - `src/tools/memory-recall.ts` — include parent session in grep when fork mode
221
+
222
+ ---
223
+
224
+ ## Rejected Approaches
225
+
226
+ ### excludeAgents / excludeSubagents
227
+
228
+ Peetie's proposal to exclude agents from memory injection is a blunt instrument that:
229
+ 1. Bypasses proper isolation (agents are already isolated by session collection)
230
+ 2. Cannot grant selective access (all-or-nothing)
231
+ 3. Doesn't solve the fork use case (subagent needs parent access, not exclusion)
232
+
233
+ The correct solution is proper fork mode, not exclusion lists.
234
+
235
+ ---
236
+
237
+ ## References
238
+
239
+ - Lossless-claw: `src/plugin/index.ts:44-59` (session key parsing)
240
+ - Lossless-claw: `src/focus-briefs.ts:737` (child session key creation)
241
+ - Lossless-claw: `docs/architecture.md:215-224` (fork bootstrap)
@@ -2,7 +2,7 @@
2
2
  "id": "libravdb-memory",
3
3
  "name": "LibraVDB Memory",
4
4
  "description": "Persistent vector memory with three-tier hybrid scoring",
5
- "version": "1.9.9",
5
+ "version": "1.9.10-beta.2",
6
6
  "kind": [
7
7
  "memory",
8
8
  "context-engine"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdarkicex/openclaw-memory-libravdb",
3
- "version": "1.9.9",
3
+ "version": "1.9.10-beta.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",