@rikaidev/yomi 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- 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 +22 -16
- package/dist/auth/credential-store.js +25 -12
- package/dist/cli/query.js +276 -69
- package/dist/cli/work-item.js +24 -0
- 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 -1
- package/dist/line/client/obs-media-client.js +2 -1
- package/dist/line/client/square-service/client.js +298 -0
- package/dist/line/client/talk-service/command-client.js +9 -1
- package/dist/line/client/talk-service/message-id-query.js +27 -3
- package/dist/line/client/talk-service/requests.js +9 -0
- package/dist/line/core/auth-session-runtime.js +1 -1
- package/dist/line/core/chat-event.js +2 -0
- package/dist/line/core/chat-runtime-service.js +18 -3
- package/dist/line/core/config.js +1 -0
- package/dist/line/core/flex-message.js +323 -0
- package/dist/line/core/service.js +0 -1
- package/dist/mcp/handlers/contacts.js +12 -6
- package/dist/mcp/handlers/conversations.js +54 -0
- package/dist/mcp/handlers/send.js +8 -11
- package/dist/mcp/integrator.js +21 -7
- package/dist/mcp/media.js +101 -2
- package/dist/mcp/names.js +78 -6
- package/dist/search/default-embedder.js +12 -3
- 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 +25 -4
- package/run.mjs +186 -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
|
+
}
|