@rikaidev/yomi 0.5.0 → 0.6.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.
Files changed (63) hide show
  1. package/README.md +28 -0
  2. package/dist/ambient/ambient-event-graph.js +172 -0
  3. package/dist/ambient/ambient-observer.js +71 -0
  4. package/dist/ambient/event-graph-reducer.js +300 -0
  5. package/dist/ambient/ingestion-adapter.js +203 -0
  6. package/dist/ambient/journal/crypto.js +58 -0
  7. package/dist/ambient/journal/journal-store.js +284 -0
  8. package/dist/ambient/journal/types.js +7 -0
  9. package/dist/ambient/summaries/retriever.js +55 -0
  10. package/dist/ambient/summaries/summary-engine.js +128 -0
  11. package/dist/ambient/summaries/types.js +4 -0
  12. package/dist/attention/policy.js +22 -16
  13. package/dist/auth/credential-store.js +25 -12
  14. package/dist/cli/query.js +276 -69
  15. package/dist/cli/work-item.js +24 -0
  16. package/dist/line/client/auth-service/client.js +7 -8
  17. package/dist/line/client/buddy-service/client.js +73 -0
  18. package/dist/line/client/index.js +28 -1
  19. package/dist/line/client/obs-media-client.js +2 -1
  20. package/dist/line/client/square-service/client.js +298 -0
  21. package/dist/line/client/talk-service/command-client.js +9 -1
  22. package/dist/line/client/talk-service/message-id-query.js +27 -3
  23. package/dist/line/client/talk-service/requests.js +9 -0
  24. package/dist/line/core/auth-session-runtime.js +1 -1
  25. package/dist/line/core/chat-event.js +2 -0
  26. package/dist/line/core/chat-runtime-service.js +18 -3
  27. package/dist/line/core/config.js +1 -0
  28. package/dist/line/core/flex-message.js +323 -0
  29. package/dist/line/core/service.js +0 -1
  30. package/dist/mcp/handlers/contacts.js +12 -6
  31. package/dist/mcp/handlers/conversations.js +54 -0
  32. package/dist/mcp/handlers/send.js +8 -11
  33. package/dist/mcp/integrator.js +21 -7
  34. package/dist/mcp/media.js +101 -2
  35. package/dist/mcp/names.js +78 -6
  36. package/dist/search/default-embedder.js +12 -3
  37. package/dist/version.js +1 -1
  38. package/dist/work-item/adapter.js +1 -0
  39. package/dist/work-item/destination-contracts.js +1 -0
  40. package/dist/work-item/event-context.js +73 -0
  41. package/dist/work-item/gold-contract.js +101 -0
  42. package/dist/work-item/gold-coverage.js +96 -0
  43. package/dist/work-item/gold-derivation.js +67 -0
  44. package/dist/work-item/gold-split.js +143 -0
  45. package/dist/work-item/index.js +23 -0
  46. package/dist/work-item/line-adapter.js +111 -0
  47. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  48. package/dist/work-item/local-provider.js +38 -0
  49. package/dist/work-item/local-work-event-provider.js +56 -0
  50. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  51. package/dist/work-item/provider.js +21 -0
  52. package/dist/work-item/registry.js +19 -0
  53. package/dist/work-item/service.js +85 -0
  54. package/dist/work-item/store.js +97 -0
  55. package/dist/work-item/types.js +79 -0
  56. package/dist/work-item/work-event-graph-engine.js +63 -0
  57. package/dist/work-item/work-event-inference.js +267 -0
  58. package/dist/work-item/work-event-matcher.js +105 -0
  59. package/dist/work-item/work-event-model-manifest.js +39 -0
  60. package/dist/work-item/work-event-store.js +125 -0
  61. package/dist/work-item/work-event.js +123 -0
  62. package/package.json +25 -4
  63. package/run.mjs +186 -9
package/dist/cli/query.js CHANGED
@@ -3,9 +3,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { scoreAttentionBatch } from '../attention/policy.js';
6
+ import { isSquareChatMid } from '../line/client/square-service/client.js';
6
7
  import { fetchStickerImage, fetchStickerPackageMetaCached, localizedStickerTitle, } from '../line/client/sticker-meta.js';
7
8
  import { chatEventParticipantMids, interpretChatEvent, } from '../line/core/chat-event.js';
8
- import { interpretFlexMessage } from '../line/core/flex-message.js';
9
+ import { interpretFlexMessage, processFlexMessage, } from '../line/core/flex-message.js';
9
10
  import { sanitizeMessagePreview } from '../line/core/message-preview.js';
