@botcord/daemon 0.2.78 → 0.2.80

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.
Files changed (61) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon-singleton.d.ts +13 -0
  7. package/dist/daemon-singleton.js +68 -0
  8. package/dist/daemon.js +21 -6
  9. package/dist/gateway/channels/botcord.d.ts +1 -0
  10. package/dist/gateway/channels/botcord.js +62 -17
  11. package/dist/gateway/channels/login-session.d.ts +12 -0
  12. package/dist/gateway/channels/login-session.js +20 -2
  13. package/dist/gateway/channels/sanitize.d.ts +5 -18
  14. package/dist/gateway/channels/sanitize.js +5 -54
  15. package/dist/gateway/channels/text-split.d.ts +5 -11
  16. package/dist/gateway/channels/text-split.js +5 -31
  17. package/dist/gateway/dispatcher.d.ts +7 -1
  18. package/dist/gateway/dispatcher.js +88 -8
  19. package/dist/gateway/gateway.d.ts +16 -1
  20. package/dist/gateway/gateway.js +21 -0
  21. package/dist/gateway/policy-resolver.js +17 -9
  22. package/dist/gateway/runtimes/deepseek-tui.js +56 -13
  23. package/dist/gateway/types.d.ts +12 -57
  24. package/dist/gateway-control.js +18 -9
  25. package/dist/index.js +8 -3
  26. package/dist/provision.d.ts +7 -3
  27. package/dist/provision.js +115 -8
  28. package/dist/room-recovery-context.d.ts +11 -0
  29. package/dist/room-recovery-context.js +97 -0
  30. package/dist/status-render.d.ts +4 -0
  31. package/dist/status-render.js +14 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  34. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  35. package/src/__tests__/daemon-singleton.test.ts +32 -0
  36. package/src/__tests__/gateway-control.test.ts +136 -0
  37. package/src/__tests__/policy-resolver.test.ts +20 -0
  38. package/src/__tests__/provision.test.ts +65 -0
  39. package/src/__tests__/status-render.test.ts +23 -0
  40. package/src/attention-policy-fetcher.ts +87 -0
  41. package/src/cloud-daemon.ts +8 -0
  42. package/src/cloud-gateway-runtime.ts +171 -0
  43. package/src/daemon-singleton.ts +85 -0
  44. package/src/daemon.ts +23 -6
  45. package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
  46. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
  47. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  48. package/src/gateway/channels/botcord.ts +69 -17
  49. package/src/gateway/channels/login-session.ts +20 -2
  50. package/src/gateway/channels/sanitize.ts +8 -66
  51. package/src/gateway/channels/text-split.ts +5 -27
  52. package/src/gateway/dispatcher.ts +123 -27
  53. package/src/gateway/gateway.ts +29 -0
  54. package/src/gateway/policy-resolver.ts +20 -9
  55. package/src/gateway/runtimes/deepseek-tui.ts +63 -13
  56. package/src/gateway/types.ts +31 -59
  57. package/src/gateway-control.ts +21 -9
  58. package/src/index.ts +9 -2
  59. package/src/provision.ts +133 -7
  60. package/src/room-recovery-context.ts +131 -0
  61. package/src/status-render.ts +14 -1
@@ -175,6 +175,232 @@ describe("DeepseekTuiAdapter", () => {
175
175
  }
176
176
  });
177
177
 
