@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
package/README.md CHANGED
@@ -6,22 +6,32 @@
6
6
  </picture>
7
7
  </p>
8
8
 
9
- # Yomi (読み)
9
+ # Yomi (読み) — the personal LINE MCP server
10
10
 
11
- **Read your LINE from an AI agent — and reply, send images, and search across every conversation — without a browser and without LINE's own client.**
11
+ **Yomi is an open-source LINE MCP server for your personal account. Read, reply,
12
+ send images, and search every conversation from Claude or any local AI agent —
13
+ without a browser, bot account, or LINE's own client.**
12
14
 
13
15
  *The name reads more than one way. **読み** (yomi) — a reading: not just parsing
14
16
  your messages, but reading the situation, the way you do. **詠み** (yomi) — to
15
- recite: it doesn't read in silence, it reads things back to you. And **黄泉** (yomi)
16
- — the realm of what's buried and out of reach; the very yomi in **黄泉帰り**
17
- (yomigaeri), "the return from it," the root of the Japanese word for revival. Yomi
18
- reaches into what is sealed or forgotten and brings it back to light.*
17
+ recite: it doesn't read in silence, it reads things back to you. And **黄泉**
18
+ (yomi) — the unreachable realm; the very yomi in **黄泉帰り** (*yomigaeri*),
19
+ "returning from Yomi." Like a messenger moving between two realms, Yomi passes
20
+ through sealing and silence to bring messages still readable deep inside your
21
+ conversations back into the light. It does not summon history already lost; it
22
+ helps what has not disappeared be seen again.*
23
+
24
+ Unlike LINE's official Bot MCP server, which connects AI agents to a LINE
25
+ Official Account through the Messaging API, Yomi connects to your existing
26
+ personal LINE account and conversations as a secondary device.
19
27
 
20
28
  Yomi speaks LINE's TCompact-over-HTTPS protocol directly, decrypts Letter-Sealing
21
29
  (E2EE) messages and media, and exposes the result to any AI agent through a small
22
30
  stdio [MCP](https://modelcontextprotocol.io) server. Point Claude Desktop (or any
23
31
  MCP client) at it and the agent can catch up on your LINE the way you do — read,
24
- reply, send an image, mention someone, and search your whole history locally.
32
+ reply, send an image, mention someone, and search your whole history locally — and,
33
+ with `get_insight`, read the *situation*: which conversations are waiting on your
34
+ reply, who ties your world together, and what has gone overdue.
25
35
 
26
36
  No official API, no bot account, no webhook. Yomi logs in as a secondary device
27
37
  on your own account.
