@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.
- package/README.md +38 -11
- 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/contacts.js +4 -16
- package/dist/mcp/handlers/conversations.js +5 -11
- package/dist/mcp/handlers/index.js +1 -0
- package/dist/mcp/handlers/insight.js +129 -0
- package/dist/mcp/handlers/login.js +3 -3
- package/dist/mcp/handlers/scope.js +5 -11
- package/dist/mcp/handlers/search.js +32 -10
- package/dist/mcp/handlers/send.js +16 -16
- package/dist/mcp/handlers/shared.js +21 -6
- package/dist/mcp/handlers/stickers.js +3 -12
- package/dist/mcp/media.js +2 -2
- package/dist/mcp/names.js +1 -1
- package/dist/mcp/server.js +11 -4
- package/dist/mcp/tools.js +17 -0
- package/dist/search/collector.js +1 -1
- package/dist/search/store.js +58 -14
- package/dist/util/data-dir.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -3
- 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
|
@@ -6,22 +6,32 @@
|
|
|
6
6
|
</picture>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
-
# Yomi (読み)
|
|
9
|
+
# Yomi (読み) — the personal LINE MCP server
|
|
10
10
|
|
|
11
|
-
**
|
|
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 **黄泉**
|
|
16
|
-
— the realm
|
|
17
|
-
|
|
18
|
-
|
|
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 **
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
}
|
|
@@ -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
|
*
|