@botcord/daemon 0.2.71 → 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,
@@ -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?;
@@ -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,
@@ -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,
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.71",
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": {
@@ -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,
@@ -1605,6 +1605,71 @@ describe("Dispatcher", () => {
1605
1605
  ).toBe(true);
1606
1606
  });
1607
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
+
1608
1673
  it("onInbound: observer is invoked with the message between ack and runtime.run", async () => {
1609
1674
  const order: string[] = [];
1610
1675
  const runtime = new FakeRuntime({
@@ -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,
@@ -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,
@@ -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
  }