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