@rikaidev/yomi 0.4.1 → 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 +41 -17
  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
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Convert raw/decrypted LINE payloads into safe user-facing preview text.
3
+ *
4
+ * E2EE media envelopes are transport data, not message text. They commonly
5
+ * contain keyMaterial, keys, chunks, ciphertext, or nonce fields. This
6
+ * boundary must be shared by every conversation/message output path so a raw
7
+ * message cannot bypass the decrypted-path sanitizer.
8
+ */
9
+ const OPAQUE_FIELD = /(?:^|["'\s,{])(keymaterial|keys|chunks|ciphertext|nonce)\s*["']?\s*:/i;
10
+ const OPAQUE_TOKEN = /\b(?:keymaterial|ciphertext|nonce)\b/i;
11
+ function isRecord(value) {
12
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
13
+ }
14
+ function containsOpaqueField(value) {
15
+ if (typeof value === 'string') {
16
+ return (OPAQUE_FIELD.test(value) ||
17
+ (value.trim().startsWith('{') && OPAQUE_TOKEN.test(value)));
18
+ }
19
+ if (Array.isArray(value))
20
+ return value.some(containsOpaqueField);
21
+ if (!isRecord(value))
22
+ return false;
23
+ return Object.entries(value).some(([key, nested]) => /^(keymaterial|keys|chunks|ciphertext|nonce)$/i.test(key) ||
24
+ containsOpaqueField(nested));
25
+ }
26
+ /** Return only actual user text; opaque E2EE envelopes return an empty string. */
27
+ export function sanitizeMessagePreview(value) {
28
+ if (containsOpaqueField(value))
29
+ return '';
30
+ if (typeof value !== 'string') {
31
+ if (!isRecord(value))
32
+ return '';
33
+ for (const key of ['text', 'message', 'body', 'content', 'caption']) {
34
+ const nested = sanitizeMessagePreview(value[key]);
35
+ if (nested)
36
+ return nested;
37
+ }
38
+ return '';
39
+ }
40
+ const text = value.trim();
41
+ if (!text)
42
+ return '';
43
+ if (!text.startsWith('{') && !text.startsWith('['))
44
+ return text;
45
+ try {
46
+ const parsed = JSON.parse(text);
47
+ if (!isRecord(parsed))
48
+ return '';
49
+ for (const key of ['text', 'message', 'body', 'content', 'caption']) {
50
+ const nested = sanitizeMessagePreview(parsed[key]);
51
+ if (nested)
52
+ return nested;
53
+ }
54
+ return '';
55
+ }
56
+ catch {
57
+ // A non-JSON message beginning with punctuation is still normal text.
58
+ return text;
59
+ }
60
+ }
@@ -137,25 +137,5 @@ export function createMessageQueryService(getClient, e2eeManager) {
137
137
  });
138
138
  return decryptLineMessages(e2eeManager, messages, chatId);
139
139
  },
140
- /**
141
- * Download the original LINE content bytes for one message.
142
- *
143
- * @param messageId - LINE message identifier
144
- * @param requestId - Optional request identifier
145
- * @returns Original message content bytes
146
- */
147
- async downloadMessageContent(messageId, requestId = `yomi-${Date.now()}`) {
148
- return requireLineClient(getClient).downloadMessageContent(messageId, requestId);
149
- },
150
- /**
151
- * Download the LINE preview bytes for one message.
152
- *
153
- * @param messageId - LINE message identifier
154
- * @param requestId - Optional request identifier
155
- * @returns Preview content bytes
156
- */
157
- async downloadMessageContentPreview(messageId, requestId = `yomi-${Date.now()}`) {
158
- return requireLineClient(getClient).downloadMessageContentPreview(messageId, requestId);
159
- },
160
140
  };
161
141
  }
