@memory-river/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +222 -0
  3. package/README.zh-TW.md +186 -0
  4. package/dist/api.d.ts +100 -0
  5. package/dist/api.js +156 -0
  6. package/dist/cognition/causal-attribution.d.ts +36 -0
  7. package/dist/cognition/causal-attribution.js +239 -0
  8. package/dist/cognition/causal-engine.d.ts +105 -0
  9. package/dist/cognition/causal-engine.js +150 -0
  10. package/dist/cognition/conflict-detector.d.ts +39 -0
  11. package/dist/cognition/conflict-detector.js +193 -0
  12. package/dist/cognition/global-working-memory.d.ts +53 -0
  13. package/dist/cognition/global-working-memory.js +211 -0
  14. package/dist/cognition/hooks-engine.d.ts +99 -0
  15. package/dist/cognition/hooks-engine.js +672 -0
  16. package/dist/cognition/ralph-core.d.ts +28 -0
  17. package/dist/cognition/ralph-core.js +104 -0
  18. package/dist/distill/concentrator-adapter.d.ts +167 -0
  19. package/dist/distill/concentrator-adapter.js +1876 -0
  20. package/dist/engine.d.ts +402 -0
  21. package/dist/engine.js +2254 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lifecycle/cleanup-engine.d.ts +80 -0
  25. package/dist/lifecycle/cleanup-engine.js +162 -0
  26. package/dist/lifecycle/cleanup-state.d.ts +34 -0
  27. package/dist/lifecycle/cleanup-state.js +50 -0
  28. package/dist/lifecycle/night-consolidation.d.ts +102 -0
  29. package/dist/lifecycle/night-consolidation.js +640 -0
  30. package/dist/lifecycle/night-recovery.d.ts +40 -0
  31. package/dist/lifecycle/night-recovery.js +107 -0
  32. package/dist/paths.d.ts +17 -0
  33. package/dist/paths.js +16 -0
  34. package/dist/pipeline/capsule-bridge.d.ts +35 -0
  35. package/dist/pipeline/capsule-bridge.js +86 -0
  36. package/dist/pipeline/compact-request.d.ts +30 -0
  37. package/dist/pipeline/compact-request.js +66 -0
  38. package/dist/pipeline/inbox-watcher.d.ts +112 -0
  39. package/dist/pipeline/inbox-watcher.js +1039 -0
  40. package/dist/ports.d.ts +29 -0
  41. package/dist/ports.js +1 -0
  42. package/dist/providers/embedder-v5.d.ts +46 -0
  43. package/dist/providers/embedder-v5.js +155 -0
  44. package/dist/providers/ollama-embedding.d.ts +25 -0
  45. package/dist/providers/ollama-embedding.js +166 -0
  46. package/dist/retrieval/abstractness-judge.d.ts +14 -0
  47. package/dist/retrieval/abstractness-judge.js +87 -0
  48. package/dist/retrieval/coverage-selection.d.ts +3 -0
  49. package/dist/retrieval/coverage-selection.js +53 -0
  50. package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
  51. package/dist/retrieval/cross-encoder-gate.js +239 -0
  52. package/dist/retrieval/retriever-v4.d.ts +78 -0
  53. package/dist/retrieval/retriever-v4.js +1200 -0
  54. package/dist/skills/validate.d.ts +6 -0
  55. package/dist/skills/validate.js +69 -0
  56. package/dist/storage.d.ts +19 -0
  57. package/dist/storage.js +54 -0
  58. package/dist/store/aux-table-maintenance.d.ts +5 -0
  59. package/dist/store/aux-table-maintenance.js +64 -0
  60. package/dist/store/graph-enumerator.d.ts +21 -0
  61. package/dist/store/graph-enumerator.js +185 -0
  62. package/dist/store/graph-store.d.ts +107 -0
  63. package/dist/store/graph-store.js +478 -0
  64. package/dist/store/status-manager.d.ts +44 -0
  65. package/dist/store/status-manager.js +235 -0
  66. package/dist/store/store-v4.d.ts +339 -0
  67. package/dist/store/store-v4.js +2871 -0
  68. package/dist/transcript/keyword-search.d.ts +9 -0
  69. package/dist/transcript/keyword-search.js +67 -0
  70. package/dist/transcript/rehydrate-keyword.d.ts +6 -0
  71. package/dist/transcript/rehydrate-keyword.js +29 -0
  72. package/dist/transcript/rehydrate.d.ts +33 -0
  73. package/dist/transcript/rehydrate.js +285 -0
  74. package/dist/transcript/transcript-archive.d.ts +46 -0
  75. package/dist/transcript/transcript-archive.js +516 -0
  76. package/dist/types.d.ts +409 -0
  77. package/dist/types.js +104 -0
  78. package/dist/util/bounded-map.d.ts +1 -0
  79. package/dist/util/bounded-map.js +8 -0
  80. package/dist/util/rate-limiter.d.ts +12 -0
  81. package/dist/util/rate-limiter.js +54 -0
  82. package/dist/util/session-identity.d.ts +65 -0
  83. package/dist/util/session-identity.js +227 -0
  84. package/dist/util/util-hash.d.ts +1 -0
  85. package/dist/util/util-hash.js +4 -0
  86. package/package.json +59 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * transcript-archive.ts
