@rikaidev/yomi 0.2.6 → 0.3.0

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 (47) hide show
  1. package/README.md +14 -4
  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/index.js +1 -0
  32. package/dist/mcp/handlers/insight.js +129 -0
  33. package/dist/mcp/handlers/shared.js +2 -2
  34. package/dist/mcp/media.js +2 -2
  35. package/dist/mcp/names.js +1 -1
  36. package/dist/mcp/server.js +7 -4
  37. package/dist/mcp/tools.js +17 -0
  38. package/dist/search/collector.js +1 -1
  39. package/dist/search/store.js +31 -14
  40. package/dist/util/data-dir.js +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +3 -1
  43. package/dist/line/core/e2ee/types.js +0 -4
  44. package/dist/line/core/store.js +0 -8
  45. package/dist/line/core/thrift/binary/binary-reader.js +0 -151
  46. package/dist/line/core/thrift/compact/compact-reader.js +0 -180
  47. package/dist/line/core/thrift/compact/compact-writer.js +0 -161
@@ -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
- };
@@ -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
+ }
@@ -11,7 +11,7 @@ export const NO_CREDENTIALS_MESSAGE = 'No persisted LINE session. Call the `logi
11
11
  // "never logged in" so the model tells the user the right story: the MCP
12
12
  // connection is healthy, LINE revoked this device's token (usually because
13
13
  // the same account logged in somewhere else).
14
- export const SESSION_REVOKED_MESSAGE = 'LINE signed this device out — usually because the same account logged in ' +
14
+ const SESSION_REVOKED_MESSAGE = 'LINE signed this device out — usually because the same account logged in ' +
15
15
  'somewhere else (another device, or a terminal `login` run). The MCP ' +
16
16
  'connection itself is fine. Call the `login` tool to reconnect; cached ' +
17
17
  'credentials usually complete without a new PIN.';
