@debbl/relay 0.0.0 → 0.0.1

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/index.mjs CHANGED
@@ -1,84 +1,13 @@
1
+ import process from "node:process";
1
2
  import * as Lark from "@larksuiteoapi/node-sdk";
2
3
  import { i18n } from "@lingui/core";
4
+ import { isPlainObject } from "es-toolkit/predicate";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
3
7
  import { spawn } from "node:child_process";
4
8
  import { createInterface } from "node:readline";
5
- import process from "node:process";
6
- import fs from "node:fs";
7
9
  import os from "node:os";
8
- import path from "node:path";
9
-
10
- //#region src/locales/en/messages.po
11
- const messages$1 = JSON.parse("{\"1xKjU/\":[\"Failed to read open projects. Please try again later.\"],\"36QmnO\":[\"thread: \",[\"0\"]],\"4IDydv\":[\"Invalid relay config at \",[\"configPath\"],\": root must be a JSON object.\"],\"4Vrm3r\":[\"cwd: \",[\"0\"]],\"6tcMXX\":[\"Invalid relay config: \",[\"field\"],\" is required and must be a non-empty string.\"],\"AWIU5i\":[\"Relay config is missing. Template created at \",[\"configPath\"],\". Please edit this file and restart.\"],\"Bb/dZi\":[\"Available commands:\"],\"CfFOzJ\":[\"Invalid relay config at \",[\"configPath\"],\": env must be a JSON object.\"],\"Ck6rmi\":[\"Current session has been cleared.\"],\"FZcpfm\":[\"Failed to process message. Please try again later.\"],\"G1WYAl\":[\"Unknown command \\\"\",[\"0\"],\"\\\".\\n\\n\",[\"helpText\"]],\"H7VlDR\":[\"Currently busy. Please try again later.\"],\"HEx9te\":[\"New Session\"],\"Hrkm8q\":[\"Invalid relay config: CODEX_TIMEOUT_MS must be a positive integer.\"],\"JvAWQ5\":[\"No working directories are currently open.\"],\"K8IUjY\":[\"mode: \",[\"0\"]],\"Ks6r4a\":[\"Invalid relay config: \",[\"field\"],\" must be a string.\"],\"MZ28Ys\":[\"Failed to read open projects: \",[\"0\"]],\"NX40Ku\":[\"/new [default|plan] - Create a new session\"],\"OZZfXh\":[\"Current session status:\"],\"Oeb7jH\":[\"model: \",[\"0\"]],\"Qq8U1p\":[\"/status does not accept arguments.\\n\\n\",[\"helpText\"]],\"RLcmLa\":[\"/status - Show current session status\"],\"Rn/EAE\":[\"You are a session title generator.\\nGenerate a short English title based on the user message.\\nStrict requirements:\\n1. Output title text only, with no explanation.\\n2. Output a single line with no line breaks.\\n3. Do not use quotes.\\n4. Keep the title within 24 characters.\"],\"T/qXZl\":[\"/new accepts at most one optional argument: default or plan.\\n\\n\",[\"helpText\"]],\"Xg0HiS\":[\"/mode <default|plan> - Switch current session mode\"],\"YwjZm9\":[\"No active session. Send a normal message or use /new to create one.\"],\"aQiwam\":[\"Failed to start relay: \",[\"message\"]],\"dV7HiV\":[\"Invalid relay config: LOCALE \\\"\",[\"0\"],\"\\\" is not supported. Falling back to en.\"],\"dXeZCW\":[\"Switched to \",[\"0\"],\" mode.\"],\"dZhhUX\":[\"Created a new session.\"],\"doQwEN\":[\"Codex execution failed: \",[\"0\"]],\"eie2Mj\":[\"Command cannot be empty.\\n\\n\",[\"helpText\"]],\"gHcqpZ\":[\"Invalid mode \\\"\",[\"modeToken\"],\"\\\", only default or plan are supported.\\n\\n\",[\"helpText\"]],\"gf810l\":[\"/mode requires one argument: default or plan.\\n\\n\",[\"helpText\"]],\"j3bsc1\":[[\"0\"],\". \",[\"root\"]],\"ksj3fj\":[\"Invalid relay config: LOCALE \\\"\",[\"normalized\"],\"\\\" is not supported. Falling back to en.\"],\"lksJXf\":[\"/projects - Show current working directories\"],\"mehbut\":[\"No active session. Send a normal message or use /new to create one first.\"],\"miupLy\":[\"Current working directories:\"],\"o4F8ck\":[\"/reset does not accept arguments.\\n\\n\",[\"helpText\"]],\"oEMyJf\":[\"Please send a text message.\"],\"oKQkYZ\":[\"/projects does not accept arguments.\\n\\n\",[\"helpText\"]],\"orhkAj\":[\"Codex execution failed. Please try again later.\"],\"qN3BkN\":[\"/reset - Clear current session\"],\"rHDhWM\":[\"Failed to parse message. Please send a text message.\"],\"rdodSw\":[\"You are a session title generator.\\nGenerate a short Chinese title based on the user message.\\nStrict requirements:\\n1. Output title text only, with no explanation.\\n2. Output a single line with no line breaks.\\n3. Do not use quotes or title marks.\\n4. Keep the title within 24 characters.\"],\"smfLBQ\":[\"Cannot identify sender. Please try again later.\"],\"tHEFWw\":[\"title: \",[\"title\"]],\"tZQUtS\":[\"Failed to read relay config at \",[\"configPath\"],\": \",[\"0\"]],\"uAkDSp\":[\"User message: \",[\"0\"]],\"wBrugH\":[\"/help does not accept arguments.\\n\\n\",[\"helpText\"]],\"wSNjFy\":[\"/help - Show help\"],\"z+7ZYe\":[\"Invalid JSON in relay config at \",[\"configPath\"],\": \",[\"0\"]]}");
12
-
13
- //#endregion
14
- //#region src/locales/zh/messages.po
15
- const messages = JSON.parse("{\"1xKjU/\":[\"读取已打开项目失败,请稍后重试。\"],\"36QmnO\":[\"thread: \",[\"0\"]],\"4IDydv\":[\"中继配置 \",[\"configPath\"],\" 无效:root 必须是 JSON 对象。\"],\"4Vrm3r\":[\"cwd: \",[\"0\"]],\"6tcMXX\":[\"中继配置无效:\",[\"field\"],\" 为必填项且必须为非空字符串。\"],\"AWIU5i\":[\"中继配置缺失,已在 \",[\"configPath\"],\" 创建模板。请编辑该文件后重启。\"],\"Bb/dZi\":[\"可用命令:\"],\"CfFOzJ\":[\"中继配置 \",[\"configPath\"],\" 无效:env 必须是 JSON 对象。\"],\"Ck6rmi\":[\"当前会话已清空。\"],\"FZcpfm\":[\"处理消息失败,请稍后重试。\"],\"G1WYAl\":[\"未知命令 \\\"\",[\"0\"],\"\\\"。\\n\\n\",[\"helpText\"]],\"H7VlDR\":[\"当前忙碌,请稍后重试。\"],\"HEx9te\":[\"新会话\"],\"Hrkm8q\":[\"中继配置无效:CODEX_TIMEOUT_MS 必须是正整数。\"],\"JvAWQ5\":[\"当前没有打开的工作目录。\"],\"K8IUjY\":[\"mode: \",[\"0\"]],\"Ks6r4a\":[\"中继配置无效:\",[\"field\"],\" 必须是字符串。\"],\"MZ28Ys\":[\"读取已打开项目失败:\",[\"0\"]],\"NX40Ku\":[\"/new [default|plan] - 创建新会话\"],\"OZZfXh\":[\"当前会话状态:\"],\"Oeb7jH\":[\"model: \",[\"0\"]],\"Qq8U1p\":[\"/status 不接受参数。\\n\\n\",[\"helpText\"]],\"RLcmLa\":[\"/status - 显示当前会话状态\"],\"Rn/EAE\":[\"你是会话标题生成器。\\n根据用户消息生成简短英文标题。\\n严格要求:\\n1. 仅输出标题文字,不要解释。\\n2. 单行输出,不要换行。\\n3. 不要使用引号。\\n4. 标题不超过 24 个字符。\"],\"T/qXZl\":[\"/new 最多接受一个可选参数:default 或 plan。\\n\\n\",[\"helpText\"]],\"Xg0HiS\":[\"/mode <default|plan> - 切换当前会话模式\"],\"YwjZm9\":[\"没有活跃会话。请发送普通消息或使用 /new 创建会话。\"],\"aQiwam\":[\"启动中继失败:\",[\"message\"]],\"dV7HiV\":[\"中继配置无效:不支持的 LOCALE \\\"\",[\"0\"],\"\\\",已回退为 en。\"],\"dXeZCW\":[\"已切换到 \",[\"0\"],\" 模式。\"],\"dZhhUX\":[\"已创建新会话。\"],\"doQwEN\":[\"Codex 执行失败:\",[\"0\"]],\"eie2Mj\":[\"命令不能为空。\\n\\n\",[\"helpText\"]],\"gHcqpZ\":[\"无效的模式 \\\"\",[\"modeToken\"],\"\\\",仅支持 default 或 plan。\\n\\n\",[\"helpText\"]],\"gf810l\":[\"/mode 需要一个参数:default 或 plan。\\n\\n\",[\"helpText\"]],\"j3bsc1\":[[\"0\"],\". \",[\"root\"]],\"ksj3fj\":[\"中继配置无效:不支持的 LOCALE \\\"\",[\"normalized\"],\"\\\",已回退为 en。\"],\"lksJXf\":[\"/projects - 显示当前工作目录\"],\"mehbut\":[\"没有活跃会话。请先发送普通消息或使用 /new 创建会话。\"],\"miupLy\":[\"当前工作目录:\"],\"o4F8ck\":[\"/reset 不接受参数。\\n\\n\",[\"helpText\"]],\"oEMyJf\":[\"请发送文本消息。\"],\"oKQkYZ\":[\"/projects 不接受参数。\\n\\n\",[\"helpText\"]],\"orhkAj\":[\"Codex 执行失败,请稍后重试。\"],\"qN3BkN\":[\"/reset - 清空当前会话\"],\"rHDhWM\":[\"解析消息失败,请发送文本消息。\"],\"rdodSw\":[\"你是会话标题生成器。\\n根据用户消息生成简短中文标题。\\n严格要求:\\n1. 仅输出标题文字,不要解释。\\n2. 单行输出,不要换行。\\n3. 不要使用引号或书名号。\\n4. 标题不超过 24 个字符。\"],\"smfLBQ\":[\"无法识别发送者,请稍后重试。\"],\"tHEFWw\":[\"title: \",[\"title\"]],\"tZQUtS\":[\"读取中继配置失败 \",[\"configPath\"],\":\",[\"0\"]],\"uAkDSp\":[\"用户消息:\",[\"0\"]],\"wBrugH\":[\"/help 不接受参数。\\n\\n\",[\"helpText\"]],\"wSNjFy\":[\"/help - 显示帮助\"],\"z+7ZYe\":[\"中继配置 \",[\"configPath\"],\" 中的 JSON 无效:\",[\"0\"]]}");
16
-
17
- //#endregion
18
- //#region src/i18n/runtime.ts
19
- const DEFAULT_LOCALE = "en";
20
- const CATALOGS = {
21
- en: messages$1,
22
- zh: messages
23
- };
24
- let activeLocale = null;
25
- function initializeI18n(locale) {
26
- const resolved = resolveLocale(locale);
27
- i18n.loadAndActivate({
28
- locale: resolved,
29
- messages: CATALOGS[resolved]
30
- });
31
- activeLocale = resolved;
32
- return resolved;
33
- }
34
- function getCurrentLocale() {
35
- ensureI18nInitialized();
36
- return activeLocale ?? DEFAULT_LOCALE;
37
- }
38
- function isSupportedLocale(locale) {
39
- return locale === "en" || locale === "zh";
40
- }
41
- function getDefaultLocale() {
42
- return DEFAULT_LOCALE;
43
- }
44
- function resolveLocale(locale) {
45
- if (!locale) return DEFAULT_LOCALE;
46
- if (isSupportedLocale(locale)) return locale;
47
- return DEFAULT_LOCALE;
48
- }
49
- function ensureI18nInitialized() {
50
- if (!activeLocale) initializeI18n(DEFAULT_LOCALE);
51
- }
52
10
 
