@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.
- 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 +15 -6
- 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/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 +24 -3
- package/run.mjs +162 -9
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { sanitizeMessagePreview } from '../line/core/message-preview.js';
|
|
2
|
+
import { resolveConversationNames, resolveSenderNames } from '../mcp/names.js';
|
|
3
|
+
import { computeEventId } from './journal/crypto.js';
|
|
4
|
+
function timestamp(message) {
|
|
5
|
+
return Number(message?.deliveredTime || message?.createdTime || 0);
|
|
6
|
+
}
|
|
7
|
+
function mediaType(contentType) {
|
|
8
|
+
const map = {
|
|
9
|
+
1: 'image',
|
|
10
|
+
2: 'video',
|
|
11
|
+
3: 'audio',
|
|
12
|
+
7: 'sticker',
|
|
13
|
+
14: 'file',
|
|
14
|
+
};
|
|
15
|
+
return map[contentType] ?? 'text';
|
|
16
|
+
}
|
|
17
|
+
function mentionMids(metadata) {
|
|
18
|
+
try {
|
|
19
|
+
const value = typeof metadata?.MENTION === 'string'
|
|
20
|
+
? JSON.parse(metadata.MENTION)
|
|
21
|
+
: metadata?.MENTION;
|
|
22
|
+
const mentions = value?.MENTIONEES ?? value?.MENTIONS ?? [];
|
|
23
|
+
return mentions
|
|
24
|
+
.map((item) => item?.M ?? item?.mid)
|
|
25
|
+
.filter((mid) => typeof mid === 'string');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class LineIngestionAdapter {
|
|
32
|
+
service;
|
|
33
|
+
constructor(service) {
|
|
34
|
+
this.service = service;
|
|
35
|
+
}
|
|
36
|
+
async collectRecentEvents(options) {
|
|
37
|
+
const windowStart = options.windowStart;
|
|
38
|
+
const maxChats = options.maxChats ?? 40;
|
|
39
|
+
const maxMessagesPerChat = options.maxMessagesPerChat ?? 100;
|
|
40
|
+
const boxesResult = await this.service.client.getAllMessageBoxes({
|
|
41
|
+
messageBoxCountLimit: 200,
|
|
42
|
+
lastMessagesPerMessageBoxCount: 1,
|
|
43
|
+
withUnreadCount: false,
|
|
44
|
+
});
|
|
45
|
+
const boxes = (boxesResult.messageBoxes ?? [])
|
|
46
|
+
.filter((box) => {
|
|
47
|
+
const last = box.lastMessages?.[0];
|
|
48
|
+
return (Number(last?.deliveredTime ||
|
|
49
|
+
box.lastDeliveredMessageId?.deliveredTime ||
|
|
50
|
+
0) >= windowStart);
|
|
51
|
+
})
|
|
52
|
+
.slice(0, 80);
|
|
53
|
+
if ((boxesResult.messageBoxes ?? []).length === 0) {
|
|
54
|
+
throw new Error('LINE returned no message boxes for ambient observation');
|
|
55
|
+
}
|
|
56
|
+
const chatIds = boxes.map((box) => String(box.id)).filter(Boolean);
|
|
57
|
+
const resolvedChatNames = await resolveConversationNames(this.service, chatIds);
|
|
58
|
+
const chatNames = new Map([...resolvedChatNames].map(([id, name]) => [id, name ?? 'LINE 對話']));
|
|
59
|
+
const events = [];
|
|
60
|
+
const officialChatIds = new Set();
|
|
61
|
+
const userChatIds = chatIds.filter((id) => id.startsWith('u'));
|
|
62
|
+
if (userChatIds.length > 0) {
|
|
63
|
+
try {
|
|
64
|
+
const contacts = await this.service.client.getContacts(userChatIds);
|
|
65
|
+
for (const contact of contacts ?? []) {
|
|
66
|
+
if (contact?.mid && contact?.isOfficial) {
|
|
67
|
+
officialChatIds.add(String(contact.mid));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
73
|
+
const observableChatIds = chatIds
|
|
74
|
+
.filter((chatId) => !officialChatIds.has(chatId))
|
|
75
|
+
.slice(0, maxChats);
|
|
76
|
+
let coverageLimited = false;
|
|
77
|
+
for (const chatId of observableChatIds) {
|
|
78
|
+
const recent = await this.service.getRecentMessages(chatId, maxMessagesPerChat);
|
|
79
|
+
if (recent.length >= maxMessagesPerChat &&
|
|
80
|
+
Math.min(...recent.map((message) => timestamp(message))) >
|
|
81
|
+
windowStart) {
|
|
82
|
+
coverageLimited = true;
|
|
83
|
+
}
|
|
84
|
+
const messages = recent.filter((message) => timestamp(message) >= windowStart);
|
|
85
|
+
const mids = [
|
|
86
|
+
...new Set(messages
|
|
87
|
+
.flatMap((message) => [
|
|
88
|
+
message.from,
|
|
89
|
+
...mentionMids(message.contentMetadata),
|
|
90
|
+
])
|
|
91
|
+
.filter(Boolean)),
|
|
92
|
+
];
|
|
93
|
+
const names = await resolveSenderNames(this.service, mids);
|
|
94
|
+
for (const message of messages) {
|
|
95
|
+
const type = mediaType(Number(message.contentType ?? 0));
|
|
96
|
+
const text = sanitizeMessagePreview(message.text) || null;
|
|
97
|
+
const mNames = mentionMids(message.contentMetadata)
|
|
98
|
+
.map((mid) => names.get(mid))
|
|
99
|
+
.filter((name) => Boolean(name));
|
|
100
|
+
const isSelf = message.from === this.service.profile?.mid;
|
|
101
|
+
const mentionsSelf = mentionMids(message.contentMetadata).includes(String(this.service.profile?.mid ?? ''));
|
|
102
|
+
const msgId = String(message.id);
|
|
103
|
+
events.push({
|
|
104
|
+
eventId: computeEventId(chatId, msgId),
|
|
105
|
+
chatId,
|
|
106
|
+
messageId: msgId,
|
|
107
|
+
opRevision: message.opRevision ? Number(message.opRevision) : null,
|
|
108
|
+
senderMid: message.from ? String(message.from) : null,
|
|
109
|
+
senderName: names.get(message.from) ?? null,
|
|
110
|
+
sentAt: timestamp(message),
|
|
111
|
+
contentType: type,
|
|
112
|
+
text,
|
|
113
|
+
metadata: {
|
|
114
|
+
mentionNames: mNames,
|
|
115
|
+
mentionMids: mentionMids(message.contentMetadata),
|
|
116
|
+
fileName: message.fileName ? String(message.fileName) : undefined,
|
|
117
|
+
stickerId: type === 'sticker' && message.contentMetadata?.STKID
|
|
118
|
+
? String(message.contentMetadata.STKID)
|
|
119
|
+
: undefined,
|
|
120
|
+
sourceUrl: type === 'sticker' && message.contentMetadata?.STKID
|
|
121
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${message.contentMetadata.STKID}/android/sticker.png`
|
|
122
|
+
: null,
|
|
123
|
+
},
|
|
124
|
+
isSelf,
|
|
125
|
+
mentionsSelf,
|
|
126
|
+
ocrEligible: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const joinedSquareChats = await this.service.client.getJoinedSquareChats(40);
|
|
132
|
+
const hydratedSquareChats = await Promise.all(joinedSquareChats.map(async (chat) => {
|
|
133
|
+
if (chat.lastMessage)
|
|
134
|
+
return chat;
|
|
135
|
+
try {
|
|
136
|
+
const status = await this.service.client.getSquareChatStatus(chat.id);
|
|
137
|
+
return { ...chat, lastMessage: status?.[3]?.[1] ?? null };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return chat;
|
|
141
|
+
}
|
|
142
|
+
}));
|
|
143
|
+
const activeSquareChats = hydratedSquareChats.filter((chat) => {
|
|
144
|
+
const last = chat.lastMessage;
|
|
145
|
+
return Number(last?.[6] || last?.[5] || 0) >= windowStart;
|
|
146
|
+
});
|
|
147
|
+
if (activeSquareChats.length > 20)
|
|
148
|
+
coverageLimited = true;
|
|
149
|
+
for (const chat of activeSquareChats.slice(0, 20)) {
|
|
150
|
+
const recent = await this.service.client.getSquareChatMessages(chat.id, maxMessagesPerChat);
|
|
151
|
+
if (recent.length >= maxMessagesPerChat &&
|
|
152
|
+
Math.min(...recent.map((message) => Number(message.createdTime ?? 0))) > windowStart) {
|
|
153
|
+
coverageLimited = true;
|
|
154
|
+
}
|
|
155
|
+
const messages = recent.filter((message) => Number(message.createdTime ?? 0) >= windowStart);
|
|
156
|
+
chatNames.set(chat.id, chat.name ?? 'LINE 社群');
|
|
157
|
+
const senderNames = new Map(messages
|
|
158
|
+
.filter((m) => m?.fromMid && m?.fromName)
|
|
159
|
+
.map((m) => [String(m.fromMid), String(m.fromName)]));
|
|
160
|
+
for (const message of messages) {
|
|
161
|
+
const type = mediaType(Number(message.contentType ?? 0));
|
|
162
|
+
const mentions = mentionMids(message.contentMetadata);
|
|
163
|
+
const msgId = String(message.id);
|
|
164
|
+
events.push({
|
|
165
|
+
eventId: computeEventId(chat.id, msgId),
|
|
166
|
+
chatId: chat.id,
|
|
167
|
+
messageId: msgId,
|
|
168
|
+
opRevision: null,
|
|
169
|
+
senderMid: message.fromMid ? String(message.fromMid) : null,
|
|
170
|
+
senderName: message.fromName ? String(message.fromName) : null,
|
|
171
|
+
sentAt: Number(message.createdTime ?? 0),
|
|
172
|
+
contentType: type,
|
|
173
|
+
text: sanitizeMessagePreview(message.text) || null,
|
|
174
|
+
metadata: {
|
|
175
|
+
fileName: message.fileName ? String(message.fileName) : undefined,
|
|
176
|
+
mentionMids: mentions,
|
|
177
|
+
mentionNames: mentions
|
|
178
|
+
.map((mid) => senderNames.get(mid))
|
|
179
|
+
.filter((name) => Boolean(name)),
|
|
180
|
+
sourceUrl: type === 'sticker' && message.contentMetadata?.STKID
|
|
181
|
+
? `https://stickershop.line-scdn.net/stickershop/v1/sticker/${message.contentMetadata.STKID}/android/sticker.png`
|
|
182
|
+
: null,
|
|
183
|
+
},
|
|
184
|
+
isSelf: message.isSelf === true,
|
|
185
|
+
mentionsSelf: Boolean(chat.memberMid && mentions.includes(String(chat.memberMid))),
|
|
186
|
+
ocrEligible: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// SquareService discovery is optional.
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
events,
|
|
196
|
+
chatNames,
|
|
197
|
+
coverageLimited,
|
|
198
|
+
chatsScanned: new Set(events.map((e) => e.chatId)).size,
|
|
199
|
+
messagesScanned: events.length,
|
|
200
|
+
officialChatsSkipped: officialChatIds.size,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdf, randomBytes, } from 'node:crypto';
|
|
2
|
+
const IV_LENGTH = 12;
|
|
3
|
+
const TAG_LENGTH = 16;
|
|
4
|
+
const KEY_LENGTH = 32;
|
|
5
|
+
/**
|
|
6
|
+
* Derive an encryption key from a seed or context string using HKDF-SHA256.
|
|
7
|
+
*/
|
|
8
|
+
export async function deriveJournalKey(secretContext) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
hkdf('sha256', Buffer.from(secretContext, 'utf8'), Buffer.from('YomiJournalSalt', 'utf8'), 'LineActivityEventJournalKey', KEY_LENGTH, (err, key) => {
|
|
11
|
+
if (err)
|
|
12
|
+
reject(err);
|
|
13
|
+
else
|
|
14
|
+
resolve(Buffer.from(key));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Encrypt a plaintext buffer with AES-256-GCM authenticated encryption.
|
|
20
|
+
* Output format: [12-byte IV][16-byte Auth Tag][Ciphertext]
|
|
21
|
+
*/
|
|
22
|
+
export function encryptPayload(plaintext, key) {
|
|
23
|
+
if (key.length !== KEY_LENGTH) {
|
|
24
|
+
throw new Error(`Invalid journal encryption key length: expected ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
25
|
+
}
|
|
26
|
+
const input = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
|
|
27
|
+
const iv = randomBytes(IV_LENGTH);
|
|
28
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
29
|
+
const ciphertext = Buffer.concat([cipher.update(input), cipher.final()]);
|
|
30
|
+
const tag = cipher.getAuthTag();
|
|
31
|
+
return Buffer.concat([iv, tag, ciphertext]);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decrypt an AES-256-GCM envelope produced by encryptPayload.
|
|
35
|
+
*/
|
|
36
|
+
export function decryptPayload(envelope, key) {
|
|
37
|
+
if (key.length !== KEY_LENGTH) {
|
|
38
|
+
throw new Error(`Invalid journal encryption key length: expected ${KEY_LENGTH} bytes, got ${key.length}`);
|
|
39
|
+
}
|
|
40
|
+
if (envelope.length < IV_LENGTH + TAG_LENGTH) {
|
|
41
|
+
throw new Error('Encrypted envelope is truncated');
|
|
42
|
+
}
|
|
43
|
+
const iv = envelope.subarray(0, IV_LENGTH);
|
|
44
|
+
const tag = envelope.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
45
|
+
const ciphertext = envelope.subarray(IV_LENGTH + TAG_LENGTH);
|
|
46
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
47
|
+
decipher.setAuthTag(tag);
|
|
48
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
49
|
+
}
|
|
50
|
+
export function hashIdentifier(id) {
|
|
51
|
+
return createHash('sha256').update(id).digest('hex').slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
export function computeEventId(chatId, messageId) {
|
|
54
|
+
return createHash('sha256')
|
|
55
|
+
.update(`${chatId}\0${messageId}`)
|
|
56
|
+
.digest('hex')
|
|
57
|
+
.slice(0, 24);
|
|
58
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { yomiDataPath } from '../../util/data-dir.js';
|
|
5
|
+
import { computeEventId, decryptPayload, deriveJournalKey, encryptPayload, hashIdentifier, } from './crypto.js';
|
|
6
|
+
const DEFAULT_MAX_EVENTS = 500;
|
|
7
|
+
const DEFAULT_MAX_SPAN_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
8
|
+
export class LineActivityJournalStore {
|
|
9
|
+
baseDir;
|
|
10
|
+
encryptionKey = null;
|
|
11
|
+
maxEventsPerSegment;
|
|
12
|
+
maxSegmentSpanMs;
|
|
13
|
+
writeQueue = Promise.resolve();
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.baseDir = options.baseDir ?? yomiDataPath('journal');
|
|
16
|
+
this.encryptionKey = options.encryptionKey ?? null;
|
|
17
|
+
this.maxEventsPerSegment = options.maxEventsPerSegment ?? DEFAULT_MAX_EVENTS;
|
|
18
|
+
this.maxSegmentSpanMs = options.maxSegmentSpanMs ?? DEFAULT_MAX_SPAN_MS;
|
|
19
|
+
}
|
|
20
|
+
async getKey() {
|
|
21
|
+
if (!this.encryptionKey) {
|
|
22
|
+
this.encryptionKey = await deriveJournalKey(this.baseDir);
|
|
23
|
+
}
|
|
24
|
+
return this.encryptionKey;
|
|
25
|
+
}
|
|
26
|
+
indexPath() {
|
|
27
|
+
return join(this.baseDir, 'segments-index.json');
|
|
28
|
+
}
|
|
29
|
+
segmentPath(fileName) {
|
|
30
|
+
return join(this.baseDir, fileName);
|
|
31
|
+
}
|
|
32
|
+
async readIndex() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(this.indexPath(), 'utf8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (parsed.schemaVersion === 1 && Array.isArray(parsed.segments)) {
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
return {
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
segments: [],
|
|
44
|
+
lastCompactedAt: 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async writeIndex(index) {
|
|
48
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
49
|
+
const temp = `${this.indexPath()}.tmp-${process.pid}-${Date.now()}`;
|
|
50
|
+
await writeFile(temp, JSON.stringify(index, null, 2), { mode: 0o600 });
|
|
51
|
+
await rename(temp, this.indexPath());
|
|
52
|
+
}
|
|
53
|
+
async readSegmentEvents(meta) {
|
|
54
|
+
const key = await this.getKey();
|
|
55
|
+
const filePath = this.segmentPath(meta.cipherFileName);
|
|
56
|
+
try {
|
|
57
|
+
const cipher = await readFile(filePath);
|
|
58
|
+
const decrypted = decryptPayload(cipher, key);
|
|
59
|
+
const lines = decrypted
|
|
60
|
+
.toString('utf8')
|
|
61
|
+
.split('\n')
|
|
62
|
+
.filter((line) => line.trim().length > 0);
|
|
63
|
+
return lines.map((line) => JSON.parse(line));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async writeSegmentEvents(segmentId, events, completeness = 'open') {
|
|
70
|
+
const key = await this.getKey();
|
|
71
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
72
|
+
const fileName = `segment-${segmentId}.enc`;
|
|
73
|
+
const filePath = this.segmentPath(fileName);
|
|
74
|
+
const serialized = `${events.map((event) => JSON.stringify(event)).join('\n')}\n`;
|
|
75
|
+
const encrypted = encryptPayload(Buffer.from(serialized, 'utf8'), key);
|
|
76
|
+
const temp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
77
|
+
await writeFile(temp, encrypted, { mode: 0o600 });
|
|
78
|
+
await rename(temp, filePath);
|
|
79
|
+
const timestamps = events.map((e) => e.sentAt);
|
|
80
|
+
const chatHashes = [...new Set(events.map((e) => hashIdentifier(e.chatId)))];
|
|
81
|
+
const digest = createHash('sha256').update(encrypted).digest('hex');
|
|
82
|
+
return {
|
|
83
|
+
segmentId,
|
|
84
|
+
cipherFileName: fileName,
|
|
85
|
+
minSentAt: timestamps.length ? Math.min(...timestamps) : 0,
|
|
86
|
+
maxSentAt: timestamps.length ? Math.max(...timestamps) : 0,
|
|
87
|
+
eventCount: events.length,
|
|
88
|
+
chatIdHashes: chatHashes,
|
|
89
|
+
completeness,
|
|
90
|
+
sha256Digest: digest,
|
|
91
|
+
byteLength: encrypted.byteLength,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Append events into the journal. Deduplicates by eventId and updates active segment.
|
|
96
|
+
*/
|
|
97
|
+
async append(incoming) {
|
|
98
|
+
if (incoming.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
const run = async () => {
|
|
101
|
+
const index = await this.readIndex();
|
|
102
|
+
let openSegmentMeta = index.segments.find((s) => s.completeness === 'open');
|
|
103
|
+
let openEvents = [];
|
|
104
|
+
if (openSegmentMeta) {
|
|
105
|
+
openEvents = await this.readSegmentEvents(openSegmentMeta);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const newSegmentId = `seg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
109
|
+
openSegmentMeta = {
|
|
110
|
+
segmentId: newSegmentId,
|
|
111
|
+
cipherFileName: `segment-${newSegmentId}.enc`,
|
|
112
|
+
minSentAt: 0,
|
|
113
|
+
maxSentAt: 0,
|
|
114
|
+
eventCount: 0,
|
|
115
|
+
chatIdHashes: [],
|
|
116
|
+
completeness: 'open',
|
|
117
|
+
sha256Digest: '',
|
|
118
|
+
byteLength: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const existingIds = new Set(openEvents.map((e) => e.eventId));
|
|
122
|
+
const toAdd = incoming
|
|
123
|
+
.map((event) => ({
|
|
124
|
+
...event,
|
|
125
|
+
eventId: event.eventId || computeEventId(event.chatId, event.messageId),
|
|
126
|
+
}))
|
|
127
|
+
.filter((event) => !existingIds.has(event.eventId));
|
|
128
|
+
if (toAdd.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
openEvents.push(...toAdd);
|
|
131
|
+
openEvents.sort((a, b) => a.sentAt - b.sentAt);
|
|
132
|
+
const span = openEvents.length > 1
|
|
133
|
+
? openEvents[openEvents.length - 1].sentAt - openEvents[0].sentAt
|
|
134
|
+
: 0;
|
|
135
|
+
const shouldSeal = openEvents.length >= this.maxEventsPerSegment ||
|
|
136
|
+
span >= this.maxSegmentSpanMs;
|
|
137
|
+
const updatedMeta = await this.writeSegmentEvents(openSegmentMeta.segmentId, openEvents, shouldSeal ? 'sealed' : 'open');
|
|
138
|
+
const segmentIndex = index.segments.findIndex((s) => s.segmentId === openSegmentMeta.segmentId);
|
|
139
|
+
if (segmentIndex >= 0) {
|
|
140
|
+
index.segments[segmentIndex] = updatedMeta;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
index.segments.push(updatedMeta);
|
|
144
|
+
}
|
|
145
|
+
await this.writeIndex(index);
|
|
146
|
+
};
|
|
147
|
+
this.writeQueue = this.writeQueue.then(run, run);
|
|
148
|
+
await this.writeQueue;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Query events matching time range, chat IDs, or limits.
|
|
152
|
+
*/
|
|
153
|
+
async query(query = {}) {
|
|
154
|
+
await this.writeQueue;
|
|
155
|
+
const index = await this.readIndex();
|
|
156
|
+
const targetChatHashes = query.chatId
|
|
157
|
+
? [hashIdentifier(query.chatId)]
|
|
158
|
+
: query.chatIds?.map(hashIdentifier);
|
|
159
|
+
// Filter relevant segments based on metadata
|
|
160
|
+
const candidateSegments = index.segments.filter((meta) => {
|
|
161
|
+
if (meta.eventCount === 0)
|
|
162
|
+
return false;
|
|
163
|
+
if (query.since && meta.maxSentAt < query.since)
|
|
164
|
+
return false;
|
|
165
|
+
if (query.until && meta.minSentAt > query.until)
|
|
166
|
+
return false;
|
|
167
|
+
if (targetChatHashes && targetChatHashes.length > 0) {
|
|
168
|
+
const hasMatchingChat = targetChatHashes.some((h) => meta.chatIdHashes.includes(h));
|
|
169
|
+
if (!hasMatchingChat)
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
const matchingEvents = [];
|
|
175
|
+
const targetChatIds = query.chatId
|
|
176
|
+
? new Set([query.chatId])
|
|
177
|
+
: query.chatIds
|
|
178
|
+
? new Set(query.chatIds)
|
|
179
|
+
: null;
|
|
180
|
+
for (const segment of candidateSegments) {
|
|
181
|
+
const events = await this.readSegmentEvents(segment);
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
if (query.since && event.sentAt < query.since)
|
|
184
|
+
continue;
|
|
185
|
+
if (query.until && event.sentAt > query.until)
|
|
186
|
+
continue;
|
|
187
|
+
if (targetChatIds && !targetChatIds.has(event.chatId))
|
|
188
|
+
continue;
|
|
189
|
+
matchingEvents.push(event);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
matchingEvents.sort((a, b) => query.reverse ? b.sentAt - a.sentAt : a.sentAt - b.sentAt);
|
|
193
|
+
if (query.limit && query.limit > 0) {
|
|
194
|
+
return matchingEvents.slice(0, query.limit);
|
|
195
|
+
}
|
|
196
|
+
return matchingEvents;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Immediately purge all events associated with a specific chatId from all segments.
|
|
200
|
+
*/
|
|
201
|
+
async purgeChat(chatId) {
|
|
202
|
+
let purgedCount = 0;
|
|
203
|
+
const run = async () => {
|
|
204
|
+
const index = await this.readIndex();
|
|
205
|
+
const targetHash = hashIdentifier(chatId);
|
|
206
|
+
const updatedSegments = [];
|
|
207
|
+
for (const meta of index.segments) {
|
|
208
|
+
if (!meta.chatIdHashes.includes(targetHash)) {
|
|
209
|
+
updatedSegments.push(meta);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const events = await this.readSegmentEvents(meta);
|
|
213
|
+
const filtered = events.filter((e) => e.chatId !== chatId);
|
|
214
|
+
purgedCount += events.length - filtered.length;
|
|
215
|
+
if (filtered.length === 0) {
|
|
216
|
+
// Remove segment completely
|
|
217
|
+
try {
|
|
218
|
+
await rm(this.segmentPath(meta.cipherFileName), { force: true });
|
|
219
|
+
}
|
|
220
|
+
catch { }
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Rewrite segment without purged events
|
|
224
|
+
const newMeta = await this.writeSegmentEvents(meta.segmentId, filtered, meta.completeness);
|
|
225
|
+
updatedSegments.push(newMeta);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
index.segments = updatedSegments;
|
|
229
|
+
await this.writeIndex(index);
|
|
230
|
+
};
|
|
231
|
+
this.writeQueue = this.writeQueue.then(run, run);
|
|
232
|
+
await this.writeQueue;
|
|
233
|
+
return purgedCount;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Delete all encrypted segments and reset journal index.
|
|
237
|
+
*/
|
|
238
|
+
async purgeAll() {
|
|
239
|
+
const run = async () => {
|
|
240
|
+
try {
|
|
241
|
+
const files = await readdir(this.baseDir);
|
|
242
|
+
for (const file of files) {
|
|
243
|
+
await rm(join(this.baseDir, file), { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch { }
|
|
247
|
+
};
|
|
248
|
+
this.writeQueue = this.writeQueue.then(run, run);
|
|
249
|
+
await this.writeQueue;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Compact journal by pruning segments older than retention period.
|
|
253
|
+
*/
|
|
254
|
+
async compact(retentionMs = 14 * 24 * 60 * 60 * 1000, now = Date.now()) {
|
|
255
|
+
let removedSegmentsCount = 0;
|
|
256
|
+
const cutoff = now - retentionMs;
|
|
257
|
+
const run = async () => {
|
|
258
|
+
const index = await this.readIndex();
|
|
259
|
+
const keepSegments = [];
|
|
260
|
+
for (const meta of index.segments) {
|
|
261
|
+
if (meta.completeness === 'sealed' && meta.maxSentAt < cutoff) {
|
|
262
|
+
try {
|
|
263
|
+
await rm(this.segmentPath(meta.cipherFileName), { force: true });
|
|
264
|
+
removedSegmentsCount += 1;
|
|
265
|
+
}
|
|
266
|
+
catch { }
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
keepSegments.push(meta);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
index.segments = keepSegments;
|
|
273
|
+
index.lastCompactedAt = now;
|
|
274
|
+
await this.writeIndex(index);
|
|
275
|
+
};
|
|
276
|
+
this.writeQueue = this.writeQueue.then(run, run);
|
|
277
|
+
await this.writeQueue;
|
|
278
|
+
return removedSegmentsCount;
|
|
279
|
+
}
|
|
280
|
+
async getIndex() {
|
|
281
|
+
await this.writeQueue;
|
|
282
|
+
return this.readIndex();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { buildEpisodeSummaries, buildRollupSummary } from './summary-engine.js';
|
|
2
|
+
export class CoarseToFineRetriever {
|
|
3
|
+
journalStore;
|
|
4
|
+
constructor(journalStore) {
|
|
5
|
+
this.journalStore = journalStore;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Coarse step: Scan 6-hour Rollup windows and return summary of activity.
|
|
9
|
+
*/
|
|
10
|
+
async retrieveRollups(since, until, windowSpanMs = 6 * 60 * 60 * 1000) {
|
|
11
|
+
const rawEvents = await this.journalStore.query({ since, until });
|
|
12
|
+
const episodes = buildEpisodeSummaries(rawEvents);
|
|
13
|
+
const rollups = [];
|
|
14
|
+
let currentStart = since;
|
|
15
|
+
while (currentStart < until) {
|
|
16
|
+
const currentEnd = Math.min(until, currentStart + windowSpanMs);
|
|
17
|
+
rollups.push(buildRollupSummary(currentStart, currentEnd, episodes));
|
|
18
|
+
currentStart += windowSpanMs;
|
|
19
|
+
}
|
|
20
|
+
return rollups;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Medium step: Find candidate episodes matching query criteria.
|
|
24
|
+
*/
|
|
25
|
+
async retrieveEpisodes(query = {}) {
|
|
26
|
+
const rawEvents = await this.journalStore.query({
|
|
27
|
+
since: query.since,
|
|
28
|
+
until: query.until,
|
|
29
|
+
chatId: query.chatId,
|
|
30
|
+
});
|
|
31
|
+
const episodes = buildEpisodeSummaries(rawEvents);
|
|
32
|
+
if (query.onlyActionable) {
|
|
33
|
+
return episodes.filter((ep) => ep.hasMentionsSelf ||
|
|
34
|
+
ep.responseState === 'waiting' ||
|
|
35
|
+
ep.candidateCount > 0);
|
|
36
|
+
}
|
|
37
|
+
return episodes;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fine step: Given an EpisodeSummary, load exact raw LineActivityEvent records
|
|
41
|
+
* from the encrypted journal.
|
|
42
|
+
*/
|
|
43
|
+
async retrieveConnectedEvidence(episode) {
|
|
44
|
+
const events = await this.journalStore.query({
|
|
45
|
+
chatId: episode.chatId,
|
|
46
|
+
since: episode.minSentAt,
|
|
47
|
+
until: episode.maxSentAt,
|
|
48
|
+
});
|
|
49
|
+
const filtered = events.filter((e) => episode.messageIds.includes(e.messageId));
|
|
50
|
+
return {
|
|
51
|
+
episode,
|
|
52
|
+
events: filtered,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|