@totalreclaw/totalreclaw 3.3.12-rc.2 → 3.3.12-rc.21

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +34 -249
  3. package/api-client.ts +17 -9
  4. package/batch-gate.ts +42 -0
  5. package/billing-cache.ts +25 -20
  6. package/claims-helper.ts +7 -1
  7. package/config.ts +54 -12
  8. package/consolidation.ts +6 -13
  9. package/contradiction-sync.ts +19 -14
  10. package/credential-provider.ts +184 -0
  11. package/crypto.ts +27 -160
  12. package/dist/api-client.js +17 -9
  13. package/dist/batch-gate.js +40 -0
  14. package/dist/billing-cache.js +19 -21
  15. package/dist/claims-helper.js +7 -1
  16. package/dist/config.js +54 -12
  17. package/dist/consolidation.js +6 -15
  18. package/dist/contradiction-sync.js +15 -15
  19. package/dist/credential-provider.js +145 -0
  20. package/dist/crypto.js +17 -137
  21. package/dist/download-ux.js +11 -7
  22. package/dist/embedder-loader.js +266 -0
  23. package/dist/embedding.js +36 -3
  24. package/dist/entry.js +123 -0
  25. package/dist/extractor.js +134 -0
  26. package/dist/fs-helpers.js +116 -241
  27. package/dist/import-adapters/chatgpt-adapter.js +14 -0
  28. package/dist/import-adapters/claude-adapter.js +14 -0
  29. package/dist/import-adapters/gemini-adapter.js +43 -159
  30. package/dist/import-adapters/mcp-memory-adapter.js +14 -0
  31. package/dist/import-state-manager.js +100 -0
  32. package/dist/index.js +1130 -2520
  33. package/dist/llm-client.js +69 -1
  34. package/dist/memory-runtime.js +459 -0
  35. package/dist/native-memory.js +123 -0
  36. package/dist/onboarding-cli.js +3 -2
  37. package/dist/pair-cli.js +1 -1
  38. package/dist/pair-crypto.js +16 -358
  39. package/dist/pair-http.js +147 -4
  40. package/dist/relay.js +140 -0
  41. package/dist/reranker.js +13 -8
  42. package/dist/semantic-dedup.js +5 -7
  43. package/dist/skill-register.js +97 -0
  44. package/dist/subgraph-search.js +3 -1
  45. package/dist/subgraph-store.js +315 -282
  46. package/dist/tool-gating.js +39 -26
  47. package/dist/tools.js +367 -0
  48. package/dist/tr-cli-export-helper.js +103 -0
  49. package/dist/tr-cli.js +220 -127
  50. package/dist/trajectory-poller.js +155 -9
  51. package/dist/vault-crypto.js +551 -0
  52. package/download-ux.ts +12 -6
  53. package/embedder-loader.ts +293 -1
  54. package/embedding.ts +43 -3
  55. package/entry.ts +132 -0
  56. package/extractor.ts +167 -0
  57. package/fs-helpers.ts +166 -292
  58. package/import-adapters/chatgpt-adapter.ts +18 -0
  59. package/import-adapters/claude-adapter.ts +18 -0
  60. package/import-adapters/gemini-adapter.ts +56 -183
  61. package/import-adapters/mcp-memory-adapter.ts +18 -0
  62. package/import-adapters/types.ts +1 -1
  63. package/import-state-manager.ts +139 -0
  64. package/index.ts +1432 -3002
  65. package/llm-client.ts +74 -1
  66. package/memory-runtime.ts +723 -0
  67. package/native-memory.ts +196 -0
  68. package/onboarding-cli.ts +3 -2
  69. package/openclaw.plugin.json +5 -17
  70. package/package.json +7 -4
  71. package/pair-cli.ts +1 -1
  72. package/pair-crypto.ts +41 -483
  73. package/pair-http.ts +194 -5
  74. package/postinstall.mjs +138 -0
  75. package/relay.ts +172 -0
  76. package/reranker.ts +13 -8
  77. package/semantic-dedup.ts +5 -6
  78. package/skill-register.ts +146 -0
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +3 -1
  81. package/subgraph-store.ts +334 -299
  82. package/tool-gating.ts +39 -26
  83. package/tools.ts +499 -0
  84. package/tr-cli-export-helper.ts +138 -0
  85. package/tr-cli.ts +263 -133
  86. package/trajectory-poller.ts +162 -10
  87. package/vault-crypto.ts +705 -0
