fraim-hub 2.0.338 → 2.0.340
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/registry/scripts/learning/completion-state.js +153 -0
- package/dist/src/ai-hub/desktop-main.js +46 -0
- package/dist/src/ai-hub/hosts.js +18 -0
- package/dist/src/ai-hub/server.js +67 -69
- package/dist/src/core/handoff-contracts.js +617 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +145 -27
- package/dist/src/local-mcp-server/learning-usage-events.js +267 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +626 -88
- package/package.json +5 -2
- package/public/ai-hub/review.css +378 -0
- package/public/ai-hub/script.js +763 -33
- package/public/ai-hub/styles.css +53 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveEventDir = resolveEventDir;
|
|
4
|
+
exports.resolveMachineId = resolveMachineId;
|
|
5
|
+
exports.appendEvents = appendEvents;
|
|
6
|
+
exports.listEventLogs = listEventLogs;
|
|
7
|
+
exports.readEventsSince = readEventsSince;
|
|
8
|
+
exports.compactEventLogs = compactEventLogs;
|
|
9
|
+
/**
|
|
10
|
+
* Per-machine usage journals: job runs append complete JSON lines; sleep folds
|
|
11
|
+
* them into a recoverable snapshot and removes processed records.
|
|
12
|
+
*
|
|
13
|
+
* A cleared journal contains only {_usageJournalThroughBytes:N}. This framing
|
|
14
|
+
* marker preserves its logical position across atomic replacement, so a crash
|
|
15
|
+
* between checkpoint publication and journal cleanup cannot reset offsets or
|
|
16
|
+
* count an event twice. It contains no activity details. New appends continue
|
|
17
|
+
* from N; an older/missing snapshot cannot silently cold-fold compacted history.
|
|
18
|
+
*
|
|
19
|
+
* Maintenance requires no concurrent writers or sync activity. Ordinary writers
|
|
20
|
+
* append one payload; different machines use different filenames.
|
|
21
|
+
*/
|
|
22
|
+
const fs_1 = require("fs");
|
|
23
|
+
const os_1 = require("os");
|
|
24
|
+
const path_1 = require("path");
|
|
25
|
+
/** Subdirectory of the usage folder holding the per-machine logs. */
|
|
26
|
+
const EVENT_DIR_NAME = 'events';
|
|
27
|
+
const LOG_SUFFIX = '.jsonl';
|
|
28
|
+
const CHECKPOINT_MARKER = '_usageJournalThroughBytes';
|
|
29
|
+
/** The log directory that belongs to a snapshot: `<usage>/events/`. */
|
|
30
|
+
function resolveEventDir(snapshotPath) {
|
|
31
|
+
return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The filename component identifying this machine. Two machines must never write
|
|
35
|
+
* one file, which is the entire cross-machine safety argument, so anything that
|
|
36
|
+
* is not a safe filename character is replaced rather than dropped (dropping can
|
|
37
|
+
* collide two different hostnames onto one name).
|
|
38
|
+
*/
|
|
39
|
+
function resolveMachineId() {
|
|
40
|
+
const raw = (() => { try {
|
|
41
|
+
return (0, os_1.hostname)();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return '';
|
|
45
|
+
} })();
|
|
46
|
+
const safe = String(raw || 'unknown-machine').trim().replace(/[^A-Za-z0-9._-]/g, '_');
|
|
47
|
+
return safe || 'unknown-machine';
|
|
48
|
+
}
|
|
49
|
+
function logPathFor(eventDir, machineId) {
|
|
50
|
+
return (0, path_1.join)(eventDir, `${machineId}${LOG_SUFFIX}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Append one JSON line per record. No read, so there is no lost-update window to
|
|
54
|
+
* narrow: the window is gone rather than smaller.
|
|
55
|
+
*/
|
|
56
|
+
function appendEvents(eventDir, records, machineId = resolveMachineId()) {
|
|
57
|
+
if (records.length === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
(0, fs_1.mkdirSync)(eventDir, { recursive: true });
|
|
60
|
+
const logPath = logPathFor(eventDir, machineId);
|
|
61
|
+
const separator = endsOnARecordBoundary(logPath) ? '' : '\n';
|
|
62
|
+
const payload = `${separator}${records.map((record) => JSON.stringify(record)).join('\n')}\n`;
|
|
63
|
+
(0, fs_1.appendFileSync)(logPath, payload, 'utf8');
|
|
64
|
+
return records.length;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Does the log already end where a record ends?
|
|
68
|
+
*
|
|
69
|
+
* A process killed part-way through an append leaves a line with no terminator.
|
|
70
|
+
* Appending straight onto it concatenates the torn record and the new one into a
|
|
71
|
+
* single unparseable line, so one interrupted write costs two events rather than
|
|
72
|
+
* one. Found in the #1752 bug bash. Closing the boundary first means a torn write
|
|
73
|
+
* loses only itself.
|
|
74
|
+
*
|
|
75
|
+
* One byte read per append, and only when the log already exists.
|
|
76
|
+
*/
|
|
77
|
+
function endsOnARecordBoundary(logPath) {
|
|
78
|
+
try {
|
|
79
|
+
const fd = (0, fs_1.openSync)(logPath, 'r');
|
|
80
|
+
try {
|
|
81
|
+
const size = (0, fs_1.fstatSync)(fd).size;
|
|
82
|
+
if (size === 0)
|
|
83
|
+
return true;
|
|
84
|
+
const last = Buffer.alloc(1);
|
|
85
|
+
(0, fs_1.readSync)(fd, last, 0, 1, size - 1);
|
|
86
|
+
return last[0] === 0x0a;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
(0, fs_1.closeSync)(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// No log yet, or it cannot be read. A fresh file starts on a boundary, and an
|
|
94
|
+
// unreadable one is the fold's problem rather than the append's.
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function listEventLogs(eventDir) {
|
|
99
|
+
if (!(0, fs_1.existsSync)(eventDir))
|
|
100
|
+
return [];
|
|
101
|
+
let names;
|
|
102
|
+
try {
|
|
103
|
+
names = (0, fs_1.readdirSync)(eventDir);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const logs = [];
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
if (!name.endsWith(LOG_SUFFIX))
|
|
111
|
+
continue;
|
|
112
|
+
const path = (0, path_1.join)(eventDir, name);
|
|
113
|
+
let size = 0;
|
|
114
|
+
let baseOffset = 0;
|
|
115
|
+
let headerBytes = 0;
|
|
116
|
+
try {
|
|
117
|
+
const fd = (0, fs_1.openSync)(path, 'r');
|
|
118
|
+
try {
|
|
119
|
+
size = (0, fs_1.fstatSync)(fd).size;
|
|
120
|
+
const prefix = Buffer.alloc(Math.min(size, 128));
|
|
121
|
+
(0, fs_1.readSync)(fd, prefix, 0, prefix.length, 0);
|
|
122
|
+
const newline = prefix.indexOf(0x0a);
|
|
123
|
+
if (newline >= 0) {
|
|
124
|
+
try {
|
|
125
|
+
const header = JSON.parse(prefix.subarray(0, newline).toString('utf8'));
|
|
126
|
+
if (Object.keys(header).length === 1 && Number.isSafeInteger(header[CHECKPOINT_MARKER]) && header[CHECKPOINT_MARKER] >= 0) {
|
|
127
|
+
baseOffset = header[CHECKPOINT_MARKER];
|
|
128
|
+
headerBytes = newline + 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* An ordinary first event, not a checkpoint marker. */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
(0, fs_1.closeSync)(fd);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size, baseOffset, headerBytes });
|
|
142
|
+
}
|
|
143
|
+
return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
|
|
144
|
+
}
|
|
145
|
+
function readTail(log, from) {
|
|
146
|
+
const end = log.baseOffset + log.size - log.headerBytes;
|
|
147
|
+
if (from < log.baseOffset)
|
|
148
|
+
throw new Error('Usage checkpoint is missing or older than the consolidated journal. Restore learning-usage.json or its .checkpoint backup; raw retrospectives cannot restore runtime offers.');
|
|
149
|
+
const restarted = from > end;
|
|
150
|
+
if (restarted && log.baseOffset > 0)
|
|
151
|
+
throw new Error('Usage journal is behind its checkpoint; restore matching files before continuing.');
|
|
152
|
+
const start = restarted ? 0 : Math.max(0, from);
|
|
153
|
+
const physicalStart = start - log.baseOffset + log.headerBytes;
|
|
154
|
+
const length = log.size - physicalStart;
|
|
155
|
+
if (length <= 0) {
|
|
156
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
157
|
+
}
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
const fd = (0, fs_1.openSync)(log.path, 'r');
|
|
161
|
+
try {
|
|
162
|
+
const buffer = Buffer.alloc(length);
|
|
163
|
+
const read = (0, fs_1.readSync)(fd, buffer, 0, length, physicalStart);
|
|
164
|
+
text = buffer.subarray(0, read).toString('utf8');
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
(0, fs_1.closeSync)(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Unreadable right now (locked, mid-sync). The checkpoint does not advance,
|
|
172
|
+
// so the next reader picks it up. Degrading to what is readable is the
|
|
173
|
+
// existing contract for this derived data.
|
|
174
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
175
|
+
}
|
|
176
|
+
// Only complete lines are consumed. A trailing partial is what an interrupted
|
|
177
|
+
// append leaves behind, and advancing past it would lose the record for good.
|
|
178
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
179
|
+
if (lastNewline === -1) {
|
|
180
|
+
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
181
|
+
}
|
|
182
|
+
const complete = text.slice(0, lastNewline);
|
|
183
|
+
const partialBytes = Buffer.byteLength(text.slice(lastNewline + 1), 'utf8');
|
|
184
|
+
const records = [];
|
|
185
|
+
let skippedLines = 0;
|
|
186
|
+
for (const line of complete.split('\n')) {
|
|
187
|
+
const trimmed = line.trim();
|
|
188
|
+
if (!trimmed)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(trimmed);
|
|
192
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
193
|
+
records.push(parsed);
|
|
194
|
+
else
|
|
195
|
+
skippedLines += 1;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
skippedLines += 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ...log, records, skippedLines, through: end - partialBytes, restarted };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Read every log's tail past its checkpoint.
|
|
205
|
+
*
|
|
206
|
+
* A checkpoint past the end of its log means the log was compacted or replaced
|
|
207
|
+
* underneath the snapshot. `restarted` says so, and the caller discards the whole
|
|
208
|
+
* snapshot rather than returning a short count that looks like real data.
|
|
209
|
+
*/
|
|
210
|
+
function readEventsSince(eventDir, checkpoints) {
|
|
211
|
+
const reads = listEventLogs(eventDir).map((log) => readTail(log, checkpoints[log.machineId] ?? 0));
|
|
212
|
+
return {
|
|
213
|
+
reads,
|
|
214
|
+
skippedLines: reads.reduce((sum, r) => sum + r.skippedLines, 0),
|
|
215
|
+
restarted: reads.some((r) => r.restarted),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Clear fully checkpointed logs whose records are all understood by this reader.
|
|
220
|
+
* Preserve unknown, malformed, partial or newly appended logs byte-for-byte.
|
|
221
|
+
* Flush then atomically replace each log with its logical-position header; the
|
|
222
|
+
* checkpoint saved before this call remains correct on either side of a crash.
|
|
223
|
+
*/
|
|
224
|
+
function compactEventLogs(eventDir, keep, checkpoints) {
|
|
225
|
+
const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [], checkpoints: { ...checkpoints } };
|
|
226
|
+
for (const log of listEventLogs(eventDir)) {
|
|
227
|
+
const read = readTail(log, log.baseOffset);
|
|
228
|
+
result.scanned += read.records.length;
|
|
229
|
+
result.skippedLines += read.skippedLines;
|
|
230
|
+
result.kept += read.records.length;
|
|
231
|
+
// The snapshot knows nothing about a later append or torn
|
|
232
|
+
// tail. Leave that log and its old checkpoint alone for the next fold.
|
|
233
|
+
if (read.through !== log.baseOffset + log.size - log.headerBytes
|
|
234
|
+
|| read.through !== (checkpoints[log.machineId] ?? 0) || read.skippedLines > 0)
|
|
235
|
+
continue;
|
|
236
|
+
const retained = read.records.filter(keep);
|
|
237
|
+
// Unknown records may belong to a newer reader. Leave their whole log
|
|
238
|
+
// intact so their original positions remain meaningful.
|
|
239
|
+
if (retained.length > 0 || read.records.length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
const temp = `${log.path}.compacting`;
|
|
242
|
+
try {
|
|
243
|
+
const payload = `${JSON.stringify({ [CHECKPOINT_MARKER]: read.through })}\n`;
|
|
244
|
+
(0, fs_1.writeFileSync)(temp, payload, { encoding: 'utf8', flush: true });
|
|
245
|
+
if ((0, fs_1.statSync)(log.path).size !== log.size) {
|
|
246
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
(0, fs_1.renameSync)(temp, log.path);
|
|
250
|
+
const dropped = read.records.length - retained.length;
|
|
251
|
+
result.kept -= dropped;
|
|
252
|
+
result.dropped += dropped;
|
|
253
|
+
// The logical checkpoint remains unchanged across the atomic replacement.
|
|
254
|
+
result.checkpoints[log.machineId] = read.through;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// The log is authoritative and the original is still intact, so a failed
|
|
258
|
+
// compaction costs a slower cold fold and nothing else.
|
|
259
|
+
try {
|
|
260
|
+
(0, fs_1.rmSync)(temp, { force: true });
|
|
261
|
+
}
|
|
262
|
+
catch { /* best effort */ }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
result.logs = listEventLogs(eventDir);
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
@@ -50,30 +50,34 @@ function buildUsageLookup(store) {
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolve the usage for one entry from whichever handles it has. Prefers the id,
|
|
52
52
|
* because a title is only a fallback for entries the id migration has not reached.
|
|
53
|
+
*
|
|
54
|
+
* Issue #1752 A1: the preference order is `resolveEntryIdentity`'s, not a second
|
|
55
|
+
* copy of it. The title key is still tried afterwards, because a record written
|
|
56
|
+
* before the entry was stamped is reachable only by title.
|
|
53
57
|
*/
|
|
54
58
|
function lookupUsage(lookup, id, fileType, title) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title));
|
|
59
|
+
const identity = (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType, title });
|
|
60
|
+
const resolved = lookup.get(identity.key);
|
|
61
|
+
if (resolved)
|
|
62
|
+
return resolved;
|
|
63
|
+
return identity.source === 'id' ? lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title)) : undefined;
|
|
61
64
|
}
|
|
62
65
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
+
* Issue #1752 C1 — "the current model" comes from the caller, never from the store.
|
|
67
|
+
*
|
|
68
|
+
* This used to return the model of the globally most-recent offer. The store is
|
|
69
|
+
* shared by concurrent sessions from two machines and two agents, so that reports
|
|
70
|
+
* whichever agent wrote last. On the sleep-on-learnings run of 2026-09-18 it
|
|
71
|
+
* answered `gpt-5` while the session was Claude Opus 5, and since retirement
|
|
72
|
+
* candidacy is computed per model, every per-model conclusion in that run was
|
|
73
|
+
* computed against a model that was not running.
|
|
74
|
+
*
|
|
75
|
+
* Resolution order is the explicit flag, then `FRAIM_ACTIVE_MODEL`, then null.
|
|
76
|
+
* That follows the existing `FRAIM_ACTIVE_USER_EMAIL` convention, which is
|
|
77
|
+
* read-only and operator-set, so it introduces no new configuration mechanism.
|
|
78
|
+
* Null is reported as `unknown` and suppresses every per-model conclusion rather
|
|
79
|
+
* than being computed against a null bucket.
|
|
66
80
|
*/
|
|
67
|
-
function resolveCurrentModel(
|
|
68
|
-
|
|
69
|
-
let bestModel = null;
|
|
70
|
-
for (const record of Object.values(store.entries)) {
|
|
71
|
-
for (const entry of record.offered.log) {
|
|
72
|
-
if (entry.model && entry.date >= bestDate) {
|
|
73
|
-
bestDate = entry.date;
|
|
74
|
-
bestModel = entry.model;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return bestModel;
|
|
81
|
+
function resolveCurrentModel(explicit) {
|
|
82
|
+
return (0, learning_usage_store_1.normalizeModelName)(explicit) ?? (0, learning_usage_store_1.normalizeModelName)(process.env.FRAIM_ACTIVE_MODEL);
|
|
79
83
|
}
|