@nowledge/openclaw-nowledge-mem 0.8.31 → 0.8.34
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/CHANGELOG.md +33 -0
- package/CLAUDE.md +3 -1
- package/README.md +10 -4
- package/SKILL.md +6 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -3
- package/src/client.js +73 -10
- package/src/context-engine.js +3 -2
- package/src/hooks/capture.js +218 -104
- package/src/index.js +2 -0
- package/src/plugin-version.js +3 -0
- package/src/session-delta.js +151 -0
- package/src/thread-sync-timeout.js +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to the Nowledge Mem OpenClaw plugin will be documented in th
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.8.34] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
- Fixed automatic capture for agent-scoped OpenClaw 2.0 Incognito session keys.
|
|
10
|
+
- Added regression coverage for bare and agent-prefixed Incognito sessions.
|
|
11
|
+
|
|
12
|
+
## [0.8.33] - 2026-08-31
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Automatic thread capture now honors OpenClaw 2.0 Incognito session keys.
|
|
17
|
+
Incognito conversations remain process-only unless the user explicitly calls
|
|
18
|
+
a memory-writing tool.
|
|
19
|
+
- Context Engine diagnostics now report the package version instead of a stale
|
|
20
|
+
hardcoded version.
|
|
21
|
+
|
|
22
|
+
## [0.8.32] - 2026-08-21
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Automatic thread capture now sends the paired Mem checkpoint contract
|
|
27
|
+
(`expected_message_count`, a content-bound idempotency key, and
|
|
28
|
+
`append_mode: "checkpointed"`) and only advances the local cursor after an
|
|
29
|
+
explicit append/create acknowledgement.
|
|
30
|
+
- Same-length and earlier-prefix replacements reset to a full replay. OpenClaw
|
|
31
|
+
compaction shrinks still reset the local cursor without replaying the
|
|
32
|
+
compacted summary.
|
|
33
|
+
- Destination-lane cursor state is isolated by API URL, credentials, and space.
|
|
34
|
+
- Thread create/append honor `NMEM_SYNC_TIMEOUT_MS` with a 120s default and
|
|
35
|
+
1-second to 30-minute bounds.
|
|
36
|
+
- Automatic capture now skips persist until a user+assistant pair exists,
|
|
37
|
+
so a user-only reset checkpoint is not stored as a complete thread.
|
|
38
|
+
|
|
39
|
+
|
|
7
40
|
## [0.8.31] - 2026-07-12
|
|
8
41
|
|
|
9
42
|
### Improved
|
package/CLAUDE.md
CHANGED
|
@@ -7,7 +7,7 @@ Continuation guide for `community/nowledge-mem-openclaw-plugin`.
|
|
|
7
7
|
- Plugin target: OpenClaw plugin runtime (memory slot + context engine)
|
|
8
8
|
- Runtime: JS ESM modules under `src/`, no TS build pipeline
|
|
9
9
|
- Memory backend: `nmem` CLI (fallback: `uvx --from nmem-cli nmem`)
|
|
10
|
-
- OpenClaw minimum: `2026.
|
|
10
|
+
- OpenClaw minimum: `2026.5.3` (`registerMemoryCorpusSupplement` for dreaming integration)
|
|
11
11
|
- Architecture: **CLI-first via OpenClaw runtime** - all CLI execution goes through `api.runtime.system.runCommandWithTimeout`, not direct `child_process`
|
|
12
12
|
- Context engine: registered under the canonical id `nowledge-mem` plus the compatibility alias `openclaw-nowledge-mem`. Activated when OpenClaw sets either value in `plugins.slots.contextEngine`. Manual config should still prefer `nowledge-mem`. Falls back to hooks when CE is not active.
|
|
13
13
|
- Remote mode: `~/.nowledge-mem/config.json` (shared) or OpenClaw dashboard. Legacy `openclaw.json` still honored.
|
|
@@ -58,6 +58,8 @@ openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema,
|
|
|
58
58
|
~/.nowledge-mem/config.json - shared credentials (apiUrl/apiKey) read by all Nowledge Mem tools
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Automatic capture excludes OpenClaw 2.0's canonical Incognito session keys. Explicit memory tool calls remain available for intentional saves.
|
|
62
|
+
|
|
61
63
|
## Corpus Supplement (Dreaming Integration)
|
|
62
64
|
|
|
63
65
|
When `corpusSupplement: true`, the plugin registers a `MemoryCorpusSupplement` via `api.registerMemoryCorpusSupplement()`. This makes Nowledge Mem's knowledge graph searchable through memory-core's recall pipeline, including its three-phase dreaming system (light, deep, REM).
|
package/README.md
CHANGED
|
@@ -11,10 +11,15 @@ This is not a flat note store. Nowledge Mem links related knowledge into a graph
|
|
|
11
11
|
- [Nowledge Mem](https://mem.nowledge.co) desktop app **or** `nmem` CLI
|
|
12
12
|
- [OpenClaw](https://openclaw.ai) >= 2026.5.3 for ClawHub installs. Older 2026.4.x builds can reject current ClawHub packages with an archive integrity mismatch before the plugin is installed.
|
|
13
13
|
|
|
14
|
+
OpenClaw 2.0 (`2026.8.1`) is supported. Automatic capture follows OpenClaw's
|
|
15
|
+
privacy boundary: Incognito sessions are never copied to Nowledge Mem or
|
|
16
|
+
distilled automatically. An explicit memory tool call can still save something
|
|
17
|
+
the user intentionally asks Mem to keep.
|
|
18
|
+
|
|
14
19
|
## Installation
|
|
15
20
|
|
|
16
21
|
```bash
|
|
17
|
-
openclaw plugins install clawhub
|
|
22
|
+
openclaw plugins install clawhub:nowledge-mem
|
|
18
23
|
```
|
|
19
24
|
|
|
20
25
|
OpenClaw's installer writes the install record, enables the plugin, and switches the `memory` slot to `openclaw-nowledge-mem`. On current OpenClaw builds, that same install flow may also set `plugins.slots.contextEngine` to `openclaw-nowledge-mem`. Plugin `0.8.18+` accepts that automatically as a compatibility alias.
|
|
@@ -30,7 +35,7 @@ If `openclaw config get tools` shows a restrictive profile such as `coding`, pre
|
|
|
30
35
|
To update the plugin, re-run the ClawHub install with `--force`:
|
|
31
36
|
|
|
32
37
|
```bash
|
|
33
|
-
openclaw plugins install clawhub
|
|
38
|
+
openclaw plugins install clawhub:nowledge-mem --force
|
|
34
39
|
```
|
|
35
40
|
|
|
36
41
|
Use this instead of `openclaw plugins update openclaw-nowledge-mem` when an old install record is pinned to a specific version.
|
|
@@ -220,7 +225,7 @@ flowchart TD
|
|
|
220
225
|
|
|
221
226
|
**Key points:**
|
|
222
227
|
- Thread capture is unconditional: every conversation is saved and searchable via `nowledge_mem_thread_search`
|
|
223
|
-
- Thread sync is incremental: the plugin preserves the real transcript
|
|
228
|
+
- Thread sync is incremental: the plugin preserves the real transcript, appends only the unsynced tail, and requires Mem's checkpointed acknowledgement before the local cursor advances
|
|
224
229
|
- LLM distillation only runs at `agent_end`, not during compaction/reset checkpoints
|
|
225
230
|
- Distilled memories carry `sourceThreadId`, linking them back to the source conversation
|
|
226
231
|
- Cooldown (`digestMinInterval`, default 300s) prevents burst distillation
|
|
@@ -498,6 +503,7 @@ NMEM_CORPUS_MAX_RESULTS=5
|
|
|
498
503
|
NMEM_CORPUS_MIN_SCORE=0
|
|
499
504
|
NMEM_API_URL=https://...
|
|
500
505
|
NMEM_API_KEY=your-key
|
|
506
|
+
NMEM_SYNC_TIMEOUT_MS=120000
|
|
501
507
|
```
|
|
502
508
|
|
|
503
509
|
### Priority
|
|
@@ -537,7 +543,7 @@ If you also use linked or workspace copies, review `plugins.load.paths` before r
|
|
|
537
543
|
The memory slot is probably still set to the built-in `memory-core`. OpenClaw 3.22+ defaults to `memory-core` when no explicit slot is configured. Reinstall to reset the slot automatically:
|
|
538
544
|
|
|
539
545
|
```bash
|
|
540
|
-
openclaw plugins install clawhub
|
|
546
|
+
openclaw plugins install clawhub:nowledge-mem
|
|
541
547
|
```
|
|
542
548
|
|
|
543
549
|
Or set the slot manually in `~/.openclaw/openclaw.json`:
|
package/SKILL.md
CHANGED
|
@@ -28,6 +28,8 @@ When talking to users:
|
|
|
28
28
|
- For remote mode, say "Set `apiUrl`, and add `apiKey` when the server requires auth."
|
|
29
29
|
- Do not imply that OpenClaw is the only source of truth. It is one client of the user's larger memory system.
|
|
30
30
|
- Do not imply that cloud backup is the default. Our default story is local-first.
|
|
31
|
+
- OpenClaw 2.0 Incognito sessions are never automatically captured or distilled.
|
|
32
|
+
Only an explicit memory-writing tool call persists information by user choice.
|
|
31
33
|
|
|
32
34
|
Good user-facing explanation:
|
|
33
35
|
|
|
@@ -103,7 +105,7 @@ Success means the backend responds correctly through `nmem`. Do not skip this st
|
|
|
103
105
|
Default install:
|
|
104
106
|
|
|
105
107
|
```bash
|
|
106
|
-
openclaw plugins install clawhub
|
|
108
|
+
openclaw plugins install clawhub:nowledge-mem
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
If this fails with `archive integrity mismatch`, update OpenClaw first, then
|
|
@@ -112,7 +114,7 @@ retry. Current ClawHub packages require OpenClaw 2026.5.3 or later.
|
|
|
112
114
|
To update an existing install:
|
|
113
115
|
|
|
114
116
|
```bash
|
|
115
|
-
openclaw plugins install clawhub
|
|
117
|
+
openclaw plugins install clawhub:nowledge-mem --force
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
Alternatively, if the user is installing from OpenClaw's default registry rather than ClawHub, this also works:
|
|
@@ -230,7 +232,7 @@ If only `memory_search` and `memory_get` tools are available (other Nowledge Mem
|
|
|
230
232
|
}
|
|
231
233
|
```
|
|
232
234
|
|
|
233
|
-
If missing, reinstall (`openclaw plugins install clawhub
|
|
235
|
+
If missing, reinstall (`openclaw plugins install clawhub:nowledge-mem`) which sets the slot automatically. This is common after upgrading OpenClaw to 3.22+ where the default memory slot changed.
|
|
234
236
|
|
|
235
237
|
Then verify in chat.
|
|
236
238
|
|
|
@@ -280,7 +282,7 @@ What to expect:
|
|
|
280
282
|
| `nmem --json status` fails in local mode | Start Nowledge Mem locally first |
|
|
281
283
|
| Remote mode fails | Re-run `nmem --json --api-url ... status` with the exact URL and auth you plan to use |
|
|
282
284
|
| `openclaw nowledge-mem status` fails after install | Restart OpenClaw and confirm the plugin was installed successfully |
|
|
283
|
-
| ClawHub reports `archive integrity mismatch` | Update OpenClaw to 2026.5.3+ or latest, then reinstall with `openclaw plugins install clawhub
|
|
285
|
+
| ClawHub reports `archive integrity mismatch` | Update OpenClaw to 2026.5.3+ or latest, then reinstall with `openclaw plugins install clawhub:nowledge-mem --force` |
|
|
284
286
|
| `plugins.allow is empty` warning | Add `openclaw-nowledge-mem` to `plugins.allow` if the user wants explicit trust |
|
|
285
287
|
| Remote config seems ignored | Check whether `~/.nowledge-mem/openclaw.json` is overriding plugin settings |
|
|
286
288
|
| Local mode unexpectedly talks to a remote server | Check for stale `NMEM_API_URL` / `NMEM_API_KEY` in the environment or an overriding `~/.nowledge-mem/openclaw.json` |
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
3
|
"name": "Nowledge Mem",
|
|
4
4
|
"description": "Cross-tool knowledge graph memory for OpenClaw with startup context, graph search, and session distillation.",
|
|
5
|
-
"version": "0.8.
|
|
5
|
+
"version": "0.8.34",
|
|
6
6
|
"kind": ["memory", "context-engine"],
|
|
7
7
|
"contracts": {
|
|
8
8
|
"tools": [
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowledge/openclaw-nowledge-mem",
|
|
3
3
|
"displayName": "Nowledge Mem for OpenClaw",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.34",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
|
|
7
7
|
"author": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"./src/index.js"
|
|
30
30
|
],
|
|
31
31
|
"install": {
|
|
32
|
-
"clawhubSpec": "clawhub
|
|
32
|
+
"clawhubSpec": "clawhub:nowledge-mem",
|
|
33
33
|
"npmSpec": "@nowledge/openclaw-nowledge-mem",
|
|
34
34
|
"defaultChoice": "clawhub"
|
|
35
35
|
},
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"pluginApi": ">=2026.5.3"
|
|
38
38
|
},
|
|
39
39
|
"build": {
|
|
40
|
-
"openclawVersion": "2026.
|
|
40
|
+
"openclawVersion": "2026.8.1"
|
|
41
41
|
},
|
|
42
42
|
"release": {
|
|
43
43
|
"publishToClawHub": true,
|
package/src/client.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { buildNmemSpawnEnv } from "./spawn-env.js";
|
|
2
|
+
import {
|
|
3
|
+
isCheckpointConflictResponse,
|
|
4
|
+
isCheckpointedAppendAck,
|
|
5
|
+
isThreadAppendAck,
|
|
6
|
+
sessionSyncLaneKey,
|
|
7
|
+
threadCreateAck,
|
|
8
|
+
} from "./session-delta.js";
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Patch a single markdown section in a Working Memory document.
|
|
@@ -94,6 +101,26 @@ export class NowledgeMemClient {
|
|
|
94
101
|
? credentials.spaceId
|
|
95
102
|
: "";
|
|
96
103
|
this._spaceRef = String(resolvedSpace ?? "").trim();
|
|
104
|
+
this._threadSyncTimeoutMs = Number.isInteger(credentials.threadSyncTimeoutMs)
|
|
105
|
+
? credentials.threadSyncTimeoutMs
|
|
106
|
+
: 120_000;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getSpaceRef() {
|
|
110
|
+
return this._spaceRef;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getThreadSyncTimeoutMs() {
|
|
114
|
+
return this._threadSyncTimeoutMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cursorKey(threadId) {
|
|
118
|
+
return sessionSyncLaneKey(
|
|
119
|
+
threadId,
|
|
120
|
+
this._apiUrl,
|
|
121
|
+
this._apiKey,
|
|
122
|
+
this._spaceRef,
|
|
123
|
+
);
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
// ── API helpers (fallback path and direct operations) ─────────────────────
|
|
@@ -162,7 +189,11 @@ export class NowledgeMemClient {
|
|
|
162
189
|
: typeof data?.raw === "string"
|
|
163
190
|
? data.raw
|
|
164
191
|
: `HTTP ${response.status}`;
|
|
165
|
-
|
|
192
|
+
const error = new Error(detail);
|
|
193
|
+
error.status = response.status;
|
|
194
|
+
error.code =
|
|
195
|
+
typeof data?.error_code === "string" ? data.error_code : undefined;
|
|
196
|
+
throw error;
|
|
166
197
|
}
|
|
167
198
|
return data;
|
|
168
199
|
} finally {
|
|
@@ -585,6 +616,10 @@ export class NowledgeMemClient {
|
|
|
585
616
|
}
|
|
586
617
|
|
|
587
618
|
async createThread({ threadId, title, messages, source = "openclaw" }) {
|
|
619
|
+
const normalizedThreadId = String(threadId || "").trim();
|
|
620
|
+
if (!normalizedThreadId) {
|
|
621
|
+
throw new Error("createThread requires threadId");
|
|
622
|
+
}
|
|
588
623
|
const normalizedTitle = String(title || "").trim();
|
|
589
624
|
if (!normalizedTitle) {
|
|
590
625
|
throw new Error("createThread requires a non-empty title");
|
|
@@ -597,16 +632,21 @@ export class NowledgeMemClient {
|
|
|
597
632
|
"POST",
|
|
598
633
|
"/threads",
|
|
599
634
|
{
|
|
600
|
-
|
|
635
|
+
thread_id: normalizedThreadId,
|
|
601
636
|
title: normalizedTitle,
|
|
602
637
|
source: String(source),
|
|
603
638
|
messages,
|
|
604
639
|
},
|
|
640
|
+
this._threadSyncTimeoutMs,
|
|
605
641
|
);
|
|
606
642
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
643
|
+
const ack = threadCreateAck(data, normalizedThreadId);
|
|
644
|
+
if (!ack) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
"Thread create did not include an explicit persistence acknowledgement",
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return ack;
|
|
610
650
|
}
|
|
611
651
|
|
|
612
652
|
async appendThread({
|
|
@@ -614,6 +654,7 @@ export class NowledgeMemClient {
|
|
|
614
654
|
messages,
|
|
615
655
|
deduplicate = true,
|
|
616
656
|
idempotencyKey,
|
|
657
|
+
expectedMessageCount,
|
|
617
658
|
}) {
|
|
618
659
|
const normalizedThreadId = String(threadId || "").trim();
|
|
619
660
|
if (!normalizedThreadId) {
|
|
@@ -623,6 +664,7 @@ export class NowledgeMemClient {
|
|
|
623
664
|
return { messagesAdded: 0, totalMessages: 0 };
|
|
624
665
|
}
|
|
625
666
|
|
|
667
|
+
const checkpointed = Number.isInteger(expectedMessageCount);
|
|
626
668
|
const data = await this.apiJson(
|
|
627
669
|
"POST",
|
|
628
670
|
`/threads/${encodeURIComponent(normalizedThreadId)}/append`,
|
|
@@ -632,11 +674,25 @@ export class NowledgeMemClient {
|
|
|
632
674
|
...(idempotencyKey
|
|
633
675
|
? { idempotency_key: String(idempotencyKey) }
|
|
634
676
|
: {}),
|
|
677
|
+
...(checkpointed
|
|
678
|
+
? { expected_message_count: expectedMessageCount }
|
|
679
|
+
: {}),
|
|
635
680
|
},
|
|
681
|
+
this._threadSyncTimeoutMs,
|
|
636
682
|
);
|
|
683
|
+
if (!isThreadAppendAck(data)) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
"Thread append did not include an explicit persistence acknowledgement",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (checkpointed && !isCheckpointedAppendAck(data)) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
"Thread append was not acknowledged as checkpointed",
|
|
691
|
+
);
|
|
692
|
+
}
|
|
637
693
|
return {
|
|
638
|
-
messagesAdded:
|
|
639
|
-
totalMessages:
|
|
694
|
+
messagesAdded: data.messages_added,
|
|
695
|
+
totalMessages: data.total_messages,
|
|
640
696
|
};
|
|
641
697
|
}
|
|
642
698
|
|
|
@@ -679,9 +735,16 @@ export class NowledgeMemClient {
|
|
|
679
735
|
err instanceof Error ? err.message : String(err)
|
|
680
736
|
).toLowerCase();
|
|
681
737
|
return (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
message.includes("not found")
|
|
738
|
+
err?.code === "thread_not_found" ||
|
|
739
|
+
err?.status === 404 ||
|
|
740
|
+
(err?.status === 400 && message.includes("thread not found"))
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
isCheckpointConflictError(err) {
|
|
745
|
+
return (
|
|
746
|
+
err?.code === "checkpoint_conflict" ||
|
|
747
|
+
isCheckpointConflictResponse({ error_code: err?.code })
|
|
685
748
|
);
|
|
686
749
|
}
|
|
687
750
|
|
package/src/context-engine.js
CHANGED
|
@@ -30,8 +30,8 @@ import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
|
|
|
30
30
|
import {
|
|
31
31
|
appendOrCreateThread,
|
|
32
32
|
hasSkipMarker,
|
|
33
|
-
isInternalCaptureSessionKey,
|
|
34
33
|
isCronCaptureSessionKey,
|
|
34
|
+
isInternalCaptureSessionKey,
|
|
35
35
|
matchesExcludePattern,
|
|
36
36
|
triageAndDistill,
|
|
37
37
|
} from "./hooks/capture.js";
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
buildRecalledKnowledgeBlock,
|
|
42
42
|
escapeForPrompt,
|
|
43
43
|
} from "./hooks/recall.js";
|
|
44
|
+
import { NOWLEDGE_MEM_PLUGIN_VERSION } from "./plugin-version.js";
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
46
47
|
// Per-session state
|
|
@@ -183,7 +184,7 @@ export function createNowledgeMemContextEngineFactory(
|
|
|
183
184
|
info: {
|
|
184
185
|
id: "nowledge-mem",
|
|
185
186
|
name: "Nowledge Mem",
|
|
186
|
-
version:
|
|
187
|
+
version: NOWLEDGE_MEM_PLUGIN_VERSION,
|
|
187
188
|
ownsCompaction: false,
|
|
188
189
|
},
|
|
189
190
|
|
package/src/hooks/capture.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { buildStableThreadId } from "./thread-identity.js";
|
|
4
|
+
import {
|
|
5
|
+
contentBoundIdempotencyKey,
|
|
6
|
+
hasUserAndAssistant,
|
|
7
|
+
prefixFingerprint,
|
|
8
|
+
selectSnapshotDelta,
|
|
9
|
+
} from "../session-delta.js";
|
|
4
10
|
export {
|
|
5
11
|
_resetConversationRoots,
|
|
6
12
|
buildStableThreadId,
|
|
@@ -25,7 +31,7 @@ const OPENCLAW_INTERNAL_SENDER_BLOCK_RE =
|
|
|
25
31
|
// Evicted opportunistically when new entries are set (see _setLastCapture).
|
|
26
32
|
const _lastCaptureAt = new Map();
|
|
27
33
|
const _MAX_COOLDOWN_ENTRIES = 200;
|
|
28
|
-
const
|
|
34
|
+
const _acknowledgedCursors = new Map();
|
|
29
35
|
const _MAX_SYNC_CURSOR_ENTRIES = 500;
|
|
30
36
|
const CAPTURE_MESSAGE_MODE_AUTO = "auto";
|
|
31
37
|
const CAPTURE_MESSAGE_MODE_SNAPSHOT = "snapshot";
|
|
@@ -70,20 +76,34 @@ function _setLastCapture(threadId, now) {
|
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
function _laneKey(client, threadId) {
|
|
80
|
+
return typeof client?.cursorKey === "function"
|
|
81
|
+
? client.cursorKey(threadId)
|
|
82
|
+
: String(threadId || "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _getCursor(client, threadId) {
|
|
86
|
+
return _acknowledgedCursors.get(_laneKey(client, threadId));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _setAcknowledgedCursor(client, threadId, cursor) {
|
|
90
|
+
if (!threadId || !cursor) return;
|
|
91
|
+
_acknowledgedCursors.set(_laneKey(client, threadId), cursor);
|
|
92
|
+
if (_acknowledgedCursors.size > _MAX_SYNC_CURSOR_ENTRIES) {
|
|
93
|
+
const excess = _acknowledgedCursors.size - _MAX_SYNC_CURSOR_ENTRIES;
|
|
78
94
|
let removed = 0;
|
|
79
|
-
for (const key of
|
|
80
|
-
|
|
95
|
+
for (const key of _acknowledgedCursors.keys()) {
|
|
96
|
+
_acknowledgedCursors.delete(key);
|
|
81
97
|
removed += 1;
|
|
82
98
|
if (removed >= excess) break;
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
export function _resetSyncCursors() {
|
|
104
|
+
_acknowledgedCursors.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
// ---------------------------------------------------------------------------
|
|
88
108
|
// Capture exclusion filters
|
|
89
109
|
// ---------------------------------------------------------------------------
|
|
@@ -136,6 +156,20 @@ export function isCronCaptureSessionKey(sessionKey) {
|
|
|
136
156
|
return isAgentCronSessionKey(raw);
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
/**
|
|
160
|
+
* OpenClaw 2.0 reserves these process-only keys for Incognito sessions.
|
|
161
|
+
* Keep this aligned with the host's routing helper so automatic capture does
|
|
162
|
+
* not turn an in-memory conversation into a durable external transcript.
|
|
163
|
+
* Explicit memory tool calls are unaffected because this only guards hooks.
|
|
164
|
+
*/
|
|
165
|
+
export function isIncognitoCaptureSessionKey(sessionKey) {
|
|
166
|
+
const raw = String(sessionKey || "")
|
|
167
|
+
.trim()
|
|
168
|
+
.toLowerCase();
|
|
169
|
+
const candidate = parseAgentSessionKey(raw)?.rest ?? raw;
|
|
170
|
+
return /^(?:dashboard|subagent|internal-session-effects):incognito-[^:]+$/u.test(candidate);
|
|
171
|
+
}
|
|
172
|
+
|
|
139
173
|
/**
|
|
140
174
|
* Check if any message contains the skip marker text.
|
|
141
175
|
* Scans both raw message content and nested message objects.
|
|
@@ -164,7 +198,7 @@ export function isInternalCaptureSessionKey(sessionKey) {
|
|
|
164
198
|
.toLowerCase();
|
|
165
199
|
if (!raw) return false;
|
|
166
200
|
if (raw.startsWith("temp:")) return true;
|
|
167
|
-
return isSubagentSessionKey(raw);
|
|
201
|
+
return isSubagentSessionKey(raw) || isIncognitoCaptureSessionKey(raw);
|
|
168
202
|
}
|
|
169
203
|
|
|
170
204
|
function stripOpenClawDirectiveTags(text) {
|
|
@@ -386,18 +420,18 @@ function buildExternalId({
|
|
|
386
420
|
return `oc-msg:${digest}`;
|
|
387
421
|
}
|
|
388
422
|
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
423
|
+
function messageExternalId(message) {
|
|
424
|
+
return String(message?.metadata?.external_id || "");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function buildContentBoundIdempotencyKey(threadId, reason, start, end, fingerprint) {
|
|
428
|
+
return contentBoundIdempotencyKey(
|
|
429
|
+
`oc:${String(reason || "event")}`,
|
|
430
|
+
threadId,
|
|
431
|
+
start,
|
|
432
|
+
end,
|
|
433
|
+
fingerprint,
|
|
434
|
+
);
|
|
401
435
|
}
|
|
402
436
|
|
|
403
437
|
function hasStableExternalHints(normalized) {
|
|
@@ -466,10 +500,28 @@ export async function appendOrCreateThread({
|
|
|
466
500
|
.map((message) => normalizeRoleMessage(message, maxMessageChars))
|
|
467
501
|
.filter(Boolean);
|
|
468
502
|
if (normalized.length === 0) return;
|
|
469
|
-
const
|
|
503
|
+
const resolvedMessageMode =
|
|
504
|
+
messageMode === CAPTURE_MESSAGE_MODE_DELTA ||
|
|
505
|
+
(messageMode === CAPTURE_MESSAGE_MODE_AUTO &&
|
|
506
|
+
hasStableExternalHints(normalized))
|
|
507
|
+
? CAPTURE_MESSAGE_MODE_DELTA
|
|
508
|
+
: CAPTURE_MESSAGE_MODE_SNAPSHOT;
|
|
509
|
+
let captured = normalized;
|
|
510
|
+
if (resolvedMessageMode === CAPTURE_MESSAGE_MODE_SNAPSHOT) {
|
|
511
|
+
let lastAssistantIndex = -1;
|
|
512
|
+
for (let index = normalized.length - 1; index >= 0; index -= 1) {
|
|
513
|
+
if (normalized[index].role === "assistant") {
|
|
514
|
+
lastAssistantIndex = index;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
captured = normalized.slice(0, lastAssistantIndex + 1);
|
|
519
|
+
}
|
|
520
|
+
if (!hasUserAndAssistant(captured)) return;
|
|
521
|
+
const title = buildThreadTitle(ctx, captured);
|
|
470
522
|
|
|
471
523
|
const buildMessages = (resolvedMessageMode) =>
|
|
472
|
-
|
|
524
|
+
captured.map((message, index) => ({
|
|
473
525
|
role: message.role,
|
|
474
526
|
content: message.content,
|
|
475
527
|
timestamp: message.timestamp,
|
|
@@ -495,111 +547,174 @@ export async function appendOrCreateThread({
|
|
|
495
547
|
: CAPTURE_MESSAGE_MODE_SNAPSHOT,
|
|
496
548
|
);
|
|
497
549
|
|
|
498
|
-
let
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
550
|
+
let cursor = _getCursor(client, threadId);
|
|
551
|
+
if (cursor === undefined) {
|
|
552
|
+
let remoteCount = null;
|
|
553
|
+
try {
|
|
554
|
+
remoteCount = await client.getThreadMessageCount(threadId);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
557
|
+
logger.warn(
|
|
558
|
+
`capture: remote message count failed for ${threadId}: ${message}`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (remoteCount !== null && Number.isFinite(remoteCount) && remoteCount >= 0) {
|
|
562
|
+
cursor = {
|
|
563
|
+
count: 0,
|
|
564
|
+
remoteCount: Math.trunc(remoteCount),
|
|
565
|
+
prefixFingerprint: "",
|
|
566
|
+
};
|
|
503
567
|
}
|
|
504
568
|
}
|
|
505
569
|
|
|
506
|
-
const resolvedMessageMode =
|
|
507
|
-
messageMode === CAPTURE_MESSAGE_MODE_DELTA ||
|
|
508
|
-
(messageMode === CAPTURE_MESSAGE_MODE_AUTO &&
|
|
509
|
-
hasStableExternalHints(normalized))
|
|
510
|
-
? CAPTURE_MESSAGE_MODE_DELTA
|
|
511
|
-
: CAPTURE_MESSAGE_MODE_SNAPSHOT;
|
|
512
570
|
if (resolvedMessageMode === CAPTURE_MESSAGE_MODE_DELTA) {
|
|
513
571
|
allMessages = buildMessages(CAPTURE_MESSAGE_MODE_DELTA);
|
|
514
|
-
const
|
|
572
|
+
const next = {
|
|
573
|
+
count: allMessages.length,
|
|
574
|
+
remoteCount: cursor?.remoteCount ?? allMessages.length,
|
|
575
|
+
...(allMessages.length > 0
|
|
576
|
+
? { lastExternalId: messageExternalId(allMessages[allMessages.length - 1]) }
|
|
577
|
+
: {}),
|
|
578
|
+
prefixFingerprint: prefixFingerprint(allMessages, allMessages.length),
|
|
579
|
+
};
|
|
580
|
+
return persistMessages({
|
|
581
|
+
client,
|
|
582
|
+
logger,
|
|
515
583
|
threadId,
|
|
584
|
+
title,
|
|
516
585
|
reason,
|
|
586
|
+
normalized,
|
|
517
587
|
allMessages,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const added = appended.messagesAdded ?? 0;
|
|
527
|
-
const total =
|
|
528
|
-
Number.isFinite(appended.totalMessages) && appended.totalMessages >= 0
|
|
529
|
-
? appended.totalMessages
|
|
530
|
-
: typeof syncedCount === "number"
|
|
531
|
-
? syncedCount + added
|
|
532
|
-
: allMessages.length;
|
|
533
|
-
_setSyncedMessageCount(threadId, total);
|
|
534
|
-
logger.info(
|
|
535
|
-
`capture: appended ${added} delta messages to ${threadId} (${reason || "event"})`,
|
|
536
|
-
);
|
|
537
|
-
return { threadId, normalized, messagesAdded: added };
|
|
538
|
-
} catch (err) {
|
|
539
|
-
if (!client.isThreadNotFoundError(err)) {
|
|
540
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
541
|
-
logger.warn(
|
|
542
|
-
`capture: thread append failed for ${threadId}: ${message}`,
|
|
543
|
-
);
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
try {
|
|
549
|
-
const createdId = await client.createThread({
|
|
550
|
-
threadId,
|
|
551
|
-
title,
|
|
552
|
-
messages: allMessages,
|
|
553
|
-
source: "openclaw",
|
|
554
|
-
});
|
|
555
|
-
_setSyncedMessageCount(threadId, allMessages.length);
|
|
556
|
-
logger.info(
|
|
557
|
-
`capture: created thread ${createdId} with ${allMessages.length} delta messages (${reason || "event"})`,
|
|
558
|
-
);
|
|
559
|
-
return { threadId, normalized, messagesAdded: allMessages.length };
|
|
560
|
-
} catch (err) {
|
|
561
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
562
|
-
logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
|
|
563
|
-
return null;
|
|
564
|
-
}
|
|
588
|
+
toSend: allMessages,
|
|
589
|
+
start: 0,
|
|
590
|
+
end: allMessages.length,
|
|
591
|
+
next,
|
|
592
|
+
cursor,
|
|
593
|
+
reset: false,
|
|
594
|
+
label: "delta",
|
|
595
|
+
});
|
|
565
596
|
}
|
|
566
597
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
598
|
+
const delta = selectSnapshotDelta(
|
|
599
|
+
allMessages,
|
|
600
|
+
cursor,
|
|
601
|
+
messageExternalId,
|
|
602
|
+
);
|
|
603
|
+
if (delta.compactionShrink) {
|
|
604
|
+
_setAcknowledgedCursor(client, threadId, delta.next);
|
|
572
605
|
return { threadId, normalized, messagesAdded: 0 };
|
|
573
606
|
}
|
|
574
|
-
|
|
575
|
-
const appendStart =
|
|
576
|
-
typeof syncedCount === "number" && syncedCount > 0
|
|
577
|
-
? Math.min(syncedCount, allMessages.length)
|
|
578
|
-
: 0;
|
|
579
|
-
const newMessages = allMessages.slice(appendStart);
|
|
580
|
-
if (newMessages.length === 0) {
|
|
607
|
+
if (delta.messages.length === 0) {
|
|
581
608
|
return { threadId, normalized, messagesAdded: 0 };
|
|
582
609
|
}
|
|
583
|
-
|
|
610
|
+
return persistMessages({
|
|
611
|
+
client,
|
|
612
|
+
logger,
|
|
584
613
|
threadId,
|
|
614
|
+
title,
|
|
585
615
|
reason,
|
|
586
|
-
|
|
616
|
+
normalized,
|
|
617
|
+
allMessages,
|
|
618
|
+
toSend: delta.messages,
|
|
619
|
+
start: delta.start,
|
|
620
|
+
end: delta.end,
|
|
621
|
+
next: delta.next,
|
|
622
|
+
cursor,
|
|
623
|
+
reset: delta.reset,
|
|
624
|
+
label: "snapshot",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function persistMessages({
|
|
629
|
+
client,
|
|
630
|
+
logger,
|
|
631
|
+
threadId,
|
|
632
|
+
title,
|
|
633
|
+
reason,
|
|
634
|
+
normalized,
|
|
635
|
+
allMessages,
|
|
636
|
+
toSend,
|
|
637
|
+
start,
|
|
638
|
+
end,
|
|
639
|
+
next,
|
|
640
|
+
cursor,
|
|
641
|
+
reset,
|
|
642
|
+
label,
|
|
643
|
+
}) {
|
|
644
|
+
const fingerprint = next.prefixFingerprint;
|
|
645
|
+
const idempotencyKey = buildContentBoundIdempotencyKey(
|
|
646
|
+
threadId,
|
|
647
|
+
reason,
|
|
648
|
+
start,
|
|
649
|
+
end,
|
|
650
|
+
fingerprint,
|
|
587
651
|
);
|
|
652
|
+
const expectedMessageCount =
|
|
653
|
+
!reset && Number.isInteger(cursor?.remoteCount) && cursor.prefixFingerprint
|
|
654
|
+
? cursor.remoteCount
|
|
655
|
+
: undefined;
|
|
656
|
+
|
|
657
|
+
const acknowledge = (remoteCount, added) => {
|
|
658
|
+
_setAcknowledgedCursor(client, threadId, {
|
|
659
|
+
...next,
|
|
660
|
+
remoteCount,
|
|
661
|
+
});
|
|
662
|
+
return { threadId, normalized, messagesAdded: added };
|
|
663
|
+
};
|
|
588
664
|
|
|
589
665
|
try {
|
|
590
666
|
const appended = await client.appendThread({
|
|
591
667
|
threadId,
|
|
592
|
-
messages:
|
|
668
|
+
messages: toSend,
|
|
593
669
|
deduplicate: true,
|
|
594
670
|
idempotencyKey,
|
|
671
|
+
expectedMessageCount,
|
|
595
672
|
});
|
|
596
673
|
const added = appended.messagesAdded ?? 0;
|
|
597
|
-
|
|
674
|
+
const total =
|
|
675
|
+
Number.isInteger(appended.totalMessages) && appended.totalMessages >= 0
|
|
676
|
+
? appended.totalMessages
|
|
677
|
+
: toSend.length;
|
|
598
678
|
logger.info(
|
|
599
|
-
`capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
|
|
679
|
+
`capture: appended ${added} ${label} messages to ${threadId} (${reason || "event"})`,
|
|
600
680
|
);
|
|
601
|
-
return
|
|
681
|
+
return acknowledge(total, added);
|
|
602
682
|
} catch (err) {
|
|
683
|
+
if (client.isCheckpointConflictError?.(err)) {
|
|
684
|
+
try {
|
|
685
|
+
const reconciled = await client.appendThread({
|
|
686
|
+
threadId,
|
|
687
|
+
messages: allMessages,
|
|
688
|
+
deduplicate: true,
|
|
689
|
+
idempotencyKey: buildContentBoundIdempotencyKey(
|
|
690
|
+
threadId,
|
|
691
|
+
`${reason || "event"}-reconcile`,
|
|
692
|
+
0,
|
|
693
|
+
allMessages.length,
|
|
694
|
+
fingerprint,
|
|
695
|
+
),
|
|
696
|
+
});
|
|
697
|
+
const added = reconciled.messagesAdded ?? allMessages.length;
|
|
698
|
+
const total =
|
|
699
|
+
Number.isInteger(reconciled.totalMessages) &&
|
|
700
|
+
reconciled.totalMessages >= 0
|
|
701
|
+
? reconciled.totalMessages
|
|
702
|
+
: allMessages.length;
|
|
703
|
+
logger.info(
|
|
704
|
+
`capture: reconciled ${added} messages to ${threadId} (${reason || "event"})`,
|
|
705
|
+
);
|
|
706
|
+
return acknowledge(total, added);
|
|
707
|
+
} catch (reconcileErr) {
|
|
708
|
+
const message =
|
|
709
|
+
reconcileErr instanceof Error
|
|
710
|
+
? reconcileErr.message
|
|
711
|
+
: String(reconcileErr);
|
|
712
|
+
logger.warn(
|
|
713
|
+
`capture: thread reconcile failed for ${threadId}: ${message}`,
|
|
714
|
+
);
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
603
718
|
if (!client.isThreadNotFoundError(err)) {
|
|
604
719
|
const message = err instanceof Error ? err.message : String(err);
|
|
605
720
|
logger.warn(`capture: thread append failed for ${threadId}: ${message}`);
|
|
@@ -608,17 +723,16 @@ export async function appendOrCreateThread({
|
|
|
608
723
|
}
|
|
609
724
|
|
|
610
725
|
try {
|
|
611
|
-
const
|
|
726
|
+
const created = await client.createThread({
|
|
612
727
|
threadId,
|
|
613
728
|
title,
|
|
614
729
|
messages: allMessages,
|
|
615
730
|
source: "openclaw",
|
|
616
731
|
});
|
|
617
|
-
_setSyncedMessageCount(threadId, allMessages.length);
|
|
618
732
|
logger.info(
|
|
619
|
-
`capture: created thread ${
|
|
733
|
+
`capture: created thread ${created.threadId} with ${created.totalMessages} persisted messages (${reason || "event"})`,
|
|
620
734
|
);
|
|
621
|
-
return
|
|
735
|
+
return acknowledge(created.totalMessages, allMessages.length);
|
|
622
736
|
} catch (err) {
|
|
623
737
|
const message = err instanceof Error ? err.message : String(err);
|
|
624
738
|
logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NowledgeMemClient } from "./client.js";
|
|
2
|
+
import { resolveThreadSyncTimeoutMs } from "./thread-sync-timeout.js";
|
|
2
3
|
import { createCliRegistrar } from "./commands/cli.js";
|
|
3
4
|
import {
|
|
4
5
|
createForgetCommand,
|
|
@@ -46,6 +47,7 @@ export default {
|
|
|
46
47
|
apiUrl: cfg.apiUrl,
|
|
47
48
|
apiKey: cfg.apiKey,
|
|
48
49
|
space: cfg.space,
|
|
50
|
+
threadSyncTimeoutMs: resolveThreadSyncTimeoutMs(process.env.NMEM_SYNC_TIMEOUT_MS),
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
const { memorySlot, memorySlotSelected } = resolveRegistrationMode({
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function sessionSyncLaneKey(threadId, apiUrl, apiKey, spaceId) {
|
|
4
|
+
const destination = createHash("sha256")
|
|
5
|
+
.update(
|
|
6
|
+
[String(apiUrl || ""), String(apiKey || ""), String(spaceId || "")].join(
|
|
7
|
+
"\0",
|
|
8
|
+
),
|
|
9
|
+
)
|
|
10
|
+
.digest("hex");
|
|
11
|
+
return `${destination}\0${String(threadId || "")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stableMessageFingerprint(message) {
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
role: message?.role,
|
|
17
|
+
content: message?.content,
|
|
18
|
+
external_id: message?.metadata?.external_id,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function prefixFingerprint(
|
|
23
|
+
messages,
|
|
24
|
+
end,
|
|
25
|
+
messageFingerprint = stableMessageFingerprint,
|
|
26
|
+
) {
|
|
27
|
+
const hash = createHash("sha256");
|
|
28
|
+
for (const message of messages.slice(0, end)) {
|
|
29
|
+
const value = messageFingerprint(message);
|
|
30
|
+
hash.update(String(Buffer.byteLength(value)));
|
|
31
|
+
hash.update(":");
|
|
32
|
+
hash.update(value);
|
|
33
|
+
}
|
|
34
|
+
return hash.digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isThreadAppendAck(data) {
|
|
38
|
+
return (
|
|
39
|
+
data !== null &&
|
|
40
|
+
typeof data === "object" &&
|
|
41
|
+
data.success === true &&
|
|
42
|
+
Number.isInteger(data.messages_added) &&
|
|
43
|
+
data.messages_added >= 0 &&
|
|
44
|
+
Number.isInteger(data.total_messages) &&
|
|
45
|
+
data.total_messages >= 0
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function threadCreateAck(data, expectedThreadId) {
|
|
50
|
+
if (data === null || typeof data !== "object") return null;
|
|
51
|
+
const thread =
|
|
52
|
+
data.thread !== null && typeof data.thread === "object" ? data.thread : data;
|
|
53
|
+
const threadId = String(thread.thread_id ?? "").trim();
|
|
54
|
+
const totalMessages = thread.message_count ?? thread.total_messages;
|
|
55
|
+
if (
|
|
56
|
+
threadId !== String(expectedThreadId || "").trim() ||
|
|
57
|
+
!Number.isInteger(totalMessages) ||
|
|
58
|
+
totalMessages < 0
|
|
59
|
+
) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { threadId, totalMessages };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isCheckpointedAppendAck(data) {
|
|
66
|
+
return isThreadAppendAck(data) && data.append_mode === "checkpointed";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isCheckpointConflictResponse(data) {
|
|
70
|
+
return (
|
|
71
|
+
data !== null &&
|
|
72
|
+
typeof data === "object" &&
|
|
73
|
+
data.error_code === "checkpoint_conflict"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function selectSnapshotDelta(
|
|
78
|
+
messages,
|
|
79
|
+
cursor,
|
|
80
|
+
externalId,
|
|
81
|
+
messageFingerprint = stableMessageFingerprint,
|
|
82
|
+
) {
|
|
83
|
+
let start = cursor?.count ?? 0;
|
|
84
|
+
let reset = false;
|
|
85
|
+
if (start > messages.length) {
|
|
86
|
+
// OpenClaw compaction shrinks the live transcript after we already
|
|
87
|
+
// stored the pre-compaction history. Reset the local cursor to the
|
|
88
|
+
// compacted length so later turns append only the post-compaction tail.
|
|
89
|
+
const end = messages.length;
|
|
90
|
+
return {
|
|
91
|
+
start: end,
|
|
92
|
+
end,
|
|
93
|
+
messages: [],
|
|
94
|
+
next: {
|
|
95
|
+
count: end,
|
|
96
|
+
remoteCount: cursor?.remoteCount ?? end,
|
|
97
|
+
...(end > 0 ? { lastExternalId: externalId(messages[end - 1]) } : {}),
|
|
98
|
+
prefixFingerprint: prefixFingerprint(messages, end, messageFingerprint),
|
|
99
|
+
},
|
|
100
|
+
reset: false,
|
|
101
|
+
compactionShrink: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const acknowledgedPrefix =
|
|
105
|
+
start >= 0
|
|
106
|
+
? prefixFingerprint(messages, start, messageFingerprint)
|
|
107
|
+
: "";
|
|
108
|
+
const fingerprintTrusted = Boolean(cursor?.prefixFingerprint);
|
|
109
|
+
const externalIdTrusted = Boolean(cursor?.lastExternalId);
|
|
110
|
+
if (
|
|
111
|
+
start < 0 ||
|
|
112
|
+
(start > 0 &&
|
|
113
|
+
((externalIdTrusted &&
|
|
114
|
+
externalId(messages[start - 1]) !== cursor.lastExternalId) ||
|
|
115
|
+
(fingerprintTrusted &&
|
|
116
|
+
acknowledgedPrefix !== cursor.prefixFingerprint)))
|
|
117
|
+
) {
|
|
118
|
+
start = 0;
|
|
119
|
+
reset = true;
|
|
120
|
+
}
|
|
121
|
+
const end = messages.length;
|
|
122
|
+
return {
|
|
123
|
+
start,
|
|
124
|
+
end,
|
|
125
|
+
messages: messages.slice(start),
|
|
126
|
+
next: {
|
|
127
|
+
count: end,
|
|
128
|
+
remoteCount: cursor?.remoteCount ?? end,
|
|
129
|
+
...(end > 0 ? { lastExternalId: externalId(messages[end - 1]) } : {}),
|
|
130
|
+
prefixFingerprint: prefixFingerprint(messages, end, messageFingerprint),
|
|
131
|
+
},
|
|
132
|
+
reset,
|
|
133
|
+
compactionShrink: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function contentBoundIdempotencyKey(prefix, threadId, start, end, fingerprint) {
|
|
138
|
+
return `${prefix}:${threadId}:${start}-${end}:${fingerprint}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hasUserAndAssistant(messages) {
|
|
142
|
+
if (!Array.isArray(messages) || messages.length < 2) return false;
|
|
143
|
+
let hasUser = false;
|
|
144
|
+
let hasAssistant = false;
|
|
145
|
+
for (const message of messages) {
|
|
146
|
+
if (message?.role === "user") hasUser = true;
|
|
147
|
+
else if (message?.role === "assistant") hasAssistant = true;
|
|
148
|
+
if (hasUser && hasAssistant) return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const DEFAULT_THREAD_SYNC_TIMEOUT_MS = 120_000;
|
|
2
|
+
const MIN_THREAD_SYNC_TIMEOUT_MS = 1_000;
|
|
3
|
+
const MAX_THREAD_SYNC_TIMEOUT_MS = 30 * 60_000;
|
|
4
|
+
|
|
5
|
+
export function resolveThreadSyncTimeoutMs(raw) {
|
|
6
|
+
const parsed = Number(raw);
|
|
7
|
+
if (!String(raw ?? "").trim() || !Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
8
|
+
return DEFAULT_THREAD_SYNC_TIMEOUT_MS;
|
|
9
|
+
}
|
|
10
|
+
return Math.min(
|
|
11
|
+
MAX_THREAD_SYNC_TIMEOUT_MS,
|
|
12
|
+
Math.max(MIN_THREAD_SYNC_TIMEOUT_MS, parsed),
|
|
13
|
+
);
|
|
14
|
+
}
|