@@ -403,9 +413,13 @@ Approve the device on your phone (see [Logging in](#logging-in)), and:
403
413
 
404
414
  ## What you can do
405
415
 
406
- Yomi exposes **19 tools** over MCP. All return JSON. "Honest error" below means an
407
- explicit failure naming the problem (e.g. `missing_decrypt_material`) — Yomi never
408
- fabricates a fallback, a placeholder, or a fake success.
416
+ Yomi exposes **20 tools** over MCP (the ones worth naming; see the tables). Large
417
+ read results use token-efficient [TOON](https://github.com/toon-format/toon);
418
+ small status and write results use compact JSON. Errors remain plain text and media
419
+ uses native MCP image/audio/resource content.
420
+ "Honest error" below means an explicit failure naming the problem (e.g.
421
+ `missing_decrypt_material`) — Yomi never fabricates a fallback, a placeholder, or a
422
+ fake success.
409
423
 
410
424
  ### Reading
411
425
 
@@ -418,6 +432,12 @@ fabricates a fallback, a placeholder, or a fake success.
418
432
  | `find_contact` / `list_contacts` | Friend-list lookup by name substring, or the full list. Raw LINE data — no fuzzy scoring, no affinity ranking. |
419
433
  | `get_group_members` | Members of a persistent group. Ad-hoc rooms without a group record fail honestly rather than returning a fake empty list. |
420
434
 
435
+ ### Insight (read the situation, not just the messages)
436
+
437
+ | Tool | Does |
438
+ | --- | --- |
439
+ | `get_insight` | A compact *context network* over your local index — structure the agent reasons over, not a message dump. Returns `connectors` (people who appear across ≥2 of your chats, with the structural **bridges** whose removal would split your contact graph), `relationships` (per-conversation engagement and your typical reply **rhythm** there), and `open` (conversations whose latest message isn't yours, ranked by how **overdue** they are *relative to that rhythm*, each with a preview). It computes only structure and statistics: it deliberately does **not** decide who a message is addressed to, whether it's an open request or a closing acknowledgement, or a nickname's real identity — that is language the agent reads from each preview, dereferencing `get_chat_messages` only when a thread is worth judging. Encoded as [TOON](https://github.com/toon-format/toon), ≈¼ the tokens of the equivalent JSON. |
440
+
421
441
  ### Writing (these really send — not drafts)
422
442
 
423
443
  | Tool | Does |
@@ -430,7 +450,7 @@ fabricates a fallback, a placeholder, or a fake success.
430
450
 
431
451
  | Tool | Does |
432
452
  | --- | --- |
433
- | `search_messages` | Hybrid search across all indexed conversations. Auto-collects on first use. Returns `mode` (`hybrid`/`semantic`/`keyword`) so nothing is hidden. |
453
+ | `search_messages` | Hybrid search across all indexed conversations. Auto-collects on first use, diversifies results across chats, and returns a small context window around each hit. Returns `mode` (`hybrid`/`semantic`/`keyword`) so nothing is hidden. |
434
454
  | `collect_messages` | Explicit bulk-index into the local DB + embed for semantic search. The only tool allowed to bulk-fetch; runs once per call, never on a timer. |
435
455
 
436
456
  ### Scope & privacy (all work offline, no LINE session needed)
@@ -530,6 +550,13 @@ live in un-embedded messages; pure keyword misses paraphrases; fusing both surfa
530
550
  an exact term and a meaning-match together. The response's `mode` field always
531
551
  reports which methods contributed.
532
552
 
553
+ Semantic ranking keeps each message as its own vector. Testing against real indexed
554
+ LINE conversations found that embedding overlapping conversation windows diluted the
555
+ center message and reduced retrieval quality. Context is therefore added only after
556
+ ranking: Yomi limits one chat from flooding the top results and expands each winner
557
+ with two messages on either side so the agent sees the exchange that gave the hit
558
+ meaning without weakening the embedding itself.
559
+
533
560
  Semantic ranking uses **`Xenova/bge-small-zh-v1.5`** (BAAI general embedding, small,
534
561
  Chinese-primary but multilingual) via **transformers.js**. Embedding **inference runs
535
562
  fully in-process on CPU — your message text is never sent anywhere.**
@@ -54,40 +54,6 @@ function parseCredentialBlob(blob) {
54
54
  return {};
55
55
  }
56
56
  }
57
- /** In-memory credential store for testing and non-persistent scenarios. */
58
- export class InMemoryStore {
59
- store;
60
- constructor() {
61
- this.store = new Map();
62
- }
63
- /**
64
- * Gets a credential value.
65
- * @param key - Credential key.
66
- * @returns Stored value or null.
67
- */
68
- async get(key) {
69
- return this.store.get(key) ?? null;
70
- }
71
- /**
72
- * Sets a credential value.
73
- * @param key - Credential key.
74
- * @param value - Value to store.
75
- */
76
- async set(key, value) {
77
- this.store.set(key, value);
78
- }
79
- /**
80
- * Deletes a credential.
81
- * @param key - Credential key to delete.
82
- */
83
- async delete(key) {
84
- this.store.delete(key);
85
- }
86
- /** Wipes all credentials. */
87
- async clearAll() {
88
- this.store.clear();
89
- }
90
- }
91
57
  /**
92
58
  * Key prefixes for VOLATILE, recomputable E2EE cache — peer public keys and
93
59
  * group shared keys. These are written on nearly every message decrypt, so
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Graph primitives for the relationship layer.
3
+ *
4
+ * "Who bridges your conversations?" is a classical graph question: build the
5
+ * person co-occurrence graph (people are nodes, an edge joins two people who
6
+ * share a chat) and find its articulation points. A node whose removal raises
7
+ * the connected-component count is a cut vertex — a person the structure of your
8
+ * contact network depends on, the bridge between otherwise-separate groups —
9
+ * defined structurally rather than by counting.
10
+ *
11
+ * Undirected graph, adjacency list, iterative traversal (recursion would risk a
12
+ * stack overflow on a long chain). Personal-scale, plain arrays.
13
+ */
14
+ /** Build an adjacency list for `n` nodes from an edge list. */
15
+ function adjacency(n, edges) {
16
+ const adj = Array.from({ length: n }, () => []);
17
+ for (const { a, b } of edges) {
18
+ if (a === b) {
19
+ continue;
20
+ }
21
+ adj[a].push(b);
22
+ adj[b].push(a);
23
+ }
24
+ return adj;
25
+ }
26
+ /**
27
+ * Articulation points (cut vertices) of the graph, by iterative Tarjan DFS.
28
+ * A node is an articulation point if removing it increases the number of
29
+ * connected components.
30
+ *
31
+ * @param n - Number of nodes.
32
+ * @param edges - Undirected edges.
33
+ * @returns Set of node indices that are articulation points.
34
+ */
35
+ export function articulationPoints(n, edges) {
36
+ const adj = adjacency(n, edges);
37
+ const disc = new Array(n).fill(-1);
38
+ const low = new Array(n).fill(0);
39
+ const isArt = new Set();
40
+ let timer = 0;
41
+ for (let s = 0; s < n; s++) {
42
+ if (disc[s] !== -1) {
43
+ continue;
44
+ }
45
+ // Iterative DFS: frame carries the node, its parent, and an index into its
46
+ // adjacency list (the resume point after visiting a child).
47
+ const stack = [
48
+ { u: s, parent: -1, i: 0 },
49
+ ];
50
+ let rootChildren = 0;
51
+ while (stack.length > 0) {
52
+ const frame = stack[stack.length - 1];
53
+ const { u, parent } = frame;
54
+ if (frame.i === 0) {
55
+ disc[u] = low[u] = timer++;
56
+ }
57
+ if (frame.i < adj[u].length) {
58
+ const v = adj[u][frame.i];
59
+ frame.i++;
60
+ if (v === parent) {
61
+ continue;
62
+ }
63
+ if (disc[v] === -1) {
64
+ if (u === s) {
65
+ rootChildren++;
66
+ }
67
+ stack.push({ u: v, parent: u, i: 0 });
68
+ }
69
+ else {
70
+ low[u] = Math.min(low[u], disc[v]);
71
+ }
72
+ }
73
+ else {
74
+ stack.pop();
75
+ if (parent !== -1) {
76
+ low[parent] = Math.min(low[parent], low[u]);
77
+ // A non-root parent is a cut vertex if a child cannot reach above it.
78
+ if (parent !== s && low[u] >= disc[parent]) {
79
+ isArt.add(parent);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // The root is a cut vertex iff it has more than one DFS child.
85
+ if (rootChildren > 1) {
86
+ isArt.add(s);
87
+ }
88
+ }
89
+ return isArt;
90
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The insight layer's first stage: build structured, unsupervised context for
3
+ * an agent to reason over — it does not decide what needs attention itself.
4
+ *
5
+ * The division of labour is deliberate. Determining whether a message is an
6
+ * open request or a closing acknowledgement is illocutionary force, which topic
7
+ * embeddings provably do not encode (see ./pending), so no amount of clustering
8
+ * or scoring here can decide "needs attention" without guessing. Instead this
9
+ * stage assembles what CAN be computed without content understanding — the
10
+ * relationship graph (./relationships), reply rhythm (./rhythm), and the
11
+ * candidate pending items with their thread context (./pending) — and returns
12
+ * it as a package. The agent consuming the tool makes the final call, grounded
13
+ * in that context rather than in a raw message dump.
14
+ */
15
+ import { getEmbeddingCount, getMessagesWithVectors } from '../search/store.js';
16
+ import { findPending } from './pending.js';
17
+ import { buildRelationships, findConnectors, } from './relationships.js';
18
+ const DEFAULT_LOOKBACK_MS = 21 * 24 * 60 * 60 * 1000;
19
+ const EMPTY = {
20
+ window: { fromMs: 0, toMs: 0, messages: 0 },
21
+ relationships: [],
22
+ connectors: [],
23
+ open: [],
24
+ };
25
+ /**
26
+ * Assemble the insight context package over the local index.
27
+ *
28
+ * @param opts - Focus, window, self identity, and embedder model selection.
29
+ * @returns The relationship graph, connectors, and pending candidates.
30
+ */
31
+ export function computeInsights(opts) {
32
+ const model = opts.embedder.modelLabel;
33
+ if (getEmbeddingCount(model) === 0) {
34
+ return EMPTY;
35
+ }
36
+ const all = getMessagesWithVectors(model);
37
+ if (all.length === 0) {
38
+ return EMPTY;
39
+ }
40
+ // Window anchors to the newest captured message, not wall-clock: capture can
41
+ // lag, and the analysis should look back from the data's own edge.
42
+ const newest = all[all.length - 1].createdTime;
43
+ const lookback = opts.sinceHours
44
+ ? opts.sinceHours * 60 * 60 * 1000
45
+ : DEFAULT_LOOKBACK_MS;
46
+ const since = newest - lookback;
47
+ const rows = all.filter((r) => r.createdTime >= since);
48
+ let relationships = buildRelationships(rows, opts.selfMid);
49
+ const connectors = findConnectors(rows, opts.selfMid);
50
+ let open = findPending(rows, opts.selfMid, newest);
51
+ if (opts.focusChatId) {
52
+ relationships = relationships.filter((r) => r.chatId === opts.focusChatId);
53
+ open = open.filter((o) => o.chatId === opts.focusChatId);
54
+ }
55
+ return {
56
+ window: { fromMs: since, toMs: newest, messages: rows.length },
57
+ relationships,
58
+ connectors,
59
+ open,
60
+ };
61
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Open threads — conversations whose latest message is not yours — as compact
3
+ * directed edges for a network the agent reads cheaply, not raw thread dumps.
4
+ *
5
+ * The tool's job stops at structure and statistics: which conversation, whether
6
+ * it is 1:1, who spoke last, how long it has waited, and how that compares to
7
+ * the relationship's own reply rhythm. It does NOT parse who a message is
8
+ * addressed to, resolve nicknames, or judge open-request vs closing-ack — those
9
+ * are language understanding (addressing conventions, illocutionary force,
10
+ * identity) that hand-written heuristics only overfit to one corpus. The agent
11
+ * reads the `preview` (and fetches the thread when needed) and does all of that
12
+ * itself, in any language, with no brittle rules here. `typicality` (embedding
13
+ * density) is a boilerplate-shape hint only — it also buries polite requests,
14
+ * so it must not be used as a filter.
15
+ */
16
+ import { replyRhythmHours } from './rhythm.js';
17
+ const HOUR = 3_600_000;
18
+ function dot(a, b) {
19
+ const n = Math.min(a.length, b.length);
20
+ let s = 0;
21
+ for (let i = 0; i < n; i++) {
22
+ s += a[i] * b[i];
23
+ }
24
+ return s;
25
+ }
26
+ /** Mean of the top-K cosine similarities to all other messages (typicality). */
27
+ function typicality(vec, all, k) {
28
+ const scores = [];
29
+ for (const v of all) {
30
+ if (v !== vec) {
31
+ scores.push(dot(vec, v));
32
+ }
33
+ }
34
+ scores.sort((a, b) => b - a);
35
+ const top = scores.slice(0, k);
36
+ return top.length ? top.reduce((s, x) => s + x, 0) / top.length : 0;
37
+ }
38
+ /**
39
+ * Find open threads across all conversations you participate in.
40
+ *
41
+ * @param rows - All windowed messages with vectors.
42
+ * @param selfMid - The account owner's MID.
43
+ * @param nowMs - Reference "now" (typically the newest captured message time).
44
+ * @param opts - Density-K and preview length.
45
+ * @returns Open threads, most overdue first.
46
+ */
47
+ export function findPending(rows, selfMid, nowMs, opts = {}) {
48
+ const k = opts.densityK ?? 15;
49
+ const previewChars = opts.previewChars ?? 80;
50
+ const allVecs = rows.map((r) => r.vector);
51
+ const byChat = new Map();
52
+ for (const r of rows) {
53
+ const list = byChat.get(r.chatId);
54
+ if (list) {
55
+ list.push(r);
56
+ }
57
+ else {
58
+ byChat.set(r.chatId, [r]);
59
+ }
60
+ }
61
+ const items = [];
62
+ for (const [, msgs] of byChat) {
63
+ const senders = new Set(msgs.map((m) => m.fromMid).filter(Boolean));
64
+ if (selfMid == null || !senders.has(selfMid)) {
65
+ continue;
66
+ }
67
+ const sorted = [...msgs].sort((a, b) => a.createdTime - b.createdTime);
68
+ const last = sorted[sorted.length - 1];
69
+ if (last.fromMid === selfMid) {
70
+ continue; // ball is in their court
71
+ }
72
+ const rhythmHours = replyRhythmHours(sorted, selfMid);
73
+ const waitHours = (nowMs - last.createdTime) / HOUR;
74
+ items.push({
75
+ chatId: last.chatId,
76
+ chatName: last.chatName,
77
+ isDM: senders.size <= 2,
78
+ fromName: last.fromName,
79
+ waitHours,
80
+ rhythmHours,
81
+ overdueRatio: rhythmHours
82
+ ? waitHours / Math.max(rhythmHours, 0.25)
83
+ : waitHours / 24,
84
+ typicality: typicality(last.vector, allVecs, k),
85
+ preview: last.text.replace(/\s+/g, ' ').slice(0, previewChars),
86
+ lastMessageId: last.messageId,
87
+ lastTimeMs: last.createdTime,
88
+ });
89
+ }
90
+ items.sort((a, b) => b.overdueRatio - a.overdueRatio);
91
+ return items;
92
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The relationship layer: who you talk to, how, and how often — the 對象/關係
3
+ * axis of the analysis, built purely from interaction structure (no message
4
+ * content). This is unsupervised context for the agent's final judgement, not a
5
+ * judgement itself.
6
+ *
7
+ * Two views:
8
+ * - per-conversation engagement (reciprocity, rhythm, recency), so a pending
9
+ * item can be weighed by how much the relationship actually matters;
10
+ * - cross-conversation connectors: people who appear in more than one of your
11
+ * chats, with the structural bridges among them found as articulation
12
+ * points of the person co-occurrence graph (see ./graph).
13
+ */
14
+ import { articulationPoints } from './graph.js';
15
+ import { replyRhythmHours } from './rhythm.js';
16
+ /**
17
+ * Build per-conversation engagement stats for every chat you have spoken in.
18
+ *
19
+ * @param rows - All windowed messages.
20
+ * @param selfMid - The account owner's MID.
21
+ * @returns One Relationship per chat you participate in, most recent first.
22
+ */
23
+ export function buildRelationships(rows, selfMid) {
24
+ const byChat = new Map();
25
+ for (const r of rows) {
26
+ const list = byChat.get(r.chatId);
27
+ if (list) {
28
+ list.push(r);
29
+ }
30
+ else {
31
+ byChat.set(r.chatId, [r]);
32
+ }
33
+ }
34
+ const out = [];
35
+ for (const [chatId, msgs] of byChat) {
36
+ const senders = new Set(msgs.map((m) => m.fromMid).filter(Boolean));
37
+ if (selfMid == null || !senders.has(selfMid)) {
38
+ continue;
39
+ }
40
+ const yourMessages = msgs.filter((m) => m.fromMid === selfMid).length;
41
+ out.push({
42
+ chatId,
43
+ chatName: msgs[msgs.length - 1].chatName,
44
+ isDM: senders.size <= 2,
45
+ participantCount: senders.size,
46
+ totalMessages: msgs.length,
47
+ yourMessages,
48
+ reciprocity: yourMessages / msgs.length,
49
+ rhythmHours: replyRhythmHours(msgs, selfMid),
50
+ lastActivityMs: Math.max(...msgs.map((m) => m.createdTime)),
51
+ });
52
+ }
53
+ out.sort((a, b) => b.lastActivityMs - a.lastActivityMs);
54
+ return out;
55
+ }
56
+ /**
57
+ * Find people who appear across two or more of your conversations, and mark
58
+ * those that are structural bridges (articulation points) of the person
59
+ * co-occurrence graph — nodes whose removal would disconnect your network.
60
+ *
61
+ * @param rows - All windowed messages.
62
+ * @param selfMid - The account owner's MID (excluded — you connect everything).
63
+ * @returns Cross-conversation connectors, most-chats first.
64
+ */
65
+ export function findConnectors(rows, selfMid) {
66
+ // person → chats they speak in, and their display name.
67
+ const chatsOf = new Map();
68
+ const nameOf = new Map();
69
+ // chat → the set of people in it, to build co-occurrence edges.
70
+ const peopleIn = new Map();
71
+ const addTo = (map, key, value) => {
72
+ const set = map.get(key);
73
+ if (set) {
74
+ set.add(value);
75
+ }
76
+ else {
77
+ map.set(key, new Set([value]));
78
+ }
79
+ };
80
+ for (const r of rows) {
81
+ const mid = r.fromMid;
82
+ if (!mid || mid === selfMid) {
83
+ continue;
84
+ }
85
+ addTo(chatsOf, mid, r.chatId);
86
+ addTo(peopleIn, r.chatId, mid);
87
+ if (!nameOf.has(mid)) {
88
+ nameOf.set(mid, r.fromName);
89
+ }
90
+ }
91
+ const people = [...chatsOf.keys()];
92
+ const index = new Map(people.map((mid, i) => [mid, i]));
93
+ const seenEdge = new Set();
94
+ const edges = [];
95
+ for (const members of peopleIn.values()) {
96
+ const list = [...members];
97
+ for (let i = 0; i < list.length; i++) {
98
+ for (let j = i + 1; j < list.length; j++) {
99
+ const a = index.get(list[i]);
100
+ const b = index.get(list[j]);
101
+ const key = a < b ? `${a}-${b}` : `${b}-${a}`;
102
+ if (!seenEdge.has(key)) {
103
+ seenEdge.add(key);
104
+ edges.push({ a, b });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ const bridges = articulationPoints(people.length, edges);
110
+ const out = [];
111
+ for (const mid of people) {
112
+ const chatCount = chatsOf.get(mid)?.size ?? 0;
113
+ if (chatCount < 2) {
114
+ continue;
115
+ }
116
+ out.push({
117
+ mid,
118
+ name: nameOf.get(mid) ?? null,
119
+ chatCount,
120
+ isBridge: bridges.has(index.get(mid)),
121
+ });
122
+ }
123
+ out.sort((a, b) => b.chatCount - a.chatCount);
124
+ return out;
125
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reply rhythm of a conversation, at the level of turns rather than messages.
3
+ *
4
+ * "How long you normally take to reply to someone" is what makes a wait
5
+ * *overdue* — a fixed hour threshold is wrong, because some threads run in
6
+ * seconds and others in days. The naive estimate (median gap between every
7
+ * incoming→your-message pair) is corrupted by bursts: inside a rapid exchange
8
+ * the gaps are near-zero, dragging the median to zero.
9
+ *
10
+ * So messages are first collapsed into turns (maximal runs by the same side,
11
+ * you vs. the other party), and a reply latency is measured only across a real
12
+ * turn hand-off: from the end of the other side's turn to the start of yours.
13
+ * The median of those latencies is the relationship's rhythm.
14
+ */
15
+ /**
16
+ * Median turn hand-off latency (in hours) from the other party to you — the
17
+ * relationship's reply rhythm. Null when the conversation contains no such
18
+ * hand-off (e.g. you never replied, or only you have spoken).
19
+ *
20
+ * @param messages - The conversation's messages, any order.
21
+ * @param selfMid - The account owner's MID.
22
+ * @returns Median reply latency in hours, or null if not estimable.
23
+ */
24
+ export function replyRhythmHours(messages, selfMid) {
25
+ const sorted = [...messages].sort((a, b) => a.createdTime - b.createdTime);
26
+ // Collapse into turns keyed by self vs. other.
27
+ const turns = [];
28
+ for (const m of sorted) {
29
+ const self = m.fromMid === selfMid;
30
+ const top = turns[turns.length - 1];
31
+ if (!top || top.self !== self) {
32
+ turns.push({ self, first: m.createdTime, last: m.createdTime });
33
+ }
34
+ else {
35
+ top.last = m.createdTime;
36
+ }
37
+ }
38
+ const HOUR = 3_600_000;
39
+ const latencies = [];
40
+ for (let i = 1; i < turns.length; i++) {
41
+ if (turns[i].self && !turns[i - 1].self) {
42
+ latencies.push((turns[i].first - turns[i - 1].last) / HOUR);
43
+ }
44
+ }
45
+ if (latencies.length === 0) {
46
+ return null;
47
+ }
48
+ latencies.sort((a, b) => a - b);
49
+ return latencies[Math.floor(latencies.length / 2)];
50
+ }
@@ -1,3 +1 @@
1
- export { initE2EE } from './e2ee-bootstrap.js';
2
- export { extractE2EEInfo } from './login-metadata.js';
3
1
  export { performPwlessLogin } from './pwless-login-flow.js';
@@ -25,7 +25,7 @@ import { checkPaakAuthenticated, checkPinCodeVerified, createSession, getE2eeKey
25
25
  * each step is this value multiplied by that step's attempt count — see
26
26
  * `PIN_VERIFY_CLIENT_CEILING_MS` / `PAAK_AUTH_CLIENT_CEILING_MS` below.
27
27
  */
28
- export const PWLESS_POLL_TIMEOUT_MS = 65000;
28
+ const PWLESS_POLL_TIMEOUT_MS = 65000;
29
29
  /**
30
30
  * Real client-side ceiling for the PIN-verification long-poll loop: how long
31
31
  * yomi keeps waiting for the human to enter the PIN before giving up.
@@ -38,7 +38,7 @@ const LINE_AUTH_INVALIDATED_PATTERN = /V3_TOKEN_CLIENT_LOGGED_OUT|LOGGED_OUT|DIV
38
38
  * @param message - Human-readable diagnostic message
39
39
  * @param data - Structured exception payload
40
40
  */
41
- export class LineRequestError extends Error {
41
+ class LineRequestError extends Error {
42
42
  data;
43
43
  type;
44
44
  constructor(type, message, data = {}) {
@@ -20,7 +20,7 @@ function normalizeNumericValue(value) {
20
20
  * @param responseRoot - Root response payload.
21
21
  * @returns Raw chat array or null.
22
22
  */
23
- export function findChatArray(responseRoot) {
23
+ function findChatArray(responseRoot) {
24
24
  if (!responseRoot) {
25
25
  return null;
26
26
  }
@@ -52,7 +52,7 @@ export function findChatArray(responseRoot) {
52
52
  * @param chat - Raw chat thrift struct.
53
53
  * @returns Normalized chat or null.
54
54
  */
55
- export function mapChatStruct(chat) {
55
+ function mapChatStruct(chat) {
56
56
  if (!chat || typeof chat !== 'object') {
57
57
  return null;
58
58
  }
@@ -73,7 +73,7 @@ export function mapChatStruct(chat) {
73
73
  * @param chats - Raw chat list.
74
74
  * @returns Normalized chat list.
75
75
  */
76
- export function mapChatList(chats) {
76
+ function mapChatList(chats) {
77
77
  return Array.isArray(chats)
78
78
  ? chats
79
79
  .map((chat) => mapChatStruct(chat))
@@ -47,7 +47,7 @@ function mapContactCapabilityFields(contact) {
47
47
  * @param contact - Raw contact thrift struct.
48
48
  * @returns Normalized contact or null.
49
49
  */
50
- export function mapContactStruct(contact) {
50
+ function mapContactStruct(contact) {
51
51
  if (!contact || typeof contact !== 'object') {
52
52
  return null;
53
53
  }
@@ -39,7 +39,7 @@ export function normalizeSendMessageOptions(to, text) {
39
39
  * @param options - Normalized send-message options.
40
40
  * @returns Thrift struct fields.
41
41
  */
42
- export function buildSendMessagePayload(options) {
42
+ function buildSendMessagePayload(options) {
43
43
  const fields = [stringField(2, options.to), i32Field(15, options.contentType)];
44
44
  if (options.text != null) {
45
45
  fields.push(stringField(10, options.text));
@@ -9,7 +9,7 @@ import { sharedKey as curve25519SharedKey } from 'curve25519-js';
9
9
  * @param args - Data to hash (strings or Uint8Array)
10
10
  * @returns Hash digest as Buffer
11
11
  */
12
- export function sha256(...args) {
12
+ function sha256(...args) {
13
13
  const h = crypto.createHash('sha256');
14
14
  for (const a of args) {
15
15
  const buf = Buffer.from(a);
@@ -22,7 +22,7 @@ export function sha256(...args) {
22
22
  * @param buf - Buffer to XOR
23
23
  * @returns Resulting buffer with half the length
24
24
  */
25
- export function xorHalves(buf) {
25
+ function xorHalves(buf) {
26
26
  const half = Math.floor(buf.length / 2);
27
27
  const out = Buffer.alloc(half);
28
28
  for (let i = 0; i < half; i++) {
@@ -41,19 +41,6 @@ export function aesDecrypt(data, key, iv) {
41
41
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
42
42
  return Buffer.concat([decipher.update(data), decipher.final()]);
43
43
  }
44
- /**
45
- * Generate X25519 key pair for E2EE
46
- * @returns Key pair object
47
- */
48
- export function generateKeyPair() {
49
- const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
50
- const pub = publicKey.export({ type: 'spki', format: 'der' }).slice(-32);
51
- const priv = privateKey.export({ type: 'pkcs8', format: 'der' }).slice(-32);
52
- return {
53
- publicKey: pub,
54
- privateKey: priv,
55
- };
56
- }
57
44
  /**
58
45
  * Compute X25519 shared secret from raw 32-byte keys.
59
46
  *
@@ -2,5 +2,4 @@
2
2
  * LINE E2EE - exports
3
3
  */
4
4
  export { decryptKeyChain } from './bootstrap/keychain.js';
5
- export { aesDecrypt, generateKeyPair, sha256, xorHalves, } from './crypto/crypto-primitives.js';
6
5
  export { KeyManager as E2EEKeyManager } from './keys/key-manager.js';