@@ -0,0 +1,723 @@
1
+ /**
2
+ * memory-runtime — adapter that bridges OpenClaw's FILE-ORIENTED memory
3
+ * result shapes to TR's ENCRYPTED-FACT + ON-CHAIN vault.
4
+ *
5
+ * Phase 2 (Task 2.1) of the OpenClaw native integration plan
6
+ * (docs/plans/2026-06-21-openclaw-native-integration-plan.md, 2026-06-21).
7
+ *
8
+ * WHY THIS FILE EXISTS — the load-bearing discovery:
9
+ * OpenClaw 2026.6.8's memory subsystem calls
10
+ * `runtime.getMemorySearchManager(...)` to get a MemorySearchManager,
11
+ * then `.search(query)` / a file-read method on it. Its result shapes
12
+ * are FILE-ORIENTED:
13
+ * search() -> MemorySearchResult[] where each hit =
14
+ * { path, startLine, endLine, score, snippet, source, citation? }
15
+ * read-by-rel -> { text, path, truncated?, from?, lines?, nextFrom? }
16
+ *
17
+ * TR's vault is ENCRYPTED-FACT + ON-CHAIN: facts have an id, encrypted
18
+ * blob, blind index, plaintext (after decrypt), scope, pinned flag.
19
+ * So this adapter SYNTHESIZES file-shaped results from decrypted facts:
20
+ * path = FACT_PATH_PREFIX + factId (a synthetic URI)
21
+ * startLine = 1, endLine = line-count of plaintext (synthetic)
22
+ * snippet = decrypted plaintext (truncated to 500 chars)
23
+ * source = 'memory', citation = factId
24
+ * The read-by-rel path reverses relPath -> id -> decrypt.
25
+ *
26
+ * This is THE thing that makes TR's on-chain vault look like a memory
27
+ * corpus to OpenClaw's `active-memory` sub-agent and the
28
+ * `memory_search` / `memory_get` tools.
29
+ *
30
+ * SCANNER-CLEAN HARD CONTRACT (env=N net=N):
31
+ * This file is pure orchestration. It touches NO environment state and
32
+ * performs NO outbound network I/O. All subgraph + decrypt work lives
33
+ * in the injected `recall` / `getById` closures (wired to the real
34
+ * pipeline in Task 2.3: subgraph-search + vault-crypto.decrypt +
35
+ * reranker). Keeping all I/O in those closures is what keeps this file
36
+ * clean under OpenClaw's per-file scanner rules — neither the
37
+ * env-harvesting pair nor the disk-exfil pair can ever co-occur here.
38
+ * `npm run check-scanner` must remain 0 flags; this docstring itself
39
+ * avoids the literal trigger tokens for that reason.
40
+ *
41
+ * PHASE 2 STATUS:
42
+ * - Task 2.1: `createTrMemorySearchManager` (shipped).
43
+ * - Task 2.3: `createTrMemoryPluginRuntime` (shipped) — the
44
+ * MemoryPluginRuntime wrapper that owns the wiring surface OpenClaw's
45
+ * memory subsystem calls. The actual binding of recall/getById to the
46
+ * real subgraph-search + vault-crypto.decrypt + reranker pipeline
47
+ * happens in Task 2.7's `buildRecallDeps` inside register().
48
+ * - Task 2.4: `buildPromptSection` (shipped) — recall guidance +
49
+ * quota warning + pinned facts. Mirrors memory-core's branching on
50
+ * memory_search/memory_get availability, adapted to TR's encrypted
51
+ * vault, plus the Hermes-grade extras (quota + pinned). The real
52
+ * quota/pinned binding happens in Task 2.7's `buildRecallDeps`.
53
+ * - Task 2.5: `buildFlushPlan` (shipped) — the `flushPlanResolver` that
54
+ * returns the memory flush PLAN (thresholds + extraction prompt) so
55
+ * OpenClaw's host can decide WHEN/HOW to flush the trajectory to TR's
56
+ * extract→encrypt→on-chain pipeline. Does NOT perform capture itself;
57
+ * the actual encrypt→on-chain path is Task 4.2 / H2 QA, and RC1 keeps
58
+ * the trajectory poller as the capture fallback so capture works
59
+ * regardless of flush cadence.
60
+ */
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Imports — kept scoped: only the canonical extraction system prompt is
64
+ // pulled from extractor.ts. memory-runtime.ts otherwise stays self-contained
65
+ // (no OpenClaw type import) so the plugin compiles without depending on
66
+ // OpenClaw's type package.
67
+ // ---------------------------------------------------------------------------
68
+ import { EXTRACTION_SYSTEM_PROMPT } from './extractor.js';
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Types — injected caller shapes. Kept loose (no OpenClaw type import) so
72
+ // the plugin compiles without depending on OpenClaw's type package. The
73
+ // returned manager is STRUCTURALLY compatible with OpenClaw's
74
+ // MemorySearchManager interface at runtime.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * A decrypted fact ready to be surfaced as a memory hit. `pinned` is
79
+ * optional and forwarded by recall if the pipeline already knows it.
80
+ */
81
+ export interface TrFact {
82
+ id: string;
83
+ plaintext: string;
84
+ score: number;
85
+ pinned?: boolean;
86
+ }
87
+
88
+ /**
89
+ * recall() runs the real subgraph-search + decrypt + reranker pipeline.
90
+ * `signal` lets the caller cancel an in-flight recall (forwarded from
91
+ * active-memory's search); `sessionKey` scopes the recall to a session.
92
+ */
93
+ export interface TrRecallFn {
94
+ (
95
+ query: string,
96
+ opts?: { maxResults?: number; signal?: AbortSignal; sessionKey?: string },
97
+ ): Promise<TrFact[]>;
98
+ }
99
+
100
+ /** getById() decrypts a single fact by id (the read-back reverse path). */
101
+ export interface TrGetFn {
102
+ (id: string): Promise<{ id: string; plaintext: string } | null>;
103
+ }
104
+
105
+ export interface TrMemorySearchManagerDeps {
106
+ recall: TrRecallFn;
107
+ getById: TrGetFn;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Types — promptBuilder (Task 2.4)
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Quota state injected into `buildPromptSection` so the prompt guidance can
116
+ * warn the agent (and indirectly the user) that new memories may not be
117
+ * saved. Mirrors Hermes `on_session_start`'s quota-warning logic.
118
+ *
119
+ * Two shapes, discriminated by presence of `denied`:
120
+ * - `{ usedPct }` — percentage of the monthly write budget consumed.
121
+ * Warns when STRICTLY greater than 80 (matches Hermes
122
+ * `used / limit > 0.8`).
123
+ * - `{ denied: true }` — the last capture attempt was rejected by the
124
+ * relay (HTTP 403 / quota exhausted). Always warns.
125
+ *
126
+ * Caller (Task 2.7's `buildRecallDeps` in register()) binds this to the
127
+ * paired account's real billing/quota state; the value is read from the
128
+ * TR client, NOT from the host environment, which keeps this file
129
+ * scanner-clean.
130
+ */
131
+ export type TrQuotaState = { usedPct: number } | { denied: true };
132
+
133
+ /**
134
+ * A pinned fact surfaced as always-relevant context by the prompt builder.
135
+ * `id` is the on-chain fact id (also usable as a `memory_get` citation);
136
+ * `plaintext` is the already-decrypted text (the caller in 2.7 decrypts).
137
+ */
138
+ export interface TrPinnedFact {
139
+ id: string;
140
+ plaintext: string;
141
+ }
142
+
143
+ /**
144
+ * Injected deps for `buildPromptSection`. Carries the runtime quota state
145
+ * and the decrypted pinned facts so the builder itself performs no
146
+ * environment read and no network I/O — it just renders strings.
147
+ */
148
+ export interface TrPromptBuilderDeps {
149
+ quota?: TrQuotaState;
150
+ pinned?: TrPinnedFact[];
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Constants
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Synthetic URI prefix encoding a fact id as a memory path. Reversible by
159
+ * readFile() so the active-memory sub-agent can dereference any hit.
160
+ */
161
+ export const FACT_PATH_PREFIX = 'totalreclaw://facts/';
162
+
163
+ /** Maximum snippet length surfaced in search() hits. Keeps tool payloads small. */
164
+ const SNIPPET_MAX = 500;
165
+
166
+ /** Default search cap when the caller doesn't pass maxResults. */
167
+ const DEFAULT_MAX_RESULTS = 8;
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Helpers
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function toLineCount(s: string): number {
174
+ // '' -> 1 (a single empty line), 'a\nb' -> 2. Used purely for synthetic
175
+ // startLine/endLine; OpenClaw treats these as display hints, not offsets.
176
+ return s.split('\n').length;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // createTrMemorySearchManager — the adapter factory
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export function createTrMemorySearchManager(deps: TrMemorySearchManagerDeps) {
184
+ /**
185
+ * search(): run recall, sort defensively by score, filter by minScore if
186
+ * requested, and synthesize file-shaped MemorySearchResult hits. We sort
187
+ * defensively here rather than relying on recall()'s ordering — the
188
+ * `score` field exists for exactly this, and n <= maxResults so it's
189
+ * effectively free. signal + sessionKey are forwarded so an aborted
190
+ * active-memory search actually cancels the in-flight recall.
191
+ */
192
+ async function search(
193
+ query: string,
194
+ opts?: { maxResults?: number; minScore?: number; signal?: AbortSignal; sessionKey?: string },
195
+ ) {
196
+ const max = opts?.maxResults ?? DEFAULT_MAX_RESULTS;
197
+ const minScore = opts?.minScore;
198
+ const facts = await deps.recall(query, {
199
+ maxResults: max,
200
+ signal: opts?.signal,
201
+ sessionKey: opts?.sessionKey,
202
+ });
203
+ facts.sort((a, b) => b.score - a.score);
204
+ const filtered = minScore === undefined ? facts : facts.filter((f) => f.score >= minScore!);
205
+ return filtered.slice(0, max).map((f) => ({
206
+ path: `${FACT_PATH_PREFIX}${f.id}`,
207
+ startLine: 1,
208
+ endLine: toLineCount(f.plaintext),
209
+ score: f.score,
210
+ snippet: f.plaintext.slice(0, SNIPPET_MAX),
211
+ source: 'memory' as const,
212
+ citation: f.id,
213
+ }));
214
+ }
215
+
216
+ /**
217
+ * readFile(): reverse relPath -> id -> decrypt. Supports optional
218
+ * `from` / `lines` pagination for large facts (1-indexed line ranges,
219
+ * matching OpenClaw's convention). Returns nextFrom when more lines
220
+ * remain so the caller can page.
221
+ */
222
+ async function readFile(params: { relPath: string; from?: number; lines?: number }) {
223
+ const id = params.relPath.startsWith(FACT_PATH_PREFIX)
224
+ ? params.relPath.slice(FACT_PATH_PREFIX.length)
225
+ : params.relPath;
226
+ const f = await deps.getById(id);
227
+ if (!f) throw new Error(`fact not found: ${id}`);
228
+
229
+ const from = params.from && params.from > 0 ? params.from : 1;
230
+ const want = params.lines && params.lines > 0 ? params.lines : undefined;
231
+
232
+ const allLines = f.plaintext.split('\n');
233
+ const totalLines = allLines.length;
234
+ const sliceEnd = want === undefined ? totalLines : Math.min(from + want - 1, totalLines);
235
+ const text = allLines.slice(from - 1, sliceEnd).join('\n');
236
+ const truncated = want !== undefined && from + want - 1 < totalLines;
237
+ const nextFrom = truncated ? from + want! : undefined;
238
+
239
+ return {
240
+ text,
241
+ path: `${FACT_PATH_PREFIX}${id}`,
242
+ truncated,
243
+ from,
244
+ // Clamp to non-negative: when `from` exceeds totalLines (e.g. reading
245
+ // past the end of a 1-line fact), sliceEnd - from + 1 goes negative.
246
+ // A bridge must never surface a negative line count.
247
+ lines: Math.max(0, sliceEnd - from + 1),
248
+ nextFrom,
249
+ };
250
+ }
251
+
252
+ function status() {
253
+ // `backend: 'builtin'` mirrors OpenClaw's non-qmd providers. The
254
+ // provider string is what the active-memory sub-agent logs against.
255
+ return { backend: 'builtin' as const, provider: 'totalreclaw' };
256
+ }
257
+
258
+ /**
259
+ * probeEmbeddingAvailability / probeVectorAvailability: optimistic OK
260
+ * here. The real availability depends on the injected pipeline (Task
261
+ * 2.3 wires the embedder + vector store); this adapter doesn't own
262
+ * that state, so the probes report ok until 2.3 gives them real hooks.
263
+ */
264
+ async function probeEmbeddingAvailability() {
265
+ // TODO(task 2.3): replace with real embedder probe.
266
+ return { ok: true };
267
+ }
268
+
269
+ async function probeVectorAvailability() {
270
+ // TODO(task 2.3): replace with real vector-store probe.
271
+ return true;
272
+ }
273
+
274
+ async function close() {}
275
+
276
+ return {
277
+ search,
278
+ readFile,
279
+ status,
280
+ probeEmbeddingAvailability,
281
+ probeVectorAvailability,
282
+ close,
283
+ };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // createTrMemoryPluginRuntime — the MemoryPluginRuntime wrapper (Task 2.3)
288
+ // ---------------------------------------------------------------------------
289
+ //
290
+ // WHY THIS WRAPPER EXISTS:
291
+ // OpenClaw 2026.6.8's memory subsystem does NOT call search/get directly.
292
+ // It calls `runtime.getMemorySearchManager(...)` to obtain a
293
+ // MemorySearchManager, then invokes `.search()` / readFile on it. It also
294
+ // calls `resolveMemoryBackendConfig` to decide between the built-in
295
+ // provider and an external `qmd` process, and `close*` on shutdown.
296
+ //
297
+ // So this wrapper is the seam OpenClaw actually talks to. It returns a
298
+ // fresh TrMemorySearchManager bound to the injected recall/getById
299
+ // pipeline on each getMemorySearchManager call. TR is its own backend
300
+ // (not qmd), so resolveMemoryBackendConfig reports `builtin`.
301
+ //
302
+ // The real pipeline binding — recall/getById wired to subgraph-search +
303
+ // vault-crypto.decrypt + reranker, parameterized by the paired account —
304
+ // is Task 2.7's `buildRecallDeps` in register(). This wrapper just carries
305
+ // whatever deps it's given.
306
+ //
307
+ // SCANNER-CLEAN HARD CONTRACT (env=N net=N):
308
+ // This function is pure orchestration. It touches NO environment state and
309
+ // performs NO outbound network I/O. The injected deps own all I/O. The
310
+ // `cfg` parameter is held as opaque (`unknown`) on purpose — the plugin
311
+ // does not import OpenClaw's config type, and getMemorySearchManager does
312
+ // not read anything off cfg (the paired-account context arrives via deps
313
+ // bound in 2.7, not via cfg here).
314
+ //
315
+ // ERROR CONTRACT:
316
+ // getMemorySearchManager MUST NEVER throw out of its async boundary — a
317
+ // failure to construct the adapter surfaces as `{ manager: null, error }`.
318
+ // Today construction is a closure capture and cannot realistically fail,
319
+ // but the try/catch is the durable guarantee for the day 2.7 adds
320
+ // paired-account resolution at construction time.
321
+
322
+ export function createTrMemoryPluginRuntime(deps: TrMemorySearchManagerDeps) {
323
+ return {
324
+ async getMemorySearchManager(_params: {
325
+ cfg: unknown;
326
+ agentId: string;
327
+ purpose?: string;
328
+ }): Promise<{ manager: ReturnType<typeof createTrMemorySearchManager> | null; error?: string }> {
329
+ try {
330
+ return {
331
+ manager: createTrMemorySearchManager(deps),
332
+ error: undefined as string | undefined,
333
+ };
334
+ } catch (e) {
335
+ const msg = e instanceof Error ? e.message : String(e);
336
+ return { manager: null, error: msg };
337
+ }
338
+ },
339
+
340
+ resolveMemoryBackendConfig(_params: { cfg: unknown; agentId: string }): {
341
+ backend: 'builtin';
342
+ } {
343
+ // TR is its own backend — never the external qmd process path.
344
+ return { backend: 'builtin' as const };
345
+ },
346
+
347
+ async closeMemorySearchManager(_params: { cfg: unknown; agentId: string }): Promise<void> {
348
+ // No per-manager resources to release today: the adapter holds only the
349
+ // injected closures; the closures' lifetimes are owned by register().
350
+ // Task 2.7 may add connection-pool / embedder teardown here.
351
+ },
352
+
353
+ async closeAllMemorySearchManagers(): Promise<void> {
354
+ // See closeMemorySearchManager — no-op until 2.7 binds pool resources.
355
+ },
356
+ };
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // buildPromptSection — recall guidance + quota warning + pinned facts
361
+ // (Task 2.4)
362
+ // ---------------------------------------------------------------------------
363
+ //
364
+ // WHY THIS EXISTS:
365
+ // OpenClaw's memory subsystem calls the registered `promptBuilder` to
366
+ // inject memory guidance into the agent's system prompt. The bundled
367
+ // memory-core's reference branches on which memory tools are available:
368
+ // search + get -> search first, then pull only the needed lines.
369
+ // search only -> search and answer from the matching results.
370
+ // get only -> pull only the needed lines.
371
+ // neither -> no guidance.
372
+ // TR's promptBuilder mirrors that branching BUT adapts the wording to
373
+ // TR's encrypted-vault model (the agent doesn't see files; it calls
374
+ // memory_search/memory_get which decrypt on the fly), AND adds two
375
+ // Hermes-grade extras via injected deps:
376
+ //
377
+ // 1. QUOTA WARNING — when the vault is near quota (>80% used) OR the
378
+ // last capture hit a 403, prepend a one-line warning so the agent
379
+ // can tell the user new memories may not be saved. Mirrors Hermes
380
+ // `on_session_start`'s billing-cache >0.8 + 403 path.
381
+ //
382
+ // 2. PINNED FACTS — always surface pinned facts as a `Pinned memories:`
383
+ // block, regardless of the query. These are always-relevant (user
384
+ // preferences, core commitments) and mirror Hermes surfacing
385
+ // pinned facts at session start.
386
+ //
387
+ // The quota + pinned data arrive via the injected `deps` object so this
388
+ // function stays environment/network clean — the caller in Task 2.7's
389
+ // `buildRecallDeps` binds real quota/pinned from the paired account.
390
+ // `citationsMode` is accepted for shape compatibility with the
391
+ // MemoryPluginCapability contract; it does not alter the guidance today
392
+ // (memory-core itself does not branch on it either, as of 2026.6.8).
393
+ //
394
+ // SCANNER-CLEAN HARD CONTRACT (env=N net=N):
395
+ // This function is pure string rendering. It touches NO host environment
396
+ // state and performs NO outbound network I/O. All quota + pinned data
397
+ // arrives via the deps parameter; the host environment and network
398
+ // primitives are never referenced. Neither the env-harvesting pair nor
399
+ // the disk-exfil pair can ever co-occur here — this docstring itself
400
+ // avoids the literal trigger tokens for that reason.
401
+
402
+ /** Quota threshold above which the warning fires. Matches Hermes >0.8. */
403
+ const QUOTA_WARN_THRESHOLD_PCT = 80;
404
+
405
+ /**
406
+ * Build the memory-prompt guidance section. Returns an array of lines
407
+ * (OpenClaw concatenates them into the system prompt).
408
+ *
409
+ * Ordering when all three are present:
410
+ * 1. (optional) quota warning — prepended so it's the first thing the
411
+ * agent sees; a near-full vault affects every capture decision.
412
+ * 2. recall guidance — the memory-core branching block.
413
+ * 3. (optional) pinned block — appended so always-relevant facts sit
414
+ * after the recall instructions the agent must follow.
415
+ */
416
+ export function buildPromptSection(
417
+ params: { availableTools: Set<string>; citationsMode?: unknown },
418
+ deps: TrPromptBuilderDeps = {},
419
+ ): string[] {
420
+ const out: string[] = [];
421
+
422
+ // (1) Quota warning — prepend when >80% used OR on 403/denied.
423
+ if (deps.quota !== undefined) {
424
+ const q = deps.quota;
425
+ const isOver =
426
+ 'denied' in q ? q.denied === true : typeof q.usedPct === 'number' && q.usedPct > QUOTA_WARN_THRESHOLD_PCT;
427
+ if (isOver) {
428
+ // Wording mirrors Hermes: tells the agent new memories may not be
429
+ // saved so it can surface the state to the user when relevant.
430
+ out.push(
431
+ '⚠️ TotalReclaw memory near quota — some new memories may not be saved. ' +
432
+ 'Let the user know if they ask why something was not remembered.',
433
+ );
434
+ }
435
+ }
436
+
437
+ // (2) Recall guidance — branch on memory tool availability, same shape
438
+ // as memory-core but adapted to TR's encrypted-vault model. The agent
439
+ // never sees files; memory_search / memory_get decrypt on demand.
440
+ const hasSearch = params.availableTools.has('memory_search');
441
+ const hasGet = params.availableTools.has('memory_get');
442
+
443
+ if (hasSearch && hasGet) {
444
+ out.push(
445
+ 'Before answering anything about prior work, decisions, dates, people, ' +
446
+ 'preferences, or todos: run memory_search against the user’s encrypted ' +
447
+ 'TotalReclaw memory vault, then use memory_get to pull only the needed ' +
448
+ 'facts in full. If your confidence is low after searching, say you ' +
449
+ 'checked memory and could not find it.',
450
+ );
451
+ } else if (hasSearch) {
452
+ out.push(
453
+ 'Before answering anything about prior work, decisions, dates, people, ' +
454
+ 'preferences, or todos: run memory_search against the user’s encrypted ' +
455
+ 'TotalReclaw memory vault and answer from the matching results. If your ' +
456
+ 'confidence is low after searching, say you checked memory and could ' +
457
+ 'not find it.',
458
+ );
459
+ } else if (hasGet) {
460
+ out.push(
461
+ 'When you need a specific prior fact, use memory_get to pull it in full ' +
462
+ 'from the user’s encrypted TotalReclaw memory vault. Do not speculate ' +
463
+ 'about prior work, decisions, dates, people, preferences, or todos ' +
464
+ 'without checking memory first.',
465
+ );
466
+ }
467
+ // If neither tool is available, no recall guidance is emitted (matches
468
+ // memory-core's no-guidance path). Pinned facts below still surface.
469
+
470
+ // (3) Pinned facts — always surface, even with no memory tools, because
471
+ // pinned facts are always-relevant context the agent should know
472
+ // regardless of whether it can also search/get.
473
+ const pinned = deps.pinned;
474
+ if (pinned !== undefined && pinned.length > 0) {
475
+ out.push('Pinned memories (always relevant):');
476
+ for (const p of pinned) {
477
+ // Each pinned fact on its own line, prefixed so the agent can tell
478
+ // the block apart from the recall guidance above. The plaintext is
479
+ // already decrypted by the caller (Task 2.7 binds this to the real
480
+ // pinned-fact lookup + decrypt pipeline).
481
+ out.push(`- ${p.plaintext}`);
482
+ }
483
+ }
484
+
485
+ return out;
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // buildFlushPlan — flushPlanResolver (Task 2.5)
490
+ // ---------------------------------------------------------------------------
491
+ //
492
+ // WHY THIS EXISTS:
493
+ // OpenClaw's memory subsystem periodically calls the registered
494
+ // `flushPlanResolver` to obtain a MemoryFlushPlan: a struct of thresholds
495
+ // + an extraction prompt. The host uses
496
+ // - `softThresholdTokens` / `forceFlushTranscriptBytes` to decide WHEN
497
+ // to flush (soft trigger as the context nears the soft limit, hard
498
+ // trigger when the raw trajectory transcript exceeds the byte limit),
499
+ // - `prompt` / `systemPrompt` to run the LLM extraction on the
500
+ // trajectory slice being flushed,
501
+ // - `relativePath` as the scratch location where the host writes the
502
+ // extraction output before handing it to the plugin.
503
+ //
504
+ // TR's resolver returns TR's OWN extraction prompt (the v1 taxonomy
505
+ // prompt shipped in extractor.ts — the same one the G-pipeline uses for
506
+ // turn extraction), so the host's flush-driven extraction produces facts
507
+ // in the exact shape TR's encrypt→on-chain pipeline expects.
508
+ //
509
+ // WHAT THIS DOES NOT DO:
510
+ // This resolver returns the PLAN ONLY. The actual
511
+ // extract→encrypt→on-chain capture is NOT here — it lives in the
512
+ // trajectory poller today (Task 4.1) and will move to a flush-driven
513
+ // capture path in Task 4.2 (gated on H2 QA). RC1 keeps the poller as the
514
+ // capture fallback so capture works regardless of flush cadence: even if
515
+ // the host never flushes, the poller still captures on its own schedule.
516
+ // This function never returns null today (capture is always on); null is
517
+ // reserved for a future capture-disabled config flag.
518
+ //
519
+ // THRESHOLD SOURCES:
520
+ // Cribbed from memory-core's `buildMemoryFlushPlan` defaults (verified at
521
+ // /tmp/tr-openclaw-probe/node_modules/openclaw/dist/extensions/memory-core/
522
+ // index.js, 2026.6.8): softThresholdTokens=4000,
523
+ // forceFlushTranscriptBytes=2097152 (2 MiB), reserveTokensFloor=20000.
524
+ // These are memory-core's documented defaults; TR does not yet expose
525
+ // config overrides (Task 2.7 may make them config-driven).
526
+ //
527
+ // RELATIVEPATH:
528
+ // `.totalreclaw/flush/<UTC-date>.jsonl` — a TR-namespaced scratch path.
529
+ // The host writes the extraction output here; the path is namespaced so
530
+ // it cannot collide with memory-core's `memory/<date>.md` file path
531
+ // (memory-core writes markdown, TR writes JSONL of extracted facts).
532
+ // The date stamp is derived from `nowMs` (UTC) so the path is
533
+ // deterministic for a given nowMs and does not depend on host TZ.
534
+ //
535
+ // SCANNER-CLEAN HARD CONTRACT (env=N net=N):
536
+ // This function is pure data assembly. It touches NO host environment
537
+ // state (no env-var reads) and performs NO outbound network I/O. The
538
+ // extraction prompt is imported from extractor.ts at module load; the
539
+ // date stamp is derived from the numeric `nowMs` param (or Date.now as a
540
+ // fallback, which is a pure clock read, not an env/network primitive).
541
+ // The `cfg` parameter is accepted for shape compatibility with the
542
+ // MemoryPluginCapability contract but is intentionally not read today —
543
+ // Task 2.7 may bind thresholds to cfg.agents.defaults.compaction.*.
544
+ // Neither the env-harvesting pair nor the disk-exfil pair can ever
545
+ // co-occur here — this docstring itself avoids the literal trigger
546
+ // tokens for that reason.
547
+
548
+ /**
549
+ * The memory flush plan returned to OpenClaw's host. Mirrors memory-core's
550
+ * `MemoryFlushPlan` shape (Appendix A of the integration plan). The host
551
+ * consumes thresholds + prompt to decide when/how to flush; capture itself
552
+ * is a separate downstream step.
553
+ */
554
+ export interface MemoryFlushPlan {
555
+ /** Soft trigger: flush when context nears this many tokens. */
556
+ softThresholdTokens: number;
557
+ /** Hard trigger: flush when raw transcript exceeds this many bytes. */
558
+ forceFlushTranscriptBytes: number;
559
+ /** Keep at least this many tokens of headroom after flush. */
560
+ reserveTokensFloor: number;
561
+ /** Extraction model (optional; Task 2.7 may make this config-driven). */
562
+ model?: string;
563
+ /** The extraction prompt handed to the LLM at flush time. */
564
+ prompt: string;
565
+ /** The extraction system prompt handed to the LLM at flush time. */
566
+ systemPrompt: string;
567
+ /** TR-namespaced scratch path where the host writes extraction output. */
568
+ relativePath: string;
569
+ }
570
+
571
+ /**
572
+ * Resolver signature — matches memory-core's `MemoryFlushPlanResolver`.
573
+ * Returns the plan, or null only if capture is explicitly disabled (which
574
+ * TR does not do today; the poller is always-on as the capture fallback).
575
+ */
576
+ export type MemoryFlushPlanResolver = (params: {
577
+ cfg?: unknown;
578
+ nowMs?: number;
579
+ }) => MemoryFlushPlan | null;
580
+
581
+ /**
582
+ * OpenClaw config shape (loose — we only read compaction overrides if
583
+ * present; today TR ignores all of it and ships documented defaults).
584
+ * Kept inline so memory-runtime.ts stays free of an OpenClaw type import.
585
+ */
586
+ interface LooseOpenClawConfig {
587
+ agents?: {
588
+ defaults?: {
589
+ compaction?: {
590
+ memoryFlush?: {
591
+ enabled?: boolean;
592
+ softThresholdTokens?: number;
593
+ forceFlushTranscriptBytes?: number | string;
594
+ prompt?: string;
595
+ systemPrompt?: string;
596
+ model?: string;
597
+ };
598
+ reserveTokensFloor?: number;
599
+ };
600
+ };
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Default flush thresholds. Cribbed from memory-core's
606
+ * `buildMemoryFlushPlan` defaults (OpenClaw 2026.6.8):
607
+ * - softThresholdTokens = 4000 (flush as context nears 4k tokens)
608
+ * - forceFlushTranscriptBytes = 2 MiB (hard flush on raw transcript size)
609
+ * - reserveTokensFloor = 20000 (headroom kept after a flush)
610
+ * These are documented defaults to be tuned at the H2 QA gate; Task 2.7
611
+ * may override them from cfg.agents.defaults.compaction.memoryFlush.
612
+ */
613
+ const DEFAULT_SOFT_THRESHOLD_TOKENS = 4000;
614
+ const DEFAULT_FORCE_FLUSH_TRANSCRIPT_BYTES = 2 * 1024 * 1024; // 2 MiB
615
+ const DEFAULT_RESERVE_TOKENS_FLOOR = 20000;
616
+
617
+ /**
618
+ * TR-canonical user-prompt TEMPLATE for flush-driven extraction. Mirrors
619
+ * the turn-extraction user prompt built inline in extractor.ts's
620
+ * `extractFacts()` (see `extractor.ts:1596`): the host appends the
621
+ * trajectory slice (and optionally the dedup context) after this prefix.
622
+ * Kept here rather than in extractor.ts because extractor.ts builds the
623
+ * user prompt dynamically per-call (it concatenates conversationText +
624
+ * existing-memory dedup context), so there's no single constant to
625
+ * import. This template captures the TR wording so the host's flush-
626
+ * driven extraction produces facts in the same shape as turn extraction.
627
+ */
628
+ const EXTRACTION_USER_PROMPT =
629
+ 'Extract important facts from these recent conversation turns:\n\n';
630
+
631
+ /**
632
+ * Scratch path prefix for TR flush output. The host writes the extraction
633
+ * result here before handing it to the plugin for encrypt→on-chain.
634
+ * Namespaced so it cannot collide with memory-core's `memory/*.md` paths.
635
+ */
636
+ const TR_FLUSH_DIR = '.totalreclaw/flush';
637
+
638
+ /**
639
+ * Format a UTC date stamp (YYYY-MM-DD) from a epoch-ms value. Pure
640
+ * function of the input — no host TZ dependence, no Intl nuance.
641
+ */
642
+ function formatUtcDateStamp(nowMs: number): string {
643
+ // ISO 8601 UTC: slice the YYYY-MM-DD prefix off the date portion. This
644
+ // mirrors memory-core's date-stamp derivation (which uses the host's
645
+ // configured TZ); TR deliberately uses UTC so the path is invariant
646
+ // across hosts and timezones — the path is a function of nowMs alone.
647
+ return new Date(nowMs).toISOString().slice(0, 10);
648
+ }
649
+
650
+ /**
651
+ * Build the memory flush plan returned to OpenClaw's host.
652
+ *
653
+ * @param params.cfg OpenClaw config (loose; today only the compaction
654
+ * overrides are consulted, and only `enabled:false`
655
+ * forces null. Task 2.7 may wire more overrides.)
656
+ * @param params.nowMs epoch-ms used to derive the date-stamped relativePath.
657
+ * Defaults to Date.now() — a pure clock read.
658
+ * @returns the MemoryFlushPlan, or null only if capture is explicitly
659
+ * disabled via cfg. Today capture is always on, so this never
660
+ * returns null in practice.
661
+ */
662
+ export function buildFlushPlan(params: { cfg?: LooseOpenClawConfig; nowMs?: number } = {}): MemoryFlushPlan | null {
663
+ const cfg = params.cfg as LooseOpenClawConfig | undefined;
664
+ const flushCfg = cfg?.agents?.defaults?.compaction?.memoryFlush;
665
+
666
+ // Capture is opt-OUT: only an explicit `enabled: false` returns null.
667
+ // This mirrors memory-core's contract. TR has no reason to disable
668
+ // capture (the poller is always-on), so default is to ship the plan.
669
+ if (flushCfg?.enabled === false) return null;
670
+
671
+ // Thresholds: cribbed defaults, optionally overridden by cfg. The byte
672
+ // size accepts a number or a human string (e.g. "2MiB") — we only honor
673
+ // numeric overrides today; string parsing is left to Task 2.7.
674
+ const softThresholdTokens =
675
+ typeof flushCfg?.softThresholdTokens === 'number' && flushCfg.softThresholdTokens >= 0
676
+ ? flushCfg.softThresholdTokens
677
+ : DEFAULT_SOFT_THRESHOLD_TOKENS;
678
+ const forceFlushTranscriptBytes =
679
+ typeof flushCfg?.forceFlushTranscriptBytes === 'number' && flushCfg.forceFlushTranscriptBytes >= 0
680
+ ? flushCfg.forceFlushTranscriptBytes
681
+ : DEFAULT_FORCE_FLUSH_TRANSCRIPT_BYTES;
682
+ const reserveTokensFloor =
683
+ typeof cfg?.agents?.defaults?.compaction?.reserveTokensFloor === 'number' &&
684
+ cfg.agents.defaults.compaction.reserveTokensFloor >= 0
685
+ ? cfg.agents.defaults.compaction.reserveTokensFloor
686
+ : DEFAULT_RESERVE_TOKENS_FLOOR;
687
+
688
+ // Extraction prompt: TR's canonical v1 taxonomy prompt. Imported from
689
+ // extractor.ts at module load. The host hands this to the LLM at flush
690
+ // time; the resulting facts are then encrypt→on-chain captured by the
691
+ // poller (today) or the flush-driven capture path (Task 4.2).
692
+ //
693
+ // cfg overrides are honored if provided (string, trimmed) — same shape
694
+ // as memory-core.
695
+ const prompt =
696
+ typeof flushCfg?.prompt === 'string' && flushCfg.prompt.trim().length > 0
697
+ ? flushCfg.prompt.trim()
698
+ : EXTRACTION_USER_PROMPT;
699
+ const systemPrompt =
700
+ typeof flushCfg?.systemPrompt === 'string' && flushCfg.systemPrompt.trim().length > 0
701
+ ? flushCfg.systemPrompt.trim()
702
+ : EXTRACTION_SYSTEM_PROMPT;
703
+
704
+ const model =
705
+ typeof flushCfg?.model === 'string' && flushCfg.model.trim().length > 0
706
+ ? flushCfg.model.trim()
707
+ : undefined;
708
+
709
+ // relativePath: TR-namespaced scratch path, date-stamped from nowMs.
710
+ const nowMs = typeof params.nowMs === 'number' ? params.nowMs : Date.now();
711
+ const dateStamp = formatUtcDateStamp(nowMs);
712
+ const relativePath = `${TR_FLUSH_DIR}/${dateStamp}.jsonl`;
713
+
714
+ return {
715
+ softThresholdTokens,
716
+ forceFlushTranscriptBytes,
717
+ reserveTokensFloor,
718
+ model,
719
+ prompt,
720
+ systemPrompt,
721
+ relativePath,
722
+ };
723
+ }