@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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +81 -0
  3. package/PRIVACY.md +39 -0
  4. package/README.md +659 -0
  5. package/dist/auth/credential-store.js +395 -0
  6. package/dist/auth/keychain.js +256 -0
  7. package/dist/cli/login.js +142 -0
  8. package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
  9. package/dist/line/auth/protocol/index.js +3 -0
  10. package/dist/line/auth/protocol/login-metadata.js +159 -0
  11. package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
  12. package/dist/line/auth/pwless/index.js +163 -0
  13. package/dist/line/auth/pwless/steps.js +213 -0
  14. package/dist/line/client/auth-service/client.js +240 -0
  15. package/dist/line/client/e2ee-service/client.js +69 -0
  16. package/dist/line/client/e2ee-service/requests.js +92 -0
  17. package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
  18. package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
  19. package/dist/line/client/index.js +213 -0
  20. package/dist/line/client/obs-media-client.js +140 -0
  21. package/dist/line/client/parsers.js +69 -0
  22. package/dist/line/client/shop-service/client.js +63 -0
  23. package/dist/line/client/sticker-meta.js +46 -0
  24. package/dist/line/client/sync-service/client.js +317 -0
  25. package/dist/line/client/talk-service/chat-query.js +122 -0
  26. package/dist/line/client/talk-service/command-client.js +242 -0
  27. package/dist/line/client/talk-service/contact-query.js +77 -0
  28. package/dist/line/client/talk-service/message-box-query.js +137 -0
  29. package/dist/line/client/talk-service/message-id-query.js +241 -0
  30. package/dist/line/client/talk-service/query-client.js +219 -0
  31. package/dist/line/client/talk-service/requests.js +383 -0
  32. package/dist/line/client/transport.js +138 -0
  33. package/dist/line/core/auth-session-logout.js +55 -0
  34. package/dist/line/core/auth-session-runtime.js +231 -0
  35. package/dist/line/core/auth-session-service.js +85 -0
  36. package/dist/line/core/chat-runtime-service.js +343 -0
  37. package/dist/line/core/client-runtime.js +17 -0
  38. package/dist/line/core/config.js +67 -0
  39. package/dist/line/core/constants.js +56 -0
  40. package/dist/line/core/contact-query-service.js +20 -0
  41. package/dist/line/core/directory-runtime-service.js +83 -0
  42. package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
  43. package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
  44. package/dist/line/core/e2ee/index.js +6 -0
  45. package/dist/line/core/e2ee/keys/decrypt.js +110 -0
  46. package/dist/line/core/e2ee/keys/encrypt.js +117 -0
  47. package/dist/line/core/e2ee/keys/group-key.js +260 -0
  48. package/dist/line/core/e2ee/keys/key-manager.js +231 -0
  49. package/dist/line/core/e2ee/keys/key-payload.js +130 -0
  50. package/dist/line/core/e2ee/keys/key-types.js +1 -0
  51. package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
  52. package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
  53. package/dist/line/core/e2ee/keys/peer-key.js +191 -0
  54. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
  55. package/dist/line/core/e2ee/types.js +4 -0
  56. package/dist/line/core/group-query-service.js +20 -0
  57. package/dist/line/core/media-decrypt.js +78 -0
  58. package/dist/line/core/media-encrypt.js +97 -0
  59. package/dist/line/core/mention.js +89 -0
  60. package/dist/line/core/message-command-service.js +454 -0
  61. package/dist/line/core/message-query-service.js +161 -0
  62. package/dist/line/core/service.js +134 -0
  63. package/dist/line/core/session-state/index.js +243 -0
  64. package/dist/line/core/session-state/persist.js +192 -0
  65. package/dist/line/core/store.js +8 -0
  66. package/dist/line/core/thrift/binary/binary-reader.js +151 -0
  67. package/dist/line/core/thrift/binary/binary-writer.js +157 -0
  68. package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
  69. package/dist/line/core/thrift/compact/compact-reader.js +180 -0
  70. package/dist/line/core/thrift/compact/compact-writer.js +161 -0
  71. package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
  72. package/dist/line/core/thrift/fields/builders.js +124 -0
  73. package/dist/line/core/thrift/index.js +12 -0
  74. package/dist/line/core/thrift/message/decoder.js +55 -0
  75. package/dist/line/core/thrift/message/encoder.js +25 -0
  76. package/dist/line/core/thrift/primitives/varint.js +64 -0
  77. package/dist/line/core/thrift/struct/reader.js +172 -0
  78. package/dist/line/core/thrift/struct/writer.js +189 -0
  79. package/dist/line/core/thrift/types.js +23 -0
  80. package/dist/line/core/video-thumbnail.js +104 -0
  81. package/dist/mcp/handlers/contacts.js +166 -0
  82. package/dist/mcp/handlers/conversations.js +193 -0
  83. package/dist/mcp/handlers/groups.js +123 -0
  84. package/dist/mcp/handlers/index.js +15 -0
  85. package/dist/mcp/handlers/login.js +473 -0
  86. package/dist/mcp/handlers/media.js +69 -0
  87. package/dist/mcp/handlers/message-actions.js +62 -0
  88. package/dist/mcp/handlers/scope.js +120 -0
  89. package/dist/mcp/handlers/search.js +196 -0
  90. package/dist/mcp/handlers/send.js +523 -0
  91. package/dist/mcp/handlers/shared.js +84 -0
  92. package/dist/mcp/handlers/stickers.js +87 -0
  93. package/dist/mcp/media.js +228 -0
  94. package/dist/mcp/names.js +133 -0
  95. package/dist/mcp/phi-guard.js +105 -0
  96. package/dist/mcp/policy.js +34 -0
  97. package/dist/mcp/server.js +301 -0
  98. package/dist/mcp/tools.js +761 -0
  99. package/dist/mcp/ui/capability.js +26 -0
  100. package/dist/mcp/ui/login-app-protocol.js +118 -0
  101. package/dist/mcp/ui/login-app-styles.js +142 -0
  102. package/dist/mcp/ui/login-app-view.js +361 -0
  103. package/dist/mcp/ui/login-app.js +72 -0
  104. package/dist/mcp/ui/login-structured-content.js +1 -0
  105. package/dist/mcp/ui/resource.js +35 -0
  106. package/dist/mcp/ui/tools-with-ui.js +40 -0
  107. package/dist/search/bigram.js +86 -0
  108. package/dist/search/capture.js +140 -0
  109. package/dist/search/collector.js +176 -0
  110. package/dist/search/default-embedder.js +101 -0
  111. package/dist/search/embedder.js +13 -0
  112. package/dist/search/scope.js +142 -0
  113. package/dist/search/sqlite.js +141 -0
  114. package/dist/search/store.js +469 -0
  115. package/dist/util/data-dir.js +55 -0
  116. package/dist/util/log.js +86 -0
  117. package/dist/version.js +7 -0
  118. package/package.json +63 -0
  119. package/run.mjs +71 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * LINE E2EE media resolution for the `get_message_media` (and legacy
3
+ * `get_message_image`) MCP tools.
4
+ *
5
+ * Mirrors the descriptor-parsing logic the original (excluded) media-asset
6
+ * pipeline used, adapted for the MCP surface: given a chat + message id,
7
+ * locate the message's OBS object metadata and E2EE key material, download
8
+ * the encrypted bytes, and decrypt them. The obs-download + AES-256-CTR
9
+ * decrypt path is identical across LINE content types (image/video/audio/
10
+ * file) — only the resolved MIME type and MCP content shape differ.
11
+ *
12
+ * Never fabricates a fallback — if key material or OBS metadata is
13
+ * missing, or the message's content type is not a downloadable media type,
14
+ * callers get an honest error naming the reason.
15
+ */
16
+ import { downloadLineObsMediaObject } from '../line/client/obs-media-client.js';
17
+ import { CONTENT_TYPE } from '../line/core/constants.js';
18
+ import { decryptLineMediaBytes } from '../line/core/media-decrypt.js';
19
+ /** LINE content types this server can download and decrypt via the OBS path. */
20
+ const DOWNLOADABLE_CONTENT_TYPES = new Set([
21
+ CONTENT_TYPE.IMAGE,
22
+ CONTENT_TYPE.VIDEO,
23
+ CONTENT_TYPE.AUDIO,
24
+ CONTENT_TYPE.FILE,
25
+ ]);
26
+ /** Short media-kind label per LINE content type, for MCP content shaping. */
27
+ const MEDIA_TYPE_BY_CONTENT_TYPE = {
28
+ [CONTENT_TYPE.IMAGE]: 'image',
29
+ [CONTENT_TYPE.VIDEO]: 'video',
30
+ [CONTENT_TYPE.AUDIO]: 'audio',
31
+ [CONTENT_TYPE.FILE]: 'file',
32
+ };
33
+ /** Fallback MIME type per content type when no file extension is known. */
34
+ const DEFAULT_MIME_BY_CONTENT_TYPE = {
35
+ [CONTENT_TYPE.IMAGE]: 'image/jpeg',
36
+ [CONTENT_TYPE.VIDEO]: 'video/mp4',
37
+ [CONTENT_TYPE.AUDIO]: 'audio/mp4',
38
+ [CONTENT_TYPE.FILE]: 'application/octet-stream',
39
+ };
40
+ /** MIME type per known file extension, across all downloadable media kinds. */
41
+ const MIME_BY_EXTENSION = {
42
+ '3gp': 'video/3gpp',
43
+ aac: 'audio/aac',
44
+ gif: 'image/gif',
45
+ jpeg: 'image/jpeg',
46
+ jpg: 'image/jpeg',
47
+ m4a: 'audio/mp4',
48
+ m4v: 'video/x-m4v',
49
+ mov: 'video/quicktime',
50
+ mp3: 'audio/mpeg',
51
+ mp4: 'video/mp4',
52
+ ogg: 'audio/ogg',
53
+ pdf: 'application/pdf',
54
+ png: 'image/png',
55
+ txt: 'text/plain',
56
+ wav: 'audio/wav',
57
+ webp: 'image/webp',
58
+ zip: 'application/zip',
59
+ };
60
+ /**
61
+ * Resolve the short media-kind label for a LINE content type.
62
+ *
63
+ * @param contentType - LINE contentType numeric value.
64
+ * @returns 'image' | 'video' | 'audio' | 'file', or null when not downloadable media.
65
+ */
66
+ export function resolveLineMediaType(contentType) {
67
+ return MEDIA_TYPE_BY_CONTENT_TYPE[contentType] || null;
68
+ }
69
+ /**
70
+ * Parse JSON safely with a fallback.
71
+ *
72
+ * @param value - Raw JSON value.
73
+ * @param fallback - Fallback value.
74
+ * @returns Parsed JSON or fallback.
75
+ */
76
+ function parseJson(value, fallback) {
77
+ if (typeof value !== 'string' || !value) {
78
+ return fallback;
79
+ }
80
+ try {
81
+ return JSON.parse(value);
82
+ }
83
+ catch {
84
+ return fallback;
85
+ }
86
+ }
87
+ /**
88
+ * Resolve a MIME type from LINE media metadata.
89
+ *
90
+ * @param extension - File extension from MEDIA_CONTENT_INFO.
91
+ * @param contentType - LINE content type.
92
+ * @returns MIME type, or null when unknown.
93
+ */
94
+ function resolveMimeType(extension, contentType) {
95
+ const normalized = extension?.toLowerCase() || '';
96
+ if (MIME_BY_EXTENSION[normalized]) {
97
+ return MIME_BY_EXTENSION[normalized];
98
+ }
99
+ return DEFAULT_MIME_BY_CONTENT_TYPE[contentType] || null;
100
+ }
101
+ /**
102
+ * Build a LINE media descriptor from a decrypted message.
103
+ *
104
+ * Image messages carry the E2EE `keyMaterial` inside their decrypted
105
+ * `text` field as JSON, and the OBS object id/service-namespace inside
106
+ * `contentMetadata.OID` / `contentMetadata.SID`.
107
+ *
108
+ * @param message - Decrypted LINE message (from getRecentMessages).
109
+ * @returns Media descriptor, or null when the message has no OBS object.
110
+ */
111
+ export function resolveLineMediaDescriptor(message) {
112
+ const contentMetadata = message?.contentMetadata || {};
113
+ if (!contentMetadata.OID || !contentMetadata.SID) {
114
+ return null;
115
+ }
116
+ const dataPayload = parseJson(message?.text, {});
117
+ const mediaInfo = parseJson(contentMetadata.MEDIA_CONTENT_INFO, {});
118
+ return {
119
+ fileName: dataPayload.fileName || null,
120
+ keyMaterial: dataPayload.keyMaterial || null,
121
+ mimeType: resolveMimeType(mediaInfo.extension || null, Number(message?.contentType || 0)),
122
+ oid: String(contentMetadata.OID),
123
+ sid: String(contentMetadata.SID),
124
+ };
125
+ }
126
+ /** Base class for honest "could not produce media bytes" errors — never caught to fabricate a fallback. */
127
+ export class LineMediaAccessError extends Error {
128
+ }
129
+ /** Thrown when the message body is missing the OBS/E2EE material required to decrypt its media. */
130
+ export class MissingDecryptMaterialError extends LineMediaAccessError {
131
+ code = 'missing_decrypt_material';
132
+ constructor(reason) {
133
+ super(`missing_decrypt_material: ${reason}`);
134
+ }
135
+ }
136
+ /** Thrown when the message's content type is not a downloadable media type (e.g. plain text, sticker ref). */
137
+ export class UnsupportedMediaTypeError extends LineMediaAccessError {
138
+ code = 'unsupported_media_type';
139
+ constructor(contentType, messageId) {
140
+ const label = resolveLineMediaType(contentType) ?? `contentType=${contentType}`;
141
+ super(`unsupported_media_type: message ${messageId} (${label}) is not downloadable media`);
142
+ }
143
+ }
144
+ /**
145
+ * Fetch and decrypt one LINE media attachment (image, video, audio, or file).
146
+ *
147
+ * The obs-media-client download and AES-256-CTR decrypt are content-type
148
+ * agnostic — only descriptor resolution (MIME/filename) and the downstream
149
+ * MCP content shape differ per media kind.
150
+ *
151
+ * @param service - Active LineProtocolService (must already have a resumed session).
152
+ * @param chatId - LINE chat MID the message belongs to.
153
+ * @param messageId - LINE message id.
154
+ * @param preview - Whether to fetch the smaller preview object instead of the original.
155
+ * @returns Decrypted media bytes, resolved MIME type, filename, and content type.
156
+ */
157
+ export async function fetchLineMessageMedia(service, chatId, messageId, preview) {
158
+ const messages = await service.getRecentMessages(chatId, 100);
159
+ const message = messages.find((candidate) => candidate.id === messageId);
160
+ if (!message) {
161
+ throw new MissingDecryptMaterialError(`message ${messageId} not found in recent history of ${chatId}`);
162
+ }
163
+ const contentType = Number(message.contentType);
164
+ if (!DOWNLOADABLE_CONTENT_TYPES.has(contentType)) {
165
+ throw new UnsupportedMediaTypeError(contentType, messageId);
166
+ }
167
+ const descriptor = resolveLineMediaDescriptor(message);
168
+ if (!descriptor) {
169
+ throw new MissingDecryptMaterialError(`message ${messageId} is missing OBS object metadata (OID/SID)`);
170
+ }
171
+ const encryptedBytes = await downloadLineObsMediaObject(service.client, {
172
+ messageId,
173
+ oid: descriptor.oid,
174
+ preview,
175
+ sid: descriptor.sid,
176
+ });
177
+ if (encryptedBytes.length === 0) {
178
+ throw new Error('LINE media download returned empty bytes');
179
+ }
180
+ const fallbackMimeType = descriptor.mimeType ||
181
+ DEFAULT_MIME_BY_CONTENT_TYPE[contentType] ||
182
+ 'application/octet-stream';
183
+ if (!descriptor.keyMaterial) {
184
+ // Not all media messages are E2EE-wrapped (e.g. some group content is
185
+ // plaintext) — but when contentMetadata carries no keyMaterial we
186
+ // cannot tell the difference between "unencrypted" and "decrypt
187
+ // context missing", so we surface the honest bytes as-is rather than
188
+ // guessing. Callers can inspect mimeType/bytes to decide.
189
+ return {
190
+ bytes: encryptedBytes,
191
+ contentType,
192
+ fileName: descriptor.fileName,
193
+ mimeType: fallbackMimeType,
194
+ };
195
+ }
196
+ // Which MAC construction applies is decided by the OBS object, not by
197
+ // contentType alone: a video's main body is MACed over its per-128KB chunk
198
+ // hashes, but that same video's `__ud-preview` poster is a whole-file image
199
+ // and is MACed like one. Confirmed against real media of each shape.
200
+ const mediaBytes = await decryptLineMediaBytes(encryptedBytes, descriptor.keyMaterial, { chunkHashMac: contentType === CONTENT_TYPE.VIDEO && !preview });
201
+ if (mediaBytes.length === 0) {
202
+ throw new Error('LINE media decrypt returned empty bytes');
203
+ }
204
+ return {
205
+ bytes: mediaBytes,
206
+ contentType,
207
+ fileName: descriptor.fileName,
208
+ mimeType: fallbackMimeType,
209
+ };
210
+ }
211
+ /**
212
+ * Fetch and decrypt one LINE image attachment. Thin `get_message_image`
213
+ * back-compat wrapper over {@link fetchLineMessageMedia} that rejects
214
+ * non-image content types with an honest error.
215
+ *
216
+ * @param service - Active LineProtocolService (must already have a resumed session).
217
+ * @param chatId - LINE chat MID the message belongs to.
218
+ * @param messageId - LINE message id.
219
+ * @param preview - Whether to fetch the smaller preview object instead of the original.
220
+ * @returns Decrypted image bytes and resolved MIME type.
221
+ */
222
+ export async function fetchLineMessageImage(service, chatId, messageId, preview) {
223
+ const result = await fetchLineMessageMedia(service, chatId, messageId, preview);
224
+ if (result.contentType !== CONTENT_TYPE.IMAGE) {
225
+ throw new UnsupportedMediaTypeError(result.contentType, messageId);
226
+ }
227
+ return { bytes: result.bytes, mimeType: result.mimeType };
228
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Yomi MCP name resolution — turns opaque LINE MIDs into display names.
3
+ *
4
+ * LINE MIDs are prefixed by conversation kind (u = 1:1 user, c = group,
5
+ * r = room). For a 1:1 chat the chat id IS the other party's contact MID,
6
+ * so it resolves through the same user-contact path as message senders.
7
+ * Group/room titles resolve through TalkService getChats' chatName field.
8
+ *
9
+ * Every resolver batches unique MIDs into a single TalkService call and
10
+ * reads/writes through LineProtocolService's existing nameCache /
11
+ * chatCache, so a name is fetched over the network at most once per
12
+ * process lifetime. Unresolvable MIDs are never given a fabricated
13
+ * placeholder — callers get `null` and may fall back to the raw MID.
14
+ */
15
+ /**
16
+ * Resolve display names for LINE user (contact) MIDs.
17
+ *
18
+ * @param service - Resumed LineProtocolService.
19
+ * @param mids - User MIDs to resolve (duplicates and falsy values ignored).
20
+ * @returns Map of MID to resolved display name (unresolved MIDs are omitted).
21
+ */
22
+ export async function resolveUserNames(service, mids) {
23
+ const unique = Array.from(new Set(mids.filter(Boolean)));
24
+ const resolved = new Map();
25
+ const missing = [];
26
+ for (const mid of unique) {
27
+ const cached = service.nameCache.get(mid);
28
+ if (cached) {
29
+ resolved.set(mid, cached);
30
+ }
31
+ else {
32
+ missing.push(mid);
33
+ }
34
+ }
35
+ if (missing.length > 0) {
36
+ const contacts = await service.client.getContacts(missing);
37
+ for (const contact of contacts) {
38
+ if (contact?.mid && contact.displayName) {
39
+ service.nameCache.set(contact.mid, contact.displayName);
40
+ resolved.set(contact.mid, contact.displayName);
41
+ }
42
+ }
43
+ }
44
+ return resolved;
45
+ }
46
+ /**
47
+ * Resolve display names for message senders, short-circuiting the
48
+ * authenticated user's own MID from the already-known profile instead of
49
+ * spending a network call on it.
50
+ *
51
+ * @param service - Resumed LineProtocolService.
52
+ * @param mids - Sender MIDs to resolve (duplicates and falsy values ignored).
53
+ * @returns Map of MID to resolved display name (unresolved MIDs are omitted).
54
+ */
55
+ export async function resolveSenderNames(service, mids) {
56
+ const unique = Array.from(new Set(mids.filter(Boolean)));
57
+ const selfMid = service.profile?.mid;
58
+ const selfName = service.profile?.displayName;
59
+ const remaining = [];
60
+ const resolved = new Map();
61
+ for (const mid of unique) {
62
+ if (mid === selfMid && selfName) {
63
+ resolved.set(mid, selfName);
64
+ service.nameCache.set(mid, selfName);
65
+ }
66
+ else {
67
+ remaining.push(mid);
68
+ }
69
+ }
70
+ const fetched = await resolveUserNames(service, remaining);
71
+ for (const [mid, name] of fetched) {
72
+ resolved.set(mid, name);
73
+ }
74
+ return resolved;
75
+ }
76
+ /**
77
+ * Resolve titles for group/room chat MIDs via getChats' chatName field.
78
+ *
79
+ * @param service - Resumed LineProtocolService.
80
+ * @param chatIds - Group/room chat MIDs to resolve.
81
+ * @returns Map of chat MID to resolved chat title (unresolved MIDs are omitted).
82
+ */
83
+ export async function resolveGroupNames(service, chatIds) {
84
+ const unique = Array.from(new Set(chatIds.filter(Boolean)));
85
+ const resolved = new Map();
86
+ const missing = [];
87
+ for (const id of unique) {
88
+ const cached = service.chatCache.get(id);
89
+ if (cached?.chatName) {
90
+ resolved.set(id, cached.chatName);
91
+ }
92
+ else if (cached === undefined) {
93
+ missing.push(id);
94
+ }
95
+ }
96
+ if (missing.length > 0) {
97
+ const chats = await service.client.getChats(missing, false);
98
+ for (const chat of chats) {
99
+ if (chat?.chatMid) {
100
+ service.chatCache.set(chat.chatMid, chat);
101
+ if (chat.chatName) {
102
+ resolved.set(chat.chatMid, chat.chatName);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return resolved;
108
+ }
109
+ /**
110
+ * Resolve a human-readable name for each conversation id, dispatching by
111
+ * LINE's MID prefix convention (u = 1:1 user, c = group, r = room).
112
+ *
113
+ * @param service - Resumed LineProtocolService.
114
+ * @param chatIds - Conversation MIDs, as returned by getMessageBoxes.
115
+ * @returns Map of chat MID to resolved name, or null when unresolvable.
116
+ */
117
+ export async function resolveConversationNames(service, chatIds) {
118
+ const userIds = chatIds.filter((id) => id?.startsWith('u'));
119
+ const groupIds = chatIds.filter((id) => id?.startsWith('c') || id?.startsWith('r'));
120
+ const [userNames, groupNames] = await Promise.all([
121
+ userIds.length > 0
122
+ ? resolveUserNames(service, userIds)
123
+ : Promise.resolve(new Map()),
124
+ groupIds.length > 0
125
+ ? resolveGroupNames(service, groupIds)
126
+ : Promise.resolve(new Map()),
127
+ ]);
128
+ const result = new Map();
129
+ for (const id of chatIds) {
130
+ result.set(id, userNames.get(id) ?? groupNames.get(id) ?? null);
131
+ }
132
+ return result;
133
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Yomi PHI/PII output guard — masks high-sensitivity values in message text
3
+ * at the MCP tool-return boundary, right before the text leaves the local
4
+ * machine for the cloud model.
5
+ *
6
+ * Deliberately narrow: only high-confidence, high-sensitivity types (national
7
+ * ID, Luhn-valid payment card, email, mobile phone) are masked, NOT every
8
+ * number — masking too much would strip context the assistant legitimately
9
+ * needs (e.g. "the Taitung meeting is at 3pm"). Detection is regex-only for
10
+ * now; a local semantic (MLX) layer and per-conversation exemptions are
11
+ * deliberately deferred, not silently dropped.
12
+ *
13
+ * Honesty: masking is never silent. Callers surface `phiNote(acc)` as an
14
+ * extra response content block reporting how many values were masked and of
15
+ * what types, so the redaction is visible rather than a quiet rewrite.
16
+ */
17
+ /** Middle-redaction marker used in masked values (Q12〇〇9 style). */
18
+ const MASK_MIDDLE = '〇〇';
19
+ /**
20
+ * Luhn check for candidate payment-card digit strings.
21
+ * @param digits - Digits only (no separators).
22
+ * @returns True when the digits satisfy the Luhn checksum.
23
+ */
24
+ function passesLuhn(digits) {
25
+ let sum = 0;
26
+ let double = false;
27
+ for (let i = digits.length - 1; i >= 0; i--) {
28
+ let d = digits.charCodeAt(i) - 48;
29
+ if (double) {
30
+ d *= 2;
31
+ if (d > 9)
32
+ d -= 9;
33
+ }
34
+ sum += d;
35
+ double = !double;
36
+ }
37
+ return digits.length >= 13 && digits.length <= 19 && sum % 10 === 0;
38
+ }
39
+ /**
40
+ * Mask one matched value, preserving a little edge context (Q12〇〇9): the
41
+ * first few characters and, for longer values, the last one.
42
+ * @param value - The raw matched substring.
43
+ * @returns The masked substring.
44
+ */
45
+ function maskValue(value) {
46
+ const trimmed = value.trim();
47
+ const head = trimmed.slice(0, Math.min(3, trimmed.length));
48
+ const tail = trimmed.length > 4 ? trimmed.slice(-1) : '';
49
+ return `${head}${MASK_MIDDLE}${tail}`;
50
+ }
51
+ // Order matters: email and phone are matched before the broad card pattern so
52
+ // their digits are not re-consumed. Each RegExp MUST have the global flag.
53
+ const PATTERNS = [
54
+ { type: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
55
+ { type: 'id', re: /\b[A-Z][12]\d{8}\b/g },
56
+ { type: 'phone', re: /\+886[\s-]?\d(?:[\s-]?\d){7,}/g },
57
+ { type: 'phone', re: /\b09\d{8}\b/g },
58
+ { type: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, validateDigits: passesLuhn },
59
+ ];
60
+ /**
61
+ * Create a fresh accumulator for one tool response.
62
+ * @returns A zeroed PhiAccumulator.
63
+ */
64
+ export function createPhiAccumulator() {
65
+ return { count: 0, types: new Set() };
66
+ }
67
+ /**
68
+ * Mask all high-sensitivity spans in one string, tallying into `acc`. Returns
69
+ * the input unchanged (and untallied) for null/empty/non-string input.
70
+ * @param acc - Accumulator to record masked count and types into.
71
+ * @param value - Text to mask.
72
+ * @returns The masked text (or the original value when nothing to mask).
73
+ */
74
+ export function maskInto(acc, value) {
75
+ if (typeof value !== 'string' || value.length === 0) {
76
+ return value;
77
+ }
78
+ let text = value;
79
+ for (const { type, re, validateDigits } of PATTERNS) {
80
+ text = text.replace(re, (match) => {
81
+ if (validateDigits && !validateDigits(match.replace(/\D/g, ''))) {
82
+ return match;
83
+ }
84
+ acc.count += 1;
85
+ acc.types.add(type);
86
+ return maskValue(match);
87
+ });
88
+ }
89
+ return text;
90
+ }
91
+ /**
92
+ * Build the honesty note for a tool response, or null when nothing was masked.
93
+ * @param acc - Accumulator populated by maskInto.
94
+ * @returns An MCP text content block reporting the redaction, or null.
95
+ */
96
+ export function phiNote(acc) {
97
+ if (acc.count === 0) {
98
+ return null;
99
+ }
100
+ const types = [...acc.types].sort().join(', ');
101
+ return {
102
+ type: 'text',
103
+ text: `[phi-guard] Masked ${acc.count} high-sensitivity value(s) (${types}) before returning them. These are redacted for privacy; if the raw value is genuinely needed, ask the user explicitly.`,
104
+ };
105
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Yomi privacy-policy loader — the single source of truth for the
3
+ * data-capture disclosure is the repo-root `PRIVACY.md` file, NOT any
4
+ * string literal in code. Both the MCP server `instructions` (surfaced to
5
+ * the client/model on connect) and the `get_scope_policy` tool read the
6
+ * policy prose from here, so the disclosure never drifts between the two.
7
+ *
8
+ * The file is read synchronously (so it is available at Server construction
9
+ * time) and memoized for the process lifetime.
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ // Resolve PRIVACY.md relative to this module (src/mcp/ -> <repo>/PRIVACY.md),
15
+ // NOT process.cwd(): an MCP client may spawn the server from any working
16
+ // directory, so a cwd-relative path would miss the file. Same REPO_ROOT
17
+ // derivation pattern as src/search/store.ts.
18
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
19
+ const PRIVACY_PATH = join(REPO_ROOT, 'PRIVACY.md');
20
+ let cachedPolicy = null;
21
+ /**
22
+ * Read and memoize the canonical privacy-policy text from the repo-root
23
+ * PRIVACY.md. The prose lives only in that file — this is how both the
24
+ * server `instructions` and `get_scope_policy` stay in lockstep with a
25
+ * single edit.
26
+ *
27
+ * @returns The full PRIVACY.md contents.
28
+ */
29
+ export function getPrivacyPolicyText() {
30
+ if (cachedPolicy === null) {
31
+ cachedPolicy = readFileSync(PRIVACY_PATH, 'utf8');
32
+ }
33
+ return cachedPolicy;
34
+ }