@rikaidev/yomi 0.4.2 → 0.6.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 (86) hide show
  1. package/README.md +46 -4
  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 +101 -0
  13. package/dist/auth/credential-store.js +47 -17
  14. package/dist/auth/keychain.js +7 -54
  15. package/dist/cli/query.js +875 -0
  16. package/dist/cli/work-item.js +24 -0
  17. package/dist/insight/relationships.js +1 -1
  18. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  19. package/dist/line/auth/pwless/index.js +1 -1
  20. package/dist/line/client/auth-service/client.js +7 -8
  21. package/dist/line/client/buddy-service/client.js +73 -0
  22. package/dist/line/client/index.js +28 -3
  23. package/dist/line/client/obs-media-client.js +38 -0
  24. package/dist/line/client/parsers.js +1 -0
  25. package/dist/line/client/square-service/client.js +298 -0
  26. package/dist/line/client/sticker-meta.js +32 -0
  27. package/dist/line/client/sync-service/client.js +31 -3
  28. package/dist/line/client/talk-service/command-client.js +9 -1
  29. package/dist/line/client/talk-service/contact-query.js +8 -1
  30. package/dist/line/client/talk-service/message-box-query.js +17 -0
  31. package/dist/line/client/talk-service/message-id-query.js +27 -3
  32. package/dist/line/client/talk-service/query-client.js +32 -26
  33. package/dist/line/client/talk-service/requests.js +10 -11
  34. package/dist/line/core/auth-session-runtime.js +17 -2
  35. package/dist/line/core/chat-event.js +35 -0
  36. package/dist/line/core/chat-runtime-service.js +18 -23
  37. package/dist/line/core/config.js +1 -0
  38. package/dist/line/core/directory-runtime-service.js +10 -2
  39. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  40. package/dist/line/core/flex-message.js +453 -0
  41. package/dist/line/core/mention.js +1 -1
  42. package/dist/line/core/message-preview.js +60 -0
  43. package/dist/line/core/message-query-service.js +0 -20
  44. package/dist/line/core/service.js +1 -3
  45. package/dist/mcp/handlers/contacts.js +12 -6
  46. package/dist/mcp/handlers/conversations.js +91 -4
  47. package/dist/mcp/handlers/login.js +1 -1
  48. package/dist/mcp/handlers/message-actions.js +1 -1
  49. package/dist/mcp/handlers/search.js +1 -1
  50. package/dist/mcp/handlers/send.js +8 -11
  51. package/dist/mcp/handlers/shared.js +1 -1
  52. package/dist/mcp/integrator.js +168 -0
  53. package/dist/mcp/media.js +135 -4
  54. package/dist/mcp/names.js +78 -6
  55. package/dist/mcp/phi-guard.js +2 -2
  56. package/dist/search/bigram.js +2 -2
  57. package/dist/search/collector.js +1 -1
  58. package/dist/search/default-embedder.js +12 -3
  59. package/dist/search/embedder.js +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/work-item/adapter.js +1 -0
  62. package/dist/work-item/destination-contracts.js +1 -0
  63. package/dist/work-item/event-context.js +73 -0
  64. package/dist/work-item/gold-contract.js +101 -0
  65. package/dist/work-item/gold-coverage.js +96 -0
  66. package/dist/work-item/gold-derivation.js +67 -0
  67. package/dist/work-item/gold-split.js +143 -0
  68. package/dist/work-item/index.js +23 -0
  69. package/dist/work-item/line-adapter.js +111 -0
  70. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  71. package/dist/work-item/local-provider.js +38 -0
  72. package/dist/work-item/local-work-event-provider.js +56 -0
  73. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  74. package/dist/work-item/provider.js +21 -0
  75. package/dist/work-item/registry.js +19 -0
  76. package/dist/work-item/service.js +85 -0
  77. package/dist/work-item/store.js +97 -0
  78. package/dist/work-item/types.js +79 -0
  79. package/dist/work-item/work-event-graph-engine.js +63 -0
  80. package/dist/work-item/work-event-inference.js +267 -0
  81. package/dist/work-item/work-event-matcher.js +105 -0
  82. package/dist/work-item/work-event-model-manifest.js +39 -0
  83. package/dist/work-item/work-event-store.js +125 -0
  84. package/dist/work-item/work-event.js +123 -0
  85. package/package.json +26 -2
  86. package/run.mjs +371 -2
