@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.
Files changed (86) hide show
  1. package/README.md +46 -4
  2. package/dist/ambient/ambient-event-graph.js +172 -0
  3. package/dist/ambient/ambient-observer.js +71 -0
  4. package/dist/ambient/event-graph-reducer.js +300 -0
  5. package/dist/ambient/ingestion-adapter.js +203 -0
  6. package/dist/ambient/journal/crypto.js +58 -0
  7. package/dist/ambient/journal/journal-store.js +284 -0
  8. package/dist/ambient/journal/types.js +7 -0
  9. package/dist/ambient/summaries/retriever.js +55 -0
  10. package/dist/ambient/summaries/summary-engine.js +128 -0
  11. package/dist/ambient/summaries/types.js +4 -0
  12. package/dist/attention/policy.js +101 -0
  13. package/dist/auth/credential-store.js +47 -17
  14. package/dist/auth/keychain.js +7 -54
  15. package/dist/cli/query.js +875 -0
  16. package/dist/cli/work-item.js +24 -0
  17. package/dist/insight/relationships.js +1 -1
  18. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  19. package/dist/line/auth/pwless/index.js +1 -1
  20. package/dist/line/client/auth-service/client.js +7 -8
  21. package/dist/line/client/buddy-service/client.js +73 -0
  22. package/dist/line/client/index.js +28 -3
  23. package/dist/line/client/obs-media-client.js +38 -0
  24. package/dist/line/client/parsers.js +1 -0
  25. package/dist/line/client/square-service/client.js +298 -0
  26. package/dist/line/client/sticker-meta.js +32 -0
  27. package/dist/line/client/sync-service/client.js +31 -3
  28. package/dist/line/client/talk-service/command-client.js +9 -1
  29. package/dist/line/client/talk-service/contact-query.js +8 -1
  30. package/dist/line/client/talk-service/message-box-query.js +17 -0
  31. package/dist/line/client/talk-service/message-id-query.js +27 -3
  32. package/dist/line/client/talk-service/query-client.js +32 -26
  33. package/dist/line/client/talk-service/requests.js +10 -11
  34. package/dist/line/core/auth-session-runtime.js +17 -2
  35. package/dist/line/core/chat-event.js +35 -0
  36. package/dist/line/core/chat-runtime-service.js +18 -23
  37. package/dist/line/core/config.js +1 -0
  38. package/dist/line/core/directory-runtime-service.js +10 -2
  39. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  40. package/dist/line/core/flex-message.js +453 -0
  41. package/dist/line/core/mention.js +1 -1
  42. package/dist/line/core/message-preview.js +60 -0
  43. package/dist/line/core/message-query-service.js +0 -20
  44. package/dist/line/core/service.js +1 -3
  45. package/dist/mcp/handlers/contacts.js +12 -6
  46. package/dist/mcp/handlers/conversations.js +91 -4
  47. package/dist/mcp/handlers/login.js +1 -1
  48. package/dist/mcp/handlers/message-actions.js +1 -1
  49. package/dist/mcp/handlers/search.js +1 -1
  50. package/dist/mcp/handlers/send.js +8 -11
  51. package/dist/mcp/handlers/shared.js +1 -1
  52. package/dist/mcp/integrator.js +168 -0
  53. package/dist/mcp/media.js +135 -4
  54. package/dist/mcp/names.js +78 -6
  55. package/dist/mcp/phi-guard.js +2 -2
  56. package/dist/search/bigram.js +2 -2
  57. package/dist/search/collector.js +1 -1
  58. package/dist/search/default-embedder.js +12 -3
  59. package/dist/search/embedder.js +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/work-item/adapter.js +1 -0
  62. package/dist/work-item/destination-contracts.js +1 -0
  63. package/dist/work-item/event-context.js +73 -0
  64. package/dist/work-item/gold-contract.js +101 -0
  65. package/dist/work-item/gold-coverage.js +96 -0
  66. package/dist/work-item/gold-derivation.js +67 -0
  67. package/dist/work-item/gold-split.js +143 -0
  68. package/dist/work-item/index.js +23 -0
  69. package/dist/work-item/line-adapter.js +111 -0
  70. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  71. package/dist/work-item/local-provider.js +38 -0
  72. package/dist/work-item/local-work-event-provider.js +56 -0
  73. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  74. package/dist/work-item/provider.js +21 -0
  75. package/dist/work-item/registry.js +19 -0
  76. package/dist/work-item/service.js +85 -0
  77. package/dist/work-item/store.js +97 -0
  78. package/dist/work-item/types.js +79 -0
  79. package/dist/work-item/work-event-graph-engine.js +63 -0
  80. package/dist/work-item/work-event-inference.js +267 -0
  81. package/dist/work-item/work-event-matcher.js +105 -0
  82. package/dist/work-item/work-event-model-manifest.js +39 -0
  83. package/dist/work-item/work-event-store.js +125 -0
  84. package/dist/work-item/work-event.js +123 -0
  85. package/package.json +26 -2
  86. 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 `com.yomi.credentials`, account
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
- - **Backward compatibility.** If no session is found under `com.yomi.credentials`,
527
- Yomi reads the legacy `com.inboxd.credentials` entry once, migrates it forward,
528
- and never deletes it. An existing session keeps working with no re-login.
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
+ }