@cecwxf/wtt 0.1.12 → 0.1.14

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/channel.js CHANGED
@@ -244,6 +244,27 @@ function isP2PTopicId(topicId) {
244
244
  const type = topicTypeCache.get(topicId.trim());
245
245
  return type === "p2p";
246
246
  }
247
+ function isDiscussionTopicMessage(raw, topicId) {
248
+ const topicType = String(raw.topic_type ?? "").trim().toLowerCase();
249
+ if (topicType === "discussion")
250
+ return true;
251
+ if (topicType === "p2p" || topicType === "broadcast" || topicType === "collaborative")
252
+ return false;
253
+ const cached = topicTypeCache.get(topicId.trim());
254
+ if (cached === "discussion")
255
+ return true;
256
+ if (cached === "p2p" || cached === "broadcast" || cached === "collaborative")
257
+ return false;
258
+ const metadata = parseInboundMetadata(raw);
259
+ const metadataType = String(metadata?.topic_type ?? metadata?.topicType ?? "").trim().toLowerCase();
260
+ if (metadataType === "discussion")
261
+ return true;
262
+ if (metadataType === "p2p" || metadataType === "broadcast" || metadataType === "collaborative")
263
+ return false;
264
+ // Heuristic fallback: most non-p2p group threads are discussion-like and
265
+ // should keep a local topic-memory file for retrieval.
266
+ return Boolean(topicId && !isLikelyP2PMessage(raw));
267
+ }
247
268
  function isP2PE2EEnabled(account) {
248
269
  const raw = account.config.p2pE2EEnabled;
249
270
  return raw === undefined ? DEFAULT_P2P_E2E_ENABLED : raw !== false;
@@ -1156,9 +1177,9 @@ function compactDiscussionContent(raw) {
1156
1177
  * Read and parse the local topic memory file into DiscussionHistoryMessage[].
1157
1178
  * Returns empty array if the file doesn't exist or is malformed.
1158
1179
  *
1159
- * File format (one entry = 2 lines):
1160
- * - [timestamp] senderType:senderId(displayName) id=xxx [reply_to=xxx]
1161
- * compact content text
1180
+ * Compatible formats:
1181
+ * - Legacy 2-line entries
1182
+ * - Extended multi-line entries with text/media/reply_excerpt blocks
1162
1183
  */
1163
1184
  async function readLocalTopicMemory(params) {
1164
1185
  if (!params.topicId)
@@ -1173,25 +1194,71 @@ async function readLocalTopicMemory(params) {
1173
1194
  }
1174
1195
  const messages = [];
1175
1196
  const lines = raw.split("\n");
1176
- // Pattern: - [timestamp] type:senderId(displayName) id=xxx [reply_to=xxx]
1177
- const entryRe = /^- \[([^\]]*)\]\s+([\w]+):(\S+?)(?:\(([^)]*)\))?\s+id=(\S+?)(?:\s+reply_to=(\S+))?$/;
1178
- for (let i = 0; i < lines.length; i++) {
1197
+ // Pattern: - [timestamp] type:senderId(displayName) id=xxx [reply_to=xxx] [...]
1198
+ const entryRe = /^- \[([^\]]*)\]\s+([\w]+):(\S+?)(?:\(([^)]*)\))?\s+id=(\S+?)(?:\s+reply_to=(\S+))?(?:\s+.*)?$/;
1199
+ let i = 0;
1200
+ while (i < lines.length) {
1179
1201
  const match = lines[i].match(entryRe);
1180
- if (!match)
1202
+ if (!match) {
1203
+ i += 1;
1181
1204
  continue;
1205
+ }
1182
1206
  const [, createdAt, senderType, senderId, displayName, id, replyTo] = match;
1183
- // Next line (indented) is the content
1184
- const contentLine = (i + 1 < lines.length && lines[i + 1].startsWith(" "))
1185
- ? lines[i + 1].slice(2).trim()
1186
- : "";
1207
+ i += 1;
1208
+ const bodyLines = [];
1209
+ while (i < lines.length && !entryRe.test(lines[i])) {
1210
+ if (lines[i].startsWith(" "))
1211
+ bodyLines.push(lines[i].slice(2));
1212
+ i += 1;
1213
+ }
1214
+ let content = "";
1215
+ const mediaPaths = [];
1216
+ const mediaUrls = [];
1217
+ let replyExcerpt;
1218
+ for (const row of bodyLines) {
1219
+ const line = row.trim();
1220
+ if (!line)
1221
+ continue;
1222
+ if (line.startsWith("text:")) {
1223
+ content = line.slice("text:".length).trim();
1224
+ continue;
1225
+ }
1226
+ if (line.startsWith("media_paths:")) {
1227
+ const payload = line.slice("media_paths:".length).trim();
1228
+ for (const part of payload.split("|").map((item) => item.trim()).filter(Boolean)) {
1229
+ mediaPaths.push(part);
1230
+ }
1231
+ continue;
1232
+ }
1233
+ if (line.startsWith("media_urls:")) {
1234
+ const payload = line.slice("media_urls:".length).trim();
1235
+ for (const part of payload.split("|").map((item) => item.trim()).filter(Boolean)) {
1236
+ mediaUrls.push(part);
1237
+ }
1238
+ continue;
1239
+ }
1240
+ if (line.startsWith("reply_excerpt:")) {
1241
+ replyExcerpt = line.slice("reply_excerpt:".length).trim() || undefined;
1242
+ continue;
1243
+ }
1244
+ if (!content) {
1245
+ content = line;
1246
+ }
1247
+ else {
1248
+ content += ` ${line}`;
1249
+ }
1250
+ }
1187
1251
  messages.push({
1188
1252
  id,
1189
1253
  senderId,
1190
1254
  senderDisplayName: displayName || undefined,
1191
1255
  senderType: senderType || undefined,
1192
- content: contentLine,
1256
+ content: content.trim(),
1193
1257
  createdAt: createdAt || undefined,
1194
1258
  replyTo: replyTo || undefined,
1259
+ mediaPaths,
1260
+ mediaUrls,
1261
+ replyExcerpt,
1195
1262
  });
1196
1263
  }
