@rikaidev/yomi 0.4.2 → 0.5.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 (41) hide show
  1. package/README.md +18 -4
  2. package/dist/attention/policy.js +95 -0
  3. package/dist/auth/credential-store.js +32 -11
  4. package/dist/auth/keychain.js +7 -54
  5. package/dist/cli/query.js +668 -0
  6. package/dist/insight/relationships.js +1 -1
  7. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  8. package/dist/line/auth/pwless/index.js +1 -1
  9. package/dist/line/client/index.js +0 -2
  10. package/dist/line/client/obs-media-client.js +37 -0
  11. package/dist/line/client/parsers.js +1 -0
  12. package/dist/line/client/sticker-meta.js +32 -0
  13. package/dist/line/client/sync-service/client.js +31 -3
  14. package/dist/line/client/talk-service/contact-query.js +8 -1
  15. package/dist/line/client/talk-service/message-box-query.js +17 -0
  16. package/dist/line/client/talk-service/query-client.js +32 -26
  17. package/dist/line/client/talk-service/requests.js +1 -11
  18. package/dist/line/core/auth-session-runtime.js +16 -1
  19. package/dist/line/core/chat-event.js +33 -0
  20. package/dist/line/core/chat-runtime-service.js +0 -20
  21. package/dist/line/core/directory-runtime-service.js +10 -2
  22. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  23. package/dist/line/core/flex-message.js +130 -0
  24. package/dist/line/core/mention.js +1 -1
  25. package/dist/line/core/message-preview.js +60 -0
  26. package/dist/line/core/message-query-service.js +0 -20
  27. package/dist/line/core/service.js +1 -2
  28. package/dist/mcp/handlers/conversations.js +37 -4
  29. package/dist/mcp/handlers/login.js +1 -1
  30. package/dist/mcp/handlers/message-actions.js +1 -1
  31. package/dist/mcp/handlers/search.js +1 -1
  32. package/dist/mcp/handlers/shared.js +1 -1
  33. package/dist/mcp/integrator.js +168 -0
  34. package/dist/mcp/media.js +34 -2
  35. package/dist/mcp/phi-guard.js +2 -2
  36. package/dist/search/bigram.js +2 -2
  37. package/dist/search/collector.js +1 -1
  38. package/dist/search/embedder.js +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +4 -1
  41. package/run.mjs +218 -2
package/README.md CHANGED
@@ -479,6 +479,11 @@ fake success.
479
479
  this device a sign-in prompt — this is the single most common reason a first login
480
480
  appears to hang.
481
481
 
