@botcord/daemon 0.2.71 → 0.2.73

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,
@@ -836,7 +836,7 @@ function normalizeBlockForHub(block, seq) {
836
836
  }
837
837
  if (kind === "tool_use") {
838
838
  // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
839
- // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
839
+ // Codex: item.started for command_execution, file_change, mcp_tool_call, web_search
840
840
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
841
841
  const tu = contents.find((c) => c?.type === "tool_use");
842
842
  if (tu) {
@@ -848,12 +848,19 @@ function normalizeBlockForHub(block, seq) {
848
848
  }
849
849
  else if (raw?.item && typeof raw.item === "object") {
850
850
  payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
851
- payload.params = raw.item;
851
+ const params = codexToolParams(raw.item);
852
+ if (Object.keys(params).length > 0)
853
+ payload.params = params;
854
+ if (typeof raw.item.id === "string")
855
+ payload.id = raw.item.id;
856
+ if (typeof raw.item.status === "string")
857
+ payload.status = raw.item.status;
852
858
  }
853
859
  return { kind: "tool_call", seq, payload };
854
860
  }
855
861
  if (kind === "tool_result") {
856
862
  // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
863
+ // Codex: item.completed for command_execution, file_change, mcp_tool_call, web_search
857
864
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
858
865
  const tr = contents.find((c) => c?.type === "tool_result");
859
866
  if (tr) {
@@ -870,6 +877,14 @@ function normalizeBlockForHub(block, seq) {
870
877
  if (typeof tr.tool_use_id === "string")
871
878
  payload.tool_use_id = tr.tool_use_id;
872
879
  }
880
+ else if (raw?.item && typeof raw.item === "object") {
881
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
882
+ if (typeof raw.item.id === "string")
883
+ payload.tool_use_id = raw.item.id;
884
+ const result = codexToolResult(raw.item);
885
+ if (result)
886
+ payload.result = result;
887
+ }
873
888
  return { kind: "tool_result", seq, payload };
874
889
  }
875
890
  if (kind === "system") {
@@ -928,6 +943,54 @@ function formatBlockDetails(raw) {
928
943
  return String(raw);
929
944
  }
930
945
  }
946
+ function codexToolParams(item) {
947
+ const params = {};
948
+ for (const key of [
949
+ "command",
950
+ "cmd",
951
+ "args",
952
+ "path",
953
+ "query",
954
+ "url",
955
+ "name",
956
+ "input",
957
+ "arguments",
958
+ "action",
959
+ "changes",
960
+ ]) {
961
+ const value = item[key];
962
+ if (value !== undefined && value !== null && value !== "")
963
+ params[key] = value;
964
+ }
965
+ const action = item.action;
966
+ if (action && typeof action === "object") {
967
+ for (const key of ["query", "url", "command", "path"]) {
968
+ const value = action[key];
969
+ if (value !== undefined && value !== null && value !== "")
970
+ params[key] = value;
971
+ }
972
+ }
973
+ return params;
974
+ }
975
+ function codexToolResult(item) {
976
+ const parts = [];
977
+ const status = typeof item.status === "string" ? item.status : "";
978
+ const exitCode = item.exit_code ?? item.exitCode;
979
+ if (status)
980
+ parts.push(`status: ${status}`);
981
+ if (typeof exitCode === "number" || typeof exitCode === "string")
982
+ parts.push(`exit_code: ${exitCode}`);
983
+ for (const key of ["output", "stdout", "stderr", "aggregated_output", "result", "summary"]) {
984
+ const value = item[key];
985
+ if (typeof value === "string" && value.trim())
986
+ parts.push(value.trim());
987
+ }
988
+ const results = item.results;
989
+ if (Array.isArray(results) && results.length > 0) {
990
+ parts.push(JSON.stringify(results, null, 2));
991
+ }
992
+ return parts.join("\n");
993
+ }
931
994
  function extractContentText(content) {
932
995
  if (!content)
933
996
  return "";
@@ -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,
@@ -380,7 +380,7 @@ function normalizeBlock(obj, seq) {
380
380
  itemType === "file_change" ||
381
381
  itemType === "mcp_tool_call" ||
382
382
  itemType === "web_search") {
383
- kind = "tool_use";
383
+ kind = type === "item.completed" ? "tool_result" : "tool_use";
384
384
  }
385
385
  }
386
386
  return { raw: obj, kind, seq };
@@ -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.73",
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,
@@ -1,7 +1,11 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { WebSocketServer, type WebSocket as WsType } from "ws";
3
3
  import type { AddressInfo } from "node:net";
4
- import { createBotCordChannel, type BotCordChannelClient } from "../channels/botcord.js";
4
+ import {
5
+ createBotCordChannel,
6
+ __normalizeBlockForHubForTests,
7
+ type BotCordChannelClient,
8
+ } from "../channels/botcord.js";
5
9
  import type { ChannelStartContext, GatewayInboundEnvelope } from "../types.js";
6
10
  import type { GatewayLogger } from "../log.js";
7
11
  import type { InboxMessage } from "@botcord/protocol-core";
@@ -649,6 +653,89 @@ describe("createBotCordChannel — ack + dedup", () => {
649
653
  // ---------------------------------------------------------------------------
650
654
 
651
655
  describe("createBotCordChannel — streamBlock()", () => {
656
+ it("normalizes Codex tool items without using internal ids as params", () => {
657
+ expect(
658
+ __normalizeBlockForHubForTests(
659
+ {
660
+ kind: "tool_use",
661
+ seq: 1,
662
+ raw: {
663
+ type: "item.started",
664
+ item: { id: "item_26", type: "command_execution", command: "rg stream-block" },
665
+ },
666
+ },
667
+ 1,
668
+ ),
669
+ ).toEqual({
670
+ kind: "tool_call",
671
+ seq: 1,
672
+ payload: {
673
+ name: "command_execution",
674
+ id: "item_26",
675
+ params: { command: "rg stream-block" },
676
+ },
677
+ });
678
+
679
+ expect(
680
+ __normalizeBlockForHubForTests(
681
+ {
682
+ kind: "tool_use",
683
+ seq: 2,
684
+ raw: {
685
+ type: "item.started",
686
+ item: {
687
+ id: "ws_abc",
688
+ type: "web_search",
689
+ action: { type: "search", query: "codex stream response" },
690
+ },
691
+ },
692
+ },
693
+ 2,
694
+ ),
695
+ ).toEqual({
696
+ kind: "tool_call",
697
+ seq: 2,
698
+ payload: {
699
+ name: "web_search",
700
+ id: "ws_abc",
701
+ params: {
702
+ action: { type: "search", query: "codex stream response" },
703
+ query: "codex stream response",
704
+ },
705
+ },
706
+ });
707
+ });
708
+
709
+ it("normalizes Codex completed tool items as results", () => {
710
+ expect(
711
+ __normalizeBlockForHubForTests(
712
+ {
713
+ kind: "tool_result",
714
+ seq: 3,
715
+ raw: {
716
+ type: "item.completed",
717
+ item: {
718
+ id: "item_26",
719
+ type: "command_execution",
720
+ status: "completed",
721
+ exit_code: 0,
722
+ output: "found 3 matches",
723
+ },
724
+ },
725
+ },
726
+ 3,
727
+ ),
728
+ ).toEqual({
729
+ kind: "tool_result",
730
+ seq: 3,
731
+ payload: {
732
+ name: "command_execution",
733
+ tool_use_id: "item_26",
734
+ result: "status: completed\nexit_code: 0\nfound 3 matches",
735
+ },
736
+ });
737
+ });
738
+
652
739
  it("POSTs to /hub/stream-block with the right trace_id + block", async () => {
653
740
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
654
741
  const realFetch = globalThis.fetch;
@@ -77,13 +77,14 @@ process.exit(0);
77
77
  expect(res.error).toBeUndefined();
78
78
  });
79
79
 
80
- it("emits tool_use StreamBlock for command_execution items", async () => {
80
+ it("emits tool_use/tool_result StreamBlocks for command_execution items", async () => {
81
81
  const script = makeScript(
82
82
  "toolblock.js",
83
83
  `
84
84
  const lines = [
85
85
  {type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde0"},
86
86
  {type:"item.started", item:{id:"i0", type:"command_execution", command:"ls"}},
87
+ {type:"item.completed", item:{id:"i0", type:"command_execution", status:"completed", output:"ok"}},
87
88
  {type:"item.completed", item:{id:"i1", type:"agent_message", text:"done"}},
88
89
  ];
89
90
  for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
@@ -103,6 +104,7 @@ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
103
104
  });
104
105
  expect(res.text).toBe("done");
105
106
  expect(seen).toContain("tool_use");
107
+ expect(seen).toContain("tool_result");
106
108
  expect(seen).toContain("assistant_text");
107
109
  expect(seen).toContain("system");
108
110
  });
@@ -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({
@@ -979,7 +979,7 @@ function normalizeBlockForHub(
979
979
 
980
980
  if (kind === "tool_use") {
981
981
  // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
982
- // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
982
+ // Codex: item.started for command_execution, file_change, mcp_tool_call, web_search
983
983
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
984
984
  const tu = contents.find((c: any) => c?.type === "tool_use");
985
985
  if (tu) {
@@ -988,13 +988,17 @@ function normalizeBlockForHub(
988
988
  if (typeof tu.id === "string") payload.id = tu.id;
989
989
  } else if (raw?.item && typeof raw.item === "object") {
990
990
  payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
991
- payload.params = raw.item;
991
+ const params = codexToolParams(raw.item);
992
+ if (Object.keys(params).length > 0) payload.params = params;
993
+ if (typeof raw.item.id === "string") payload.id = raw.item.id;
994
+ if (typeof raw.item.status === "string") payload.status = raw.item.status;
992
995
  }
993
996
  return { kind: "tool_call", seq, payload };
994
997
  }
995
998
 
996
999
  if (kind === "tool_result") {
997
1000
  // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
1001
+ // Codex: item.completed for command_execution, file_change, mcp_tool_call, web_search
998
1002
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
999
1003
  const tr = contents.find((c: any) => c?.type === "tool_result");
1000
1004
  if (tr) {
@@ -1008,6 +1012,11 @@ function normalizeBlockForHub(
1008
1012
  }
1009
1013
  payload.result = resultStr;
1010
1014
  if (typeof tr.tool_use_id === "string") payload.tool_use_id = tr.tool_use_id;
1015
+ } else if (raw?.item && typeof raw.item === "object") {
1016
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
1017
+ if (typeof raw.item.id === "string") payload.tool_use_id = raw.item.id;
1018
+ const result = codexToolResult(raw.item);
1019
+ if (result) payload.result = result;
1011
1020
  }
1012
1021
  return { kind: "tool_result", seq, payload };
1013
1022
  }
@@ -1062,6 +1071,56 @@ function formatBlockDetails(raw: unknown): string {
1062
1071
  }
1063
1072
  }
1064
1073
 
1074
+ function codexToolParams(item: Record<string, unknown>): Record<string, unknown> {
1075
+ const params: Record<string, unknown> = {};
1076
+ for (const key of [
1077
+ "command",
1078
+ "cmd",
1079
+ "args",
1080
+ "path",
1081
+ "query",
1082
+ "url",
1083
+ "name",
1084
+ "input",
1085
+ "arguments",
1086
+ "action",
1087
+ "changes",
1088
+ ]) {
1089
+ const value = item[key];
1090
+ if (value !== undefined && value !== null && value !== "") params[key] = value;
1091
+ }
1092
+
1093
+ const action = item.action as Record<string, unknown> | undefined;
1094
+ if (action && typeof action === "object") {
1095
+ for (const key of ["query", "url", "command", "path"]) {
1096
+ const value = action[key];
1097
+ if (value !== undefined && value !== null && value !== "") params[key] = value;
1098
+ }
1099
+ }
1100
+
1101
+ return params;
1102
+ }
1103
+
1104
+ function codexToolResult(item: Record<string, unknown>): string {
1105
+ const parts: string[] = [];
1106
+ const status = typeof item.status === "string" ? item.status : "";
1107
+ const exitCode = item.exit_code ?? item.exitCode;
1108
+ if (status) parts.push(`status: ${status}`);
1109
+ if (typeof exitCode === "number" || typeof exitCode === "string") parts.push(`exit_code: ${exitCode}`);
1110
+
1111
+ for (const key of ["output", "stdout", "stderr", "aggregated_output", "result", "summary"]) {
1112
+ const value = item[key];
1113
+ if (typeof value === "string" && value.trim()) parts.push(value.trim());
1114
+ }
1115
+
1116
+ const results = item.results;
1117
+ if (Array.isArray(results) && results.length > 0) {
1118
+ parts.push(JSON.stringify(results, null, 2));
1119
+ }
1120
+
1121
+ return parts.join("\n");
1122
+ }
1123
+
1065
1124
  function extractContentText(content: unknown): string {
1066
1125
  if (!content) return "";
1067
1126
  if (typeof content === "string") return content;
@@ -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,
@@ -420,7 +420,7 @@ function normalizeBlock(obj: any, seq: number): StreamBlock {
420
420
  itemType === "mcp_tool_call" ||
421
421
  itemType === "web_search"
422
422
  ) {
423
- kind = "tool_use";
423
+ kind = type === "item.completed" ? "tool_result" : "tool_use";
424
424
  }
425
425
  }
426
426
  return { raw: obj, kind, seq };
@@ -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
  }