@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.
- package/README.md +18 -4
- package/dist/attention/policy.js +95 -0
- package/dist/auth/credential-store.js +41 -17
- package/dist/auth/keychain.js +7 -54
- package/dist/cli/query.js +668 -0
- package/dist/insight/relationships.js +1 -1
- package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
- package/dist/line/auth/pwless/index.js +1 -1
- package/dist/line/client/index.js +0 -2
- package/dist/line/client/obs-media-client.js +37 -0
- package/dist/line/client/parsers.js +1 -0
- package/dist/line/client/sticker-meta.js +32 -0
- package/dist/line/client/sync-service/client.js +31 -3
- package/dist/line/client/talk-service/contact-query.js +8 -1
- package/dist/line/client/talk-service/message-box-query.js +17 -0
- package/dist/line/client/talk-service/query-client.js +32 -26
- package/dist/line/client/talk-service/requests.js +1 -11
- package/dist/line/core/auth-session-runtime.js +16 -1
- package/dist/line/core/chat-event.js +33 -0
- package/dist/line/core/chat-runtime-service.js +0 -20
- package/dist/line/core/directory-runtime-service.js +10 -2
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
- package/dist/line/core/flex-message.js +130 -0
- package/dist/line/core/mention.js +1 -1
- package/dist/line/core/message-preview.js +60 -0
- package/dist/line/core/message-query-service.js +0 -20
- package/dist/line/core/service.js +1 -2
- package/dist/mcp/handlers/conversations.js +37 -4
- package/dist/mcp/handlers/login.js +1 -1
- package/dist/mcp/handlers/message-actions.js +1 -1
- package/dist/mcp/handlers/search.js +1 -1
- package/dist/mcp/handlers/shared.js +1 -1
- package/dist/mcp/integrator.js +168 -0
- package/dist/mcp/media.js +34 -2
- package/dist/mcp/phi-guard.js +2 -2
- package/dist/search/bigram.js +2 -2
- package/dist/search/collector.js +1 -1
- package/dist/search/embedder.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- 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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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`);
|
|
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
|
-
|
|
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,
|
package/dist/mcp/phi-guard.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/dist/search/bigram.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
package/dist/search/collector.js
CHANGED
|
@@ -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.
|
|
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
|
package/dist/search/embedder.js
CHANGED
|
@@ -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.
|
|
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
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.
|
|
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",
|