@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
@@ -1,3 +1,132 @@
1
+ import { createHash } from 'node:crypto';
2
+ function flexLayoutFingerprint(root) {
3
+ return createHash('sha256').update(JSON.stringify(root)).digest('hex');
4
+ }
5
+ const FLEX_METRIC = {
6
+ none: 0,
7
+ xxs: 2,
8
+ xs: 4,
9
+ sm: 6,
10
+ md: 8,
11
+ lg: 12,
12
+ xl: 16,
13
+ xxl: 20,
14
+ };
15
+ function resolveMetric(value, fallback = 0) {
16
+ if (typeof value !== 'string')
17
+ return fallback;
18
+ if (/^\d+(\.\d+)?px$/.test(value))
19
+ return Number(value.slice(0, -2));
20
+ return FLEX_METRIC[value] ?? fallback;
21
+ }
22
+ function resolveOptionalMetric(value) {
23
+ return typeof value === 'string' ? resolveMetric(value) : null;
24
+ }
25
+ function resolveDimension(value) {
26
+ if (typeof value !== 'string')
27
+ return { points: null, fraction: null };
28
+ if (/^\d+(\.\d+)?px$/.test(value))
29
+ return { points: Number(value.slice(0, -2)), fraction: null };
30
+ if (/^\d+(\.\d+)?%$/.test(value))
31
+ return { points: null, fraction: Number(value.slice(0, -1)) / 100 };
32
+ return { points: null, fraction: null };
33
+ }
34
+ function resolveFontSize(value) {
35
+ if (typeof value === 'string' && /^\d+(\.\d+)?px$/.test(value))
36
+ return Number(value.slice(0, -2));
37
+ const sizes = {
38
+ xxs: 10,
39
+ xs: 11,
40
+ sm: 12,
41
+ md: 13,
42
+ lg: 15,
43
+ xl: 18,
44
+ xxl: 22,
45
+ '3xl': 26,
46
+ '4xl': 30,
47
+ '5xl': 34,
48
+ };
49
+ return sizes[typeof value === 'string' ? value : 'md'] ?? 13;
50
+ }
51
+ function resolveAspectRatio(value) {
52
+ if (typeof value !== 'string')
53
+ return 1;
54
+ const [width, height] = value.split(':').map(Number);
55
+ return width > 0 && height > 0 ? width / height : 1;
56
+ }
57
+ function normalizeGradient(value) {
58
+ const raw = parseObject(value);
59
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
60
+ return null;
61
+ const gradient = raw;
62
+ if (gradient.type !== 'linearGradient' ||
63
+ typeof gradient.startColor !== 'string' ||
64
+ typeof gradient.endColor !== 'string')
65
+ return null;
66
+ const angle = typeof gradient.angle === 'string'
67
+ ? Number(gradient.angle.replace(/deg$/, ''))
68
+ : 0;
69
+ const centerPosition = typeof gradient.centerPosition === 'string'
70
+ ? Number(gradient.centerPosition.replace(/%$/, '')) / 100
71
+ : null;
72
+ return {
73
+ type: 'linearGradient',
74
+ angle: Number.isFinite(angle) ? angle : 0,
75
+ startColor: gradient.startColor,
76
+ centerColor: typeof gradient.centerColor === 'string' ? gradient.centerColor : null,
77
+ endColor: gradient.endColor,
78
+ centerPosition: centerPosition !== null && Number.isFinite(centerPosition)
79
+ ? centerPosition
80
+ : null,
81
+ };
82
+ }
83
+ function resolveFlexStyle(node) {
84
+ const all = resolveMetric(node.paddingAll);
85
+ const width = resolveDimension(node.width);
86
+ const maxWidth = resolveDimension(node.maxWidth);
87
+ const height = resolveDimension(node.height);
88
+ const maxHeight = resolveDimension(node.maxHeight);
89
+ return {
90
+ axis: ['horizontal', 'vertical', 'baseline'].includes(node.layout)
91
+ ? node.layout
92
+ : null,
93
+ spacing: resolveMetric(node.spacing, 8),
94
+ paddingTop: resolveMetric(node.paddingTop, all),
95
+ paddingEnd: resolveMetric(node.paddingEnd, all),
96
+ paddingBottom: resolveMetric(node.paddingBottom, all),
97
+ paddingStart: resolveMetric(node.paddingStart, all),
98
+ marginBefore: resolveMetric(node.margin),
99
+ fontSize: resolveFontSize(node.size),
100
+ fontWeight: node.weight === 'bold' ? 700 : 400,
101
+ lineLimit: Number.isFinite(node.maxLines)
102
+ ? Number(node.maxLines)
103
+ : node.wrap === true
104
+ ? null
105
+ : 1,
106
+ aspectRatio: resolveAspectRatio(node.aspectRatio),
107
+ contentMode: node.aspectMode === 'cover' ? 'cover' : 'fit',
108
+ buttonStyle: node.style === 'primary' || node.style === 'secondary'
109
+ ? node.style
110
+ : 'link',
111
+ buttonHeight: node.height === 'sm' ? 34 : 40,
112
+ cornerRadius: resolveMetric(node.cornerRadius),
113
+ borderWidth: resolveMetric(node.borderWidth),
114
+ flexGrow: Number.isFinite(node.flex) ? Math.max(0, Number(node.flex)) : 0,
115
+ position: node.position === 'absolute' ? 'absolute' : 'relative',
116
+ offsetTop: resolveOptionalMetric(node.offsetTop),
117
+ offsetBottom: resolveOptionalMetric(node.offsetBottom),
118
+ offsetStart: resolveOptionalMetric(node.offsetStart),
119
+ offsetEnd: resolveOptionalMetric(node.offsetEnd),
120
+ widthPoints: width.points,
121
+ widthFraction: width.fraction,
122
+ maxWidthPoints: maxWidth.points,
123
+ maxWidthFraction: maxWidth.fraction,
124
+ heightPoints: height.points,
125
+ heightFraction: height.fraction,
126
+ maxHeightPoints: maxHeight.points,
127
+ maxHeightFraction: maxHeight.fraction,
128
+ };
129
+ }
1
130
  const FLEX_PAYLOAD_KEYS = [
2
131
  'FLEX_JSON',
3
132
  'FLEX_CONTENTS',
@@ -31,6 +160,200 @@ function webUrl(value) {
31
160
  return null;
32
161
  }
33
162
  }