482
+ > **⚠️ Single Desktop Session Limit:** LINE allows only **one desktop client session at a time**
483
+ > per account. Because Yomi connects as a desktop secondary device (`DESKTOPMAC`), logging into Yomi
484
+ > will sign out your official LINE Desktop app (and signing back into LINE Desktop will invalidate
485
+ > Yomi's session). You cannot use Yomi and the official LINE Desktop client simultaneously.
486
+
482
487
  You only need to give the agent your phone number in E.164 form — it supplies the
483
488
  region itself (e.g. `TW` for a `+886` number) when it calls the tool.
484
489
 
@@ -516,16 +521,16 @@ entirely**.
516
521
  ### Sessions and credentials
517
522
 
518
523
  Yomi owns its own login. On startup it calls `resumeSession()` once, reading the
519
- LINE session from the macOS Keychain (service `com.yomi.credentials`, account
524
+ LINE session from the macOS Keychain (service `dev.rikai.yomi.credentials`, account
520
525
  `line`) and silently refreshing the token if needed.
521
526
 
522
527
  - **First-party credentials.** The passwordless login persists the auth token,
523
528
  refresh token, certificate, MID, and the E2EE keypair itself — then reads them
524
529
  back to verify the write actually landed. A login that can't be persisted fails
525
530
  loudly at login, not silently at the next restart.
526
- - **Backward compatibility.** If no session is found under `com.yomi.credentials`,
527
- Yomi reads the legacy `com.inboxd.credentials` entry once, migrates it forward,
528
- and never deletes it. An existing session keeps working with no re-login.
531
+ - **Shared session.** The session is stored in the canonical `dev.rikai.yomi.credentials`
532
+ keychain entry (or local credential store), allowing Yomi MCP and Yomi Desktop to
533
+ share the exact same LINE login session seamlessly.
529
534
  - **Platform note.** On macOS the session lives in the login Keychain. On **Linux
530
535
  and Windows** Yomi currently falls back to a local JSON file — functional, but
531
536
  less protected than an OS secret store, and less exercised than the macOS path.
@@ -590,6 +595,15 @@ Yomi runs as a **secondary device** on your account. That shapes what it can see
590
595
  per-epoch group keys, not a bug here. It can also cut sideways: the epoch your phone
591
596
  holds and the epoch Yomi holds need not be the same, so the **two devices can each
592
597
  read a different slice** of the very same group.
598
+ - **Installing Yomi does not itself rotate anything, so "before I installed Yomi" is
599
+ the wrong boundary.** Yomi only ever *resolves* an existing group key; it never
600
+ registers (mints) one, because minting rotates the group's shared secret for every
601
+ member and strands every message encrypted under the previous one. So a fresh install
602
+ picks up the **current** key and reads back to the last rotation — which may predate
603
+ the install by months. The boundary is the last rekey, not your install date. And
604
+ once Yomi has seen an epoch it keeps that key, so a rotation *after* install leaves
605
+ it able to read both sides. If a group comes back undecryptable all the way to
606
+ **today**, that is not this limitation — please report it.
593
607
  - **1:1 media** uses the account-level E2EE keychain, which a secondary device fully
594
608
  possesses (LINE syncs it during pairing) — Yomi **can** decrypt 1:1 images/files it
595
609
  can see.
@@ -0,0 +1,95 @@
1
+ import { getDefaultEmbedder } from '../search/default-embedder.js';
2
+ // Natural-language intent descriptions, not trigger-word lists. The local
3
+ // transformer embeds both descriptions and messages; vector similarity does
4
+ // the classification even when no words literally overlap.
5
+ const INTENT_ANCHORS = {
6
+ reply: [
7
+ '對方直接向我提出問題、請求協助或要求我做決定,正在等待我的答覆',
8
+ '工作交辦或需要我採取具體行動並回覆結果',
9
+ ],
10
+ deadline: [
11
+ '有明確期限、會議時間、預約或即將到期的工作,需要及時處理',
12
+ '緊急事故、風險或延誤,若不處理會造成後果',
13
+ ],
14
+ transaction: [
15
+ '與我有關的帳單、付款、訂單、物流、航班、預約或帳號安全狀態異常',
16
+ '交易失敗、行程變更或服務中斷,需要本人確認',
17
+ ],
18
+ important: [
19
+ '與目前工作或生活直接相關的重要進度與公告,值得本人閱讀但不必回覆',
20
+ '關鍵人物提供需要掌握的決策背景或狀態更新',
21
+ ],
22
+ ordinary: [
23
+ '一般聊天、問候、貼圖、轉傳或沒有下一步的日常資訊',
24
+ '內容與本人沒有直接關係,也不需要採取行動',
25
+ ],
26
+ promotion: [
27
+ '品牌官方帳號寄送的行銷廣告、優惠活動、導購內容或宣傳文案',
28
+ '用吸引人的疑問句包裝促銷活動,但並非真的在等待本人回答',
29
+ ],
30
+ };
31
+ function dot(a, b) {
32
+ let value = 0;
33
+ for (let i = 0; i < Math.min(a.length, b.length); i++)
34
+ value += a[i] * b[i];
35
+ return value;
36
+ }
37
+ /** Transformer-embedding semantic classification followed only by explicit
38
+ * product controls such as mute, mention and manual priority. */
39
+ export async function scoreAttentionBatch(inputs, embedder = getDefaultEmbedder()) {
40
+ if (inputs.length === 0)
41
+ return [];
42
+ const intents = Object.keys(INTENT_ANCHORS);
43
+ const anchors = intents.flatMap((intent) => INTENT_ANCHORS[intent].map((text) => ({ intent, text })));
44
+ const vectors = await embedder.embed([
45
+ ...anchors.map((anchor) => anchor.text),
46
+ ...inputs.map((input) => input.text || '無文字訊息'),
47
+ ]);
48
+ const anchorVectors = vectors.slice(0, anchors.length);
49
+ const messageVectors = vectors.slice(anchors.length);
50
+ return inputs.map((input, inputIndex) => {
51
+ if (input.isMuted)
52
+ return { score: -100, tier: 'filtered', reason: '已靜音' };
53
+ if (input.explicitPriority)
54
+ return { score: 70, tier: 'now', reason: '使用者標記優先' };
55
+ if (input.hasMention)
56
+ return { score: 65, tier: 'now', reason: '直接提及你' };
57
+ const similarities = new Map();
58
+ anchors.forEach((anchor, anchorIndex) => {
59
+ const similarity = dot(messageVectors[inputIndex], anchorVectors[anchorIndex]);
60
+ similarities.set(anchor.intent, Math.max(similarities.get(anchor.intent) ?? -Infinity, similarity));
61
+ });
62
+ const ranked = [...intents].sort((a, b) => (similarities.get(b) ?? 0) - (similarities.get(a) ?? 0));
63
+ let intent = ranked[0];
64
+ // Broadcast accounts commonly announce dated events and use rhetorical
65
+ // questions. Only a transaction/account/service state tied to the user may
66
+ // elevate them; generic events are not the user's deadline.
67
+ if (input.isOfficial && intent !== 'transaction')
68
+ intent = 'promotion';
69
+ const scoreByIntent = {
70
+ reply: 55,
71
+ deadline: 60,
72
+ transaction: 50,
73
+ important: 25,
74
+ ordinary: 0,
75
+ promotion: -40,
76
+ };
77
+ const score = scoreByIntent[intent] + (input.unreadCount > 0 ? 5 : 0);
78
+ const tier = score >= 55
79
+ ? 'now'
80
+ : score >= 35
81
+ ? 'today'
82
+ : score >= 20
83
+ ? 'know'
84
+ : 'filtered';
85
+ const reasons = {
86
+ reply: '對方可能在等你回覆',
87
+ deadline: '有期限、行程或緊急事項',
88
+ transaction: '與你的交易或服務狀態有關',
89
+ important: '重要資訊,但不需立即回覆',
90
+ ordinary: '一般資訊,沒有明確下一步',
91
+ promotion: '官方宣傳或與你較無關',
92
+ };
93
+ return { score, tier, reason: reasons[intent] };
94
+ });
95
+ }
@@ -13,9 +13,8 @@
13
13
  * rotated auth token during `resumeSession`), not only by a direct login.
14
14
  *
15
15
  * Keychain account: namespace (e.g. 'line')
16
- * KeychainService service name: see keychain.ts — writes go to Yomi's own
17
- * canonical namespace; a legacy namespace from before this split is read
18
- * as a fallback and migrated forward, never written to or deleted.
16
+ * KeychainService service name: see keychain.ts — stored in Yomi's canonical
17
+ * namespace (`dev.rikai.yomi.credentials`), shared across Yomi MCP and Yomi Desktop.
19
18
  *
20
19
  * The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
21
20
  * macOS an automated run that only redirected the data dir would still find —
@@ -197,8 +196,13 @@ export class CredentialStore {
197
196
  this.filePath = fallbackFilePath;
198
197
  this.cache = new Map();
199
198
  this.loaded = false;
199
+ // Desktop background bridges are deliberately read-only. They must use
200
+ // the user-isolated credential file and never invoke `security` to mutate
201
+ // the Keychain during a 15s poll (which causes repeated macOS prompts).
200
202
  this.secureStorageEnabled =
201
- process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
203
+ process.platform === 'darwin' &&
204
+ process.env.YOMI_NO_KEYCHAIN !== '1' &&
205
+ process.env.YOMI_READ_ONLY_SESSION !== '1';
202
206
  this.keychainService = this.secureStorageEnabled
203
207
  ? getKeychainService()
204
208
  : null;
@@ -217,20 +221,31 @@ export class CredentialStore {
217
221
  * @returns Serialized credential blob or null when missing.
218
222
  */
219
223
  async readPersistedBlob() {
224
+ try {
225
+ const fs = await import('node:fs/promises');
226
+ const fileData = await fs.readFile(this.filePath, 'utf-8');
227
+ if (fileData?.trim()) {
228
+ return fileData;
229
+ }
230
+ }
231
+ catch { }
220
232
  if (this.secureStorageEnabled && this.keychainService) {
221
233
  const result = await this.keychainService.getCredential(this.keychainAccount);
222
234
  if (result.success && result.password) {
235
+ try {
236
+ const fs = await import('node:fs/promises');
237
+ await fs.mkdir(dirname(this.filePath), {
238
+ recursive: true,
239
+ mode: 0o700,
240
+ });
241
+ await fs.writeFile(this.filePath, result.password, { mode: 0o600 });
242
+ }
243
+ catch { }
223
244
  return result.password;
224
245
  }
225
246
  return null;
226
247
  }
227
- try {
228
- const fs = await import('node:fs/promises');
229
- return await fs.readFile(this.filePath, 'utf-8');
230
- }
231
- catch {
232
- return null;
233
- }
248
+ return null;
234
249
  }
235
250
  /**
236
251
  * Load all credentials into cache from keychain or file.
@@ -263,6 +278,12 @@ export class CredentialStore {
263
278
  }
264
279
  if (this.secureStorageEnabled && this.keychainService) {
265
280
  await this.keychainService.setCredential(this.keychainAccount, blob);
281
+ try {
282
+ const fs = await import('node:fs/promises');
283
+ await fs.mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
284
+ await fs.writeFile(this.filePath, blob, { mode: 0o600 });
285
+ }
286
+ catch { }
266
287
  this.lastPersistedBlob = blob;
267
288
  return;
268
289
  }
@@ -8,20 +8,12 @@
8
8
  import { spawn } from 'node:child_process';
9
9
  import { createCliLogger } from '../util/log.js';
10
10
  /**
11
- * Yomi's own Keychain service name. Yomi performs a first-party
11
+ * Yomi's Keychain service name. Yomi performs a first-party
12
12
  * passwordless LINE login (see the `login` MCP tool) and persists the
13
13
  * resulting session here — auth token, certificate, refresh token, mid,
14
- * and the E2EE NaCl keypair. All writes go through this namespace only.
14
+ * and the E2EE NaCl keypair. Shared between Yomi MCP and Yomi Desktop.
15
15
  */
16
- const SERVICE_NAME = 'com.yomi.credentials';
17
- /**
18
- * Legacy keychain service name, read-only. Predates Yomi owning its own
19
- * credentials; kept solely so a live session created before this split
20
- * keeps working without forcing the operator through a phone-PIN
21
- * re-login. Entries here are read and migrated forward into
22
- * `SERVICE_NAME`, never written to and never deleted.
23
- */
24
- const LEGACY_SERVICE_NAME = 'com.inboxd.credentials';
16
+ const SERVICE_NAME = 'dev.rikai.yomi.credentials';
25
17
  const authLog = createCliLogger('AUTH');
26
18
  /**
27
19
  * Wrapper for macOS `security` keychain CLI commands.
@@ -49,6 +41,7 @@ class KeychainCommands {
49
41
  '-w',
50
42
  password,
51
43
  '-U',
44
+ '-A',
52
45
  ]);
53
46
  let stderr = '';
54
47
  child.stderr.on('data', (data) => {
@@ -166,20 +159,12 @@ class KeychainCommands {
166
159
  /**
167
160
  * Service for managing macOS Keychain credentials.
168
161
  *
169
- * Reads try the canonical namespace first, then fall back to the legacy
170
- * namespace and migrate a hit forward. Writes and deletes only ever touch
171
- * the canonical namespace — the legacy entry is never deleted.
162
+ * Direct reads, writes, and deletes on the canonical Yomi namespace.
172
163
  */
173
164
  export class KeychainService {
174
165
  commands;
175
- legacyCommands;
176
- // Accounts whose canonical entry this process has already seen (read or
177
- // written). Once seen, a later read miss is treated as transient and must
178
- // NOT fall back to the legacy namespace — see getCredential.
179
- canonicalSeen = new Set();
180
166
  constructor() {
181
167
  this.commands = new KeychainCommands(SERVICE_NAME);
182
- this.legacyCommands = new KeychainCommands(LEGACY_SERVICE_NAME);
183
168
  }
184
169
  /**
185
170
  * Stores a credential in Keychain.
@@ -189,52 +174,20 @@ export class KeychainService {
189
174
  * @returns Promise resolving to credential result.
190
175
  */
191
176
  async setCredential(account, password) {
192
- // `add-generic-password -U` updates the item in place when it exists, so
193
- // this is a single atomic keychain op. Do NOT delete-then-add: that leaves
194
- // a window where the entry is absent, during which a concurrent read falls
195
- // through to the legacy namespace and resurrects stale credentials over the
196
- // current ones (the root of the "session silently logged out" bug).
197
- const result = await this.commands.set(account, password);
198
- if (result.success) {
199
- this.canonicalSeen.add(account);
200
- }
201
- return result;
177
+ return await this.commands.set(account, password);
202
178
  }
203
179
  /**
204
180
  * Retrieves a credential from Keychain.
205
181
  *
206
- * Tries the canonical namespace first. Only if this process has NEVER seen
207
- * the canonical entry does it fall back to the legacy namespace and migrate a
208
- * hit forward (one-time). Once the canonical entry has been seen, a later
209
- * miss is transient (item mid-update) and returns the miss as-is rather than
210
- * resurrecting a stale legacy copy on top of the live session.
211
- *
212
182
  * @param account - Account name.
213
183
  * @returns Promise resolving to credential result.
214
184
  */
215
185
  async getCredential(account) {
216
- const primary = await this.commands.get(account);
217
- if (primary.success) {
218
- this.canonicalSeen.add(account);
219
- return primary;
220
- }
221
- if (this.canonicalSeen.has(account)) {
222
- return primary;
223
- }
224
- const legacy = await this.legacyCommands.get(account);
225
- if (legacy.success && legacy.password) {
226
- await this.commands.set(account, legacy.password);
227
- this.canonicalSeen.add(account);
228
- authLog.info('keychain.migrated_from_legacy', { account });
229
- }
230
- return legacy;
186
+ return await this.commands.get(account);
231
187
  }
232
188
  /**
233
189
  * Deletes a credential from Keychain.
234
190
  *
235
- * Operates on the canonical namespace only. The legacy namespace is
236
- * never deleted, even here.
237
- *
238
191
  * @param account - Account name.
239
192
  * @returns Promise resolving to credential result.
240
193
  */