@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.
- package/LICENSE +201 -0
- package/README.md +222 -0
- package/README.zh-TW.md +186 -0
- package/dist/api.d.ts +100 -0
- package/dist/api.js +156 -0
- package/dist/cognition/causal-attribution.d.ts +36 -0
- package/dist/cognition/causal-attribution.js +239 -0
- package/dist/cognition/causal-engine.d.ts +105 -0
- package/dist/cognition/causal-engine.js +150 -0
- package/dist/cognition/conflict-detector.d.ts +39 -0
- package/dist/cognition/conflict-detector.js +193 -0
- package/dist/cognition/global-working-memory.d.ts +53 -0
- package/dist/cognition/global-working-memory.js +211 -0
- package/dist/cognition/hooks-engine.d.ts +99 -0
- package/dist/cognition/hooks-engine.js +672 -0
- package/dist/cognition/ralph-core.d.ts +28 -0
- package/dist/cognition/ralph-core.js +104 -0
- package/dist/distill/concentrator-adapter.d.ts +167 -0
- package/dist/distill/concentrator-adapter.js +1876 -0
- package/dist/engine.d.ts +402 -0
- package/dist/engine.js +2254 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/lifecycle/cleanup-engine.d.ts +80 -0
- package/dist/lifecycle/cleanup-engine.js +162 -0
- package/dist/lifecycle/cleanup-state.d.ts +34 -0
- package/dist/lifecycle/cleanup-state.js +50 -0
- package/dist/lifecycle/night-consolidation.d.ts +102 -0
- package/dist/lifecycle/night-consolidation.js +640 -0
- package/dist/lifecycle/night-recovery.d.ts +40 -0
- package/dist/lifecycle/night-recovery.js +107 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +16 -0
- package/dist/pipeline/capsule-bridge.d.ts +35 -0
- package/dist/pipeline/capsule-bridge.js +86 -0
- package/dist/pipeline/compact-request.d.ts +30 -0
- package/dist/pipeline/compact-request.js +66 -0
- package/dist/pipeline/inbox-watcher.d.ts +112 -0
- package/dist/pipeline/inbox-watcher.js +1039 -0
- package/dist/ports.d.ts +29 -0
- package/dist/ports.js +1 -0
- package/dist/providers/embedder-v5.d.ts +46 -0
- package/dist/providers/embedder-v5.js +155 -0
- package/dist/providers/ollama-embedding.d.ts +25 -0
- package/dist/providers/ollama-embedding.js +166 -0
- package/dist/retrieval/abstractness-judge.d.ts +14 -0
- package/dist/retrieval/abstractness-judge.js +87 -0
- package/dist/retrieval/coverage-selection.d.ts +3 -0
- package/dist/retrieval/coverage-selection.js +53 -0
- package/dist/retrieval/cross-encoder-gate.d.ts +40 -0
- package/dist/retrieval/cross-encoder-gate.js +239 -0
- package/dist/retrieval/retriever-v4.d.ts +78 -0
- package/dist/retrieval/retriever-v4.js +1200 -0
- package/dist/skills/validate.d.ts +6 -0
- package/dist/skills/validate.js +69 -0
- package/dist/storage.d.ts +19 -0
- package/dist/storage.js +54 -0
- package/dist/store/aux-table-maintenance.d.ts +5 -0
- package/dist/store/aux-table-maintenance.js +64 -0
- package/dist/store/graph-enumerator.d.ts +21 -0
- package/dist/store/graph-enumerator.js +185 -0
- package/dist/store/graph-store.d.ts +107 -0
- package/dist/store/graph-store.js +478 -0
- package/dist/store/status-manager.d.ts +44 -0
- package/dist/store/status-manager.js +235 -0
- package/dist/store/store-v4.d.ts +339 -0
- package/dist/store/store-v4.js +2871 -0
- package/dist/transcript/keyword-search.d.ts +9 -0
- package/dist/transcript/keyword-search.js +67 -0
- package/dist/transcript/rehydrate-keyword.d.ts +6 -0
- package/dist/transcript/rehydrate-keyword.js +29 -0
- package/dist/transcript/rehydrate.d.ts +33 -0
- package/dist/transcript/rehydrate.js +285 -0
- package/dist/transcript/transcript-archive.d.ts +46 -0
- package/dist/transcript/transcript-archive.js +516 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.js +104 -0
- package/dist/util/bounded-map.d.ts +1 -0
- package/dist/util/bounded-map.js +8 -0
- package/dist/util/rate-limiter.d.ts +12 -0
- package/dist/util/rate-limiter.js +54 -0
- package/dist/util/session-identity.d.ts +65 -0
- package/dist/util/session-identity.js +227 -0
- package/dist/util/util-hash.d.ts +1 -0
- package/dist/util/util-hash.js +4 -0
- package/package.json +59 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface KeywordSearchCandidate<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
text: string;
|
|
4
|
+
timestamp?: number | string | Date | null;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildKeywordSearchTerms(keyword: string): string[];
|
|
7
|
+
export declare function countKeywordMatches(text: string, keyword: string): number;
|
|
8
|
+
export declare function matchesKeywordSearch(text: string, keyword: string): boolean;
|
|
9
|
+
export declare function rankKeywordMatches<T>(candidates: readonly KeywordSearchCandidate<T>[], keyword: string, limit?: number): T[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const COMMON_SINGLE_CJK_TOKENS = new Set([
|
|
2
|
+
'的',
|
|
3
|
+
'了',
|
|
4
|
+
'是',
|
|
5
|
+
'在',
|
|
6
|
+
'有',
|
|
7
|
+
'他',
|
|
8
|
+
'她',
|
|
9
|
+
'我',
|
|
10
|
+
'你',
|
|
11
|
+
'不',
|
|
12
|
+
'一',
|
|
13
|
+
]);
|
|
14
|
+
export function buildKeywordSearchTerms(keyword) {
|
|
15
|
+
return [...new Set(keyword
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.map(token => token.trim().toLowerCase())
|
|
18
|
+
.filter(token => token.length > 0)
|
|
19
|
+
.filter(token => !/^[\x00-\x7f]$/.test(token))
|
|
20
|
+
.filter(token => !COMMON_SINGLE_CJK_TOKENS.has(token)))];
|
|
21
|
+
}
|
|
22
|
+
export function countKeywordMatches(text, keyword) {
|
|
23
|
+
const normalized = text.toLowerCase();
|
|
24
|
+
return buildKeywordSearchTerms(keyword)
|
|
25
|
+
.reduce((count, token) => count + Number(normalized.includes(token)), 0);
|
|
26
|
+
}
|
|
27
|
+
export function matchesKeywordSearch(text, keyword) {
|
|
28
|
+
return countKeywordMatches(text, keyword) > 0;
|
|
29
|
+
}
|
|
30
|
+
export function rankKeywordMatches(candidates, keyword, limit = Number.POSITIVE_INFINITY) {
|
|
31
|
+
const terms = buildKeywordSearchTerms(keyword);
|
|
32
|
+
if (terms.length === 0 || limit <= 0)
|
|
33
|
+
return [];
|
|
34
|
+
return candidates
|
|
35
|
+
.map((candidate, index) => {
|
|
36
|
+
const normalized = candidate.text.toLowerCase();
|
|
37
|
+
const matchCount = terms.reduce((count, term) => count + Number(normalized.includes(term)), 0);
|
|
38
|
+
return {
|
|
39
|
+
...candidate,
|
|
40
|
+
index,
|
|
41
|
+
matchCount,
|
|
42
|
+
timestampMs: normalizeTimestamp(candidate.timestamp),
|
|
43
|
+
};
|
|
44
|
+
})
|
|
45
|
+
.filter(candidate => candidate.matchCount > 0)
|
|
46
|
+
.sort((a, b) => b.matchCount - a.matchCount
|
|
47
|
+
|| b.timestampMs - a.timestampMs
|
|
48
|
+
|| a.index - b.index)
|
|
49
|
+
.slice(0, limit)
|
|
50
|
+
.map(candidate => candidate.value);
|
|
51
|
+
}
|
|
52
|
+
function normalizeTimestamp(timestamp) {
|
|
53
|
+
if (typeof timestamp === 'number')
|
|
54
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
55
|
+
if (timestamp instanceof Date) {
|
|
56
|
+
const value = timestamp.getTime();
|
|
57
|
+
return Number.isFinite(value) ? value : 0;
|
|
58
|
+
}
|
|
59
|
+
if (typeof timestamp === 'string') {
|
|
60
|
+
const numeric = Number(timestamp);
|
|
61
|
+
if (Number.isFinite(numeric))
|
|
62
|
+
return numeric;
|
|
63
|
+
const parsed = Date.parse(timestamp);
|
|
64
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { rankKeywordMatches } from './keyword-search.js';
|
|
4
|
+
const MAX_KEYWORD_RESULTS = 200;
|
|
5
|
+
export async function rehydrateByKeyword(transcriptsDir, keyword, opts) {
|
|
6
|
+
if (!fs.existsSync(transcriptsDir))
|
|
7
|
+
return [];
|
|
8
|
+
const files = fs.readdirSync(transcriptsDir)
|
|
9
|
+
.filter(file => file.endsWith('.jsonl') && !file.endsWith('.idx'))
|
|
10
|
+
.filter(file => !opts?.sessionKey || file === `${opts.sessionKey}.jsonl` || file.startsWith(`${opts.sessionKey}.`))
|
|
11
|
+
.sort((a, b) => fs.statSync(path.join(transcriptsDir, b)).mtimeMs - fs.statSync(path.join(transcriptsDir, a)).mtimeMs)
|
|
12
|
+
.slice(0, opts?.sessionKey ? undefined : 10);
|
|
13
|
+
const candidates = [];
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
for (const line of fs.readFileSync(path.join(transcriptsDir, file), 'utf8').split('\n')) {
|
|
16
|
+
if (!line.trim())
|
|
17
|
+
continue;
|
|
18
|
+
try {
|
|
19
|
+
const entry = JSON.parse(line);
|
|
20
|
+
const text = `${entry.user ?? ''} ${entry.assistant ?? ''}`.toLowerCase();
|
|
21
|
+
candidates.push({ value: entry, text, timestamp: entry.timestamp });
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const offset = opts?.offset ?? 0;
|
|
27
|
+
const limit = Math.min(opts?.limit ?? 10, MAX_KEYWORD_RESULTS);
|
|
28
|
+
return rankKeywordMatches(candidates, keyword).slice(offset, offset + limit);
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rehydrate.ts — Memory Rehydrate System
|
|
3
|
+
*
|
|
4
|
+
* 根據濃縮時記錄的 sourceEntryIds,從 transcript JSONL 撈回原始對話。
|
|
5
|
+
* 使用 .idx sidecar 做 O(1) byte offset seek,避免全檔 scan。
|
|
6
|
+
*
|
|
7
|
+
* Fallback:當 entryId 查不到(舊檔或 .idx 損壞),走 line-scan 並打日誌。
|
|
8
|
+
*/
|
|
9
|
+
export interface TranscriptEntry {
|
|
10
|
+
entryId: number;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
user: string;
|
|
13
|
+
assistant: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 根據 entry IDs 撈原文,前後各擴 bleed 筆記錄。
|
|
18
|
+
* 使用 .idx sidecar 做 byte offset seek(O(1) 查詢)。
|
|
19
|
+
*
|
|
20
|
+
* @param jsonlPath transcript JSONL 檔案路徑
|
|
21
|
+
* @param entryIds 要撈的 entry ID 陣列
|
|
22
|
+
* @param bleed 往前後各擴多少筆記錄(預設 2)
|
|
23
|
+
* @returns TranscriptEntry[] — 不論找到多少筆都回傳(可為空陣列)
|
|
24
|
+
*/
|
|
25
|
+
export declare function rehydrate(jsonlPath: string, entryIds: number[], bleed?: number): Promise<TranscriptEntry[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Fallback:LLM 完全沒給 ID 時,用時間窗救援(走 legacy line-scan)。
|
|
28
|
+
*
|
|
29
|
+
* @param jsonlPath transcript JSONL 檔案路徑
|
|
30
|
+
* @param centerTimestamp 中心時間(ISO string 或 Unix ms)
|
|
31
|
+
* @param windowMinutes 時間窗分鐘數(預設 30)
|
|
32
|
+
*/
|
|
33
|
+
export declare function rehydrateByTime(jsonlPath: string, centerTimestamp: string, windowMinutes?: number): Promise<TranscriptEntry[]>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rehydrate.ts — Memory Rehydrate System
|
|
3
|
+
*
|
|
4
|
+
* 根據濃縮時記錄的 sourceEntryIds,從 transcript JSONL 撈回原始對話。
|
|
5
|
+
* 使用 .idx sidecar 做 O(1) byte offset seek,避免全檔 scan。
|
|
6
|
+
*
|
|
7
|
+
* Fallback:當 entryId 查不到(舊檔或 .idx 損壞),走 line-scan 並打日誌。
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
function getIdxPath(jsonlPath) {
|
|
12
|
+
return jsonlPath + '.idx';
|
|
13
|
+
}
|
|
14
|
+
function loadIdx(jsonlPath) {
|
|
15
|
+
const idxPath = getIdxPath(jsonlPath);
|
|
16
|
+
try {
|
|
17
|
+
if (fs.existsSync(idxPath)) {
|
|
18
|
+
const raw = fs.readFileSync(idxPath, 'utf-8');
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
function getTranscriptPaths(jsonlPath) {
|
|
26
|
+
const dir = path.dirname(jsonlPath);
|
|
27
|
+
const base = path.basename(jsonlPath, '.jsonl');
|
|
28
|
+
let rotated = [];
|
|
29
|
+
try {
|
|
30
|
+
rotated = fs.readdirSync(dir)
|
|
31
|
+
.filter((file) => file.startsWith(`${base}.`) && file.endsWith('.jsonl'))
|
|
32
|
+
.map((file) => ({
|
|
33
|
+
index: Number.parseInt(file.slice(base.length + 1, -'.jsonl'.length), 10),
|
|
34
|
+
filePath: path.join(dir, file),
|
|
35
|
+
}))
|
|
36
|
+
.filter((file) => Number.isFinite(file.index))
|
|
37
|
+
.sort((a, b) => b.index - a.index);
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
return [jsonlPath, ...rotated.map((file) => file.filePath)];
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------
|
|
43
|
+
// 核心工具:從 byte offset 讀取一行 JSONL
|
|
44
|
+
// ---------------------------------------------------------------
|
|
45
|
+
function readLineAt(fd, offset) {
|
|
46
|
+
const MAX_LINE_LENGTH = 100_000; // 防止極端行的無限迴圈
|
|
47
|
+
const buf = Buffer.alloc(MAX_LINE_LENGTH);
|
|
48
|
+
const bytesRead = fs.readSync(fd, buf, 0, MAX_LINE_LENGTH, offset);
|
|
49
|
+
if (bytesRead === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const content = buf.toString('utf-8', 0, bytesRead);
|
|
52
|
+
const eol = content.indexOf('\n');
|
|
53
|
+
if (eol === -1)
|
|
54
|
+
return content.trim() || null;
|
|
55
|
+
return content.substring(0, eol).trim() || null;
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------
|
|
58
|
+
// rehydrate:根據 entryIds 撈原文(O(1) seek via .idx)
|
|
59
|
+
// ---------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* 根據 entry IDs 撈原文,前後各擴 bleed 筆記錄。
|
|
62
|
+
* 使用 .idx sidecar 做 byte offset seek(O(1) 查詢)。
|
|
63
|
+
*
|
|
64
|
+
* @param jsonlPath transcript JSONL 檔案路徑
|
|
65
|
+
* @param entryIds 要撈的 entry ID 陣列
|
|
66
|
+
* @param bleed 往前後各擴多少筆記錄(預設 2)
|
|
67
|
+
* @returns TranscriptEntry[] — 不論找到多少筆都回傳(可為空陣列)
|
|
68
|
+
*/
|
|
69
|
+
export async function rehydrate(jsonlPath, entryIds, bleed = 2) {
|
|
70
|
+
if (!entryIds || entryIds.length === 0)
|
|
71
|
+
return [];
|
|
72
|
+
const transcriptPaths = getTranscriptPaths(jsonlPath);
|
|
73
|
+
if (!transcriptPaths.some((transcriptPath) => fs.existsSync(transcriptPath)))
|
|
74
|
+
return [];
|
|
75
|
+
const found = new Map(); // entryId → entry
|
|
76
|
+
const foundPaths = new Map();
|
|
77
|
+
const indexes = new Map();
|
|
78
|
+
// ── O(1) seek via .idx ──────────────────────────────
|
|
79
|
+
for (const transcriptPath of transcriptPaths) {
|
|
80
|
+
const idx = loadIdx(transcriptPath);
|
|
81
|
+
indexes.set(transcriptPath, idx);
|
|
82
|
+
const missingIds = entryIds.filter((eid) => !found.has(eid));
|
|
83
|
+
if (missingIds.length === 0 || Object.keys(idx).length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
let fd = null;
|
|
86
|
+
try {
|
|
87
|
+
fd = fs.openSync(transcriptPath, 'r');
|
|
88
|
+
for (const eid of missingIds) {
|
|
89
|
+
const offset = idx[eid];
|
|
90
|
+
if (offset === undefined) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const line = readLineAt(fd, offset);
|
|
94
|
+
if (!line)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(line);
|
|
98
|
+
found.set(eid, {
|
|
99
|
+
entryId: typeof raw.entryId === 'number' ? raw.entryId : eid,
|
|
100
|
+
sessionId: raw.sessionId || '',
|
|
101
|
+
user: raw.user || '',
|
|
102
|
+
assistant: raw.assistant || '',
|
|
103
|
+
timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : 0,
|
|
104
|
+
});
|
|
105
|
+
foundPaths.set(eid, transcriptPath);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
console.warn(`[rehydrate] JSON parse failed for entryId=${eid}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (fd !== null)
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ── Legacy fallback:所有輪替代的 .idx 都 miss 後才 line scan ──────────────
|
|
118
|
+
const missingIds = entryIds.filter((eid) => !found.has(eid));
|
|
119
|
+
if (missingIds.length > 0) {
|
|
120
|
+
console.warn(`[rehydrate] legacy_transcript_fallback: path=${jsonlPath}, ids=${missingIds.join(',')}`);
|
|
121
|
+
for (const transcriptPath of transcriptPaths) {
|
|
122
|
+
const legacyEntries = await legacyLineScan(transcriptPath, missingIds, bleed);
|
|
123
|
+
for (const entry of legacyEntries) {
|
|
124
|
+
if (missingIds.includes(entry.entryId)) {
|
|
125
|
+
found.set(entry.entryId, entry);
|
|
126
|
+
foundPaths.set(entry.entryId, transcriptPath);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const entryMap = new Map(found);
|
|
132
|
+
for (const transcriptPath of transcriptPaths) {
|
|
133
|
+
const entries = Array.from(found.entries())
|
|
134
|
+
.filter(([entryId]) => foundPaths.get(entryId) === transcriptPath)
|
|
135
|
+
.map(([, entry]) => entry);
|
|
136
|
+
if (entries.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
for (const entry of await expandBleed(transcriptPath, entries, bleed, indexes.get(transcriptPath) ?? {})) {
|
|
139
|
+
entryMap.set(entry.entryId, entry);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return Array.from(entryMap.values()).sort((a, b) => a.entryId - b.entryId);
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------
|
|
145
|
+
// rehydrateByTime:時間窗救援(完全沒拿到 entryIds 時的 fallback)
|
|
146
|
+
// ---------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* Fallback:LLM 完全沒給 ID 時,用時間窗救援(走 legacy line-scan)。
|
|
149
|
+
*
|
|
150
|
+
* @param jsonlPath transcript JSONL 檔案路徑
|
|
151
|
+
* @param centerTimestamp 中心時間(ISO string 或 Unix ms)
|
|
152
|
+
* @param windowMinutes 時間窗分鐘數(預設 30)
|
|
153
|
+
*/
|
|
154
|
+
export async function rehydrateByTime(jsonlPath, centerTimestamp, windowMinutes = 30) {
|
|
155
|
+
const transcriptPaths = getTranscriptPaths(jsonlPath);
|
|
156
|
+
if (!transcriptPaths.some((transcriptPath) => fs.existsSync(transcriptPath)))
|
|
157
|
+
return [];
|
|
158
|
+
const centerMs = isNaN(Number(centerTimestamp))
|
|
159
|
+
? new Date(centerTimestamp).getTime()
|
|
160
|
+
: Number(centerTimestamp);
|
|
161
|
+
if (isNaN(centerMs)) {
|
|
162
|
+
console.warn(`[rehydrateByTime] invalid timestamp: ${centerTimestamp}`);
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const halfWindowMs = windowMinutes * 60 * 1000;
|
|
166
|
+
const since = centerMs - halfWindowMs;
|
|
167
|
+
const until = centerMs + halfWindowMs;
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const transcriptPath of transcriptPaths) {
|
|
170
|
+
try {
|
|
171
|
+
const raw = fs.readFileSync(transcriptPath, 'utf-8');
|
|
172
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
try {
|
|
175
|
+
const entry = JSON.parse(line);
|
|
176
|
+
if (!entry.timestamp)
|
|
177
|
+
continue;
|
|
178
|
+
if (entry.timestamp >= since && entry.timestamp <= until) {
|
|
179
|
+
results.push({
|
|
180
|
+
entryId: entry.entryId ?? -1,
|
|
181
|
+
sessionId: entry.sessionId || '',
|
|
182
|
+
user: entry.user || '',
|
|
183
|
+
assistant: entry.assistant || '',
|
|
184
|
+
timestamp: entry.timestamp,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch { }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results.sort((a, b) => a.timestamp - b.timestamp);
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------
|
|
198
|
+
// 內部工具
|
|
199
|
+
// ---------------------------------------------------------------
|
|
200
|
+
/**
|
|
201
|
+
* 對已排序的 entry 陣列,往前向後擴充 bleed 筆記錄。
|
|
202
|
+
*/
|
|
203
|
+
async function expandBleed(jsonlPath, entries, bleed, index) {
|
|
204
|
+
if (entries.length === 0 || bleed <= 0)
|
|
205
|
+
return entries;
|
|
206
|
+
const hitIds = new Set(entries.map(e => e.entryId));
|
|
207
|
+
const minId = Math.min(...hitIds) - bleed;
|
|
208
|
+
const maxId = Math.max(...hitIds) + bleed;
|
|
209
|
+
const entryMap = new Map();
|
|
210
|
+
entries.forEach(e => entryMap.set(e.entryId, e));
|
|
211
|
+
const fd = fs.openSync(jsonlPath, 'r');
|
|
212
|
+
try {
|
|
213
|
+
for (let id = minId; id <= maxId; id++) {
|
|
214
|
+
if (entryMap.has(id))
|
|
215
|
+
continue; // 已有的跳過
|
|
216
|
+
const offset = index[id];
|
|
217
|
+
if (offset == null)
|
|
218
|
+
continue; // 不存在的 ID 跳過
|
|
219
|
+
const buf = Buffer.alloc(8192);
|
|
220
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
|
|
221
|
+
const nlIdx = buf.indexOf(0x0a);
|
|
222
|
+
const lineEnd = nlIdx === -1 ? bytesRead : nlIdx;
|
|
223
|
+
const line = buf.slice(0, lineEnd).toString('utf8');
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(line);
|
|
226
|
+
entryMap.set(parsed.entryId, parsed);
|
|
227
|
+
}
|
|
228
|
+
catch { /* 壞行跳過 */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
fs.closeSync(fd);
|
|
233
|
+
}
|
|
234
|
+
return [...entryMap.values()].sort((a, b) => a.entryId - b.entryId);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Legacy line-scan:當 .idx 不存在或找不到 ID 時使用。
|
|
238
|
+
* 按 entryId 找目標 + 附近鄰居。
|
|
239
|
+
*/
|
|
240
|
+
async function legacyLineScan(jsonlPath, targetIds, bleed) {
|
|
241
|
+
const targetSet = new Set(targetIds);
|
|
242
|
+
const found = [];
|
|
243
|
+
const allEntries = [];
|
|
244
|
+
try {
|
|
245
|
+
const raw = fs.readFileSync(jsonlPath, 'utf-8');
|
|
246
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
try {
|
|
249
|
+
const entry = JSON.parse(line);
|
|
250
|
+
allEntries.push({
|
|
251
|
+
entryId: entry.entryId ?? -1,
|
|
252
|
+
sessionId: entry.sessionId || '',
|
|
253
|
+
user: entry.user || '',
|
|
254
|
+
assistant: entry.assistant || '',
|
|
255
|
+
timestamp: entry.timestamp || 0,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch { }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error(`[rehydrate] legacyLineScan failed:`, err);
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
if (allEntries.length === 0)
|
|
266
|
+
return [];
|
|
267
|
+
// 對有 targetId 的精確找,沒有 targetId 就取所有(因為 legacy 檔根本沒 entryId)
|
|
268
|
+
if (targetIds.length > 0 && targetIds[0] !== -1) {
|
|
269
|
+
for (const entry of allEntries) {
|
|
270
|
+
if (targetSet.has(entry.entryId)) {
|
|
271
|
+
found.push(entry);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// 沒有精確匹配:說明是舊檔(沒 entryId),全部返回
|
|
275
|
+
if (found.length === 0) {
|
|
276
|
+
console.warn(`[rehydrate] legacy_transcript_fallback: no entryId match, returning all entries (${allEntries.length})`);
|
|
277
|
+
return allEntries.slice(-20); // 最多取最近 20 筆
|
|
278
|
+
}
|
|
279
|
+
return found;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// 沒有 targetId → 全量返回(最多 20 筆)
|
|
283
|
+
return allEntries.slice(-20);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { ContextMessage } from '../types.js';
|
|
15
|
+
/**
|
|
16
|
+
* 由 sessionIdentity 衍生的 archive 識別子。
|
|
17
|
+
*
|
|
18
|
+
* - canonicalKey:對外統一 key,用於 in-memory cache(Phase 4-2 起)
|
|
19
|
+
* - sessionKey:用於磁碟檔名(Q6 漸進路徑,新檔仍走 sessionKey 命名)
|
|
20
|
+
* - sessionId:4-4 disk read fallback 用(sessionKey-named 找不到時試 sessionId-named)
|
|
21
|
+
*
|
|
22
|
+
* 結構上等同 SessionIdentity 子集;caller 可以直接把 SessionIdentity 傳進來。
|
|
23
|
+
*/
|
|
24
|
+
export interface TranscriptIdentity {
|
|
25
|
+
canonicalKey: string;
|
|
26
|
+
sessionKey: string | null;
|
|
27
|
+
sessionId?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface RawTranscriptEntry {
|
|
30
|
+
entryId: number;
|
|
31
|
+
user: string;
|
|
32
|
+
assistant: string;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ArchiveSnapshotResult {
|
|
36
|
+
ok: boolean;
|
|
37
|
+
appendedEntries: number;
|
|
38
|
+
dedupSkipped: number;
|
|
39
|
+
}
|
|
40
|
+
export declare function createTranscriptArchive(transcriptsDir: string): {
|
|
41
|
+
archiveSnapshot: (identity: TranscriptIdentity, messages: ContextMessage[]) => ArchiveSnapshotResult;
|
|
42
|
+
clearTranscriptCache: () => void;
|
|
43
|
+
getRawTranscript: (identity: TranscriptIdentity, since?: number, limit?: number) => RawTranscriptEntry[];
|
|
44
|
+
getTranscriptPath: (sessionKey: string) => string;
|
|
45
|
+
};
|
|
46
|
+
export type TranscriptArchive = ReturnType<typeof createTranscriptArchive>;
|