163
+ export function resolveImagemapImageUrl(rawUrl, _canvasWidth, targetPixelWidth = 640) {
164
+ const base = webUrl(rawUrl);
165
+ if (!base)
166
+ return null;
167
+ const supported = [240, 300, 460, 700, 1040];
168
+ const requested = Math.max(1, targetPixelWidth);
169
+ const width = supported.find((candidate) => candidate >= requested) ??
170
+ supported[supported.length - 1];
171
+ const url = new URL(base);
172
+ if (!url.pathname.endsWith(`/${width}`)) {
173
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/${width}`;
174
+ }
175
+ return url.toString();
176
+ }
177
+ function flexAction(raw) {
178
+ const parsed = parseObject(raw);
179
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
180
+ return null;
181
+ const record = parsed;
182
+ const params = record.params && typeof record.params === 'object' ? record.params : record;
183
+ const rawType = String(record.type ?? '').toLowerCase();
184
+ const uri = webUrl(params.linkUri) || webUrl(record.uri);
185
+ const text = typeof params.text === 'string'
186
+ ? params.text
187
+ : typeof record.text === 'string'
188
+ ? record.text
189
+ : null;
190
+ const label = String(record.label ?? record.text ?? params.displayText ?? '').trim();
191
+ const data = typeof record.data === 'string'
192
+ ? record.data
193
+ : typeof params.data === 'string'
194
+ ? params.data
195
+ : null;
196
+ const type = rawType === 'web' || rawType === 'uri'
197
+ ? 'uri'
198
+ : rawType === 'message' || rawType === 'sendmessage'
199
+ ? 'message'
200
+ : rawType === 'postback'
201
+ ? 'postback'
202
+ : 'unknown';
203
+ return { type, label, uri, text, data };
204
+ }
205
+ function normalizeFlexNode(raw, depth = 0, block = null, blockStyle = null) {
206
+ if (depth > 24)
207
+ return null;
208
+ const parsed = parseObject(raw);
209
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
210
+ return null;
211
+ const node = parsed;
212
+ const nodeType = String(node.type ?? 'box').toLowerCase();
213
+ const parsedBlockStyle = parseObject(blockStyle);
214
+ const inherited = parsedBlockStyle &&
215
+ typeof parsedBlockStyle === 'object' &&
216
+ !Array.isArray(parsedBlockStyle)
217
+ ? parsedBlockStyle
218
+ : {};
219
+ const childValues = Array.isArray(node.contents)
220
+ ? node.contents.map((child) => [child, null, null])
221
+ : ['header', 'hero', 'body', 'footer']
222
+ .filter((key) => Boolean(node[key]))
223
+ .map((key) => [node[key], key, node.styles?.[key]]);
224
+ if (node.altContent)
225
+ childValues.push([node.altContent, null, null]);
226
+ return {
227
+ type: nodeType,
228
+ block,
229
+ layout: typeof node.layout === 'string' ? node.layout : null,
230
+ text: typeof node.text === 'string' ? node.text : null,
231
+ url: webUrl(node.url) || webUrl(node.previewUrl),
232
+ color: typeof node.color === 'string' ? node.color : null,
233
+ backgroundColor: typeof node.backgroundColor === 'string'
234
+ ? node.backgroundColor
235
+ : typeof inherited.backgroundColor === 'string'
236
+ ? inherited.backgroundColor
237
+ : nodeType === 'bubble'
238
+ ? '#FFFFFF'
239
+ : null,
240
+ background: normalizeGradient(node.background),
241
+ size: typeof node.size === 'string' ? node.size : null,
242
+ weight: typeof node.weight === 'string' ? node.weight : null,
243
+ wrap: typeof node.wrap === 'boolean' ? node.wrap : null,
244
+ margin: typeof node.margin === 'string' ? node.margin : null,
245
+ spacing: typeof node.spacing === 'string' ? node.spacing : null,
246
+ paddingAll: typeof node.paddingAll === 'string' ? node.paddingAll : null,
247
+ paddingTop: typeof node.paddingTop === 'string' ? node.paddingTop : null,
248
+ paddingBottom: typeof node.paddingBottom === 'string' ? node.paddingBottom : null,
249
+ paddingStart: typeof node.paddingStart === 'string' ? node.paddingStart : null,
250
+ paddingEnd: typeof node.paddingEnd === 'string' ? node.paddingEnd : null,
251
+ style: typeof node.style === 'string' ? node.style : null,
252
+ height: typeof node.height === 'string' ? node.height : null,
253
+ width: typeof node.width === 'string' ? node.width : null,
254
+ maxWidth: typeof node.maxWidth === 'string' ? node.maxWidth : null,
255
+ maxHeight: typeof node.maxHeight === 'string' ? node.maxHeight : null,
256
+ justifyContent: typeof node.justifyContent === 'string' ? node.justifyContent : null,
257
+ alignItems: typeof node.alignItems === 'string' ? node.alignItems : null,
258
+ cornerRadius: typeof node.cornerRadius === 'string' ? node.cornerRadius : null,
259
+ borderColor: typeof node.borderColor === 'string' ? node.borderColor : null,
260
+ borderWidth: typeof node.borderWidth === 'string' ? node.borderWidth : null,
261
+ separator: inherited.separator === true,
262
+ separatorColor: typeof inherited.separatorColor === 'string'
263
+ ? inherited.separatorColor
264
+ : null,
265
+ maxLines: Number.isFinite(node.maxLines) ? Number(node.maxLines) : null,
266
+ decoration: typeof node.decoration === 'string' ? node.decoration : null,
267
+ position: typeof node.position === 'string' ? node.position : null,
268
+ offsetTop: typeof node.offsetTop === 'string' ? node.offsetTop : null,
269
+ offsetBottom: typeof node.offsetBottom === 'string' ? node.offsetBottom : null,
270
+ offsetStart: typeof node.offsetStart === 'string' ? node.offsetStart : null,
271
+ offsetEnd: typeof node.offsetEnd === 'string' ? node.offsetEnd : null,
272
+ resolved: resolveFlexStyle(node),
273
+ aspectRatio: typeof node.aspectRatio === 'string' ? node.aspectRatio : null,
274
+ aspectMode: node.aspectMode === 'fit' || node.aspectMode === 'cover'
275
+ ? node.aspectMode
276
+ : null,
277
+ align: typeof node.align === 'string' ? node.align : null,
278
+ gravity: typeof node.gravity === 'string' ? node.gravity : null,
279
+ flex: Number.isFinite(node.flex) ? Number(node.flex) : null,
280
+ action: flexAction(node.action),
281
+ children: childValues
282
+ .map(([child, childBlock, childStyle]) => normalizeFlexNode(child, depth + 1, childBlock, childStyle))
283
+ .filter((child) => Boolean(child)),
284
+ };
285
+ }
286
+ export function processFlexMessage(message) {
287
+ const metadata = message?.contentMetadata && typeof message.contentMetadata === 'object'
288
+ ? message.contentMetadata
289
+ : {};
290
+ const explicitAlt = typeof metadata.ALT_TEXT === 'string' ? metadata.ALT_TEXT : '';
291
+ const markup = parseObject(metadata.MARKUP_JSON);
292
+ if (markup && typeof markup === 'object' && !Array.isArray(markup)) {
293
+ const record = markup;
294
+ const canvas = record.canvas;
295
+ const scene = record.scenes?.[canvas?.initialScene];
296
+ if (canvas && scene && Array.isArray(scene.listeners)) {
297
+ const regions = scene.listeners.flatMap((listener) => {
298
+ const [x, y, width, height] = listener?.params ?? [];
299
+ const action = flexAction(record.actions?.[listener?.action]);
300
+ if (![x, y, width, height].every(Number.isFinite) || !action)
301
+ return [];
302
+ return [{ x, y, width, height, action }];
303
+ });
304
+ return {
305
+ version: 1,
306
+ layoutVersion: 1,
307
+ layoutFingerprint: flexLayoutFingerprint(null),
308
+ kind: 'imagemap',
309
+ altText: explicitAlt,
310
+ width: Number(canvas.width) || null,
311
+ height: Number(canvas.height) || null,
312
+ imageUrl: resolveImagemapImageUrl(metadata.DOWNLOAD_URL, canvas.width),
313
+ regions,
314
+ root: null,
315
+ };
316
+ }
317
+ }
318
+ for (const key of FLEX_PAYLOAD_KEYS) {
319
+ if (key === 'MARKUP_JSON')
320
+ continue;
321
+ const parsed = parseObject(metadata[key]);
322
+ if (!parsed)
323
+ continue;
324
+ const root = normalizeFlexNode(parsed);
325
+ if (root) {
326
+ return {
327
+ version: 1,
328
+ layoutVersion: 1,
329
+ layoutFingerprint: flexLayoutFingerprint(root),
330
+ kind: 'flex',
331
+ altText: explicitAlt,
332
+ width: null,
333
+ height: null,
334
+ imageUrl: null,
335
+ regions: [],
336
+ root,
337
+ };
338
+ }
339
+ }
340
+ const textPayload = parseObject(message?.text);
341
+ const root = normalizeFlexNode(textPayload);
342
+ return root
343
+ ? {
344
+ version: 1,
345
+ layoutVersion: 1,
346
+ layoutFingerprint: flexLayoutFingerprint(root),
347
+ kind: 'flex',
348
+ altText: explicitAlt,
349
+ width: null,
350
+ height: null,
351
+ imageUrl: null,
352
+ regions: [],
353
+ root,
354
+ }
355
+ : null;
356
+ }
34
357
  /** Interpret LINE bubble/carousel content in header→hero→body→footer order. */
35
358
  export function interpretFlexPayload(payload) {
36
359
  const texts = [];
@@ -58,7 +58,6 @@ export class LineProtocolService extends EventEmitter {
58
58
  invalidateSession;
59
59
  tryRefreshToken;
60
60
  resumeSession;
61
- resolveName;
62
61
  getContact;
63
62
  getGroup;
64
63
  listStickerPackages;
@@ -1,5 +1,5 @@
1
1
  import { createCliLogger } from '../../util/log.js';
2
- import { resolveUserNames } from '../names.js';
2
+ import { contactDisplayName, resolveUserNames } from '../names.js';
3
3
  import { jsonResult, toolError, toonResult } from './shared.js';
4
4
  const log = createCliLogger('Yomi');
5
5
  /**
@@ -23,12 +23,16 @@ async function fetchAllContacts(service) {
23
23
  if (!contact?.mid) {
24
24
  continue;
25
25
  }
26
- if (contact.displayName) {
27
- service.nameCache.set(contact.mid, contact.displayName);
26
+ const name = contactDisplayName(contact);
27
+ if (name) {
28
+ service.nameCache.set(contact.mid, name);
28
29
  }
29
30
  summaries.push({
30
31
  mid: contact.mid,
31
- displayName: contact.displayName ?? null,
32
+ displayName: name,
33
+ ...(contact.displayNameOverridden && contact.displayName
34
+ ? { profileName: contact.displayName }
35
+ : {}),
32
36
  });
33
37
  }
34
38
  return summaries;
@@ -47,7 +51,8 @@ export async function handleListContacts(service) {
47
51
  }
48
52
  /**
49
53
  * Handle `find_contact` — case-insensitive substring match over the
50
- * friend list's displayName, so a caller can resolve a person's name to
54
+ * friend list's displayName (and profileName, when the user renamed the
55
+ * friend), so a caller can resolve a person's name to
51
56
  * the MID `send_message` needs for a 1:1. Pure lookup: no fuzzy scoring,
52
57
  * no ranking by interaction history.
53
58
  *
@@ -61,7 +66,8 @@ export async function handleFindContact(service, args) {
61
66
  }
62
67
  const needle = args.name.toLowerCase();
63
68
  const contacts = await fetchAllContacts(service);
64
- const matches = contacts.filter((contact) => contact.displayName?.toLowerCase().includes(needle));
69
+ const matches = contacts.filter((contact) => contact.displayName?.toLowerCase().includes(needle) ||
70
+ contact.profileName?.toLowerCase().includes(needle));
65
71
  return toonResult(matches);
66
72
  }
67
73
  /**
@@ -1,3 +1,4 @@
1
+ import { isSquareChatMid } from '../../line/client/square-service/client.js';
1
2
  import { sanitizeMessagePreview } from '../../line/core/message-preview.js';
2
3
  import { decryptLineMessage } from '../../line/core/message-query-service.js';
3
4
  import { getExcludedChatIds } from '../../search/scope.js';
@@ -73,6 +74,38 @@ export async function handleListConversations(service, args) {
73
74
  unreadCount: box.unreadCount ?? 0,
74
75
  };
75
76
  }));
77
+ try {
78
+ const squareChats = await service.client.getJoinedSquareChats(args.limit ?? 20);
79
+ for (const chat of squareChats) {
80
+ let last = chat.lastMessage;
81
+ let senderName = chat.lastSenderName;
82
+ if (!last) {
83
+ const recent = await service.client.getSquareChatMessages(chat.id, 1);
84
+ const normalized = recent.at(-1);
85
+ if (normalized) {
86
+ last = {
87
+ 4: normalized.id,
88
+ 5: normalized.createdTime,
89
+ 10: normalized.text,
90
+ 15: normalized.contentType,
91
+ };
92
+ senderName = normalized.fromName;
93
+ }
94
+ }
95
+ const preview = sanitizeMessagePreview(last?.[10]);
96
+ conversations.push({
97
+ id: chat.id,
98
+ lastMessagePreview: maskInto(acc, preview || (last ? '[社群訊息]' : null)),
99
+ name: maskInto(acc, chat.name),
100
+ unreadCount: chat.unreadCount,
101
+ ...(senderName ? { lastSenderName: maskInto(acc, senderName) } : {}),
102
+ });
103
+ }
104
+ }
105
+ catch {
106
+ // OpenChat availability differs by LINE account/region. TalkService
107
+ // conversations remain authoritative when SquareService is unavailable.
108
+ }
76
109
  const content = [
77
110
  { type: 'text', text: toonText(conversations) },
78
111
  ];
@@ -109,6 +142,27 @@ export async function handleGetChatMessages(service, args) {
109
142
  if (!args.chatId) {
110
143
  return toolError('chatId is required.');
111
144
  }
145
+ if (isSquareChatMid(args.chatId)) {
146
+ const squareMessages = await service.client.getSquareChatMessages(args.chatId, args.count ?? 50);
147
+ const acc = createPhiAccumulator();
148
+ const shaped = squareMessages.map((message) => ({
149
+ createdTime: message.createdTime,
150
+ deliveredTime: message.createdTime,
151
+ from: message.fromMid,
152
+ fromName: maskInto(acc, message.fromName),
153
+ id: message.id,
154
+ mediaType: resolveLineMediaType(Number(message.contentType)),
155
+ mentions: message.contentMetadata?.MENTION ?? null,
156
+ text: maskInto(acc, sanitizeMessagePreview(message.text) || '[社群訊息]'),
157
+ e2eeDecrypted: null,
158
+ service: 'SQUARE',
159
+ }));
160
+ const content = [{ type: 'text', text: toonText(shaped) }];
161
+ const note = phiNote(acc);
162
+ if (note)
163
+ content.push(note);
164
+ return { content };
165
+ }
112
166
  const fetched = args.before
113
167
  ? await service.getPreviousMessages(args.chatId, args.count ?? 50, args.before)
114
168
  : await service.getRecentMessages(args.chatId, args.count ?? 50);
@@ -226,6 +226,11 @@ export async function handleSendFile(service, args) {
226
226
  * person's mid in contentMetadata. `displayName` is resolved from the mid when
227
227
  * the caller omits it. One send per call; honest error on failure.
228
228
  *
229
+ * The recipient reads this string, so it is always the contact's own profile
230
+ * name (contact-query field 22) and never the name a shared cache holds. That
231
+ * cache exists to show the user the name they gave a friend locally, which is
232
+ * a private label and must not travel to a third party.
233
+ *
229
234
  * @param service - Resumed LineProtocolService.
230
235
  * @param args - Tool arguments.
231
236
  * @returns MCP tool result.
@@ -240,19 +245,11 @@ export async function handleSendContact(service, args) {
240
245
  let displayName = args.displayName ?? '';
241
246
  if (!displayName) {
242
247
  try {
243
- displayName = service.resolveName(args.contactMid) || '';
248
+ const contact = await service.getContact(args.contactMid);
249
+ displayName = contact?.displayName ?? '';
244
250
  }
245
251
  catch {
246
- displayName = '';
247
- }
248
- if (!displayName) {
249
- try {
250
- const contact = await service.getContact(args.contactMid);
251
- displayName = contact?.displayName ?? '';
252
- }
253
- catch {
254
- // Leave empty — LINE resolves the name from the mid on the recipient side.
255
- }
252
+ // Leave empty — LINE resolves the name from the mid on the recipient side.
256
253
  }
257
254
  }
258
255
  const result = await service.sendContact(args.chatId, args.contactMid, displayName);
@@ -79,19 +79,33 @@ description: Manage and interact with user's LINE account via Yomi E2EE MCP Serv
79
79
 
80
80
  # Yomi LINE Copilot Skill
81
81
 
82
- You have real-time access to the user's LINE account through the Yomi Letter-Sealing E2EE MCP Server.
82
+ You have real-time access to the user's LINE account through the Yomi Letter-Sealing E2EE server. Discover the current tools and complete a scoped read before claiming that access; the names below describe expected capabilities, not proof they are connected.
83
83
 
84
- ## Capabilities
85
- 1. \`get_recent_messages\`: Retrieve recent decrypted messages.
86
- 2. \`search_messages\`: Full-text search across conversations.
87
- 3. \`list_contacts\`: Retrieve friends and group information.
88
- 4. \`send_message\`: Send messages to chats with confirmation.
84
+ ## Reading
85
+ 1. \`list_conversations\`: List the chats the account can see.
86
+ 2. \`get_chat_messages\`: Read the recent decrypted messages of one chat.
87
+ 3. \`collect_messages\`: Collect messages across several chats.
88
+ 4. \`search_messages\`: Full-text search across conversations.
89
+ 5. \`get_unread_digest\`: Summarize what is unread.
90
+ 6. \`get_insight\`: Ask the account model for an insight about the messages.
91
+ 7. \`find_contact\` and \`list_contacts\`: Resolve a person or a group to its chat id.
92
+
93
+ ## Writing
94
+ 1. \`send_message\`: Send a text message to a chat.
95
+ 2. \`unsend_message\`: Recall a message the account sent.
96
+
97
+ ## Operating the server
98
+ - The Model Context Protocol server speaks stdio and lives only while its stdin stays open. A command that closes stdin ends the server, so keep the pipe open for as long as the session needs it.
99
+ - The \`yomi messages\` CLI reports an empty list; only the Model Context Protocol tools above return real messages.
100
+ - \`unsend_message\` is irreversible, and LINE accepts a recall only within one hour of sending. A recall past that window is refused by LINE itself, not by the tool.
89
101
 
90
102
  ## Guidelines
91
103
  - Summarize conversations concisely and highlight action items or mentions needing attention.
92
- - When drafting a reply, ask the user for confirmation before executing \`send_message\`.
104
+ - Drafting does not authorize sending. Require explicit sending authorization for the recipient and content; reuse that exact authorization when already given.
105
+ - A recall writes to the conversation as well, so it needs the same explicit authorization as sending.
93
106
  `;
94
107
  const skillDirs = [
108
+ join(home, '.agents', 'skills', 'yomi-line-manager'),
95
109
  join(home, '.claude', 'skills', 'yomi-line-manager'),
96
110
  join(home, '.gemini', 'antigravity-cli', 'builtin', 'skills', 'yomi-line-manager'),
97
111
  join(home, '.codex', 'skills', 'yomi-line-manager'),
package/dist/mcp/media.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * callers get an honest error naming the reason.
15
15
  */
16
16
  import { downloadLineMessageData, downloadLineObsMediaObject, } from '../line/client/obs-media-client.js';
17
+ import { isSquareChatMid } from '../line/client/square-service/client.js';
17
18
  import { CONTENT_TYPE } from '../line/core/constants.js';
18
19
  import { decryptLineMediaBytes } from '../line/core/media-decrypt.js';
19
20
  /** LINE content types this server can download and decrypt via the OBS path. */
@@ -57,6 +58,51 @@ const MIME_BY_EXTENSION = {
57
58
  webp: 'image/webp',
58
59
  zip: 'application/zip',
59
60
  };
61
+ const TRUSTED_PUBLIC_MEDIA_HOST_SUFFIXES = [
62
+ 'line-scdn.net',
63
+ 'line-apps.com',
64
+ 'cdn.hinet.net',
65
+ ];
66
+ export function resolvePublicMediaUrl(message, preview) {
67
+ const metadata = message?.contentMetadata ?? {};
68
+ if (metadata.PUBLIC !== 'true')
69
+ return null;
70
+ const raw = preview
71
+ ? metadata.PREVIEW_URL || metadata.DOWNLOAD_URL
72
+ : metadata.DOWNLOAD_URL || metadata.PREVIEW_URL;
73
+ if (typeof raw !== 'string' || !raw)
74
+ return null;
75
+ try {
76
+ const url = new URL(raw);
77
+ const hostname = url.hostname.toLowerCase();
78
+ if (url.protocol !== 'https:' ||
79
+ !TRUSTED_PUBLIC_MEDIA_HOST_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))) {
80
+ return null;
81
+ }
82
+ return url;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function downloadPublicMedia(url) {
89
+ const response = await fetch(url, { redirect: 'error' });
90
+ if (!response.ok) {
91
+ throw new Error(`Public LINE media download failed (${response.status})`);
92
+ }
93
+ const length = Number(response.headers.get('content-length') ?? 0);
94
+ if (Number.isFinite(length) && length > 25 * 1024 * 1024) {
95
+ throw new Error('Public LINE media exceeds 25 MB');
96
+ }
97
+ const bytes = Buffer.from(await response.arrayBuffer());
98
+ if (bytes.length === 0 || bytes.length > 25 * 1024 * 1024) {
99
+ throw new Error('Public LINE media returned invalid bytes');
100
+ }
101
+ return {
102
+ bytes,
103
+ mimeType: response.headers.get('content-type')?.split(';')[0] || null,
104
+ };
105
+ }
60
106
  /**
61
107
  * Resolve the short media-kind label for a LINE content type.
62
108
  *
@@ -125,10 +171,18 @@ export function resolveLineMediaDescriptor(message) {
125
171
  }
126
172
  const dataPayload = parseJson(message?.text, {});
127
173
  const mediaInfo = parseJson(contentMetadata.MEDIA_CONTENT_INFO, {});
174
+ const fileName = dataPayload.fileName ||
175
+ (typeof contentMetadata.FILE_NAME === 'string' &&
176
+ contentMetadata.FILE_NAME) ||
177
+ (typeof contentMetadata.FILE_NAME_ORIGINAL === 'string' &&
178
+ contentMetadata.FILE_NAME_ORIGINAL) ||
179
+ (typeof contentMetadata.name === 'string' && contentMetadata.name) ||
180
+ null;
181
+ const extension = mediaInfo.extension || (fileName ? fileName.split('.').pop() : null) || null;
128
182
  return {
129
- fileName: dataPayload.fileName || null,
183
+ fileName,
130
184
  keyMaterial: dataPayload.keyMaterial || null,
131
- mimeType: resolveMimeType(mediaInfo.extension || null, Number(message?.contentType || 0)),
185
+ mimeType: resolveMimeType(extension, Number(message?.contentType || 0)),
132
186
  oid: String(contentMetadata.OID),
133
187
  sid: String(contentMetadata.SID),
134
188
  };
@@ -165,6 +219,38 @@ class UnsupportedMediaTypeError extends LineMediaAccessError {
165
219
  * @returns Decrypted media bytes, resolved MIME type, filename, and content type.
166
220
  */
167
221
  export async function fetchLineMessageMedia(service, chatId, messageId, preview) {
222
+ if (isSquareChatMid(chatId)) {
223
+ const mainMessages = await service.client.getSquareChatMessages(chatId, 100);
224
+ let message = mainMessages.find((candidate) => candidate.id === messageId);
225
+ if (!message) {
226
+ for (const root of mainMessages.filter((candidate) => candidate.isThreadRoot && candidate.threadMid)) {
227
+ const replies = await service.client.getSquareChatMessages(chatId, 100, root.threadMid);
228
+ message = replies.find((candidate) => candidate.id === messageId);
229
+ if (message)
230
+ break;
231
+ }
232
+ }
233
+ if (!message) {
234
+ throw new MissingDecryptMaterialError(`Square message ${messageId} not found in ${chatId}`);
235
+ }
236
+ const contentType = Number(message.contentType);
237
+ if (!DOWNLOADABLE_CONTENT_TYPES.has(contentType)) {
238
+ throw new UnsupportedMediaTypeError(contentType, messageId);
239
+ }
240
+ const downloaded = await downloadLineMessageData(service.client, {
241
+ messageId,
242
+ preview,
243
+ square: true,
244
+ });
245
+ return {
246
+ bytes: downloaded.bytes,
247
+ contentType,
248
+ fileName: message.contentMetadata?.FILE_NAME || null,
249
+ mimeType: downloaded.mimeType ||
250
+ DEFAULT_MIME_BY_CONTENT_TYPE[contentType] ||
251
+ 'application/octet-stream',
252
+ };
253
+ }
168
254
  const messages = await service.getRecentMessages(chatId, 100);
169
255
  const message = messages.find((candidate) => candidate.id === messageId);
170
256
  if (!message) {
@@ -174,6 +260,19 @@ export async function fetchLineMessageMedia(service, chatId, messageId, preview)
174
260
  if (!DOWNLOADABLE_CONTENT_TYPES.has(contentType)) {
175
261
  throw new UnsupportedMediaTypeError(contentType, messageId);
176
262
  }
263
+ const publicMediaUrl = resolvePublicMediaUrl(message, preview);
264
+ if (publicMediaUrl) {
265
+ const downloaded = await downloadPublicMedia(publicMediaUrl);
266
+ const extension = publicMediaUrl.pathname.split('.').pop() ?? null;
267
+ return {
268
+ bytes: downloaded.bytes,
269
+ contentType,
270
+ fileName: null,
271
+ mimeType: downloaded.mimeType ||
272
+ resolveMimeType(extension, contentType) ||
273
+ 'application/octet-stream',
274
+ };
275
+ }
177
276
  const descriptor = resolveLineMediaDescriptor(message);
178
277
  if (!descriptor) {
179
278
  // Older/non-E2EE Talk messages carry MEDIA_CONTENT_INFO but omit OID/SID.