@xmanrui/dsh-im 4.9.1 → 4.10.0

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 (39) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +610 -56
  4. package/lib/index.js +273 -272
  5. package/package.json +12 -1
  6. package/plugin-src/client/channels/feishu/api.js +2 -0
  7. package/plugin-src/client/channels/feishu/index.js +54 -0
  8. package/plugin-src/client/global-settings.js +341 -0
  9. package/plugin-src/client/i18n.js +29 -0
  10. package/plugin-src/client/index.js +47 -6
  11. package/plugin-src/client/styles.js +49 -0
  12. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  13. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/channels/qq/production.mjs +12 -0
  16. package/plugin-src/host/channels/shared/production.mjs +12 -0
  17. package/plugin-src/host/channels/slack/production.mjs +12 -0
  18. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  19. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  20. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  21. package/plugin-src/host/delivery-adapter.mjs +1 -0
  22. package/plugin-src/host/delivery-service.mjs +15 -0
  23. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  24. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  25. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  26. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  27. package/plugin-src/host/index.mjs +8 -0
  28. package/plugin-src/host/modern-harness-api.mjs +58 -2
  29. package/src/channels/feishu/bridge.mjs +164 -4
  30. package/src/channels/feishu/feishu-channel.mjs +45 -10
  31. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  32. package/src/channels/feishu/message-utils.mjs +13 -0
  33. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  34. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  35. package/src/channels/feishu/state-store.mjs +25 -0
  36. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  37. package/src/channels/shared/inbound-file.mjs +117 -2
  38. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  39. package/src/channels/shared/inbound-ttl.mjs +22 -0