10
11
  import { decryptLineMessage } from '../line/core/message-query-service.js';
11
12
  import { LineProtocolService } from '../line/core/service.js';
@@ -14,8 +15,8 @@ import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
14
15
  const attentionCachePath = join(process.env.YOMI_DATA_DIR || join(homedir(), '.yomi'), 'attention-cache.json');
15
16
  const ATTENTION_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
16
17
  const ATTENTION_CACHE_MAX_ENTRIES = 1000;
17
- const ATTENTION_BATCH_SIZE = 8;
18
- const ATTENTION_POLICY_VERSION = 2;
18
+ const ATTENTION_BATCH_SIZE = 32;
19
+ const ATTENTION_POLICY_VERSION = 5;
19
20
  function attentionSignature(item) {
20
21
  return createHash('sha256')
21
22
  .update(JSON.stringify([
@@ -62,7 +63,7 @@ export async function cliClassifyAttentionJson(items) {
62
63
  cache[attentionSignature(item)] = { ...result, updatedAt: Date.now() };
63
64
  return {
64
65
  id: item.id,
65
- attentionScore: result.score + (item.unreadCount > 0 ? 5 : 0),
66
+ attentionScore: result.score,
66
67
  attentionTier: result.tier,
67
68
  attentionReason: result.reason,
68
69
  };
@@ -171,6 +172,7 @@ export function richFields(message) {
171
172
  ? message.contentMetadata
172
173
  : {};
173
174
  const interpreted = interpretFlexMessage(message);
175
+ const flexDocument = processFlexMessage(message);
174
176
  const explicitAlt = sanitizeMessagePreview(metadata.ALT_TEXT) || '';
175
177
  const altText = interpreted.summary || explicitAlt;
176
178
  const mediaUrl = safeWebUrl(metadata.DOWNLOAD_URL) || interpreted.imageUrl;
@@ -188,68 +190,99 @@ export function richFields(message) {
188
190
  altText,
189
191
  mediaUrl,
190
192
  actionUrl: redirect || interpreted.actionUrl || firstWebLink(markup),
193
+ flexDocument,
191
194
  };
192
195
  }
193
196
  export { sanitizeMessagePreview } from '../line/core/message-preview.js';
194
197
  export function messagePlaceholder(contentType, hasOpaquePayload = false) {
195
198
  switch (contentType) {
196
199
  case 0:
197
- return hasOpaquePayload ? '[加密訊息]' : '[訊息]';
200
+ return hasOpaquePayload ? '[yomi:encrypted]' : '[yomi:message]';
198
201
  case 7:
199
- return '[貼圖]';
202
+ return '[yomi:sticker]';
200
203
  case 4:
201
- return '[網頁內容]';
204
+ return '[yomi:web]';
202
205
  case 5:
203
- return '[PDF 文件]';
206
+ return '[yomi:pdf]';
204
207
  case 6:
205
- return '[通話紀錄]';
208
+ return '[yomi:call]';
206
209
  case 8:
207
- return '[動態狀態]';
210
+ return '[yomi:status]';
208
211
  case 9:
209
- return '[禮物]';
212
+ return '[yomi:gift]';
210
213
  case 10:
211
- return '[群組記事本]';
214
+ return '[yomi:note]';
212
215
  case 11:
213
- return '[應用程式連結]';
216
+ return '[yomi:app]';
214
217
  case 12:
215
- return '[分享連結]';
218
+ return '[yomi:share]';
216
219
  case 13:
217
- return '[聯絡人]';
220
+ return '[yomi:contact]';
218
221
  case 15:
219
- return '[位置資訊]';
222
+ return '[yomi:location]';
220
223
  case 16:
221
- return '[貼文通知]';
224
+ return '[yomi:post]';
222
225
  case 17:
223
- return '[互動卡片]';
226
+ return '[yomi:rich]';
224
227
  case 18:
225
- return '[聊天室活動]';
228
+ return '[yomi:chat_event]';
226
229
  case 19:
227
- return '[音樂]';
230
+ return '[yomi:music]';
228
231
  case 20:
229
- return '[付款資訊]';
232
+ return '[yomi:payment]';
230
233
  case 21:
231
- return '[延伸圖片]';
234
+ return '[yomi:extended_image]';
232
235
  case 22:
233
- return '[互動卡片]';
236
+ return '[yomi:rich]';
234
237
  case 1:
235
- return '[圖片]';
238
+ return '[yomi:image]';
236
239
  case 2:
237
- return '[影片]';
240
+ return '[yomi:video]';
238
241
  case 3:
239
- return '[語音訊息]';
242
+ return '[yomi:audio]';
240
243
  case 14:
241
- return '[檔案]';
244
+ return '[yomi:file]';
242
245
  default:
243
- return hasOpaquePayload ? '[加密訊息]' : `[LINE 內容 ${contentType}]`;
246
+ return hasOpaquePayload
247
+ ? '[yomi:encrypted]'
248
+ : `[yomi:line_content:${contentType}]`;
244
249
  }
245
250
  }
246
251
  export function messageFallback(message) {
247
252
  if (message?.contentMetadata?.UNSENT === 'true')
248
- return '訊息已收回';
253
+ return '[yomi:unsent]';
254
+ const altText = messageAltText(message?.contentMetadata);
255
+ if (altText)
256
+ return altText;
249
257
  return message?.e2eeDecryptFailure
250
- ? '[無法解密的訊息]'
258
+ ? '[yomi:decrypt_failed]'
251
259
  : messagePlaceholder(Number(message?.contentType || 0), Boolean(message?.text));
252
260
  }
261
+ /** Convert LINE's private CALL transport string into a stable UI token. */
262
+ export function callHistoryPreview(value, isSelf) {
263
+ if (typeof value !== 'string')
264
+ return '[yomi:call]';
265
+ const match = /^Call History\s*:\s*(\d+)\s+millisecs,\s*Result:\s*(\d+)$/i.exec(value.trim());
266
+ if (!match)
267
+ return '[yomi:call]';
268
+ const milliseconds = Number(match[1]);
269
+ const result = Number(match[2]);
270
+ if (result === 16 && milliseconds > 0) {
271
+ return `[yomi:call_duration:${Math.floor(milliseconds / 1000)}]`;
272
+ }
273
+ if (result === 77) {
274
+ return isSelf ? '[yomi:call_no_answer]' : '[yomi:call_missed]';
275
+ }
276
+ return '[yomi:call]';
277
+ }
278
+ function messageAltText(metadata) {
279
+ for (const key of ['ALT_TEXT', 'altText', 'ALT']) {
280
+ const value = sanitizeMessagePreview(metadata?.[key]);
281
+ if (value)
282
+ return value;
283
+ }
284
+ return '';
285
+ }
253
286
  function parseMentionRanges(metadata) {
254
287
  if (typeof metadata.MENTION !== 'string')
255
288
  return [];
@@ -269,6 +302,33 @@ function parseMentionRanges(metadata) {
269
302
  return [];
270
303
  }
271
304
  }
305
+ export function parseEmojiRanges(metadata) {
306
+ if (typeof metadata.REPLACE !== 'string')
307
+ return [];
308
+ try {
309
+ const parsed = JSON.parse(metadata.REPLACE);
310
+ return (parsed.sticon?.resources || []).flatMap((resource) => {
311
+ const location = Number(resource.S);
312
+ const end = Number(resource.E);
313
+ if (!Number.isInteger(location) ||
314
+ !Number.isInteger(end) ||
315
+ end <= location ||
316
+ !resource.productId ||
317
+ !resource.sticonId)
318
+ return [];
319
+ return [
320
+ {
321
+ location,
322
+ length: end - location,
323
+ url: `https://stickershop.line-scdn.net/sticonshop/v1/sticon/${resource.productId}/android/${resource.sticonId}.png`,
324
+ },
325
+ ];
326
+ });
327
+ }
328
+ catch {
329
+ return [];
330
+ }
331
+ }
272
332
  function isRichContentType(contentType) {
273
333
  return contentType === 17 || contentType === 22;
274
334
  }
@@ -308,10 +368,27 @@ function latestMessage(messages) {
308
368
  ? candidate
309
369
  : latest);
310
370
  }
311
- export async function cliListConversationsJson(args = {}) {
312
- const service = new LineProtocolService();
313
- const ok = await service.resumeSession();
314
- if (!ok)
371
+ function squareMessageContent(message) {
372
+ const contentType = Number(message?.contentType ?? message?.[15] ?? 0);
373
+ const metadata = message?.contentMetadata ?? message?.[18];
374
+ const text = messageAltText(metadata) ||
375
+ sanitizeMessagePreview(message?.text ?? message?.[10]) ||
376
+ null;
377
+ return { contentType, metadata, text };
378
+ }
379
+ export function isRenderableSquareMessage(message) {
380
+ const content = squareMessageContent(message);
381
+ return content.contentType !== 0 || content.text !== null;
382
+ }
383
+ /** Normalize provider-specific message windows to the newest chronological page. */
384
+ export function newestMessageWindow(messages, count) {
385
+ return [...messages]
386
+ .sort((a, b) => messageTimestamp(a) - messageTimestamp(b))
387
+ .slice(-Math.max(0, count));
388
+ }
389
+ export async function cliListConversationsJson(args = {}, activeService) {
390
+ const service = activeService ?? new LineProtocolService();
391
+ if (!activeService && !(await service.resumeSession()))
315
392
  return [];
316
393
  try {
317
394
  // Advance the Talk revision before reading message boxes. A fresh client
@@ -325,17 +402,11 @@ export async function cliListConversationsJson(args = {}) {
325
402
  }
326
403
  }
327
404
  catch { }
328
- const result = args.firstPage
329
- ? await service.client.getMessageBoxes({
330
- lastMessagesPerMessageBoxCount: 1,
331
- messageBoxCountLimit: args.limit ?? 100,
332
- withUnreadCount: true,
333
- })
334
- : await service.client.getAllMessageBoxes({
335
- lastMessagesPerMessageBoxCount: 1,
336
- ...(args.limit ? { messageBoxCountLimit: args.limit } : {}),
337
- withUnreadCount: true,
338
- });
405
+ const result = await service.client.getAllMessageBoxes({
406
+ lastMessagesPerMessageBoxCount: 1,
407
+ ...(args.limit ? { messageBoxCountLimit: args.limit } : {}),
408
+ withUnreadCount: true,
409
+ });
339
410
  const boxes = result.messageBoxes || [];
340
411
  const chatIds = boxes.map((box) => box.id).filter(Boolean);
341
412
  const names = await resolveConversationNames(service, chatIds);
@@ -370,14 +441,18 @@ export async function cliListConversationsJson(args = {}) {
370
441
  }
371
442
  catch { }
372
443
  }
373
- let preview = '(無訊息)';
444
+ let preview = '[yomi:no_message]';
374
445
  let timestamp = 0;
375
446
  if (lastMsg) {
376
447
  timestamp = messageTimestamp(lastMsg);
377
448
  const rich = isRichContentType(Number(lastMsg.contentType))
378
449
  ? richFields(lastMsg)
379
450
  : null;
380
- const rawPreview = rich?.altText || sanitizeMessagePreview(lastMsg.text);
451
+ const rawPreview = Number(lastMsg.contentType) === 6
452
+ ? callHistoryPreview(lastMsg.text, lastMsg.from === service.profile?.mid)
453
+ : rich?.altText ||
454
+ messageAltText(lastMsg.contentMetadata) ||
455
+ sanitizeMessagePreview(lastMsg.text);
381
456
  if (rawPreview) {
382
457
  preview = rawPreview;
383
458
  }
@@ -424,36 +499,96 @@ export async function cliListConversationsJson(args = {}) {
424
499
  pictureUrl,
425
500
  isOfficial: Boolean(contact?.isOfficial),
426
501
  attentionScore: 0,
427
- attentionTier: 'filtered',
428
- attentionReason: '正在整理新訊息…',
502
+ attentionTier: 'pending',
503
+ attentionReason: 'pending',
429
504
  };
430
505
  }));
506
+ // OpenChat (SquareService) is a separate inbox. Its `m...`/legacy `t...`
507
+ // chats never
508
+ // appear in TalkService message boxes, so merge the two authoritative
509
+ // lists before attention scoring and sorting. SquareChatStatus supplies
510
+ // the authoritative latest message cheaply enough for background polls.
511
+ try {
512
+ const squareChats = await service.client.getJoinedSquareChats(args.limit ?? 100);
513
+ const hydratedSquareChats = await Promise.all(squareChats.map(async (chat) => {
514
+ if (chat.lastMessage)
515
+ return chat;
516
+ let hydrated = chat;
517
+ try {
518
+ const status = await service.client.getSquareChatStatus(chat.id);
519
+ hydrated = {
520
+ ...chat,
521
+ lastMessage: status?.[3]?.[1] || null,
522
+ lastSenderName: status?.[4] || null,
523
+ unreadCount: Number(status?.[5]?.[2] || chat.unreadCount),
524
+ };
525
+ }
526
+ catch { }
527
+ if (isRenderableSquareMessage(hydrated.lastMessage))
528
+ return hydrated;
529
+ try {
530
+ const messages = await service.client.getSquareChatMessages(chat.id, 100);
531
+ return {
532
+ ...hydrated,
533
+ lastMessage: [...messages].reverse().find(isRenderableSquareMessage) ?? null,
534
+ };
535
+ }
536
+ catch {
537
+ return hydrated;
538
+ }
539
+ }));
540
+ for (const chat of hydratedSquareChats) {
541
+ if (conversations.some((item) => item.id === chat.id))
542
+ continue;
543
+ const last = chat.lastMessage;
544
+ const timestamp = Number(last?.createdTime || last?.[6] || last?.[5] || 0);
545
+ const { contentType, text } = squareMessageContent(last);
546
+ conversations.push({
547
+ id: chat.id,
548
+ title: chat.name,
549
+ lastMessage: text ||
550
+ (contentType
551
+ ? messagePlaceholder(contentType)
552
+ : '[yomi:no_message]'),
553
+ timeString: formatTimestamp(timestamp),
554
+ timestamp,
555
+ unreadCount: chat.unreadCount,
556
+ isMuted: chat.isMuted,
557
+ isGroup: true,
558
+ hasMention: false,
559
+ pictureUrl: chat.pictureHash
560
+ ? `https://obs.line-scdn.net/${chat.pictureHash}`
561
+ : null,
562
+ isOfficial: false,
563
+ attentionScore: 0,
564
+ attentionTier: 'pending',
565
+ attentionReason: 'pending',
566
+ });
567
+ }
568
+ }
569
+ catch {
570
+ // Older/regional accounts may not expose SquareService. Ordinary LINE
571
+ // conversations must remain usable when OpenChat discovery is absent.
572
+ }
431
573
  const cache = await loadAttentionCache();
432
574
  const pending = [];
433
575
  conversations.forEach((conversation, index) => {
434
576
  const cached = cache[attentionSignature(conversation)];
435
577
  if (cached) {
436
- conversation.attentionScore =
437
- cached.score + (conversation.unreadCount > 0 ? 5 : 0);
578
+ conversation.attentionScore = cached.score;
438
579
  conversation.attentionTier = cached.tier;
439
580
  conversation.attentionReason = cached.reason;
440
581
  }
441
582
  else if (conversation.isMuted || conversation.unreadCount === 0) {
442
583
  conversation.attentionScore = conversation.isMuted ? -100 : 0;
443
584
  conversation.attentionTier = 'filtered';
444
- conversation.attentionReason = conversation.isMuted
445
- ? '已靜音'
446
- : '已讀,等待需要時分析';
585
+ conversation.attentionReason = conversation.isMuted ? 'muted' : 'read';
447
586
  }
448
587
  else {
449
588
  pending.push(index);
450
- // Fast first paint: uncached unread messages stay visible until the
451
- // background semantic pass refines them. No model loads on this path.
452
- conversation.attentionScore = conversation.isOfficial ? 0 : 20;
453
- conversation.attentionTier = conversation.isOfficial
454
- ? 'filtered'
455
- : 'know';
456
- conversation.attentionReason = '正在整理新訊息…';
589
+ conversation.attentionScore = 0;
590
+ conversation.attentionTier = 'pending';
591
+ conversation.attentionReason = 'pending';
457
592
  }
458
593
  });
459
594
  if (args.semantic && pending.length > 0) {
@@ -476,8 +611,7 @@ export async function cliListConversationsJson(args = {}) {
476
611
  ...result,
477
612
  updatedAt: Date.now(),
478
613
  };
479
- conversation.attentionScore =
480
- result.score + (conversation.unreadCount > 0 ? 5 : 0);
614
+ conversation.attentionScore = result.score;
481
615
  conversation.attentionTier = result.tier;
482
616
  conversation.attentionReason = result.reason;
483
617
  });
@@ -485,14 +619,14 @@ export async function cliListConversationsJson(args = {}) {
485
619
  }
486
620
  catch {
487
621
  pending.forEach((index) => {
488
- conversations[index].attentionReason = '暫時無法判斷重要性,稍後再試';
622
+ conversations[index].attentionReason = 'ai_unavailable';
489
623
  });
490
624
  }
491
625
  }
492
626
  // Sort by timestamp descending
493
627
  conversations.sort((a, b) => b.timestamp - a.timestamp);
494
628
  if (args.attentionOnly) {
495
- return conversations.filter((c) => c.attentionTier !== 'filtered');
629
+ return conversations.filter((c) => ['now', 'today', 'know'].includes(c.attentionTier));
496
630
  }
497
631
  return conversations;
498
632
  }
