@rikaidev/yomi 0.6.1 → 0.6.4
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/dist/line/core/message-box-recency.js +27 -0
- package/dist/mcp/daemon.js +326 -0
- package/dist/mcp/handlers/conversations.js +10 -12
- package/dist/mcp/integrator.js +7 -1
- package/dist/mcp/server.js +41 -39
- package/dist/mcp/tools.js +2 -2
- package/dist/search/collector.js +2 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/run.mjs +97 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function messageTimestamp(message) {
|
|
2
|
+
return Number(message?.deliveredTime || message?.createdTime || 0);
|
|
3
|
+
}
|
|
4
|
+
/** Newest activity time of one message box, in epoch milliseconds. */
|
|
5
|
+
export function boxActivity(box) {
|
|
6
|
+
const cursorTime = Number(box?.lastDeliveredMessageId?.deliveredTime || 0);
|
|
7
|
+
const lastTimes = Array.isArray(box?.lastMessages)
|
|
8
|
+
? box.lastMessages.map(messageTimestamp)
|
|
9
|
+
: [];
|
|
10
|
+
return Math.max(cursorTime, ...lastTimes, 0);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every message box, most recently active first.
|
|
14
|
+
*
|
|
15
|
+
* LINE returns message boxes in chat-MID order (`c…` groups, then `m…`, then
|
|
16
|
+
* `u…` 1:1 chats) and one page holds only the requested count, so a single
|
|
17
|
+
* `getMessageBoxes` call is neither recent nor complete: it silently drops
|
|
18
|
+
* every chat whose MID sorts past the page. Page through all of them, then
|
|
19
|
+
* order by activity so a caller-supplied limit keeps the newest chats.
|
|
20
|
+
*/
|
|
21
|
+
export async function listBoxesByRecency(service, options) {
|
|
22
|
+
const result = await service.client.getAllMessageBoxes({
|
|
23
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
24
|
+
withUnreadCount: options.withUnreadCount,
|
|
25
|
+
});
|
|
26
|
+
return [...(result.messageBoxes || [])].sort((a, b) => boxActivity(b) - boxActivity(a));
|
|
27
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yomi MCP Shared Daemon.
|
|
3
|
+
*
|
|
4
|
+
* Runs a single, persistent Yomi instance (holding one LINE session, one
|
|
5
|
+
* Transformers embedding model, and one SQLite search index) as a background
|
|
6
|
+
* daemon. Multiple AI clients (OpenCode, Minimax, Claude Code, Cursor, Agy)
|
|
7
|
+
* share this daemon via HTTP/SSE or transparent stdio proxies, eliminating
|
|
8
|
+
* redundant processes, memory duplication, and session race conditions.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
import { createMcpHandler, ReadBuffer, serializeMessage, } from '@modelcontextprotocol/server';
|
|
15
|
+
import { LineProtocolService } from '../line/core/service.js';
|
|
16
|
+
import { startCapture } from '../search/capture.js';
|
|
17
|
+
import { getDefaultEmbedder } from '../search/default-embedder.js';
|
|
18
|
+
import { yomiDataPath } from '../util/data-dir.js';
|
|
19
|
+
import { createCliLogger } from '../util/log.js';
|
|
20
|
+
import { checkForUpdate } from '../util/update-check.js';
|
|
21
|
+
import { YOMI_VERSION } from '../version.js';
|
|
22
|
+
import { NO_CREDENTIALS_MESSAGE } from './handlers/index.js';
|
|
23
|
+
import { buildInstructions, createBuildServer } from './server.js';
|
|
24
|
+
const log = createCliLogger('YomiDaemon');
|
|
25
|
+
export const DEFAULT_DAEMON_PORT = 3333;
|
|
26
|
+
export const DEFAULT_DAEMON_HOST = '127.0.0.1';
|
|
27
|
+
export function daemonInfoPath() {
|
|
28
|
+
return yomiDataPath('daemon.json');
|
|
29
|
+
}
|
|
30
|
+
export function readDaemonInfo() {
|
|
31
|
+
const file = daemonInfoPath();
|
|
32
|
+
if (!existsSync(file)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(file, 'utf8');
|
|
37
|
+
const data = JSON.parse(raw);
|
|
38
|
+
if (!data.pid || !data.port) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Verify whether the process is actually alive
|
|
42
|
+
try {
|
|
43
|
+
process.kill(data.pid, 0);
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Process is dead, clean up stale state file
|
|
48
|
+
try {
|
|
49
|
+
rmSync(file);
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function isDaemonHealthy(info) {
|
|
60
|
+
try {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), 1000);
|
|
63
|
+
const res = await fetch(`http://${info.host}:${info.port}/health`, {
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const json = (await res.json());
|
|
71
|
+
return json?.ok === true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function getRunningDaemon() {
|
|
78
|
+
const info = readDaemonInfo();
|
|
79
|
+
if (!info) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const healthy = await isDaemonHealthy(info);
|
|
83
|
+
if (healthy) {
|
|
84
|
+
return info;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
rmSync(daemonInfoPath());
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export async function startDaemonServer(options = {}) {
|
|
93
|
+
const host = options.host ?? DEFAULT_DAEMON_HOST;
|
|
94
|
+
const requestedPort = options.port ??
|
|
95
|
+
(process.env.YOMI_PORT
|
|
96
|
+
? parseInt(process.env.YOMI_PORT, 10)
|
|
97
|
+
: DEFAULT_DAEMON_PORT);
|
|
98
|
+
const service = options.service ?? new LineProtocolService();
|
|
99
|
+
service.on('error', (error) => log.warn('service.error', { error: error?.message ?? String(error) }));
|
|
100
|
+
const resumed = await service.resumeSession();
|
|
101
|
+
if (!resumed) {
|
|
102
|
+
log.warn('session.resume_failed', { message: NO_CREDENTIALS_MESSAGE });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
log.info('session.resumed', { mid: service.profile?.mid ?? null });
|
|
106
|
+
startCapture(service, getDefaultEmbedder()).catch((error) => {
|
|
107
|
+
log.error('capture.start_failed', {
|
|
108
|
+
error: error?.message ?? String(error),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const updateNotice = await checkForUpdate();
|
|
113
|
+
if (updateNotice) {
|
|
114
|
+
log.info('update.available', { current: YOMI_VERSION });
|
|
115
|
+
}
|
|
116
|
+
const instructions = buildInstructions(updateNotice);
|
|
117
|
+
const buildServer = createBuildServer(service, instructions);
|
|
118
|
+
const mcpHandler = createMcpHandler(buildServer, {
|
|
119
|
+
legacy: 'stateless',
|
|
120
|
+
onerror: (error) => log.error('server.protocol_error', {
|
|
121
|
+
error: error?.message ?? String(error),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
const server = createServer(async (req, res) => {
|
|
125
|
+
// Health and status endpoint
|
|
126
|
+
if (req.url === '/health' || req.url === '/status') {
|
|
127
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
128
|
+
res.end(JSON.stringify({
|
|
129
|
+
ok: true,
|
|
130
|
+
version: YOMI_VERSION,
|
|
131
|
+
pid: process.pid,
|
|
132
|
+
uptime: process.uptime(),
|
|
133
|
+
session: service.profile ? { mid: service.profile.mid } : null,
|
|
134
|
+
loginRequired: service.loginRequired ?? false,
|
|
135
|
+
}));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Modern / Streamable HTTP & Legacy MCP endpoint
|
|
139
|
+
const url = `http://${req.headers.host || `${host}:${requestedPort}`}${req.url}`;
|
|
140
|
+
const headers = new Headers();
|
|
141
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
142
|
+
if (v) {
|
|
143
|
+
headers.set(k, Array.isArray(v) ? v.join(', ') : v);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Ensure Accept header includes text/event-stream for MCP spec compliance
|
|
147
|
+
const accept = headers.get('accept');
|
|
148
|
+
if (!accept) {
|
|
149
|
+
headers.set('accept', 'application/json, text/event-stream');
|
|
150
|
+
}
|
|
151
|
+
else if (!accept.includes('text/event-stream')) {
|
|
152
|
+
headers.set('accept', `${accept}, text/event-stream`);
|
|
153
|
+
}
|
|
154
|
+
const body = req.method === 'GET' || req.method === 'HEAD'
|
|
155
|
+
? undefined
|
|
156
|
+
: Readable.toWeb(req);
|
|
157
|
+
const webReq = new Request(url, {
|
|
158
|
+
method: req.method,
|
|
159
|
+
headers,
|
|
160
|
+
body,
|
|
161
|
+
duplex: 'half',
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
const webRes = await mcpHandler.fetch(webReq);
|
|
165
|
+
res.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries()));
|
|
166
|
+
if (webRes.body) {
|
|
167
|
+
const reader = webRes.body.getReader();
|
|
168
|
+
while (true) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done)
|
|
171
|
+
break;
|
|
172
|
+
res.write(value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
res.end();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
jsonrpc: '2.0',
|
|
181
|
+
error: { code: -32603, message: error?.message ?? String(error) },
|
|
182
|
+
id: null,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
server.on('error', reject);
|
|
188
|
+
server.listen(requestedPort, host, () => resolve());
|
|
189
|
+
});
|
|
190
|
+
const addr = server.address();
|
|
191
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : requestedPort;
|
|
192
|
+
const info = {
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
port: actualPort,
|
|
195
|
+
host,
|
|
196
|
+
startedAt: new Date().toISOString(),
|
|
197
|
+
version: YOMI_VERSION,
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
const file = daemonInfoPath();
|
|
201
|
+
const dir = dirname(file);
|
|
202
|
+
if (!existsSync(dir)) {
|
|
203
|
+
mkdirSync(dir, { recursive: true });
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(file, JSON.stringify(info, null, 2), 'utf8');
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
log.warn('daemon.info_write_failed', { error: err?.message ?? String(err) });
|
|
209
|
+
}
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
try {
|
|
212
|
+
rmSync(daemonInfoPath());
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
try {
|
|
216
|
+
server.close();
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
};
|
|
220
|
+
process.once('SIGTERM', cleanup);
|
|
221
|
+
process.once('SIGINT', cleanup);
|
|
222
|
+
process.once('exit', cleanup);
|
|
223
|
+
if (!options.quiet) {
|
|
224
|
+
log.info('daemon.started', { port: actualPort, host, pid: process.pid });
|
|
225
|
+
}
|
|
226
|
+
return { server, port: actualPort, info };
|
|
227
|
+
}
|
|
228
|
+
export async function stopDaemon() {
|
|
229
|
+
const info = readDaemonInfo();
|
|
230
|
+
if (!info) {
|
|
231
|
+
return { stopped: false };
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
process.kill(info.pid, 'SIGTERM');
|
|
235
|
+
const start = Date.now();
|
|
236
|
+
while (Date.now() - start < 3000) {
|
|
237
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
238
|
+
try {
|
|
239
|
+
process.kill(info.pid, 0);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
try {
|
|
243
|
+
rmSync(daemonInfoPath());
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
return { stopped: true, pid: info.pid };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Force kill if graceful exit timed out
|
|
250
|
+
process.kill(info.pid, 'SIGKILL');
|
|
251
|
+
try {
|
|
252
|
+
rmSync(daemonInfoPath());
|
|
253
|
+
}
|
|
254
|
+
catch { }
|
|
255
|
+
return { stopped: true, pid: info.pid };
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
try {
|
|
259
|
+
rmSync(daemonInfoPath());
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
return { stopped: false };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export async function proxyStdioToDaemon(info) {
|
|
266
|
+
const daemonUrl = `http://${info.host}:${info.port}/mcp`;
|
|
267
|
+
const readBuffer = new ReadBuffer();
|
|
268
|
+
process.stdin.on('data', (chunk) => {
|
|
269
|
+
readBuffer.append(chunk);
|
|
270
|
+
while (true) {
|
|
271
|
+
let message;
|
|
272
|
+
try {
|
|
273
|
+
message = readBuffer.readMessage();
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
log.error('proxy.buffer_error', { error: err?.message ?? String(err) });
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (!message)
|
|
280
|
+
break;
|
|
281
|
+
const body = serializeMessage(message);
|
|
282
|
+
fetch(daemonUrl, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
'content-type': 'application/json',
|
|
286
|
+
accept: 'application/json, text/event-stream',
|
|
287
|
+
},
|
|
288
|
+
body,
|
|
289
|
+
})
|
|
290
|
+
.then(async (res) => {
|
|
291
|
+
const text = await res.text();
|
|
292
|
+
const lines = text.split('\n');
|
|
293
|
+
let handled = false;
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (line.startsWith('data: ')) {
|
|
296
|
+
const data = line.slice(6).trim();
|
|
297
|
+
if (data) {
|
|
298
|
+
process.stdout.write(`${data}\n`);
|
|
299
|
+
handled = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!handled && text.trim()) {
|
|
304
|
+
process.stdout.write(`${text.trim()}\n`);
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.catch((error) => {
|
|
308
|
+
if (message.id !== undefined && message.id !== null) {
|
|
309
|
+
const errResp = {
|
|
310
|
+
jsonrpc: '2.0',
|
|
311
|
+
id: message.id,
|
|
312
|
+
error: {
|
|
313
|
+
code: -32000,
|
|
314
|
+
message: `Failed to forward request to Yomi daemon: ${error?.message ?? String(error)}`,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
process.stdout.write(`${JSON.stringify(errResp)}\n`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
await new Promise((resolve) => {
|
|
323
|
+
process.stdin.on('end', resolve);
|
|
324
|
+
process.stdin.on('close', resolve);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isSquareChatMid } from '../../line/client/square-service/client.js';
|
|
2
|
+
import { listBoxesByRecency } from '../../line/core/message-box-recency.js';
|
|
2
3
|
import { sanitizeMessagePreview } from '../../line/core/message-preview.js';
|
|
3
4
|
import { decryptLineMessage } from '../../line/core/message-query-service.js';
|
|
4
5
|
import { getExcludedChatIds } from '../../search/scope.js';
|
|
@@ -34,12 +35,7 @@ export async function handleListConversations(service, args) {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
catch { }
|
|
37
|
-
const
|
|
38
|
-
lastMessagesPerMessageBoxCount: 1,
|
|
39
|
-
messageBoxCountLimit: args.limit ?? 20,
|
|
40
|
-
withUnreadCount: true,
|
|
41
|
-
});
|
|
42
|
-
const boxes = result.messageBoxes || [];
|
|
38
|
+
const boxes = (await listBoxesByRecency(service, { withUnreadCount: true })).slice(0, args.limit ?? 20);
|
|
43
39
|
const names = await resolveConversationNames(service, boxes.map((box) => box.id).filter(Boolean));
|
|
44
40
|
// The last message embedded in getMessageBoxes is E2EE-wrapped for most
|
|
45
41
|
// chats; run it through the same local decrypt path get_chat_messages
|
|
@@ -237,13 +233,9 @@ export async function handleMarkRead(service, args) {
|
|
|
237
233
|
* @returns MCP tool result.
|
|
238
234
|
*/
|
|
239
235
|
export async function handleGetUnreadDigest(service, args) {
|
|
240
|
-
const result = await service.client.getMessageBoxes({
|
|
241
|
-
lastMessagesPerMessageBoxCount: 1,
|
|
242
|
-
messageBoxCountLimit: args.limit ?? 20,
|
|
243
|
-
withUnreadCount: true,
|
|
244
|
-
});
|
|
245
236
|
const excluded = getExcludedChatIds();
|
|
246
|
-
const
|
|
237
|
+
const allUnread = (await listBoxesByRecency(service, { withUnreadCount: true })).filter((box) => (box.unreadCount ?? 0) > 0 && box.id && !excluded.has(box.id));
|
|
238
|
+
const unreadBoxes = args.limit && args.limit > 0 ? allUnread.slice(0, args.limit) : allUnread;
|
|
247
239
|
const names = await resolveConversationNames(service, unreadBoxes.map((box) => box.id));
|
|
248
240
|
const perChat = args.perChat ?? 10;
|
|
249
241
|
const acc = createPhiAccumulator();
|
|
@@ -267,6 +259,12 @@ export async function handleGetUnreadDigest(service, args) {
|
|
|
267
259
|
};
|
|
268
260
|
}));
|
|
269
261
|
const content = [{ type: 'text', text: toonText(digest) }];
|
|
262
|
+
if (unreadBoxes.length < allUnread.length) {
|
|
263
|
+
content.push({
|
|
264
|
+
type: 'text',
|
|
265
|
+
text: `Showing the ${unreadBoxes.length} most recently active of ${allUnread.length} unread conversations (limit=${args.limit}). Omit limit to list all.`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
270
268
|
const note = phiNote(acc);
|
|
271
269
|
if (note)
|
|
272
270
|
content.push(note);
|
package/dist/mcp/integrator.js
CHANGED
|
@@ -79,7 +79,7 @@ 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 server.
|
|
82
|
+
You have real-time access to the user's LINE account through the Yomi Letter-Sealing E2EE server. Check which of these tools your runtime actually exposes, and complete a scoped read before claiming that access; the names below describe expected capabilities, not proof they are connected.
|
|
83
83
|
|
|
84
84
|
## Reading
|
|
85
85
|
1. \`list_conversations\`: List the chats the account can see.
|
|
@@ -95,10 +95,16 @@ You have real-time access to the user's LINE account through the Yomi Letter-Sea
|
|
|
95
95
|
2. \`unsend_message\`: Recall a message the account sent.
|
|
96
96
|
|
|
97
97
|
## Operating the server
|
|
98
|
+
- The Yomi Model Context Protocol server is registered globally for the coding tools on this machine. Every tool keeps its own server list, so a tool that does not expose the names above is missing its own registration, not the server.
|
|
99
|
+
- When the names above are in your tool list, call them directly. Start the server yourself only when they are absent.
|
|
98
100
|
- 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
101
|
- The \`yomi messages\` CLI reports an empty list; only the Model Context Protocol tools above return real messages.
|
|
100
102
|
- \`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.
|
|
101
103
|
|
|
104
|
+
## Recovering message text
|
|
105
|
+
- A message can come back with no text while the conversation still holds it. The local search index keeps the plaintext, so look the same message id up in \`search-index.db\` in the Yomi data directory before you report a message as empty.
|
|
106
|
+
- An empty text field is not proof of a recall. A recall is an unsend you made yourself, and LINE refuses one outside the first hour.
|
|
107
|
+
|
|
102
108
|
## Guidelines
|
|
103
109
|
- Summarize conversations concisely and highlight action items or mentions needing attention.
|
|
104
110
|
- Drafting does not authorize sending. Require explicit sending authorization for the recipient and content; reuse that exact authorization when already given.
|
package/dist/mcp/server.js
CHANGED
|
@@ -21,43 +21,8 @@ import { TOOLS } from './tools.js';
|
|
|
21
21
|
import { LOGIN_UI_RESOURCE_CONTENTS, LOGIN_UI_RESOURCE_LISTING, LOGIN_UI_RESOURCE_URI, } from './ui/resource.js';
|
|
22
22
|
import { toolsForClient } from './ui/tools-with-ui.js';
|
|
23
23
|
const log = createCliLogger('Yomi');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
async function main() {
|
|
28
|
-
const service = new LineProtocolService();
|
|
29
|
-
// A Node EventEmitter with zero 'error' listeners turns any emit('error')
|
|
30
|
-
// into an uncaught exception that kills this process (see pwless-login-flow.ts,
|
|
31
|
-
// auth-session-service.ts). This listener must exist for the lifetime of the
|
|
32
|
-
// service so a login/session error surfaces as a log line, not a crash — the
|
|
33
|
-
// real failure still reaches callers through the normal throw/rejection path.
|
|
34
|
-
service.on('error', (error) => log.warn('service.error', { error: error?.message ?? String(error) }));
|
|
35
|
-
const resumed = await service.resumeSession();
|
|
36
|
-
if (!resumed) {
|
|
37
|
-
log.warn('session.resume_failed', { message: NO_CREDENTIALS_MESSAGE });
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
log.info('session.resumed', { mid: service.profile?.mid ?? null });
|
|
41
|
-
startCapture(service, getDefaultEmbedder()).catch((error) => {
|
|
42
|
-
log.error('capture.start_failed', {
|
|
43
|
-
error: error?.message ?? String(error),
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
// Asked once per process, bounded, best-effort: a hand-installed .mcpb
|
|
48
|
-
// bundle never auto-updates, so this notice is the only way a GUI-only user
|
|
49
|
-
// learns a newer Yomi exists (see ../util/update-check.ts). Awaited here
|
|
50
|
-
// because `instructions` is built once, before any client connects.
|
|
51
|
-
const updateNotice = await checkForUpdate();
|
|
52
|
-
if (updateNotice) {
|
|
53
|
-
log.info('update.available', { current: YOMI_VERSION });
|
|
54
|
-
}
|
|
55
|
-
// `instructions` is surfaced by the MCP SDK to the client/model on
|
|
56
|
-
// initialize — a "TOS on connect" privacy disclosure. It is a consent
|
|
57
|
-
// notice, not decoration. A short model directive precedes the canonical
|
|
58
|
-
// policy prose, which lives ONLY in PRIVACY.md (see ./policy.ts) so the
|
|
59
|
-
// disclosure is single-sourced and never drifts from get_scope_policy.
|
|
60
|
-
const instructions = (updateNotice ? `${updateNotice}\n\n` : '') +
|
|
24
|
+
export function buildInstructions(updateNotice) {
|
|
25
|
+
return ((updateNotice ? `${updateNotice}\n\n` : '') +
|
|
61
26
|
'SESSION ERRORS — if a tool fails with a login-required or signed-out ' +
|
|
62
27
|
'error, the MCP server and its connection are healthy: LINE revoked this ' +
|
|
63
28
|
"device's session (usually because the same account logged in somewhere " +
|
|
@@ -82,8 +47,10 @@ async function main() {
|
|
|
82
47
|
'is included below, so you do NOT need to call get_scope_policy just to recite the ' +
|
|
83
48
|
'policy; call get_scope_policy only when the user asks to see the policy again or ' +
|
|
84
49
|
'wants the current exclusion list. Policy follows:\n\n' +
|
|
85
|
-
getPrivacyPolicyText();
|
|
86
|
-
|
|
50
|
+
getPrivacyPolicyText());
|
|
51
|
+
}
|
|
52
|
+
export function createBuildServer(service, instructions) {
|
|
53
|
+
return ({ era }) => {
|
|
87
54
|
const server = new Server({ name: 'yomi', version: YOMI_VERSION }, {
|
|
88
55
|
capabilities: { tools: {}, resources: {} },
|
|
89
56
|
instructions,
|
|
@@ -258,6 +225,41 @@ async function main() {
|
|
|
258
225
|
});
|
|
259
226
|
return server;
|
|
260
227
|
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Build and start the Yomi MCP server over stdio.
|
|
231
|
+
*/
|
|
232
|
+
async function main() {
|
|
233
|
+
// If a background daemon is running and standalone is not forced, proxy stdio to it!
|
|
234
|
+
if (process.env.YOMI_STANDALONE !== '1') {
|
|
235
|
+
const { getRunningDaemon, proxyStdioToDaemon } = await import('./daemon.js');
|
|
236
|
+
const daemon = await getRunningDaemon();
|
|
237
|
+
if (daemon) {
|
|
238
|
+
log.info('daemon.connected', { port: daemon.port, pid: daemon.pid });
|
|
239
|
+
await proxyStdioToDaemon(daemon);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const service = new LineProtocolService();
|
|
244
|
+
service.on('error', (error) => log.warn('service.error', { error: error?.message ?? String(error) }));
|
|
245
|
+
const resumed = await service.resumeSession();
|
|
246
|
+
if (!resumed) {
|
|
247
|
+
log.warn('session.resume_failed', { message: NO_CREDENTIALS_MESSAGE });
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
log.info('session.resumed', { mid: service.profile?.mid ?? null });
|
|
251
|
+
startCapture(service, getDefaultEmbedder()).catch((error) => {
|
|
252
|
+
log.error('capture.start_failed', {
|
|
253
|
+
error: error?.message ?? String(error),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const updateNotice = await checkForUpdate();
|
|
258
|
+
if (updateNotice) {
|
|
259
|
+
log.info('update.available', { current: YOMI_VERSION });
|
|
260
|
+
}
|
|
261
|
+
const instructions = buildInstructions(updateNotice);
|
|
262
|
+
const buildServer = createBuildServer(service, instructions);
|
|
261
263
|
serveStdio(buildServer, {
|
|
262
264
|
legacy: 'serve',
|
|
263
265
|
onerror: (error) => log.error('server.protocol_error', { error: error.message }),
|
package/dist/mcp/tools.js
CHANGED
|
@@ -115,7 +115,7 @@ export const TOOLS = [
|
|
|
115
115
|
name: 'get_message_media',
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
|
-
description: 'One-shot unread digest: every LINE conversation with unread messages, each with its most recent messages (default 10), E2EE-decrypted, with resolved sender names. Saves calling list_conversations then get_chat_messages per chat. Read-only: never marks anything read, never touches the search index; denylist-excluded conversations are omitted. Returns an empty list when nothing is unread — never fabricated.',
|
|
118
|
+
description: 'One-shot unread digest: every LINE conversation with unread messages (all pages of the inbox, newest activity first), each with its most recent messages (default 10), E2EE-decrypted, with resolved sender names. Saves calling list_conversations then get_chat_messages per chat. Read-only: never marks anything read, never touches the search index; denylist-excluded conversations are omitted. Returns an empty list when nothing is unread — never fabricated.',
|
|
119
119
|
inputSchema: {
|
|
120
120
|
type: 'object',
|
|
121
121
|
properties: {
|
|
@@ -125,7 +125,7 @@ export const TOOLS = [
|
|
|
125
125
|
},
|
|
126
126
|
limit: {
|
|
127
127
|
type: 'number',
|
|
128
|
-
description: 'Maximum number of conversations to
|
|
128
|
+
description: 'Maximum number of unread conversations to return, most recently active first (default: all of them).',
|
|
129
129
|
},
|
|
130
130
|
},
|
|
131
131
|
},
|
package/dist/search/collector.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* messages + bigram search_text are always stored regardless of whether
|
|
27
27
|
* embedding succeeded, so keyword search keeps working either way.
|
|
28
28
|
*/
|
|
29
|
+
import { listBoxesByRecency } from '../line/core/message-box-recency.js';
|
|
29
30
|
import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
|
|
30
31
|
import { createCliLogger } from '../util/log.js';
|
|
31
32
|
import { getExcludedChatIds } from './scope.js';
|
|
@@ -46,12 +47,7 @@ async function resolveTargetChats(service, chatIds) {
|
|
|
46
47
|
if (chatIds && chatIds.length > 0) {
|
|
47
48
|
return resolveConversationNames(service, chatIds);
|
|
48
49
|
}
|
|
49
|
-
const
|
|
50
|
-
lastMessagesPerMessageBoxCount: 1,
|
|
51
|
-
messageBoxCountLimit: DEFAULT_CHAT_LIMIT,
|
|
52
|
-
withUnreadCount: false,
|
|
53
|
-
});
|
|
54
|
-
const boxes = result.messageBoxes || [];
|
|
50
|
+
const boxes = (await listBoxesByRecency(service, { withUnreadCount: false })).slice(0, DEFAULT_CHAT_LIMIT);
|
|
55
51
|
const ids = boxes.map((box) => box.id).filter(Boolean);
|
|
56
52
|
return resolveConversationNames(service, ids);
|
|
57
53
|
}
|
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.
|
|
6
|
+
"version": "0.6.4",
|
|
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",
|
package/run.mjs
CHANGED
|
@@ -433,14 +433,107 @@ switch (cmd) {
|
|
|
433
433
|
case 'serve':
|
|
434
434
|
await load('mcp/server');
|
|
435
435
|
break;
|
|
436
|
+
case 'daemon': {
|
|
437
|
+
const subCmd = args[1] ?? 'start';
|
|
438
|
+
const { startDaemonServer, stopDaemon, getRunningDaemon } = await load('mcp/daemon');
|
|
439
|
+
|
|
440
|
+
if (subCmd === 'status') {
|
|
441
|
+
const daemon = await getRunningDaemon();
|
|
442
|
+
if (!daemon) {
|
|
443
|
+
console.log(JSON.stringify({ running: false }));
|
|
444
|
+
} else {
|
|
445
|
+
console.log(JSON.stringify({ running: true, ...daemon }));
|
|
446
|
+
}
|
|
447
|
+
process.exit(0);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (subCmd === 'stop') {
|
|
451
|
+
const res = await stopDaemon();
|
|
452
|
+
if (res.stopped) {
|
|
453
|
+
console.log(JSON.stringify({ stopped: true, pid: res.pid }));
|
|
454
|
+
} else {
|
|
455
|
+
console.log(JSON.stringify({ stopped: false, message: 'Daemon is not running' }));
|
|
456
|
+
}
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (subCmd === 'start' || subCmd === 'run') {
|
|
461
|
+
const isForeground = args.includes('--foreground') || args.includes('-f');
|
|
462
|
+
const portIdx = args.indexOf('--port');
|
|
463
|
+
const port = portIdx >= 0 && args[portIdx + 1] ? parseInt(args[portIdx + 1], 10) : undefined;
|
|
464
|
+
|
|
465
|
+
const existing = await getRunningDaemon();
|
|
466
|
+
if (existing) {
|
|
467
|
+
console.log(JSON.stringify({ started: false, message: 'Daemon already running', ...existing }));
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (isForeground) {
|
|
472
|
+
await startDaemonServer({ port });
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { spawn } = await import('node:child_process');
|
|
477
|
+
const child = spawn(process.execPath, [process.argv[1], 'daemon', '--foreground', ...(port ? ['--port', String(port)] : [])], {
|
|
478
|
+
detached: true,
|
|
479
|
+
stdio: 'ignore',
|
|
480
|
+
env: process.env,
|
|
481
|
+
});
|
|
482
|
+
child.unref();
|
|
483
|
+
|
|
484
|
+
let running = null;
|
|
485
|
+
for (let i = 0; i < 30; i++) {
|
|
486
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
487
|
+
running = await getRunningDaemon();
|
|
488
|
+
if (running) break;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (running) {
|
|
492
|
+
console.log(JSON.stringify({ started: true, ...running }));
|
|
493
|
+
process.exit(0);
|
|
494
|
+
} else {
|
|
495
|
+
console.error(JSON.stringify({ started: false, error: 'Daemon failed to initialize in background' }));
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (subCmd === 'restart') {
|
|
501
|
+
await stopDaemon();
|
|
502
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
503
|
+
const { spawn } = await import('node:child_process');
|
|
504
|
+
const child = spawn(process.execPath, [process.argv[1], 'daemon', '--foreground'], {
|
|
505
|
+
detached: true,
|
|
506
|
+
stdio: 'ignore',
|
|
507
|
+
env: process.env,
|
|
508
|
+
});
|
|
509
|
+
child.unref();
|
|
510
|
+
let running = null;
|
|
511
|
+
for (let i = 0; i < 30; i++) {
|
|
512
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
513
|
+
running = await getRunningDaemon();
|
|
514
|
+
if (running) break;
|
|
515
|
+
}
|
|
516
|
+
if (running) {
|
|
517
|
+
console.log(JSON.stringify({ restarted: true, ...running }));
|
|
518
|
+
process.exit(0);
|
|
519
|
+
} else {
|
|
520
|
+
console.error(JSON.stringify({ restarted: false, error: 'Daemon failed to restart' }));
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
console.error(`Unknown daemon command: ${subCmd}\nUsage: yomi daemon [start|stop|status|restart] [--foreground] [--port <number>]`);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
436
528
|
case 'help':
|
|
437
529
|
case '--help':
|
|
438
530
|
case '-h':
|
|
439
531
|
console.log(`Yomi (読み) — read, reply, send images to, and search your LINE from any AI agent.
|
|
440
532
|
|
|
441
533
|
Usage:
|
|
442
|
-
yomi Start the MCP stdio server (default)
|
|
534
|
+
yomi Start the MCP stdio server (default, proxies to daemon if running)
|
|
443
535
|
yomi serve Same as above, explicit
|
|
536
|
+
yomi daemon [...] Manage background persistent MCP daemon (start|stop|status|restart)
|
|
444
537
|
yomi login [...] Run the passwordless login flow in a terminal
|
|
445
538
|
yomi unsend <id> Retract (unsend) a message sent within the last hour
|
|
446
539
|
yomi help Show this help
|
|
@@ -448,6 +541,9 @@ Usage:
|
|
|
448
541
|
|
|
449
542
|
Examples:
|
|
450
543
|
npx @rikaidev/yomi # start MCP server
|
|
544
|
+
npx @rikaidev/yomi daemon start # start persistent shared daemon
|
|
545
|
+
npx @rikaidev/yomi daemon status # check daemon status
|
|
546
|
+
npx @rikaidev/yomi daemon stop # stop background daemon
|
|
451
547
|
npx @rikaidev/yomi login # interactive login
|
|
452
548
|
npx @rikaidev/yomi login --phone +886912345678 --region TW`);
|
|
453
549
|
break;
|