@@ -62,6 +62,7 @@ export class LineProtocolService extends EventEmitter {
62
62
  getContact;
63
63
  getGroup;
64
64
  listStickerPackages;
65
+ getAllMessageBoxes;
65
66
  searchStickerPackages;
66
67
  sendMessage;
67
68
  sendImage;
@@ -73,8 +74,6 @@ export class LineProtocolService extends EventEmitter {
73
74
  sendLocation;
74
75
  getRecentMessages;
75
76
  getPreviousMessages;
76
- downloadMessageContent;
77
- downloadMessageContentPreview;
78
77
  markChatRead;
79
78
  renameGroup;
80
79
  inviteToGroup;
@@ -1,3 +1,4 @@
1
+ import { sanitizeMessagePreview } from '../../line/core/message-preview.js';
1
2
  import { decryptLineMessage } from '../../line/core/message-query-service.js';
2
3
  import { getExcludedChatIds } from '../../search/scope.js';
3
4
  import { createCliLogger } from '../../util/log.js';
@@ -6,6 +7,16 @@ import { resolveConversationNames, resolveSenderNames } from '../names.js';
6
7
  import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
7
8
  import { jsonResult, toolError, toonText } from './shared.js';
8
9
  const log = createCliLogger('Yomi');
10
+ function messageTimestamp(message) {
11
+ return Number(message?.deliveredTime || message?.createdTime || 0);
12
+ }
13
+ function latestMessage(messages) {
14
+ if (!Array.isArray(messages) || messages.length === 0)
15
+ return null;
16
+ return messages.reduce((latest, candidate) => messageTimestamp(candidate) >= messageTimestamp(latest)
17
+ ? candidate
18
+ : latest);
19
+ }
9
20
  /**
10
21
  * Handle `list_conversations` — list LINE conversations (chats, groups, rooms)
11
22
  * with unread counts, a last-message preview, and a resolved display name.
@@ -15,6 +26,13 @@ const log = createCliLogger('Yomi');
15
26
  * @returns MCP tool result.
16
27
  */
17
28
  export async function handleListConversations(service, args) {
29
+ try {
30
+ await service.client.syncLongPoll(50, 1000);
31
+ if (service.sessionState?.saveSyncRevisions) {
32
+ await service.sessionState.saveSyncRevisions(Number(service.client.revision || 0), Number(service.client.globalRevision || 0), Number(service.client.individualRevision || 0));
33
+ }
34
+ }
35
+ catch { }
18
36
  const result = await service.client.getMessageBoxes({
19
37
  lastMessagesPerMessageBoxCount: 1,
20
38
  messageBoxCountLimit: args.limit ?? 20,
@@ -30,13 +48,27 @@ export async function handleListConversations(service, args) {
30
48
  // decrypted.
31
49
  const acc = createPhiAccumulator();
32
50
  const conversations = await Promise.all(boxes.map(async (box) => {
33
- const lastMessage = box.lastMessages?.[box.lastMessages.length - 1] || null;
51
+ let lastMessage = latestMessage(box.lastMessages);
52
+ const cursorTime = Number(box.lastDeliveredMessageId?.deliveredTime || 0);
53
+ if (box.id &&
54
+ (!lastMessage || messageTimestamp(lastMessage) < cursorTime)) {
55
+ try {
56
+ const recent = await service.getRecentMessages(box.id, 1);
57
+ const candidate = latestMessage(recent);
58
+ if (candidate &&
59
+ messageTimestamp(candidate) >= messageTimestamp(lastMessage)) {
60
+ lastMessage = candidate;
61
+ }
62
+ }
63
+ catch { }
64
+ }
34
65
  const decryptedLastMessage = lastMessage
35
66
  ? await decryptLineMessage(service.e2eeManager, lastMessage, box.id)
36
67
  : null;
68
+ const previewText = sanitizeMessagePreview(decryptedLastMessage?.text ?? lastMessage?.text);
37
69
  return {
38
70
  id: box.id,
39
- lastMessagePreview: maskInto(acc, decryptedLastMessage?.text ?? null),
71
+ lastMessagePreview: maskInto(acc, previewText || (lastMessage?.text ? '[加密訊息]' : null)),
40
72
  name: names.get(box.id) ?? null,
41
73
  unreadCount: box.unreadCount ?? 0,
42
74
  };
@@ -104,7 +136,8 @@ export async function handleGetChatMessages(service, args) {
104
136
  // be observed against a live mentioning message before anyone builds
105
137
  // the outbound (send-a-mention) side.
106
138
  mentions: message.contentMetadata?.MENTION ?? null,
107
- text: maskInto(acc, message.text),
139
+ text: maskInto(acc, sanitizeMessagePreview(message.text) ||
140
+ (message.text ? '[加密訊息]' : null)),
108
141
  e2eeDecrypted: message.e2eeDecrypted ?? null,
109
142
  // Emitted ONLY when the message decrypted but could not be authenticated
110
143
  // (LINE E2EE v1 — AES-CBC, no tag, no AAD), so its presence is a signal
@@ -175,7 +208,7 @@ export async function handleGetUnreadDigest(service, args) {
175
208
  mediaType: resolveLineMediaDescriptor(m) !== null
176
209
  ? resolveLineMediaType(Number(m.contentType))
177
210
  : null,
178
- text: maskInto(acc, m.text),
211
+ text: maskInto(acc, sanitizeMessagePreview(m.text) || (m.text ? '[加密訊息]' : null)),
179
212
  })),
180
213
  };
181
214
  }));
@@ -44,7 +44,7 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
44
44
  * Handle `login` on a LEGACY-era connection whose client supports form
45
45
  * elicitation — drive the passwordless flow entirely through pushed
46
46
  * elicitation requests, with no out-of-band channel required. A pre-flight
47
- * form confirms the 允許自其他裝置登入 prerequisite (and collects
47
+ * form confirms the allow-login-from-other-devices prerequisite (and collects
48
48
  * phone/region when missing) BEFORE any PIN is issued; the PIN is then
49
49
  * surfaced through a second elicitation fired without blocking
50
50
  * `runPwlessLogin`, which keeps running while the human acts on their phone.
@@ -4,7 +4,7 @@ const log = createCliLogger('Yomi');
4
4
  /**
5
5
  * Handle `react_message` — REALLY adds a predefined reaction to a real LINE
6
6
  * message now (TalkService react). Visible to the conversation. `reactionType`:
7
- * 2=LIKE 👍, 3=LOVE ❤️, 4=LAUGH 😆, 5=SURPRISE 😮, 6=SAD 😢, 7=ANGRY 😡
7
+ * Protocol emoji mapping: 2=LIKE 👍, 3=LOVE ❤️, 4=LAUGH 😆, 5=SURPRISE 😮, 6=SAD 😢, 7=ANGRY 😡
8
8
  * (default 2).
9
9
  *
10
10
  * @param service - Resumed LineProtocolService.
@@ -17,7 +17,7 @@
17
17
  * result that either method ranks highly surfaces near the top. This is
18
18
  * deliberately not "semantic OR keyword": pure semantic over a partially-
19
19
  * embedded index silently drops exact matches that live in un-embedded
20
- * messages (the "search 台東 misses the chat that literally says 台東" bug),
20
+ * messages (the "search for a place name misses the exact same place name" bug),
21
21
  * and pure keyword misses paraphrases. Which methods actually contributed
22
22
  * is always reported via the `mode` field (`hybrid` | `semantic` |
23
23
  * `keyword`), never hidden.
@@ -6,7 +6,7 @@ import { encode } from '@toon-format/toon';
6
6
  * under src/mcp/handlers/ can import these without a circular dependency.
7
7
  */
8
8
  // Yomi logs in on its own (via the `login`/`login_complete` tools, or
9
- // `npx @rikaidev/yomi login`); it does not depend on inboxd for anything.
9
+ // `npx @rikaidev/yomi login`); session is shared with Yomi Desktop.
10
10
  export const NO_CREDENTIALS_MESSAGE = 'No persisted LINE session. Call the `login` tool, or run `npx @rikaidev/yomi login` in a terminal.';
11
11
  // Shown when LINE invalidated a previously working session — distinct from
12
12
  // "never logged in" so the model tells the user the right story: the MCP
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Universal 1-Click MCP Integrator for Claude Desktop, Claude Cowork, Codex, Cursor, Windsurf, VS Code (Cline/Roo).
3
+ * Also automatically generates and registers Yomi Skills for AI Agent assistants.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ export function getClientTargets() {
9
+ const home = homedir();
10
+ return [
11
+ {
12
+ id: 'claude-desktop',
13
+ name: 'Claude Desktop & Cowork',
14
+ configPath: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
15
+ configType: 'mcpServers',
16
+ requiresApp: '/Applications/Claude.app',
17
+ },
18
+ {
19
+ id: 'codex-desktop',
20
+ name: 'Codex Desktop & CLI',
21
+ configPath: join(home, '.codex', 'mcp.json'),
22
+ configType: 'mcpServers',
23
+ },
24
+ {
25
+ id: 'cursor',
26
+ name: 'Cursor IDE',
27
+ configPath: join(home, '.cursor', 'mcp.json'),
28
+ configType: 'mcpServers',
29
+ requiresApp: '/Applications/Cursor.app',
30
+ },
31
+ {
32
+ id: 'windsurf',
33
+ name: 'Windsurf (Codeium)',
34
+ configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'),
35
+ configType: 'mcpServers',
36
+ requiresApp: '/Applications/Windsurf.app',
37
+ },
38
+ {
39
+ id: 'cline',
40
+ name: 'VS Code (Cline)',
41
+ configPath: join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'),
42
+ configType: 'mcpServers',
43
+ },
44
+ {
45
+ id: 'roo-code',
46
+ name: 'VS Code (Roo Code)',
47
+ configPath: join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'cline_mcp_settings.json'),
48
+ configType: 'mcpServers',
49
+ },
50
+ {
51
+ id: 'claude-cli',
52
+ name: 'Claude Code CLI',
53
+ configPath: join(home, '.claude.json'),
54
+ configType: 'mcpServers',
55
+ },
56
+ ];
57
+ }
58
+ export function getYomiMcpDefinition() {
59
+ const home = homedir();
60
+ const npxPath = existsSync('/opt/homebrew/bin/npx')
61
+ ? '/opt/homebrew/bin/npx'
62
+ : existsSync('/usr/local/bin/npx')
63
+ ? '/usr/local/bin/npx'
64
+ : 'npx';
65
+ return {
66
+ command: npxPath,
67
+ args: ['-y', '@rikaidev/yomi'],
68
+ env: {
69
+ PATH: `${home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
70
+ },
71
+ };
72
+ }
73
+ export function installYomiAgentSkill() {
74
+ const home = homedir();
75
+ const skillContent = `---
76
+ name: yomi-line-manager
77
+ description: Manage and interact with user's LINE account via Yomi E2EE MCP Server. Query messages, summarize chats, search history, find contacts, and draft replies.
78
+ ---
79
+
80
+ # Yomi LINE Copilot Skill
81
+
82
+ You have real-time access to the user's LINE account through the Yomi Letter-Sealing E2EE MCP Server.
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.
89
+
90
+ ## Guidelines
91
+ - 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\`.
93
+ `;
94
+ const skillDirs = [
95
+ join(home, '.claude', 'skills', 'yomi-line-manager'),
96
+ join(home, '.gemini', 'antigravity-cli', 'builtin', 'skills', 'yomi-line-manager'),
97
+ join(home, '.codex', 'skills', 'yomi-line-manager'),
98
+ ];
99
+ let installedPath = '';
100
+ for (const dir of skillDirs) {
101
+ try {
102
+ if (!existsSync(dir)) {
103
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
104
+ }
105
+ const skillFile = join(dir, 'SKILL.md');
106
+ writeFileSync(skillFile, skillContent, { mode: 0o644 });
107
+ installedPath = skillFile;
108
+ }
109
+ catch {
110
+ // Ignore directory write failures for optional tools
111
+ }
112
+ }
113
+ return { success: true, path: installedPath };
114
+ }
115
+ export function integrateClient(target) {
116
+ try {
117
+ const dir = dirname(target.configPath);
118
+ if (!existsSync(dir)) {
119
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
120
+ }
121
+ let config = {};
122
+ if (existsSync(target.configPath)) {
123
+ try {
124
+ const raw = readFileSync(target.configPath, 'utf8');
125
+ config = JSON.parse(raw);
126
+ }
127
+ catch {
128
+ config = {};
129
+ }
130
+ }
131
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
132
+ config.mcpServers = {};
133
+ }
134
+ config.mcpServers.yomi = getYomiMcpDefinition();
135
+ writeFileSync(target.configPath, JSON.stringify(config, null, 2), {
136
+ mode: 0o600,
137
+ });
138
+ return {
139
+ success: true,
140
+ message: `Integrated Yomi MCP into ${target.name}`,
141
+ path: target.configPath,
142
+ };
143
+ }
144
+ catch (err) {
145
+ return {
146
+ success: false,
147
+ message: `Failed to configure ${target.name}: ${err.message}`,
148
+ path: target.configPath,
149
+ };
150
+ }
151
+ }
152
+ export function integrateAllClients() {
153
+ const skillResult = installYomiAgentSkill();
154
+ const targets = getClientTargets();
155
+ const results = targets.map((t) => {
156
+ const res = integrateClient(t);
157
+ return {
158
+ id: t.id,
159
+ name: t.name,
160
+ ...res,
161
+ };
162
+ });
163
+ return {
164
+ results,
165
+ totalSuccess: results.filter((r) => r.success).length,
166
+ skillInstalled: skillResult.success,
167
+ };
168
+ }
package/dist/mcp/media.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * missing, or the message's content type is not a downloadable media type,
14
14
  * callers get an honest error naming the reason.
15
15
  */
16
- import { downloadLineObsMediaObject } from '../line/client/obs-media-client.js';
16
+ import { downloadLineMessageData, downloadLineObsMediaObject, } from '../line/client/obs-media-client.js';
17
17
  import { CONTENT_TYPE } from '../line/core/constants.js';
18
18
  import { decryptLineMediaBytes } from '../line/core/media-decrypt.js';
19
19
  /** LINE content types this server can download and decrypt via the OBS path. */
@@ -98,6 +98,16 @@ function resolveMimeType(extension, contentType) {
98
98
  }
99
99
  return DEFAULT_MIME_BY_CONTENT_TYPE[contentType] || null;
100
100
  }
101
+ /**
102
+ * Resolve a legacy message's filename extension without requiring an OBS
103
+ * descriptor. Older Talk messages expose MEDIA_CONTENT_INFO but omit OID/SID.
104
+ */
105
+ function resolveMediaInfoExtension(message) {
106
+ const info = parseJson(message?.contentMetadata?.MEDIA_CONTENT_INFO, {});
107
+ return typeof info.extension === 'string' && info.extension.trim()
108
+ ? info.extension.trim()
109
+ : null;
110
+ }
101
111
  /**
102
112
  * Build a LINE media descriptor from a decrypted message.
103
113
  *
@@ -166,7 +176,29 @@ export async function fetchLineMessageMedia(service, chatId, messageId, preview)
166
176
  }
167
177
  const descriptor = resolveLineMediaDescriptor(message);
168
178
  if (!descriptor) {
169
- throw new MissingDecryptMaterialError(`message ${messageId} is missing OBS object metadata (OID/SID)`);
179
+ // Older/non-E2EE Talk messages carry MEDIA_CONTENT_INFO but omit OID/SID.
180
+ // Their object is addressed by message id at OBS /r/talk/m/{id}; the
181
+ // similarly named TalkService methods are not real `/S4` methods and
182
+ // return Invalid method name. Keep the request at the OBS layer.
183
+ const downloaded = await downloadLineMessageData(service.client, {
184
+ messageId,
185
+ preview,
186
+ });
187
+ const contentMetadata = message?.contentMetadata || {};
188
+ const fileName = (typeof contentMetadata.FILE_NAME === 'string' &&
189
+ contentMetadata.FILE_NAME) ||
190
+ (typeof contentMetadata.FILE_NAME_ORIGINAL === 'string' &&
191
+ contentMetadata.FILE_NAME_ORIGINAL) ||
192
+ (typeof contentMetadata.name === 'string' && contentMetadata.name) ||
193
+ null;
194
+ return {
195
+ bytes: downloaded.bytes,
196
+ contentType,
197
+ fileName,
198
+ mimeType: downloaded.mimeType ||
199
+ resolveMimeType(resolveMediaInfoExtension(message), contentType) ||
200
+ 'application/octet-stream',
201
+ };
170
202
  }
171
203
  const encryptedBytes = await downloadLineObsMediaObject(service.client, {
172
204
  messageId,
@@ -14,7 +14,7 @@
14
14
  * extra response content block reporting how many values were masked and of
15
15
  * what types, so the redaction is visible rather than a quiet rewrite.
16
16
  */
17
- /** Middle-redaction marker used in masked values (Q12〇〇9 style). */
17
+ /** Middle-redaction marker used in masked values (for example, Q12••9). */
18
18
  const MASK_MIDDLE = '〇〇';
19
19
  /**
20
20
  * Luhn check for candidate payment-card digit strings.
@@ -37,7 +37,7 @@ function passesLuhn(digits) {
37
37
  return digits.length >= 13 && digits.length <= 19 && sum % 10 === 0;
38
38
  }
39
39
  /**
40
- * Mask one matched value, preserving a little edge context (Q12〇〇9): the
40
+ * Mask one matched value, preserving a little edge context (for example, Q12••9): the
41
41
  * first few characters and, for longer values, the last one.
42
42
  * @param value - The raw matched substring.
43
43
  * @returns The masked substring.
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * The FTS5 `unicode61` tokenizer has no Chinese/Japanese/Korean word
5
5
  * segmentation: a whole CJK run collapses into a single opaque token, so a
6
- * substring like "會議" inside "會議通知" never matches. Yomi has no ICU
6
+ * two-character CJK substring inside a longer CJK term never matches. Yomi has no ICU
7
7
  * tokenizer available and takes on no new dependency to get one, so instead
8
8
  * both the indexed text and the search query are rewritten into overlapping
9
9
  * character bigrams for CJK runs before ever reaching FTS5. Overlapping
10
- * bigrams turn "會議通知" into "會議 議通 通知" — searching for "會議"
10
+ * overlapping bigrams preserve exact two-character search terms inside longer CJK text
11
11
  * (itself a single bigram once transformed) becomes a plain token match.
12
12
  * This covers the common case of 2+ character CJK words; single-character
13
13
  * CJK terms still match via the emitted unigram.
@@ -20,7 +20,7 @@
20
20
  * Also batch-embeds each chat's collected messages (when an Embedder is
21
21
  * supplied) so semantic search has vectors to rank over. The embedder is
22
22
  * always caller-injected — never imported directly here — so the MCP
23
- * server can pass its DefaultEmbedder while a host app (e.g. inboxd) can
23
+ * server can pass its DefaultEmbedder while a host app (e.g. Yomi Desktop) can
24
24
  * pass its own local model instead. Embedding failure (model unavailable,
25
25
  * offline first run, etc.) is caught per-chat and never aborts collection:
26
26
  * messages + bigram search_text are always stored regardless of whether
@@ -6,7 +6,7 @@
6
6
  * a default implementation (./default-embedder.ts, transformers.js/ONNX) so
7
7
  * the MCP server works standalone, but nothing in store.ts/collector.ts
8
8
  * imports that default directly — they only depend on this interface, so a
9
- * host app (e.g. inboxd) can inject its own embedder (its local MLX
9
+ * host app (e.g. Yomi Desktop) can inject its own embedder (its local MLX
10
10
  * `generateEmbedding`) instead. See ./default-embedder.ts's doc comment for
11
11
  * the exact adapter shape.
12
12
  */
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * virtual filesystem). MUST stay equal to the "version" field in
5
5
  * package.json — the test in version.test.ts enforces that.
6
6
  */
7
- export const YOMI_VERSION = '0.4.1';
7
+ export const YOMI_VERSION = '0.5.0';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=22.13.0 <23.0.0 || >=23.4.0"
5
5
  },
6
- "version": "0.4.1",
6
+ "version": "0.5.0",
7
7
  "description": "Open-source personal LINE MCP server for AI agents. Read, reply, send images, and search LINE conversations locally with Letter-Sealing E2EE.",
8
8
  "type": "module",
9
9
  "license": "MIT",
@@ -43,6 +43,9 @@
43
43
  "start": "node run.mjs",
44
44
  "lint": "biome check src/",
45
45
  "lint:fix": "biome check --write src/",
46
+ "lint:comments": "node scripts/lint-comments.mjs",
47
+ "hooks:install": "git config core.hooksPath .githooks",
48
+ "pretest": "npm run lint:comments",
46
49
  "format": "biome format --write src/",
47
50
  "test": "bun test",
48
51
  "version": "node scripts/sync-version.mjs && git add src/version.ts",