@rikaidev/yomi 0.4.2 → 0.6.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 +46 -4
- package/dist/ambient/ambient-event-graph.js +172 -0
- package/dist/ambient/ambient-observer.js +71 -0
- package/dist/ambient/event-graph-reducer.js +300 -0
- package/dist/ambient/ingestion-adapter.js +203 -0
- package/dist/ambient/journal/crypto.js +58 -0
- package/dist/ambient/journal/journal-store.js +284 -0
- package/dist/ambient/journal/types.js +7 -0
- package/dist/ambient/summaries/retriever.js +55 -0
- package/dist/ambient/summaries/summary-engine.js +128 -0
- package/dist/ambient/summaries/types.js +4 -0
- package/dist/attention/policy.js +101 -0
- package/dist/auth/credential-store.js +47 -17
- package/dist/auth/keychain.js +7 -54
- package/dist/cli/query.js +875 -0
- package/dist/cli/work-item.js +24 -0
- package/dist/insight/relationships.js +1 -1
- package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
- package/dist/line/auth/pwless/index.js +1 -1
- package/dist/line/client/auth-service/client.js +7 -8
- package/dist/line/client/buddy-service/client.js +73 -0
- package/dist/line/client/index.js +28 -3
- package/dist/line/client/obs-media-client.js +38 -0
- package/dist/line/client/parsers.js +1 -0
- package/dist/line/client/square-service/client.js +298 -0
- package/dist/line/client/sticker-meta.js +32 -0
- package/dist/line/client/sync-service/client.js +31 -3
- package/dist/line/client/talk-service/command-client.js +9 -1
- package/dist/line/client/talk-service/contact-query.js +8 -1
- package/dist/line/client/talk-service/message-box-query.js +17 -0
- package/dist/line/client/talk-service/message-id-query.js +27 -3
- package/dist/line/client/talk-service/query-client.js +32 -26
- package/dist/line/client/talk-service/requests.js +10 -11
- package/dist/line/core/auth-session-runtime.js +17 -2
- package/dist/line/core/chat-event.js +35 -0
- package/dist/line/core/chat-runtime-service.js +18 -23
- package/dist/line/core/config.js +1 -0
- package/dist/line/core/directory-runtime-service.js +10 -2
- package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
- package/dist/line/core/flex-message.js +453 -0
- package/dist/line/core/mention.js +1 -1
- package/dist/line/core/message-preview.js +60 -0
- package/dist/line/core/message-query-service.js +0 -20
- package/dist/line/core/service.js +1 -3
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +91 -4
- package/dist/mcp/handlers/login.js +1 -1
- package/dist/mcp/handlers/message-actions.js +1 -1
- package/dist/mcp/handlers/search.js +1 -1
- package/dist/mcp/handlers/send.js +8 -11
- package/dist/mcp/handlers/shared.js +1 -1
- package/dist/mcp/integrator.js +168 -0
- package/dist/mcp/media.js +135 -4
- package/dist/mcp/names.js +78 -6
- package/dist/mcp/phi-guard.js +2 -2
- package/dist/search/bigram.js +2 -2
- package/dist/search/collector.js +1 -1
- package/dist/search/default-embedder.js +12 -3
- package/dist/search/embedder.js +1 -1
- package/dist/version.js +1 -1
- package/dist/work-item/adapter.js +1 -0
- package/dist/work-item/destination-contracts.js +1 -0
- package/dist/work-item/event-context.js +73 -0
- package/dist/work-item/gold-contract.js +101 -0
- package/dist/work-item/gold-coverage.js +96 -0
- package/dist/work-item/gold-derivation.js +67 -0
- package/dist/work-item/gold-split.js +143 -0
- package/dist/work-item/index.js +23 -0
- package/dist/work-item/line-adapter.js +111 -0
- package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
- package/dist/work-item/local-provider.js +38 -0
- package/dist/work-item/local-work-event-provider.js +56 -0
- package/dist/work-item/onnx-work-event-adapter.js +223 -0
- package/dist/work-item/provider.js +21 -0
- package/dist/work-item/registry.js +19 -0
- package/dist/work-item/service.js +85 -0
- package/dist/work-item/store.js +97 -0
- package/dist/work-item/types.js +79 -0
- package/dist/work-item/work-event-graph-engine.js +63 -0
- package/dist/work-item/work-event-inference.js +267 -0
- package/dist/work-item/work-event-matcher.js +105 -0
- package/dist/work-item/work-event-model-manifest.js +39 -0
- package/dist/work-item/work-event-store.js +125 -0
- package/dist/work-item/work-event.js +123 -0
- package/package.json +26 -2
- package/run.mjs +371 -2
package/README.md
CHANGED
|
@@ -51,6 +51,34 @@ Guides: **[What is a LINE MCP server?](https://rikaidev.github.io/yomi/line-mcp/
|
|
|
51
51
|
|
|
52
52
|
## Getting started
|
|
53
53
|
|
|
54
|
+
### Native Desktop preview
|
|
55
|
+
|
|
56
|
+
Yomi Desktop puts the LINE inbox and local agent workspace in one native app.
|
|
57
|
+
It bundles its own runtime: **users do not install Node, open Terminal, clone
|
|
58
|
+
this repository, or configure `YOMI_RUN_MJS`.** Native Desktop is separate from
|
|
59
|
+
the Claude Desktop MCPB extension described below.
|
|
60
|
+
|
|
61
|
+
| Platform | General-user package | Signing status | Install experience |
|
|
62
|
+
| --- | --- | --- | --- |
|
|
63
|
+
| macOS 14+, Apple Silicon | `Yomi-Desktop-macOS-arm64-<version>.dmg` | Developer ID signed and Apple notarized | Open DMG → drag Yomi to Applications → open Yomi |
|
|
64
|
+
| Windows 10/11 x64 | `Yomi-Desktop-Windows-x64-<version>-Setup.exe` | Trusted OSS signing application in progress | Download remains unavailable until signing and installer smoke pass |
|
|
65
|
+
|
|
66
|
+
The macOS package has been tested through the real downloaded-app path:
|
|
67
|
+
quarantined DMG, install to `/Applications`, Gatekeeper assessment, first
|
|
68
|
+
launch, bundled-runtime message refresh, and no-terminal LINE login form. The
|
|
69
|
+
Windows installer contains the equivalent in-app phone/PIN login flow and is
|
|
70
|
+
tested on a native Windows runner before release.
|
|
71
|
+
|
|
72
|
+
Desktop builds are experimental and unofficial. LINE changes may break them,
|
|
73
|
+
and running an additional client may put an account at risk. Prefer a test
|
|
74
|
+
account and keep a current backup. Preview installers are published as GitHub
|
|
75
|
+
pre-releases only after platform signing is available.
|
|
76
|
+
|
|
77
|
+
See the [desktop release process](DESKTOP-RELEASING.md),
|
|
78
|
+
[code-signing policy](CODE_SIGNING.md), and [privacy policy](PRIVACY.md).
|
|
79
|
+
|
|
80
|
+
### MCP server and Desktop Extension
|
|
81
|
+
|
|
54
82
|
You need [Node.js](https://nodejs.org) and a LINE account. Yomi runs locally
|
|
55
83
|
through `npx`; you do not need to clone this repository, install Bun, or build
|
|
56
84
|
anything. Run `node --version` first, and install the current LTS if the command
|
|
@@ -479,6 +507,11 @@ fake success.
|
|
|
479
507
|
this device a sign-in prompt — this is the single most common reason a first login
|
|
480
508
|
appears to hang.
|
|
481
509
|
|
|
510
|
+
> **⚠️ Single Desktop Session Limit:** LINE allows only **one desktop client session at a time**
|
|
511
|
+
> per account. Because Yomi connects as a desktop secondary device (`DESKTOPMAC`), logging into Yomi
|
|
512
|
+
> will sign out your official LINE Desktop app (and signing back into LINE Desktop will invalidate
|
|
513
|
+
> Yomi's session). You cannot use Yomi and the official LINE Desktop client simultaneously.
|
|
514
|
+
|
|
482
515
|
You only need to give the agent your phone number in E.164 form — it supplies the
|
|
483
516
|
region itself (e.g. `TW` for a `+886` number) when it calls the tool.
|
|
484
517
|
|
|
@@ -516,16 +549,16 @@ entirely**.
|
|
|
516
549
|
### Sessions and credentials
|
|
517
550
|
|
|
518
551
|
Yomi owns its own login. On startup it calls `resumeSession()` once, reading the
|
|
519
|
-
LINE session from the macOS Keychain (service `
|
|
552
|
+
LINE session from the macOS Keychain (service `dev.rikai.yomi.credentials`, account
|
|
520
553
|
`line`) and silently refreshing the token if needed.
|
|
521
554
|
|
|
522
555
|
- **First-party credentials.** The passwordless login persists the auth token,
|
|
523
556
|
refresh token, certificate, MID, and the E2EE keypair itself — then reads them
|
|
524
557
|
back to verify the write actually landed. A login that can't be persisted fails
|
|
525
558
|
loudly at login, not silently at the next restart.
|
|
526
|
-
- **
|
|
527
|
-
|
|
528
|
-
|
|
559
|
+
- **Shared session.** The session is stored in the canonical `dev.rikai.yomi.credentials`
|
|
560
|
+
keychain entry (or local credential store), allowing Yomi MCP and Yomi Desktop to
|
|
561
|
+
share the exact same LINE login session seamlessly.
|
|
529
562
|
- **Platform note.** On macOS the session lives in the login Keychain. On **Linux
|
|
530
563
|
and Windows** Yomi currently falls back to a local JSON file — functional, but
|
|
531
564
|
less protected than an OS secret store, and less exercised than the macOS path.
|
|
@@ -590,6 +623,15 @@ Yomi runs as a **secondary device** on your account. That shapes what it can see
|
|
|
590
623
|
per-epoch group keys, not a bug here. It can also cut sideways: the epoch your phone
|
|
591
624
|
holds and the epoch Yomi holds need not be the same, so the **two devices can each
|
|
592
625
|
read a different slice** of the very same group.
|
|
626
|
+
- **Installing Yomi does not itself rotate anything, so "before I installed Yomi" is
|
|
627
|
+
the wrong boundary.** Yomi only ever *resolves* an existing group key; it never
|
|
628
|
+
registers (mints) one, because minting rotates the group's shared secret for every
|
|
629
|
+
member and strands every message encrypted under the previous one. So a fresh install
|
|
630
|
+
picks up the **current** key and reads back to the last rotation — which may predate
|
|
631
|
+
the install by months. The boundary is the last rekey, not your install date. And
|
|
632
|
+
once Yomi has seen an epoch it keeps that key, so a rotation *after* install leaves
|
|
633
|
+
it able to read both sides. If a group comes back undecryptable all the way to
|
|
634
|
+
**today**, that is not this limitation — please report it.
|
|
593
635
|
- **1:1 media** uses the account-level E2EE keychain, which a secondary device fully
|
|
594
636
|
possesses (LINE syncs it during pairing) — Yomi **can** decrypt 1:1 images/files it
|
|
595
637
|
can see.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
function characterBigrams(value) {
|
|
2
|
+
const normalized = (value ?? '')
|
|
3
|
+
.normalize('NFKC')
|
|
4
|
+
.toLocaleLowerCase()
|
|
5
|
+
.replace(/[\p{P}\p{S}\s]+/gu, '');
|
|
6
|
+
const values = new Set();
|
|
7
|
+
for (let index = 0; index + 1 < normalized.length; index++) {
|
|
8
|
+
values.add(normalized.slice(index, index + 2));
|
|
9
|
+
}
|
|
10
|
+
return values;
|
|
11
|
+
}
|
|
12
|
+
function overlap(left, right) {
|
|
13
|
+
const a = characterBigrams(left);
|
|
14
|
+
const b = characterBigrams(right);
|
|
15
|
+
if (a.size === 0 || b.size === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
let shared = 0;
|
|
18
|
+
for (const value of a)
|
|
19
|
+
if (b.has(value))
|
|
20
|
+
shared += 1;
|
|
21
|
+
return shared / (a.size + b.size - shared);
|
|
22
|
+
}
|
|
23
|
+
function quantile(values, fraction) {
|
|
24
|
+
if (values.length === 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
27
|
+
const index = Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction));
|
|
28
|
+
return sorted[index];
|
|
29
|
+
}
|
|
30
|
+
/** Derive a session scale from each conversation instead of a global fixed window. */
|
|
31
|
+
function conversationSessionScale(messages) {
|
|
32
|
+
const gaps = messages
|
|
33
|
+
.slice(1)
|
|
34
|
+
.map((message, index) => message.sentAt - messages[index].sentAt)
|
|
35
|
+
.filter((gap) => gap > 0);
|
|
36
|
+
const observed = quantile(gaps, 0.75) * 2.5;
|
|
37
|
+
return Math.min(90 * 60_000, Math.max(15 * 60_000, observed || 15 * 60_000));
|
|
38
|
+
}
|
|
39
|
+
function temporalAffinity(deltaMs, sessionScaleMs) {
|
|
40
|
+
return Math.exp(-Math.max(0, deltaMs) / Math.max(1, sessionScaleMs));
|
|
41
|
+
}
|
|
42
|
+
function normalizedEntropy(weights) {
|
|
43
|
+
if (weights.length <= 1)
|
|
44
|
+
return 0;
|
|
45
|
+
const exponentials = weights.map((weight) => Math.exp(weight * 4));
|
|
46
|
+
const total = exponentials.reduce((sum, value) => sum + value, 0);
|
|
47
|
+
const probabilities = exponentials.map((value) => value / total);
|
|
48
|
+
const entropy = -probabilities.reduce((sum, value) => sum + (value > 0 ? value * Math.log(value) : 0), 0);
|
|
49
|
+
return entropy / Math.log(probabilities.length);
|
|
50
|
+
}
|
|
51
|
+
function scoreParent(parent, child, sessionScaleMs, consecutive) {
|
|
52
|
+
const reasons = [];
|
|
53
|
+
const direct = child.chatId.startsWith('u');
|
|
54
|
+
const alternating = parent.isSelf !== child.isSelf;
|
|
55
|
+
const parentQuestion = /[??]/.test(parent.text ?? '');
|
|
56
|
+
const delta = child.sentAt - parent.sentAt;
|
|
57
|
+
const withinSession = delta >= 0 && delta <= sessionScaleMs;
|
|
58
|
+
let weight = 0;
|
|
59
|
+
if (withinSession && consecutive && alternating) {
|
|
60
|
+
weight += 0.18 + 0.2 * temporalAffinity(delta, sessionScaleMs);
|
|
61
|
+
weight += direct ? 0.24 : 0.16;
|
|
62
|
+
reasons.push(direct ? 'dyadic_turn' : 'local_turn_exchange');
|
|
63
|
+
}
|
|
64
|
+
else if (withinSession && consecutive && direct) {
|
|
65
|
+
weight += 0.2 + 0.16 * temporalAffinity(delta, sessionScaleMs);
|
|
66
|
+
reasons.push('dyadic_continuation');
|
|
67
|
+
}
|
|
68
|
+
else if (withinSession &&
|
|
69
|
+
consecutive &&
|
|
70
|
+
parent.isSelf === true &&
|
|
71
|
+
child.isSelf === true) {
|
|
72
|
+
weight += 0.2 + 0.16 * temporalAffinity(delta, sessionScaleMs);
|
|
73
|
+
reasons.push('self_continuation');
|
|
74
|
+
}
|
|
75
|
+
if (parentQuestion && alternating) {
|
|
76
|
+
weight += direct ? 0.52 : 0.34;
|
|
77
|
+
reasons.push('adjacency_pair');
|
|
78
|
+
}
|
|
79
|
+
if (parent.senderName && child.mentionNames?.includes(parent.senderName)) {
|
|
80
|
+
weight += 0.52;
|
|
81
|
+
reasons.push('mentions_parent');
|
|
82
|
+
}
|
|
83
|
+
if (child.senderName && parent.mentionNames?.includes(child.senderName)) {
|
|
84
|
+
weight += 0.72;
|
|
85
|
+
reasons.push('addressed_responder');
|
|
86
|
+
}
|
|
87
|
+
if (parent.mentionsSelf && child.isSelf) {
|
|
88
|
+
weight += 0.7;
|
|
89
|
+
reasons.push('self_answers_mention');
|
|
90
|
+
}
|
|
91
|
+
const semanticOverlap = withinSession ? overlap(parent.text, child.text) : 0;
|
|
92
|
+
if (parent.senderName &&
|
|
93
|
+
parent.senderName === child.senderName &&
|
|
94
|
+
(semanticOverlap >= 0.08 || parent.mediaType || child.mediaType)) {
|
|
95
|
+
weight += 0.18;
|
|
96
|
+
reasons.push('same_speaker_continuation');
|
|
97
|
+
}
|
|
98
|
+
if (semanticOverlap > 0) {
|
|
99
|
+
weight += Math.min(0.28, semanticOverlap * 0.4);
|
|
100
|
+
reasons.push('lexical_overlap');
|
|
101
|
+
}
|
|
102
|
+
return { weight, reasons };
|
|
103
|
+
}
|
|
104
|
+
export function inferAmbientEventGraph(messages) {
|
|
105
|
+
const edges = [];
|
|
106
|
+
const byChat = new Map();
|
|
107
|
+
for (const message of messages) {
|
|
108
|
+
const rows = byChat.get(message.chatId) ?? [];
|
|
109
|
+
rows.push(message);
|
|
110
|
+
byChat.set(message.chatId, rows);
|
|
111
|
+
}
|
|
112
|
+
for (const rows of byChat.values()) {
|
|
113
|
+
const sorted = [...rows].sort((a, b) => a.sentAt - b.sentAt);
|
|
114
|
+
const sessionScaleMs = conversationSessionScale(sorted);
|
|
115
|
+
for (let index = 1; index < sorted.length; index++) {
|
|
116
|
+
const child = sorted[index];
|
|
117
|
+
const candidates = sorted
|
|
118
|
+
.slice(Math.max(0, index - 100), index)
|
|
119
|
+
.map((parent, candidateIndex, candidates) => ({
|
|
120
|
+
parent,
|
|
121
|
+
...scoreParent(parent, child, sessionScaleMs, candidateIndex === candidates.length - 1),
|
|
122
|
+
}))
|
|
123
|
+
.filter((candidate) => candidate.weight >= 0.3)
|
|
124
|
+
.sort((a, b) => b.weight - a.weight);
|
|
125
|
+
if (candidates.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
const entropy = normalizedEntropy(candidates.slice(0, 8).map((candidate) => candidate.weight));
|
|
128
|
+
const best = candidates[0];
|
|
129
|
+
const selected = [
|
|
130
|
+
best,
|
|
131
|
+
...(entropy >= 0.72
|
|
132
|
+
? candidates
|
|
133
|
+
.slice(1)
|
|
134
|
+
.filter((candidate) => best.weight - candidate.weight <= 0.08)
|
|
135
|
+
.slice(0, 1)
|
|
136
|
+
: []),
|
|
137
|
+
];
|
|
138
|
+
for (const candidate of selected) {
|
|
139
|
+
edges.push({
|
|
140
|
+
parentId: candidate.parent.messageId,
|
|
141
|
+
childId: child.messageId,
|
|
142
|
+
weight: Number(candidate.weight.toFixed(4)),
|
|
143
|
+
entropy: Number(entropy.toFixed(4)),
|
|
144
|
+
reasons: candidate.reasons,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const children = new Map();
|
|
150
|
+
for (const edge of edges) {
|
|
151
|
+
const values = children.get(edge.parentId) ?? [];
|
|
152
|
+
values.push(edge.childId);
|
|
153
|
+
children.set(edge.parentId, values);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
edges,
|
|
157
|
+
descendants(messageIds) {
|
|
158
|
+
const found = new Set();
|
|
159
|
+
const queue = [...messageIds];
|
|
160
|
+
while (queue.length > 0) {
|
|
161
|
+
const current = queue.shift();
|
|
162
|
+
for (const child of children.get(current) ?? []) {
|
|
163
|
+
if (found.has(child))
|
|
164
|
+
continue;
|
|
165
|
+
found.add(child);
|
|
166
|
+
queue.push(child);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
2
|
+
import { LineProtocolService } from '../line/core/service.js';
|
|
3
|
+
import { MAX_LOOKBACK_MS, reduceAmbientObservations, } from './event-graph-reducer.js';
|
|
4
|
+
import { LineIngestionAdapter } from './ingestion-adapter.js';
|
|
5
|
+
function mediaType(contentType) {
|
|
6
|
+
return ({
|
|
7
|
+
1: 'image',
|
|
8
|
+
2: 'video',
|
|
9
|
+
3: 'audio',
|
|
10
|
+
7: 'sticker',
|
|
11
|
+
14: 'file',
|
|
12
|
+
}[contentType] ?? null);
|
|
13
|
+
}
|
|
14
|
+
function mentionMids(metadata) {
|
|
15
|
+
try {
|
|
16
|
+
const value = typeof metadata?.MENTION === 'string'
|
|
17
|
+
? JSON.parse(metadata.MENTION)
|
|
18
|
+
: metadata?.MENTION;
|
|
19
|
+
const mentions = value?.MENTIONEES ?? value?.MENTIONS ?? [];
|
|
20
|
+
return mentions
|
|
21
|
+
.map((item) => item?.M ?? item?.mid)
|
|
22
|
+
.filter((mid) => typeof mid === 'string');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function normalizeSquareObservationRows(chat, messages) {
|
|
29
|
+
const senderNames = new Map(messages
|
|
30
|
+
.filter((message) => message?.fromMid && message?.fromName)
|
|
31
|
+
.map((message) => [String(message.fromMid), String(message.fromName)]));
|
|
32
|
+
return messages.map((message) => {
|
|
33
|
+
const type = mediaType(Number(message.contentType ?? 0));
|
|
34
|
+
const mentions = mentionMids(message.contentMetadata);
|
|
35
|
+
return {
|
|
36
|
+
messageId: String(message.id),
|
|
37
|
+
chatId: chat.id,
|
|
38
|
+
senderName: message.fromName ? String(message.fromName) : null,
|
|
39
|
+
sentAt: Number(message.createdTime ?? 0),
|
|
40
|
+
text: sanitizeMessagePreview(message.text) || null,
|
|
41
|
+
mediaType: type,
|
|
42
|
+
fileName: message.fileName ? String(message.fileName) : null,
|
|
43
|
+
ocrEligible: true,
|
|
44
|
+
isSelf: message.isSelf === true,
|
|
45
|
+
mentionsSelf: Boolean(chat.memberMid && mentions.includes(String(chat.memberMid))),
|
|
46
|
+
sourceUrl: type === 'sticker' && message.contentMetadata?.STKID
|
|
47
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${message.contentMetadata.STKID}/android/sticker.png`
|
|
48
|
+
: null,
|
|
49
|
+
mentionNames: mentions
|
|
50
|
+
.map((mid) => senderNames.get(mid))
|
|
51
|
+
.filter((name) => Boolean(name)),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function buildAmbientObservations(rows, chatNames, now = Date.now(), requestedWindowStart = now - MAX_LOOKBACK_MS) {
|
|
56
|
+
return reduceAmbientObservations(rows, chatNames, now, requestedWindowStart);
|
|
57
|
+
}
|
|
58
|
+
export async function scanAmbientObservations(requestedWindowStart, now = Date.now()) {
|
|
59
|
+
const service = new LineProtocolService();
|
|
60
|
+
if (!(await service.resumeSession()))
|
|
61
|
+
throw new Error('LINE session unavailable');
|
|
62
|
+
const windowStart = Math.max(requestedWindowStart ?? now - MAX_LOOKBACK_MS, now - MAX_LOOKBACK_MS);
|
|
63
|
+
const adapter = new LineIngestionAdapter(service);
|
|
64
|
+
const ingestion = await adapter.collectRecentEvents({ windowStart });
|
|
65
|
+
const report = reduceAmbientObservations(ingestion.events, ingestion.chatNames, now, windowStart);
|
|
66
|
+
report.coverageLimited = ingestion.coverageLimited;
|
|
67
|
+
report.chatsScanned = ingestion.chatsScanned;
|
|
68
|
+
report.messagesScanned = ingestion.messagesScanned;
|
|
69
|
+
report.officialChatsSkipped = ingestion.officialChatsSkipped;
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { inferAmbientEventGraph } from './ambient-event-graph.js';
|
|
3
|
+
export const MAX_LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000;
|
|
4
|
+
const EPISODE_GAP_MS = 45 * 60 * 1000;
|
|
5
|
+
const MEDIA_ATTACH_GAP_MS = 5 * 60 * 1000;
|
|
6
|
+
const STICKER_RESPONSE_GAP_MS = 30 * 60 * 1000;
|
|
7
|
+
const SEMANTIC_THRESHOLD = 0.58;
|
|
8
|
+
export function toWorkingEvidence(event) {
|
|
9
|
+
return {
|
|
10
|
+
messageId: event.messageId,
|
|
11
|
+
chatId: event.chatId,
|
|
12
|
+
senderName: event.senderName,
|
|
13
|
+
sentAt: event.sentAt,
|
|
14
|
+
text: event.text,
|
|
15
|
+
mediaType: event.contentType,
|
|
16
|
+
fileName: event.metadata.fileName ?? null,
|
|
17
|
+
ocrEligible: event.ocrEligible,
|
|
18
|
+
isSelf: event.isSelf,
|
|
19
|
+
mentionsSelf: event.mentionsSelf,
|
|
20
|
+
sourceUrl: event.metadata.sourceUrl ?? null,
|
|
21
|
+
mentionNames: event.metadata.mentionNames,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function cosine(a, b) {
|
|
25
|
+
let dot = 0;
|
|
26
|
+
let aa = 0;
|
|
27
|
+
let bb = 0;
|
|
28
|
+
const length = Math.min(a.length, b.length);
|
|
29
|
+
for (let index = 0; index < length; index++) {
|
|
30
|
+
dot += a[index] * b[index];
|
|
31
|
+
aa += a[index] * a[index];
|
|
32
|
+
bb += b[index] * b[index];
|
|
33
|
+
}
|
|
34
|
+
return aa && bb ? dot / Math.sqrt(aa * bb) : 0;
|
|
35
|
+
}
|
|
36
|
+
function observationId(chatId, firstMessageId) {
|
|
37
|
+
return createHash('sha256')
|
|
38
|
+
.update(`${chatId}\0${firstMessageId}`)
|
|
39
|
+
.digest('hex')
|
|
40
|
+
.slice(0, 20);
|
|
41
|
+
}
|
|
42
|
+
function titleFor(evidence) {
|
|
43
|
+
const text = evidence
|
|
44
|
+
.find((item) => item.text)
|
|
45
|
+
?.text?.replace(/\s+/g, ' ')
|
|
46
|
+
.trim();
|
|
47
|
+
if (text)
|
|
48
|
+
return text.slice(0, 72);
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
function semanticClusters(rows) {
|
|
52
|
+
const clusters = [];
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (!row.vector) {
|
|
55
|
+
if (row.mediaType === 'sticker' && row.isSelf !== true) {
|
|
56
|
+
const questionCluster = [...clusters].reverse().find((cluster) => {
|
|
57
|
+
const last = cluster.rows[cluster.rows.length - 1];
|
|
58
|
+
return (row.sentAt - last.sentAt <= STICKER_RESPONSE_GAP_MS &&
|
|
59
|
+
cluster.rows.some((item) => item.isSelf === true && /[??]/.test(item.text ?? '')));
|
|
60
|
+
});
|
|
61
|
+
if (questionCluster) {
|
|
62
|
+
questionCluster.rows.push(row);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const handoffCluster = [...clusters].reverse().find((cluster) => {
|
|
66
|
+
const last = cluster.rows[cluster.rows.length - 1];
|
|
67
|
+
return (row.sentAt - last.sentAt <= STICKER_RESPONSE_GAP_MS &&
|
|
68
|
+
cluster.rows.some((item) => ['image', 'video', 'file'].includes(item.mediaType ?? '') &&
|
|
69
|
+
item.senderName !== row.senderName));
|
|
70
|
+
});
|
|
71
|
+
if (handoffCluster) {
|
|
72
|
+
handoffCluster.rows.push(row);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const nearest = [...clusters]
|
|
77
|
+
.reverse()
|
|
78
|
+
.find((cluster) => row.sentAt - cluster.rows[cluster.rows.length - 1].sentAt <=
|
|
79
|
+
MEDIA_ATTACH_GAP_MS);
|
|
80
|
+
if (nearest)
|
|
81
|
+
nearest.rows.push(row);
|
|
82
|
+
else
|
|
83
|
+
clusters.push({ rows: [row], vectorCount: 0 });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let best;
|
|
87
|
+
let bestScore = -1;
|
|
88
|
+
for (const cluster of clusters) {
|
|
89
|
+
if (!cluster.centroid)
|
|
90
|
+
continue;
|
|
91
|
+
const score = cosine(row.vector, cluster.centroid);
|
|
92
|
+
if (score > bestScore) {
|
|
93
|
+
bestScore = score;
|
|
94
|
+
best = cluster;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!best || bestScore < SEMANTIC_THRESHOLD) {
|
|
98
|
+
clusters.push({ rows: [row], centroid: [...row.vector], vectorCount: 1 });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
best.rows.push(row);
|
|
102
|
+
const count = best.vectorCount + 1;
|
|
103
|
+
best.centroid = best.centroid?.map((value, index) => (value * best.vectorCount + row.vector[index]) / count);
|
|
104
|
+
best.vectorCount = count;
|
|
105
|
+
}
|
|
106
|
+
return clusters.map((cluster) => cluster.rows);
|
|
107
|
+
}
|
|
108
|
+
export function reduceAmbientObservations(inputRows, chatNames, now = Date.now(), requestedWindowStart = now - MAX_LOOKBACK_MS) {
|
|
109
|
+
const rows = inputRows.map((r) => 'eventId' in r ? toWorkingEvidence(r) : r);
|
|
110
|
+
const windowStart = Math.max(requestedWindowStart, now - MAX_LOOKBACK_MS);
|
|
111
|
+
const groups = new Map();
|
|
112
|
+
for (const row of rows.filter((item) => item.sentAt >= windowStart)) {
|
|
113
|
+
const values = groups.get(row.chatId) ?? [];
|
|
114
|
+
values.push(row);
|
|
115
|
+
groups.set(row.chatId, values);
|
|
116
|
+
}
|
|
117
|
+
const candidates = [];
|
|
118
|
+
for (const [chatId, unsorted] of groups) {
|
|
119
|
+
const sorted = [...unsorted].sort((a, b) => a.sentAt - b.sentAt);
|
|
120
|
+
let episode = [];
|
|
121
|
+
const flush = () => {
|
|
122
|
+
if (episode.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
for (const cluster of semanticClusters(episode)) {
|
|
125
|
+
const imageCount = cluster.filter((item) => item.mediaType === 'image').length;
|
|
126
|
+
const ownerHints = [
|
|
127
|
+
...new Set(cluster.flatMap((item) => item.mentionNames ?? [])),
|
|
128
|
+
];
|
|
129
|
+
const hasQuestionMark = cluster.some((item) => /[??]/.test(item.text ?? ''));
|
|
130
|
+
const outboundQuestion = cluster.some((item) => item.isSelf === true && /[??]/.test(item.text ?? ''));
|
|
131
|
+
const inboundDirectQuestion = chatId.startsWith('u') &&
|
|
132
|
+
cluster.some((item) => item.isSelf !== true && /[??]/.test(item.text ?? ''));
|
|
133
|
+
const outboundQuestionIndex = cluster.findIndex((item) => item.isSelf === true && /[??]/.test(item.text ?? ''));
|
|
134
|
+
const receivedResponse = outboundQuestionIndex >= 0 &&
|
|
135
|
+
cluster
|
|
136
|
+
.slice(outboundQuestionIndex + 1)
|
|
137
|
+
.some((item) => item.isSelf !== true);
|
|
138
|
+
const mediaIndex = cluster.findIndex((item) => ['image', 'video', 'file'].includes(item.mediaType ?? ''));
|
|
139
|
+
const mediaHandoff = mediaIndex >= 0 &&
|
|
140
|
+
cluster
|
|
141
|
+
.slice(mediaIndex + 1)
|
|
142
|
+
.some((item) => item.senderName !== cluster[mediaIndex].senderName);
|
|
143
|
+
const selfMediaHandoff = mediaIndex >= 0 &&
|
|
144
|
+
cluster[mediaIndex].isSelf === true &&
|
|
145
|
+
cluster.slice(mediaIndex + 1).some((item) => item.isSelf !== true);
|
|
146
|
+
const directedResponse = cluster.length >= 2 &&
|
|
147
|
+
(ownerHints.length > 0 || hasQuestionMark) &&
|
|
148
|
+
cluster
|
|
149
|
+
.slice(1)
|
|
150
|
+
.some((item) => item.senderName !== cluster[0].senderName);
|
|
151
|
+
const mentionsSelf = cluster.some((item) => item.mentionsSelf === true);
|
|
152
|
+
const senders = new Set(cluster.map((item) => item.senderName).filter(Boolean));
|
|
153
|
+
const vectorRows = cluster.filter((item) => item.vector);
|
|
154
|
+
const coherence = vectorRows.length >= 2
|
|
155
|
+
? vectorRows
|
|
156
|
+
.slice(1)
|
|
157
|
+
.reduce((sum, item, index) => sum + cosine(vectorRows[index].vector, item.vector), 0) /
|
|
158
|
+
(vectorRows.length - 1)
|
|
159
|
+
: 0;
|
|
160
|
+
const reasons = [
|
|
161
|
+
...(cluster.length >= 3 ? ['message_burst'] : []),
|
|
162
|
+
...(imageCount >= 3 ? ['media_burst'] : []),
|
|
163
|
+
...(ownerHints.length > 0 ? ['explicit_mention'] : []),
|
|
164
|
+
...(mentionsSelf ? ['mentions_self'] : []),
|
|
165
|
+
...(hasQuestionMark ? ['question_mark'] : []),
|
|
166
|
+
...(outboundQuestion ? ['outbound_question'] : []),
|
|
167
|
+
...(inboundDirectQuestion ? ['direct_message_question'] : []),
|
|
168
|
+
...(receivedResponse ? ['response_received'] : []),
|
|
169
|
+
...(mediaHandoff ? ['media_handoff'] : []),
|
|
170
|
+
...(directedResponse ? ['directed_response'] : []),
|
|
171
|
+
...(senders.size >= 2 ? ['multi_party'] : []),
|
|
172
|
+
...(coherence >= 0.65 ? ['semantic_coherence'] : []),
|
|
173
|
+
];
|
|
174
|
+
const qualifies = mentionsSelf ||
|
|
175
|
+
outboundQuestion ||
|
|
176
|
+
inboundDirectQuestion ||
|
|
177
|
+
selfMediaHandoff ||
|
|
178
|
+
(vectorRows.length >= 2 && coherence >= 0.72);
|
|
179
|
+
if (!qualifies)
|
|
180
|
+
continue;
|
|
181
|
+
const startedAt = cluster[0].sentAt;
|
|
182
|
+
const updatedAt = cluster[cluster.length - 1].sentAt;
|
|
183
|
+
const confidence = Math.min(0.92, 0.3 +
|
|
184
|
+
Math.min(0.18, cluster.length * 0.03) +
|
|
185
|
+
(imageCount >= 3 ? 0.14 : 0) +
|
|
186
|
+
(mediaHandoff ? 0.12 : 0) +
|
|
187
|
+
(inboundDirectQuestion ? 0.12 : 0) +
|
|
188
|
+
(mentionsSelf ? 0.16 : ownerHints.length > 0 ? 0.04 : 0) +
|
|
189
|
+
(senders.size >= 2 ? 0.08 : 0) +
|
|
190
|
+
coherence * 0.18);
|
|
191
|
+
candidates.push({
|
|
192
|
+
id: observationId(chatId, cluster[0].messageId),
|
|
193
|
+
chatId,
|
|
194
|
+
chatName: chatNames.get(chatId) ?? 'LINE conversation',
|
|
195
|
+
topic: 'unclassified',
|
|
196
|
+
title: titleFor(cluster),
|
|
197
|
+
startedAt,
|
|
198
|
+
updatedAt,
|
|
199
|
+
confidence: Number(confidence.toFixed(2)),
|
|
200
|
+
status: 'candidate',
|
|
201
|
+
ownerHints,
|
|
202
|
+
relevance: mentionsSelf || outboundQuestion || inboundDirectQuestion
|
|
203
|
+
? 'self'
|
|
204
|
+
: ownerHints.length > 0 ||
|
|
205
|
+
(mediaHandoff &&
|
|
206
|
+
!cluster.some((item) => item.isSelf === true))
|
|
207
|
+
? 'other'
|
|
208
|
+
: 'unassigned',
|
|
209
|
+
responseState: receivedResponse || mediaHandoff || directedResponse
|
|
210
|
+
? 'received'
|
|
211
|
+
: outboundQuestion || inboundDirectQuestion
|
|
212
|
+
? 'waiting'
|
|
213
|
+
: 'observing',
|
|
214
|
+
reasons,
|
|
215
|
+
evidence: cluster
|
|
216
|
+
.slice(-20)
|
|
217
|
+
.map(({ mentionNames: _mentions, vector: _vector, ...item }) => item),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
episode = [];
|
|
221
|
+
};
|
|
222
|
+
for (const row of sorted) {
|
|
223
|
+
if (episode.length > 0 &&
|
|
224
|
+
row.sentAt - episode[episode.length - 1].sentAt > EPISODE_GAP_MS) {
|
|
225
|
+
flush();
|
|
226
|
+
}
|
|
227
|
+
episode.push(row);
|
|
228
|
+
}
|
|
229
|
+
flush();
|
|
230
|
+
}
|
|
231
|
+
const candidateMessageIds = new Set(candidates.flatMap((candidate) => candidate.evidence.map((item) => item.messageId)));
|
|
232
|
+
for (const row of rows.filter((item) => item.mediaType === 'file' && !candidateMessageIds.has(item.messageId))) {
|
|
233
|
+
const target = candidates
|
|
234
|
+
.filter((candidate) => candidate.chatId === row.chatId &&
|
|
235
|
+
row.sentAt >= candidate.startedAt &&
|
|
236
|
+
row.sentAt - candidate.updatedAt <= 48 * 60 * 60 * 1000)
|
|
237
|
+
.filter((candidate) => candidate.evidence.some((item) => /請|麻煩|協助|撰寫|繳交|進度|報告|提供/.test(item.text ?? '')))
|
|
238
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
239
|
+
if (!target)
|
|
240
|
+
continue;
|
|
241
|
+
const { mentionNames: _mentionNames, vector: _vector, ...evidence } = row;
|
|
242
|
+
target.evidence.push(evidence);
|
|
243
|
+
target.updatedAt = Math.max(target.updatedAt, row.sentAt);
|
|
244
|
+
target.responseState = 'received';
|
|
245
|
+
target.reasons.push('file_delivery');
|
|
246
|
+
candidateMessageIds.add(row.messageId);
|
|
247
|
+
}
|
|
248
|
+
const graph = inferAmbientEventGraph(rows);
|
|
249
|
+
const rowByID = new Map(rows.map((row) => [row.messageId, row]));
|
|
250
|
+
for (const candidate of candidates) {
|
|
251
|
+
if (candidate.responseState === 'received')
|
|
252
|
+
continue;
|
|
253
|
+
const trigger = rowByID.get(candidate.evidence[0]?.messageId);
|
|
254
|
+
if (!trigger)
|
|
255
|
+
continue;
|
|
256
|
+
const descendantIDs = graph.descendants([trigger.messageId]);
|
|
257
|
+
const responseRows = [...descendantIDs]
|
|
258
|
+
.map((id) => rowByID.get(id))
|
|
259
|
+
.filter((row) => Boolean(row))
|
|
260
|
+
.sort((a, b) => a.sentAt - b.sentAt);
|
|
261
|
+
const inboundDirectQuestion = trigger.chatId.startsWith('u') &&
|
|
262
|
+
trigger.isSelf !== true &&
|
|
263
|
+
/[??]/.test(trigger.text ?? '');
|
|
264
|
+
const outboundQuestion = trigger.isSelf === true && /[??]/.test(trigger.text ?? '');
|
|
265
|
+
const response = responseRows.find((row) => {
|
|
266
|
+
if (inboundDirectQuestion || trigger.mentionsSelf)
|
|
267
|
+
return row.isSelf === true;
|
|
268
|
+
if (outboundQuestion)
|
|
269
|
+
return row.isSelf !== true;
|
|
270
|
+
return (row.senderName !== null && candidate.ownerHints.includes(row.senderName));
|
|
271
|
+
});
|
|
272
|
+
if (!response)
|
|
273
|
+
continue;
|
|
274
|
+
const existingIDs = new Set(candidate.evidence.map((item) => item.messageId));
|
|
275
|
+
candidate.evidence.push(...responseRows
|
|
276
|
+
.filter((row) => !existingIDs.has(row.messageId))
|
|
277
|
+
.slice(0, 6)
|
|
278
|
+
.map(({ mentionNames: _mentions, vector: _vector, ...row }) => row));
|
|
279
|
+
candidate.updatedAt = Math.max(candidate.updatedAt, ...responseRows.map((row) => row.sentAt));
|
|
280
|
+
candidate.responseState = 'received';
|
|
281
|
+
candidate.reasons.push('graph_response_received');
|
|
282
|
+
}
|
|
283
|
+
const relevanceRank = { self: 0, unassigned: 1, other: 2 };
|
|
284
|
+
const actionRank = (candidate) => candidate.responseState === 'received'
|
|
285
|
+
? 3
|
|
286
|
+
: relevanceRank[candidate.relevance];
|
|
287
|
+
candidates.sort((a, b) => actionRank(a) - actionRank(b) ||
|
|
288
|
+
b.updatedAt - a.updatedAt ||
|
|
289
|
+
b.confidence - a.confidence);
|
|
290
|
+
return {
|
|
291
|
+
engineVersion: 16,
|
|
292
|
+
generatedAt: now,
|
|
293
|
+
windowStart,
|
|
294
|
+
coverageLimited: false,
|
|
295
|
+
chatsScanned: new Set(rows.map((row) => row.chatId)).size,
|
|
296
|
+
messagesScanned: rows.length,
|
|
297
|
+
officialChatsSkipped: 0,
|
|
298
|
+
candidates: candidates.slice(0, 24),
|
|
299
|
+
};
|
|
300
|
+
}
|