@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.
Files changed (63) 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 +25 -12
  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/integrator.js +21 -7
  34. package/dist/mcp/media.js +101 -2
  35. package/dist/mcp/names.js +78 -6
  36. package/dist/search/default-embedder.js +12 -3
  37. package/dist/version.js +1 -1
  38. package/dist/work-item/adapter.js +1 -0
  39. package/dist/work-item/destination-contracts.js +1 -0
  40. package/dist/work-item/event-context.js +73 -0
  41. package/dist/work-item/gold-contract.js +101 -0
  42. package/dist/work-item/gold-coverage.js +96 -0
  43. package/dist/work-item/gold-derivation.js +67 -0
  44. package/dist/work-item/gold-split.js +143 -0
  45. package/dist/work-item/index.js +23 -0
  46. package/dist/work-item/line-adapter.js +111 -0
  47. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  48. package/dist/work-item/local-provider.js +38 -0
  49. package/dist/work-item/local-work-event-provider.js +56 -0
  50. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  51. package/dist/work-item/provider.js +21 -0
  52. package/dist/work-item/registry.js +19 -0
  53. package/dist/work-item/service.js +85 -0
  54. package/dist/work-item/store.js +97 -0
  55. package/dist/work-item/types.js +79 -0
  56. package/dist/work-item/work-event-graph-engine.js +63 -0
  57. package/dist/work-item/work-event-inference.js +267 -0
  58. package/dist/work-item/work-event-matcher.js +105 -0
  59. package/dist/work-item/work-event-model-manifest.js +39 -0
  60. package/dist/work-item/work-event-store.js +125 -0
  61. package/dist/work-item/work-event.js +123 -0
  62. package/package.json +25 -4
  63. package/run.mjs +186 -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,7 @@
1
+ /**
2
+ * Types for the encrypted append-only LineActivityEvent journal.
3
+ *
4
+ * LineActivityEvent is the immutable unit of observation recorded from LINE sync
5
+ * operations, TalkService message boxes, and SquareService channels.
6
+ */
7
+ export {};
@@ -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
+ }