@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.
- package/README.md +14 -4
- package/dist/auth/credential-store.js +0 -34
- package/dist/insight/graph.js +90 -0
- package/dist/insight/index.js +61 -0
- package/dist/insight/pending.js +92 -0
- package/dist/insight/relationships.js +125 -0
- package/dist/insight/rhythm.js +50 -0
- package/dist/line/auth/protocol/index.js +0 -2
- package/dist/line/auth/pwless/index.js +1 -1
- package/dist/line/client/index.js +1 -1
- package/dist/line/client/talk-service/chat-query.js +3 -3
- package/dist/line/client/talk-service/contact-query.js +1 -1
- package/dist/line/client/talk-service/requests.js +1 -1
- package/dist/line/core/e2ee/crypto/crypto-primitives.js +2 -15
- package/dist/line/core/e2ee/index.js +0 -1
- package/dist/line/core/e2ee/keys/decrypt.js +0 -1
- package/dist/line/core/e2ee/keys/group-key.js +0 -1
- package/dist/line/core/e2ee/keys/key-payload.js +1 -1
- package/dist/line/core/e2ee/keys/message-decrypt.js +1 -1
- package/dist/line/core/e2ee/keys/peer-key.js +1 -1
- package/dist/line/core/media-decrypt.js +1 -1
- package/dist/line/core/mention.js +1 -1
- package/dist/line/core/message-query-service.js +2 -2
- package/dist/line/core/session-state/persist.js +1 -1
- package/dist/line/core/thrift/binary/type-codec-dsl.js +1 -1
- package/dist/line/core/thrift/compact/type-codec-dsl.js +1 -43
- package/dist/line/core/thrift/fields/builders.js +1 -11
- package/dist/line/core/thrift/index.js +1 -6
- package/dist/line/core/thrift/primitives/varint.js +1 -1
- package/dist/line/core/thrift/types.js +0 -6
- package/dist/mcp/handlers/index.js +1 -0
- package/dist/mcp/handlers/insight.js +129 -0
- package/dist/mcp/handlers/shared.js +2 -2
- package/dist/mcp/media.js +2 -2
- package/dist/mcp/names.js +1 -1
- package/dist/mcp/server.js +7 -4
- package/dist/mcp/tools.js +17 -0
- package/dist/search/collector.js +1 -1
- package/dist/search/store.js +31 -14
- package/dist/util/data-dir.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/dist/line/core/e2ee/types.js +0 -4
- package/dist/line/core/store.js +0 -8
- package/dist/line/core/thrift/binary/binary-reader.js +0 -151
- package/dist/line/core/thrift/compact/compact-reader.js +0 -180
- package/dist/line/core/thrift/compact/compact-writer.js +0 -161
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ Yomi speaks LINE's TCompact-over-HTTPS protocol directly, decrypts Letter-Sealin
|
|
|
21
21
|
(E2EE) messages and media, and exposes the result to any AI agent through a small
|
|
22
22
|
stdio [MCP](https://modelcontextprotocol.io) server. Point Claude Desktop (or any
|
|
23
23
|
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
|
|
24
|
+
reply, send an image, mention someone, and search your whole history locally — and,
|
|
25
|
+
with `get_insight`, read the *situation*: which conversations are waiting on your
|
|
26
|
+
reply, who ties your world together, and what has gone overdue.
|
|
25
27
|
|
|
26
28
|
No official API, no bot account, no webhook. Yomi logs in as a secondary device
|
|
27
29
|
on your own account.
|
|
@@ -403,9 +405,11 @@ Approve the device on your phone (see [Logging in](#logging-in)), and:
|
|
|
403
405
|
|
|
404
406
|
## What you can do
|
|
405
407
|
|
|
406
|
-
Yomi exposes **
|
|
407
|
-
|
|
408
|
-
|
|
408
|
+
Yomi exposes **20 tools** over MCP (the ones worth naming; see the tables). All
|
|
409
|
+
return JSON — except `get_insight`, which returns [TOON](https://github.com/toon-format/toon).
|
|
410
|
+
"Honest error" below means an explicit failure naming the problem (e.g.
|
|
411
|
+
`missing_decrypt_material`) — Yomi never fabricates a fallback, a placeholder, or a
|
|
412
|
+
fake success.
|
|
409
413
|
|
|
410
414
|
### Reading
|
|
411
415
|
|
|
@@ -418,6 +422,12 @@ fabricates a fallback, a placeholder, or a fake success.
|
|
|
418
422
|
| `find_contact` / `list_contacts` | Friend-list lookup by name substring, or the full list. Raw LINE data — no fuzzy scoring, no affinity ranking. |
|
|
419
423
|
| `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
424
|
|
|
425
|
+
### Insight (read the situation, not just the messages)
|
|
426
|
+
|
|
427
|
+
| Tool | Does |
|
|
428
|
+
| --- | --- |
|
|
429
|
+
| `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. |
|
|
430
|
+
|
|
421
431
|
### Writing (these really send — not drafts)
|
|
422
432
|
|
|
423
433
|
| Tool | Does |
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
*
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
async function persistPeerPublicKey(ctx, mid, keyId, data) {
|
|
63
63
|
const store = ctx.getStore();
|
|
64
64
|
const record = JSON.stringify({
|
|
65
65
|
mid,
|