178
+ it("parses current DeepSeek item.delta agent_message events as assistant text", async () => {
179
+ const server = await startMockDeepseekServer({
180
+ events: [
181
+ {
182
+ event: "turn.started",
183
+ data: {
184
+ seq: 1,
185
+ thread_id: "thr_test",
186
+ turn_id: "turn_test",
187
+ event: "turn.started",
188
+ payload: { turn: { status: "in_progress" } },
189
+ },
190
+ },
191
+ {
192
+ event: "item.started",
193
+ data: {
194
+ seq: 2,
195
+ thread_id: "thr_test",
196
+ turn_id: "turn_test",
197
+ item_id: "item_msg",
198
+ event: "item.started",
199
+ payload: { item: { id: "item_msg", kind: "agent_message", status: "in_progress" } },
200
+ },
201
+ },
202
+ {
203
+ event: "item.delta",
204
+ data: {
205
+ seq: 3,
206
+ thread_id: "thr_test",
207
+ turn_id: "turn_test",
208
+ item_id: "item_msg",
209
+ event: "item.delta",
210
+ payload: { kind: "agent_message", delta: "hello " },
211
+ },
212
+ },
213
+ {
214
+ event: "item.delta",
215
+ data: {
216
+ seq: 4,
217
+ thread_id: "thr_test",
218
+ turn_id: "turn_test",
219
+ item_id: "item_msg",
220
+ event: "item.delta",
221
+ payload: { kind: "agent_message", delta: "deepseek" },
222
+ },
223
+ },
224
+ {
225
+ event: "turn.completed",
226
+ data: {
227
+ seq: 5,
228
+ thread_id: "thr_test",
229
+ turn_id: "turn_test",
230
+ event: "turn.completed",
231
+ payload: { turn: { status: "completed" } },
232
+ },
233
+ },
234
+ ],
235
+ });
236
+ try {
237
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
238
+ const res = await result;
239
+ expect(res).toEqual({ text: "hello deepseek", newSessionId: server.threadId });
240
+ expect(blocks).toContain("assistant_text");
241
+ expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
242
+ } finally {
243
+ await server.close();
244
+ }
245
+ });
246
+
247
+ it("parses current DeepSeek item.started/item.completed tool events", async () => {
248
+ const server = await startMockDeepseekServer({
249
+ events: [
250
+ {
251
+ event: "turn.started",
252
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
253
+ },
254
+ {
255
+ event: "item.started",
256
+ data: {
257
+ thread_id: "thr_test",
258
+ turn_id: "turn_test",
259
+ event: "item.started",
260
+ payload: {
261
+ item: { id: "item_tool", kind: "tool_call", status: "in_progress" },
262
+ tool: { id: "call_1", name: "web_search", input: { query: "Shanghai weather" } },
263
+ },
264
+ },
265
+ },
266
+ {
267
+ event: "item.completed",
268
+ data: {
269
+ thread_id: "thr_test",
270
+ turn_id: "turn_test",
271
+ event: "item.completed",
272
+ payload: {
273
+ item: {
274
+ id: "item_tool",
275
+ kind: "tool_call",
276
+ status: "completed",
277
+ detail: "Found 5 result(s)",
278
+ },
279
+ },
280
+ },
281
+ },
282
+ {
283
+ event: "item.delta",
284
+ data: {
285
+ thread_id: "thr_test",
286
+ turn_id: "turn_test",
287
+ event: "item.delta",
288
+ payload: { kind: "agent_message", delta: "done" },
289
+ },
290
+ },
291
+ {
292
+ event: "turn.completed",
293
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
294
+ },
295
+ ],
296
+ });
297
+ try {
298
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
299
+ await expect(result).resolves.toMatchObject({ text: "done" });
300
+ expect(blocks).toEqual(expect.arrayContaining(["tool_use", "tool_result", "assistant_text"]));
301
+ expect(status).toContainEqual({ phase: "updated", label: "web_search" });
302
+ } finally {
303
+ await server.close();
304
+ }
305
+ });
306
+
307
+ it("infers current DeepSeek tool status labels without a payload.tool object", async () => {
308
+ const server = await startMockDeepseekServer({
309
+ events: [
310
+ {
311
+ event: "turn.started",
312
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
313
+ },
314
+ {
315
+ event: "item.started",
316
+ data: {
317
+ thread_id: "thr_test",
318
+ turn_id: "turn_test",
319
+ event: "item.started",
320
+ payload: {
321
+ item: {
322
+ id: "item_exec",
323
+ kind: "tool_call",
324
+ status: "in_progress",
325
+ summary: "exec_shell started",
326
+ detail: "{\"cmd\":\"botcord-daemon --version\"}",
327
+ },
328
+ },
329
+ },
330
+ },
331
+ {
332
+ event: "item.delta",
333
+ data: {
334
+ thread_id: "thr_test",
335
+ turn_id: "turn_test",
336
+ event: "item.delta",
337
+ payload: { kind: "agent_message", delta: "done" },
338
+ },
339
+ },
340
+ {
341
+ event: "turn.completed",
342
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
343
+ },
344
+ ],
345
+ });
346
+ try {
347
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
348
+ await expect(result).resolves.toMatchObject({ text: "done" });
349
+ expect(blocks).toEqual(expect.arrayContaining(["tool_use", "assistant_text"]));
350
+ expect(status).toContainEqual({ phase: "updated", label: "exec_shell" });
351
+ } finally {
352
+ await server.close();
353
+ }
354
+ });
355
+
356
+ it("emits current DeepSeek agent_reasoning completions as thinking blocks", async () => {
357
+ const server = await startMockDeepseekServer({
358
+ events: [
359
+ {
360
+ event: "turn.started",
361
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.started" },
362
+ },
363
+ {
364
+ event: "item.completed",
365
+ data: {
366
+ thread_id: "thr_test",
367
+ turn_id: "turn_test",
368
+ event: "item.completed",
369
+ payload: {
370
+ item: {
371
+ id: "item_reasoning",
372
+ kind: "agent_reasoning",
373
+ status: "completed",
374
+ summary: "I should answer briefly.",
375
+ detail: "I should answer briefly.",
376
+ },
377
+ },
378
+ },
379
+ },
380
+ {
381
+ event: "item.delta",
382
+ data: {
383
+ thread_id: "thr_test",
384
+ turn_id: "turn_test",
385
+ event: "item.delta",
386
+ payload: { kind: "agent_message", delta: "hi" },
387
+ },
388
+ },
389
+ {
390
+ event: "turn.completed",
391
+ data: { thread_id: "thr_test", turn_id: "turn_test", event: "turn.completed" },
392
+ },
393
+ ],
394
+ });
395
+ try {
396
+ const { result, blocks } = runAdapter(server.baseUrl, server.token);
397
+ await expect(result).resolves.toMatchObject({ text: "hi" });
398
+ expect(blocks).toContain("thinking");
399
+ } finally {
400
+ await server.close();
401
+ }
402
+ });
403
+
178
404
  it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
