@rikaidev/yomi 0.5.0 → 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.
- package/README.md +28 -0
- package/dist/ambient/ambient-event-graph.js +172 -0
- package/dist/ambient/ambient-observer.js +71 -0
- package/dist/ambient/event-graph-reducer.js +300 -0
- package/dist/ambient/ingestion-adapter.js +203 -0
- package/dist/ambient/journal/crypto.js +58 -0
- package/dist/ambient/journal/journal-store.js +284 -0
- package/dist/ambient/journal/types.js +7 -0
- package/dist/ambient/summaries/retriever.js +55 -0
- package/dist/ambient/summaries/summary-engine.js +128 -0
- package/dist/ambient/summaries/types.js +4 -0
- package/dist/attention/policy.js +22 -16
- package/dist/auth/credential-store.js +15 -6
- package/dist/cli/query.js +276 -69
- package/dist/cli/work-item.js +24 -0
- package/dist/line/client/auth-service/client.js +7 -8
- package/dist/line/client/buddy-service/client.js +73 -0
- package/dist/line/client/index.js +28 -1
- package/dist/line/client/obs-media-client.js +2 -1
- package/dist/line/client/square-service/client.js +298 -0
- package/dist/line/client/talk-service/command-client.js +9 -1
- package/dist/line/client/talk-service/message-id-query.js +27 -3
- package/dist/line/client/talk-service/requests.js +9 -0
- package/dist/line/core/auth-session-runtime.js +1 -1
- package/dist/line/core/chat-event.js +2 -0
- package/dist/line/core/chat-runtime-service.js +18 -3
- package/dist/line/core/config.js +1 -0
- package/dist/line/core/flex-message.js +323 -0
- package/dist/line/core/service.js +0 -1
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +54 -0
- package/dist/mcp/handlers/send.js +8 -11
- package/dist/mcp/media.js +101 -2
- package/dist/mcp/names.js +78 -6
- package/dist/search/default-embedder.js +12 -3
- package/dist/version.js +1 -1
- package/dist/work-item/adapter.js +1 -0
- package/dist/work-item/destination-contracts.js +1 -0
- package/dist/work-item/event-context.js +73 -0
- package/dist/work-item/gold-contract.js +101 -0
- package/dist/work-item/gold-coverage.js +96 -0
- package/dist/work-item/gold-derivation.js +67 -0
- package/dist/work-item/gold-split.js +143 -0
- package/dist/work-item/index.js +23 -0
- package/dist/work-item/line-adapter.js +111 -0
- package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
- package/dist/work-item/local-provider.js +38 -0
- package/dist/work-item/local-work-event-provider.js +56 -0
- package/dist/work-item/onnx-work-event-adapter.js +223 -0
- package/dist/work-item/provider.js +21 -0
- package/dist/work-item/registry.js +19 -0
- package/dist/work-item/service.js +85 -0
- package/dist/work-item/store.js +97 -0
- package/dist/work-item/types.js +79 -0
- package/dist/work-item/work-event-graph-engine.js +63 -0
- package/dist/work-item/work-event-inference.js +267 -0
- package/dist/work-item/work-event-matcher.js +105 -0
- package/dist/work-item/work-event-model-manifest.js +39 -0
- package/dist/work-item/work-event-store.js +125 -0
- package/dist/work-item/work-event.js +123 -0
- package/package.json +24 -3
- package/run.mjs +162 -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 =
|
|
18
|
-
const ATTENTION_POLICY_VERSION =
|
|
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
|
|
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 '[
|
|
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
|
|
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
|
-
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
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 =
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
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: '
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
conversation.
|
|
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) =>
|
|
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
|
-
|
|
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] :
|
|
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
|
-
|
|
108
|
-
throw new Error(
|
|
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
|
+
}
|