@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 +3 -0
- package/dist/gateway/channels/botcord.js +65 -2
- package/dist/gateway/dispatcher.d.ts +9 -1
- package/dist/gateway/dispatcher.js +50 -1
- package/dist/gateway/gateway.d.ts +6 -1
- package/dist/gateway/gateway.js +1 -0
- package/dist/gateway/runtimes/codex.js +1 -1
- package/dist/gateway/types.d.ts +6 -0
- package/dist/working-memory.d.ts +6 -0
- package/dist/working-memory.js +23 -0
- package/package.json +1 -1
- package/src/__tests__/working-memory.test.ts +30 -0
- package/src/daemon.ts +4 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +88 -1
- package/src/gateway/__tests__/codex-adapter.test.ts +3 -1
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/channels/botcord.ts +61 -2
- package/src/gateway/dispatcher.ts +65 -1
- package/src/gateway/gateway.ts +7 -0
- package/src/gateway/runtimes/codex.ts +1 -1
- package/src/gateway/types.ts +10 -0
- package/src/working-memory.ts +33 -0
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
|
|
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
|
-
|
|
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.
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -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 };
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -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";
|
package/dist/working-memory.d.ts
CHANGED
|
@@ -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;
|
package/dist/working-memory.js
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -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 };
|
package/src/gateway/types.ts
CHANGED
|
@@ -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";
|
package/src/working-memory.ts
CHANGED
|
@@ -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
|
}
|