fraim-hub 2.0.317 → 2.0.319
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/dist/src/ai-hub/atomic-json-file.js +20 -2
- package/dist/src/ai-hub/catalog.js +19 -2
- package/dist/src/ai-hub/conversation-history.js +363 -0
- package/dist/src/ai-hub/conversation-search-index.js +297 -171
- package/dist/src/ai-hub/conversation-search-projection.js +21 -38
- package/dist/src/ai-hub/conversation-search.js +68 -94
- package/dist/src/ai-hub/conversation-store-lock.js +35 -8
- package/dist/src/ai-hub/conversation-store.js +888 -120
- package/dist/src/ai-hub/conversation-ui-state.js +90 -0
- package/dist/src/ai-hub/custom-employees.js +1 -0
- package/dist/src/ai-hub/history-migration.js +517 -0
- package/dist/src/ai-hub/history-policy.js +45 -0
- package/dist/src/ai-hub/history-writer-compatibility.js +79 -0
- package/dist/src/ai-hub/host-output-stream.js +225 -0
- package/dist/src/ai-hub/hosts.js +153 -74
- package/dist/src/ai-hub/hub-app-materializer.js +2 -8
- package/dist/src/ai-hub/hub-runtime-file.js +1 -1
- package/dist/src/ai-hub/preferences.js +8 -2
- package/dist/src/ai-hub/server.js +1272 -302
- package/dist/src/ai-hub/stale-bucket-sweep.js +25 -4
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +11 -1
- package/dist/src/cli/utils/win32-cmd-spawn.js +38 -0
- package/dist/src/config/persona-capability-bundles.js +1 -1
- package/dist/src/core/quality-evidence.js +10 -0
- package/dist/src/core/resolve-phase-edge.js +20 -2
- package/dist/src/first-run/session-service.js +21 -6
- package/dist/src/local-mcp-server/learning-context-builder.js +8 -5
- package/package.json +5 -3
- package/public/ai-hub/index.html +133 -160
- package/public/ai-hub/review.css +2 -48
- package/public/ai-hub/script.js +1647 -1306
- package/public/ai-hub/styles.css +334 -65
|
@@ -14,8 +14,8 @@ const path_1 = __importDefault(require("path"));
|
|
|
14
14
|
// same write-temp-then-rename dance, and the same Windows-specific retry around it. Keeping two
|
|
15
15
|
// copies meant a future fix to the retry behaviour would land in one and silently not the other,
|
|
16
16
|
// which is exactly the drift the store's #820 durability work exists to prevent.
|
|
17
|
-
const RENAME_ATTEMPTS = 25;
|
|
18
|
-
const RENAME_RETRY_DELAY_MS = 10;
|
|
17
|
+
const RENAME_ATTEMPTS = process.platform === 'win32' ? 250 : 25;
|
|
18
|
+
const RENAME_RETRY_DELAY_MS = process.platform === 'win32' ? 20 : 10;
|
|
19
19
|
let tempSeq = 0;
|
|
20
20
|
/**
|
|
21
21
|
* Rename, retrying the Windows contention codes.
|
|
@@ -66,7 +66,25 @@ function writeFileAtomic(filePath, data, encoding) {
|
|
|
66
66
|
fs_1.default.writeFileSync(tempPath, data);
|
|
67
67
|
}
|
|
68
68
|
try {
|
|
69
|
+
const fd = fs_1.default.openSync(tempPath, 'r+');
|
|
70
|
+
try {
|
|
71
|
+
fs_1.default.fsyncSync(fd);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
fs_1.default.closeSync(fd);
|
|
75
|
+
}
|
|
69
76
|
renameWithRetry(tempPath, filePath);
|
|
77
|
+
// POSIX supports directory fsync for rename durability. Windows does not expose
|
|
78
|
+
// a directory flush through Node; file data is flushed, rename is atomic.
|
|
79
|
+
if (process.platform !== 'win32') {
|
|
80
|
+
const directory = fs_1.default.openSync(path_1.default.dirname(filePath), 'r');
|
|
81
|
+
try {
|
|
82
|
+
fs_1.default.fsyncSync(directory);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
fs_1.default.closeSync(directory);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
70
88
|
}
|
|
71
89
|
catch (error) {
|
|
72
90
|
// Do not leave the temp file behind for a scanner to trip over.
|
|
@@ -492,7 +492,11 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
|
|
|
492
492
|
const phaseDef = fm.phases[cursor];
|
|
493
493
|
if (!phaseDef)
|
|
494
494
|
break;
|
|
495
|
-
|
|
495
|
+
if ((0, resolve_phase_edge_1.isRecurrencePhase)(phaseDef)) {
|
|
496
|
+
ordered.splice(ordered.length - 1, 1, phaseDef.setup.phase, phaseDef.recur.phase, phaseDef.done.phase);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseDef.onSuccess, discriminant);
|
|
496
500
|
}
|
|
497
501
|
const labels = fm.phaseLabels || {};
|
|
498
502
|
return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
|
|
@@ -513,6 +517,8 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
|
|
|
513
517
|
const phaseDef = fm.phases[phaseId];
|
|
514
518
|
if (!phaseDef)
|
|
515
519
|
return null;
|
|
520
|
+
if ((0, resolve_phase_edge_1.isRecurrencePhase)(phaseDef))
|
|
521
|
+
return outcome === 'complete' ? phaseDef.setup.phase : null;
|
|
516
522
|
const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
|
|
517
523
|
return (0, resolve_phase_edge_1.resolvePhaseEdge)(edge, discriminant);
|
|
518
524
|
}
|
|
@@ -525,7 +531,18 @@ function loadAllJobPhaseIds(jobId, projectPath) {
|
|
|
525
531
|
const phases = loadJobPhasesFromSteps(declaredPath);
|
|
526
532
|
return new Set(phases.map((p) => p.id));
|
|
527
533
|
}
|
|
528
|
-
|
|
534
|
+
const ids = new Set();
|
|
535
|
+
for (const [phaseId, phase] of Object.entries(fm.phases)) {
|
|
536
|
+
if ((0, resolve_phase_edge_1.isRecurrencePhase)(phase)) {
|
|
537
|
+
ids.add(phase.setup.phase);
|
|
538
|
+
ids.add(phase.recur.phase);
|
|
539
|
+
ids.add(phase.done.phase);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
ids.add(phaseId);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return ids;
|
|
529
546
|
}
|
|
530
547
|
// Issue #347 — public exposure of the friendly-label rule so callers
|
|
531
548
|
// outside catalog.ts can render labels for phases that exist in the
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.historyDirectory = exports.historyHash = void 0;
|
|
7
|
+
exports.historySequenceRange = historySequenceRange;
|
|
8
|
+
exports.historyOf = historyOf;
|
|
9
|
+
exports.jsonBytes = jsonBytes;
|
|
10
|
+
exports.historyMessagesNewest = historyMessagesNewest;
|
|
11
|
+
exports.prepareConversationHead = prepareConversationHead;
|
|
12
|
+
exports.messagePage = messagePage;
|
|
13
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const atomic_json_file_1 = require("./atomic-json-file");
|
|
17
|
+
const history_policy_1 = require("./history-policy");
|
|
18
|
+
const conversation_ui_state_1 = require("./conversation-ui-state");
|
|
19
|
+
const REFERENCE_WINDOW = 100;
|
|
20
|
+
function referenceDirectory(bucket, id) { return path_1.default.join((0, exports.historyDirectory)(bucket, id), 'pages'); }
|
|
21
|
+
function readReferencePage(bucket, id, window, revision) {
|
|
22
|
+
const directory = referenceDirectory(bucket, id);
|
|
23
|
+
try {
|
|
24
|
+
let hash = JSON.parse(fs_1.default.readFileSync(path_1.default.join(directory, `${window}.head.json`), 'utf8')).hash;
|
|
25
|
+
for (let hops = 0; hash && hops < 10000; hops++) {
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(hash))
|
|
27
|
+
return null;
|
|
28
|
+
const file = path_1.default.join(directory, `${hash}.json`);
|
|
29
|
+
if (fs_1.default.statSync(file).size > 32 * 1024)
|
|
30
|
+
return null;
|
|
31
|
+
const text = fs_1.default.readFileSync(file, 'utf8');
|
|
32
|
+
if ((0, exports.historyHash)(text) !== hash)
|
|
33
|
+
return null;
|
|
34
|
+
const page = JSON.parse(text);
|
|
35
|
+
if (page.revision <= revision) {
|
|
36
|
+
const commit = JSON.parse(fs_1.default.readFileSync(path_1.default.join((0, exports.historyDirectory)(bucket, id), 'commits', `${page.revision}.json`), 'utf8'));
|
|
37
|
+
if (commit.id === page.commitId)
|
|
38
|
+
return { hash, page };
|
|
39
|
+
}
|
|
40
|
+
hash = page.previous;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* derived page missing/corrupt: caller reads the authoritative chunks */ }
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function indexChunk(bucket, id, hash, messages, revision, commitId) {
|
|
47
|
+
const windows = new Map();
|
|
48
|
+
messages.forEach((message, index) => {
|
|
49
|
+
const window = Math.floor((message.sequence - 1) / REFERENCE_WINDOW);
|
|
50
|
+
if (!windows.has(window))
|
|
51
|
+
windows.set(window, []);
|
|
52
|
+
windows.get(window).push([message.sequence, { chunk: hash, index }]);
|
|
53
|
+
});
|
|
54
|
+
for (const [window, additions] of windows) {
|
|
55
|
+
const previous = readReferencePage(bucket, id, window, revision);
|
|
56
|
+
const entries = { ...previous?.page.entries };
|
|
57
|
+
for (const [sequence, ref] of additions)
|
|
58
|
+
entries[sequence] = ref;
|
|
59
|
+
const page = { revision, commitId, previous: previous?.hash ?? null, entries };
|
|
60
|
+
const text = JSON.stringify(page), pageHash = (0, exports.historyHash)(text), directory = referenceDirectory(bucket, id);
|
|
61
|
+
writeImmutable(path_1.default.join(directory, `${pageHash}.json`), text);
|
|
62
|
+
(0, atomic_json_file_1.writeFileAtomic)(path_1.default.join(directory, `${window}.head.json`), JSON.stringify({ hash: pageHash }), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Resolve a sequence range with one small reference page and one chunk cache at a time. */
|
|
66
|
+
function historySequenceRange(bucket, record, first, last) {
|
|
67
|
+
const resident = new Map((record.messages || []).map(message => [message.sequence, message]));
|
|
68
|
+
const found = new Map();
|
|
69
|
+
let window = -1, references, chunkHash = '', chunk, missing = false;
|
|
70
|
+
for (let sequence = first; sequence <= last; sequence++) {
|
|
71
|
+
const inline = resident.get(sequence);
|
|
72
|
+
if (inline) {
|
|
73
|
+
found.set(sequence, inline);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const nextWindow = Math.floor((sequence - 1) / REFERENCE_WINDOW);
|
|
77
|
+
if (window !== nextWindow) {
|
|
78
|
+
window = nextWindow;
|
|
79
|
+
references = readReferencePage(bucket, record.id, window, Number(record.revision || 0))?.page;
|
|
80
|
+
}
|
|
81
|
+
const ref = references?.entries[String(sequence)];
|
|
82
|
+
if (!ref) {
|
|
83
|
+
missing = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (chunkHash !== ref.chunk) {
|
|
87
|
+
chunkHash = ref.chunk;
|
|
88
|
+
chunk = readChunk(bucket, record.id, ref.chunk);
|
|
89
|
+
}
|
|
90
|
+
const message = chunk?.messages[ref.index];
|
|
91
|
+
if (!message || message.sequence !== sequence) {
|
|
92
|
+
missing = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
found.set(sequence, message);
|
|
96
|
+
}
|
|
97
|
+
if (missing) {
|
|
98
|
+
// Never silently omit data when a rebuildable index is absent. One bounded
|
|
99
|
+
// range is resolved from the linked source, newest correction first.
|
|
100
|
+
found.clear();
|
|
101
|
+
for (const message of historyMessagesNewest(bucket, record))
|
|
102
|
+
if (message.sequence >= first && message.sequence <= last && !found.has(message.sequence))
|
|
103
|
+
found.set(message.sequence, message);
|
|
104
|
+
}
|
|
105
|
+
if (found.size !== last - first + 1)
|
|
106
|
+
throw new history_policy_1.HistoryStoreError('history_unavailable');
|
|
107
|
+
return [...found.values()].sort((a, b) => a.sequence - b.sequence);
|
|
108
|
+
}
|
|
109
|
+
const historyHash = (value) => crypto_1.default.createHash('sha256').update(value).digest('hex');
|
|
110
|
+
exports.historyHash = historyHash;
|
|
111
|
+
const historyDirectory = (bucket, id) => path_1.default.join(bucket, 'history', (0, exports.historyHash)(id).slice(0, 40));
|
|
112
|
+
exports.historyDirectory = historyDirectory;
|
|
113
|
+
function historyOf(record) {
|
|
114
|
+
const h = record?.history;
|
|
115
|
+
return h?.version === 3 ? h : undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Account JSON bytes recursively before constructing a potentially enormous string. */
|
|
118
|
+
function jsonBytes(value, max = Number.MAX_SAFE_INTEGER) {
|
|
119
|
+
let bytes = 0;
|
|
120
|
+
const add = (n) => { bytes += n; if (bytes > max)
|
|
121
|
+
throw new history_policy_1.HistoryStoreError('history_payload_too_large', 413); };
|
|
122
|
+
const visit = (v) => {
|
|
123
|
+
if (v === undefined) {
|
|
124
|
+
add(4);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typeof v === 'string') {
|
|
128
|
+
add(2 + Buffer.byteLength(v));
|
|
129
|
+
for (let i = 0; i < v.length; i++) {
|
|
130
|
+
const code = v.charCodeAt(i);
|
|
131
|
+
if (code < 32)
|
|
132
|
+
add(code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 1 : 5);
|
|
133
|
+
else if (code === 34 || code === 92)
|
|
134
|
+
add(1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if (Array.isArray(v)) {
|
|
138
|
+
add(2);
|
|
139
|
+
v.forEach((x, i) => { if (i)
|
|
140
|
+
add(1); visit(x); });
|
|
141
|
+
}
|
|
142
|
+
else if (v && typeof v === 'object') {
|
|
143
|
+
add(2);
|
|
144
|
+
let n = 0;
|
|
145
|
+
for (const [k, x] of Object.entries(v)) {
|
|
146
|
+
if (x === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
if (n++)
|
|
149
|
+
add(1);
|
|
150
|
+
visit(k);
|
|
151
|
+
add(1);
|
|
152
|
+
visit(x);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
add(JSON.stringify(v)?.length ?? 4);
|
|
157
|
+
};
|
|
158
|
+
visit(value);
|
|
159
|
+
return bytes;
|
|
160
|
+
}
|
|
161
|
+
function readChunk(bucket, id, hash) {
|
|
162
|
+
if (!/^[a-f0-9]{64}$/.test(hash))
|
|
163
|
+
throw new history_policy_1.HistoryStoreError('history_unavailable');
|
|
164
|
+
try {
|
|
165
|
+
const file = path_1.default.join((0, exports.historyDirectory)(bucket, id), `${hash}.json`);
|
|
166
|
+
if (fs_1.default.statSync(file).size > history_policy_1.HISTORY_POLICY.chunkBytes)
|
|
167
|
+
throw new Error('oversized chunk');
|
|
168
|
+
const text = fs_1.default.readFileSync(file, 'utf8');
|
|
169
|
+
if ((0, exports.historyHash)(text) !== hash)
|
|
170
|
+
throw new Error('checksum');
|
|
171
|
+
const chunk = JSON.parse(text);
|
|
172
|
+
if (chunk.version !== 3 || chunk.conversationId !== id || !Array.isArray(chunk.messages))
|
|
173
|
+
throw new Error('invalid chunk');
|
|
174
|
+
return chunk;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new history_policy_1.HistoryStoreError('history_unavailable');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function writeImmutable(file, content) {
|
|
181
|
+
if (!fs_1.default.existsSync(file))
|
|
182
|
+
(0, atomic_json_file_1.writeFileAtomic)(file, content, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
function inlineMessage(bucket, id, raw, sequence) {
|
|
185
|
+
const result = { ...raw, id: typeof raw.id === 'string' ? raw.id : (0, exports.historyHash)(`${sequence}:${raw.role}:${raw.at ?? raw.createdAt}:${raw.text}`), sequence };
|
|
186
|
+
if (typeof result.text === 'string' && Buffer.byteLength(result.text) > history_policy_1.HISTORY_POLICY.inlineTextBytes) {
|
|
187
|
+
const hash = (0, exports.historyHash)(result.text);
|
|
188
|
+
const bytes = Buffer.byteLength(result.text);
|
|
189
|
+
writeImmutable(path_1.default.join((0, exports.historyDirectory)(bucket, id), 'blobs', hash), result.text);
|
|
190
|
+
result.contentRef = { hash, bytes };
|
|
191
|
+
result.text = Buffer.from(result.text).subarray(0, 4096).toString('utf8');
|
|
192
|
+
}
|
|
193
|
+
jsonBytes(result, history_policy_1.HISTORY_POLICY.chunkBytes - 1024);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
function appendChunks(bucket, id, messages, tail, revision, commitId) {
|
|
197
|
+
let pending = [];
|
|
198
|
+
let bytes = 512;
|
|
199
|
+
const flush = () => {
|
|
200
|
+
if (!pending.length)
|
|
201
|
+
return;
|
|
202
|
+
const chunk = { version: 3, conversationId: id, previous: tail, messages: pending };
|
|
203
|
+
const text = JSON.stringify(chunk);
|
|
204
|
+
const hash = (0, exports.historyHash)(text);
|
|
205
|
+
writeImmutable(path_1.default.join((0, exports.historyDirectory)(bucket, id), `${hash}.json`), text);
|
|
206
|
+
indexChunk(bucket, id, hash, pending, revision, commitId);
|
|
207
|
+
tail = hash;
|
|
208
|
+
pending = [];
|
|
209
|
+
bytes = 512;
|
|
210
|
+
};
|
|
211
|
+
for (const message of messages) {
|
|
212
|
+
const size = jsonBytes(message) + 1;
|
|
213
|
+
if (bytes + size > history_policy_1.HISTORY_POLICY.chunkBytes)
|
|
214
|
+
flush();
|
|
215
|
+
pending.push(message);
|
|
216
|
+
bytes += size;
|
|
217
|
+
}
|
|
218
|
+
flush();
|
|
219
|
+
return tail;
|
|
220
|
+
}
|
|
221
|
+
/** A newest-revision-first traversal makes corrections authoritative without a growing manifest. */
|
|
222
|
+
function* historyMessagesNewest(bucket, record) {
|
|
223
|
+
const resident = (record.messages || []);
|
|
224
|
+
for (let i = resident.length - 1; i >= 0; i--)
|
|
225
|
+
yield resident[i];
|
|
226
|
+
let tail = historyOf(record)?.archiveTail;
|
|
227
|
+
let hops = 0;
|
|
228
|
+
while (tail) {
|
|
229
|
+
if (++hops > 1_000_000)
|
|
230
|
+
throw new history_policy_1.HistoryStoreError('history_unavailable');
|
|
231
|
+
const chunk = readChunk(bucket, record.id, tail);
|
|
232
|
+
for (let i = chunk.messages.length - 1; i >= 0; i--)
|
|
233
|
+
yield chunk.messages[i];
|
|
234
|
+
tail = chunk.previous;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function prepareConversationHead(bucket, incoming, existing) {
|
|
238
|
+
const revision = Number(existing?.revision || 0) + 1, commitId = crypto_1.default.randomUUID();
|
|
239
|
+
// Reusing a revision after a failed head publication must not authorize orphaned
|
|
240
|
+
// reference pages from the failed transaction. A new nonce invalidates those pages.
|
|
241
|
+
(0, atomic_json_file_1.writeFileAtomic)(path_1.default.join((0, exports.historyDirectory)(bucket, incoming.id), 'commits', `${revision}.json`), JSON.stringify({ id: commitId }), 'utf8');
|
|
242
|
+
const previousHistory = historyOf(existing);
|
|
243
|
+
const oldResident = (existing?.messages || []);
|
|
244
|
+
const messages = incoming.messages ?? oldResident;
|
|
245
|
+
let lastSequence = previousHistory?.lastSequence ?? 0;
|
|
246
|
+
const bySequence = new Map();
|
|
247
|
+
for (const message of oldResident)
|
|
248
|
+
if (message.sequence)
|
|
249
|
+
bySequence.set(message.sequence, message);
|
|
250
|
+
const byId = new Map(oldResident.map(m => [m.id, m]));
|
|
251
|
+
const archivedCorrections = [];
|
|
252
|
+
const hasSequencedInput = messages.some(message => Number.isSafeInteger(message.sequence));
|
|
253
|
+
for (let i = 0; i < messages.length; i++) {
|
|
254
|
+
let raw = messages[i];
|
|
255
|
+
let previous = typeof raw.id === 'string' ? byId.get(raw.id) : undefined;
|
|
256
|
+
if (typeof raw.id !== 'string' && !Number.isSafeInteger(raw.sequence)) {
|
|
257
|
+
const sequence = i + 1;
|
|
258
|
+
if (existing && previousHistory && sequence <= previousHistory.lastSequence)
|
|
259
|
+
previous = historySequenceRange(bucket, existing, sequence, sequence)[0];
|
|
260
|
+
raw = { ...raw, sequence, ...(previous?.id ? { id: previous.id } : {}) };
|
|
261
|
+
}
|
|
262
|
+
if (!previous && !hasSequencedInput && existing && previousHistory && typeof raw.id === 'string' && !raw.sequence) {
|
|
263
|
+
// Legacy callers can resend a complete transcript. Resolve IDs against cold history
|
|
264
|
+
// one chunk at a time, rather than inventing duplicate sequence numbers.
|
|
265
|
+
for (const old of historyMessagesNewest(bucket, existing))
|
|
266
|
+
if (old.id === raw.id) {
|
|
267
|
+
previous = old;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const supplied = Number(raw.sequence);
|
|
272
|
+
const sequence = previous?.sequence ?? (Number.isSafeInteger(supplied) && supplied > 0 && supplied <= lastSequence ? supplied : ++lastSequence);
|
|
273
|
+
const message = inlineMessage(bucket, incoming.id, raw, sequence);
|
|
274
|
+
if (previousHistory && sequence < previousHistory.firstResidentSequence) {
|
|
275
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(message))
|
|
276
|
+
archivedCorrections.push(message);
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
bySequence.set(sequence, message);
|
|
280
|
+
}
|
|
281
|
+
const sorted = [...bySequence.values()].sort((a, b) => a.sequence - b.sequence);
|
|
282
|
+
let bytes = 2, cut = sorted.length;
|
|
283
|
+
while (cut > 0 && sorted.length - cut < history_policy_1.HISTORY_POLICY.residentMessages) {
|
|
284
|
+
const size = jsonBytes(sorted[cut - 1]) + 1;
|
|
285
|
+
if (bytes + size > history_policy_1.HISTORY_POLICY.residentMessageBytes)
|
|
286
|
+
break;
|
|
287
|
+
bytes += size;
|
|
288
|
+
cut--;
|
|
289
|
+
}
|
|
290
|
+
const tail = appendChunks(bucket, incoming.id, [...sorted.slice(0, cut), ...archivedCorrections], previousHistory?.archiveTail ?? null, revision, commitId);
|
|
291
|
+
const resident = sorted.slice(cut);
|
|
292
|
+
const result = {
|
|
293
|
+
...incoming,
|
|
294
|
+
revision,
|
|
295
|
+
messages: resident,
|
|
296
|
+
history: { version: 3, firstResidentSequence: resident[0]?.sequence ?? lastSequence + 1, lastSequence, archiveTail: tail },
|
|
297
|
+
messageCount: lastSequence,
|
|
298
|
+
hasMessageBlobs: Boolean(existing?.hasMessageBlobs || sorted.some(m => m.contentRef || m.rawContentRef) || archivedCorrections.some(m => m.contentRef || m.rawContentRef)),
|
|
299
|
+
hasReviewHandoff: Object.prototype.hasOwnProperty.call(incoming, 'reviewHandoff') ? incoming.reviewHandoff?.reviewRequired === true : Boolean(incoming.hasReviewHandoff),
|
|
300
|
+
hasDelegation: Object.prototype.hasOwnProperty.call(incoming, 'delegation') ? Boolean(incoming.delegation) : Boolean(incoming.hasDelegation),
|
|
301
|
+
uiState: (0, conversation_ui_state_1.conversationUiState)(incoming),
|
|
302
|
+
};
|
|
303
|
+
if (result.run) {
|
|
304
|
+
result.run = { ...result.run };
|
|
305
|
+
delete result.run.messages;
|
|
306
|
+
}
|
|
307
|
+
// Preserve large historical/control values in immutable documents; full consumers must
|
|
308
|
+
// request the named document, while the interactive body remains bounded.
|
|
309
|
+
const refs = { ...(existing?.documentRefs || {}) };
|
|
310
|
+
const storeDocument = (key, value) => {
|
|
311
|
+
jsonBytes(value, history_policy_1.HISTORY_POLICY.controlDocumentBytes);
|
|
312
|
+
const text = JSON.stringify(value), hash = (0, exports.historyHash)(text);
|
|
313
|
+
writeImmutable(path_1.default.join((0, exports.historyDirectory)(bucket, incoming.id), 'documents', hash), text);
|
|
314
|
+
refs[key] = { hash, bytes: Buffer.byteLength(text) };
|
|
315
|
+
};
|
|
316
|
+
if (result.run)
|
|
317
|
+
for (const [key, value] of Object.entries(result.run)) {
|
|
318
|
+
if (value !== undefined && jsonBytes(value, history_policy_1.HISTORY_POLICY.controlDocumentBytes) > 32 * 1024) {
|
|
319
|
+
storeDocument(`run.${key}`, value);
|
|
320
|
+
delete result.run[key];
|
|
321
|
+
}
|
|
322
|
+
else
|
|
323
|
+
delete refs[`run.${key}`];
|
|
324
|
+
}
|
|
325
|
+
for (const [key, value] of Object.entries(result)) {
|
|
326
|
+
if (['messages', 'history', 'documentRefs'].includes(key) || value === undefined)
|
|
327
|
+
continue;
|
|
328
|
+
const size = jsonBytes(value, history_policy_1.HISTORY_POLICY.controlDocumentBytes);
|
|
329
|
+
if (size > 64 * 1024) {
|
|
330
|
+
storeDocument(key, value);
|
|
331
|
+
delete result[key];
|
|
332
|
+
}
|
|
333
|
+
else
|
|
334
|
+
delete refs[key];
|
|
335
|
+
}
|
|
336
|
+
if (Object.keys(refs).length)
|
|
337
|
+
result.documentRefs = refs;
|
|
338
|
+
else
|
|
339
|
+
delete result.documentRefs;
|
|
340
|
+
jsonBytes(result, history_policy_1.HISTORY_POLICY.headBytes - 1024);
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
function messagePage(bucket, record, options = {}) {
|
|
344
|
+
const limit = (0, history_policy_1.historyPageLimit)(options.limit);
|
|
345
|
+
const before = options.beforeSequence ?? Number.MAX_SAFE_INTEGER;
|
|
346
|
+
if (!Number.isSafeInteger(before) || before < 1)
|
|
347
|
+
throw new history_policy_1.HistoryStoreError('invalid_cursor', 400);
|
|
348
|
+
const last = Math.min(before - 1, historyOf(record)?.lastSequence ?? (record.messages?.length || 0));
|
|
349
|
+
const firstSequence = Math.max(1, last - limit);
|
|
350
|
+
const sorted = last >= firstSequence ? historySequenceRange(bucket, record, firstSequence, last).reverse() : [];
|
|
351
|
+
const messages = [];
|
|
352
|
+
let bytes = 512;
|
|
353
|
+
for (const message of sorted.slice(0, limit)) {
|
|
354
|
+
const size = jsonBytes(message);
|
|
355
|
+
if (bytes + size > history_policy_1.HISTORY_POLICY.responseBytes)
|
|
356
|
+
break;
|
|
357
|
+
messages.push(message);
|
|
358
|
+
bytes += size;
|
|
359
|
+
}
|
|
360
|
+
messages.reverse();
|
|
361
|
+
const first = messages[0]?.sequence ?? before;
|
|
362
|
+
return { messages, hasEarlier: sorted.length > messages.length || first > 1, nextBeforeSequence: first > 1 ? first : null, revision: Number(record.revision || 0) };
|
|
363
|
+
}
|