@rikaidev/yomi 0.2.6
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/LICENSE +21 -0
- package/NOTICE +81 -0
- package/PRIVACY.md +39 -0
- package/README.md +659 -0
- package/dist/auth/credential-store.js +395 -0
- package/dist/auth/keychain.js +256 -0
- package/dist/cli/login.js +142 -0
- package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
- package/dist/line/auth/protocol/index.js +3 -0
- package/dist/line/auth/protocol/login-metadata.js +159 -0
- package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
- package/dist/line/auth/pwless/index.js +163 -0
- package/dist/line/auth/pwless/steps.js +213 -0
- package/dist/line/client/auth-service/client.js +240 -0
- package/dist/line/client/e2ee-service/client.js +69 -0
- package/dist/line/client/e2ee-service/requests.js +92 -0
- package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
- package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
- package/dist/line/client/index.js +213 -0
- package/dist/line/client/obs-media-client.js +140 -0
- package/dist/line/client/parsers.js +69 -0
- package/dist/line/client/shop-service/client.js +63 -0
- package/dist/line/client/sticker-meta.js +46 -0
- package/dist/line/client/sync-service/client.js +317 -0
- package/dist/line/client/talk-service/chat-query.js +122 -0
- package/dist/line/client/talk-service/command-client.js +242 -0
- package/dist/line/client/talk-service/contact-query.js +77 -0
- package/dist/line/client/talk-service/message-box-query.js +137 -0
- package/dist/line/client/talk-service/message-id-query.js +241 -0
- package/dist/line/client/talk-service/query-client.js +219 -0
- package/dist/line/client/talk-service/requests.js +383 -0
- package/dist/line/client/transport.js +138 -0
- package/dist/line/core/auth-session-logout.js +55 -0
- package/dist/line/core/auth-session-runtime.js +231 -0
- package/dist/line/core/auth-session-service.js +85 -0
- package/dist/line/core/chat-runtime-service.js +343 -0
- package/dist/line/core/client-runtime.js +17 -0
- package/dist/line/core/config.js +67 -0
- package/dist/line/core/constants.js +56 -0
- package/dist/line/core/contact-query-service.js +20 -0
- package/dist/line/core/directory-runtime-service.js +83 -0
- package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
- package/dist/line/core/e2ee/index.js +6 -0
- package/dist/line/core/e2ee/keys/decrypt.js +110 -0
- package/dist/line/core/e2ee/keys/encrypt.js +117 -0
- package/dist/line/core/e2ee/keys/group-key.js +260 -0
- package/dist/line/core/e2ee/keys/key-manager.js +231 -0
- package/dist/line/core/e2ee/keys/key-payload.js +130 -0
- package/dist/line/core/e2ee/keys/key-types.js +1 -0
- package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
- package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
- package/dist/line/core/e2ee/keys/peer-key.js +191 -0
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
- package/dist/line/core/e2ee/types.js +4 -0
- package/dist/line/core/group-query-service.js +20 -0
- package/dist/line/core/media-decrypt.js +78 -0
- package/dist/line/core/media-encrypt.js +97 -0
- package/dist/line/core/mention.js +89 -0
- package/dist/line/core/message-command-service.js +454 -0
- package/dist/line/core/message-query-service.js +161 -0
- package/dist/line/core/service.js +134 -0
- package/dist/line/core/session-state/index.js +243 -0
- package/dist/line/core/session-state/persist.js +192 -0
- package/dist/line/core/store.js +8 -0
- package/dist/line/core/thrift/binary/binary-reader.js +151 -0
- package/dist/line/core/thrift/binary/binary-writer.js +157 -0
- package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
- package/dist/line/core/thrift/compact/compact-reader.js +180 -0
- package/dist/line/core/thrift/compact/compact-writer.js +161 -0
- package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
- package/dist/line/core/thrift/fields/builders.js +124 -0
- package/dist/line/core/thrift/index.js +12 -0
- package/dist/line/core/thrift/message/decoder.js +55 -0
- package/dist/line/core/thrift/message/encoder.js +25 -0
- package/dist/line/core/thrift/primitives/varint.js +64 -0
- package/dist/line/core/thrift/struct/reader.js +172 -0
- package/dist/line/core/thrift/struct/writer.js +189 -0
- package/dist/line/core/thrift/types.js +23 -0
- package/dist/line/core/video-thumbnail.js +104 -0
- package/dist/mcp/handlers/contacts.js +166 -0
- package/dist/mcp/handlers/conversations.js +193 -0
- package/dist/mcp/handlers/groups.js +123 -0
- package/dist/mcp/handlers/index.js +15 -0
- package/dist/mcp/handlers/login.js +473 -0
- package/dist/mcp/handlers/media.js +69 -0
- package/dist/mcp/handlers/message-actions.js +62 -0
- package/dist/mcp/handlers/scope.js +120 -0
- package/dist/mcp/handlers/search.js +196 -0
- package/dist/mcp/handlers/send.js +523 -0
- package/dist/mcp/handlers/shared.js +84 -0
- package/dist/mcp/handlers/stickers.js +87 -0
- package/dist/mcp/media.js +228 -0
- package/dist/mcp/names.js +133 -0
- package/dist/mcp/phi-guard.js +105 -0
- package/dist/mcp/policy.js +34 -0
- package/dist/mcp/server.js +301 -0
- package/dist/mcp/tools.js +761 -0
- package/dist/mcp/ui/capability.js +26 -0
- package/dist/mcp/ui/login-app-protocol.js +118 -0
- package/dist/mcp/ui/login-app-styles.js +142 -0
- package/dist/mcp/ui/login-app-view.js +361 -0
- package/dist/mcp/ui/login-app.js +72 -0
- package/dist/mcp/ui/login-structured-content.js +1 -0
- package/dist/mcp/ui/resource.js +35 -0
- package/dist/mcp/ui/tools-with-ui.js +40 -0
- package/dist/search/bigram.js +86 -0
- package/dist/search/capture.js +140 -0
- package/dist/search/collector.js +176 -0
- package/dist/search/default-embedder.js +101 -0
- package/dist/search/embedder.js +13 -0
- package/dist/search/scope.js +142 -0
- package/dist/search/sqlite.js +141 -0
- package/dist/search/store.js +469 -0
- package/dist/util/data-dir.js +55 -0
- package/dist/util/log.js +86 -0
- package/dist/version.js +7 -0
- package/package.json +63 -0
- package/run.mjs +71 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createCliLogger } from '../../util/log.js';
|
|
2
|
+
import { jsonResult, toolError } from './shared.js';
|
|
3
|
+
const log = createCliLogger('Yomi');
|
|
4
|
+
/**
|
|
5
|
+
* Handle `react_message` — REALLY adds a predefined reaction to a real LINE
|
|
6
|
+
* message now (TalkService react). Visible to the conversation. `reactionType`:
|
|
7
|
+
* 2=LIKE 👍, 3=LOVE ❤️, 4=LAUGH 😆, 5=SURPRISE 😮, 6=SAD 😢, 7=ANGRY 😡
|
|
8
|
+
* (default 2).
|
|
9
|
+
*
|
|
10
|
+
* @param service - Resumed LineProtocolService.
|
|
11
|
+
* @param args - Tool arguments.
|
|
12
|
+
* @returns MCP tool result.
|
|
13
|
+
*/
|
|
14
|
+
export async function handleReactMessage(service, args) {
|
|
15
|
+
if (!args.messageId) {
|
|
16
|
+
return toolError('messageId is required.');
|
|
17
|
+
}
|
|
18
|
+
const result = await service.reactToMessage(args.messageId, args.reactionType);
|
|
19
|
+
log.info('react_message.done', {
|
|
20
|
+
messageId: args.messageId,
|
|
21
|
+
reactionType: result?.reactionType,
|
|
22
|
+
});
|
|
23
|
+
return jsonResult(result);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Handle `unsend_message` — REALLY retracts one of THIS account's own messages
|
|
27
|
+
* for everyone (TalkService unsendMessage). This deletes the message from the
|
|
28
|
+
* conversation for all participants and is NOT reversible. Guarded by an
|
|
29
|
+
* explicit `confirm: true` gate so it can never fire by accident. LINE only
|
|
30
|
+
* permits unsending the caller's own messages.
|
|
31
|
+
*
|
|
32
|
+
* @param service - Resumed LineProtocolService.
|
|
33
|
+
* @param args - Tool arguments.
|
|
34
|
+
* @returns MCP tool result.
|
|
35
|
+
*/
|
|
36
|
+
export async function handleUnsendMessage(service, args) {
|
|
37
|
+
if (!args.messageId) {
|
|
38
|
+
return toolError('messageId is required.');
|
|
39
|
+
}
|
|
40
|
+
if (args.confirm !== true) {
|
|
41
|
+
return toolError('unsend_message permanently retracts your message for everyone and cannot be undone. Re-call with confirm: true to proceed.');
|
|
42
|
+
}
|
|
43
|
+
const result = await service.unsendMessage(args.messageId);
|
|
44
|
+
log.info('unsend_message.done', { messageId: args.messageId });
|
|
45
|
+
return jsonResult(result);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Handle `cancel_reaction` — REALLY removes THIS account's reaction from a real
|
|
49
|
+
* LINE message now (TalkService cancelReaction).
|
|
50
|
+
*
|
|
51
|
+
* @param service - Resumed LineProtocolService.
|
|
52
|
+
* @param args - Tool arguments.
|
|
53
|
+
* @returns MCP tool result.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleCancelReaction(service, args) {
|
|
56
|
+
if (!args.messageId) {
|
|
57
|
+
return toolError('messageId is required.');
|
|
58
|
+
}
|
|
59
|
+
const result = await service.cancelReaction(args.messageId);
|
|
60
|
+
log.info('cancel_reaction.done', { messageId: args.messageId });
|
|
61
|
+
return jsonResult(result);
|
|
62
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yomi conversation-scoping MCP tool handlers — exclude_chats,
|
|
3
|
+
* include_chats, list_excluded_chats, get_scope_policy.
|
|
4
|
+
*
|
|
5
|
+
* Split out from handlers.ts (mirrors the search-handlers.ts split) to keep
|
|
6
|
+
* that file under the project's 500-scc-line module cap. These are
|
|
7
|
+
* LOCAL-INDEX operations over the denylist in ../search/scope.ts: they read
|
|
8
|
+
* and write the same search-index.db that ../search/collector.ts and
|
|
9
|
+
* ../search/store.ts own, and never require a live LINE session except to
|
|
10
|
+
* best-effort resolve display names in list_excluded_chats/get_scope_policy.
|
|
11
|
+
*/
|
|
12
|
+
import { addExcludedChatIds, deleteMessagesForChats, getExcludedChatIds, removeExcludedChatIds, } from '../../search/scope.js';
|
|
13
|
+
import { resolveConversationNames } from '../names.js';
|
|
14
|
+
import { getPrivacyPolicyText } from '../policy.js';
|
|
15
|
+
import { toolError } from './shared.js';
|
|
16
|
+
/**
|
|
17
|
+
* Read the current exclusion denylist and pair each chatId with a
|
|
18
|
+
* best-effort display name. Name resolution only runs when a live LINE
|
|
19
|
+
* session exists (`service.client`); without one, or when a name is
|
|
20
|
+
* otherwise unresolvable, `name` is `null` rather than a fabricated
|
|
21
|
+
* placeholder. Shared by list_excluded_chats and get_scope_policy so the
|
|
22
|
+
* two never drift.
|
|
23
|
+
*
|
|
24
|
+
* @param service - LineProtocolService; `service.client` is used only for
|
|
25
|
+
* name resolution and may be absent.
|
|
26
|
+
* @returns Excluded conversations, each `{ chatId, name }`.
|
|
27
|
+
*/
|
|
28
|
+
async function listExcludedChatsWithNames(service) {
|
|
29
|
+
const chatIds = Array.from(getExcludedChatIds());
|
|
30
|
+
if (chatIds.length === 0) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const names = service.client
|
|
34
|
+
? await resolveConversationNames(service, chatIds)
|
|
35
|
+
: new Map();
|
|
36
|
+
return chatIds.map((chatId) => ({ chatId, name: names.get(chatId) ?? null }));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Handle `exclude_chats` — add chatIds to the scoping denylist AND purge
|
|
40
|
+
* their already-indexed messages/embeddings. Both effects happen together:
|
|
41
|
+
* excluding a chat is meant to remove what Yomi already learned about it,
|
|
42
|
+
* not just stop learning more (see ../search/scope.ts).
|
|
43
|
+
*
|
|
44
|
+
* @param args - Tool arguments.
|
|
45
|
+
* @returns MCP tool result with `{ excluded, purgedMessages }`.
|
|
46
|
+
*/
|
|
47
|
+
export async function handleExcludeChats(args) {
|
|
48
|
+
if (!args.chatIds || args.chatIds.length === 0) {
|
|
49
|
+
return toolError('chatIds is required and must be a non-empty array.');
|
|
50
|
+
}
|
|
51
|
+
const excluded = addExcludedChatIds(args.chatIds);
|
|
52
|
+
const purgedMessages = deleteMessagesForChats(args.chatIds);
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: JSON.stringify({ excluded, purgedMessages }, null, 2),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Handle `include_chats` — remove chatIds from the scoping denylist,
|
|
64
|
+
* re-allowing future capture. Does NOT re-fetch or restore any previously
|
|
65
|
+
* purged data; the next `collect_messages`/`search_messages` auto-collect
|
|
66
|
+
* picks the chat back up going forward.
|
|
67
|
+
*
|
|
68
|
+
* @param args - Tool arguments.
|
|
69
|
+
* @returns MCP tool result with `{ included }`.
|
|
70
|
+
*/
|
|
71
|
+
export async function handleIncludeChats(args) {
|
|
72
|
+
if (!args.chatIds || args.chatIds.length === 0) {
|
|
73
|
+
return toolError('chatIds is required and must be a non-empty array.');
|
|
74
|
+
}
|
|
75
|
+
const included = removeExcludedChatIds(args.chatIds);
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{ type: 'text', text: JSON.stringify({ included }, null, 2) },
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Handle `list_excluded_chats` — the current scoping denylist, with
|
|
84
|
+
* best-effort display-name resolution when a live LINE session exists.
|
|
85
|
+
* Without a session (or when a name is otherwise unresolvable), `name` is
|
|
86
|
+
* `null` rather than a fabricated placeholder.
|
|
87
|
+
*
|
|
88
|
+
* @param service - LineProtocolService; `service.client` is used only for
|
|
89
|
+
* name resolution and may be absent.
|
|
90
|
+
* @returns MCP tool result with `[{ chatId, name }]`.
|
|
91
|
+
*/
|
|
92
|
+
export async function handleListExcludedChats(service) {
|
|
93
|
+
const result = await listExcludedChatsWithNames(service);
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Handle `get_scope_policy` — return Yomi's data-capture privacy policy (the
|
|
100
|
+
* disclosure the agent should surface to the user) alongside the current
|
|
101
|
+
* exclusion list. The policy prose comes from the canonical PRIVACY.md via
|
|
102
|
+
* getPrivacyPolicyText (single source of truth — never duplicated here).
|
|
103
|
+
* Reuses the same best-effort name resolution as list_excluded_chats via
|
|
104
|
+
* listExcludedChatsWithNames, so names appear only when a live LINE session
|
|
105
|
+
* exists, else `null`.
|
|
106
|
+
*
|
|
107
|
+
* @param service - LineProtocolService; `service.client` is used only for
|
|
108
|
+
* name resolution and may be absent.
|
|
109
|
+
* @returns MCP tool result with `{ policy, excludedChats }`.
|
|
110
|
+
*/
|
|
111
|
+
export async function handleGetScopePolicy(service) {
|
|
112
|
+
const excludedChats = await listExcludedChatsWithNames(service);
|
|
113
|
+
const result = {
|
|
114
|
+
policy: getPrivacyPolicyText(),
|
|
115
|
+
excludedChats,
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yomi cross-conversation search tool handlers — collect_messages and
|
|
3
|
+
* search_messages.
|
|
4
|
+
*
|
|
5
|
+
* Split out from handlers.ts because these two tools are the boundary
|
|
6
|
+
* between the pure-query LINE tools (handlers.ts) and the local search
|
|
7
|
+
* index in ../search/ (see store.ts, collector.ts): collect_messages
|
|
8
|
+
* writes to that index, search_messages reads from it. No other tool
|
|
9
|
+
* touches the index.
|
|
10
|
+
*
|
|
11
|
+
* search_messages is HYBRID: it always runs FTS5 keyword search (bigram-
|
|
12
|
+
* preprocessed, so it covers every indexed message and guarantees exact
|
|
13
|
+
* substring matches are never lost) and, when embeddings exist for the
|
|
14
|
+
* current model, also runs semantic search (cosine similarity over
|
|
15
|
+
* transformers.js embeddings, see ../search/default-embedder.ts). The two
|
|
16
|
+
* ranked lists are fused by Reciprocal Rank Fusion (see fuseByRrf) so a
|
|
17
|
+
* result that either method ranks highly surfaces near the top. This is
|
|
18
|
+
* deliberately not "semantic OR keyword": pure semantic over a partially-
|
|
19
|
+
* embedded index silently drops exact matches that live in un-embedded
|
|
20
|
+
* messages (the "search 台東 misses the chat that literally says 台東" bug),
|
|
21
|
+
* and pure keyword misses paraphrases. Which methods actually contributed
|
|
22
|
+
* is always reported via the `mode` field (`hybrid` | `semantic` |
|
|
23
|
+
* `keyword`), never hidden.
|
|
24
|
+
*/
|
|
25
|
+
import { collectMessages } from '../../search/collector.js';
|
|
26
|
+
import { getDefaultEmbedder } from '../../search/default-embedder.js';
|
|
27
|
+
import { getEmbeddingCount, getIndexedMessageCount, searchMessages, semanticSearch, } from '../../search/store.js';
|
|
28
|
+
import { createCliLogger } from '../../util/log.js';
|
|
29
|
+
import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
|
|
30
|
+
import { toolError } from './shared.js';
|
|
31
|
+
const log = createCliLogger('Yomi');
|
|
32
|
+
/**
|
|
33
|
+
* Reciprocal Rank Fusion constant. The standard k=60 from Cormack et al.:
|
|
34
|
+
* a result's contribution from one list is 1/(k + rank), so rank matters
|
|
35
|
+
* but no single list can dominate on raw score scale (bm25's negative log
|
|
36
|
+
* scores and cosine's [0,1] are never compared directly — only their
|
|
37
|
+
* ranks are). Larger k flattens the rank curve; 60 is the well-tested default.
|
|
38
|
+
*/
|
|
39
|
+
const RRF_K = 60;
|
|
40
|
+
/**
|
|
41
|
+
* Merge several already-ranked result lists into one, deduping by
|
|
42
|
+
* messageId, using Reciprocal Rank Fusion. Each list contributes
|
|
43
|
+
* 1/(RRF_K + rank) to a message's fused score; scores sum across lists, so
|
|
44
|
+
* a message ranked highly by BOTH keyword and semantic search rises above
|
|
45
|
+
* one ranked highly by only one. The returned `score` is the fused RRF
|
|
46
|
+
* score (NOT a cosine similarity or bm25 value) — it exists only to order
|
|
47
|
+
* results, and is reported as-is rather than dressed up as a probability.
|
|
48
|
+
*
|
|
49
|
+
* @param lists - Ranked result lists (best-first) to fuse.
|
|
50
|
+
* @param limit - Maximum number of fused results to return.
|
|
51
|
+
* @returns Fused results, best-first, each carrying its RRF score.
|
|
52
|
+
*/
|
|
53
|
+
function fuseByRrf(lists, limit) {
|
|
54
|
+
const scores = new Map();
|
|
55
|
+
const rows = new Map();
|
|
56
|
+
for (const list of lists) {
|
|
57
|
+
list.forEach((row, rank) => {
|
|
58
|
+
scores.set(row.messageId, (scores.get(row.messageId) ?? 0) + 1 / (RRF_K + rank));
|
|
59
|
+
if (!rows.has(row.messageId)) {
|
|
60
|
+
rows.set(row.messageId, row);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return [...scores.entries()]
|
|
65
|
+
.sort((a, b) => b[1] - a[1])
|
|
66
|
+
.slice(0, limit)
|
|
67
|
+
.map(([messageId, score]) => ({
|
|
68
|
+
...rows.get(messageId),
|
|
69
|
+
score,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Handle `collect_messages` — explicit bulk fetch of recent messages from
|
|
74
|
+
* one or more LINE conversations into the local search index, batch-
|
|
75
|
+
* embedding each chat's messages with the default embedder along the way
|
|
76
|
+
* (best-effort — see ../search/collector.ts's embedAndStore for the
|
|
77
|
+
* failure-never-blocks-collection guarantee). The only tool allowed to
|
|
78
|
+
* bulk-fetch across chats; runs exactly once per call.
|
|
79
|
+
*
|
|
80
|
+
* @param service - Resumed LineProtocolService.
|
|
81
|
+
* @param args - Tool arguments.
|
|
82
|
+
* @returns MCP tool result summarizing what was indexed and embedded.
|
|
83
|
+
*/
|
|
84
|
+
export async function handleCollectMessages(service, args) {
|
|
85
|
+
const summary = await collectMessages(service, {
|
|
86
|
+
chatIds: args.chatIds,
|
|
87
|
+
perChat: args.perChat,
|
|
88
|
+
embedder: getDefaultEmbedder(),
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{ type: 'text', text: JSON.stringify(summary, null, 2) },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Handle `search_messages` — hybrid keyword + semantic search, fused by
|
|
98
|
+
* Reciprocal Rank Fusion (see fuseByRrf). Keyword search always runs and
|
|
99
|
+
* covers every indexed message, so exact matches are never dropped;
|
|
100
|
+
* semantic search additionally runs whenever vectors exist for the current
|
|
101
|
+
* model, adding paraphrase recall. `mode` reports which methods contributed.
|
|
102
|
+
*
|
|
103
|
+
* Auto-collects on an empty index: the first search with a live session
|
|
104
|
+
* transparently runs a full `collect_messages` across all conversations
|
|
105
|
+
* before searching, so callers never have to know `collect_messages`
|
|
106
|
+
* exists — searching "just works" out of the box. This is the one place
|
|
107
|
+
* search itself may hit LINE; a non-empty index searches locally with no
|
|
108
|
+
* network. Ongoing freshness is not this handler's job: the background
|
|
109
|
+
* capture loop (see ../search/capture.ts) indexes incoming messages as they
|
|
110
|
+
* arrive, so the index stays current without search re-collecting on every
|
|
111
|
+
* query (which would cost tens of seconds of latency — not any rate limit,
|
|
112
|
+
* which we have never observed). With an empty index and no session, it says
|
|
113
|
+
* so honestly rather than returning a silently empty result.
|
|
114
|
+
*
|
|
115
|
+
* @param service - Resumed LineProtocolService (used only to auto-collect an empty index).
|
|
116
|
+
* @param args - Tool arguments.
|
|
117
|
+
* @returns MCP tool result with `{ mode, results }`, or an honest notice.
|
|
118
|
+
*/
|
|
119
|
+
export async function handleSearchMessages(service, args) {
|
|
120
|
+
if (!args.query) {
|
|
121
|
+
return toolError('query is required.');
|
|
122
|
+
}
|
|
123
|
+
if (getIndexedMessageCount() === 0) {
|
|
124
|
+
if (!service.client) {
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text: 'Search index is empty and there is no LINE session — log in first; the index then builds automatically on your next search.',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
log.info('search.auto_collect', { reason: 'empty_index' });
|
|
135
|
+
const summary = await collectMessages(service, {
|
|
136
|
+
embedder: getDefaultEmbedder(),
|
|
137
|
+
});
|
|
138
|
+
log.info('search.auto_collect_done', { ...summary });
|
|
139
|
+
if (getIndexedMessageCount() === 0) {
|
|
140
|
+
return {
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: 'text',
|
|
144
|
+
text: 'Nothing to search — auto-collect found no readable messages across your conversations.',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const limit = args.limit ?? 20;
|
|
151
|
+
// Pull a deeper candidate pool from each method than the caller's limit,
|
|
152
|
+
// so RRF has enough overlap/depth to reorder meaningfully before slicing.
|
|
153
|
+
const pool = Math.max(limit * 3, 30);
|
|
154
|
+
const embedder = getDefaultEmbedder();
|
|
155
|
+
const keyword = searchMessages(args.query, pool);
|
|
156
|
+
let semantic = [];
|
|
157
|
+
if (getEmbeddingCount(embedder.modelLabel) > 0) {
|
|
158
|
+
try {
|
|
159
|
+
semantic = await semanticSearch(args.query, pool, embedder);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
log.warn('search.semantic_failed', {
|
|
163
|
+
error: error?.message ?? String(error),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let mode;
|
|
168
|
+
let results;
|
|
169
|
+
if (semantic.length > 0 && keyword.length > 0) {
|
|
170
|
+
mode = 'hybrid';
|
|
171
|
+
results = fuseByRrf([keyword, semantic], limit);
|
|
172
|
+
}
|
|
173
|
+
else if (semantic.length > 0) {
|
|
174
|
+
mode = 'semantic';
|
|
175
|
+
results = semantic.slice(0, limit);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
mode = 'keyword';
|
|
179
|
+
results = keyword.slice(0, limit);
|
|
180
|
+
}
|
|
181
|
+
const acc = createPhiAccumulator();
|
|
182
|
+
const maskedResults = results.map((row) => ({
|
|
183
|
+
...row,
|
|
184
|
+
text: maskInto(acc, row.text),
|
|
185
|
+
}));
|
|
186
|
+
const content = [
|
|
187
|
+
{
|
|
188
|
+
type: 'text',
|
|
189
|
+
text: JSON.stringify({ mode, results: maskedResults }, null, 2),
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
const note = phiNote(acc);
|
|
193
|
+
if (note)
|
|
194
|
+
content.push(note);
|
|
195
|
+
return { content };
|
|
196
|
+
}
|