@botcord/daemon 0.2.85 → 0.2.87

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.
@@ -1196,27 +1196,46 @@ function extractToolResult(raw: any): { name?: string; result: string; id?: stri
1196
1196
  function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id?: string; status?: string } | null {
1197
1197
  const payload = raw?.payload;
1198
1198
  if (!payload || typeof payload !== "object") return null;
1199
+ const innerPayload = unwrapDeepseekPayload(raw);
1200
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1199
1201
 
1200
- if (raw?.event === "tool.started") {
1201
- const tool = payload.tool && typeof payload.tool === "object" ? payload.tool : undefined;
1202
+ if (event === "tool.started") {
1203
+ const tool = innerPayload?.tool && typeof innerPayload.tool === "object" ? innerPayload.tool : undefined;
1202
1204
  return {
1203
- name: stringField(payload, "name") ?? stringField(tool, "name") ?? "tool",
1204
- params: parseMaybeJson(payload.input ?? payload.arguments ?? payload.params ?? tool?.input ?? tool?.rawInput),
1205
- id: stringField(payload, "id") ?? stringField(tool, "id"),
1206
- status: stringField(payload, "status") ?? stringField(tool, "status"),
1205
+ name: stringField(innerPayload, "name") ?? stringField(tool, "name") ?? "tool",
1206
+ params: parseMaybeJson(
1207
+ innerPayload?.input ??
1208
+ innerPayload?.arguments ??
1209
+ innerPayload?.params ??
1210
+ tool?.input ??
1211
+ tool?.rawInput ??
1212
+ tool?.arguments ??
1213
+ tool?.params,
1214
+ ),
1215
+ id: stringField(innerPayload, "id") ?? stringField(tool, "id"),
1216
+ status: stringField(innerPayload, "status") ?? stringField(tool, "status"),
1207
1217
  };
1208
1218
  }
1209
1219
 
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
- : {};
1220
+ if (event === "item.started") {
1221
+ const inner = innerPayload ?? {};
1217
1222
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1218
1223
  const tool = inner.tool && typeof inner.tool === "object" ? inner.tool : item?.tool;
1219
- const itemParams = parseMaybeJson(item?.input ?? item?.arguments ?? item?.detail);
1224
+ const metadata = item?.metadata && typeof item.metadata === "object" ? item.metadata : undefined;
1225
+ const metadataCommand =
1226
+ metadata && (metadata.command ?? metadata.cmd)
1227
+ ? { [metadata.command ? "command" : "cmd"]: metadata.command ?? metadata.cmd }
1228
+ : undefined;
1229
+ const itemParams = parseMaybeJson(
1230
+ item?.input ??
1231
+ item?.arguments ??
1232
+ item?.params ??
1233
+ metadata?.input ??
1234
+ metadata?.arguments ??
1235
+ metadata?.params ??
1236
+ metadataCommand ??
1237
+ item?.detail,
1238
+ );
1220
1239
  const detailParams =
1221
1240
  itemParams !== undefined
1222
1241
  ? itemParams
@@ -1240,9 +1259,18 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1240
1259
  inner.arguments ??
1241
1260
  inner.params ??
1242
1261
  item?.input ??
1243
- item?.arguments,
1262
+ item?.arguments ??
1263
+ item?.params ??
1264
+ metadata?.input ??
1265
+ metadata?.arguments ??
1266
+ metadata?.params ??
1267
+ metadataCommand,
1244
1268
  ) ?? detailParams ?? tool ?? item,
1245
- id: stringField(tool, "id") ?? stringField(inner, "id") ?? stringField(item, "id"),
1269
+ id:
1270
+ stringField(tool, "id") ??
1271
+ stringField(inner, "id") ??
1272
+ stringField(item, "id") ??
1273
+ stringField(payload, "item_id"),
1246
1274
  status: stringField(tool, "status") ?? stringField(inner, "status") ?? stringField(item, "status"),
1247
1275
  };
1248
1276
  }
