@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,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
+ }