@@ -500,12 +634,80 @@ export async function cliListConversationsJson(args = {}) {
500
634
  return [];
501
635
  }
502
636
  }
503
- export async function cliGetChatMessagesJson(chatId, count = 30) {
504
- const service = new LineProtocolService();
505
- const ok = await service.resumeSession();
506
- if (!ok)
637
+ export async function cliGetChatMessagesJson(chatId, count = 30, activeService) {
638
+ const service = activeService ?? new LineProtocolService();
639
+ if (!activeService && !(await service.resumeSession()))
507
640
  return [];
508
641
  try {
642
+ if (isSquareChatMid(chatId)) {
643
+ // Square's small-limit response can be the oldest edge of its server
644
+ // window rather than the newest page. Fetch the protocol's full recent
645
+ // window, normalize by timestamp, then expose the requested tail.
646
+ const squareWindow = await service.client.getSquareChatMessages(chatId, Math.max(100, count));
647
+ let latestStatusMessage = null;
648
+ try {
649
+ latestStatusMessage =
650
+ await service.client.getSquareChatLatestMessage(chatId);
651
+ }
652
+ catch { }
653
+ const combinedWindow = latestStatusMessage
654
+ ? [...squareWindow, latestStatusMessage]
655
+ : squareWindow;
656
+ const meaningfulWindow = combinedWindow.filter((message) => Number(message.contentType ?? 0) !== 0 ||
657
+ Boolean(sanitizeMessagePreview(message.text)) ||
658
+ Boolean(messageAltText(message.contentMetadata)));
659
+ const messages = newestMessageWindow(meaningfulWindow, count);
660
+ const shapeSquareMessage = (message) => ({
661
+ id: message.id,
662
+ fromMid: message.fromMid,
663
+ fromName: message.isSelf ? '我' : message.fromName,
664
+ fromPictureUrl: message.fromPictureUrl,
665
+ isSelf: message.isSelf,
666
+ text: (message.contentType === 0
667
+ ? sanitizeMessagePreview(message.text) ||
668
+ messageAltText(message.contentMetadata)
669
+ : '') ||
670
+ (message.contentType ? messagePlaceholder(message.contentType) : ''),
671
+ timeString: formatTimestamp(message.createdTime),
672
+ timestamp: message.createdTime,
673
+ contentType: message.contentType,
674
+ mediaType: {
675
+ 1: 'image',
676
+ 2: 'video',
677
+ 3: 'audio',
678
+ 7: 'sticker',
679
+ 14: 'file',
680
+ 17: 'rich',
681
+ 22: 'rich',
682
+ }[message.contentType] || null,
683
+ mediaUrl: message.contentType === 7 && message.contentMetadata?.STKID
684
+ ? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${message.contentMetadata.STKID}/android/sticker.png`
685
+ : null,
686
+ actionUrl: null,
687
+ fileName: message.contentMetadata?.FILE_NAME || null,
688
+ isDecryptFailure: false,
689
+ isUnsent: false,
690
+ mentionRanges: [],
691
+ threadMid: message.threadMid,
692
+ isThreadRoot: message.isThreadRoot,
693
+ emojiRanges: parseEmojiRanges(message.contentMetadata || {}),
694
+ });
695
+ return Promise.all(messages.map(async (message) => {
696
+ const shaped = shapeSquareMessage(message);
697
+ if (!message.isThreadRoot || !message.threadMid)
698
+ return shaped;
699
+ try {
700
+ const threadMessages = await service.client.getSquareChatMessages(chatId, 50, message.threadMid);
701
+ shaped.threadReplies = threadMessages
702
+ .filter((reply) => reply.id !== message.id)
703
+ .map(shapeSquareMessage);
704
+ }
705
+ catch {
706
+ shaped.threadReplies = [];
707
+ }
708
+ return shaped;
709
+ }));
710
+ }
509
711
  // Always use the service-owned query boundary here. It opportunistically
510
712
  // decrypts E2EE envelopes; reading service.client directly leaves media
511
713
  // messages with contentType=0 and opaque text, which makes image/file
@@ -552,6 +754,8 @@ export async function cliGetChatMessagesJson(chatId, count = 30) {
552
754
  const stickerId = metadata.STKID || metadata.stickerId;
553
755
  const rich = isRichContentType(contentType) ? richFields(m) : null;
554
756
  let text = sanitizeMessagePreview(m.text);
757
+ if (contentType === 6)
758
+ text = callHistoryPreview(m.text, isSelf);
555
759
  if (contentType === 18)
556
760
  text = interpretChatEvent(m, names);
557
761
  if (rich?.altText)
@@ -574,6 +778,7 @@ export async function cliGetChatMessagesJson(chatId, count = 30) {
574
778
  1: 'image',
575
779
  2: 'video',
576
780
  3: 'audio',
781
+ 6: 'call',
577
782
  7: 'sticker',
578
783
  14: 'file',
579
784
  17: 'rich',
@@ -584,6 +789,7 @@ export async function cliGetChatMessagesJson(chatId, count = 30) {
584
789
  ? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/android/sticker.png`
585
790
  : null),
586
791
  actionUrl: rich?.actionUrl || null,
792
+ flexDocument: rich?.flexDocument || null,
587
793
  fileName: (typeof mediaText.fileName === 'string'
588
794
  ? mediaText.fileName
589
795
  : null) ||
@@ -597,6 +803,7 @@ export async function cliGetChatMessagesJson(chatId, count = 30) {
597
803
  isDecryptFailure: Boolean(m.e2eeDecryptFailure),
598
804
  isUnsent: metadata.UNSENT === 'true',
599
805
  mentionRanges: parseMentionRanges(metadata),
806
+ emojiRanges: parseEmojiRanges(metadata),
600
807
  };
601
808
  });
602
809
  // Chronological order (oldest to newest) for chat bubble view
@@ -0,0 +1,24 @@
1
+ import { InMemoryWorkItemStore, LineWorkItemAdapter, LocalReviewProvider, WorkItemProviderRegistry, WorkItemService, } from '../work-item/index.js';
2
+ /**
3
+ * Read-only/create-draft bridge for the desktop and CLI callers. It never
4
+ * sends to a destination; callers must explicitly select a provider and
5
+ * approve the draft before delivery.
6
+ */
7
+ export async function cliCreateWorkItemDraftJson(input, options = {}) {
8
+ const service = new WorkItemService(new InMemoryWorkItemStore());
9
+ return service.create(new LineWorkItemAdapter(), input, options);
10
+ }
11
+ /** Execute only local Work Item transitions; no external provider is registered. */
12
+ export async function cliWorkItemOperationJson(operation, draft) {
13
+ const store = new InMemoryWorkItemStore();
14
+ const provider = new LocalReviewProvider();
15
+ const registry = new WorkItemProviderRegistry().register(provider);
16
+ const service = new WorkItemService(store, registry);
17
+ await store.save(draft);
18
+ if (operation === 'local-review') {
19
+ return service.deliverByProviderId(draft.id, 'local.review');
20
+ }
21
+ return service.transition(draft.id, {
22
+ type: operation === 'preview' ? 'preview' : 'approve',
23
+ });
24
+ }
@@ -39,6 +39,9 @@ function unwrapAuthSuccess(method, result) {
39
39
  }
40
40
  return result.fields?.[0] ?? true;
41
41
  }
42
+ function stringifyAuthValue(value) {
43
+ return (JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? item.toString() : item) ?? String(value));
44
+ }
42
45
  /**
43
46
  * Create the auth capability bound to one LINE client runtime.
44
47
  *
@@ -68,7 +71,7 @@ export function createAuthClient(runtime) {
68
71
  }
69
72
  if (result.fields?.[1] && typeof result.fields[1] === 'object') {
70
73
  const exc = result.fields[1];
71
- const msg = typeof exc[2] === 'string' ? exc[2] : JSON.stringify(exc);
74
+ const msg = typeof exc[2] === 'string' ? exc[2] : stringifyAuthValue(exc);
72
75
  console.error(`[LINE] ${method} exception: ${msg}`);
73
76
  }
74
77
  return result;
@@ -83,10 +86,7 @@ export function createAuthClient(runtime) {
83
86
  // stderr-only logger — stdout is the MCP JSON-RPC channel and this runs
84
87
  // inside the stdio server (token refresh on resume/rotation).
85
88
  const lineLog = getLineClientLog(runtime);
86
- lineLog.info('auth.refresh.call', {
87
- auth: `${String(runtime.authToken || '').slice(0, 16)}...`,
88
- refresh: `${String(refreshToken || '').slice(0, 12)}...`,
89
- });
89
+ lineLog.info('auth.refresh.call');
90
90
  const result = await runtime.sendAuth('refresh', [[12, 1, [[11, 1, refreshToken]]]], LINE_APP_CONFIG.tokenRefreshPath);
91
91
  const resp = result.fields?.[0];
92
92
  if (resp?.[1]) {
@@ -95,7 +95,6 @@ export function createAuthClient(runtime) {
95
95
  const durationUntilRefreshInSec = typeof resp[2] === 'bigint' ? Number(resp[2]) : resp[2] || 0;
96
96
  const tokenIssueTimeEpochSec = typeof resp[3] === 'bigint' ? Number(resp[3]) : resp[3] || null;
97
97
  lineLog.info('auth.refresh.success', {
98
- auth: `${String(resp[1]).slice(0, 16)}...`,
99
98
  next_in_hours: Math.round(durationUntilRefreshInSec / 3600),
100
99
  });
101
100
  return {
@@ -104,8 +103,8 @@ export function createAuthClient(runtime) {
104
103
  tokenIssueTimeEpochSec,
105
104
  };
106
105
  }
107
- console.error('[LINE] refresh() exception payload:', JSON.stringify(result, (_key, val) => typeof val === 'bigint' ? val.toString() : val));
108
- throw new Error(`Token refresh failed: ${JSON.stringify(result, (_key, val) => (typeof val === 'bigint' ? val.toString() : val))}`);
106
+ lineLog.warn('auth.refresh.rejected');
107
+ throw new Error('Token refresh was rejected by LINE');
109
108
  },
110
109
  /**
111
110
  * Report a freshly rotated access token back to LINE's auth service.
@@ -0,0 +1,73 @@
1
+ export function createBuddyClient(runtime) {
2
+ return {
3
+ async getBuddyRichMenu(buddyMid) {
4
+ const detail = await runtime.sendBinary('/BUDDY3', 'getBuddyDetailWithPersonal', [
5
+ [11, 1, buddyMid],
6
+ [14, 2, [8, [0, 1, 2]]],
7
+ ]);
8
+ const buddy = detail.fields?.[0]?.[1];
9
+ const personal = detail.fields?.[0]?.[2];
10
+ const richMenuId = personal?.[1];
11
+ if (!richMenuId)
12
+ return null;
13
+ const chatBar = await runtime.sendBinary('/BUDDY3', 'getBuddyChatBarV2', [
14
+ [
15
+ 12,
16
+ 1,
17
+ [
18
+ [11, 1, buddyMid],
19
+ [10, 2, buddy?.[21] || 0],
20
+ [11, 3, richMenuId],
21
+ ],
22
+ ],
23
+ ]);
24
+ const rich = (chatBar.fields?.[0]?.[1] || [])
25
+ .map((item) => item?.[1])
26
+ .find((item) => typeof item?.[2] === 'string');
27
+ if (!rich)
28
+ return null;
29
+ let body;
30
+ try {
31
+ body = JSON.parse(rich[2]);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ const canvas = body?.canvas;
37
+ const scene = body?.scenes?.[canvas?.initialScene];
38
+ const image = Object.values(body?.images || {})[0];
39
+ if (!canvas || !scene || !image?.imageUrl)
40
+ return null;
41
+ const areas = (scene.listeners || []).flatMap((listener) => {
42
+ const [x, y, width, height] = listener?.params || [];
43
+ const action = body?.actions?.[listener?.action];
44
+ if (![x, y, width, height].every(Number.isFinite) || !action)
45
+ return [];
46
+ return [
47
+ {
48
+ x,
49
+ y,
50
+ width,
51
+ height,
52
+ type: String(action.type || ''),
53
+ uri: typeof action.params?.linkUri === 'string'
54
+ ? action.params.linkUri
55
+ : null,
56
+ text: typeof action.params?.text === 'string'
57
+ ? action.params.text
58
+ : null,
59
+ },
60
+ ];
61
+ });
62
+ return {
63
+ label: String(rich[1] || '選單'),
64
+ richMenuId: String(richMenuId),
65
+ selected: Boolean(rich[3]),
66
+ width: Number(canvas.width),
67
+ height: Number(canvas.height),
68
+ imageUrl: String(image.imageUrl),
69
+ areas,
70
+ };
71
+ },
72
+ };
73
+ }