@@ -1253,28 +1281,26 @@ function extractDeepseekToolCall(raw: any): { name: string; params?: unknown; id
1253
1281
  function extractDeepseekToolResult(raw: any): { name?: string; result: string; id?: string } | null {
1254
1282
  const payload = raw?.payload;
1255
1283
  if (!payload || typeof payload !== "object") return null;
1284
+ const innerPayload = unwrapDeepseekPayload(raw);
1285
+ const event = stringField(raw, "event") ?? stringField(payload, "event");
1256
1286
 
1257
- if (raw?.event === "tool.completed") {
1258
- const result = payload.output ?? payload.result ?? payload.content ?? payload.error ?? payload;
1287
+ if (event === "tool.completed") {
1288
+ const result =
1289
+ innerPayload?.output ??
1290
+ innerPayload?.result ??
1291
+ innerPayload?.content ??
1292
+ innerPayload?.error ??
1293
+ innerPayload ??
1294
+ payload;
1259
1295
  return {
1260
- name: stringField(payload, "name"),
1296
+ name: stringField(innerPayload, "name"),
1261
1297
  result: stringifyToolResult(result),
1262
- id: stringField(payload, "id"),
1298
+ id: stringField(innerPayload, "id"),
1263
1299
  };
1264
1300
  }
1265
1301
 
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
- : {};
1302
+ if (event === "item.completed" || event === "item.failed") {
1303
+ const inner = innerPayload ?? {};
1278
1304
  const item = inner.item && typeof inner.item === "object" ? inner.item : undefined;
1279
1305
  const result =
1280
1306
  item?.output ??
@@ -1295,13 +1321,35 @@ function extractDeepseekToolResult(raw: any): { name?: string; result: string; i
1295
1321
  stringField(inner, "name") ??
1296
1322
  stringField(item, "type"),
1297
1323
  result: stringifyToolResult(result),
1298
- id: stringField(item, "id") ?? stringField(inner, "id"),
1324
+ id: stringField(item, "id") ?? stringField(inner, "id") ?? stringField(payload, "item_id"),
1299
1325
  };
1300
1326
  }
1301
1327
 
1302
1328
  return null;
1303
1329
  }
1304
1330
 
1331
+ function unwrapDeepseekPayload(raw: any): any {
1332
+ const payload = raw?.payload;
1333
+ if (!payload || typeof payload !== "object") return undefined;
1334
+ const nested = payload.payload;
1335
+ if (nested && typeof nested === "object") {
1336
+ const outerEvent = stringField(payload, "event");
1337
+ if (
1338
+ outerEvent ||
1339
+ nested.item ||
1340
+ nested.tool ||
1341
+ nested.turn ||
1342
+ nested.kind ||
1343
+ nested.output ||
1344
+ nested.result ||
1345
+ nested.error
1346
+ ) {
1347
+ return nested;
1348
+ }
1349
+ }
1350
+ return payload;
1351
+ }
1352
+
1305
1353
  function formatBlockDetails(raw: unknown): string {
1306
1354
  if (!raw || typeof raw !== "object") return "";
1307
1355
  const r = raw as any;
@@ -208,6 +208,31 @@ function buildRuntimeRecoveryPrompt(args: {
208
208
  ].join("\n");
209
209
  }
210
210
 
211
+ /**
212
+ * Pick the canonical reply_to value to attach to outbound replies for a given
213
+ * inbound `GatewayInboundMessage`. Priority:
214
+ *
215
+ * 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
216
+ * receipts and threaded replies point at the original target.
217
+ * 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
218
+ * This is the canonical form the hub stores in `reply_to_msg_id`.
219
+ * 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
220
+ * stamps on every inbound. The hub accepts this form via
221
+ * `_load_reply_target`'s prefix-based discriminator, but emitting it is
222
+ * lossy because the hub then has to resolve it back to msg_id.
223
+ *
224
+ * Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
225
+ */
226
+ export function pickReplyToTarget(msg: GatewayInboundMessage): string {
227
+ if (msg.replyTo) return msg.replyTo;
228
+ const raw = msg.raw as { envelope?: { msg_id?: unknown } } | null | undefined;
229
+ const envMsgId =
230
+ raw && typeof raw.envelope?.msg_id === "string" && raw.envelope.msg_id
231
+ ? raw.envelope.msg_id
232
+ : null;
233
+ return envMsgId ?? msg.id;
234
+ }
235
+
211
236
  /** Factory signature for building a runtime adapter at turn dispatch time. */
212
237
  export type RuntimeFactory = (
213
238
  runtimeId: string,
@@ -2132,7 +2157,7 @@ export class Dispatcher {
2132
2157
  }
2133
2158
 
2134
2159
  private providerReplyTo(msg: GatewayInboundMessage): string {
2135
- return msg.replyTo ?? msg.id;
2160
+ return pickReplyToTarget(msg);
2136
2161
  }
2137
2162
 
2138
2163
  private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
@@ -497,9 +497,15 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
497
497
  }
498
498
 
499
499
  function isToolStarted(eventName: string, payload: any): boolean {
500
+ const itemKind = payload?.payload?.item?.kind ?? payload?.item?.kind;
500
501
  return (
501
502
  (eventName === "item.started" &&
502
- (!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
503
+ (
504
+ !!payload?.tool ||
505
+ itemKind === "tool_call" ||
506
+ itemKind === "command_execution" ||
507
+ itemKind === "file_change"
508
+ )) ||
503
509
  (payload?.event === "item.started" && !!payload?.payload?.tool)
504
510
  );
505
511
  }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type RouteRuleMatch,
18
18
  } from "./config.js";
19
19
  import {
20
+ acquireDaemonSingletonLock,
20
21
  ensureNoOtherDaemonFromPidFile,
21
22
  findOtherDaemonProcesses,
22
23
  pidAlive,
@@ -625,13 +626,22 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
625
626
  }
626
627
 
627
628
  // Foreground: we ARE the daemon.
629
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
628
630
  writeCurrentPid();
629
- const handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
631
+ let handle: Awaited<ReturnType<typeof startDaemon>>;
632
+ try {
633
+ handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
634
+ } catch (err) {
635
+ removePidFile();
636
+ singletonLock.release();
637
+ throw err;
638
+ }
630
639
 
631
640
  const shutdown = async (sig: string) => {
632
641
  log.info("signal received", { sig });
633
642
  await handle.stop(sig);
634
643
  removePidFile();
644
+ singletonLock.release();
635
645
  process.exit(0);
636
646
  };
637
647
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -661,6 +671,7 @@ async function cmdStartCloud(_args: ParsedArgs): Promise<void> {
661
671
  daemonInstanceId: cloudConfig.daemonInstanceId,
662
672
  hubUrl: cloudConfig.hubUrl,
663
673
  });
674
+ const singletonLock = await acquireDaemonSingletonLock({ logger: log });
664
675
  await stopDaemonFromPidFileForRestart({ logger: log });
665
676
  await stopOtherDaemonProcessesForRestart({ logger: log });
666
677
  writeCurrentPid();
@@ -676,16 +687,24 @@ async function cmdStartCloud(_args: ParsedArgs): Promise<void> {
676
687
  saveConfig(cfg);
677
688
  log.info("cloud mode config initialized", { configPath: CONFIG_FILE_PATH });
678
689
 
679
- const handle = await startCloudDaemon({
680
- cloudConfig,
681
- config: cfg,
682
- configPath: CONFIG_FILE_PATH,
683
- });
690
+ let handle: Awaited<ReturnType<typeof startCloudDaemon>>;
691
+ try {
692
+ handle = await startCloudDaemon({
693
+ cloudConfig,
694
+ config: cfg,
695
+ configPath: CONFIG_FILE_PATH,
696
+ });
697
+ } catch (err) {
698
+ removePidFile();
699
+ singletonLock.release();
700
+ throw err;
701
+ }
684
702
 
685
703
  const shutdown = async (sig: string): Promise<void> => {
686
704
  log.info("signal received", { sig });
687
705
  await handle.stop(sig);
688
706
  removePidFile();
707
+ singletonLock.release();
689
708
  process.exit(0);
690
709
  };
691
710
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
package/src/provision.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  defaultCredentialsFile,
14
14
  derivePublicKey,
15
15
  loadStoredCredentials,
16
+ normalizeTokenExpiresAt,
16
17
  writeCredentialsFile,
17
18
  type AgentIdentitySnapshot,
18
19
  type ControlAck,
@@ -74,6 +75,7 @@ import { log as daemonLog } from "./log.js";
74
75
  import { discoverAgentCredentials } from "./agent-discovery.js";
75
76
  import { resolveMemoryDir } from "./working-memory.js";
76
77
  import { discoverRuntimeModelCatalog } from "./runtime-models.js";
78
+ import { collectAgentSkillSnapshot } from "./skill-index.js";
77
79
  import {
78
80
  buildRuntimeSelectionExtraArgs,
79
81
  mergeRuntimeExtraArgs,
@@ -83,6 +85,16 @@ import {
83
85
  type CloudGatewayTypingEmitter,
84
86
  } from "./cloud-gateway-runtime.js";
85
87
 
88
+ interface ListAgentSkillsParams {
89
+ agentId: string;
90
+ }
91
+
92
+ function runtimeForLoadedAgent(gateway: Gateway, agentId: string): string | undefined {
93
+ return gateway.listManagedRoutes()
94
+ .find((route) => route.match?.accountId === agentId)
95
+ ?.runtime;
96
+ }
97
+
86
98
  /**
87
99
  * Information passed to {@link OnAgentInstalledHook} after a successful
88
100
  * provision. Mirrors the credential fields the daemon's per-agent caches
@@ -486,6 +498,34 @@ export function createProvisioner(opts: ProvisionerOptions): (
486
498
  return { ok: true, result };
487
499
  }
488
500
 
501
+ case "list_agent_skills": {
502
+ const params = (frame.params ?? {}) as unknown as ListAgentSkillsParams;
503
+ if (!params.agentId) {
504
+ return {
505
+ ok: false,
506
+ error: { code: "bad_params", message: "list_agent_skills requires params.agentId" },
507
+ };
508
+ }
509
+ const channels = gateway.snapshot().channels;
510
+ if (!channels[params.agentId]) {
511
+ return {
512
+ ok: false,
513
+ error: {
514
+ code: "agent_not_loaded",
515
+ message: `agent ${params.agentId} is not loaded in daemon gateway`,
516
+ },
517
+ };
518
+ }
519
+ const runtime = runtimeForLoadedAgent(gateway, params.agentId);
520
+ const result = collectAgentSkillSnapshot(params.agentId, { runtime });
521
+ daemonLog.debug("list_agent_skills", {
522
+ agentId: params.agentId,
523
+ runtime,
524
+ count: result.skills.length,
525
+ });
526
+ return { ok: true, result };
527
+ }
528
+
489
529
  case "wake_agent": {
490
530
  return handleWakeAgent(gateway, frame.params);
491
531
  }
@@ -1227,7 +1267,8 @@ async function materializeCredentials(
1227
1267
  };
1228
1268
  if (c.displayName) record.displayName = c.displayName;
1229
1269
  if (c.token) record.token = c.token;
1230
- if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
1270
+ const tokenExpiresAt = normalizeTokenExpiresAt(c.tokenExpiresAt);
1271
+ if (tokenExpiresAt !== undefined) record.tokenExpiresAt = tokenExpiresAt;
1231
1272
  if (runtime) record.runtime = runtime;
1232
1273
  const runtimeSelection = pickRuntimeSelection(params);
1233
1274
  if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;
@@ -23,9 +23,23 @@ export interface SoftSkillEntry {
23
23
  mtimeMs: number;
24
24
  }
25
25
 
26
+ export interface AgentSkillSnapshotEntry {
27
+ name: string;
28
+ source: string;
29
+ description?: string;
30
+ mtimeMs: number;
31
+ }
32
+
33
+ export interface AgentSkillSnapshot {
34
+ agentId: string;
35
+ skills: AgentSkillSnapshotEntry[];
36
+ probedAt: number;
37
+ }
38
+
26
39
  export interface SkillIndexOptions {
27
40
  extraDirs?: string[];
28
41
  includeGlobal?: boolean;
42
+ runtime?: string;
29
43
  }
30
44
 
31
45
  export function defaultSkillDirs(
@@ -33,21 +47,26 @@ export function defaultSkillDirs(
33
47
  opts: SkillIndexOptions = {},
34
48
  ): Array<{ dir: string; source: string }> {
35
49
  const includeGlobal = opts.includeGlobal !== false;
36
- const dirs: Array<{ dir: string; source: string }> = [
37
- {
38
- dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
39
- source: "agent-claude",
40
- },
41
- {
42
- dir: path.join(agentCodexHomeDir(agentId), "skills"),
43
- source: "agent-codex",
44
- },
45
- ];
50
+ const agentClaude = {
51
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
52
+ source: "agent-claude",
53
+ };
54
+ const agentCodex = {
55
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
56
+ source: "agent-codex",
57
+ };
58
+ const dirs: Array<{ dir: string; source: string }> =
59
+ runtimeFamily(opts.runtime) === "codex"
60
+ ? [agentCodex, agentClaude]
61
+ : [agentClaude, agentCodex];
46
62
 
47
63
  if (includeGlobal) {
64
+ const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
65
+ const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
48
66
  dirs.push(
49
- { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" },
50
- { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" },
67
+ ...(runtimeFamily(opts.runtime) === "codex"
68
+ ? [globalCodex, globalClaude]
69
+ : [globalClaude, globalCodex]),
51
70
  );
52
71
  }
53
72
 
@@ -56,7 +75,7 @@ export function defaultSkillDirs(
56
75
  dirs.push({ dir, source: "external" });
57
76
  }
58
77
 
59
- return dedupeDirs(dirs);
78
+ return dedupeDirs(expandSkillRoots(dirs));
60
79
  }
61
80
 
62
81
  export function scanSoftSkills(
@@ -70,7 +89,7 @@ export function scanSoftSkills(
70
89
  if (!existsSync(root.dir)) continue;
71
90
  let children: string[];
72
91
  try {
73
- children = readdirSync(root.dir);
92
+ children = readdirSync(root.dir).sort((a, b) => a.localeCompare(b));
74
93
  } catch {
75
94
  continue;
76
95
  }
@@ -98,22 +117,37 @@ export function scanSoftSkills(
98
117
  description: parsed.description,
99
118
  mtimeMs: st.mtimeMs,
100
119
  };
101
- if (!existing || priority(root.source) < priority(existing.source)) {
120
+ if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
102
121
  byName.set(entry.name, entry);
103
122
  }
104
123
  }
105
124
  }
106
125
 
107
126
  return Array.from(byName.values())
108
- .sort((a, b) => a.name.localeCompare(b.name))
109
- .slice(0, MAX_SKILLS);
127
+ .sort((a, b) => a.name.localeCompare(b.name));
128
+ }
129
+
130
+ export function collectAgentSkillSnapshot(
131
+ agentId: string,
132
+ opts: SkillIndexOptions = {},
133
+ ): AgentSkillSnapshot {
134
+ return {
135
+ agentId,
136
+ skills: scanSoftSkills(agentId, opts).map((skill) => ({
137
+ name: skill.name,
138
+ source: skill.source.startsWith("agent-") ? "workspace" : "runtime-global",
139
+ ...(skill.description ? { description: skill.description } : {}),
140
+ mtimeMs: skill.mtimeMs,
141
+ })),
142
+ probedAt: Date.now(),
143
+ };
110
144
  }
111
145
 
112
146
  export function buildSoftSkillIndexPrompt(
113
147
  agentId: string,
114
148
  opts: SkillIndexOptions = {},
115
149
  ): string | null {
116
- const skills = scanSoftSkills(agentId, opts);
150
+ const skills = scanSoftSkills(agentId, opts).slice(0, MAX_SKILLS);
117
151
  if (skills.length === 0) return null;
118
152
 
119
153
  const lines = [
@@ -196,7 +230,41 @@ function dedupeDirs(
196
230
  return out;
197
231
  }
198
232
 
199
- function priority(source: string): number {
233
+ function expandSkillRoots(
234
+ dirs: Array<{ dir: string; source: string }>,
235
+ ): Array<{ dir: string; source: string }> {
236
+ const out: Array<{ dir: string; source: string }> = [];
237
+ for (const entry of dirs) {
238
+ out.push(entry);
239
+ if (entry.source.includes("codex")) {
240
+ out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "other" {
247
+ if (runtime === "codex") return "codex";
248
+ if (runtime === "claude-code") return "claude";
249
+ return "other";
250
+ }
251
+
252
+ function priority(source: string, runtime: string | undefined): number {
253
+ if (runtimeFamily(runtime) === "codex") {
254
+ switch (source) {
255
+ case "agent-codex":
256
+ return 0;
257
+ case "global-codex":
258
+ return 1;
259
+ case "agent-claude":
260
+ return 2;
261
+ case "global-claude":
262
+ return 3;
263
+ default:
264
+ return 4;
265
+ }
266
+ }
267
+
200
268
  switch (source) {
201
269
  case "agent-claude":
202
270
  return 0;
package/src/turn-text.ts CHANGED
@@ -112,6 +112,18 @@ interface BatchedEntry {
112
112
  source_type?: unknown;
113
113
  source_user_name?: unknown;
114
114
  mentioned?: unknown;
115
+ reply_preview?: ReplyPreviewRaw | null;
116
+ }
117
+
118
+ /** Structural mirror of protocol-core ReplyPreview — kept local so the
119
+ * composer has no cross-package import. */
120
+ interface ReplyPreviewRaw {
121
+ msg_id?: unknown;
122
+ sender_id?: unknown;
123
+ sender_display_name?: unknown;
124
+ text_preview?: unknown;
125
+ topic_id?: unknown;
126
+ deleted?: unknown;
115
127
  }
116
128
 
117
129
  interface RoomContextRaw {
@@ -196,6 +208,40 @@ function entryText(e: BatchedEntry): string {
196
208
  return "";
197
209
  }
198
210
 
211
+ /**
212
+ * Format the inline quote-reply context line that prefixes a message body
213
+ * when the inbound envelope replies to another message. Single layer — we
214
+ * never render a quote-of-a-quote chain. Returns `null` when the source
215
+ * carries no reply_preview, so the caller can skip emitting an empty line.
216
+ *
217
+ * Both sender label and preview body are sanitized; the preview is hard-
218
+ * capped at 120 chars to mirror the backend truncation.
219
+ */
220
+ function formatReplyQuoteLine(raw: unknown): string | null {
221
+ if (!raw || typeof raw !== "object") return null;
222
+ const rp = (raw as { reply_preview?: ReplyPreviewRaw | null }).reply_preview;
223
+ if (!rp || typeof rp !== "object") return null;
224
+ if (rp.deleted === true) {
225
+ return "[quoting (deleted message)]";
226
+ }
227
+ const senderRaw =
228
+ typeof rp.sender_display_name === "string" && rp.sender_display_name
229
+ ? rp.sender_display_name
230
+ : typeof rp.sender_id === "string" && rp.sender_id
231
+ ? rp.sender_id
232
+ : "unknown";
233
+ const sender = sanitizeSenderName(senderRaw);
234
+ const previewRaw =
235
+ typeof rp.text_preview === "string" ? rp.text_preview : "";
236
+ const previewClean = sanitizeUntrustedContent(previewRaw)
237
+ .replace(/[\r\n]+/g, " ")
238
+ .slice(0, 120);
239
+ if (!previewClean) {
240
+ return `[quoting ${sender}]`;
241
+ }
242
+ return `[quoting ${sender}: "${previewClean}"]`;
243
+ }
244
+
199
245
  function formatRoomContext(raw: unknown, fallback: { id: string; title?: string }): string[] {
200
246
  const r = raw && typeof raw === "object" ? (raw as RoomContextRaw) : {};
201
247
  const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
@@ -293,11 +339,13 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
293
339
  "agent ID and any message they attached.]"
294
340
  : null;
295
341
 
342
+ const quoteLine = formatReplyQuoteLine(msg.raw);
296
343
  const lines: string[] = [
297
344
  headerFields.join(" | "),
298
345
  ...formatScheduleContext(msg.raw),
299
346
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
300
347
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
348
+ ...(quoteLine ? [quoteLine] : []),
301
349
  trimmed,
302
350
  `</${tag}>`,
303
351
  "",
@@ -350,8 +398,10 @@ function composeBatchedTurn(
350
398
  // non-owner. Still sanitize defensively.
351
399
  const safeBody = sanitizeUntrustedContent(raw);
352
400
  const tag = kind === "human" ? "human-message" : "agent-message";
401
+ const quoteLine = formatReplyQuoteLine(entry);
402
+ const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
353
403
  blocks.push(
354
- `<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`,
404
+ `<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`,
355
405
  );
356
406
  if (envelopeType === "contact_request") {
357
407
  contactRequestSenders.push(safeLabel);