3
+ *
4
+ * Raw transcript 持久化層 — memory-river 濃縮失敗的底層保障。
5
+ * Append-only JSONL,5MB 上限自動 rotation(留最多 10 個輪替檔)。
6
+ *
7
+ * 與 streaming-recovery/scripts/transcriptStore.ts 共用相同的 rotation 模式,
8
+ * 但專注於「即將被蒸餾的原始訊息」的快照,不依賴任何外部 library。
9
+ *
10
+ * [Rehydrate System]
11
+ * - 每筆 entry 自帶 entryId(單調遞增),寫入 .idx sidecar 做 O(1) seek
12
+ * - transcript.counter 持久化 counter 狀態
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { createHash } from 'node:crypto';
17
+ // ---------------------------------------------------------------
18
+ // 常數
19
+ // ---------------------------------------------------------------
20
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
21
+ const MAX_ROTATE_FILES = 10;
22
+ const MAX_CACHE_SIZE_PER_SESSION = 5 * 1024 * 1024; // 5MB
23
+ const DEDUP_TAIL_LINES = 1000;
24
+ export function createTranscriptArchive(transcriptsDir) {
25
+ /**
26
+ * canonicalKey → RawTranscriptEntry[] 的 LRU cache
27
+ * Map 保持插入順序,天然 LRU
28
+ */
29
+ const transcriptCache = new Map();
30
+ let inMemoryCounter = 0;
31
+ let counterLoaded = false;
32
+ /** 給 plugin start 清狀態用(Phase 4-7) */
33
+ function clearTranscriptCache() {
34
+ transcriptCache.clear();
35
+ }
36
+ // ---------------------------------------------------------------
37
+ // 目錄工具
38
+ // ---------------------------------------------------------------
39
+ function getBasePath() {
40
+ return transcriptsDir;
41
+ }
42
+ function ensureTranscriptDir() {
43
+ const basePath = getBasePath();
44
+ if (!fs.existsSync(basePath)) {
45
+ fs.mkdirSync(basePath, { recursive: true, mode: 0o700 });
46
+ }
47
+ return basePath;
48
+ }
49
+ function getTranscriptPath(sessionKey) {
50
+ if (!/^[A-Za-z0-9._:-]+$/.test(sessionKey) || sessionKey.includes('..')) {
51
+ throw new Error('invalid sessionKey');
52
+ }
53
+ const dir = ensureTranscriptDir();
54
+ const resolvedDir = path.resolve(dir);
55
+ const transcriptPath = path.resolve(resolvedDir, `${sessionKey}.jsonl`);
56
+ const dirPrefix = resolvedDir.endsWith(path.sep) ? resolvedDir : `${resolvedDir}${path.sep}`;
57
+ if (!transcriptPath.startsWith(dirPrefix)) {
58
+ throw new Error('invalid sessionKey');
59
+ }
60
+ return transcriptPath;
61
+ }
62
+ // ---------------------------------------------------------------
63
+ // Counter 持久化(Rehydrate System)
64
+ // ---------------------------------------------------------------
65
+ function getCounterPath() {
66
+ return path.join(getBasePath(), 'transcript.counter');
67
+ }
68
+ function loadCounter() {
69
+ if (counterLoaded)
70
+ return;
71
+ const counterPath = getCounterPath();
72
+ try {
73
+ if (fs.existsSync(counterPath)) {
74
+ const raw = fs.readFileSync(counterPath, 'utf-8');
75
+ const parsed = JSON.parse(raw);
76
+ inMemoryCounter = typeof parsed.counter === 'number' ? parsed.counter : 0;
77
+ }
78
+ }
79
+ catch {
80
+ inMemoryCounter = 0;
81
+ }
82
+ counterLoaded = true;
83
+ }
84
+ function saveCounter() {
85
+ fs.writeFileSync(getCounterPath(), JSON.stringify({ counter: inMemoryCounter }), { encoding: 'utf-8', mode: 0o600 });
86
+ }
87
+ function nextEntryId() {
88
+ loadCounter();
89
+ return ++inMemoryCounter;
90
+ }
91
+ function getIdxPath(jsonlPath) {
92
+ return jsonlPath + '.idx';
93
+ }
94
+ function loadIdx(jsonlPath) {
95
+ const idxPath = getIdxPath(jsonlPath);
96
+ try {
97
+ if (fs.existsSync(idxPath)) {
98
+ const raw = fs.readFileSync(idxPath, 'utf-8');
99
+ return JSON.parse(raw);
100
+ }
101
+ }
102
+ catch { }
103
+ return {};
104
+ }
105
+ function appendIdx(jsonlPath, entryId, offset) {
106
+ const idxPath = getIdxPath(jsonlPath);
107
+ const idx = loadIdx(jsonlPath);
108
+ idx[entryId] = offset;
109
+ try {
110
+ fs.writeFileSync(idxPath, JSON.stringify(idx), { encoding: 'utf-8', mode: 0o600 });
111
+ }
112
+ catch (err) {
113
+ console.error('[transcript-archive] appendIdx failed:', err);
114
+ }
115
+ }
116
+ function appendJsonlWithFsync(filePath, content) {
117
+ const fd = fs.openSync(filePath, 'a', 0o600);
118
+ try {
119
+ fs.writeFileSync(fd, content, 'utf-8');
120
+ fs.fsyncSync(fd);
121
+ }
122
+ finally {
123
+ fs.closeSync(fd);
124
+ }
125
+ }
126
+ function writeIdxWithFsync(jsonlPath, idx) {
127
+ const idxPath = getIdxPath(jsonlPath);
128
+ const fd = fs.openSync(idxPath, 'w', 0o600);
129
+ try {
130
+ fs.writeFileSync(fd, JSON.stringify(idx), 'utf-8');
131
+ fs.fsyncSync(fd);
132
+ }
133
+ finally {
134
+ fs.closeSync(fd);
135
+ }
136
+ }
137
+ // ---------------------------------------------------------------
138
+ // Rotation 邏輯(直接參考 transcriptStore.ts — 已驗證可用)
139
+ // ---------------------------------------------------------------
140
+ function rotateIfNeeded(filePath) {
141
+ try {
142
+ if (!fs.existsSync(filePath))
143
+ return;
144
+ const stats = fs.statSync(filePath);
145
+ if (stats.size < MAX_FILE_SIZE_BYTES)
146
+ return;
147
+ const dir = path.dirname(filePath);
148
+ const base = path.basename(filePath, '.jsonl');
149
+ let maxIndex = 0;
150
+ const files = fs.readdirSync(dir);
151
+ for (const f of files) {
152
+ if (f.startsWith(`${base}.`) && f.endsWith('.jsonl')) {
153
+ const idx = parseInt(f.replace(`${base}.`, '').replace('.jsonl', ''), 10);
154
+ if (!isNaN(idx) && idx > maxIndex)
155
+ maxIndex = idx;
156
+ }
157
+ }
158
+ // 刪除最舊的輪替檔(保留 MAX_ROTATE_FILES 個)
159
+ const oldestIndex = maxIndex + 1 - MAX_ROTATE_FILES;
160
+ if (oldestIndex > 0) {
161
+ const oldestPath = path.join(dir, `${base}.${oldestIndex}.jsonl`);
162
+ if (fs.existsSync(oldestPath)) {
163
+ fs.unlinkSync(oldestPath);
164
+ }
165
+ // 同時刪除對應的 .idx sidecar
166
+ const oldestIdxPath = oldestPath + '.idx';
167
+ if (fs.existsSync(oldestIdxPath)) {
168
+ fs.unlinkSync(oldestIdxPath);
169
+ }
170
+ }
171
+ // 將主檔 rotate 為 .1, .2, ...
172
+ const nextIndex = maxIndex + 1;
173
+ const rotatedPath = path.join(dir, `${base}.${nextIndex}.jsonl`);
174
+ fs.renameSync(filePath, rotatedPath);
175
+ // rotation 完成後,清除對應的 in-memory cache(避免孤島)。
176
+ // Phase 4-2 起 cache key 為 canonicalKey;舊行為下檔名等同 sessionKey,
177
+ // 大多數情境 canonicalKey === sessionKey,這裡仍以檔名為線索做 best-effort 清除。
178
+ const baseKey = path.basename(filePath, '.jsonl');
179
+ if (transcriptCache.has(baseKey)) {
180
+ transcriptCache.delete(baseKey);
181
+ console.log(`[transcript-archive] Cache cleared after rotation: ${baseKey}`);
182
+ }
183
+ // 同時 rotate .idx sidecar
184
+ const idxPath = getIdxPath(filePath);
185
+ if (fs.existsSync(idxPath)) {
186
+ fs.renameSync(idxPath, getIdxPath(rotatedPath));
187
+ }
188
+ }
189
+ catch (err) {
190
+ console.error(`[transcript-archive] rotateIfNeeded failed:`, err);
191
+ }
192
+ }
193
+ function getTranscriptPaths(filePath) {
194
+ const dir = path.dirname(filePath);
195
+ const base = path.basename(filePath, '.jsonl');
196
+ let rotated = [];
197
+ try {
198
+ rotated = fs.readdirSync(dir)
199
+ .filter((file) => file.startsWith(`${base}.`) && file.endsWith('.jsonl'))
200
+ .map((file) => ({
201
+ index: Number.parseInt(file.slice(base.length + 1, -'.jsonl'.length), 10),
202
+ filePath: path.join(dir, file),
203
+ }))
204
+ .filter((file) => Number.isFinite(file.index))
205
+ .sort((a, b) => b.index - a.index);
206
+ }
207
+ catch { }
208
+ return [filePath, ...rotated.map((file) => file.filePath)];
209
+ }
210
+ // ---------------------------------------------------------------
211
+ // 內部工具:從 ContextMessage 提取可見文字
212
+ // ---------------------------------------------------------------
213
+ // 🛡️ 過濾 session initialization 訊息(這些是 OpenClaw inject 的系統訊息,不是真的 user 話)
214
+ const SESSION_INIT_PATTERNS = [
215
+ 'A new session was started via /new or /reset',
216
+ 'Run your Session Startup sequence',
217
+ ];
218
+ function isSessionInitMessage(text) {
219
+ return SESSION_INIT_PATTERNS.some(p => text.includes(p));
220
+ }
221
+ function enforceCacheLimit(canonicalKey) {
222
+ const entries = transcriptCache.get(canonicalKey);
223
+ if (!entries)
224
+ return;
225
+ const approxSize = new TextEncoder().encode(JSON.stringify(entries)).length;
226
+ if (approxSize > MAX_CACHE_SIZE_PER_SESSION) {
227
+ // 砍掉最舊的 20%
228
+ const cutIndex = Math.floor(entries.length * 0.2);
229
+ transcriptCache.set(canonicalKey, entries.slice(cutIndex));
230
+ }
231
+ }
232
+ function normalizeDedupText(text) {
233
+ return text.trim().replace(/[\s\u3000]+/g, ' ');
234
+ }
235
+ function buildDedupKey(user, assistant, timestamp) {
236
+ return `${timestamp}\u001f${normalizeDedupText(user)}\u001f${normalizeDedupText(assistant)}`;
237
+ }
238
+ function hashDedupKey(key) {
239
+ return createHash('sha1').update(key).digest('hex');
240
+ }
241
+ function readTailLines(filePath, maxLines) {
242
+ if (!fs.existsSync(filePath) || maxLines <= 0)
243
+ return [];
244
+ const stat = fs.statSync(filePath);
245
+ if (stat.size === 0)
246
+ return [];
247
+ const fd = fs.openSync(filePath, 'r');
248
+ const chunkSize = 64 * 1024;
249
+ let position = stat.size;
250
+ let collected = '';
251
+ let newlineCount = 0;
252
+ try {
253
+ while (position > 0 && newlineCount <= maxLines) {
254
+ const readSize = Math.min(chunkSize, position);
255
+ position -= readSize;
256
+ const buffer = Buffer.alloc(readSize);
257
+ fs.readSync(fd, buffer, 0, readSize, position);
258
+ collected = buffer.toString('utf-8') + collected;
259
+ newlineCount = (collected.match(/\n/g) || []).length;
260
+ }
261
+ }
262
+ finally {
263
+ fs.closeSync(fd);
264
+ }
265
+ return collected.split('\n').filter((line) => line.trim()).slice(-maxLines);
266
+ }
267
+ function loadRecentDedupKeys(filePath, maxLines) {
268
+ const keys = new Set();
269
+ for (const line of readTailLines(filePath, maxLines)) {
270
+ try {
271
+ const entry = JSON.parse(line);
272
+ if (typeof entry.timestamp !== 'number')
273
+ continue;
274
+ keys.add(buildDedupKey(entry.user || '', entry.assistant || '', entry.timestamp));
275
+ }
276
+ catch {
277
+ // 忽略壞行;dedup 安全網不能因單行毀損阻斷 archive
278
+ }
279
+ }
280
+ return keys;
281
+ }
282
+ /**
283
+ * 從 ContextMessage.content 提取「乾淨的文字」。
284
+ * - string content → 直接取
285
+ * - array content → 取 type='text' 的 text,去除 type='thinking'/'thinkingSignature' blocks
286
+ * - tool calls / metadata / role labels → 全數移除
287
+ */
288
+ function extractVisibleText(msg) {
289
+ const raw = msg.content;
290
+ if (typeof raw === 'string')
291
+ return raw.trim();
292
+ if (!Array.isArray(raw))
293
+ return '';
294
+ const parts = [];
295
+ for (const block of raw) {
296
+ if (!block || typeof block !== 'object')
297
+ continue;
298
+ const t = block.type ?? '';
299
+ if (t === 'thinking' || t === 'thinkingSignature')
300
+ continue;
301
+ if (t === 'text' && typeof block.text === 'string') {
302
+ parts.push(block.text.trim());
303
+ }
304
+ }
305
+ return parts.join('\n').trim();
306
+ }
307
+ // ---------------------------------------------------------------
308
+ // 核心 API
309
+ // ---------------------------------------------------------------
310
+ /**
311
+ * 將即將被蒸餾的訊息陣列寫入 append-only raw transcript。
312
+ *
313
+ * 處理邏輯:
314
+ * - 遍歷訊息,按 user→assistant 配對,只保留 content 文字
315
+ * - 去除 role label、tool calls、thinking blocks、metadata
316
+ * - 寫入 JSONL,並在達到 5MB 時自動 rotate(留最多 10 個輪替檔)
317
+ * - 每筆記錄附加 entryId(單調遞增),寫入 .idx sidecar 做 O(1) seek
318
+ *
319
+ * Phase 4-2:disk path 仍由 sessionKey 衍生(與舊行為一致),
320
+ * in-memory cache 改用 canonicalKey;若 sessionKey 缺席則跳過寫入
321
+ * (與 maintain() 端的 sessionKey gate 行為一致)。
322
+ *
323
+ * @param identity - 由 sessionIdentity 解析得到的 canonical/原始 key
324
+ * @param messages - 即將被蒸餾的 ContextMessage 陣列
325
+ */
326
+ function archiveSnapshot(identity, messages) {
327
+ try {
328
+ if (!identity.sessionKey) {
329
+ console.warn(`[transcript-archive] archiveSnapshot skipped: sessionKey missing canonicalKey=${identity.canonicalKey}`);
330
+ return { ok: false, appendedEntries: 0, dedupSkipped: 0 };
331
+ }
332
+ const filePath = getTranscriptPath(identity.sessionKey);
333
+ const cacheKey = identity.canonicalKey;
334
+ const pendingEntries = [];
335
+ let pendingUser = null;
336
+ let pendingTimestamp = Date.now();
337
+ for (const msg of messages) {
338
+ const text = extractVisibleText(msg);
339
+ if (!text || isSessionInitMessage(text))
340
+ continue;
341
+ if (msg.role === 'user') {
342
+ pendingUser = text;
343
+ pendingTimestamp = msg.timestamp ?? Date.now();
344
+ }
345
+ else if (msg.role === 'assistant' && pendingUser !== null) {
346
+ pendingEntries.push({
347
+ user: pendingUser,
348
+ assistant: text,
349
+ timestamp: pendingTimestamp,
350
+ });
351
+ pendingUser = null;
352
+ }
353
+ }
354
+ // 如果最後是孤單的 user 訊息(沒有後續 assistant),仍然寫入一筆記錄
355
+ if (pendingUser !== null) {
356
+ pendingEntries.push({
357
+ user: pendingUser,
358
+ assistant: '',
359
+ timestamp: pendingTimestamp,
360
+ });
361
+ }
362
+ if (pendingEntries.length === 0)
363
+ return { ok: true, appendedEntries: 0, dedupSkipped: 0 };
364
+ // 先檢查是否需要 rotation,避免 offset 計算到舊檔
365
+ rotateIfNeeded(filePath);
366
+ const seenKeys = loadRecentDedupKeys(filePath, DEDUP_TAIL_LINES);
367
+ const newEntries = [];
368
+ let dedupSkipped = 0;
369
+ let firstSkippedHash = null;
370
+ for (const entry of pendingEntries) {
371
+ const key = buildDedupKey(entry.user, entry.assistant, entry.timestamp);
372
+ if (seenKeys.has(key)) {
373
+ dedupSkipped++;
374
+ firstSkippedHash ||= hashDedupKey(key).slice(0, 8);
375
+ continue;
376
+ }
377
+ seenKeys.add(key);
378
+ newEntries.push({
379
+ entryId: nextEntryId(),
380
+ user: entry.user,
381
+ assistant: entry.assistant,
382
+ timestamp: entry.timestamp,
383
+ });
384
+ }
385
+ if (dedupSkipped > 0) {
386
+ console.log(`[archive] dedup skipped ${dedupSkipped} duplicate pairs (canonical=${identity.canonicalKey})`);
387
+ if (firstSkippedHash) {
388
+ console.log(`[archive] first skipped key hash=${firstSkippedHash} (canonical=${identity.canonicalKey})`);
389
+ }
390
+ }
391
+ if (newEntries.length === 0)
392
+ return { ok: true, appendedEntries: 0, dedupSkipped };
393
+ // 寫入磁碟(append),紀錄每筆記錄的 byte offset
394
+ let offset;
395
+ try {
396
+ const stats = fs.statSync(filePath);
397
+ offset = stats.size;
398
+ }
399
+ catch {
400
+ offset = 0;
401
+ }
402
+ const idx = loadIdx(filePath);
403
+ const lines = [];
404
+ for (const entry of newEntries) {
405
+ const line = JSON.stringify(entry);
406
+ lines.push(line);
407
+ idx[entry.entryId] = offset;
408
+ offset += Buffer.byteLength(line, 'utf-8') + 1; // +1 for \n
409
+ }
410
+ const content = lines.join('\n') + '\n';
411
+ saveCounter();
412
+ appendJsonlWithFsync(filePath, content);
413
+ writeIdxWithFsync(filePath, idx);
414
+ // 更新 cache(write-through)— Phase 4-2 起以 canonicalKey 為 key
415
+ const existing = transcriptCache.get(cacheKey) || [];
416
+ transcriptCache.set(cacheKey, [...existing, ...newEntries]);
417
+ // LRU 上限檢查
418
+ enforceCacheLimit(cacheKey);
419
+ return { ok: true, appendedEntries: newEntries.length, dedupSkipped };
420
+ }
421
+ catch (err) {
422
+ console.error(`[transcript-archive] archiveSnapshot failed:`, err);
423
+ // 磁碟寫入失敗時不更新 cache(保持一致性)
424
+ return { ok: false, appendedEntries: 0, dedupSkipped: 0 };
425
+ }
426
+ }
427
+ /**
428
+ * 查詢 raw transcript,支援時間範圍過濾。
429
+ *
430
+ * Phase 4-2:cache lookup 改走 canonicalKey;磁碟讀取仍用 sessionKey。
431
+ * Phase 4-4 會加上 sessionId fallback path。
432
+ *
433
+ * @param identity - 由 sessionIdentity 解析得到的 key 集合
434
+ * @param since - 只取 timestamp >= since 的項目(可選)
435
+ * @param limit - 最多取 limit 筆記錄(可選,預設全部)
436
+ * @returns RawTranscriptEntry[] - 時間正序陣列
437
+ */
438
+ function getRawTranscript(identity, since, limit) {
439
+ const cacheKey = identity.canonicalKey;
440
+ try {
441
+ // 優先查 cache(canonicalKey)
442
+ if (transcriptCache.has(cacheKey)) {
443
+ let entries = transcriptCache.get(cacheKey);
444
+ const oldestTimestamp = Math.min(...entries.map((entry) => entry.timestamp));
445
+ if (since === undefined || (entries.length > 0 && since >= oldestTimestamp)) {
446
+ if (since !== undefined)
447
+ entries = entries.filter(e => e.timestamp >= since);
448
+ return entries.slice(limit ? -limit : undefined);
449
+ }
450
+ }
451
+ // cache miss → 讀磁碟。Phase 4-4 漸進路徑:
452
+ // 1) 優先 sessionKey 對應檔(與寫入端一致)
453
+ // 2) sessionKey 不存在時試 sessionId 對應檔(過渡期相容)
454
+ // 兩者都有:以 sessionKey 為準。sessionId fallback 命中時記一條 warning。
455
+ const sessionKey = identity.sessionKey;
456
+ const sessionId = identity.sessionId;
457
+ let filePath = null;
458
+ if (sessionKey) {
459
+ const skPath = getTranscriptPath(sessionKey);
460
+ if (getTranscriptPaths(skPath).some((transcriptPath) => fs.existsSync(transcriptPath))) {
461
+ filePath = skPath;
462
+ }
463
+ }
464
+ if (!filePath && sessionId) {
465
+ const sidPath = getTranscriptPath(sessionId);
466
+ if (getTranscriptPaths(sidPath).some((transcriptPath) => fs.existsSync(transcriptPath))) {
467
+ filePath = sidPath;
468
+ console.warn(`[transcript-archive] read fallback to sessionId-named file: canonicalKey=${cacheKey} sessionId=${sessionId}`);
469
+ }
470
+ }
471
+ if (!filePath) {
472
+ if (!sessionKey && !sessionId) {
473
+ console.warn(`[transcript-archive] getRawTranscript: sessionKey/sessionId both missing canonicalKey=${cacheKey}; cannot search disk`);
474
+ }
475
+ return [];
476
+ }
477
+ const entries = [];
478
+ for (const transcriptPath of getTranscriptPaths(filePath)) {
479
+ try {
480
+ const raw = fs.readFileSync(transcriptPath, 'utf-8');
481
+ for (const line of raw.split('\n').filter(l => l.trim())) {
482
+ try {
483
+ entries.push(JSON.parse(line));
484
+ }
485
+ catch {
486
+ // Legacy 格式(無 entryId)→ 補一個假的 entryId = -1
487
+ console.warn(`[transcript-archive] legacy_transcript_fallback: canonicalKey=${cacheKey}, no entryId`);
488
+ const legacy = JSON.parse(line);
489
+ entries.push({ entryId: -1, ...legacy });
490
+ }
491
+ }
492
+ }
493
+ catch {
494
+ continue;
495
+ }
496
+ }
497
+ entries.sort((a, b) => a.timestamp - b.timestamp);
498
+ // 寫入 cache(canonicalKey)
499
+ transcriptCache.set(cacheKey, entries);
500
+ enforceCacheLimit(cacheKey);
501
+ // 時間過濾
502
+ let result = since !== undefined ? entries.filter(e => e.timestamp >= since) : entries;
503
+ return result.slice(limit ? -limit : undefined);
504
+ }
505
+ catch (err) {
506
+ console.error(`[transcript-archive] getRawTranscript failed:`, err);
507
+ return [];
508
+ }
509
+ }
510
+ return {
511
+ archiveSnapshot,
512
+ clearTranscriptCache,
513
+ getRawTranscript,
514
+ getTranscriptPath,
515
+ };
516
+ }