@rikaidev/yomi 0.2.6 → 0.3.1

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 (54) hide show
  1. package/README.md +38 -11
  2. package/dist/auth/credential-store.js +0 -34
  3. package/dist/insight/graph.js +90 -0
  4. package/dist/insight/index.js +61 -0
  5. package/dist/insight/pending.js +92 -0
  6. package/dist/insight/relationships.js +125 -0
  7. package/dist/insight/rhythm.js +50 -0
  8. package/dist/line/auth/protocol/index.js +0 -2
  9. package/dist/line/auth/pwless/index.js +1 -1
  10. package/dist/line/client/index.js +1 -1
  11. package/dist/line/client/talk-service/chat-query.js +3 -3
  12. package/dist/line/client/talk-service/contact-query.js +1 -1
  13. package/dist/line/client/talk-service/requests.js +1 -1
  14. package/dist/line/core/e2ee/crypto/crypto-primitives.js +2 -15
  15. package/dist/line/core/e2ee/index.js +0 -1
  16. package/dist/line/core/e2ee/keys/decrypt.js +0 -1
  17. package/dist/line/core/e2ee/keys/group-key.js +0 -1
  18. package/dist/line/core/e2ee/keys/key-payload.js +1 -1
  19. package/dist/line/core/e2ee/keys/message-decrypt.js +1 -1
  20. package/dist/line/core/e2ee/keys/peer-key.js +1 -1
  21. package/dist/line/core/media-decrypt.js +1 -1
  22. package/dist/line/core/mention.js +1 -1
  23. package/dist/line/core/message-query-service.js +2 -2
  24. package/dist/line/core/session-state/persist.js +1 -1
  25. package/dist/line/core/thrift/binary/type-codec-dsl.js +1 -1
  26. package/dist/line/core/thrift/compact/type-codec-dsl.js +1 -43
  27. package/dist/line/core/thrift/fields/builders.js +1 -11
  28. package/dist/line/core/thrift/index.js +1 -6
  29. package/dist/line/core/thrift/primitives/varint.js +1 -1
  30. package/dist/line/core/thrift/types.js +0 -6
  31. package/dist/mcp/handlers/contacts.js +4 -16
  32. package/dist/mcp/handlers/conversations.js +5 -11
  33. package/dist/mcp/handlers/index.js +1 -0
  34. package/dist/mcp/handlers/insight.js +129 -0
  35. package/dist/mcp/handlers/login.js +3 -3
  36. package/dist/mcp/handlers/scope.js +5 -11
  37. package/dist/mcp/handlers/search.js +32 -10
  38. package/dist/mcp/handlers/send.js +16 -16
  39. package/dist/mcp/handlers/shared.js +21 -6
  40. package/dist/mcp/handlers/stickers.js +3 -12
  41. package/dist/mcp/media.js +2 -2
  42. package/dist/mcp/names.js +1 -1
  43. package/dist/mcp/server.js +11 -4
  44. package/dist/mcp/tools.js +17 -0
  45. package/dist/search/collector.js +1 -1
  46. package/dist/search/store.js +58 -14
  47. package/dist/util/data-dir.js +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +5 -3
  50. package/dist/line/core/e2ee/types.js +0 -4
  51. package/dist/line/core/store.js +0 -8
  52. package/dist/line/core/thrift/binary/binary-reader.js +0 -151
  53. package/dist/line/core/thrift/compact/compact-reader.js +0 -180
  54. package/dist/line/core/thrift/compact/compact-writer.js +0 -161
@@ -2,7 +2,6 @@ import { tryDecryptInner } from './message-decrypt.js';
2
2
  const PEER_KEY_MISMATCH_PATTERN = /Peer E2EE key mismatch/i;
3
3
  const MISSING_GROUP_SENDER_KEY_PATTERN = /Missing group sender key/i;
4
4
  const GCM_AUTH_FAILURE_PATTERN = /unable to authenticate data|Unsupported state or unable to authenticate/i;