1197
1264
  return messages;
@@ -1257,7 +1324,9 @@ async function persistDiscussionTopicMemory(params) {
1257
1324
  const path = joinPath(dir, `topic_id_${params.topicId}.md`);
1258
1325
  const lines = [];
1259
1326
  lines.push(`# topic_id_${params.topicId}`);
1260
- lines.push("");
1327
+ if (params.topicName?.trim()) {
1328
+ lines.push(`topic_name: ${params.topicName.trim()}`);
1329
+ }
1261
1330
  lines.push(`updated_at: ${new Date().toISOString()}`);
1262
1331
  lines.push("");
1263
1332
  for (const msg of params.messages) {
@@ -1265,9 +1334,17 @@ async function persistDiscussionTopicMemory(params) {
1265
1334
  const nameTag = msg.senderDisplayName ? `(${msg.senderDisplayName})` : "";
1266
1335
  const who = `${msg.senderType || "unknown"}:${msg.senderId}${nameTag}`;
1267
1336
  const content = compactDiscussionContent(msg.content).replace(/\n/g, " ").trim();
1268
- lines.push(`- [${ts}] ${who} id=${msg.id}${msg.replyTo ? ` reply_to=${msg.replyTo}` : ""}`);
1269
- if (content) {
1270
- lines.push(` ${content}`);
1337
+ const mediaCount = (msg.mediaPaths?.length ?? 0) + (msg.mediaUrls?.length ?? 0);
1338
+ lines.push(`- [${ts}] ${who} id=${msg.id}${msg.replyTo ? ` reply_to=${msg.replyTo}` : ""}${mediaCount > 0 ? ` media_count=${mediaCount}` : ""}`);
1339
+ lines.push(` text: ${content}`);
1340
+ if (msg.mediaPaths && msg.mediaPaths.length > 0) {
1341
+ lines.push(` media_paths: ${msg.mediaPaths.join(" | ")}`);
1342
+ }
1343
+ if (msg.mediaUrls && msg.mediaUrls.length > 0) {
1344
+ lines.push(` media_urls: ${msg.mediaUrls.join(" | ")}`);
1345
+ }
1346
+ if (msg.replyExcerpt) {
1347
+ lines.push(` reply_excerpt: ${msg.replyExcerpt}`);
1271
1348
  }
1272
1349
  }
1273
1350
  await writeFile(path, `${lines.join("\n")}\n`, "utf8");
@@ -1299,22 +1376,45 @@ async function appendDiscussionTopicMemory(params) {
1299
1376
  const idMarker = `id=${params.msg.id}`;
1300
1377
  if (existing.includes(idMarker))
1301
1378
  return;
1379
+ const history = existing ? await readLocalTopicMemory({ topicId: params.topicId }) : [];
1380
+ const replyTarget = params.msg.replyTo
1381
+ ? history.find((item) => item.id === params.msg.replyTo)
1382
+ : undefined;
1383
+ const replyExcerpt = replyTarget
1384
+ ? compactDiscussionContent(replyTarget.content).slice(0, 220)
1385
+ : undefined;
1302
1386
  const ts = params.msg.createdAt ?? new Date().toISOString();
1303
1387
  const nameTag = params.msg.senderDisplayName ? `(${params.msg.senderDisplayName})` : "";
1304
1388
  const who = `${params.msg.senderType || "unknown"}:${params.msg.senderId}${nameTag}`;
1305
1389
  const content = compactDiscussionContent(params.msg.content).replace(/\n/g, " ").trim();
1306
- let line = `- [${ts}] ${who} ${idMarker}${params.msg.replyTo ? ` reply_to=${params.msg.replyTo}` : ""}`;
1307
- if (content) {
1308
- line += `\n ${content}`;
1390
+ const mediaPaths = Array.from(new Set((params.msg.mediaPaths ?? []).map((v) => String(v || "").trim()).filter(Boolean)));
1391
+ const mediaUrls = Array.from(new Set((params.msg.mediaUrls ?? []).map((v) => String(v || "").trim()).filter(Boolean)));
1392
+ const mediaCount = mediaPaths.length + mediaUrls.length;
1393
+ const lines = [];
1394
+ lines.push(`- [${ts}] ${who} ${idMarker}${params.msg.replyTo ? ` reply_to=${params.msg.replyTo}` : ""}${mediaCount > 0 ? ` media_count=${mediaCount}` : ""}`);
1395
+ lines.push(` text: ${content}`);
1396
+ if (mediaPaths.length > 0) {
1397
+ lines.push(` media_paths: ${mediaPaths.join(" | ")}`);
1398
+ }
1399
+ if (mediaUrls.length > 0) {
1400
+ lines.push(` media_urls: ${mediaUrls.join(" | ")}`);
1401
+ }
1402
+ if (replyExcerpt) {
1403
+ lines.push(` reply_excerpt: ${replyExcerpt}`);
1309
1404
  }
1310
1405
  if (!existing) {
1311
1406
  // Create file with header
1312
- const header = `# topic_id_${params.topicId}\n\nupdated_at: ${new Date().toISOString()}\n\n`;
1313
- await writeFile(path, header + line + "\n", "utf8");
1407
+ const headerLines = [`# topic_id_${params.topicId}`];
1408
+ if (params.topicName?.trim()) {
1409
+ headerLines.push(`topic_name: ${params.topicName.trim()}`);
1410
+ }
1411
+ headerLines.push(`updated_at: ${new Date().toISOString()}`);
1412
+ headerLines.push("");
1413
+ await writeFile(path, `${headerLines.join("\n")}\n${lines.join("\n")}\n`, "utf8");
1314
1414
  }
1315
1415
  else {
1316
1416
  // Append to existing file
1317
- await writeFile(path, existing.trimEnd() + "\n" + line + "\n", "utf8");
1417
+ await writeFile(path, `${existing.trimEnd()}\n${lines.join("\n")}\n`, "utf8");
1318
1418
  }
1319
1419
  }
1320
1420
  catch (err) {
@@ -1336,15 +1436,26 @@ function buildDiscussionContextBlock(params) {
1336
1436
  const targetName = target.senderDisplayName ? `(${target.senderDisplayName})` : "";
1337
1437
  lines.push(`[被回复消息] id=${target.id} sender=${target.senderId}${targetName}`);
1338
1438
  lines.push(compactDiscussionContent(target.content).slice(0, 1200));
1439
+ if (target.mediaPaths && target.mediaPaths.length > 0) {
1440
+ lines.push(`media_paths: ${target.mediaPaths.join(" | ")}`);
1441
+ }
1442
+ if (target.mediaUrls && target.mediaUrls.length > 0) {
1443
+ lines.push(`media_urls: ${target.mediaUrls.join(" | ")}`);
1444
+ }
1339
1445
  lines.push("");
1340
1446
  }
1341
1447
  }
1342
1448
  for (const msg of focus) {
1343
1449
  const compact = compactDiscussionContent(msg.content);
1344
- if (!compact)
1450
+ const hasMedia = Boolean((msg.mediaPaths && msg.mediaPaths.length > 0) || (msg.mediaUrls && msg.mediaUrls.length > 0));
1451
+ if (!compact && !hasMedia)
1345
1452
  continue;
1346
1453
  const nameTag = msg.senderDisplayName ? `(${msg.senderDisplayName})` : "";
1347
- lines.push(`- id=${msg.id} sender=${msg.senderId}${nameTag}${msg.replyTo ? ` reply_to=${msg.replyTo}` : ""}: ${compact}`);
1454
+ const mediaTag = (msg.mediaPaths && msg.mediaPaths.length > 0)
1455
+ ? ` media_paths=${msg.mediaPaths.join("|")}`
1456
+ : ((msg.mediaUrls && msg.mediaUrls.length > 0) ? ` media_urls=${msg.mediaUrls.join("|")}` : "");
1457
+ const replyExcerptTag = msg.replyExcerpt ? ` reply_excerpt=${msg.replyExcerpt}` : "";
1458
+ lines.push(`- id=${msg.id} sender=${msg.senderId}${nameTag}${msg.replyTo ? ` reply_to=${msg.replyTo}` : ""}${mediaTag}${replyExcerptTag}: ${compact || "[media_message]"}`);
1348
1459
  }
1349
1460
  let block = lines.join("\n").trim();
1350
1461
  if (block.length > params.maxChars) {
@@ -1886,11 +1997,13 @@ export async function routeInboundWsMessage(params) {
1886
1997
  }
1887
1998
  const typingTopicId = normalized.topicId?.trim() || "";
1888
1999
  const mentionMatch = resolveMentionMatch(String(rawMsg.content ?? ""), params.account.agentId, params.account.name);
2000
+ const originalMediaUrls = normalized.mediaUrls.slice();
2001
+ const originalMediaTypes = normalized.mediaTypes.slice();
1889
2002
  // Remember recent media from this sender/topic even when the message itself
1890
2003
  // does not trigger inference (for example: image first, @mention second).
1891
- if (typingTopicId && normalized.mediaUrls.length > 0) {
1892
- rememberRecentTopicMedia(typingTopicId, normalized.senderId, normalized.mediaUrls, normalized.mediaTypes);
1893
- params.log?.("info", `[${params.accountId}] inbound media captured topic=${typingTopicId} sender=${normalized.senderId} count=${normalized.mediaUrls.length}`);
2004
+ if (typingTopicId && originalMediaUrls.length > 0) {
2005
+ rememberRecentTopicMedia(typingTopicId, normalized.senderId, originalMediaUrls, originalMediaTypes);
2006
+ params.log?.("info", `[${params.accountId}] inbound media captured topic=${typingTopicId} sender=${normalized.senderId} count=${originalMediaUrls.length}`);
1894
2007
  }
1895
2008
  // If current @mention message carries no media, try to reuse sender's latest
1896
2009
  // media in the same topic so "image + @mention" split messages still work.
@@ -1915,24 +2028,6 @@ export async function routeInboundWsMessage(params) {
1915
2028
  params.log?.("info", `[${params.accountId}] inbound media cache_miss topic=${typingTopicId} sender=${normalized.senderId} raw_keys=${rawKeys} metadata_keys=${metadataKeys} content_preview=${contentPreview}`);
1916
2029
  }
1917
2030
  }
1918
- // Incrementally record every discussion message to the topic memory file.
1919
- // This runs BEFORE inference gating, so even messages that don't trigger
1920
- // inference (no @mention) are persisted for later context retrieval.
1921
- if (inferredTopicType === "discussion" && typingTopicId && normalized.text.trim()) {
1922
- appendDiscussionTopicMemory({
1923
- topicId: typingTopicId,
1924
- msg: {
1925
- id: normalized.messageId,
1926
- senderId: normalized.senderId,
1927
- senderDisplayName: normalized.senderName ?? toOptionalString(rawMsg.sender_display_name),
1928
- senderType: String(rawMsg.sender_type ?? "unknown"),
1929
- content: normalized.text,
1930
- createdAt: normalized.timestamp,
1931
- replyTo: toOptionalString(rawMsg.reply_to),
1932
- },
1933
- log: params.log,
1934
- }).catch(() => { });
1935
- }
1936
2031
  const emitTypingSignal = async (state) => {
1937
2032
  if (!typingTopicId || !params.typingSignal)
1938
2033
  return;
@@ -1995,12 +2090,43 @@ export async function routeInboundWsMessage(params) {
1995
2090
  const slashBypassMentionGate = params.account.config.slashBypassMentionGate ?? DEFAULT_SLASH_BYPASS_MENTION_GATE;
1996
2091
  const topicType = String(rawMsg.topic_type ?? "").toLowerCase();
1997
2092
  const topicName = String(rawMsg.topic_name ?? "");
2093
+ const isDiscussionTopic = isDiscussionTopicMessage(rawMsg, typingTopicId);
1998
2094
  const isTaskLinkedTopic = Boolean(inboundTaskId || topicName.startsWith("TASK-"));
1999
2095
  const isSlashLike = /^\/\S+/.test((normalized.text || "").trim());
2000
2096
  const inboundMetadata = parseInboundMetadata(rawMsg);
2001
- const mentionTargetedDiscussion = topicType === "discussion" && mentionMatch.matchesAgent;
2097
+ const mentionTargetedDiscussion = isDiscussionTopic && mentionMatch.matchesAgent;
2098
+ // For discussion topics, keep local topic memory complete even when inference
2099
+ // is not triggered. Download image media and index local paths/urls.
2100
+ let indexedTopicMedia = { mediaPaths: [], mediaTypes: [] };
2101
+ if (isDiscussionTopic && typingTopicId && (normalized.text.trim() || originalMediaUrls.length > 0)) {
2102
+ if (originalMediaUrls.length > 0) {
2103
+ indexedTopicMedia = await materializeInboundMediaForContext({
2104
+ mediaUrls: originalMediaUrls,
2105
+ mediaTypes: originalMediaTypes,
2106
+ account: params.account,
2107
+ accountId: params.accountId,
2108
+ log: params.log,
2109
+ });
2110
+ }
2111
+ await appendDiscussionTopicMemory({
2112
+ topicId: typingTopicId,
2113
+ topicName: normalized.topicName ?? topicName,
2114
+ msg: {
2115
+ id: normalized.messageId,
2116
+ senderId: normalized.senderId,
2117
+ senderDisplayName: normalized.senderName ?? toOptionalString(rawMsg.sender_display_name),
2118
+ senderType: String(rawMsg.sender_type ?? "unknown"),
2119
+ content: normalized.text,
2120
+ createdAt: normalized.timestamp,
2121
+ replyTo: toOptionalString(rawMsg.reply_to),
2122
+ mediaPaths: indexedTopicMedia.mediaPaths,
2123
+ mediaUrls: originalMediaUrls,
2124
+ },
2125
+ log: params.log,
2126
+ });
2127
+ }
2002
2128
  if (slashCompatEnabled) {
2003
- if (topicType === "discussion" && !isTaskLinkedTopic && isSlashLike) {
2129
+ if (isDiscussionTopic && !isTaskLinkedTopic && isSlashLike) {
2004
2130
  const targetAgentId = toOptionalString(inboundMetadata?.command_target_agent_id)
2005
2131
  ?? toOptionalString(inboundMetadata?.commandTargetAgentId);
2006
2132
  // For non-task discuss slash commands: execute only on explicitly targeted agent.
@@ -2054,13 +2180,16 @@ export async function routeInboundWsMessage(params) {
2054
2180
  }
2055
2181
  // Telegram-style media handling: download inbound media first, then pass
2056
2182
  // local file paths into MediaPath/MediaPaths for stable vision pipeline input.
2057
- const downloadedInboundMedia = await materializeInboundMediaForContext({
2058
- mediaUrls: normalized.mediaUrls,
2059
- mediaTypes: normalized.mediaTypes,
2060
- account: params.account,
2061
- accountId: params.accountId,
2062
- log: params.log,
2063
- });
2183
+ // Reuse files already downloaded for topic-memory indexing when available.
2184
+ const downloadedInboundMedia = indexedTopicMedia.mediaPaths.length > 0
2185
+ ? indexedTopicMedia
2186
+ : await materializeInboundMediaForContext({
2187
+ mediaUrls: normalized.mediaUrls,
2188
+ mediaTypes: normalized.mediaTypes,
2189
+ account: params.account,
2190
+ accountId: params.accountId,
2191
+ log: params.log,
2192
+ });
2064
2193
  const contextMediaPaths = downloadedInboundMedia.mediaPaths.length > 0
2065
2194
  ? downloadedInboundMedia.mediaPaths
2066
2195
  : normalized.mediaUrls;
@@ -2099,12 +2228,12 @@ export async function routeInboundWsMessage(params) {
2099
2228
  ?? toOptionalString(rawMsg.taskTitle)
2100
2229
  ?? toOptionalString(rawMsg.title)
2101
2230
  ?? (normalized.topicName && !normalized.topicName.startsWith("TASK-") ? normalized.topicName : undefined);
2102
- const mentionDirective = (topicType === "discussion" && mentionTargetedDiscussion)
2231
+ const mentionDirective = (isDiscussionTopic && mentionTargetedDiscussion)
2103
2232
  ? "注意:这是讨论话题中明确 @ 你的消息,必须直接回应用户问题,禁止输出 NO_REPLY。\n\n"
2104
2233
  : "";
2105
2234
  const replyToId = toOptionalString(rawMsg.reply_to);
2106
2235
  let discussionContextBlock = "";
2107
- if (topicType === "discussion" && normalized.topicId && (mentionTargetedDiscussion || Boolean(replyToId))) {
2236
+ if (isDiscussionTopic && normalized.topicId && (mentionTargetedDiscussion || Boolean(replyToId))) {
2108
2237
  const contextWindow = toPositiveInt(params.account.config.discussionContextWindow, DEFAULT_DISCUSSION_CONTEXT_WINDOW);
2109
2238
  const contextMaxChars = toPositiveInt(params.account.config.discussionContextMaxChars, DEFAULT_DISCUSSION_CONTEXT_MAX_CHARS);
2110
2239
  // Local-first: read from local memory file (kept up-to-date by incremental append).
@@ -2126,6 +2255,7 @@ export async function routeInboundWsMessage(params) {
2126
2255
  if (discussionMessages.length > 0) {
2127
2256
  await persistDiscussionTopicMemory({
2128
2257
  topicId: normalized.topicId,
2258
+ topicName: normalized.topicName,
2129
2259
  messages: discussionMessages,
2130
2260
  log: params.log,
2131
2261
  });
@@ -2281,7 +2411,7 @@ export async function routeInboundWsMessage(params) {
2281
2411
  finally {
2282
2412
  await emitTypingSignal("stop");
2283
2413
  }
2284
- const shouldForceMentionAck = topicType === "discussion"
2414
+ const shouldForceMentionAck = isDiscussionTopic
2285
2415
  && Boolean(inferDecision?.trigger)
2286
2416
  && (mentionTargetedDiscussion || inferDecision?.reason === "discussion_runner_match");
2287
2417
  if (!dispatchProducedOutput && shouldForceMentionAck) {