53
- //#endregion
54
- //#region src/session/store.ts
55
- const sessionStore = /* @__PURE__ */ new Map();
56
- const sessionQueue = /* @__PURE__ */ new Map();
57
- function getSessionKey(input) {
58
- if (input.chatType === "p2p") return `p2p:${input.chatId}`;
59
- return `group:${input.chatId}:${input.userId}`;
60
- }
61
- function getSession(sessionKey) {
62
- return sessionStore.get(sessionKey);
63
- }
64
- function setSession(sessionKey, session) {
65
- sessionStore.set(sessionKey, session);
66
- }
67
- function clearSession(sessionKey) {
68
- sessionStore.delete(sessionKey);
69
- }
70
- async function withSessionLock(sessionKey, run) {
71
- const running = (sessionQueue.get(sessionKey) ?? Promise.resolve()).then(() => run(), () => run());
72
- const queueItem = running.then(() => void 0, () => void 0);
73
- sessionQueue.set(sessionKey, queueItem);
74
- try {
75
- return await running;
76
- } finally {
77
- if (sessionQueue.get(sessionKey) === queueItem) sessionQueue.delete(sessionKey);
78
- }
79
- }
80
-
81
- //#endregion
82
11
  //#region src/bot/commands.ts
83
12
  const COMMAND_HELP = "/help";
