@botcord/daemon 0.2.70 → 0.2.72

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/dist/daemon.js CHANGED
@@ -11,6 +11,7 @@ import { adoptDiscoveredOpenclawAgents, collectRuntimeSnapshot, createProvisione
11
11
  import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
12
12
  import { SnapshotWriter } from "./snapshot-writer.js";
13
13
  import { createDaemonSystemContextBuilder } from "./system-context.js";
14
+ import { readWorkingMemorySnapshot } from "./working-memory.js";
14
15
  import { createRoomStaticContextBuilder } from "./room-context.js";
15
16
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
16
17
  import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
@@ -261,6 +262,7 @@ export async function startDaemon(opts) {
261
262
  const fallback = scBuilders.get(first);
262
263
  return fallback ? fallback(message) : undefined;
263
264
  };
265
+ const buildMemoryContext = (message) => readWorkingMemorySnapshot(message.accountId);
264
266
  // Observer runs after ack + before runtime.run. Keeping the side effect
265
267
  // outside the system-context builder (option A) means the builder stays
266
268
  // pure — a cleaner contract the gateway can also expose to non-daemon
@@ -362,6 +364,7 @@ export async function startDaemon(opts) {
362
364
  log: logger,
363
365
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
364
366
  buildSystemContext,
367
+ buildMemoryContext,
365
368
  onInbound,
366
369
  onOutbound,
367
370
  composeUserTurn: composeBotCordUserTurn,
@@ -8,6 +8,8 @@ import { splitText } from "./text-split.js";
8
8
  const FEISHU_PROVIDER = "feishu";
9
9
  const DEFAULT_SPLIT_AT = 4000;
10
10
  const MAX_SEEN_MESSAGES = 2048;
11
+ const TYPING_EMOJI = "Typing";
12
+ const TYPING_REACTION_TTL_MS = 20_000;
11
13
  function sdkDomain(domain) {
12
14
  const sdk = Lark;
13
15
  return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
@@ -63,6 +65,7 @@ export function createFeishuChannel(opts) {
63
65
  let botOpenId;
64
66
  let botName;
65
67
  let liveSetStatus = null;
68
+ const activeTypingReactions = new Map();
66
69
  let statusSnapshot = {
67
70
  channel: opts.id,
68
71
  accountId: opts.accountId,
@@ -292,6 +295,45 @@ export function createFeishuChannel(opts) {
292
295
  return ((typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
293
296
  (typeof res.message_id === "string" ? res.message_id : undefined));
294
297
  }
298
+ function resultReactionId(res) {
299
+ return ((typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
300
+ (typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
301
+ null);
302
+ }
303
+ function messageIdFromTrace(traceId) {
304
+ if (!traceId.startsWith("feishu:"))
305
+ return null;
306
+ const messageId = traceId.slice("feishu:".length).trim();
307
+ return messageId.length > 0 ? messageId : null;
308
+ }
309
+ async function removeTypingReaction(messageId) {
310
+ const state = activeTypingReactions.get(messageId);
311
+ if (!state)
312
+ return;
313
+ activeTypingReactions.delete(messageId);
314
+ if (state.timer)
315
+ clearTimeout(state.timer);
316
+ if (!state.reactionId)
317
+ return;
318
+ try {
319
+ await callFeishu({
320
+ method: "DELETE",
321
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
322
+ });
323
+ }
324
+ catch (err) {
325
+ statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
326
+ }
327
+ }
328
+ function scheduleTypingCleanup(messageId, state) {
329
+ if (state.timer)
330
+ clearTimeout(state.timer);
331
+ state.timer = setTimeout(() => {
332
+ void removeTypingReaction(messageId);
333
+ }, TYPING_REACTION_TTL_MS);
334
+ if (typeof state.timer.unref === "function")
335
+ state.timer.unref();
336
+ }
295
337
  function resultResourceKey(res, key) {
296
338
  const direct = res[key];
297
339
  if (typeof direct === "string")
@@ -422,14 +464,61 @@ export function createFeishuChannel(opts) {
422
464
  replyInThread: Boolean(ctx.message.threadId),
423
465
  }) ?? providerMessageId;
424
466
  }
467
+ if (ctx.message.replyTo) {
468
+ void removeTypingReaction(ctx.message.replyTo);
469
+ }
470
+ if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
471
+ void removeTypingReaction(ctx.message.threadId);
472
+ }
425
473
  markStatus({ lastSendAt: Date.now() });
426
474
  return { providerMessageId };
427
475
  }
428
476
  async function typing(ctx) {
429
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
430
- channel: opts.id,
431
- conversationId: ctx.conversationId,
432
- });
477
+ const messageId = messageIdFromTrace(ctx.traceId);
478
+ if (!messageId) {
479
+ ctx.log.debug("feishu typing skipped: trace id has no message id", {
480
+ channel: opts.id,
481
+ conversationId: ctx.conversationId,
482
+ traceId: ctx.traceId,
483
+ });
484
+ return;
485
+ }
486
+ const existing = activeTypingReactions.get(messageId);
487
+ if (existing) {
488
+ scheduleTypingCleanup(messageId, existing);
489
+ return;
490
+ }
491
+ const state = { reactionId: null, timer: null };
492
+ activeTypingReactions.set(messageId, state);
493
+ scheduleTypingCleanup(messageId, state);
494
+ try {
495
+ const res = await callFeishu({
496
+ method: "POST",
497
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
498
+ data: { reaction_type: { emoji_type: TYPING_EMOJI } },
499
+ });
500
+ const reactionId = resultReactionId(res);
501
+ if (activeTypingReactions.get(messageId) !== state) {
502
+ if (reactionId) {
503
+ await callFeishu({
504
+ method: "DELETE",
505
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
506
+ });
507
+ }
508
+ return;
509
+ }
510
+ state.reactionId = reactionId;
511
+ }
512
+ catch (err) {
513
+ activeTypingReactions.delete(messageId);
514
+ if (state.timer)
515
+ clearTimeout(state.timer);
516
+ ctx.log.warn("feishu typing reaction failed", {
517
+ channel: opts.id,
518
+ conversationId: ctx.conversationId,
519
+ err: err instanceof Error ? err.message : String(err),
520
+ });
521
+ }
433
522
  }
434
523
  async function stop(_ctx) {
435
524
  try {
@@ -439,6 +528,7 @@ export function createFeishuChannel(opts) {
439
528
  // best effort
440
529
  }
441
530
  wsClient = null;
531
+ await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
442
532
  try {
443
533
  stateStore?.close();
444
534
  }
@@ -1,7 +1,7 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
3
  import { type TranscriptWriter } from "./transcript.js";
4
- import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
4
+ import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
5
5
  /** Factory signature for building a runtime adapter at turn dispatch time. */
6
6
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
7
7
  /** Constructor options for `Dispatcher`. */
@@ -24,6 +24,13 @@ export interface DispatcherOptions {
24
24
  * swallowed and logged — they never abort the turn.
25
25
  */
26
26
  buildSystemContext?: SystemContextBuilder;
27
+ /**
28
+ * Optional hook returning the current working-memory snapshot/version. When
29
+ * a resumed runtime session last saw a different version, dispatcher injects
30
+ * the snapshot into the actual user prompt so resumed transcripts cannot
31
+ * keep following stale memory.
32
+ */
33
+ buildMemoryContext?: MemoryContextBuilder;
27
34
  /**
28
35
  * Optional side-effect hook invoked after ack, before the turn runs.
29
36
  * Intended for bookkeeping (e.g. activity tracking). Errors are logged
@@ -86,6 +93,7 @@ export declare class Dispatcher {
86
93
  private readonly log;
87
94
  private readonly turnTimeoutMs;
88
95
  private readonly buildSystemContext?;
96
+ private readonly buildMemoryContext?;
89
97
  private readonly onInbound?;
90
98
  private readonly onOutbound?;
91
99
  private readonly composeUserTurn?;
@@ -149,6 +157,7 @@ export declare class Dispatcher {
149
157
  private recomposeUserTurn;
150
158
  private runTurn;
151
159
  private sendReply;
160
+ private providerReplyTo;
152
161
  private emitInbound;
153
162
  private emitOutbound;
154
163
  }
@@ -39,6 +39,18 @@ function transcriptBlocksVerbose() {
39
39
  return process.env.BOTCORD_TRANSCRIPT_BLOCKS === "verbose" ||
40
40
  process.env.BOTCORD_TRACE_VERBOSE === "1";
41
41
  }
42
+ function buildMemoryUpdateNotice(args) {
43
+ return [
44
+ "[BotCord Memory Update Notice]",
45
+ `The persistent working memory changed since this runtime session last used it (previous: ${args.previousVersion ?? "none"}, current: ${args.currentVersion}).`,
46
+ "Before acting on the message below, retrieve the latest working memory through the available BotCord memory tool or CLI, then treat that latest memory as authoritative.",
47
+ "If using the local daemon CLI, run: botcord-daemon memory get",
48
+ "The latest memory supersedes older goals, monitoring rules, preferences, and task state in the resumed conversation.",
49
+ "",
50
+ "[Current Message]",
51
+ args.userTurn,
52
+ ].join("\n");
53
+ }
42
54
  function summarizeStreamBlock(block) {
43
55
  const summary = { type: block.kind };
44
56
  const raw = block.raw;
@@ -126,6 +138,7 @@ export class Dispatcher {
126
138
  log;
127
139
  turnTimeoutMs;
128
140
  buildSystemContext;
141
+ buildMemoryContext;
129
142
  onInbound;
130
143
  onOutbound;
131
144
  composeUserTurn;
@@ -149,6 +162,7 @@ export class Dispatcher {
149
162
  this.log = opts.log;
150
163
  this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
151
164
  this.buildSystemContext = opts.buildSystemContext;
165
+ this.buildMemoryContext = opts.buildMemoryContext;
152
166
  this.onInbound = opts.onInbound;
153
167
  this.onOutbound = opts.onOutbound;
154
168
  this.composeUserTurn = opts.composeUserTurn;
@@ -749,6 +763,8 @@ export class Dispatcher {
749
763
  });
750
764
  const entry = this.sessionStore.get(key);
751
765
  const sessionId = entry?.runtimeSessionId ?? null;
766
+ let currentMemoryVersion;
767
+ let runtimeText = text;
752
768
  const trustLevel = route.trustLevel ?? "trusted";
753
769
  const streamable = msg.trace?.streamable === true;
754
770
  const traceId = msg.trace?.id;
@@ -1008,13 +1024,45 @@ export class Dispatcher {
1008
1024
  });
1009
1025
  }
1010
1026
  }
1027
+ if (this.buildMemoryContext) {
1028
+ try {
1029
+ const snapshot = await this.buildMemoryContext(msg);
1030
+ if (snapshot &&
1031
+ typeof snapshot.version === "string" &&
1032
+ snapshot.version.length > 0) {
1033
+ currentMemoryVersion = snapshot.version;
1034
+ const previousMemoryVersion = entry?.memoryVersion ?? null;
1035
+ if (sessionId && previousMemoryVersion !== currentMemoryVersion) {
1036
+ runtimeText = buildMemoryUpdateNotice({
1037
+ previousVersion: previousMemoryVersion,
1038
+ currentVersion: currentMemoryVersion,
1039
+ userTurn: text,
1040
+ });
1041
+ this.log.info("dispatcher: injected memory update notice", {
1042
+ agentId: msg.accountId,
1043
+ roomId: msg.conversation.id,
1044
+ topicId: msg.conversation.threadId ?? null,
1045
+ turnId,
1046
+ previousMemoryVersion,
1047
+ currentMemoryVersion,
1048
+ });
1049
+ }
1050
+ }
1051
+ }
1052
+ catch (err) {
1053
+ this.log.warn("buildMemoryContext threw — continuing without memory version check", {
1054
+ error: err instanceof Error ? err.message : String(err),
1055
+ messageId: msg.id,
1056
+ });
1057
+ }
1058
+ }
1011
1059
  const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
1012
1060
  let result;
1013
1061
  let threw;
1014
1062
  try {
1015
1063
  try {
1016
1064
  result = await runtime.run({
1017
- text,
1065
+ text: runtimeText,
1018
1066
  sessionId,
1019
1067
  cwd: route.cwd,
1020
1068
  accountId: msg.accountId,
@@ -1098,7 +1146,7 @@ export class Dispatcher {
1098
1146
  threadId: msg.conversation.threadId ?? null,
1099
1147
  type: "error",
1100
1148
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1101
- replyTo: msg.id,
1149
+ replyTo: this.providerReplyTo(msg),
1102
1150
  traceId: msg.trace?.id ?? null,
1103
1151
  }, turnId);
1104
1152
  }
@@ -1144,7 +1192,7 @@ export class Dispatcher {
1144
1192
  threadId: msg.conversation.threadId ?? null,
1145
1193
  type: "error",
1146
1194
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1147
- replyTo: msg.id,
1195
+ replyTo: this.providerReplyTo(msg),
1148
1196
  traceId: msg.trace?.id ?? null,
1149
1197
  }, turnId);
1150
1198
  }
@@ -1194,6 +1242,7 @@ export class Dispatcher {
1194
1242
  key,
1195
1243
  runtime: route.runtime,
1196
1244
  runtimeSessionId: result.newSessionId,
1245
+ memoryVersion: currentMemoryVersion ?? entry?.memoryVersion ?? null,
1197
1246
  channel: msg.channel,
1198
1247
  accountId: msg.accountId,
1199
1248
  conversationKind: msg.conversation.kind,
@@ -1252,7 +1301,7 @@ export class Dispatcher {
1252
1301
  threadId: msg.conversation.threadId ?? null,
1253
1302
  type: "error",
1254
1303
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1255
- replyTo: msg.id,
1304
+ replyTo: this.providerReplyTo(msg),
1256
1305
  traceId: msg.trace?.id ?? null,
1257
1306
  }, turnId);
1258
1307
  this.emitOutbound({
@@ -1323,7 +1372,7 @@ export class Dispatcher {
1323
1372
  conversationId: msg.conversation.id,
1324
1373
  threadId: msg.conversation.threadId ?? null,
1325
1374
  text: replyText,
1326
- replyTo: msg.id,
1375
+ replyTo: this.providerReplyTo(msg),
1327
1376
  traceId: msg.trace?.id ?? null,
1328
1377
  }, turnId);
1329
1378
  this.emitOutbound({
@@ -1388,6 +1437,9 @@ export class Dispatcher {
1388
1437
  }
1389
1438
  return { ok: true };
1390
1439
  }
1440
+ providerReplyTo(msg) {
1441
+ return msg.replyTo ?? msg.id;
1442
+ }
1391
1443
  emitInbound(turnId, msg) {
1392
1444
  if (!this.transcript.enabled)
1393
1445
  return;
@@ -2,7 +2,7 @@ import { type ChannelBackoffOptions } from "./channel-manager.js";
2
2
  import { type RuntimeFactory } from "./dispatcher.js";
3
3
  import { type GatewayLogger } from "./log.js";
4
4
  import { type TranscriptWriter } from "./transcript.js";
5
- import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
5
+ import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, MemoryContextBuilder, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
6
6
  /** Constructor options for `Gateway`. */
7
7
  export interface GatewayBootOptions {
8
8
  config: GatewayConfig;
@@ -20,6 +20,11 @@ export interface GatewayBootOptions {
20
20
  * abort the turn.
21
21
  */
22
22
  buildSystemContext?: SystemContextBuilder;
23
+ /**
24
+ * Snapshot/version hook for working memory. Forwarded to dispatcher so
25
+ * resumed runtime sessions get an explicit prompt when memory changes.
26
+ */
27
+ buildMemoryContext?: MemoryContextBuilder;
23
28
  /**
24
29
  * Observer called after the dispatcher acks each inbound message. Useful
25
30
  * for activity tracking or metrics. Errors are logged and swallowed.
@@ -68,6 +68,7 @@ export class Gateway {
68
68
  log: this.log,
69
69
  turnTimeoutMs: opts.turnTimeoutMs,
70
70
  buildSystemContext: opts.buildSystemContext,
71
+ buildMemoryContext: opts.buildMemoryContext,
71
72
  onInbound: opts.onInbound,
72
73
  composeUserTurn: opts.composeUserTurn,
73
74
  onOutbound: opts.onOutbound,
@@ -133,6 +133,10 @@ export type InboundObserver = (message: GatewayInboundMessage) => Promise<void>
133
133
  * a buggy composer never drops turns.
134
134
  */
135
135
  export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
136
+ export interface MemoryContextSnapshot {
137
+ version: string;
138
+ }
139
+ export type MemoryContextBuilder = (message: GatewayInboundMessage) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
136
140
  /**
137
141
  * Optional hook fired after the dispatcher dispatches a reply to a channel.
138
142
  * Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
@@ -382,6 +386,8 @@ export interface GatewaySessionEntry {
382
386
  key: string;
383
387
  runtime: string;
384
388
  runtimeSessionId: string;
389
+ /** Version of working memory last injected into this runtime session. */
390
+ memoryVersion?: string | null;
385
391
  channel: string;
386
392
  accountId: string;
387
393
  conversationKind: "direct" | "group";
@@ -4,6 +4,10 @@ export interface WorkingMemory {
4
4
  sections: Record<string, string>;
5
5
  updatedAt: string;
6
6
  }
7
+ export interface WorkingMemorySnapshot {
8
+ memory: WorkingMemory | null;
9
+ version: string;
10
+ }
7
11
  /** Characters per section; matches the plugin-side limit. */
8
12
  export declare const MAX_SECTION_CHARS = 10000;
9
13
  export declare const MAX_GOAL_CHARS = 500;
@@ -15,6 +19,8 @@ export declare const DEFAULT_SECTION = "notes";
15
19
  */
16
20
  export declare function resolveMemoryDir(agentId: string): string;
17
21
  export declare function readWorkingMemory(agentId: string): WorkingMemory | null;
22
+ export declare function workingMemoryVersion(memory: WorkingMemory | null): string;
23
+ export declare function readWorkingMemorySnapshot(agentId: string): WorkingMemorySnapshot;
18
24
  export declare function writeWorkingMemory(agentId: string, data: WorkingMemory): void;
19
25
  export interface SetSectionResult {
20
26
  memory: WorkingMemory;
@@ -9,6 +9,7 @@
9
9
  * branches) and plugin/src/memory-protocol.ts (prompt builder).
10
10
  */
11
11
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
12
+ import { createHash } from "node:crypto";
12
13
  import { homedir } from "node:os";
13
14
  import path from "node:path";
14
15
  import { agentStateDir } from "./agent-workspace.js";
@@ -171,6 +172,28 @@ export function readWorkingMemory(agentId) {
171
172
  return null;
172
173
  return normalize(readJson(p));
173
174
  }
175
+ function canonicalizeWorkingMemory(memory) {
176
+ const sections = {};
177
+ for (const [key, value] of Object.entries(memory?.sections ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
178
+ sections[key] = value;
179
+ }
180
+ return {
181
+ version: 2,
182
+ goal: memory?.goal ?? null,
183
+ sections,
184
+ };
185
+ }
186
+ export function workingMemoryVersion(memory) {
187
+ const canonical = JSON.stringify(canonicalizeWorkingMemory(memory));
188
+ return `wm-sha256:${createHash("sha256").update(canonical).digest("hex").slice(0, 16)}`;
189
+ }
190
+ export function readWorkingMemorySnapshot(agentId) {
191
+ const memory = readWorkingMemory(agentId);
192
+ return {
193
+ memory,
194
+ version: workingMemoryVersion(memory),
195
+ };
196
+ }
174
197
  export function writeWorkingMemory(agentId, data) {
175
198
  writeJsonAtomic(workingMemoryPath(agentId), data);
176
199
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.70",
3
+ "version": "0.2.72",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
7
- "botcord-daemon": "./dist/index.js"
7
+ "botcord-daemon": "dist/index.js"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -274,3 +274,33 @@ describe("buildWorkingMemoryPrompt", () => {
274
274
  expect(p).toContain("‹current_memory›");
275
275
  });
276
276
  });
277
+
278
+ describe("working-memory version", () => {
279
+ it("is stable for identical content regardless of section insertion order or updatedAt", () => {
280
+ const a = wm.workingMemoryVersion({
281
+ version: 2,
282
+ sections: { b: "two", a: "one" },
283
+ updatedAt: "2026-01-01T00:00:00.000Z",
284
+ });
285
+ const b = wm.workingMemoryVersion({
286
+ version: 2,
287
+ sections: { a: "one", b: "two" },
288
+ updatedAt: "2026-01-02T00:00:00.000Z",
289
+ });
290
+ expect(a).toBe(b);
291
+ });
292
+
293
+ it("changes when durable memory content changes", () => {
294
+ const a = wm.workingMemoryVersion({
295
+ version: 2,
296
+ sections: { notes: "old" },
297
+ updatedAt: "2026-01-01T00:00:00.000Z",
298
+ });
299
+ const b = wm.workingMemoryVersion({
300
+ version: 2,
301
+ sections: { notes: "new" },
302
+ updatedAt: "2026-01-01T00:00:00.000Z",
303
+ });
304
+ expect(a).not.toBe(b);
305
+ });
306
+ });
package/src/daemon.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
36
36
  import { SnapshotWriter } from "./snapshot-writer.js";
37
37
  import { createDaemonSystemContextBuilder } from "./system-context.js";
38
+ import { readWorkingMemorySnapshot } from "./working-memory.js";
38
39
  import { createRoomStaticContextBuilder } from "./room-context.js";
39
40
  import { createRoomContextFetcher } from "./room-context-fetcher.js";
40
41
  import {
@@ -400,6 +401,8 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
400
401
  const fallback = scBuilders.get(first);
401
402
  return fallback ? fallback(message) : undefined;
402
403
  };
404
+ const buildMemoryContext = (message: GatewayInboundMessage) =>
405
+ readWorkingMemorySnapshot(message.accountId);
403
406
 
404
407
  // Observer runs after ack + before runtime.run. Keeping the side effect
405
408
  // outside the system-context builder (option A) means the builder stays
@@ -511,6 +514,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
511
514
  log: logger,
512
515
  turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
513
516
  buildSystemContext,
517
+ buildMemoryContext,
514
518
  onInbound,
515
519
  onOutbound,
516
520
  composeUserTurn: composeBotCordUserTurn,
@@ -293,6 +293,30 @@ describe("Dispatcher", () => {
293
293
  expect(store.all()[0].threadId).toBe("t_1");
294
294
  });
295
295
 
296
+ it("sends replies to the provider reply id when it differs from the internal message id", async () => {
297
+ const runtime = new FakeRuntime({ reply: "ok" });
298
+ const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
299
+ const { dispatcher, channel } = await scaffold({
300
+ runtimeFactory: () => runtime,
301
+ channel: feishuChannel,
302
+ config: baseConfig({
303
+ channels: [{ id: "gw_feishu_1", type: "feishu", accountId: "ag_me" }],
304
+ }),
305
+ });
306
+
307
+ await dispatcher.handle(
308
+ makeEnvelope({
309
+ id: "feishu:om_internal_wrapped",
310
+ replyTo: "om_provider_raw",
311
+ channel: "gw_feishu_1",
312
+ conversation: { id: "feishu:user:oc_chat", kind: "direct" },
313
+ }),
314
+ );
315
+
316
+ expect(channel.sends.length).toBe(1);
317
+ expect(channel.sends[0].message.replyTo).toBe("om_provider_raw");
318
+ });
319
+
296
320
  it("reuses session id on second message with same queue key", async () => {
297
321
  const seen: Array<string | null> = [];
298
322
  const runtime = new FakeRuntime({
@@ -1581,6 +1605,71 @@ describe("Dispatcher", () => {
1581
1605
  ).toBe(true);
1582
1606
  });
1583
1607
 
1608
+ it("injects a memory update notice into resumed sessions when memory version changes", async () => {
1609
+ const seenText: string[] = [];
1610
+ let memoryVersion = "wm-sha256:v1";
1611
+ const runtime = new FakeRuntime({
1612
+ newSessionId: (opts) => opts.sessionId ?? "sid-1",
1613
+ observeRun: (opts) => seenText.push(opts.text),
1614
+ });
1615
+ const { store, dir } = await makeStore();
1616
+ tempDirs.push(dir);
1617
+ const channel = new FakeChannel();
1618
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1619
+ const dispatcher = new Dispatcher({
1620
+ config: baseConfig(),
1621
+ channels,
1622
+ runtime: () => runtime,
1623
+ sessionStore: store,
1624
+ log: silentLogger(),
1625
+ buildMemoryContext: () => ({
1626
+ version: memoryVersion,
1627
+ }),
1628
+ });
1629
+
1630
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "first" }));
1631
+ expect(seenText[0]).toBe("first");
1632
+ expect(store.all()[0].memoryVersion).toBe("wm-sha256:v1");
1633
+
1634
+ memoryVersion = "wm-sha256:v2";
1635
+ await dispatcher.handle(makeEnvelope({ id: "msg_2", text: "second" }));
1636
+ expect(seenText[1]).toContain("[BotCord Memory Update Notice]");
1637
+ expect(seenText[1]).toContain("previous: wm-sha256:v1, current: wm-sha256:v2");
1638
+ expect(seenText[1]).toContain("retrieve the latest working memory");
1639
+ expect(seenText[1]).toContain("botcord-daemon memory get");
1640
+ expect(seenText[1]).not.toContain("[BotCord Working Memory]\nversion wm-sha256:v2");
1641
+ expect(seenText[1]).toContain("[Current Message]\nsecond");
1642
+ expect(store.all()[0].memoryVersion).toBe("wm-sha256:v2");
1643
+ });
1644
+
1645
+ it("does not inject a memory update notice when the resumed session already has the current memory version", async () => {
1646
+ const seenText: string[] = [];
1647
+ const runtime = new FakeRuntime({
1648
+ newSessionId: (opts) => opts.sessionId ?? "sid-1",
1649
+ observeRun: (opts) => seenText.push(opts.text),
1650
+ });
1651
+ const { store, dir } = await makeStore();
1652
+ tempDirs.push(dir);
1653
+ const channel = new FakeChannel();
1654
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1655
+ const dispatcher = new Dispatcher({
1656
+ config: baseConfig(),
1657
+ channels,
1658
+ runtime: () => runtime,
1659
+ sessionStore: store,
1660
+ log: silentLogger(),
1661
+ buildMemoryContext: () => ({
1662
+ version: "wm-sha256:same",
1663
+ }),
1664
+ });
1665
+
1666
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", text: "first" }));
1667
+ await dispatcher.handle(makeEnvelope({ id: "msg_2", text: "second" }));
1668
+
1669
+ expect(seenText).toEqual(["first", "second"]);
1670
+ expect(store.all()[0].memoryVersion).toBe("wm-sha256:same");
1671
+ });
1672
+
1584
1673
  it("onInbound: observer is invoked with the message between ack and runtime.run", async () => {
1585
1674
  const order: string[] = [];
1586
1675
  const runtime = new FakeRuntime({
@@ -2009,7 +2098,8 @@ describe("Dispatcher", () => {
2009
2098
  });
2010
2099
  await dispatcher.handle(
2011
2100
  makeEnvelope({
2012
- id: "m_err",
2101
+ id: "feishu:om_internal_err",
2102
+ replyTo: "om_provider_err",
2013
2103
  conversation: { id: "rm_g_other", kind: "group" },
2014
2104
  }),
2015
2105
  );
@@ -2017,7 +2107,7 @@ describe("Dispatcher", () => {
2017
2107
  expect(channel.sends[0].message.type).toBe("error");
2018
2108
  expect(channel.sends[0].message.text).toContain("Runtime error: boom");
2019
2109
  expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
2020
- expect(channel.sends[0].message.replyTo).toBe("m_err");
2110
+ expect(channel.sends[0].message.replyTo).toBe("om_provider_err");
2021
2111
  });
2022
2112
 
2023
2113
  // ─────────────────────────────────────────────────────────────────────
@@ -255,27 +255,80 @@ describe("createFeishuChannel", () => {
255
255
  expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
256
256
  });
257
257
 
258
- it("exposes typing as a safe no-op because Feishu has no bot typing API", async () => {
259
- const debug = vi.fn();
258
+ it("adds a Typing reaction for typing and removes it when replying", async () => {
260
259
  const adapter = createFeishuChannel({
261
260
  id: "gw_fs",
262
261
  accountId: "ag_self",
263
262
  appId: "cli_a",
264
263
  appSecret: "sec",
265
264
  });
265
+ larkMock.responses.push(
266
+ { code: 0, data: { reaction_id: "react_typing_1" } },
267
+ { code: 0, data: { message_id: "om_reply" } },
268
+ { code: 0, data: {} },
269
+ );
266
270
 
267
271
  await adapter.typing?.({
268
272
  traceId: "feishu:om_1",
269
273
  accountId: "ag_self",
270
274
  conversationId: "feishu:chat:oc_chat",
271
- log: { ...SILENT_LOG, debug },
275
+ log: SILENT_LOG,
272
276
  });
273
277
 
274
- expect(larkMock.requests).toHaveLength(0);
275
- expect(debug).toHaveBeenCalledWith(
276
- "feishu typing ignored: no native bot typing API",
277
- expect.objectContaining({ channel: "gw_fs" }),
278
- );
278
+ expect(larkMock.requests).toHaveLength(1);
279
+ expect(larkMock.requests[0]).toEqual({
280
+ method: "POST",
281
+ url: "/open-apis/im/v1/messages/om_1/reactions",
282
+ data: { reaction_type: { emoji_type: "Typing" } },
283
+ });
284
+
285
+ await adapter.send({
286
+ message: {
287
+ channel: "gw_fs",
288
+ accountId: "ag_self",
289
+ conversationId: "feishu:chat:oc_chat",
290
+ text: "reply",
291
+ replyTo: "om_1",
292
+ },
293
+ log: SILENT_LOG,
294
+ });
295
+ await new Promise((resolve) => setTimeout(resolve, 0));
296
+
297
+ expect(larkMock.requests).toHaveLength(3);
298
+ expect(larkMock.requests[2]).toEqual({
299
+ method: "DELETE",
300
+ url: "/open-apis/im/v1/messages/om_1/reactions/react_typing_1",
301
+ });
302
+ });
303
+
304
+ it("refreshes an existing Feishu typing reaction without creating duplicates", async () => {
305
+ const adapter = createFeishuChannel({
306
+ id: "gw_fs",
307
+ accountId: "ag_self",
308
+ appId: "cli_a",
309
+ appSecret: "sec",
310
+ });
311
+ larkMock.responses.push({ code: 0, data: { reaction_id: "react_typing_1" } });
312
+
313
+ await adapter.typing?.({
314
+ traceId: "feishu:om_1",
315
+ accountId: "ag_self",
316
+ conversationId: "feishu:chat:oc_chat",
317
+ log: SILENT_LOG,
318
+ });
319
+ await adapter.typing?.({
320
+ traceId: "feishu:om_1",
321
+ accountId: "ag_self",
322
+ conversationId: "feishu:chat:oc_chat",
323
+ log: SILENT_LOG,
324
+ });
325
+
326
+ expect(larkMock.requests).toHaveLength(1);
327
+ expect(larkMock.requests[0]).toEqual({
328
+ method: "POST",
329
+ url: "/open-apis/im/v1/messages/om_1/reactions",
330
+ data: { reaction_type: { emoji_type: "Typing" } },
331
+ });
279
332
  });
280
333
 
281
334
  it("surfaces websocket start failures in channel status", async () => {
@@ -21,6 +21,8 @@ import type { FeishuDomain } from "./feishu-registration.js";
21
21
  const FEISHU_PROVIDER = "feishu" as const;
22
22
  const DEFAULT_SPLIT_AT = 4000;
23
23
  const MAX_SEEN_MESSAGES = 2048;
24
+ const TYPING_EMOJI = "Typing";
25
+ const TYPING_REACTION_TTL_MS = 20_000;
24
26
 
25
27
  export interface FeishuChannelOptions {
26
28
  id: string;
@@ -80,6 +82,10 @@ interface FeishuApiResponse {
80
82
  }
81
83
 
82
84
  type FeishuClient = { request(args: unknown): Promise<unknown> };
85
+ type TypingReactionState = {
86
+ reactionId: string | null;
87
+ timer: ReturnType<typeof setTimeout> | null;
88
+ };
83
89
 
84
90
  function sdkDomain(domain: FeishuDomain | undefined): unknown {
85
91
  const sdk = Lark as unknown as {
@@ -137,6 +143,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
137
143
  let botOpenId: string | undefined;
138
144
  let botName: string | undefined;
139
145
  let liveSetStatus: ((patch: Partial<ChannelStatusSnapshot>) => void) | null = null;
146
+ const activeTypingReactions = new Map<string, TypingReactionState>();
140
147
 
141
148
  let statusSnapshot: ChannelStatusSnapshot = {
142
149
  channel: opts.id,
@@ -387,6 +394,44 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
387
394
  );
388
395
  }
389
396
 
397
+ function resultReactionId(res: FeishuApiResponse): string | null {
398
+ return (
399
+ (typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
400
+ (typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
401
+ null
402
+ );
403
+ }
404
+
405
+ function messageIdFromTrace(traceId: string): string | null {
406
+ if (!traceId.startsWith("feishu:")) return null;
407
+ const messageId = traceId.slice("feishu:".length).trim();
408
+ return messageId.length > 0 ? messageId : null;
409
+ }
410
+
411
+ async function removeTypingReaction(messageId: string): Promise<void> {
412
+ const state = activeTypingReactions.get(messageId);
413
+ if (!state) return;
414
+ activeTypingReactions.delete(messageId);
415
+ if (state.timer) clearTimeout(state.timer);
416
+ if (!state.reactionId) return;
417
+ try {
418
+ await callFeishu({
419
+ method: "DELETE",
420
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
421
+ });
422
+ } catch (err) {
423
+ statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
424
+ }
425
+ }
426
+
427
+ function scheduleTypingCleanup(messageId: string, state: TypingReactionState): void {
428
+ if (state.timer) clearTimeout(state.timer);
429
+ state.timer = setTimeout(() => {
430
+ void removeTypingReaction(messageId);
431
+ }, TYPING_REACTION_TTL_MS);
432
+ if (typeof state.timer.unref === "function") state.timer.unref();
433
+ }
434
+
390
435
  function resultResourceKey(res: FeishuApiResponse, key: "image_key" | "file_key"): string {
391
436
  const direct = res[key];
392
437
  if (typeof direct === "string") return direct;
@@ -516,15 +561,61 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
516
561
  replyInThread: Boolean(ctx.message.threadId),
517
562
  }) ?? providerMessageId;
518
563
  }
564
+ if (ctx.message.replyTo) {
565
+ void removeTypingReaction(ctx.message.replyTo);
566
+ }
567
+ if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
568
+ void removeTypingReaction(ctx.message.threadId);
569
+ }
519
570
  markStatus({ lastSendAt: Date.now() });
520
571
  return { providerMessageId };
521
572
  }
522
573
 
523
574
  async function typing(ctx: ChannelTypingContext): Promise<void> {
524
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
525
- channel: opts.id,
526
- conversationId: ctx.conversationId,
527
- });
575
+ const messageId = messageIdFromTrace(ctx.traceId);
576
+ if (!messageId) {
577
+ ctx.log.debug("feishu typing skipped: trace id has no message id", {
578
+ channel: opts.id,
579
+ conversationId: ctx.conversationId,
580
+ traceId: ctx.traceId,
581
+ });
582
+ return;
583
+ }
584
+ const existing = activeTypingReactions.get(messageId);
585
+ if (existing) {
586
+ scheduleTypingCleanup(messageId, existing);
587
+ return;
588
+ }
589
+
590
+ const state: TypingReactionState = { reactionId: null, timer: null };
591
+ activeTypingReactions.set(messageId, state);
592
+ scheduleTypingCleanup(messageId, state);
593
+ try {
594
+ const res = await callFeishu({
595
+ method: "POST",
596
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
597
+ data: { reaction_type: { emoji_type: TYPING_EMOJI } },
598
+ });
599
+ const reactionId = resultReactionId(res);
600
+ if (activeTypingReactions.get(messageId) !== state) {
601
+ if (reactionId) {
602
+ await callFeishu({
603
+ method: "DELETE",
604
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
605
+ });
606
+ }
607
+ return;
608
+ }
609
+ state.reactionId = reactionId;
610
+ } catch (err) {
611
+ activeTypingReactions.delete(messageId);
612
+ if (state.timer) clearTimeout(state.timer);
613
+ ctx.log.warn("feishu typing reaction failed", {
614
+ channel: opts.id,
615
+ conversationId: ctx.conversationId,
616
+ err: err instanceof Error ? err.message : String(err),
617
+ });
618
+ }
528
619
  }
529
620
 
530
621
  async function stop(_ctx: ChannelStopContext): Promise<void> {
@@ -534,6 +625,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
534
625
  // best effort
535
626
  }
536
627
  wsClient = null;
628
+ await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
537
629
  try {
538
630
  stateStore?.close();
539
631
  } catch {
@@ -18,6 +18,7 @@ import type {
18
18
  GatewayRoute,
19
19
  GatewaySessionEntry,
20
20
  InboundObserver,
21
+ MemoryContextBuilder,
21
22
  OutboundObserver,
22
23
  QueueMode,
23
24
  RuntimeAdapter,
@@ -73,6 +74,23 @@ function transcriptBlocksVerbose(): boolean {
73
74
  process.env.BOTCORD_TRACE_VERBOSE === "1";
74
75
  }
75
76
 
77
+ function buildMemoryUpdateNotice(args: {
78
+ previousVersion: string | null;
79
+ currentVersion: string;
80
+ userTurn: string;
81
+ }): string {
82
+ return [
83
+ "[BotCord Memory Update Notice]",
84
+ `The persistent working memory changed since this runtime session last used it (previous: ${args.previousVersion ?? "none"}, current: ${args.currentVersion}).`,
85
+ "Before acting on the message below, retrieve the latest working memory through the available BotCord memory tool or CLI, then treat that latest memory as authoritative.",
86
+ "If using the local daemon CLI, run: botcord-daemon memory get",
87
+ "The latest memory supersedes older goals, monitoring rules, preferences, and task state in the resumed conversation.",
88
+ "",
89
+ "[Current Message]",
90
+ args.userTurn,
91
+ ].join("\n");
92
+ }
93
+
76
94
  function summarizeStreamBlock(block: StreamBlock): TranscriptBlockSummary {
77
95
  const summary: TranscriptBlockSummary = { type: block.kind };
78
96
  const raw = block.raw as {
@@ -150,6 +168,13 @@ export interface DispatcherOptions {
150
168
  * swallowed and logged — they never abort the turn.
151
169
  */
152
170
  buildSystemContext?: SystemContextBuilder;
171
+ /**
172
+ * Optional hook returning the current working-memory snapshot/version. When
173
+ * a resumed runtime session last saw a different version, dispatcher injects
174
+ * the snapshot into the actual user prompt so resumed transcripts cannot
175
+ * keep following stale memory.
176
+ */
177
+ buildMemoryContext?: MemoryContextBuilder;
153
178
  /**
154
179
  * Optional side-effect hook invoked after ack, before the turn runs.
155
180
  * Intended for bookkeeping (e.g. activity tracking). Errors are logged
@@ -285,6 +310,7 @@ export class Dispatcher {
285
310
  private readonly log: GatewayLogger;
286
311
  private readonly turnTimeoutMs: number;
287
312
  private readonly buildSystemContext?: SystemContextBuilder;
313
+ private readonly buildMemoryContext?: MemoryContextBuilder;
288
314
  private readonly onInbound?: InboundObserver;
289
315
  private readonly onOutbound?: OutboundObserver;
290
316
  private readonly composeUserTurn?: UserTurnBuilder;
@@ -311,6 +337,7 @@ export class Dispatcher {
311
337
  this.log = opts.log;
312
338
  this.turnTimeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
313
339
  this.buildSystemContext = opts.buildSystemContext;
340
+ this.buildMemoryContext = opts.buildMemoryContext;
314
341
  this.onInbound = opts.onInbound;
315
342
  this.onOutbound = opts.onOutbound;
316
343
  this.composeUserTurn = opts.composeUserTurn;
@@ -990,6 +1017,8 @@ export class Dispatcher {
990
1017
  });
991
1018
  const entry = this.sessionStore.get(key);
992
1019
  const sessionId = entry?.runtimeSessionId ?? null;
1020
+ let currentMemoryVersion: string | undefined;
1021
+ let runtimeText = text;
993
1022
  const trustLevel = route.trustLevel ?? "trusted";
994
1023
 
995
1024
  const streamable = msg.trace?.streamable === true;
@@ -1252,13 +1281,47 @@ export class Dispatcher {
1252
1281
  }
1253
1282
  }
1254
1283
 
1284
+ if (this.buildMemoryContext) {
1285
+ try {
1286
+ const snapshot = await this.buildMemoryContext(msg);
1287
+ if (
1288
+ snapshot &&
1289
+ typeof snapshot.version === "string" &&
1290
+ snapshot.version.length > 0
1291
+ ) {
1292
+ currentMemoryVersion = snapshot.version;
1293
+ const previousMemoryVersion = entry?.memoryVersion ?? null;
1294
+ if (sessionId && previousMemoryVersion !== currentMemoryVersion) {
1295
+ runtimeText = buildMemoryUpdateNotice({
1296
+ previousVersion: previousMemoryVersion,
1297
+ currentVersion: currentMemoryVersion,
1298
+ userTurn: text,
1299
+ });
1300
+ this.log.info("dispatcher: injected memory update notice", {
1301
+ agentId: msg.accountId,
1302
+ roomId: msg.conversation.id,
1303
+ topicId: msg.conversation.threadId ?? null,
1304
+ turnId,
1305
+ previousMemoryVersion,
1306
+ currentMemoryVersion,
1307
+ });
1308
+ }
1309
+ }
1310
+ } catch (err) {
1311
+ this.log.warn("buildMemoryContext threw — continuing without memory version check", {
1312
+ error: err instanceof Error ? err.message : String(err),
1313
+ messageId: msg.id,
1314
+ });
1315
+ }
1316
+ }
1317
+
1255
1318
  const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
1256
1319
  let result: { text: string; newSessionId: string; costUsd?: number; error?: string } | undefined;
1257
1320
  let threw: unknown;
1258
1321
  try {
1259
1322
  try {
1260
1323
  result = await runtime.run({
1261
- text,
1324
+ text: runtimeText,
1262
1325
  sessionId,
1263
1326
  cwd: route.cwd,
1264
1327
  accountId: msg.accountId,
@@ -1343,7 +1406,7 @@ export class Dispatcher {
1343
1406
  threadId: msg.conversation.threadId ?? null,
1344
1407
  type: "error",
1345
1408
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1346
- replyTo: msg.id,
1409
+ replyTo: this.providerReplyTo(msg),
1347
1410
  traceId: msg.trace?.id ?? null,
1348
1411
  }, turnId);
1349
1412
  } else {
@@ -1389,7 +1452,7 @@ export class Dispatcher {
1389
1452
  threadId: msg.conversation.threadId ?? null,
1390
1453
  type: "error",
1391
1454
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1392
- replyTo: msg.id,
1455
+ replyTo: this.providerReplyTo(msg),
1393
1456
  traceId: msg.trace?.id ?? null,
1394
1457
  }, turnId);
1395
1458
  } else {
@@ -1438,6 +1501,7 @@ export class Dispatcher {
1438
1501
  key,
1439
1502
  runtime: route.runtime,
1440
1503
  runtimeSessionId: result.newSessionId,
1504
+ memoryVersion: currentMemoryVersion ?? entry?.memoryVersion ?? null,
1441
1505
  channel: msg.channel,
1442
1506
  accountId: msg.accountId,
1443
1507
  conversationKind: msg.conversation.kind,
@@ -1494,7 +1558,7 @@ export class Dispatcher {
1494
1558
  threadId: msg.conversation.threadId ?? null,
1495
1559
  type: "error",
1496
1560
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1497
- replyTo: msg.id,
1561
+ replyTo: this.providerReplyTo(msg),
1498
1562
  traceId: msg.trace?.id ?? null,
1499
1563
  }, turnId);
1500
1564
  this.emitOutbound({
@@ -1571,7 +1635,7 @@ export class Dispatcher {
1571
1635
  conversationId: msg.conversation.id,
1572
1636
  threadId: msg.conversation.threadId ?? null,
1573
1637
  text: replyText,
1574
- replyTo: msg.id,
1638
+ replyTo: this.providerReplyTo(msg),
1575
1639
  traceId: msg.trace?.id ?? null,
1576
1640
  }, turnId);
1577
1641
  this.emitOutbound({
@@ -1638,6 +1702,10 @@ export class Dispatcher {
1638
1702
  return { ok: true };
1639
1703
  }
1640
1704
 
1705
+ private providerReplyTo(msg: GatewayInboundMessage): string {
1706
+ return msg.replyTo ?? msg.id;
1707
+ }
1708
+
1641
1709
  private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
1642
1710
  if (!this.transcript.enabled) return;
1643
1711
  const rawText = typeof msg.text === "string" ? msg.text : "";
@@ -17,6 +17,7 @@ import type {
17
17
  GatewayRoute,
18
18
  GatewayRuntimeSnapshot,
19
19
  InboundObserver,
20
+ MemoryContextBuilder,
20
21
  OutboundObserver,
21
22
  SystemContextBuilder,
22
23
  UserTurnBuilder,
@@ -39,6 +40,11 @@ export interface GatewayBootOptions {
39
40
  * abort the turn.
40
41
  */
41
42
  buildSystemContext?: SystemContextBuilder;
43
+ /**
44
+ * Snapshot/version hook for working memory. Forwarded to dispatcher so
45
+ * resumed runtime sessions get an explicit prompt when memory changes.
46
+ */
47
+ buildMemoryContext?: MemoryContextBuilder;
42
48
  /**
43
49
  * Observer called after the dispatcher acks each inbound message. Useful
44
50
  * for activity tracking or metrics. Errors are logged and swallowed.
@@ -159,6 +165,7 @@ export class Gateway {
159
165
  log: this.log,
160
166
  turnTimeoutMs: opts.turnTimeoutMs,
161
167
  buildSystemContext: opts.buildSystemContext,
168
+ buildMemoryContext: opts.buildMemoryContext,
162
169
  onInbound: opts.onInbound,
163
170
  composeUserTurn: opts.composeUserTurn,
164
171
  onOutbound: opts.onOutbound,
@@ -162,6 +162,14 @@ export type InboundObserver = (
162
162
  */
163
163
  export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
164
164
 
165
+ export interface MemoryContextSnapshot {
166
+ version: string;
167
+ }
168
+
169
+ export type MemoryContextBuilder = (
170
+ message: GatewayInboundMessage,
171
+ ) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
172
+
165
173
  /**
166
174
  * Optional hook fired after the dispatcher dispatches a reply to a channel.
167
175
  * Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
@@ -448,6 +456,8 @@ export interface GatewaySessionEntry {
448
456
  key: string;
449
457
  runtime: string;
450
458
  runtimeSessionId: string;
459
+ /** Version of working memory last injected into this runtime session. */
460
+ memoryVersion?: string | null;
451
461
  channel: string;
452
462
  accountId: string;
453
463
  conversationKind: "direct" | "group";
@@ -17,6 +17,7 @@ import {
17
17
  unlinkSync,
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
+ import { createHash } from "node:crypto";
20
21
  import { homedir } from "node:os";
21
22
  import path from "node:path";
22
23
  import { agentStateDir } from "./agent-workspace.js";
@@ -30,6 +31,11 @@ export interface WorkingMemory {
30
31
  updatedAt: string;
31
32
  }
32
33
 
34
+ export interface WorkingMemorySnapshot {
35
+ memory: WorkingMemory | null;
36
+ version: string;
37
+ }
38
+
33
39
  /** v1 shape kept only for one-way migration on read. */
34
40
  interface WorkingMemoryV1 {
35
41
  version: 1;
@@ -205,6 +211,33 @@ export function readWorkingMemory(agentId: string): WorkingMemory | null {
205
211
  return normalize(readJson<unknown>(p));
206
212
  }
207
213
 
214
+ function canonicalizeWorkingMemory(memory: WorkingMemory | null): unknown {
215
+ const sections: Record<string, string> = {};
216
+ for (const [key, value] of Object.entries(memory?.sections ?? {}).sort(([a], [b]) =>
217
+ a.localeCompare(b),
218
+ )) {
219
+ sections[key] = value;
220
+ }
221
+ return {
222
+ version: 2,
223
+ goal: memory?.goal ?? null,
224
+ sections,
225
+ };
226
+ }
227
+
228
+ export function workingMemoryVersion(memory: WorkingMemory | null): string {
229
+ const canonical = JSON.stringify(canonicalizeWorkingMemory(memory));
230
+ return `wm-sha256:${createHash("sha256").update(canonical).digest("hex").slice(0, 16)}`;
231
+ }
232
+
233
+ export function readWorkingMemorySnapshot(agentId: string): WorkingMemorySnapshot {
234
+ const memory = readWorkingMemory(agentId);
235
+ return {
236
+ memory,
237
+ version: workingMemoryVersion(memory),
238
+ };
239
+ }
240
+
208
241
  export function writeWorkingMemory(agentId: string, data: WorkingMemory): void {
209
242
  writeJsonAtomic(workingMemoryPath(agentId), data);
210
243
  }