fraim 2.0.338 → 2.0.339

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.
@@ -0,0 +1,261 @@
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
+ * Issue #1752 — the durable half of the usage record.
11
+ *
12
+ * The store used to be one mutable JSON document rewritten by every writer with a
13
+ * plain `writeFileSync`. Measured (spike S1b): eight concurrent processes doing
14
+ * that on a *plain local disk*, no sync folder involved, lost 1589 of 1600
15
+ * writes. The four OneDrive conflict copies in issue #1752 are a symptom of the
16
+ * same defect, not its cause.
17
+ *
18
+ * So the durable artifact is an append-only log, one file per machine:
19
+ *
20
+ * OFFER/FIRING event → appendFileSync of one whole JSON line
21
+ * usage/learning-usage.json → a derived snapshot with a byte checkpoint
22
+ *
23
+ * Three measurements decided the shape, and each closed off an alternative:
24
+ *
25
+ * S2 16 processes x 1000 lines on the real OneDrive-synced path produced
26
+ * 16000/16000 complete parseable lines, 0 interleaved, 0 conflict copies.
27
+ * That is what removes the need for a same-machine lock. The per-machine
28
+ * filename is what removes the cross-machine question, because two
29
+ * machines then never write one file.
30
+ * S3 `renameSync` over a target any process holds open fails EPERM 100 times
31
+ * in 100 on Windows, on a synced path *and* a local one, because Node's
32
+ * `openSync(path,'r')` does not request FILE_SHARE_DELETE. The usage store
33
+ * is read by concurrent MCP processes by design, so rename-publish is not
34
+ * available here. Recorded so it is not re-proposed. See also #820.
35
+ * S4b A full fold is linear: 84 ms at today's volume, 794 ms at ten times it.
36
+ * A snapshot plus a tail fold is 2.6 ms at both 9.9 MB and 79 MB. Flat.
37
+ *
38
+ * This module owns the log files and nothing else: framing, appending, reading a
39
+ * tail from a byte offset, and compaction. It knows a line is a JSON object and
40
+ * knows nothing about what a usage record looks like. That is deliberate — the
41
+ * record shape lives in `learning-usage-store.ts`, which imports this, so there
42
+ * is no cycle between them.
43
+ *
44
+ * The same primitive is already used by `src/ai-hub/raw-event-log-store.ts`
45
+ * (append one JSON line, track byte offsets); this is the learning-usage
46
+ * equivalent rather than a second general mechanism.
47
+ */
48
+ const fs_1 = require("fs");
49
+ const os_1 = require("os");
50
+ const path_1 = require("path");
51
+ /** Subdirectory of the usage folder holding the per-machine logs. */
52
+ const EVENT_DIR_NAME = 'events';
53
+ const LOG_SUFFIX = '.jsonl';
54
+ /** The log directory that belongs to a snapshot: `<usage>/events/`. */
55
+ function resolveEventDir(snapshotPath) {
56
+ return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
57
+ }
58
+ /**
59
+ * The filename component identifying this machine. Two machines must never write
60
+ * one file, which is the entire cross-machine safety argument, so anything that
61
+ * is not a safe filename character is replaced rather than dropped (dropping can
62
+ * collide two different hostnames onto one name).
63
+ */
64
+ function resolveMachineId() {
65
+ const raw = (() => { try {
66
+ return (0, os_1.hostname)();
67
+ }
68
+ catch {
69
+ return '';
70
+ } })();
71
+ const safe = String(raw || 'unknown-machine').trim().replace(/[^A-Za-z0-9._-]/g, '_');
72
+ return safe || 'unknown-machine';
73
+ }
74
+ function logPathFor(eventDir, machineId) {
75
+ return (0, path_1.join)(eventDir, `${machineId}${LOG_SUFFIX}`);
76
+ }
77
+ /**
78
+ * Append one JSON line per record. No read, so there is no lost-update window to
79
+ * narrow: the window is gone rather than smaller.
80
+ */
81
+ function appendEvents(eventDir, records, machineId = resolveMachineId()) {
82
+ if (records.length === 0)
83
+ return 0;
84
+ (0, fs_1.mkdirSync)(eventDir, { recursive: true });
85
+ const logPath = logPathFor(eventDir, machineId);
86
+ const separator = endsOnARecordBoundary(logPath) ? '' : '\n';
87
+ const payload = `${separator}${records.map((record) => JSON.stringify(record)).join('\n')}\n`;
88
+ (0, fs_1.appendFileSync)(logPath, payload, 'utf8');
89
+ return records.length;
90
+ }
91
+ /**
92
+ * Does the log already end where a record ends?
93
+ *
94
+ * A process killed part-way through an append leaves a line with no terminator.
95
+ * Appending straight onto it concatenates the torn record and the new one into a
96
+ * single unparseable line, so one interrupted write costs two events rather than
97
+ * one. Found in the #1752 bug bash. Closing the boundary first means a torn write
98
+ * loses only itself.
99
+ *
100
+ * One byte read per append, and only when the log already exists.
101
+ */
102
+ function endsOnARecordBoundary(logPath) {
103
+ try {
104
+ const fd = (0, fs_1.openSync)(logPath, 'r');
105
+ try {
106
+ const size = (0, fs_1.fstatSync)(fd).size;
107
+ if (size === 0)
108
+ return true;
109
+ const last = Buffer.alloc(1);
110
+ (0, fs_1.readSync)(fd, last, 0, 1, size - 1);
111
+ return last[0] === 0x0a;
112
+ }
113
+ finally {
114
+ (0, fs_1.closeSync)(fd);
115
+ }
116
+ }
117
+ catch {
118
+ // No log yet, or it cannot be read. A fresh file starts on a boundary, and an
119
+ // unreadable one is the fold's problem rather than the append's.
120
+ return true;
121
+ }
122
+ }
123
+ function listEventLogs(eventDir) {
124
+ if (!(0, fs_1.existsSync)(eventDir))
125
+ return [];
126
+ let names;
127
+ try {
128
+ names = (0, fs_1.readdirSync)(eventDir);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ const logs = [];
134
+ for (const name of names) {
135
+ if (!name.endsWith(LOG_SUFFIX))
136
+ continue;
137
+ const path = (0, path_1.join)(eventDir, name);
138
+ let size = 0;
139
+ try {
140
+ const fd = (0, fs_1.openSync)(path, 'r');
141
+ try {
142
+ size = (0, fs_1.fstatSync)(fd).size;
143
+ }
144
+ finally {
145
+ (0, fs_1.closeSync)(fd);
146
+ }
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size });
152
+ }
153
+ return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
154
+ }
155
+ function readTail(log, from) {
156
+ const restarted = from > log.size;
157
+ const start = restarted ? 0 : Math.max(0, from);
158
+ const length = log.size - start;
159
+ if (length <= 0) {
160
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
161
+ }
162
+ let text;
163
+ try {
164
+ const fd = (0, fs_1.openSync)(log.path, 'r');
165
+ try {
166
+ const buffer = Buffer.alloc(length);
167
+ const read = (0, fs_1.readSync)(fd, buffer, 0, length, start);
168
+ text = buffer.subarray(0, read).toString('utf8');
169
+ }
170
+ finally {
171
+ (0, fs_1.closeSync)(fd);
172
+ }
173
+ }
174
+ catch {
175
+ // Unreadable right now (locked, mid-sync). The checkpoint does not advance,
176
+ // so the next reader picks it up. Degrading to what is readable is the
177
+ // existing contract for this derived data.
178
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
179
+ }
180
+ // Only complete lines are consumed. A trailing partial is what an interrupted
181
+ // append leaves behind, and advancing past it would lose the record for good.
182
+ const lastNewline = text.lastIndexOf('\n');
183
+ if (lastNewline === -1) {
184
+ return { ...log, records: [], skippedLines: 0, through: start, restarted };
185
+ }
186
+ const complete = text.slice(0, lastNewline);
187
+ const partialBytes = Buffer.byteLength(text.slice(lastNewline + 1), 'utf8');
188
+ const records = [];
189
+ let skippedLines = 0;
190
+ for (const line of complete.split('\n')) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed)
193
+ continue;
194
+ try {
195
+ const parsed = JSON.parse(trimmed);
196
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
197
+ records.push(parsed);
198
+ else
199
+ skippedLines += 1;
200
+ }
201
+ catch {
202
+ skippedLines += 1;
203
+ }
204
+ }
205
+ return { ...log, records, skippedLines, through: log.size - partialBytes, restarted };
206
+ }
207
+ /**
208
+ * Read every log's tail past its checkpoint.
209
+ *
210
+ * A checkpoint past the end of its log means the log was compacted or replaced
211
+ * underneath the snapshot. `restarted` says so, and the caller discards the whole
212
+ * snapshot rather than returning a short count that looks like real data.
213
+ */
214
+ function readEventsSince(eventDir, checkpoints) {
215
+ const reads = listEventLogs(eventDir).map((log) => readTail(log, checkpoints[log.machineId] ?? 0));
216
+ return {
217
+ reads,
218
+ skippedLines: reads.reduce((sum, r) => sum + r.skippedLines, 0),
219
+ restarted: reads.some((r) => r.restarted),
220
+ };
221
+ }
222
+ /**
223
+ * Rewrite every log keeping only the records `keep` accepts.
224
+ *
225
+ * Required rather than an optimization: S4 measured a cold full fold growing
226
+ * linearly to 794 ms at ten times today's volume, and the checkpoint only makes a
227
+ * cold fold rare, not bounded.
228
+ *
229
+ * Published by writing a temp file and renaming it. That is safe here and not on
230
+ * the snapshot (S3), because nothing holds a compacted log open for reading in
231
+ * the way concurrent MCP processes hold the snapshot: this runs from the CLI, and
232
+ * a failed rename leaves the original log untouched.
233
+ */
234
+ function compactEventLogs(eventDir, keep) {
235
+ const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [] };
236
+ for (const log of listEventLogs(eventDir)) {
237
+ const read = readTail(log, 0);
238
+ result.scanned += read.records.length;
239
+ result.skippedLines += read.skippedLines;
240
+ const retained = read.records.filter(keep);
241
+ result.kept += retained.length;
242
+ result.dropped += read.records.length - retained.length;
243
+ if (retained.length === read.records.length && read.skippedLines === 0)
244
+ continue;
245
+ const temp = `${log.path}.compacting`;
246
+ try {
247
+ (0, fs_1.writeFileSync)(temp, retained.length ? `${retained.map((r) => JSON.stringify(r)).join('\n')}\n` : '', 'utf8');
248
+ (0, fs_1.renameSync)(temp, log.path);
249
+ }
250
+ catch {
251
+ // The log is authoritative and the original is still intact, so a failed
252
+ // compaction costs a slower cold fold and nothing else.
253
+ try {
254
+ (0, fs_1.rmSync)(temp, { force: true });
255
+ }
256
+ catch { /* best effort */ }
257
+ }
258
+ }
259
+ result.logs = listEventLogs(eventDir);
260
+ return result;
261
+ }
@@ -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
- if (id) {
56
- const byId = lookup.get(id);
57
- if (byId)
58
- return byId;
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
- * The model with the most recent offer. Used as "the current model" for the
64
- * per-model retirement question, so it follows the agent in use rather than
65
- * needing to be configured.
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(store) {
68
- let bestDate = '';
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
  }