@@ -0,0 +1,875 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { scoreAttentionBatch } from '../attention/policy.js';
6
+ import { isSquareChatMid } from '../line/client/square-service/client.js';
7
+ import { fetchStickerImage, fetchStickerPackageMetaCached, localizedStickerTitle, } from '../line/client/sticker-meta.js';
8
+ import { chatEventParticipantMids, interpretChatEvent, } from '../line/core/chat-event.js';
9
+ import { interpretFlexMessage, processFlexMessage, } from '../line/core/flex-message.js';
10
+ import { sanitizeMessagePreview } from '../line/core/message-preview.js';
11
+ import { decryptLineMessage } from '../line/core/message-query-service.js';
12
+ import { LineProtocolService } from '../line/core/service.js';
13
+ import { handleListConversations } from '../mcp/handlers/conversations.js';
14
+ import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
15
+ const attentionCachePath = join(process.env.YOMI_DATA_DIR || join(homedir(), '.yomi'), 'attention-cache.json');
16
+ const ATTENTION_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
17
+ const ATTENTION_CACHE_MAX_ENTRIES = 1000;
18
+ const ATTENTION_BATCH_SIZE = 32;
19
+ const ATTENTION_POLICY_VERSION = 5;
20
+ function attentionSignature(item) {
21
+ return createHash('sha256')
22
+ .update(JSON.stringify([
23
+ ATTENTION_POLICY_VERSION,
24
+ item.id,
25
+ item.lastMessage,
26
+ item.isOfficial,
27
+ item.isMuted,
28
+ ]))
29
+ .digest('hex');
30
+ }
31
+ async function loadAttentionCache() {
32
+ try {
33
+ const cache = JSON.parse(await readFile(attentionCachePath, 'utf8'));
34
+ const cutoff = Date.now() - ATTENTION_CACHE_TTL_MS;
35
+ return Object.fromEntries(Object.entries(cache).filter(([, entry]) => (entry.updatedAt || 0) >= cutoff));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ async function saveAttentionCache(cache) {
42
+ const bounded = Object.fromEntries(Object.entries(cache)
43
+ .sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
44
+ .slice(0, ATTENTION_CACHE_MAX_ENTRIES));
45
+ await mkdir(dirname(attentionCachePath), { recursive: true });
46
+ await writeFile(attentionCachePath, JSON.stringify(bounded), { mode: 0o600 });
47
+ }
48
+ export async function cliClassifyAttentionJson(items) {
49
+ const cache = await loadAttentionCache();
50
+ const batch = items.slice(0, ATTENTION_BATCH_SIZE);
51
+ if (batch.length === 0)
52
+ return [];
53
+ const evaluations = await scoreAttentionBatch(batch.map((item) => ({
54
+ id: item.id,
55
+ text: item.lastMessage,
56
+ unreadCount: 0,
57
+ isMuted: item.isMuted,
58
+ isOfficial: item.isOfficial,
59
+ hasMention: item.hasMention,
60
+ })));
61
+ const results = batch.map((item, index) => {
62
+ const result = evaluations[index];
63
+ cache[attentionSignature(item)] = { ...result, updatedAt: Date.now() };
64
+ return {
65
+ id: item.id,
66
+ attentionScore: result.score,
67
+ attentionTier: result.tier,
68
+ attentionReason: result.reason,
69
+ };
70
+ });
71
+ await saveAttentionCache(cache);
72
+ return results;
73
+ }
74
+ /** Read-only bridge for the desktop sticker picker. */
75
+ export async function cliListStickersJson(language = 'zh_TW') {
76
+ const service = new LineProtocolService();
77
+ if (!(await service.resumeSession()))
78
+ return [];
79
+ const owned = await service.listStickerPackages(language);
80
+ return Promise.all(owned.map(async (pkg) => {
81
+ const packageId = String(pkg.packageId ?? '');
82
+ if (!packageId)
83
+ return pkg;
84
+ const meta = await fetchStickerPackageMetaCached(packageId);
85
+ return {
86
+ ...pkg,
87
+ title: localizedStickerTitle(meta?.title, language, String(pkg.title ?? '')),
88
+ };
89
+ }));
90
+ }
91
+ /** Read-only bridge for owned sticker previews; never sends a message. */
92
+ export async function cliStickerPreviewsJson(packageId, limit = 12, language = 'en') {
93
+ if (!packageId)
94
+ return [];
95
+ const service = new LineProtocolService();
96
+ if (!(await service.resumeSession()))
97
+ return [];
98
+ // The package must be owned before the public CDN metadata is expanded.
99
+ const owned = await service.listStickerPackages(language);
100
+ if (!owned.some((item) => String(item.packageId) === packageId))
101
+ return [];
102
+ const meta = await fetchStickerPackageMetaCached(packageId);
103
+ if (!meta)
104
+ return [];
105
+ const result = [];
106
+ for (const stickerId of meta.stickerIds.slice(0, Math.max(1, limit))) {
107
+ const bytes = await fetchStickerImage(stickerId);
108
+ if (!bytes)
109
+ continue;
110
+ result.push({
111
+ stickerId,
112
+ packageId,
113
+ data: bytes.toString('base64'),
114
+ mimeType: 'image/png',
115
+ });
116
+ }
117
+ return result;
118
+ }
119
+ function parseMediaText(value) {
120
+ if (typeof value !== 'string' || !value.trim())
121
+ return {};
122
+ try {
123
+ const parsed = JSON.parse(value);
124
+ return parsed && typeof parsed === 'object'
125
+ ? parsed
126
+ : {};
127
+ }
128
+ catch {
129
+ return {};
130
+ }
131
+ }
132
+ /** Only expose web URLs from LINE rich-message metadata. */
133
+ function safeWebUrl(value) {
134
+ if (typeof value !== 'string' || !value.trim())
135
+ return null;
136
+ try {
137
+ const url = new URL(value.trim());
138
+ return url.protocol === 'https:' || url.protocol === 'http:'
139
+ ? url.toString()
140
+ : null;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ function firstWebLink(value) {
147
+ if (!value || typeof value !== 'object')
148
+ return null;
149
+ if (Array.isArray(value)) {
150
+ for (const item of value) {
151
+ const found = firstWebLink(item);
152
+ if (found)
153
+ return found;
154
+ }
155
+ return null;
156
+ }
157
+ const record = value;
158
+ for (const [key, item] of Object.entries(record)) {
159
+ if (key === 'linkUri') {
160
+ const found = safeWebUrl(item);
161
+ if (found)
162
+ return found;
163
+ }
164
+ const nested = firstWebLink(item);
165
+ if (nested)
166
+ return nested;
167
+ }
168
+ return null;
169
+ }
170
+ export function richFields(message) {
171
+ const metadata = message?.contentMetadata && typeof message.contentMetadata === 'object'
172
+ ? message.contentMetadata
173
+ : {};
174
+ const interpreted = interpretFlexMessage(message);
175
+ const flexDocument = processFlexMessage(message);
176
+ const explicitAlt = sanitizeMessagePreview(metadata.ALT_TEXT) || '';
177
+ const altText = interpreted.summary || explicitAlt;
178
+ const mediaUrl = safeWebUrl(metadata.DOWNLOAD_URL) || interpreted.imageUrl;
179
+ const redirect = safeWebUrl(metadata.LINE_TAG_REDIRECTOR);
180
+ let markup = metadata.MARKUP_JSON;
181
+ if (typeof markup === 'string') {
182
+ try {
183
+ markup = JSON.parse(markup);
184
+ }
185
+ catch {
186
+ markup = null;
187
+ }
188
+ }
189
+ return {
190
+ altText,
191
+ mediaUrl,
192
+ actionUrl: redirect || interpreted.actionUrl || firstWebLink(markup),
193
+ flexDocument,
194
+ };
195
+ }
196
+ export { sanitizeMessagePreview } from '../line/core/message-preview.js';
197
+ export function messagePlaceholder(contentType, hasOpaquePayload = false) {
198
+ switch (contentType) {
199
+ case 0:
200
+ return hasOpaquePayload ? '[yomi:encrypted]' : '[yomi:message]';
201
+ case 7:
202
+ return '[yomi:sticker]';
203
+ case 4:
204
+ return '[yomi:web]';
205
+ case 5:
206
+ return '[yomi:pdf]';
207
+ case 6:
208
+ return '[yomi:call]';
209
+ case 8:
210
+ return '[yomi:status]';
211
+ case 9:
212
+ return '[yomi:gift]';
213
+ case 10:
214
+ return '[yomi:note]';
215
+ case 11:
216
+ return '[yomi:app]';
217
+ case 12:
218
+ return '[yomi:share]';
219
+ case 13:
220
+ return '[yomi:contact]';
221
+ case 15:
222
+ return '[yomi:location]';
223
+ case 16:
224
+ return '[yomi:post]';
225
+ case 17:
226
+ return '[yomi:rich]';
227
+ case 18:
228
+ return '[yomi:chat_event]';
229
+ case 19:
230
+ return '[yomi:music]';
231
+ case 20:
232
+ return '[yomi:payment]';
233
+ case 21:
234
+ return '[yomi:extended_image]';
235
+ case 22:
236
+ return '[yomi:rich]';
237
+ case 1:
238
+ return '[yomi:image]';
239
+ case 2:
240
+ return '[yomi:video]';
241
+ case 3:
242
+ return '[yomi:audio]';
243
+ case 14:
244
+ return '[yomi:file]';
245
+ default:
246
+ return hasOpaquePayload
247
+ ? '[yomi:encrypted]'
248
+ : `[yomi:line_content:${contentType}]`;
249
+ }
250
+ }
251
+ export function messageFallback(message) {
252
+ if (message?.contentMetadata?.UNSENT === 'true')
253
+ return '[yomi:unsent]';
254
+ const altText = messageAltText(message?.contentMetadata);
255
+ if (altText)
256
+ return altText;
257
+ return message?.e2eeDecryptFailure
258
+ ? '[yomi:decrypt_failed]'
259
+ : messagePlaceholder(Number(message?.contentType || 0), Boolean(message?.text));
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
+ }
286
+ function parseMentionRanges(metadata) {
287
+ if (typeof metadata.MENTION !== 'string')
288
+ return [];
289
+ try {
290
+ const parsed = JSON.parse(metadata.MENTION);
291
+ return (parsed.MENTIONEES || []).flatMap((mention) => {
292
+ const location = Number(mention.S);
293
+ const end = Number(mention.E);
294
+ return Number.isInteger(location) &&
295
+ Number.isInteger(end) &&
296
+ end > location
297
+ ? [{ location, length: end - location }]
298
+ : [];
299
+ });
300
+ }
301
+ catch {
302
+ return [];
303
+ }
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
+ }
332
+ function isRichContentType(contentType) {
333
+ return contentType === 17 || contentType === 22;
334
+ }
335
+ function formatTimestamp(timestamp) {
336
+ if (!timestamp || timestamp <= 0)
337
+ return '';
338
+ const date = new Date(Number(timestamp));
339
+ const now = new Date();
340
+ const isToday = date.getFullYear() === now.getFullYear() &&
341
+ date.getMonth() === now.getMonth() &&
342
+ date.getDate() === now.getDate();
343
+ const yesterday = new Date(now);
344
+ yesterday.setDate(now.getDate() - 1);
345
+ const isYesterday = date.getFullYear() === yesterday.getFullYear() &&
346
+ date.getMonth() === yesterday.getMonth() &&
347
+ date.getDate() === yesterday.getDate();
348
+ const hours = date.getHours().toString().padStart(2, '0');
349
+ const minutes = date.getMinutes().toString().padStart(2, '0');
350
+ if (isToday) {
351
+ return `${hours}:${minutes}`;
352
+ }
353
+ if (isYesterday) {
354
+ return '昨天';
355
+ }
356
+ if (date.getFullYear() === now.getFullYear()) {
357
+ return `${date.getMonth() + 1}月${date.getDate()}日`;
358
+ }
359
+ return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
360
+ }
361
+ function messageTimestamp(message) {
362
+ return Number(message?.deliveredTime || message?.createdTime || 0);
363
+ }
364
+ function latestMessage(messages) {
365
+ if (!Array.isArray(messages) || messages.length === 0)
366
+ return null;
367
+ return messages.reduce((latest, candidate) => messageTimestamp(candidate) >= messageTimestamp(latest)
368
+ ? candidate
369
+ : latest);
370
+ }
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()))
392
+ return [];
393
+ try {
394
+ // Advance the Talk revision before reading message boxes. A fresh client
395
+ // process starts with no checkpoint; syncLongPoll now obtains the server's
396
+ // last-op revision before issuing sync, then we persist the monotonic
397
+ // checkpoint through the session-state owner for the next bridge process.
398
+ try {
399
+ await service.client.syncLongPoll(50, 1000);
400
+ if (service.sessionState?.setRevisions) {
401
+ await service.sessionState.setRevisions(Number(service.client.revision || 0), Number(service.client.globalRevision || 0), Number(service.client.individualRevision || 0));
402
+ }
403
+ }
404
+ catch { }
405
+ const result = await service.client.getAllMessageBoxes({
406
+ lastMessagesPerMessageBoxCount: 1,
407
+ ...(args.limit ? { messageBoxCountLimit: args.limit } : {}),
408
+ withUnreadCount: true,
409
+ });
410
+ const boxes = result.messageBoxes || [];
411
+ const chatIds = boxes.map((box) => box.id).filter(Boolean);
412
+ const names = await resolveConversationNames(service, chatIds);
413
+ // Resolve contacts picture paths
414
+ const userMids = chatIds.filter((id) => id.startsWith('u'));
415
+ const contactMap = new Map();
416
+ if (userMids.length > 0) {
417
+ try {
418
+ const contacts = await service.client.getContacts(userMids);
419
+ for (const c of contacts || []) {
420
+ if (c?.mid)
421
+ contactMap.set(c.mid, c);
422
+ }
423
+ }
424
+ catch { }
425
+ }
426
+ const conversations = await Promise.all(boxes.map(async (box) => {
427
+ let lastMsg = latestMessage(box.lastMessages);
428
+ const boxDeliveredTime = Number(box.lastDeliveredMessageId?.deliveredTime || 0);
429
+ // Some LINE responses return a lastMessages array that lags the
430
+ // last-delivered cursor. Fetch one message only for that box so the
431
+ // inbox preview and date cannot remain days behind.
432
+ if (box.id &&
433
+ (!lastMsg || messageTimestamp(lastMsg) < boxDeliveredTime)) {
434
+ try {
435
+ const recent = await service.getRecentMessages(box.id, 1);
436
+ const candidate = latestMessage(recent);
437
+ if (candidate &&
438
+ messageTimestamp(candidate) >= messageTimestamp(lastMsg)) {
439
+ lastMsg = candidate;
440
+ }
441
+ }
442
+ catch { }
443
+ }
444
+ let preview = '[yomi:no_message]';
445
+ let timestamp = 0;
446
+ if (lastMsg) {
447
+ timestamp = messageTimestamp(lastMsg);
448
+ const rich = isRichContentType(Number(lastMsg.contentType))
449
+ ? richFields(lastMsg)
450
+ : null;
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);
456
+ if (rawPreview) {
457
+ preview = rawPreview;
458
+ }
459
+ else {
460
+ const decrypted = await decryptLineMessage(service.e2eeManager, lastMsg, box.id);
461
+ const decryptedPreview = sanitizeMessagePreview(decrypted?.text);
462
+ if (decryptedPreview)
463
+ preview = decryptedPreview;
464
+ else if (lastMsg.contentType)
465
+ preview = messagePlaceholder(Number(lastMsg.contentType));
466
+ else if (lastMsg.text)
467
+ preview = messagePlaceholder(0, true);
468
+ }
469
+ }
470
+ const isGroup = box.id?.startsWith('c') || box.id?.startsWith('r');
471
+ const defaultTitle = isGroup ? '群組對話' : '好友對話';
472
+ const title = names.get(box.id) || defaultTitle;
473
+ const chatCached = service.chatCache.get(box.id);
474
+ const contact = contactMap.get(box.id);
475
+ let picPath = null;
476
+ if (isGroup && chatCached?.picturePath) {
477
+ picPath = chatCached.picturePath;
478
+ }
479
+ else if (!isGroup && contact?.picturePath) {
480
+ picPath = contact.picturePath;
481
+ }
482
+ const pictureUrl = picPath
483
+ ? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
484
+ : null;
485
+ const isMuted = isGroup
486
+ ? Boolean(chatCached?.notificationDisabled)
487
+ : Boolean(contact?.notificationDisabled ?? chatCached?.notificationDisabled);
488
+ const unreadCount = box.unreadCount ?? 0;
489
+ return {
490
+ id: box.id,
491
+ title,
492
+ lastMessage: preview.replace(/\n+/g, ' ').trim(),
493
+ timeString: formatTimestamp(timestamp),
494
+ timestamp,
495
+ unreadCount,
496
+ isMuted,
497
+ isGroup,
498
+ hasMention: false,
499
+ pictureUrl,
500
+ isOfficial: Boolean(contact?.isOfficial),
501
+ attentionScore: 0,
502
+ attentionTier: 'pending',
503
+ attentionReason: 'pending',
504
+ };
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
+ }
573
+ const cache = await loadAttentionCache();
574
+ const pending = [];
575
+ conversations.forEach((conversation, index) => {
576
+ const cached = cache[attentionSignature(conversation)];
577
+ if (cached) {
578
+ conversation.attentionScore = cached.score;
579
+ conversation.attentionTier = cached.tier;
580
+ conversation.attentionReason = cached.reason;
581
+ }
582
+ else if (conversation.isMuted || conversation.unreadCount === 0) {
583
+ conversation.attentionScore = conversation.isMuted ? -100 : 0;
584
+ conversation.attentionTier = 'filtered';
585
+ conversation.attentionReason = conversation.isMuted ? 'muted' : 'read';
586
+ }
587
+ else {
588
+ pending.push(index);
589
+ conversation.attentionScore = 0;
590
+ conversation.attentionTier = 'pending';
591
+ conversation.attentionReason = 'pending';
592
+ }
593
+ });
594
+ if (args.semantic && pending.length > 0) {
595
+ try {
596
+ const evaluations = await scoreAttentionBatch(pending.map((index) => {
597
+ const conversation = conversations[index];
598
+ return {
599
+ id: conversation.id,
600
+ text: conversation.lastMessage,
601
+ unreadCount: 0,
602
+ isMuted: conversation.isMuted,
603
+ isOfficial: conversation.isOfficial,
604
+ hasMention: conversation.hasMention,
605
+ };
606
+ }));
607
+ pending.forEach((conversationIndex, resultIndex) => {
608
+ const conversation = conversations[conversationIndex];
609
+ const result = evaluations[resultIndex];
610
+ cache[attentionSignature(conversation)] = {
611
+ ...result,
612
+ updatedAt: Date.now(),
613
+ };
614
+ conversation.attentionScore = result.score;
615
+ conversation.attentionTier = result.tier;
616
+ conversation.attentionReason = result.reason;
617
+ });
618
+ await saveAttentionCache(cache);
619
+ }
620
+ catch {
621
+ pending.forEach((index) => {
622
+ conversations[index].attentionReason = 'ai_unavailable';
623
+ });
624
+ }
625
+ }
626
+ // Sort by timestamp descending
627
+ conversations.sort((a, b) => b.timestamp - a.timestamp);
628
+ if (args.attentionOnly) {
629
+ return conversations.filter((c) => ['now', 'today', 'know'].includes(c.attentionTier));
630
+ }
631
+ return conversations;
632
+ }
633
+ catch {
634
+ return [];
635
+ }
636
+ }
637
+ export async function cliGetChatMessagesJson(chatId, count = 30, activeService) {
638
+ const service = activeService ?? new LineProtocolService();
639
+ if (!activeService && !(await service.resumeSession()))
640
+ return [];
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
+ }
711
+ // Always use the service-owned query boundary here. It opportunistically
712
+ // decrypts E2EE envelopes; reading service.client directly leaves media
713
+ // messages with contentType=0 and opaque text, which makes image/file
714
+ // rendering look empty even though LINE returned the message.
715
+ const rawMessages = await service.getRecentMessages(chatId, count);
716
+ const mids = Array.from(new Set(rawMessages
717
+ .flatMap((message) => [
718
+ message.from,
719
+ ...chatEventParticipantMids(message),
720
+ ])
721
+ .filter(Boolean)));
722
+ const names = await resolveSenderNames(service, mids);
723
+ const userMids = Array.from(new Set(mids.filter((m) => m.startsWith('u'))));
724
+ const contactMap = new Map();
725
+ if (userMids.length > 0) {
726
+ try {
727
+ const contacts = await service.client.getContacts(userMids);
728
+ for (const c of contacts || []) {
729
+ if (c?.mid)
730
+ contactMap.set(c.mid, c);
731
+ }
732
+ }
733
+ catch { }
734
+ }
735
+ const selfMid = service.profile?.mid;
736
+ const selfName = service.profile?.displayName || '我';
737
+ const selfPicPath = service.profile?.picturePath;
738
+ const selfPicUrl = selfPicPath
739
+ ? `https://obs.line-scdn.net/${selfPicPath.replace(/^\/+/, '')}`
740
+ : null;
741
+ const formatted = rawMessages.map((m) => {
742
+ const isSelf = m.from === selfMid;
743
+ const fromName = isSelf ? selfName : names.get(m.from) || '成員';
744
+ const contact = contactMap.get(m.from);
745
+ const picPath = isSelf ? selfPicPath : contact?.picturePath;
746
+ const fromPictureUrl = isSelf
747
+ ? selfPicUrl
748
+ : picPath
749
+ ? `https://obs.line-scdn.net/${picPath.replace(/^\/+/, '')}`
750
+ : null;
751
+ const contentType = Number(m.contentType || 0);
752
+ const metadata = m.contentMetadata || {};
753
+ const mediaText = parseMediaText(m.text);
754
+ const stickerId = metadata.STKID || metadata.stickerId;
755
+ const rich = isRichContentType(contentType) ? richFields(m) : null;
756
+ let text = sanitizeMessagePreview(m.text);
757
+ if (contentType === 6)
758
+ text = callHistoryPreview(m.text, isSelf);
759
+ if (contentType === 18)
760
+ text = interpretChatEvent(m, names);
761
+ if (rich?.altText)
762
+ text = rich.altText;
763
+ if (!text) {
764
+ text = messageFallback(m);
765
+ }
766
+ const timestamp = Number(m.deliveredTime || m.createdTime || 0);
767
+ return {
768
+ id: String(m.id),
769
+ fromMid: m.from || '',
770
+ fromName,
771
+ fromPictureUrl,
772
+ isSelf,
773
+ text,
774
+ timeString: formatTimestamp(timestamp),
775
+ timestamp,
776
+ contentType,
777
+ mediaType: {
778
+ 1: 'image',
779
+ 2: 'video',
780
+ 3: 'audio',
781
+ 6: 'call',
782
+ 7: 'sticker',
783
+ 14: 'file',
784
+ 17: 'rich',
785
+ 22: 'rich',
786
+ }[contentType] || null,
787
+ mediaUrl: rich?.mediaUrl ||
788
+ (stickerId
789
+ ? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${stickerId}/android/sticker.png`
790
+ : null),
791
+ actionUrl: rich?.actionUrl || null,
792
+ flexDocument: rich?.flexDocument || null,
793
+ fileName: (typeof mediaText.fileName === 'string'
794
+ ? mediaText.fileName
795
+ : null) ||
796
+ (typeof metadata.FILE_NAME === 'string'
797
+ ? metadata.FILE_NAME
798
+ : null) ||
799
+ (typeof metadata.FILE_NAME_ORIGINAL === 'string'
800
+ ? metadata.FILE_NAME_ORIGINAL
801
+ : null) ||
802
+ (typeof metadata.name === 'string' ? metadata.name : null),
803
+ isDecryptFailure: Boolean(m.e2eeDecryptFailure),
804
+ isUnsent: metadata.UNSENT === 'true',
805
+ mentionRanges: parseMentionRanges(metadata),
806
+ emojiRanges: parseEmojiRanges(metadata),
807
+ };
808
+ });
809
+ // Chronological order (oldest to newest) for chat bubble view
810
+ formatted.sort((a, b) => a.timestamp - b.timestamp);
811
+ return formatted;
812
+ }
813
+ catch {
814
+ return [];
815
+ }
816
+ }
817
+ export async function cliMarkReadJson(chatId, messageId) {
818
+ const service = new LineProtocolService();
819
+ const ok = await service.resumeSession();
820
+ if (!ok)
821
+ return { success: false, marked: false };
822
+ try {
823
+ const res = await service.markChatRead(chatId, messageId);
824
+ return { success: true, marked: res.marked };
825
+ }
826
+ catch {
827
+ return { success: false, marked: false };
828
+ }
829
+ }
830
+ export async function cliRecentSummary() {
831
+ const service = new LineProtocolService();
832
+ const ok = await service.resumeSession();
833
+ if (!ok) {
834
+ return '【LINE 狀態】尚未連線或憑證已過期,請先於選單列重新登入。';
835
+ }
836
+ try {
837
+ const result = await handleListConversations(service, { limit: 12 });
838
+ const rawText = result.content?.[0]?.text || '';
839
+ const name = service.profile?.displayName || 'LINE User';
840
+ let output = `【LINE 即時訊息摘要 — ${name}】\n• 加密狀態:Letter-Sealing (E2EE) 已解密安全連線\n\n`;
841
+ // Parse TOON format lines
842
+ const lines = rawText.split('\n').filter((l) => l.trim().length > 0);
843
+ const chats = [];
844
+ for (const line of lines) {
845
+ if (line.startsWith('[') || line.includes('{id,'))
846
+ continue;
847
+ // Format: id,lastMessagePreview,name,unreadCount
848
+ // Note that preview might contain quotes or escaped newlines
849
+ const trimmed = line.trim();
850
+ const parts = trimmed.split(',');
851
+ if (parts.length >= 4) {
852
+ const unread = parseInt(parts[parts.length - 1], 10) || 0;
853
+ const chatName = parts[parts.length - 2] || '聊天室';
854
+ const previewRaw = parts.slice(1, parts.length - 2).join(',');
855
+ const preview = previewRaw === 'null'
856
+ ? '(無文字或貼圖/媒體)'
857
+ : previewRaw.replace(/^"|"$/g, '').replace(/\\n/g, ' ');
858
+ chats.push({ name: chatName, unread, preview });
859
+ }
860
+ }
861
+ if (chats.length > 0) {
862
+ chats.forEach((chat) => {
863
+ const unreadBadge = chat.unread > 0 ? ` 🔴 **[未讀 ${chat.unread} 則]**` : '';
864
+ output += `• **${chat.name}**${unreadBadge}\n ↳ 「${chat.preview}」\n\n`;
865
+ });
866
+ }
867
+ else {
868
+ output += '目前沒有最近的聊天室紀錄。\n';
869
+ }
870
+ return output.trim();
871
+ }
872
+ catch (err) {
873
+ return `查詢 LINE 訊息時發生錯誤:${err.message || String(err)}`;
874
+ }
875
+ }