84
13
  const COMMAND_NEW = "/new";
@@ -255,17 +184,238 @@ function parseMode(input) {
255
184
  return null;
256
185
  }
257
186
 
187
+ //#endregion
188
+ //#region src/session/store.ts
189
+ const sessionStore = /* @__PURE__ */ new Map();
190
+ const sessionQueue = /* @__PURE__ */ new Map();
191
+ const SESSION_FILE_NAME = "sessions.json";
192
+ const SESSION_FILE_VERSION = 1;
193
+ let persistenceState = null;
194
+ function initializeSessionStore(input) {
195
+ const relayDir = path.join(input.homeDir, ".relay");
196
+ const filePath = path.join(relayDir, SESSION_FILE_NAME);
197
+ fs.mkdirSync(relayDir, { recursive: true });
198
+ ensureSessionFileExists(filePath);
199
+ const persisted = readPersistedSessionsFile(filePath);
200
+ const workspaceSessions = persisted.workspaces[input.workspaceCwd];
201
+ sessionStore.clear();
202
+ sessionQueue.clear();
203
+ if (workspaceSessions) {
204
+ if (workspaceSessions.activeBySessionKey) {
205
+ const sessionRef = workspaceSessions.activeBySessionKey;
206
+ sessionStore.set(sessionRef.sessionKey, hydrateSession(sessionRef, input.workspaceCwd));
207
+ }
208
+ }
209
+ persistenceState = {
210
+ filePath,
211
+ workspaceCwd: input.workspaceCwd,
212
+ data: persisted
213
+ };
214
+ }
215
+ function getSessionKey(input) {
216
+ if (input.chatType === "p2p") return `p2p:${input.chatId}`;
217
+ return `group:${input.chatId}:${input.userId}`;
218
+ }
219
+ function getSession(sessionKey) {
220
+ return sessionStore.get(sessionKey);
221
+ }
222
+ function setSession(sessionKey, session) {
223
+ sessionStore.set(sessionKey, session);
224
+ persistSetSession(sessionKey, session);
225
+ }
226
+ function clearSession(sessionKey) {
227
+ sessionStore.delete(sessionKey);
228
+ persistClearSession(sessionKey);
229
+ }
230
+ async function withSessionLock(sessionKey, run) {
231
+ const running = (sessionQueue.get(sessionKey) ?? Promise.resolve()).then(() => run(), () => run());
232
+ const queueItem = running.then(() => void 0, () => void 0);
233
+ sessionQueue.set(sessionKey, queueItem);
234
+ try {
235
+ return await running;
236
+ } finally {
237
+ if (sessionQueue.get(sessionKey) === queueItem) sessionQueue.delete(sessionKey);
238
+ }
239
+ }
240
+ function persistSetSession(sessionKey, session) {
241
+ const state = persistenceState;
242
+ if (!state) return;
243
+ const savedAt = (/* @__PURE__ */ new Date()).toISOString();
244
+ const activeSession = toPersistedActiveSession(sessionKey, session, savedAt);
245
+ const historySession = toPersistedSessionSnapshot(session, savedAt);
246
+ const workspaceSessions = getOrCreateWorkspaceSessions(state.data, state.workspaceCwd);
247
+ workspaceSessions.activeBySessionKey = activeSession;
248
+ const history = workspaceSessions.historyBySessionKey[session.threadId] ?? [];
249
+ history.push(historySession);
250
+ workspaceSessions.historyBySessionKey[session.threadId] = history;
251
+ state.data.updatedAt = savedAt;
252
+ writePersistedSessionsFile(state.filePath, state.data);
253
+ }
254
+ function persistClearSession(sessionKey) {
255
+ const state = persistenceState;
256
+ if (!state) return;
257
+ const workspaceSessions = state.data.workspaces[state.workspaceCwd];
258
+ if (!workspaceSessions) return;
259
+ if (!workspaceSessions.activeBySessionKey || workspaceSessions.activeBySessionKey.sessionKey !== sessionKey) return;
260
+ workspaceSessions.activeBySessionKey = null;
261
+ state.data.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
262
+ writePersistedSessionsFile(state.filePath, state.data);
263
+ }
264
+ function ensureSessionFileExists(filePath) {
265
+ if (fs.existsSync(filePath)) return;
266
+ const initialContent = `${JSON.stringify(createEmptyPersistedSessionsFile(), null, 2)}\n`;
267
+ fs.writeFileSync(filePath, initialContent, {
268
+ encoding: "utf-8",
269
+ flag: "wx"
270
+ });
271
+ }
272
+ function createEmptyPersistedSessionsFile() {
273
+ return {
274
+ version: SESSION_FILE_VERSION,
275
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
276
+ workspaces: {}
277
+ };
278
+ }
279
+ function readPersistedSessionsFile(filePath) {
280
+ let raw;
281
+ try {
282
+ raw = fs.readFileSync(filePath, "utf-8");
283
+ } catch (error) {
284
+ throw new Error(`Failed to read relay session index at ${filePath}: ${formatError$1(error)}`);
285
+ }
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(raw);
289
+ } catch (error) {
290
+ throw new Error(`Invalid JSON in relay session index at ${filePath}: ${formatError$1(error)}`);
291
+ }
292
+ return parsePersistedSessionsFile(parsed, filePath);
293
+ }
294
+ function parsePersistedSessionsFile(value, filePath) {
295
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: root must be a JSON object.`);
296
+ if (value.version !== SESSION_FILE_VERSION) throw new Error(`Invalid relay session index at ${filePath}: version must be ${SESSION_FILE_VERSION}.`);
297
+ if (typeof value.updatedAt !== "string") throw new TypeError(`Invalid relay session index at ${filePath}: updatedAt must be a string.`);
298
+ if (!isObject$1(value.workspaces)) throw new Error(`Invalid relay session index at ${filePath}: workspaces must be a JSON object.`);
299
+ const workspaces = {};
300
+ for (const [workspaceCwd, workspaceValue] of Object.entries(value.workspaces)) workspaces[workspaceCwd] = parseWorkspaceSessions(workspaceValue, filePath, workspaceCwd);
301
+ return {
302
+ version: SESSION_FILE_VERSION,
303
+ updatedAt: value.updatedAt,
304
+ workspaces
305
+ };
306
+ }
307
+ function parseWorkspaceSessions(value, filePath, workspaceCwd) {
308
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: workspace "${workspaceCwd}" must be a JSON object.`);
309
+ return {
310
+ activeBySessionKey: parseWorkspaceActiveSession(value.activeBySessionKey, filePath, workspaceCwd),
311
+ historyBySessionKey: parseWorkspaceHistorySessions(value.historyBySessionKey, filePath, workspaceCwd)
312
+ };
313
+ }
314
+ function parseWorkspaceActiveSession(value, filePath, workspaceCwd) {
315
+ if (value === null || value === void 0) return null;
316
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: activeBySessionKey for workspace "${workspaceCwd}" must be a JSON object or null.`);
317
+ return parsePersistedActiveSession(value, filePath, `activeBySessionKey for workspace "${workspaceCwd}"`);
318
+ }
319
+ function parseWorkspaceHistorySessions(value, filePath, workspaceCwd) {
320
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: historyBySessionKey for workspace "${workspaceCwd}" must be a JSON object.`);
321
+ const historyBySessionKey = {};
322
+ for (const [entryKey, historyValue] of Object.entries(value)) {
323
+ if (entryKey.trim().length === 0) throw new Error(`Invalid relay session index at ${filePath}: historyBySessionKey key in workspace "${workspaceCwd}" must be a non-empty threadId.`);
324
+ if (!Array.isArray(historyValue)) throw new TypeError(`Invalid relay session index at ${filePath}: historyBySessionKey.${entryKey} must be an array.`);
325
+ historyBySessionKey[entryKey] = historyValue.map((item, index) => parsePersistedSessionSnapshot(item, filePath, `historyBySessionKey.${entryKey}[${index}]`));
326
+ }
327
+ return historyBySessionKey;
328
+ }
329
+ function parsePersistedActiveSession(value, filePath, location) {
330
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: ${location} must be a JSON object.`);
331
+ return {
332
+ sessionKey: parseNonEmptyString(value.sessionKey, filePath, `${location}.sessionKey`),
333
+ threadId: parseNonEmptyString(value.threadId, filePath, `${location}.threadId`),
334
+ ...parsePersistedSessionSnapshot(value, filePath, location)
335
+ };
336
+ }
337
+ function parsePersistedSessionSnapshot(value, filePath, location) {
338
+ if (!isObject$1(value)) throw new Error(`Invalid relay session index at ${filePath}: ${location} must be a JSON object.`);
339
+ const model = parseNonEmptyString(value.model, filePath, `${location}.model`);
340
+ if (value.mode !== "default" && value.mode !== "plan") throw new Error(`Invalid relay session index at ${filePath}: ${location}.mode must be "default" or "plan".`);
341
+ if (typeof value.savedAt !== "string") throw new TypeError(`Invalid relay session index at ${filePath}: ${location}.savedAt must be a string.`);
342
+ const title = normalizeOptionalTitle(value.title);
343
+ return {
344
+ mode: value.mode,
345
+ model,
346
+ title,
347
+ savedAt: value.savedAt
348
+ };
349
+ }
350
+ function parseNonEmptyString(value, filePath, location) {
351
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`Invalid relay session index at ${filePath}: ${location} must be a non-empty string.`);
352
+ return value;
353
+ }
354
+ function hydrateSession(sessionRef, cwd) {
355
+ return {
356
+ threadId: sessionRef.threadId,
357
+ mode: sessionRef.mode,
358
+ model: sessionRef.model,
359
+ cwd,
360
+ title: normalizeOptionalTitle(sessionRef.title)
361
+ };
362
+ }
363
+ function toPersistedActiveSession(sessionKey, session, savedAt) {
364
+ return {
365
+ sessionKey,
366
+ threadId: session.threadId,
367
+ ...toPersistedSessionSnapshot(session, savedAt)
368
+ };
369
+ }
370
+ function toPersistedSessionSnapshot(session, savedAt) {
371
+ const title = normalizeOptionalTitle(session.title);
372
+ return {
373
+ mode: session.mode,
374
+ model: session.model,
375
+ title,
376
+ savedAt
377
+ };
378
+ }
379
+ function normalizeOptionalTitle(title) {
380
+ if (typeof title !== "string") return;
381
+ const normalized = title.trim();
382
+ if (normalized.length === 0) return;
383
+ return normalized;
384
+ }
385
+ function getOrCreateWorkspaceSessions(data, workspaceCwd) {
386
+ const existing = data.workspaces[workspaceCwd];
387
+ if (existing) return existing;
388
+ const created = {
389
+ activeBySessionKey: null,
390
+ historyBySessionKey: {}
391
+ };
392
+ data.workspaces[workspaceCwd] = created;
393
+ return created;
394
+ }
395
+ function writePersistedSessionsFile(filePath, data) {
396
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
397
+ const content = `${JSON.stringify(data, null, 2)}\n`;
398
+ try {
399
+ fs.writeFileSync(tempPath, content, "utf-8");
400
+ fs.renameSync(tempPath, filePath);
401
+ } catch (error) {
402
+ try {
403
+ if (fs.existsSync(tempPath)) fs.rmSync(tempPath, { force: true });
404
+ } catch {}
405
+ throw new Error(`Failed to write relay session index at ${filePath}: ${formatError$1(error)}`);
406
+ }
407
+ }
408
+ function isObject$1(value) {
409
+ return typeof value === "object" && value !== null && !Array.isArray(value);
410
+ }
411
+ function formatError$1(error) {
412
+ if (error instanceof Error) return error.message;
413
+ return String(error);
414
+ }
415
+
258
416
  //#endregion
259
417
  //#region src/bot/handler.ts
260
418
  const MAX_SESSION_TITLE_LENGTH = 24;
261
- const WRAPPING_QUOTE_PAIRS = [
262
- ["\"", "\""],
263
- ["'", "'"],
264
- ["“", "”"],
265
- ["‘", "’"],
266
- ["「", "」"],
267
- ["《", "》"]
268
- ];
269
419
  async function handleIncomingText(input, deps) {
270
420
  const sessionKey = getSessionKey({
271
421
  chatType: input.chatType,
@@ -397,9 +547,7 @@ async function handleIncomingText(input, deps) {
397
547
  });
398
548
  const title = await resolveSessionTitle({
399
549
  currentSession,
400
- prompt: parsed.prompt,
401
- mode,
402
- runTurn: deps.runTurn
550
+ prompt: parsed.prompt
403
551
  });
404
552
  deps.setSession(sessionKey, {
405
553
  threadId: result.threadId,
@@ -440,27 +588,7 @@ async function resolveSessionTitle(input) {
440
588
  const currentTitle = normalizeSessionTitle(input.currentSession?.title);
441
589
  if (currentTitle) return currentTitle;
442
590
  if (!input.currentSession) return;
443
- return generateSessionTitleWithFallback({
444
- prompt: input.prompt,
445
- mode: input.mode,
446
- runTurn: input.runTurn
447
- });
448
- }
449
- async function generateSessionTitleWithFallback(input) {
450
- const fallbackTitle = buildFallbackSessionTitle(input.prompt);
451
- const titlePrompt = buildTitleGenerationPrompt(input.prompt);
452
- try {
453
- const sanitizedTitle = sanitizeGeneratedTitle((await input.runTurn({
454
- prompt: titlePrompt,
455
- mode: input.mode,
456
- session: null
457
- })).message);
458
- if (sanitizedTitle) return sanitizedTitle;
459
- logTitleGenerationFallback("generated title is empty after post-processing, using fallback");
460
- } catch (error) {
461
- logTitleGenerationFallback(`title generation request failed: ${formatErrorMessage(error)}`);
462
- }
463
- return fallbackTitle;
591
+ return buildFallbackSessionTitle(input.prompt);
464
592
  }
465
593
  function buildFallbackSessionTitle(prompt) {
466
594
  const normalizedPrompt = normalizePrompt(prompt);
@@ -476,48 +604,6 @@ function normalizeSessionTitle(title) {
476
604
  if (normalized.length === 0) return null;
477
605
  return normalized;
478
606
  }
479
- function buildTitleGenerationPrompt(prompt) {
480
- return [
481
- getCurrentLocale() === "zh" ? i18n._({
482
- id: "rdodSw",
483
- message: "You are a session title generator.\nGenerate a short Chinese title based on the user message.\nStrict requirements:\n1. Output title text only, with no explanation.\n2. Output a single line with no line breaks.\n3. Do not use quotes or title marks.\n4. Keep the title within 24 characters."
484
- }) : i18n._({
485
- id: "Rn/EAE",
486
- message: "You are a session title generator.\nGenerate a short English title based on the user message.\nStrict requirements:\n1. Output title text only, with no explanation.\n2. Output a single line with no line breaks.\n3. Do not use quotes.\n4. Keep the title within 24 characters."
487
- }),
488
- "",
489
- i18n._({
490
- id: "uAkDSp",
491
- message: "User message: {0}",
492
- values: { 0: normalizePrompt(prompt) }
493
- })
494
- ].join("\n");
495
- }
496
- function sanitizeGeneratedTitle(rawTitle) {
497
- const trimmed = rawTitle.trim();
498
- if (trimmed.length === 0) return null;
499
- const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
500
- if (firstLine.length === 0) return null;
501
- const unquoted = stripWrappingQuotes(normalizePrompt(firstLine));
502
- if (unquoted.length === 0) return null;
503
- return truncateTitle(unquoted);
504
- }
505
- function stripWrappingQuotes(input) {
506
- let value = input.trim();
507
- let changed = true;
508
- while (changed && value.length > 0) {
509
- changed = false;
510
- for (const [left, right] of WRAPPING_QUOTE_PAIRS) if (value.startsWith(left) && value.endsWith(right)) {
511
- const inner = value.slice(left.length, value.length - right.length).trim();
512
- if (inner !== value) {
513
- value = inner;
514
- changed = true;
515
- break;
516
- }
517
- }
518
- }
519
- return value;
520
- }
521
607
  function truncateTitle(input) {
522
608
  const chars = Array.from(input);
523
609
  if (chars.length <= MAX_SESSION_TITLE_LENGTH) return input;
@@ -527,13 +613,6 @@ function truncateTitle(input) {
527
613
  function normalizePrompt(input) {
528
614
  return input.replace(/\s+/g, " ").trim();
529
615
  }
530
- function formatErrorMessage(error) {
531
- if (error instanceof Error && error.message.trim().length > 0) return error.message;
532
- return String(error);
533
- }
534
- function logTitleGenerationFallback(message) {
535
- console.warn(`[relay] ${message}`);
536
- }
537
616
 
538
617
  //#endregion
539
618
  //#region src/bot/message-filter.ts
@@ -594,15 +673,12 @@ function stripMentionTags(text) {
594
673
  function parseTextContent(content) {
595
674
  try {
596
675
  const parsed = JSON.parse(content);
597
- if (!isRecord$1(parsed)) return null;
676
+ if (!isPlainObject(parsed)) return null;
598
677
  return typeof parsed.text === "string" ? parsed.text : null;
599
678
  } catch {
600
679
  return null;
601
680
  }
602
681
  }
603
- function isRecord$1(value) {
604
- return typeof value === "object" && value !== null;
605
- }
606
682
 
607
683
  //#endregion
608
684
  //#region src/codex/rpc.ts
@@ -613,7 +689,7 @@ function parseRpcLine(line) {
613
689
  } catch {
614
690
  return null;
615
691
  }
616
- if (!isRecord(parsed)) return null;
692
+ if (!isPlainObject(parsed)) return null;
617
693
  if (typeof parsed.method === "string") {
618
694
  if (isRpcRequestId(parsed.id)) return {
619
695
  id: parsed.id,
@@ -639,14 +715,11 @@ function parseRpcLine(line) {
639
715
  function formatRpcError(error) {
640
716
  return `Codex RPC error (${error.code}): ${error.message}`;
641
717
  }
642
- function isRecord(value) {
643
- return typeof value === "object" && value !== null;
644
- }
645
718
  function isRpcRequestId(value) {
646
719
  return typeof value === "number" || typeof value === "string";
647
720
  }
648
721
  function isRpcErrorObject(value) {
649
- if (!isRecord(value)) return false;
722
+ if (!isPlainObject(value)) return false;
650
723
  return typeof value.code === "number" && typeof value.message === "string";
651
724
  }
652
725
  function isRpcErrorResponse(value) {
@@ -884,16 +957,16 @@ function isThreadMissingError(error) {
884
957
  return error.message.includes("thread not found");
885
958
  }
886
959
  function isCollaborationModeMask(value) {
887
- if (!isRecord(value)) return false;
960
+ if (!isPlainObject(value)) return false;
888
961
  const modeIsValid = value.mode === null || value.mode === "default" || value.mode === "plan";
889
962
  return typeof value.name === "string" && modeIsValid && (typeof value.model === "string" || value.model === null) && (typeof value.reasoning_effort === "string" || value.reasoning_effort === null) && (typeof value.developer_instructions === "string" || value.developer_instructions === null);
890
963
  }
891
964
  function isCollaborationModeListResponse(value) {
892
- if (!isRecord(value) || !Array.isArray(value.data)) return false;
965
+ if (!isPlainObject(value) || !Array.isArray(value.data)) return false;
893
966
  return value.data.every(isCollaborationModeMask);
894
967
  }
895
968
  function isThreadResult(value) {
896
- if (!isRecord(value) || !isRecord(value.thread)) return false;
969
+ if (!isPlainObject(value) || !isPlainObject(value.thread)) return false;
897
970
  return typeof value.thread.id === "string" && typeof value.model === "string";
898
971
  }
899
972
 
@@ -909,7 +982,7 @@ function createTurnAccumulator() {
909
982
  }
910
983
  function applyTurnNotification(accumulator, notification) {
911
984
  if (notification.method === "error") {
912
- if (isRecord(notification.params) && typeof notification.params.message === "string") accumulator.turnError = notification.params.message;
985
+ if (isPlainObject(notification.params) && typeof notification.params.message === "string") accumulator.turnError = notification.params.message;
913
986
  else accumulator.turnError = "Codex returned an unknown error event";
914
987
  accumulator.turnCompleted = true;
915
988
  return;
@@ -1050,6 +1123,43 @@ async function listOpenProjects() {
1050
1123
  return { roots: [process.cwd()] };
1051
1124
  }
1052
1125
 
1126
+ //#endregion
1127
+ //#region src/locales/en/messages.po
1128
+ const messages$1 = JSON.parse("{\"1xKjU/\":[\"Failed to read open projects. Please try again later.\"],\"36QmnO\":[\"thread: \",[\"0\"]],\"4IDydv\":[\"Invalid relay config at \",[\"configPath\"],\": root must be a JSON object.\"],\"4Vrm3r\":[\"cwd: \",[\"0\"]],\"6tcMXX\":[\"Invalid relay config: \",[\"field\"],\" is required and must be a non-empty string.\"],\"AWIU5i\":[\"Relay config is missing. Template created at \",[\"configPath\"],\". Please edit this file and restart.\"],\"Bb/dZi\":[\"Available commands:\"],\"CfFOzJ\":[\"Invalid relay config at \",[\"configPath\"],\": env must be a JSON object.\"],\"Ck6rmi\":[\"Current session has been cleared.\"],\"FZcpfm\":[\"Failed to process message. Please try again later.\"],\"G1WYAl\":[\"Unknown command \\\"\",[\"0\"],\"\\\".\\n\\n\",[\"helpText\"]],\"H7VlDR\":[\"Currently busy. Please try again later.\"],\"HEx9te\":[\"New Session\"],\"Hrkm8q\":[\"Invalid relay config: CODEX_TIMEOUT_MS must be a positive integer.\"],\"JvAWQ5\":[\"No working directories are currently open.\"],\"K8IUjY\":[\"mode: \",[\"0\"]],\"Ks6r4a\":[\"Invalid relay config: \",[\"field\"],\" must be a string.\"],\"MZ28Ys\":[\"Failed to read open projects: \",[\"0\"]],\"NX40Ku\":[\"/new [default|plan] - Create a new session\"],\"OZZfXh\":[\"Current session status:\"],\"Oeb7jH\":[\"model: \",[\"0\"]],\"Qq8U1p\":[\"/status does not accept arguments.\\n\\n\",[\"helpText\"]],\"RLcmLa\":[\"/status - Show current session status\"],\"Rn/EAE\":[\"You are a session title generator.\\nGenerate a short English title based on the user message.\\nStrict requirements:\\n1. Output title text only, with no explanation.\\n2. Output a single line with no line breaks.\\n3. Do not use quotes.\\n4. Keep the title within 24 characters.\"],\"T/qXZl\":[\"/new accepts at most one optional argument: default or plan.\\n\\n\",[\"helpText\"]],\"Xg0HiS\":[\"/mode <default|plan> - Switch current session mode\"],\"YwjZm9\":[\"No active session. Send a normal message or use /new to create one.\"],\"aQiwam\":[\"Failed to start relay: \",[\"message\"]],\"dV7HiV\":[\"Invalid relay config: LOCALE \\\"\",[\"0\"],\"\\\" is not supported. Falling back to en.\"],\"dXeZCW\":[\"Switched to \",[\"0\"],\" mode.\"],\"dZhhUX\":[\"Created a new session.\"],\"doQwEN\":[\"Codex execution failed: \",[\"0\"]],\"eie2Mj\":[\"Command cannot be empty.\\n\\n\",[\"helpText\"]],\"gHcqpZ\":[\"Invalid mode \\\"\",[\"modeToken\"],\"\\\", only default or plan are supported.\\n\\n\",[\"helpText\"]],\"gf810l\":[\"/mode requires one argument: default or plan.\\n\\n\",[\"helpText\"]],\"j3bsc1\":[[\"0\"],\". \",[\"root\"]],\"ksj3fj\":[\"Invalid relay config: LOCALE \\\"\",[\"normalized\"],\"\\\" is not supported. Falling back to en.\"],\"lksJXf\":[\"/projects - Show current working directories\"],\"mehbut\":[\"No active session. Send a normal message or use /new to create one first.\"],\"miupLy\":[\"Current working directories:\"],\"o4F8ck\":[\"/reset does not accept arguments.\\n\\n\",[\"helpText\"]],\"oEMyJf\":[\"Please send a text message.\"],\"oKQkYZ\":[\"/projects does not accept arguments.\\n\\n\",[\"helpText\"]],\"orhkAj\":[\"Codex execution failed. Please try again later.\"],\"qN3BkN\":[\"/reset - Clear current session\"],\"rHDhWM\":[\"Failed to parse message. Please send a text message.\"],\"rdodSw\":[\"You are a session title generator.\\nGenerate a short Chinese title based on the user message.\\nStrict requirements:\\n1. Output title text only, with no explanation.\\n2. Output a single line with no line breaks.\\n3. Do not use quotes or title marks.\\n4. Keep the title within 24 characters.\"],\"smfLBQ\":[\"Cannot identify sender. Please try again later.\"],\"tHEFWw\":[\"title: \",[\"title\"]],\"tZQUtS\":[\"Failed to read relay config at \",[\"configPath\"],\": \",[\"0\"]],\"uAkDSp\":[\"User message: \",[\"0\"]],\"wBrugH\":[\"/help does not accept arguments.\\n\\n\",[\"helpText\"]],\"wSNjFy\":[\"/help - Show help\"],\"z+7ZYe\":[\"Invalid JSON in relay config at \",[\"configPath\"],\": \",[\"0\"]]}");
1129
+
1130
+ //#endregion
1131
+ //#region src/locales/zh/messages.po
1132
+ const messages = JSON.parse("{\"1xKjU/\":[\"读取已打开项目失败,请稍后重试。\"],\"36QmnO\":[\"thread: \",[\"0\"]],\"4IDydv\":[\"中继配置 \",[\"configPath\"],\" 无效:root 必须是 JSON 对象。\"],\"4Vrm3r\":[\"cwd: \",[\"0\"]],\"6tcMXX\":[\"中继配置无效:\",[\"field\"],\" 为必填项且必须为非空字符串。\"],\"AWIU5i\":[\"中继配置缺失,已在 \",[\"configPath\"],\" 创建模板。请编辑该文件后重启。\"],\"Bb/dZi\":[\"可用命令:\"],\"CfFOzJ\":[\"中继配置 \",[\"configPath\"],\" 无效:env 必须是 JSON 对象。\"],\"Ck6rmi\":[\"当前会话已清空。\"],\"FZcpfm\":[\"处理消息失败,请稍后重试。\"],\"G1WYAl\":[\"未知命令 \\\"\",[\"0\"],\"\\\"。\\n\\n\",[\"helpText\"]],\"H7VlDR\":[\"当前忙碌,请稍后重试。\"],\"HEx9te\":[\"新会话\"],\"Hrkm8q\":[\"中继配置无效:CODEX_TIMEOUT_MS 必须是正整数。\"],\"JvAWQ5\":[\"当前没有打开的工作目录。\"],\"K8IUjY\":[\"mode: \",[\"0\"]],\"Ks6r4a\":[\"中继配置无效:\",[\"field\"],\" 必须是字符串。\"],\"MZ28Ys\":[\"读取已打开项目失败:\",[\"0\"]],\"NX40Ku\":[\"/new [default|plan] - 创建新会话\"],\"OZZfXh\":[\"当前会话状态:\"],\"Oeb7jH\":[\"model: \",[\"0\"]],\"Qq8U1p\":[\"/status 不接受参数。\\n\\n\",[\"helpText\"]],\"RLcmLa\":[\"/status - 显示当前会话状态\"],\"Rn/EAE\":[\"你是会话标题生成器。\\n根据用户消息生成简短英文标题。\\n严格要求:\\n1. 仅输出标题文字,不要解释。\\n2. 单行输出,不要换行。\\n3. 不要使用引号。\\n4. 标题不超过 24 个字符。\"],\"T/qXZl\":[\"/new 最多接受一个可选参数:default 或 plan。\\n\\n\",[\"helpText\"]],\"Xg0HiS\":[\"/mode <default|plan> - 切换当前会话模式\"],\"YwjZm9\":[\"没有活跃会话。请发送普通消息或使用 /new 创建会话。\"],\"aQiwam\":[\"启动中继失败:\",[\"message\"]],\"dV7HiV\":[\"中继配置无效:不支持的 LOCALE \\\"\",[\"0\"],\"\\\",已回退为 en。\"],\"dXeZCW\":[\"已切换到 \",[\"0\"],\" 模式。\"],\"dZhhUX\":[\"已创建新会话。\"],\"doQwEN\":[\"Codex 执行失败:\",[\"0\"]],\"eie2Mj\":[\"命令不能为空。\\n\\n\",[\"helpText\"]],\"gHcqpZ\":[\"无效的模式 \\\"\",[\"modeToken\"],\"\\\",仅支持 default 或 plan。\\n\\n\",[\"helpText\"]],\"gf810l\":[\"/mode 需要一个参数:default 或 plan。\\n\\n\",[\"helpText\"]],\"j3bsc1\":[[\"0\"],\". \",[\"root\"]],\"ksj3fj\":[\"中继配置无效:不支持的 LOCALE \\\"\",[\"normalized\"],\"\\\",已回退为 en。\"],\"lksJXf\":[\"/projects - 显示当前工作目录\"],\"mehbut\":[\"没有活跃会话。请先发送普通消息或使用 /new 创建会话。\"],\"miupLy\":[\"当前工作目录:\"],\"o4F8ck\":[\"/reset 不接受参数。\\n\\n\",[\"helpText\"]],\"oEMyJf\":[\"请发送文本消息。\"],\"oKQkYZ\":[\"/projects 不接受参数。\\n\\n\",[\"helpText\"]],\"orhkAj\":[\"Codex 执行失败,请稍后重试。\"],\"qN3BkN\":[\"/reset - 清空当前会话\"],\"rHDhWM\":[\"解析消息失败,请发送文本消息。\"],\"rdodSw\":[\"你是会话标题生成器。\\n根据用户消息生成简短中文标题。\\n严格要求:\\n1. 仅输出标题文字,不要解释。\\n2. 单行输出,不要换行。\\n3. 不要使用引号或书名号。\\n4. 标题不超过 24 个字符。\"],\"smfLBQ\":[\"无法识别发送者,请稍后重试。\"],\"tHEFWw\":[\"title: \",[\"title\"]],\"tZQUtS\":[\"读取中继配置失败 \",[\"configPath\"],\":\",[\"0\"]],\"uAkDSp\":[\"用户消息:\",[\"0\"]],\"wBrugH\":[\"/help 不接受参数。\\n\\n\",[\"helpText\"]],\"wSNjFy\":[\"/help - 显示帮助\"],\"z+7ZYe\":[\"中继配置 \",[\"configPath\"],\" 中的 JSON 无效:\",[\"0\"]]}");
1133
+
1134
+ //#endregion
1135
+ //#region src/i18n/runtime.ts
1136
+ const DEFAULT_LOCALE = "en";
1137
+ const CATALOGS = {
1138
+ en: messages$1,
1139
+ zh: messages
1140
+ };
1141
+ let activeLocale = null;
1142
+ function initializeI18n(locale) {
1143
+ const resolved = resolveLocale(locale);
1144
+ i18n.loadAndActivate({
1145
+ locale: resolved,
1146
+ messages: CATALOGS[resolved]
1147
+ });
1148
+ activeLocale = resolved;
1149
+ return resolved;
1150
+ }
1151
+ function isSupportedLocale(locale) {
1152
+ return locale === "en" || locale === "zh";
1153
+ }
1154
+ function getDefaultLocale() {
1155
+ return DEFAULT_LOCALE;
1156
+ }
1157
+ function resolveLocale(locale) {
1158
+ if (!locale) return DEFAULT_LOCALE;
1159
+ if (isSupportedLocale(locale)) return locale;
1160
+ return DEFAULT_LOCALE;
1161
+ }
1162
+
1053
1163
  //#endregion
1054
1164
  //#region src/core/config.ts
1055
1165
  const DEFAULT_CODEX_BIN = "codex";
@@ -1088,6 +1198,7 @@ function loadRelayConfig(options = {}) {
1088
1198
  appSecret: readRequiredString(parsed.env.APP_SECRET, "APP_SECRET"),
1089
1199
  domain
1090
1200
  },
1201
+ homeDir,
1091
1202
  botOpenId: readOptionalString(parsed.env.BOT_OPEN_ID, "BOT_OPEN_ID"),
1092
1203
  codexBin: readOptionalString(parsed.env.CODEX_BIN, "CODEX_BIN") ?? DEFAULT_CODEX_BIN,
1093
1204
  codexTimeoutMs: readTimeoutMs(parsed.env.CODEX_TIMEOUT_MS),
@@ -1252,9 +1363,9 @@ function formatStartupError(error) {
1252
1363
 
1253
1364
  //#endregion
1254
1365
  //#region src/feishu/reply.ts
1255
- const FALLBACK_REPLY_TAG = "[no-thread]";
1256
- async function sendReply(larkClient, data, text) {
1257
- const content = JSON.stringify({ text: formatReplyTextWithThreadId(data, text) });
1366
+ const FALLBACK_REPLY_TAG = "no-thread";
1367
+ async function sendReply(larkClient, data, text, options) {
1368
+ const content = JSON.stringify({ text: formatReplyTextWithThreadId(data, text, options) });
1258
1369
  if (data.message.chat_type === "p2p") {
1259
1370
  await larkClient.im.v1.message.create({
1260
1371
  params: { receive_id_type: "chat_id" },
@@ -1274,7 +1385,8 @@ async function sendReply(larkClient, data, text) {
1274
1385
  }
1275
1386
  });
1276
1387
  }
1277
- function formatReplyTextWithThreadId(data, text) {
1388
+ function formatReplyTextWithThreadId(data, text, options) {
1389
+ if (!options?.includeThreadTag) return text.trim();
1278
1390
  const replyTag = resolveReplyTag(data);
1279
1391
  const normalizedText = text.trim();
1280
1392
  if (normalizedText.length === 0) return `${replyTag}\n`;
@@ -1289,13 +1401,27 @@ function resolveReplyTag(data) {
1289
1401
  userId: senderId
1290
1402
  }));
1291
1403
  if (!session || session.threadId.trim().length === 0) return FALLBACK_REPLY_TAG;
1292
- return `[${session.threadId}]`;
1404
+ return session.threadId;
1293
1405
  }
1294
1406
 
1295
1407
  //#endregion
1296
1408
  //#region src/index.ts
1297
1409
  const relayConfig = loadConfigOrExit();
1298
1410
  initializeI18n(relayConfig.locale);
1411
+ try {
1412
+ initializeSessionStore({
1413
+ homeDir: relayConfig.homeDir,
1414
+ workspaceCwd: relayConfig.workspaceCwd
1415
+ });
1416
+ } catch (error) {
1417
+ const message = error instanceof Error ? error.message : String(error);
1418
+ console.error(i18n._({
1419
+ id: "aQiwam",
1420
+ message: "Failed to start relay: {message}",
1421
+ values: { message }
1422
+ }));
1423
+ process.exit(1);
1424
+ }
1299
1425
  const BUSY_MESSAGE = i18n._({
1300
1426
  id: "H7VlDR",
1301
1427
  message: "Currently busy. Please try again later."
@@ -1328,7 +1454,7 @@ async function processIncomingEvent(data) {
1328
1454
  })
1329
1455
  });
1330
1456
  if (reply === null) return;
1331
- await sendReply(client, data, reply);
1457
+ await sendReply(client, data, reply, { includeThreadTag: shouldAttachThreadTag(data) });
1332
1458
  } catch (error) {
1333
1459
  console.error("failed to handle Feishu message", error);
1334
1460
  try {
@@ -1341,6 +1467,22 @@ async function processIncomingEvent(data) {
1341
1467
  }
1342
1468
  }
1343
1469
  }
1470
+ function shouldAttachThreadTag(data) {
1471
+ const rawText = parseEventText(data.message.content);
1472
+ if (rawText === null) return false;
1473
+ const normalizedText = stripMentionTags(rawText).trim();
1474
+ if (normalizedText.length === 0) return false;
1475
+ return parseCommand(normalizedText).type === "prompt";
1476
+ }
1477
+ function parseEventText(content) {
1478
+ try {
1479
+ const parsed = JSON.parse(content);
1480
+ if (!isPlainObject(parsed)) return null;
1481
+ return typeof parsed.text === "string" ? parsed.text : null;
1482
+ } catch {
1483
+ return null;
1484
+ }
1485
+ }
1344
1486
  const eventDispatcher = new Lark.EventDispatcher({}).register({ "im.message.receive_v1": async (data) => {
1345
1487
  console.info("feishu message received\n", JSON.stringify(data, null, 2), "\n");
1346
1488
  if (!shouldProcessMessage(data, relayConfig.botOpenId)) return;