@rikaidev/yomi 0.5.0 → 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 (62) hide show
  1. package/README.md +28 -0
  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 +22 -16
  13. package/dist/auth/credential-store.js +15 -6
  14. package/dist/cli/query.js +276 -69
  15. package/dist/cli/work-item.js +24 -0
  16. package/dist/line/client/auth-service/client.js +7 -8
  17. package/dist/line/client/buddy-service/client.js +73 -0
  18. package/dist/line/client/index.js +28 -1
  19. package/dist/line/client/obs-media-client.js +2 -1
  20. package/dist/line/client/square-service/client.js +298 -0
  21. package/dist/line/client/talk-service/command-client.js +9 -1
  22. package/dist/line/client/talk-service/message-id-query.js +27 -3
  23. package/dist/line/client/talk-service/requests.js +9 -0
  24. package/dist/line/core/auth-session-runtime.js +1 -1
  25. package/dist/line/core/chat-event.js +2 -0
  26. package/dist/line/core/chat-runtime-service.js +18 -3
  27. package/dist/line/core/config.js +1 -0
  28. package/dist/line/core/flex-message.js +323 -0
  29. package/dist/line/core/service.js +0 -1
  30. package/dist/mcp/handlers/contacts.js +12 -6
  31. package/dist/mcp/handlers/conversations.js +54 -0
  32. package/dist/mcp/handlers/send.js +8 -11
  33. package/dist/mcp/media.js +101 -2
  34. package/dist/mcp/names.js +78 -6
  35. package/dist/search/default-embedder.js +12 -3
  36. package/dist/version.js +1 -1
  37. package/dist/work-item/adapter.js +1 -0
  38. package/dist/work-item/destination-contracts.js +1 -0
  39. package/dist/work-item/event-context.js +73 -0
  40. package/dist/work-item/gold-contract.js +101 -0
  41. package/dist/work-item/gold-coverage.js +96 -0
  42. package/dist/work-item/gold-derivation.js +67 -0
  43. package/dist/work-item/gold-split.js +143 -0
  44. package/dist/work-item/index.js +23 -0
  45. package/dist/work-item/line-adapter.js +111 -0
  46. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  47. package/dist/work-item/local-provider.js +38 -0
  48. package/dist/work-item/local-work-event-provider.js +56 -0
  49. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  50. package/dist/work-item/provider.js +21 -0
  51. package/dist/work-item/registry.js +19 -0
  52. package/dist/work-item/service.js +85 -0
  53. package/dist/work-item/store.js +97 -0
  54. package/dist/work-item/types.js +79 -0
  55. package/dist/work-item/work-event-graph-engine.js +63 -0
  56. package/dist/work-item/work-event-inference.js +267 -0
  57. package/dist/work-item/work-event-matcher.js +105 -0
  58. package/dist/work-item/work-event-model-manifest.js +39 -0
  59. package/dist/work-item/work-event-store.js +125 -0
  60. package/dist/work-item/work-event.js +123 -0
  61. package/package.json +24 -3
  62. package/run.mjs +162 -9
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
@@ -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
+ }