@@ -208,7 +208,7 @@ export class VerifiedFeishuChannel {
208
208
  let activeCard = null;
209
209
  let rotating = false;
210
210
  try {
211
- activeCard = await this.#createStreamCard(chatId, options.replyTo);
211
+ activeCard = await this.#createStreamCard(chatId, options);
212
212
  cards.push(activeCard);
213
213
  let lastContent = this.#initialText;
214
214
  // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
@@ -217,7 +217,7 @@ export class VerifiedFeishuChannel {
217
217
  // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
218
218
  const ensureActiveCard = async () => {
219
219
  if (!rotating) return activeCard;
220
- activeCard = await this.#createStreamCard(chatId, options.replyTo);
220
+ activeCard = await this.#createStreamCard(chatId, options);
221
221
  cards.push(activeCard);
222
222
  rotating = false;
223
223
  return activeCard;
@@ -255,7 +255,7 @@ export class VerifiedFeishuChannel {
255
255
  for (const [index, chunk] of chunks.entries()) {
256
256
  const card = index === 0
257
257
  ? await ensureActiveCard()
258
- : await this.#createStreamCard(chatId, options.replyTo);
258
+ : await this.#createStreamCard(chatId, options);
259
259
  if (index > 0) cards.push(card);
260
260
  await this.#updateStreamCard(card, chunk);
261
261
  await this.#finishStreamCard(card);
@@ -272,19 +272,33 @@ export class VerifiedFeishuChannel {
272
272
  }
273
273
  }
274
274
 
275
- async sendFile(chatId, file, { replyTo, signal } = {}) {
275
+ async sendFile(chatId, file, {
276
+ replyTo,
277
+ signal,
278
+ replyInThread = false,
279
+ onReplyThreadId,
280
+ } = {}) {
276
281
  return this.#sendArtifact(chatId, file, {
277
282
  replyTo,
278
283
  signal,
284
+ replyInThread,
285
+ onReplyThreadId,
279
286
  messageType: 'file',
280
287
  presentation: 'feishu-file',
281
288
  });
282
289
  }
283
290
 
284
- async sendImage(chatId, file, { replyTo, signal } = {}) {
291
+ async sendImage(chatId, file, {
292
+ replyTo,
293
+ signal,
294
+ replyInThread = false,
295
+ onReplyThreadId,
296
+ } = {}) {
285
297
  return this.#sendArtifact(chatId, file, {
286
298
  replyTo,
287
299
  signal,
300
+ replyInThread,
301
+ onReplyThreadId,
288
302
  messageType: 'image',
289
303
  presentation: 'feishu-image',
290
304
  });
@@ -293,6 +307,8 @@ export class VerifiedFeishuChannel {
293
307
  async #sendArtifact(chatId, file, {
294
308
  replyTo,
295
309
  signal,
310
+ replyInThread = false,
311
+ onReplyThreadId,
296
312
  messageType,
297
313
  presentation,
298
314
  }) {
@@ -349,7 +365,12 @@ export class VerifiedFeishuChannel {
349
365
  const request = replyTo
350
366
  ? {
351
367
  path: { message_id: replyTo },
352
- data: { msg_type: messageType, content, uuid },
368
+ data: {
369
+ msg_type: messageType,
370
+ content,
371
+ uuid,
372
+ ...(replyInThread === true ? { reply_in_thread: true } : {}),
373
+ },
353
374
  }
354
375
  : {
355
376
  params: { receive_id_type: 'chat_id' },
@@ -399,6 +420,10 @@ export class VerifiedFeishuChannel {
399
420
  if (typeof messageId !== 'string' || !messageId) {
400
421
  throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
401
422
  }
423
+ if (replyTo && typeof onReplyThreadId === 'function') {
424
+ const threadId = response?.data?.thread_id;
425
+ if (typeof threadId === 'string' && threadId) await onReplyThreadId(threadId);
426
+ }
402
427
  return createDeliveryReceipt({
403
428
  deliveryId: file.deliveryKey,
404
429
  presentation,
@@ -410,7 +435,7 @@ export class VerifiedFeishuChannel {
410
435
  });
411
436
  }
412
437
 
413
- async #createStreamCard(chatId, replyTo) {
438
+ async #createStreamCard(chatId, options = {}) {
414
439
  const content = streamPreview(this.#initialText);
415
440
  const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
416
441
  data: {
@@ -420,7 +445,7 @@ export class VerifiedFeishuChannel {
420
445
  }));
421
446
  const cardId = response?.data?.card_id;
422
447
  if (!cardId) throw new Error('Feishu card.create returned no card_id');
423
- const messageId = await this.#sendCard(chatId, cardId, replyTo);
448
+ const messageId = await this.#sendCard(chatId, cardId, options);
424
449
  return { cardId, messageId, content, sequence: 0 };
425
450
  }
426
451
 
@@ -455,12 +480,18 @@ export class VerifiedFeishuChannel {
455
480
  assertApiSuccess('Feishu card.settings', response);
456
481
  }
457
482
 
458
- async #sendCard(chatId, cardId, replyTo) {
483
+ async #sendCard(chatId, cardId, options = {}) {
484
+ const replyTo = options.replyTo;
485
+ const replyInThread = options.replyInThread === true;
459
486
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
460
487
  const response = replyTo
461
488
  ? await this.#client.im.v1.message.reply({
462
489
  path: { message_id: replyTo },
463
- data: { msg_type: 'interactive', content },
490
+ data: {
491
+ msg_type: 'interactive',
492
+ content,
493
+ ...(replyInThread ? { reply_in_thread: true } : {}),
494
+ },
464
495
  })
465
496
  : await this.#client.im.v1.message.create({
466
497
  params: { receive_id_type: 'chat_id' },
@@ -469,6 +500,10 @@ export class VerifiedFeishuChannel {
469
500
  assertApiSuccess('Feishu message send', response);
470
501
  const messageId = response?.data?.message_id;
471
502
  if (!messageId) throw new Error('Feishu message send returned no message_id');
503
+ if (replyTo && typeof options.onReplyThreadId === 'function') {
504
+ const threadId = response?.data?.thread_id;
505
+ if (typeof threadId === 'string' && threadId) await options.onReplyThreadId(threadId);
506
+ }
472
507
  return messageId;
473
508
  }
474
509
 
@@ -110,6 +110,7 @@ export class FeishuRuntime {
110
110
  #domain;
111
111
  #botOpenId;
112
112
  #groupResponseMode;
113
+ #groupTopicReply;
113
114
  #ownerOpenIds;
114
115
  #harness;
115
116
  #state;
@@ -139,6 +140,7 @@ export class FeishuRuntime {
139
140
  domain = 'feishu',
140
141
  botOpenId,
141
142
  groupResponseMode,
143
+ groupTopicReply = false,
142
144
  ownerOpenId,
143
145
  ownerOpenIds,
144
146
  harness,
@@ -174,6 +176,7 @@ export class FeishuRuntime {
174
176
  this.#domain = domain;
175
177
  this.#botOpenId = nonEmptyString(botOpenId);
176
178
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
179
+ this.#groupTopicReply = groupTopicReply === true;
177
180
  this.#ownerOpenIds = normalizedOwners;
178
181
  this.#harness = harness;
179
182
  this.#state = state;
@@ -198,6 +201,11 @@ export class FeishuRuntime {
198
201
  this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
199
202
  }
200
203
 
204
+ setGroupTopicReply(value) {
205
+ this.#groupTopicReply = value === true;
206
+ this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
207
+ }
208
+
201
209
  async start() {
202
210
  while (true) {
203
211
  while (this.#stopping) await this.#stopping;
@@ -286,6 +294,7 @@ export class FeishuRuntime {
286
294
  appId: this.#appId,
287
295
  botOpenId: this.#botOpenId,
288
296
  groupResponseMode: this.#groupResponseMode,
297
+ groupTopicReply: this.#groupTopicReply,
289
298
  repair: this.#repair,
290
299
  replyTimeoutMs: this.#replyTimeoutMs,
291
300
  // Interaction cards (approval/question buttons) are on by default.
@@ -13,6 +13,19 @@ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
13
13
  const FEISHU_IMAGE_PERMISSION_MESSAGE =
14
14
  '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
15
15
 
16
+ /** Managed-topic group key: one per Feishu topic auto-rooted at a message. */
17
+ export function managedGroupKey(chatId, rootMessageId) {
18
+ if (!chatId) throw new Error('Feishu managed-topic key needs a chat_id');
19
+ if (!rootMessageId) throw new Error('Feishu managed-topic key needs a root message id');
20
+ return `group:${chatId}:managed:${rootMessageId}`;
21
+ }
22
+
23
+ /** Whether a key denotes a topic-isolated group conversation (thread or managed). */
24
+ export function isTopicGroupKey(key) {
25
+ return typeof key === 'string' && key.startsWith('group:')
26
+ && (key.includes(':thread:') || key.includes(':managed:'));
27
+ }
28
+
16
29
  export function conversationKey(event) {
17
30
  const chatType = event?.message?.chat_type;
18
31
  if (chatType === 'p2p') {
@@ -90,6 +90,7 @@ function configuredBotFingerprint(config) {
90
90
  botOpenId: config.botOpenId,
91
91
  activated: config.activated,
92
92
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
93
+ groupTopicReply: config.groupTopicReply === true,
93
94
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
94
95
  deletionPending: config.deletionPending === true,
95
96
  connectedAt: config.connectedAt ?? null,
@@ -584,6 +585,20 @@ export class MultiBotDshFeishuController {
584
585
  }));
585
586
  }
586
587
 
588
+ async updateGroupTopicReply(botId, groupTopicReply) {
589
+ this.#assertOpen();
590
+ if (typeof groupTopicReply !== 'boolean') {
591
+ throw new TypeError('Invalid Feishu group topic reply flag');
592
+ }
593
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
594
+ const config = this.#requireBot(botId);
595
+ const saved = await this.#configStore.saveBot({ ...config, groupTopicReply });
596
+ this.#runtimes.get(botId)?.setGroupTopicReply?.(saved.groupTopicReply);
597
+ this.#touch();
598
+ return this.status(botId);
599
+ }));
600
+ }
601
+
587
602
  async deleteBot(botId) {
588
603
  this.#assertOpen();
589
604
  return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@@ -646,6 +661,7 @@ export class MultiBotDshFeishuController {
646
661
  connected,
647
662
  configured: true,
648
663
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
664
+ groupTopicReply: config.groupTopicReply === true,
649
665
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
650
666
  bot: publicBot(config),
651
667
  connection,
@@ -45,6 +45,7 @@ function normalizeBot(value, { legacy = false } = {}) {
45
45
  botOpenId: cleanString(value.botOpenId),
46
46
  activated: value.activated ?? null,
47
47
  groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
48
+ groupTopicReply: value.groupTopicReply === true,
48
49
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
49
50
  deletionPending: value.deletionPending === true,
50
51
  connectedAt: cleanString(value.connectedAt),
@@ -7,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
7
7
  seenMessageIds: [],
8
8
  watches: {},
9
9
  includeArchivedSessions: false,
10
+ topics: {},
10
11
  });
11
12
 
12
13
  /** One conversation key may watch at most this many sessions. */
@@ -40,6 +41,7 @@ export class StateStore {
40
41
  includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
41
42
  ? parsed.includeArchivedSessions
42
43
  : false,
44
+ topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
43
45
  };
44
46
  } catch (error) {
45
47
  if (error?.code !== 'ENOENT') throw error;
@@ -136,6 +138,29 @@ export class StateStore {
136
138
  return [...ids];
137
139
  }
138
140
 
141
+ // ── Managed Feishu topics (thread_id → root message, persisted) ────────
142
+
143
+ topicRootFor(threadId) {
144
+ const entry = this.#state.topics[threadId];
145
+ return entry && typeof entry === 'object'
146
+ && typeof entry.rootMessageId === 'string' && entry.rootMessageId.length > 0
147
+ && typeof entry.chatId === 'string' && entry.chatId.length > 0
148
+ ? { rootMessageId: entry.rootMessageId, chatId: entry.chatId }
149
+ : null;
150
+ }
151
+
152
+ async setTopic(threadId, root) {
153
+ const validRoot = root && typeof root === 'object'
154
+ && typeof root.rootMessageId === 'string' && root.rootMessageId.length > 0
155
+ && typeof root.chatId === 'string' && root.chatId.length > 0;
156
+ if (!validRoot) throw new TypeError('Invalid Feishu topic root');
157
+ this.#state.topics[threadId] = {
158
+ rootMessageId: root.rootMessageId,
159
+ chatId: root.chatId,
160
+ };
161
+ await this.#persist();
162
+ }
163
+
139
164
  // ── Session-list archived policy (per bot) ───────────────────
140
165
 
141
166
  includesArchivedSessions() {
@@ -323,6 +323,12 @@ export class BotWorkspaceStore {
323
323
  return Object.hasOwn(this.#workspaces, id) && !this.#removals.has(id);
324
324
  }
325
325
 
326
+ listBotIds() {
327
+ return Object.keys(this.#workspaces)
328
+ .filter((botId) => !this.#removals.has(botId))
329
+ .sort();
330
+ }
331
+
326
332
  incarnationFor(botId) {
327
333
  return this.#incarnations.get(botIdOf(botId)) ?? null;
328
334
  }
@@ -1,11 +1,40 @@
1
1
  import { createWriteStream } from 'node:fs';
2
- import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises';
3
3
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
 
6
6
  import { t } from './i18n.mjs';
7
7
 
8
8
  const FILES_DIRECTORY = join('.dsh-im', 'inbound');
9
+ const RETENTIONS = new Set(['turn', 'persistent']);
10
+ const DIRECTORY_TIMESTAMP_PATTERN = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/;
11
+
12
+ function padded(value) {
13
+ return String(value).padStart(2, '0');
14
+ }
15
+
16
+ /** Timestamp prefix (`yyyyMMdd-HHmmss-`) handed to mkdtemp for random suffixes. */
17
+ function inboundDirectoryPrefix(now = new Date()) {
18
+ return `${now.getFullYear()}${padded(now.getMonth() + 1)}${padded(now.getDate())}`
19
+ + `-${padded(now.getHours())}${padded(now.getMinutes())}${padded(now.getSeconds())}-`;
20
+ }
21
+
22
+ /**
23
+ * Parse the local-time instant encoded in a staged inbound directory name,
24
+ * returning null for legacy `turn-` names and anything unparseable.
25
+ */
26
+ export function parseInboundDirectoryName(name) {
27
+ if (typeof name !== 'string') return null;
28
+ const match = DIRECTORY_TIMESTAMP_PATTERN.exec(name);
29
+ if (!match) return null;
30
+ const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
31
+ const date = new Date(year, month - 1, day, hour, minute, second);
32
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day
33
+ || date.getHours() !== hour || date.getMinutes() !== minute || date.getSeconds() !== second) {
34
+ return null;
35
+ }
36
+ return date;
37
+ }
9
38
 
10
39
  export class InboundFileError extends Error {
11
40
  constructor(code, message, userMessage = t('文件接收失败,请重新发送后再试。'), options = {}) {
@@ -97,7 +126,18 @@ export function prefetchInboundFiles(message, { signal } = {}) {
97
126
  export async function stageInboundFiles(message, {
98
127
  workspace,
99
128
  signal,
129
+ retention = 'turn',
130
+ onStagedDirectory = null,
100
131
  } = {}) {
132
+ if (!RETENTIONS.has(retention)) {
133
+ throw new InboundFileError(
134
+ 'inbound-file-retention-invalid',
135
+ `Unknown inbound file retention: ${String(retention)}`,
136
+ );
137
+ }
138
+ if (onStagedDirectory !== null && typeof onStagedDirectory !== 'function') {
139
+ throw new TypeError('onStagedDirectory must be a function');
140
+ }
101
141
  const sources = fileSources(message);
102
142
  if (sources.length === 0) return null;
103
143
  if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
@@ -110,7 +150,10 @@ export async function stageInboundFiles(message, {
110
150
  signal?.throwIfAborted();
111
151
  const root = resolve(workspace, FILES_DIRECTORY);
112
152
  await mkdir(root, { recursive: true, mode: 0o700 });
113
- const directory = await mkdtemp(join(root, 'turn-'));
153
+ const directory = await mkdtemp(join(root, inboundDirectoryPrefix()));
154
+ // Register the batch before any file is written so a concurrent TTL=0
155
+ // sweep can never observe the directory as untracked mid-staging.
156
+ onStagedDirectory?.(directory);
114
157
  const files = [];
115
158
 
116
159
  try {
@@ -177,7 +220,11 @@ export async function stageInboundFiles(message, {
177
220
  }
178
221
  return Object.freeze({
179
222
  files: Object.freeze(files),
223
+ directory,
224
+ retention,
180
225
  async cleanup() {
226
+ // Persistent retention leaves the directory to the global TTL sweeper.
227
+ if (retention !== 'turn') return;
181
228
  await rm(directory, { recursive: true, force: true });
182
229
  },
183
230
  });
@@ -187,6 +234,74 @@ export async function stageInboundFiles(message, {
187
234
  }
188
235
  }
189
236
 
237
+ /**
238
+ * Delete expired staged inbound directories under one workspace. Only
239
+ * directories whose names parse as `yyyyMMdd-HHmmss` timestamps are managed;
240
+ * legacy `turn-` names and anything unparseable stay untouched. Directories
241
+ * reported as tracked by an in-flight turn are always skipped.
242
+ */
243
+ export async function sweepInboundAttachments(workspace, ttlHours, {
244
+ now = () => new Date(),
245
+ isTracked,
246
+ } = {}) {
247
+ if (typeof workspace !== 'string' || !workspace) {
248
+ throw new TypeError('sweepInboundAttachments requires a workspace');
249
+ }
250
+ // An unparsable TTL must never widen into unconditional deletion.
251
+ if (ttlHours !== 0 && !(Number.isInteger(ttlHours) && ttlHours > 0)) {
252
+ return { deleted: 0 };
253
+ }
254
+ const root = resolve(workspace, FILES_DIRECTORY);
255
+ let canonicalWorkspace;
256
+ let canonicalRoot;
257
+ try {
258
+ [canonicalWorkspace, canonicalRoot] = await Promise.all([
259
+ realpath(workspace),
260
+ realpath(root),
261
+ ]);
262
+ } catch (error) {
263
+ if (error?.code === 'ENOENT') return { deleted: 0 };
264
+ throw error;
265
+ }
266
+ const rootInWorkspace = relative(canonicalWorkspace, canonicalRoot);
267
+ if (!rootInWorkspace || rootInWorkspace.startsWith('..') || isAbsolute(rootInWorkspace)) {
268
+ throw new InboundFileError(
269
+ 'inbound-file-root-outside-workspace',
270
+ 'The inbound attachment directory does not resolve inside the Harness Session workspace.',
271
+ );
272
+ }
273
+ let entries;
274
+ try {
275
+ entries = await readdir(canonicalRoot, { withFileTypes: true });
276
+ } catch (error) {
277
+ if (error?.code === 'ENOENT') return { deleted: 0 };
278
+ throw error;
279
+ }
280
+ const current = now();
281
+ let deleted = 0;
282
+ for (const entry of entries) {
283
+ if (!entry.isDirectory()) continue;
284
+ const stagedAt = parseInboundDirectoryName(entry.name);
285
+ if (stagedAt === null) continue;
286
+ const directory = join(root, entry.name);
287
+ const canonicalDirectory = join(canonicalRoot, entry.name);
288
+ if (typeof isTracked === 'function'
289
+ && (isTracked(directory) || (directory !== canonicalDirectory && isTracked(canonicalDirectory)))) {
290
+ continue;
291
+ }
292
+ const ageHours = (current - stagedAt) / 3_600_000;
293
+ if (ttlHours === 0 || ageHours >= ttlHours) {
294
+ try {
295
+ await rm(canonicalDirectory, { recursive: true, force: true });
296
+ deleted += 1;
297
+ } catch {
298
+ // One undeletable directory must not abort the remaining sweep.
299
+ }
300
+ }
301
+ }
302
+ return { deleted };
303
+ }
304
+
190
305
  export function appendInboundFilesToPrompt(prompt, staged) {
191
306
  if (!staged?.files?.length) return prompt;
192
307
  const manifest = [
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ import { DEFAULT_INBOUND_TTL_HOURS, normalizeInboundTtlHours } from './inbound-ttl.mjs';
6
+
7
+ const DOCUMENT_VERSION = 1;
8
+ // Fail-safe TTL for documents that exist but cannot be trusted (damage or an
9
+ // unknown future version, e.g. after a rollback). TTL 0 would sweep every
10
+ // untracked directory on the next pass, so keep-forever is the safe fallback.
11
+ const UNREADABLE_DOCUMENT_TTL_HOURS = -1;
12
+
13
+ function invalidTtlError() {
14
+ const error = new Error('Invalid inbound attachment TTL hours.');
15
+ error.code = 'inbound-ttl-invalid';
16
+ return error;
17
+ }
18
+
19
+ // Mirrors the atomic settings writes of the bot workspace store and the
20
+ // update service: create a private temporary file, then rename it into place.
21
+ async function writeSettingsDocument(path, document) {
22
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
23
+ const temporary = `${path}.${randomUUID()}.tmp`;
24
+ try {
25
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
26
+ encoding: 'utf8',
27
+ mode: 0o600,
28
+ flag: 'wx',
29
+ });
30
+ await rename(temporary, path);
31
+ } finally {
32
+ await unlink(temporary).catch((error) => {
33
+ if (error.code !== 'ENOENT') throw error;
34
+ });
35
+ }
36
+ }
37
+
38
+ /** Durable host-side settings for the global inbound attachment TTL. */
39
+ export class InboundTtlStore {
40
+ #path;
41
+ #ttlHours = DEFAULT_INBOUND_TTL_HOURS;
42
+
43
+ constructor(path) {
44
+ if (typeof path !== 'string' || !path) throw new TypeError('inbound TTL store path is required');
45
+ this.#path = path;
46
+ }
47
+
48
+ /**
49
+ * A missing file means first run (default TTL). A damaged or future
50
+ * document falls back to keep-forever so an unreadable intent can never
51
+ * widen into deletion; the value stays visible in the settings UI.
52
+ */
53
+ async load() {
54
+ let raw;
55
+ try {
56
+ raw = await readFile(this.#path, 'utf8');
57
+ } catch (error) {
58
+ if (error?.code !== 'ENOENT') throw error;
59
+ this.#ttlHours = DEFAULT_INBOUND_TTL_HOURS;
60
+ await this.#removeStaleTemporaries();
61
+ return this;
62
+ }
63
+ this.#ttlHours = this.#readTtlHours(raw) ?? UNREADABLE_DOCUMENT_TTL_HOURS;
64
+ await this.#removeStaleTemporaries();
65
+ return this;
66
+ }
67
+
68
+ // Crash leftovers from an interrupted atomic write are unreferenced by
69
+ // anyone; remove them so the settings directory stays clean.
70
+ async #removeStaleTemporaries() {
71
+ const directory = dirname(this.#path);
72
+ const prefix = `${basename(this.#path)}.`;
73
+ try {
74
+ const entries = await readdir(directory);
75
+ await Promise.all(entries
76
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
77
+ .map((name) => unlink(join(directory, name)).catch(() => {})));
78
+ } catch {
79
+ // A missing directory or concurrent removal is fine; cleanup is best-effort.
80
+ }
81
+ }
82
+
83
+ #readTtlHours(raw) {
84
+ let document;
85
+ try {
86
+ document = JSON.parse(raw);
87
+ } catch {
88
+ return null;
89
+ }
90
+ if (!document || typeof document !== 'object' || Array.isArray(document)) return null;
91
+ if (document.version !== DOCUMENT_VERSION) return null;
92
+ if (document.inboundAttachmentTtlHours === undefined) return DEFAULT_INBOUND_TTL_HOURS;
93
+ return normalizeInboundTtlHours(document.inboundAttachmentTtlHours);
94
+ }
95
+
96
+ getTtlHours() {
97
+ return this.#ttlHours;
98
+ }
99
+
100
+ async setTtlHours(value) {
101
+ const ttlHours = normalizeInboundTtlHours(value);
102
+ if (ttlHours === null) throw invalidTtlError();
103
+ await writeSettingsDocument(this.#path, {
104
+ version: DOCUMENT_VERSION,
105
+ inboundAttachmentTtlHours: ttlHours,
106
+ });
107
+ this.#ttlHours = ttlHours;
108
+ return ttlHours;
109
+ }
110
+ }
@@ -0,0 +1,22 @@
1
+ export const DEFAULT_INBOUND_TTL_HOURS = 168;
2
+ export const INBOUND_TTL_MAX_HOURS = 8760;
3
+
4
+ const INTEGER_STRING = /^[+-]?\d+$/;
5
+
6
+ /**
7
+ * Normalize a global inbound attachment TTL. Valid values are -1 (keep
8
+ * forever), 0 (delete once the owning turn ends), and whole hours 1..8760.
9
+ * Integer strings are tolerated; every other input returns null.
10
+ */
11
+ export function normalizeInboundTtlHours(value) {
12
+ let candidate = value;
13
+ if (typeof candidate === 'string') {
14
+ const trimmed = candidate.trim();
15
+ if (!INTEGER_STRING.test(trimmed)) return null;
16
+ candidate = Number(trimmed);
17
+ }
18
+ if (typeof candidate !== 'number' || !Number.isInteger(candidate)) return null;
19
+ if (candidate === -1) return -1;
20
+ if (candidate >= 0 && candidate <= INBOUND_TTL_MAX_HOURS) return candidate;
21
+ return null;
22
+ }