@@ -30,7 +30,7 @@ export function sessionRequiredError() {
30
30
  // Recovery is the same `login` call as a revocation, but the cause is not — no
31
31
  // competing login happened, so claiming one sends the user hunting for a device
32
32
  // that never signed in.
33
- export const SESSION_EXPIRED_MESSAGE = 'The saved LINE session expired and the automatic token refresh failed. ' +
33
+ const SESSION_EXPIRED_MESSAGE = 'The saved LINE session expired and the automatic token refresh failed. ' +
34
34
  'Nothing signed this device out — the token simply aged out, and the MCP ' +
35
35
  'connection itself is fine. Call the `login` tool to reconnect; cached ' +
36
36
  'credentials usually complete without a new PIN.';
package/dist/mcp/media.js CHANGED
@@ -127,14 +127,14 @@ export function resolveLineMediaDescriptor(message) {
127
127
  export class LineMediaAccessError extends Error {
128
128
  }
129
129
  /** Thrown when the message body is missing the OBS/E2EE material required to decrypt its media. */
130
- export class MissingDecryptMaterialError extends LineMediaAccessError {
130
+ class MissingDecryptMaterialError extends LineMediaAccessError {
131
131
  code = 'missing_decrypt_material';
132
132
  constructor(reason) {
133
133
  super(`missing_decrypt_material: ${reason}`);
134
134
  }
135
135
  }
136
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 {
137
+ class UnsupportedMediaTypeError extends LineMediaAccessError {
138
138
  code = 'unsupported_media_type';
139
139
  constructor(contentType, messageId) {
140
140
  const label = resolveLineMediaType(contentType) ?? `contentType=${contentType}`;
package/dist/mcp/names.js CHANGED
@@ -80,7 +80,7 @@ export async function resolveSenderNames(service, mids) {
80
80
  * @param chatIds - Group/room chat MIDs to resolve.
81
81
  * @returns Map of chat MID to resolved chat title (unresolved MIDs are omitted).
82
82
  */
83
- export async function resolveGroupNames(service, chatIds) {
83
+ async function resolveGroupNames(service, chatIds) {
84
84
  const unique = Array.from(new Set(chatIds.filter(Boolean)));
85
85
  const resolved = new Map();
86
86
  const missing = [];
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Yomi MCP server — LINE query + reply surface over stdio.
3
3
  *
4
- * On startup resumes any persisted LINE session. Exposes forty tools:
4
+ * On startup resumes any persisted LINE session. Exposes forty-one tools:
5
5
  * login, login_complete, list_conversations, get_chat_messages,
6
- * get_message_image, get_message_media, get_unread_digest, mark_read,
6
+ * get_message_image, get_message_media, get_unread_digest, get_insight,
7
+ * mark_read,
7
8
  * send_message, send_image, send_file, send_audio, send_video,
8
9
  * send_location, send_contact, send_sticker, list_stickers, search_stickers,
9
10
  * preview_sticker, find_contact, list_contacts, get_group_members,
@@ -71,7 +72,7 @@ import { startCapture } from '../search/capture.js';
71
72
  import { getDefaultEmbedder } from '../search/default-embedder.js';
72
73
  import { createCliLogger } from '../util/log.js';
73
74
  import { YOMI_VERSION } from '../version.js';
74
- import { handleAcceptInvitation, handleAddFriend, handleBlockContact, handleCancelReaction, handleCollectMessages, handleCreateGroup, handleExcludeChats, handleFindContact, handleGetChatMessages, handleGetGroupMembers, handleGetMessageImage, handleGetMessageMedia, handleGetScopePolicy, handleGetUnreadDigest, handleIncludeChats, handleInviteMember, handleKickMember, handleLeaveGroup, handleListContacts, handleListConversations, handleListExcludedChats, handleListStickers, handleLogin, handleLoginComplete, handleMarkRead, handlePreviewSticker, handleReactMessage, handleRenameGroup, handleSearchMessages, handleSearchStickers, handleSendAudio, handleSendContact, handleSendFile, handleSendImage, handleSendLocation, handleSendMessage, handleSendSticker, handleSendVideo, handleUnblockContact, handleUnsendMessage, NO_CREDENTIALS_MESSAGE, sessionExpiredError, sessionRequiredError, sessionRevokedError, toolError, } from './handlers/index.js';
75
+ import { handleAcceptInvitation, handleAddFriend, handleBlockContact, handleCancelReaction, handleCollectMessages, handleCreateGroup, handleExcludeChats, handleFindContact, handleGetChatMessages, handleGetGroupMembers, handleGetInsight, handleGetMessageImage, handleGetMessageMedia, handleGetScopePolicy, handleGetUnreadDigest, handleIncludeChats, handleInviteMember, handleKickMember, handleLeaveGroup, handleListContacts, handleListConversations, handleListExcludedChats, handleListStickers, handleLogin, handleLoginComplete, handleMarkRead, handlePreviewSticker, handleReactMessage, handleRenameGroup, handleSearchMessages, handleSearchStickers, handleSendAudio, handleSendContact, handleSendFile, handleSendImage, handleSendLocation, handleSendMessage, handleSendSticker, handleSendVideo, handleUnblockContact, handleUnsendMessage, NO_CREDENTIALS_MESSAGE, sessionExpiredError, sessionRequiredError, sessionRevokedError, toolError, } from './handlers/index.js';
75
76
  import { getPrivacyPolicyText } from './policy.js';
76
77
  import { TOOLS } from './tools.js';
77
78
  import { supportsMcpApps } from './ui/capability.js';
@@ -119,7 +120,7 @@ async function main() {
119
120
  'it. The field is absent on normal (v2) messages, which are authenticated.' +
120
121
  '\n\n' +
121
122
  'PRIVACY DISCLOSURE (say ONCE per session) — the first time this session ' +
122
- 'does a bulk read (collect_messages/search_messages), tell the user once, in ' +
123
+ 'does a bulk read (collect_messages/search_messages/get_insight), tell the user once, in ' +
123
124
  'plain language, that Yomi captures all conversations by default, keeps the data ' +
124
125
  'on this machine, and that they can exclude conversations. Do not bury it, and do ' +
125
126
  'NOT repeat it on every call — once per session is enough. The full policy text ' +
@@ -205,6 +206,8 @@ async function main() {
205
206
  return await handleGetMessageMedia(service, (args ?? {}));
206
207
  case 'get_unread_digest':
207
208
  return await handleGetUnreadDigest(service, (args ?? {}));
209
+ case 'get_insight':
210
+ return await handleGetInsight(service, (args ?? {}));
208
211
  case 'mark_read':
209
212
  return await handleMarkRead(service, (args ?? {}));
210
213
  case 'send_message':
package/dist/mcp/tools.js CHANGED
@@ -142,6 +142,23 @@ export const TOOLS = [
142
142
  },
143
143
  name: 'get_unread_digest',
144
144
  },
145
+ {
146
+ description: 'A compact "what needs my attention" context network over the local index — you make the final call, this assembles the evidence cheaply. Nodes: `connectors` (people across ≥2 of your chats, with structural bridges) and `relationships` (per-conversation engagement, reply rhythm, recency). `open`: conversations whose latest message is NOT yours, ranked by how overdue they are relative to your usual reply rhythm there, each with `fromName` (last speaker), a `preview` of the latest message, `overdueRatio`/`typicality`, and a `lastMessageId` pointer. It carries NO message threads and makes NO judgement about addressee, nicknames, or open-request vs closing-ack — those are language understanding you do by reading each `preview` (a group message may be addressed to someone else, who then owns it), fetching the full thread with get_chat_messages only for the few worth it. Reads across all conversations (denylist-excluded dropped). Empty only when the index is empty.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ chatId: {
151
+ type: 'string',
152
+ description: 'Optional focus: restrict `relationships` and `pending` to this chat (as returned by list_conversations). Omit to scan all conversations.',
153
+ },
154
+ sinceHours: {
155
+ type: 'number',
156
+ description: 'Lookback window in hours, measured back from the newest captured message (not wall-clock). Default 504 (21 days).',
157
+ },
158
+ },
159
+ },
160
+ name: 'get_insight',
161
+ },
145
162
  {
146
163
  description: 'Sends a text message to a LINE conversation immediately (not a draft). Always E2EE (pairwise for 1:1, group key for group/room); fails honestly rather than sending plaintext if the key cannot be resolved. One send per call. To @mention someone, put the visible "@name " into `text` AND pass a matching `mentions` entry — without `mentions`, "@name" is plain text and notifies no one. Resolve MIDs via get_group_members or find_contact first.',
147
164
  inputSchema: {
@@ -121,7 +121,7 @@ export async function collectMessages(service, options = {}) {
121
121
  * @param embedder - Injected embedder to embed missing rows with.
122
122
  * @returns Number of messages newly embedded and stored.
123
123
  */
124
- export async function backfillMissingEmbeddings(embedder) {
124
+ async function backfillMissingEmbeddings(embedder) {
125
125
  const missing = getMessagesMissingEmbedding(embedder.modelLabel);
126
126
  if (missing.length === 0) {
127
127
  return 0;
@@ -437,22 +437,39 @@ export async function semanticSearch(query, limit, embedder) {
437
437
  return scored.slice(0, limit);
438
438
  }
439
439
  /**
440
- * Read the persisted capture cursor, if any has been saved yet.
440
+ * Load every indexed message that has an embedding for `model`, newest-window
441
+ * first bounded by `sinceMs`, with its vector decoded. Empty-text and
442
+ * timestamp-less rows are excluded — clustering needs both a vector to compare
443
+ * and a time to gate on, and a row missing either can only add noise.
441
444
  *
442
- * @returns The persisted capture state, or null before capture has ever flushed.
445
+ * This is a full scan of the embedded corpus (personal-scale: thousands of
446
+ * rows), left to the caller to cluster; the store deliberately holds no
447
+ * opinion on thresholds or windowing beyond the `sinceMs` cutoff.
448
+ *
449
+ * @param model - Embedder.modelLabel whose vectors to load.
450
+ * @param sinceMs - Optional lower bound on createdTime (epoch ms); omit for all.
451
+ * @returns Messages with decoded vectors, oldest first.
443
452
  */
444
- export function getCaptureState() {
445
- const row = getDb()
446
- .prepare('SELECT revision, globalRevision, individualRevision, updatedAt FROM capture_state WHERE id = 1')
447
- .get();
448
- return row
449
- ? {
450
- revision: row.revision ?? 0,
451
- globalRevision: row.globalRevision ?? 0,
452
- individualRevision: row.individualRevision ?? 0,
453
- updatedAt: row.updatedAt ?? 0,
454
- }
455
- : null;
453
+ export function getMessagesWithVectors(model, sinceMs) {
454
+ const handle = getDb();
455
+ const rows = handle
456
+ .prepare(`
457
+ SELECT m.messageId as messageId, m.chatId as chatId, m.chatName as chatName,
458
+ m.fromMid as fromMid, m.fromName as fromName, m.text as text,
459
+ m.createdTime as createdTime, e.vector as vector
460
+ FROM embeddings e
461
+ JOIN messages m ON m.messageId = e.messageId
462
+ WHERE e.model = $model
463
+ AND m.createdTime IS NOT NULL
464
+ AND ($since IS NULL OR m.createdTime >= $since)
465
+ AND length(m.text) > 0
466
+ ORDER BY m.createdTime ASC;
467
+ `)
468
+ .all({ $model: model, $since: sinceMs ?? null });
469
+ return rows.map((row) => {
470
+ const { vector, ...rest } = row;
471
+ return { ...rest, vector: decodeVector(vector) };
472
+ });
456
473
  }
457
474
  /**
458
475
  * Persist the current capture cursor, overwriting the single stored row.
@@ -28,7 +28,7 @@ import { join } from 'node:path';
28
28
  *
29
29
  * @returns Absolute path to the directory Yomi's state lives in.
30
30
  */
31
- export function resolveYomiDataDir() {
31
+ function resolveYomiDataDir() {
32
32
  const override = process.env.YOMI_DATA_DIR;
33
33
  if (override) {
34
34
  return override;
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * virtual filesystem). MUST stay equal to the "version" field in
5
5
  * package.json — the test in version.test.ts enforces that.
6
6
  */
7
- export const YOMI_VERSION = '0.2.6';
7
+ export const YOMI_VERSION = '0.3.0';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=22.13.0 <23.0.0 || >=23.4.0"
5
5
  },
6
- "version": "0.2.6",
6
+ "version": "0.3.0",
7
7
  "description": "Yomi (読み) — reads your LINE (decrypting Letter-Sealing E2EE) and sends on your behalf, via a stdio MCP server, so an agent can catch up on your LINE for you.",
8
8
  "type": "module",
9
9
  "license": "MIT",
@@ -51,6 +51,7 @@
51
51
  "dependencies": {
52
52
  "@huggingface/transformers": "^4.2.0",
53
53
  "@modelcontextprotocol/sdk": "^1.12.0",
54
+ "@toon-format/toon": "^2.3.1",
54
55
  "curve25519-js": "^0.0.4",
55
56
  "tweetnacl": "^1.0.3"
56
57
  },
@@ -58,6 +59,7 @@
58
59
  "@biomejs/biome": "^2.5.3",
59
60
  "@types/bun": "^1.3.14",
60
61
  "@types/node": "^22.10.0",
62
+ "knip": "^6.27.0",
61
63
  "typescript": "^7.0.0"
62
64
  }
63
65
  }
@@ -1,4 +0,0 @@
1
- /**
2
- * LINE E2EE types
3
- */
4
- export {};
@@ -1,8 +0,0 @@
1
- /**
2
- * LINE Credential Stores — re-exports
3
- *
4
- * Both stores live in Yomi's vendored auth/credential-store module.
5
- * This module re-exports them under their original names for
6
- * compatibility with the LINE protocol core.
7
- */
8
- export { CredentialStore as FileCredentialStore, InMemoryStore, } from '../../auth/credential-store.js';