5
- export { decryptV1, decryptV2, decryptV2WithCandidates, extractTextPayload, generateAAD, getIntBytes, toChunkKeyId, } from './message-crypto.js';
6
5
  /**
7
6
  * Build one structured decrypt-failure log payload from a raw LINE message.
8
7
  *
@@ -257,4 +257,3 @@ export async function getGroupKey(ctx, chatMid, receiverKeyId, senderMid, sender
257
257
  ctx.groupKeyFetches.delete(fetchKey);
258
258
  }
259
259
  }
260
- export { normalizeGroupPublicKeys } from './key-payload.js';
@@ -52,7 +52,7 @@ function readNegotiatedValue(publicKey, raw, fieldId) {
52
52
  * @param keyData - Raw key payload from thrift or object responses.
53
53
  * @returns Buffer instance or null when unsupported.
54
54
  */
55
- export function toKeyBuffer(keyData) {
55
+ function toKeyBuffer(keyData) {
56
56
  if (Buffer.isBuffer(keyData)) {
57
57
  return keyData;
58
58
  }
@@ -43,7 +43,7 @@ function hasDecryptMaterial(resolved) {
43
43
  * @param msg - Raw message object
44
44
  * @returns Parsed E2EE chunk data, or null if the message is not E2EE
45
45
  */
46
- export function parseE2EEChunks(ctx, msg) {
46
+ function parseE2EEChunks(ctx, msg) {
47
47
  const chunks = msg.chunks;
48
48
  if (!chunks || !Array.isArray(chunks) || chunks.length < 5) {
49
49
  return null;
@@ -59,7 +59,7 @@ export async function getPeerPublicKey(ctx, mid, keyId) {
59
59
  * @param keyId - Public key id
60
60
  * @param data - 32-byte public key
61
61
  */
62
- export async function persistPeerPublicKey(ctx, mid, keyId, data) {
62
+ async function persistPeerPublicKey(ctx, mid, keyId, data) {
63
63
  const store = ctx.getStore();
64
64
  const record = JSON.stringify({
65
65
  mid,
@@ -24,7 +24,7 @@ async function deriveLineMediaKeyMaterial(keyMaterial) {
24
24
  };
25
25
  }
26
26
  /** Thrown when a media object's MAC does not match its ciphertext. */
27
- export class MediaMacVerificationError extends Error {
27
+ class MediaMacVerificationError extends Error {
28
28
  constructor(message) {
29
29
  super(message);
30
30
  this.name = 'MediaMacVerificationError';
@@ -31,7 +31,7 @@
31
31
  * @param mentions - Proposed mentions.
32
32
  * @throws Error describing the first invalid mention or overlap found.
33
33
  */
34
- export function validateMentions(text, mentions) {
34
+ function validateMentions(text, mentions) {
35
35
  const sorted = [...mentions].sort((a, b) => a.start - b.start);
36
36
  for (const mention of sorted) {
37
37
  const { mid, start, end } = mention;
@@ -27,7 +27,7 @@ function inferLineToType(chatId) {
27
27
  * @param chatId - Requested LINE chat MID.
28
28
  * @returns Message with stable chat context.
29
29
  */
30
- export function normalizeLineMessageContext(message, chatId = null) {
30
+ function normalizeLineMessageContext(message, chatId = null) {
31
31
  const resolvedChatId = message?.chatMid || chatId || null;
32
32
  const inferredToType = inferLineToType(resolvedChatId);
33
33
  return {
@@ -97,7 +97,7 @@ export async function decryptLineMessage(e2eeManager, message, chatId = null) {
97
97
  * @param chatId - Optional chat id to attach to each message.
98
98
  * @returns Messages with decrypted text flags when available.
99
99
  */
100
- export async function decryptLineMessages(e2eeManager, messages, chatId = null) {
100
+ async function decryptLineMessages(e2eeManager, messages, chatId = null) {
101
101
  return Promise.all(messages.map((message) => decryptLineMessage(e2eeManager, message, chatId)));
102
102
  }
103
103
  /**
@@ -11,7 +11,7 @@ const persistLog = createCliLogger('SESSION-PERSIST');
11
11
  * @param value - Raw credential value.
12
12
  * @returns Persistable string value.
13
13
  */
14
- export function toPersistedKeyValue(value) {
14
+ function toPersistedKeyValue(value) {
15
15
  if (!value) {
16
16
  return null;
17
17
  }
@@ -1,5 +1,5 @@
1
1
  import { THRIFT_TYPE } from '../types.js';
2
- export const BINARY_TYPE_CODEC_DEFINITIONS = [
2
+ const BINARY_TYPE_CODEC_DEFINITIONS = [
3
3
  {
4
4
  type: THRIFT_TYPE.BOOL,
5
5
  readHandler: 'readBoolValue',
@@ -1,37 +1,4 @@
1
- import { THRIFT_TYPE } from '../types.js';
2
- export const COMPACT_TYPE_CODEC_DEFINITIONS = [
3
- {
4
- type: THRIFT_TYPE.BOOL,
5
- readHandler: 'readBoolValue',
6
- writeHandler: 'writeBoolValue',
7
- },
8
- {
9
- type: THRIFT_TYPE.BYTE,
10
- readHandler: 'readByteValue',
11
- writeHandler: 'writeByteValue',
12
- },
13
- {
14
- type: THRIFT_TYPE.I16,
15
- readHandler: 'readI16Value',
16
- writeHandler: 'writeI16Value',
17
- },
18
- {
19
- type: THRIFT_TYPE.I32,
20
- readHandler: 'readI32Value',
21
- writeHandler: 'writeI32Value',
22
- },
23
- {
24
- type: THRIFT_TYPE.I64,
25
- readHandler: 'readI64Value',
26
- writeHandler: 'writeI64Value',
27
- },
28
- {
29
- type: THRIFT_TYPE.STRING,
30
- readHandler: 'readStringValue',
31
- writeHandler: 'writeStringValue',
32
- },
33
- ];
34
- export const COMPACT_STRUCT_TYPE_DEFINITIONS = [
1
+ const COMPACT_STRUCT_TYPE_DEFINITIONS = [
35
2
  { type: 0, handler: 'readNullValue' },
36
3
  { type: 1, handler: 'readTrueValue' },
37
4
  { type: 2, handler: 'readFalseValue' },
@@ -46,15 +13,6 @@ export const COMPACT_STRUCT_TYPE_DEFINITIONS = [
46
13
  { type: 11, handler: 'readMapValue' },
47
14
  { type: 12, handler: 'readStructValue' },
48
15
  ];
49
- /**
50
- * Resolve one compact thrift type into its configured codec definition.
51
- *
52
- * @param type - Thrift wire type.
53
- * @returns Codec definition, if defined.
54
- */
55
- export function getCompactTypeCodecDefinition(type) {
56
- return (COMPACT_TYPE_CODEC_DEFINITIONS.find((candidate) => candidate.type === type) ?? null);
57
- }
58
16
  /**
59
17
  * Resolve one compact struct field type into its configured reader handler.
60
18
  *
@@ -9,7 +9,7 @@
9
9
  * @param value - Field value.
10
10
  * @returns Thrift field tuple.
11
11
  */
12
- export function field(type, fieldId, value) {
12
+ function field(type, fieldId, value) {
13
13
  return [type, fieldId, value];
14
14
  }
15
15
  /**
@@ -32,16 +32,6 @@ export function boolField(fieldId, value) {
32
32
  export function byteField(fieldId, value) {
33
33
  return field(3, fieldId, value);
34
34
  }
35
- /**
36
- * Build an I16 field tuple.
37
- *
38
- * @param fieldId - Thrift field ID.
39
- * @param value - I16 value.
40
- * @returns Thrift field tuple.
41
- */
42
- export function i16Field(fieldId, value) {
43
- return field(6, fieldId, value);
44
- }
45
35
  /**
46
36
  * Build an I32 field tuple.
47
37
  *
@@ -1,12 +1,7 @@
1
1
  /**
2
2
  * Thrift - exports all modules
3
3
  */
4
- export { TBinaryReader } from './binary/binary-reader.js';
5
- export { TBinaryWriter } from './binary/binary-writer.js';
6
- export { TCompactReader } from './compact/compact-reader.js';
7
- export { TCompactWriter } from './compact/compact-writer.js';
8
- export { boolField, byteField, field, i16Field, i32Field, i64Field, listField, mapField, setField, stringField, structField, } from './fields/builders.js';
4
+ export { boolField, byteField, i32Field, i64Field, listField, mapField, setField, stringField, structField, } from './fields/builders.js';
9
5
  export { decodeResponseMessage } from './message/decoder.js';
10
6
  export { encodeCallMessage } from './message/encoder.js';
11
7
  export { readStructFields } from './struct/reader.js';
12
- export { MSG_TYPE, THRIFT_TYPE } from './types.js';
@@ -36,7 +36,7 @@ export function zigzag(value) {
36
36
  * @param value - Bigint to encode.
37
37
  * @returns Encoded varint buffer.
38
38
  */
39
- export function varintBig(value) {
39
+ function varintBig(value) {
40
40
  const bytes = [];
41
41
  let remaining = value;
42
42
  if (remaining < 0n) {
@@ -15,9 +15,3 @@ export const THRIFT_TYPE = {
15
15
  SET: 14,
16
16
  LIST: 15,
17
17
  };
18
- export const MSG_TYPE = {
19
- CALL: 1,
20
- REPLY: 2,
21
- EXCEPTION: 3,
22
- ONEWAY: 4,
23
- };
@@ -1,6 +1,6 @@
1
1
  import { createCliLogger } from '../../util/log.js';
2
2
  import { resolveUserNames } from '../names.js';
3
- import { jsonResult, toolError } from './shared.js';
3
+ import { jsonResult, toolError, toonResult } from './shared.js';
4
4
  const log = createCliLogger('Yomi');
5
5
  /**
6
6
  * Fetch and normalize the authenticated user's full friend list in one
@@ -43,11 +43,7 @@ async function fetchAllContacts(service) {
43
43
  */
44
44
  export async function handleListContacts(service) {
45
45
  const contacts = await fetchAllContacts(service);
46
- return {
47
- content: [
48
- { type: 'text', text: JSON.stringify(contacts, null, 2) },
49
- ],
50
- };
46
+ return toonResult(contacts);
51
47
  }
52
48
  /**
53
49
  * Handle `find_contact` — case-insensitive substring match over the
@@ -66,11 +62,7 @@ export async function handleFindContact(service, args) {
66
62
  const needle = args.name.toLowerCase();
67
63
  const contacts = await fetchAllContacts(service);
68
64
  const matches = contacts.filter((contact) => contact.displayName?.toLowerCase().includes(needle));
69
- return {
70
- content: [
71
- { type: 'text', text: JSON.stringify(matches, null, 2) },
72
- ],
73
- };
65
+ return toonResult(matches);
74
66
  }
75
67
  /**
76
68
  * Handle `get_group_members` — the raw member list of one LINE group/room.
@@ -110,11 +102,7 @@ export async function handleGetGroupMembers(service, args) {
110
102
  invited: true,
111
103
  })),
112
104
  ];
113
- return {
114
- content: [
115
- { type: 'text', text: JSON.stringify(summaries, null, 2) },
116
- ],
117
- };
105
+ return toonResult(summaries);
118
106
  }
119
107
  /**
120
108
  * Handle `add_friend` — REALLY adds a person to THIS account's LINE friends now
@@ -4,7 +4,7 @@ import { createCliLogger } from '../../util/log.js';
4
4
  import { resolveLineMediaDescriptor, resolveLineMediaType } from '../media.js';
5
5
  import { resolveConversationNames, resolveSenderNames } from '../names.js';
6
6
  import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
7
- import { toolError } from './shared.js';
7
+ import { jsonResult, toolError, toonText } from './shared.js';
8
8
  const log = createCliLogger('Yomi');
9
9
  /**
10
10
  * Handle `list_conversations` — list LINE conversations (chats, groups, rooms)
@@ -42,7 +42,7 @@ export async function handleListConversations(service, args) {
42
42
  };
43
43
  }));
44
44
  const content = [
45
- { type: 'text', text: JSON.stringify(conversations, null, 2) },
45
+ { type: 'text', text: toonText(conversations) },
46
46
  ];
47
47
  const note = phiNote(acc);
48
48
  if (note)
@@ -113,9 +113,7 @@ export async function handleGetChatMessages(service, args) {
113
113
  ? { e2eeIntegrityVerified: false }
114
114
  : {}),
115
115
  }));
116
- const content = [
117
- { type: 'text', text: JSON.stringify(shaped, null, 2) },
118
- ];
116
+ const content = [{ type: 'text', text: toonText(shaped) }];
119
117
  const note = phiNote(acc);
120
118
  if (note)
121
119
  content.push(note);
@@ -137,9 +135,7 @@ export async function handleMarkRead(service, args) {
137
135
  }
138
136
  const result = await service.markChatRead(args.chatId, args.messageId);
139
137
  log.info('mark_read.done', { chatId: args.chatId, marked: result.marked });
140
- return {
141
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
142
- };
138
+ return jsonResult(result);
143
139
  }
144
140
  /**
145
141
  * Handle `get_unread_digest` — one-shot composite: every conversation with
@@ -183,9 +179,7 @@ export async function handleGetUnreadDigest(service, args) {
183
179
  })),
184
180
  };
185
181
  }));
186
- const content = [
187
- { type: 'text', text: JSON.stringify(digest, null, 2) },
188
- ];
182
+ const content = [{ type: 'text', text: toonText(digest) }];
189
183
  const note = phiNote(acc);
190
184
  if (note)
191
185
  content.push(note);
@@ -5,6 +5,7 @@
5
5
  export * from './contacts.js';
6
6
  export * from './conversations.js';
7
7
  export * from './groups.js';
8
+ export * from './insight.js';
8
9
  export * from './login.js';
9
10
  export * from './media.js';
10
11
  export * from './message-actions.js';
@@ -0,0 +1,129 @@
1
+ import { encode } from '@toon-format/toon';
2
+ import { computeInsights } from '../../insight/index.js';
3
+ import { getDefaultEmbedder } from '../../search/default-embedder.js';
4
+ import { getExcludedChatIds } from '../../search/scope.js';
5
+ import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
6
+ /**
7
+ * get_insight — return structured attention context for the agent to judge.
8
+ *
9
+ * This is a first-stage tool: it does not decide what needs attention, it
10
+ * assembles a compact context network that can be computed without reading
11
+ * illocutionary force. Nodes are cross-conversation connectors and
12
+ * conversations; `open` are directed edges (last speaker → addressee) for
13
+ * threads whose latest message is not yours, each with reply-rhythm, overdue
14
+ * ratio, a boilerplate-shape hint, an address hint, and a message-id pointer —
15
+ * but no thread text. The agent dereferences the pointer (get_chat_messages)
16
+ * only for the edges it chooses to judge, and decides open-request vs
17
+ * closing-ack, a judgement the topic embedding cannot make. See ../../insight
18
+ * for why the split is drawn exactly there.
19
+ *
20
+ * Denylist-excluded chats (../../search/scope) are removed from every section.
21
+ *
22
+ * @param service - Live LINE service (used for the owner's identity).
23
+ * @param args - Optional focus chat and lookback window.
24
+ * @returns MCP tool result: the JSON context package plus any PHI-masking note.
25
+ */
26
+ export async function handleGetInsight(service, args) {
27
+ const pkg = computeInsights({
28
+ focusChatId: args.chatId,
29
+ sinceHours: args.sinceHours,
30
+ selfMid: service.profile?.mid ?? null,
31
+ embedder: getDefaultEmbedder(),
32
+ });
33
+ const excluded = getExcludedChatIds();
34
+ const filtered = {
35
+ window: pkg.window,
36
+ relationships: pkg.relationships.filter((r) => !excluded.has(r.chatId)),
37
+ connectors: pkg.connectors,
38
+ open: pkg.open.filter((o) => !excluded.has(o.chatId)),
39
+ };
40
+ if (filtered.window.messages === 0) {
41
+ return {
42
+ content: [
43
+ {
44
+ type: 'text',
45
+ text: 'The semantic index is empty — run collect_messages (or any search) first, then retry.',
46
+ },
47
+ ],
48
+ };
49
+ }
50
+ const acc = createPhiAccumulator();
51
+ const masked = maskPackage(filtered, acc);
52
+ // TOON encoding (github.com/toon-format/toon) over a lean projection: uniform
53
+ // arrays become a one-line column header plus CSV rows (~half the tokens of
54
+ // JSON), and the projection drops fields the agent can derive or does not need
55
+ // — long MIDs/pointers, and low-signal one-off conversations.
56
+ const guide = 'A context network over your local LINE index, encoded as TOON — objects are `key: value`, uniform arrays are `name[count]{columns}:` then one comma-separated row per item. NOT verdicts. `connectors`: people across ≥2 of your chats (hubs; `isBridge` = removing them splits your contact graph). `relationships`: per-conversation engagement (`msgs` total, `yours` from you, `rhythmH` = your usual reply latency in hours) for conversations that matter. `open`: conversations whose latest message is not yours, most overdue vs that rhythm first (`overdueR`), with the last speaker (`from`) and a `preview`. Read each `preview` to judge — who it addresses (a group message may name someone else, who then owns it), open-request vs closing-acknowledgement, and nickname identities — then fetch the thread via get_chat_messages(chatId) only for the few worth it. `typicality` hints boilerplate shape but also buries polite requests, so never filter on it.';
57
+ const content = [
58
+ {
59
+ type: 'text',
60
+ text: `${guide}\n\n${encode(roundNumbers(project(masked)))}`,
61
+ },
62
+ ];
63
+ const note = phiNote(acc);
64
+ if (note) {
65
+ content.push(note);
66
+ }
67
+ return { content };
68
+ }
69
+ /**
70
+ * Project the package to a lean shape for encoding: drop long MIDs and message
71
+ * pointers, drop values the agent can derive (reciprocity, absolute times), and
72
+ * keep only conversations with signal — an open thread, or real back-and-forth
73
+ * (≥5 messages). One-off two-line chats are noise in a "what needs attention"
74
+ * scaffold.
75
+ */
76
+ function project(pkg) {
77
+ const openChatIds = new Set(pkg.open.map((o) => o.chatId));
78
+ return {
79
+ window: pkg.window,
80
+ connectors: pkg.connectors.map((c) => ({
81
+ name: c.name,
82
+ chats: c.chatCount,
83
+ isBridge: c.isBridge,
84
+ })),
85
+ relationships: pkg.relationships
86
+ .filter((r) => openChatIds.has(r.chatId) || r.totalMessages >= 5)
87
+ .map((r) => ({
88
+ chatId: r.chatId,
89
+ name: r.chatName,
90
+ isDM: r.isDM,
91
+ msgs: r.totalMessages,
92
+ yours: r.yourMessages,
93
+ rhythmH: r.rhythmHours,
94
+ })),
95
+ open: pkg.open.map((o) => ({
96
+ chatId: o.chatId,
97
+ name: o.chatName,
98
+ isDM: o.isDM,
99
+ from: o.fromName,
100
+ waitH: o.waitHours,
101
+ overdueR: o.overdueRatio,
102
+ typicality: o.typicality,
103
+ preview: o.preview,
104
+ })),
105
+ };
106
+ }
107
+ /** Round every number to 2 decimals — full float precision is pure token cost. */
108
+ function roundNumbers(value) {
109
+ if (typeof value === 'number') {
110
+ return (Math.round(value * 100) / 100);
111
+ }
112
+ if (Array.isArray(value)) {
113
+ return value.map(roundNumbers);
114
+ }
115
+ if (value && typeof value === 'object') {
116
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, roundNumbers(v)]));
117
+ }
118
+ return value;
119
+ }
120
+ /** Mask PHI in the free-text previews the package carries. */
121
+ function maskPackage(pkg, acc) {
122
+ return {
123
+ ...pkg,
124
+ open: pkg.open.map((o) => ({
125
+ ...o,
126
+ preview: maskInto(acc, o.preview) ?? o.preview,
127
+ })),
128
+ };
129
+ }
@@ -27,7 +27,7 @@ import { runPwlessLogin } from '../../cli/login.js';
27
27
  import { LINE_PIN_CODE_LIFETIME_MS, PAAK_AUTH_CLIENT_CEILING_MS, PIN_VERIFY_CLIENT_CEILING_MS, } from '../../line/auth/pwless/index.js';
28
28
  import { createCliLogger } from '../../util/log.js';
29
29
  import { supportsMcpApps } from '../ui/capability.js';
30
- import { toolError } from './shared.js';
30
+ import { jsonText, toolError } from './shared.js';
31
31
  const log = createCliLogger('Yomi');
32
32
  /** LINE region codes offered on the login elicitation form. */
33
33
  const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
@@ -176,7 +176,7 @@ async function handleLoginElicitation(server, service, args) {
176
176
  content: [
177
177
  {
178
178
  type: 'text',
179
- text: JSON.stringify({ loggedIn: true, mid, displayName }, null, 2),
179
+ text: jsonText({ loggedIn: true, mid, displayName }),
180
180
  },
181
181
  ],
182
182
  };
@@ -461,7 +461,7 @@ export async function handleLoginComplete() {
461
461
  content: [
462
462
  {
463
463
  type: 'text',
464
- text: JSON.stringify({ loggedIn: true, mid, displayName }, null, 2),
464
+ text: jsonText({ loggedIn: true, mid, displayName }),
465
465
  },
466
466
  ],
467
467
  };
@@ -12,7 +12,7 @@
12
12
  import { addExcludedChatIds, deleteMessagesForChats, getExcludedChatIds, removeExcludedChatIds, } from '../../search/scope.js';
13
13
  import { resolveConversationNames } from '../names.js';
14
14
  import { getPrivacyPolicyText } from '../policy.js';
15
- import { toolError } from './shared.js';
15
+ import { jsonResult, jsonText, toolError } from './shared.js';
16
16
  /**
17
17
  * Read the current exclusion denylist and pair each chatId with a
18
18
  * best-effort display name. Name resolution only runs when a live LINE
@@ -54,7 +54,7 @@ export async function handleExcludeChats(args) {
54
54
  content: [
55
55
  {
56
56
  type: 'text',
57
- text: JSON.stringify({ excluded, purgedMessages }, null, 2),
57
+ text: jsonText({ excluded, purgedMessages }),
58
58
  },
59
59
  ],
60
60
  };
@@ -74,9 +74,7 @@ export async function handleIncludeChats(args) {
74
74
  }
75
75
  const included = removeExcludedChatIds(args.chatIds);
76
76
  return {
77
- content: [
78
- { type: 'text', text: JSON.stringify({ included }, null, 2) },
79
- ],
77
+ content: [{ type: 'text', text: jsonText({ included }) }],
80
78
  };
81
79
  }
82
80
  /**
@@ -91,9 +89,7 @@ export async function handleIncludeChats(args) {
91
89
  */
92
90
  export async function handleListExcludedChats(service) {
93
91
  const result = await listExcludedChatsWithNames(service);
94
- return {
95
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
96
- };
92
+ return jsonResult(result);
97
93
  }
98
94
  /**
99
95
  * Handle `get_scope_policy` — return Yomi's data-capture privacy policy (the
@@ -114,7 +110,5 @@ export async function handleGetScopePolicy(service) {
114
110
  policy: getPrivacyPolicyText(),
115
111
  excludedChats,
116
112
  };
117
- return {
118
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
119
- };
113
+ return jsonResult(result);
120
114
  }
@@ -24,10 +24,10 @@
24
24
  */
25
25
  import { collectMessages } from '../../search/collector.js';
26
26
  import { getDefaultEmbedder } from '../../search/default-embedder.js';
27
- import { getEmbeddingCount, getIndexedMessageCount, searchMessages, semanticSearch, } from '../../search/store.js';
27
+ import { getEmbeddingCount, getIndexedMessageCount, getMessageContext, searchMessages, semanticSearch, } from '../../search/store.js';
28
28
  import { createCliLogger } from '../../util/log.js';
29
29
  import { createPhiAccumulator, maskInto, phiNote } from '../phi-guard.js';
30
- import { toolError } from './shared.js';
30
+ import { jsonText, toolError, toonText } from './shared.js';
31
31
  const log = createCliLogger('Yomi');
32
32
  /**
33
33
  * Reciprocal Rank Fusion constant. The standard k=60 from Cormack et al.:
@@ -50,7 +50,7 @@ const RRF_K = 60;
50
50
  * @param limit - Maximum number of fused results to return.
51
51
  * @returns Fused results, best-first, each carrying its RRF score.
52
52
  */
53
- function fuseByRrf(lists, limit) {
53
+ export function fuseByRrf(lists, limit) {
54
54
  const scores = new Map();
55
55
  const rows = new Map();
56
56
  for (const list of lists) {
@@ -69,6 +69,26 @@ function fuseByRrf(lists, limit) {
69
69
  score,
70
70
  }));
71
71
  }
72
+ /**
73
+ * Keep one active conversation from flooding the top results. Skipped rows are
74
+ * backfilled only when there are not enough distinct conversations, so a query
75
+ * scoped by its natural matches still returns the requested number of hits.
76
+ */
77
+ export function applyConversationDiversity(rows, limit, perChat = 3) {
78
+ const selected = [];
79
+ const deferred = [];
80
+ const counts = new Map();
81
+ for (const row of rows) {
82
+ if ((counts.get(row.chatId) ?? 0) < perChat) {
83
+ selected.push(row);
84
+ counts.set(row.chatId, (counts.get(row.chatId) ?? 0) + 1);
85
+ }
86
+ else {
87
+ deferred.push(row);
88
+ }
89
+ }
90
+ return [...selected, ...deferred].slice(0, limit);
91
+ }
72
92
  /**
73
93
  * Handle `collect_messages` — explicit bulk fetch of recent messages from
74
94
  * one or more LINE conversations into the local search index, batch-
@@ -88,9 +108,7 @@ export async function handleCollectMessages(service, args) {
88
108
  embedder: getDefaultEmbedder(),
89
109
  });
90
110
  return {
91
- content: [
92
- { type: 'text', text: JSON.stringify(summary, null, 2) },
93
- ],
111
+ content: [{ type: 'text', text: jsonText(summary) }],
94
112
  };
95
113
  }
96
114
  /**
@@ -168,25 +186,29 @@ export async function handleSearchMessages(service, args) {
168
186
  let results;
169
187
  if (semantic.length > 0 && keyword.length > 0) {
170
188
  mode = 'hybrid';
171
- results = fuseByRrf([keyword, semantic], limit);
189
+ results = applyConversationDiversity(fuseByRrf([keyword, semantic], pool), limit);
172
190
  }
173
191
  else if (semantic.length > 0) {
174
192
  mode = 'semantic';
175
- results = semantic.slice(0, limit);
193
+ results = applyConversationDiversity(semantic, limit);
176
194
  }
177
195
  else {
178
196
  mode = 'keyword';
179
- results = keyword.slice(0, limit);
197
+ results = applyConversationDiversity(keyword, limit);
180
198
  }
181
199
  const acc = createPhiAccumulator();
182
200
  const maskedResults = results.map((row) => ({
183
201
  ...row,
184
202
  text: maskInto(acc, row.text),
203
+ context: getMessageContext(row.messageId).map((message) => ({
204
+ ...message,
205
+ text: maskInto(acc, message.text),
206
+ })),
185
207
  }));
186
208
  const content = [
187
209
  {
188
210
  type: 'text',
189
- text: JSON.stringify({ mode, results: maskedResults }, null, 2),
211
+ text: toonText({ mode, results: maskedResults }),
190
212
  },
191
213
  ];
192
214
  const note = phiNote(acc);