179
405
  const server = await startMockDeepseekServer({ threadId: "thr_existing" });
180
406
  try {
@@ -233,6 +459,43 @@ describe("DeepseekTuiAdapter", () => {
233
459
  }
234
460
  });
235
461
 
462
+ it("surfaces a diagnostic error when DeepSeek completes a turn with no assistant_message", async () => {
463
+ const server = await startMockDeepseekServer({
464
+ events: [
465
+ { event: "turn.started", data: { thread_id: "thr_test", turn_id: "turn_test" } },
466
+ {
467
+ event: "item.completed",
468
+ data: {
469
+ thread_id: "thr_test",
470
+ turn_id: "turn_test",
471
+ event: "item.completed",
472
+ payload: {
473
+ item: {
474
+ id: "item_reasoning",
475
+ kind: "agent_reasoning",
476
+ status: "completed",
477
+ summary: "thinking...",
478
+ },
479
+ },
480
+ },
481
+ },
482
+ {
483
+ event: "turn.completed",
484
+ data: { thread_id: "thr_test", turn_id: "turn_test", payload: { turn: { status: "completed" } } },
485
+ },
486
+ ],
487
+ });
488
+ try {
489
+ const { result } = runAdapter(server.baseUrl, server.token);
490
+ const res = await result;
491
+ expect(res.text).toBe("");
492
+ expect(res.newSessionId).toBe("thr_test");
493
+ expect(res.error).toMatch(/no assistant_message/);
494
+ } finally {
495
+ await server.close();
496
+ }
497
+ });
498
+
236
499
  it("returns a runtime error when DeepSeek completes the turn as failed", async () => {
237
500
  const server = await startMockDeepseekServer({
238
501
  events: [
@@ -244,6 +244,7 @@ describe("Dispatcher", () => {
244
244
  turnTimeoutMs?: number;
245
245
  runtimeAuthFailureThreshold?: number;
246
246
  runtimeAuthFailureCooldownMs?: number;
247
+ buildRuntimeRecoveryContext?: (message: GatewayInboundMessage) => Promise<string | null> | string | null;
247
248
  }) {
248
249
  const { store, dir } = await makeStore();
249
250
  tempDirs.push(dir);
@@ -258,6 +259,7 @@ describe("Dispatcher", () => {
258
259
  turnTimeoutMs: args.turnTimeoutMs,
259
260
  runtimeAuthFailureThreshold: args.runtimeAuthFailureThreshold,
260
261
  runtimeAuthFailureCooldownMs: args.runtimeAuthFailureCooldownMs,
262
+ buildRuntimeRecoveryContext: args.buildRuntimeRecoveryContext,
261
263
  });
262
264
  return { dispatcher, channel, store };
263
265
  }
@@ -460,6 +462,60 @@ describe("Dispatcher", () => {
460
462
  expect(channel.sends[0].message.type).toBe("error");
461
463
  });
462
464
 
465
+ it("codex: retries a poisoned resumed session in a fresh session with recent room context", async () => {
466
+ let factoryCall = 0;
467
+ const recoveryRuntime: RuntimeAdapter = {
468
+ id: "codex",
469
+ run: vi.fn(async (opts: RuntimeRunOptions): Promise<RuntimeRunResult> => {
470
+ if ((recoveryRuntime.run as any).mock.calls.length === 1) {
471
+ expect(opts.sessionId).toBe("sid-1");
472
+ return {
473
+ text: "",
474
+ newSessionId: "sid-1",
475
+ error: "Codex context compaction failed: maximum context length exceeded",
476
+ };
477
+ }
478
+ expect(opts.sessionId).toBe(null);
479
+ expect(opts.text).toContain("[BotCord Runtime Recovery Notice]");
480
+ expect(opts.text).toContain("[Recent Room Messages]");
481
+ expect(opts.text).toContain("Alice: deploy is failing");
482
+ expect(opts.text).toContain("[Current User Turn]");
483
+ expect(opts.text).toContain("continue");
484
+ return { text: "recovered", newSessionId: "sid-2" };
485
+ }) as RuntimeAdapter["run"],
486
+ };
487
+ const runtimeFactory: RuntimeFactory = () => {
488
+ factoryCall += 1;
489
+ if (factoryCall === 1) {
490
+ return new FakeRuntime({ id: "codex", reply: "ok", newSessionId: "sid-1" });
491
+ }
492
+ return recoveryRuntime;
493
+ };
494
+ const { dispatcher, store, channel } = await scaffold({
495
+ config: baseConfig({ defaultRoute: { runtime: "codex", cwd: "/tmp/default" } }),
496
+ runtimeFactory,
497
+ buildRuntimeRecoveryContext: () =>
498
+ "[Recent Room Messages]\n- Alice: deploy is failing\n- Bot: I am checking logs",
499
+ });
500
+
501
+ await dispatcher.handle(
502
+ makeEnvelope({ id: "msg_1", conversation: { id: "rm_oc_recover", kind: "direct" } }),
503
+ );
504
+ expect(store.all()[0].runtimeSessionId).toBe("sid-1");
505
+
506
+ await dispatcher.handle(
507
+ makeEnvelope({
508
+ id: "msg_2",
509
+ text: "continue",
510
+ conversation: { id: "rm_oc_recover", kind: "direct" },
511
+ }),
512
+ );
513
+
514
+ expect(recoveryRuntime.run).toHaveBeenCalledTimes(2);
515
+ expect(store.all()[0].runtimeSessionId).toBe("sid-2");
516
+ expect(channel.sends.map((s) => s.message.text)).toEqual(["ok", "recovered"]);
517
+ });
518
+
463
519
  it("treats auth failure text as an error and does not persist the failed session", async () => {
464
520
  let callNo = 0;
465
521
  const runtimeFactory: RuntimeFactory = () => {
@@ -977,16 +977,19 @@ export { normalizeBlockForHub as __normalizeBlockForHubForTests };
977
977
  function normalizeBlockForHub(
978
978
  block: { raw?: unknown; kind?: string; seq?: number } | undefined,
979
979
  seq: number,
980
- ): { kind: string; seq: number; payload: Record<string, unknown> } {
980
+ ): { kind: string; seq: number; payload: Record<string, unknown>; raw?: unknown } {
981
981
  const raw = (block?.raw ?? {}) as any;
982
982
  const kind = block?.kind ?? "other";
983
983
  const payload: Record<string, unknown> = {};
984
+ const withRaw = (out: { kind: string; seq: number; payload: Record<string, unknown> }) => (
985
+ block && "raw" in block ? { ...out, raw: block.raw } : out
986
+ );
984
987
 
985
988
  if (kind === "assistant_text") {
986
989
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
987
990
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
988
991
  // DeepSeek: {event:"message.delta", payload:{content}} or
989
- // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
992
+ // {event:"item.delta", payload:{kind:"agent_message", delta}}
990
993
  let text = "";
991
994
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
992
995
  for (const c of contents) {
@@ -999,10 +1002,14 @@ function normalizeBlockForHub(
999
1002
  if (
1000
1003
  !text &&
1001
1004
  raw?.event === "item.delta" &&
1002
- raw?.payload?.payload?.kind === "agent_message" &&
1003
- typeof raw?.payload?.payload?.delta === "string"
1005
+ (raw?.payload?.kind === "agent_message" || raw?.payload?.payload?.kind === "agent_message")
1004
1006
  ) {
1005
- text = raw.payload.payload.delta;
1007
+ text =
1008
+ typeof raw?.payload?.delta === "string"
1009
+ ? raw.payload.delta
1010
+ : typeof raw?.payload?.payload?.delta === "string"
1011
+ ? raw.payload.payload.delta
1012
+ : "";
1006
1013
  }
1007
1014
  return { kind: "assistant", seq, payload: { text } };
1008
1015
  }
@@ -1018,7 +1025,7 @@ function normalizeBlockForHub(
1018
1025
  if (call.id) payload.id = call.id;
1019
1026
  if (call.status) payload.status = call.status;
1020
1027
  }
1021
- return { kind: "tool_call", seq, payload };
1028
+ return withRaw({ kind: "tool_call", seq, payload });
1022
1029
  }
1023
1030
 
1024
1031
  if (kind === "tool_result") {
@@ -1028,7 +1035,7 @@ function normalizeBlockForHub(
1028
1035
  payload.result = result.result;
1029
1036
  if (result.id) payload.tool_use_id = result.id;
1030
1037
  }
1031
- return { kind: "tool_result", seq, payload };
1038
+ return withRaw({ kind: "tool_result", seq, payload });
1032
1039
  }
1033
1040
 
1034
1041
  if (kind === "system") {
@@ -1036,7 +1043,7 @@ function normalizeBlockForHub(
1036
1043
  if (typeof raw?.session_id === "string") payload.session_id = raw.session_id;
1037
1044
  if (typeof raw?.model === "string") payload.model = raw.model;
1038
1045
  payload.details = formatBlockDetails(raw);
1039
- return { kind: "system", seq, payload };
1046
+ return withRaw({ kind: "system", seq, payload });
1040
1047
  }
1041
1048
 
1042
1049
  if (kind === "thinking") {
@@ -1048,7 +1055,7 @@ function normalizeBlockForHub(
1048
1055
  if (typeof raw?.label === "string") payload.label = raw.label;
1049
1056
  if (typeof raw?.source === "string") payload.source = raw.source;
1050
1057
  payload.details = formatBlockDetails(raw);
1051
- return { kind: "thinking", seq, payload };
1058
+ return withRaw({ kind: "thinking", seq, payload });
1052
1059
  }
1053
1060
 
1054
1061
  // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
@@ -1058,14 +1065,14 @@ function normalizeBlockForHub(
1058
1065
  const event = typeof raw?.event === "string" ? raw.event : undefined;
1059
1066
  const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
1060
1067
  if (event || embedded) payload.event = event ?? embedded;
1061
- return { kind: "other", seq, payload };
1068
+ return withRaw({ kind: "other", seq, payload });
1062
1069
  }
1063
1070
  if (raw?.type === "result") {
1064
1071
  if (typeof raw.result === "string") payload.text = raw.result;
1065
1072
  if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
1066
1073
  if (typeof raw.total_cost_usd === "number") payload.total_cost_usd = raw.total_cost_usd;
1067
1074
  }
1068
- return { kind: "other", seq, payload };
1075
+ return withRaw({ kind: "other", seq, payload });
1069
1076
  }
1070
1077
 
1071
1078
  function isTerminalRuntimeBlock(raw: any): boolean {
@@ -1200,25 +1207,41 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1200
1207
  };
1201
1208
  }
1202
1209
 
1203
- if (payload.event === "item.started") {
1204
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1210
+ if (raw?.event === "item.started" || payload.event === "item.started") {
1211
+ const inner =
1212
+ raw?.event === "item.started"
1213
+ ? payload
1214
+ : payload.payload && typeof payload.payload === "object"
1215
+ ? payload.payload
1216
+ : {};
1205
1217
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1206
1218
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1219
+ const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
1220
+ const detailParams =
1221
+ itemParams !== undefined
1222
+ ? itemParams
1223
+ : typeof item?.detail === "string" && item.detail.trim()
1224
+ ? item.detail.trim()
1225
+ : undefined;
1207
1226
  return {
1208
1227
  name:
1209
1228
  stringField(tool, "name") ??
1210
1229
  stringField(inner, "name") ??
1211
1230
  stringField(item, "name") ??
1231
+ inferDeepseekToolName(item) ??
1212
1232
  stringField(item, "type") ??
1213
1233
  "tool",
1214
1234
  params: parseMaybeJson(
1215
1235
  tool?.input ??
1216
1236
  tool?.rawInput ??
1217
1237
  tool?.arguments ??
1238
+ tool?.params ??
1218
1239
  inner.input ??
1240
+ inner.arguments ??
1241
+ inner.params ??
1219
1242
  item?.input ??
1220
1243
  item?.arguments,
1221
- ) ?? tool ?? item,
1244
+ ) ?? detailParams ?? tool ?? item,
1222
1245
  id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
1223
1246
  status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
1224
1247
  };
@@ -1240,8 +1263,18 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
1240
1263
  };
1241
1264
  }
1242
1265
 
1243
- if (payload.event === "item.completed" || payload.event === "item.failed") {
1244
- const inner = payload.payload && typeof payload.payload === "object" ? payload.payload : {};
1266
+ if (
1267
+ raw?.event === "item.completed" ||
1268
+ raw?.event === "item.failed" ||
1269
+ payload.event === "item.completed" ||
1270
+ payload.event === "item.failed"
1271
+ ) {
1272
+ const inner =
1273
+ raw?.event === "item.completed" || raw?.event === "item.failed"
1274
+ ? payload
1275
+ : payload.payload && typeof payload.payload === "object"
1276
+ ? payload.payload
1277
+ : {};
1245
1278
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1246
1279
  const result =
1247
1280
  item?.output ??
@@ -1256,7 +1289,11 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
1256
1289
  item ??
1257
1290
  inner;
1258
1291
  return {
1259
- name: stringField(item, "name") ?? stringField(item, "type") ?? stringField(inner, "name"),
1292
+ name:
1293
+ stringField(item, "name") ??
1294
+ inferDeepseekToolName(item) ??
1295
+ stringField(inner, "name") ??
1296
+ stringField(item, "type"),
1260
1297
  result: stringifyToolResult(result),
1261
1298
  id: stringField(item, "id") ?? stringField(inner, "id"),
1262
1299
  };
@@ -1273,6 +1310,11 @@ function formatBlockDetails(raw: unknown): string {
1273
1310
  : typeof r.message === "string" ? r.message
1274
1311
  : typeof r.summary === "string" ? r.summary
1275
1312
  : typeof r.label === "string" ? r.label
1313
+ : typeof r.payload?.delta === "string" ? r.payload.delta
1314
+ : typeof r.payload?.item?.detail === "string" ? r.payload.item.detail
1315
+ : typeof r.payload?.item?.summary === "string" ? r.payload.item.summary
1316
+ : typeof r.payload?.payload?.item?.detail === "string" ? r.payload.payload.item.detail
1317
+ : typeof r.payload?.payload?.item?.summary === "string" ? r.payload.payload.item.summary
1276
1318
  : "";
1277
1319
  if (direct) return direct;
1278
1320
 
@@ -1368,6 +1410,16 @@ function parseMaybeJson(value: unknown): unknown {
1368
1410
  }
1369
1411
  }
1370
1412
 
1413
+ function inferDeepseekToolName(item: any): string | undefined {
1414
+ const candidates = [stringField(item, "summary"), stringField(item, "detail")];
1415
+ for (const candidate of candidates) {
1416
+ if (!candidate) continue;
1417
+ const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
1418
+ if (match?.[1] && match[1] !== "tool_call") return match[1];
1419
+ }
1420
+ return undefined;
1421
+ }
1422
+
1371
1423
  function isEmptyRecord(value: unknown): boolean {
1372
1424
  return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
1373
1425
  }
@@ -77,10 +77,28 @@ export class LoginSessionStore {
77
77
  return session;
78
78
  }
79
79
 
80
+ /**
81
+ * Distinguish whether `loginId` is unknown to the store ("missing") vs
82
+ * known-but-past-TTL ("expired"). When the entry is expired this also
83
+ * evicts it from the internal map so callers do not need to follow up
84
+ * with a separate `delete`. Use this when the caller wants to surface
85
+ * a precise error code to the user; prefer `get` when a single nullable
86
+ * result is enough.
87
+ */
88
+ resolve(loginId: string): { state: "live" | "expired" | "missing"; session?: LoginSession } {
89
+ const s = this.sessions.get(loginId);
90
+ if (!s) return { state: "missing" };
91
+ if (s.expiresAt <= this.now()) {
92
+ this.sessions.delete(loginId);
93
+ return { state: "expired" };
94
+ }
95
+ return { state: "live", session: s };
96
+ }
97
+
80
98
  /** Get a non-expired session by id, or `null` when missing/expired. */
81
99
  get(loginId: string): LoginSession | null {
82
- this.sweep();
83
- return this.sessions.get(loginId) ?? null;
100
+ const { state, session } = this.resolve(loginId);
101
+ return state === "live" && session ? session : null;
84
102
  }
85
103
 
86
104
  /**
@@ -1,68 +1,10 @@
1
1
  /**
2
- * Sanitize untrusted inbound content before handing it off to a local runtime.
3
- *
4
- * Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
5
- * does not depend back on the daemon package. Keep these two files in sync —
6
- * any new structural marker added in one place should be mirrored in the other.
7
- *
8
- * Neutralizes:
9
- * - BotCord structural markers the channel itself emits (so peers can't forge them).
10
- * - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
11
- * - Wrapper XML tags the channel uses to frame inbound content
12
- * (<agent-message>, <human-message>, <room-rule>).
2
+ * Thin re-export `sanitizeUntrustedContent` / `sanitizeSenderName` live
3
+ * in `@botcord/protocol-core` so the daemon channel adapters and the
4
+ * `gateway-ingress` provider adapters use one canonical implementation.
5
+ * Existing imports of this module keep working unchanged.
13
6
  */
14
-
15
- export function sanitizeUntrustedContent(text: string): string {
16
- let s = text;
17
- s = s.replace(
18
- /<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
19
- "[⚠ stripped: agent-message tag]",
20
- );
21
- s = s.replace(
22
- /<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
23
- "[⚠ stripped: human-message tag]",
24
- );
25
- s = s.replace(
26
- /<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi,
27
- "[⚠ stripped: room-rule tag]",
28
- );
29
-
30
- return s
31
- .split(/\r?\n/)
32
- .map((line) => {
33
- let l = line;
34
- l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
35
- l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
36
- l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
37
- l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
38
- l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
39
- const head = String(label).split(":")[0].trim() || String(label).trim();
40
- return `[⚠ fake: BotCord ${head}]`;
41
- });
42
- l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
43
- l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
44
- l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
45
- l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
46
- l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
47
- l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
48
- l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
49
- return l;
50
- })
51
- .join("\n");
52
- }
53
-
54
- /**
55
- * Sanitize a sender label so it's safe to embed inside
56
- * `<agent-message sender="...">`. Must not contain newlines, structural
57
- * markers, or characters that could break the XML attribute boundary.
58
- */
59
- export function sanitizeSenderName(name: string): string {
60
- return name
61
- .replace(/[\n\r]/g, " ")
62
- .replace(/\[/g, "⟦")
63
- .replace(/\]/g, "⟧")
64
- .replace(/"/g, "'")
65
- .replace(/</g, "<")
66
- .replace(/>/g, ">")
67
- .slice(0, 100);
68
- }
7
+ export {
8
+ sanitizeUntrustedContent,
9
+ sanitizeSenderName,
10
+ } from "@botcord/protocol-core";