fraim-hub 2.0.337 → 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.
- package/dist/registry/scripts/learning/entry-format.js +27 -0
- package/dist/registry/scripts/learning/learning-domains.json +78 -0
- package/dist/src/ai-hub/managed-browser.js +130 -5
- package/dist/src/ai-hub/server.js +61 -31
- package/dist/src/cli/utils/pack-home.js +4 -2
- package/dist/src/config/learning-domains.js +10 -113
- package/dist/src/core/utils/local-registry-resolver.js +6 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +49 -8
- package/dist/src/local-mcp-server/learning-usage-events.js +261 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +589 -82
- package/package.json +7 -2
|
@@ -12,130 +12,27 @@
|
|
|
12
12
|
* the current job's-domain learnings only, so a marketing job no longer loads
|
|
13
13
|
* engineering learnings.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
* registry-category -> domain map, the per-domain authoring voice (R3)
|
|
17
|
-
* resolver used at the injection sites
|
|
15
|
+
* The shared registry/scripts/learning/learning-domains.json owns the domain list, the
|
|
16
|
+
* registry-category -> domain map, and the per-domain authoring voice (R3).
|
|
17
|
+
* This module provides the pure resolver used at the injection sites, following the
|
|
18
18
|
* config-module convention of `src/config/portfolio-slug-overrides.ts`.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = void 0;
|
|
21
|
+
exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = exports.LEARNING_DOMAIN_MAP_VERSION = void 0;
|
|
22
22
|
exports.isLearningDomain = isLearningDomain;
|
|
23
23
|
exports.jobCategoryFromRegistryPath = jobCategoryFromRegistryPath;
|
|
24
24
|
exports.resolveJobDomain = resolveJobDomain;
|
|
25
25
|
exports.domainFromJobFrontmatter = domainFromJobFrontmatter;
|
|
26
26
|
exports.resolveDomainForJob = resolveDomainForJob;
|
|
27
|
-
|
|
28
|
-
exports.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'finance',
|
|
34
|
-
'legal',
|
|
35
|
-
'compliance',
|
|
36
|
-
'customer',
|
|
37
|
-
'company-building',
|
|
38
|
-
'operations',
|
|
39
|
-
];
|
|
27
|
+
const learningDomainData = require('../../registry/scripts/learning/learning-domains.json');
|
|
28
|
+
exports.LEARNING_DOMAIN_MAP_VERSION = learningDomainData.mapVersion;
|
|
29
|
+
exports.LEARNING_DOMAINS = Object.freeze(learningDomainData.domains);
|
|
30
|
+
exports.JOB_DOMAIN_MAP = Object.freeze(learningDomainData.jobDomainMap);
|
|
31
|
+
exports.GLOBAL_JOB_CATEGORIES = new Set(learningDomainData.globalJobCategories);
|
|
32
|
+
exports.DOMAIN_VOICE = Object.freeze(learningDomainData.domainVoice);
|
|
40
33
|
function isLearningDomain(value) {
|
|
41
34
|
return typeof value === 'string' && exports.LEARNING_DOMAINS.includes(value);
|
|
42
35
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Registry job category -> learning domain. Seeded from the registry taxonomy and
|
|
45
|
-
* the `PERSONA_CAPABILITY_BUNDLES` groupings. Every domain-scoped category maps
|
|
46
|
-
* here; cross-cutting categories are listed in `GLOBAL_JOB_CATEGORIES` instead.
|
|
47
|
-
*
|
|
48
|
-
* A build-time test (`tests/test-unit-learning-domains.ts`) asserts that every
|
|
49
|
-
* registry job category (from `registry/jobs/**`) is covered by exactly one of
|
|
50
|
-
* this map or `GLOBAL_JOB_CATEGORIES`, so the taxonomy cannot silently drift as
|
|
51
|
-
* new job categories are added.
|
|
52
|
-
*/
|
|
53
|
-
exports.JOB_DOMAIN_MAP = {
|
|
54
|
-
// engineering
|
|
55
|
-
'product-building': 'engineering',
|
|
56
|
-
'dev-ops': 'engineering',
|
|
57
|
-
'quality-assurance': 'engineering',
|
|
58
|
-
'migration': 'engineering',
|
|
59
|
-
'replicate': 'engineering',
|
|
60
|
-
'reviewer': 'engineering',
|
|
61
|
-
'security': 'engineering',
|
|
62
|
-
'delivery-ops': 'engineering',
|
|
63
|
-
'salesforce': 'engineering',
|
|
64
|
-
'ai-engineering': 'engineering',
|
|
65
|
-
// product
|
|
66
|
-
'product-management': 'product',
|
|
67
|
-
'customer-development': 'product',
|
|
68
|
-
'brainstorming': 'product',
|
|
69
|
-
// go-to-market
|
|
70
|
-
'marketing': 'go-to-market',
|
|
71
|
-
'branding': 'go-to-market',
|
|
72
|
-
'sales': 'go-to-market',
|
|
73
|
-
'go-to-market': 'go-to-market',
|
|
74
|
-
'business-development': 'go-to-market',
|
|
75
|
-
'commercial-ops': 'go-to-market',
|
|
76
|
-
// people-ops
|
|
77
|
-
'people-ops': 'people-ops',
|
|
78
|
-
'inclusion-ops': 'people-ops',
|
|
79
|
-
'career': 'people-ops',
|
|
80
|
-
// finance
|
|
81
|
-
'finance': 'finance',
|
|
82
|
-
'invoicing': 'finance',
|
|
83
|
-
'banking': 'finance',
|
|
84
|
-
// legal
|
|
85
|
-
'legal': 'legal',
|
|
86
|
-
// compliance
|
|
87
|
-
'compliance': 'compliance',
|
|
88
|
-
// customer
|
|
89
|
-
'customer-success': 'customer',
|
|
90
|
-
'customer-communication': 'customer',
|
|
91
|
-
// company-building
|
|
92
|
-
'company-creation': 'company-building',
|
|
93
|
-
'bootstrap': 'company-building',
|
|
94
|
-
'fundraising': 'company-building',
|
|
95
|
-
// operations
|
|
96
|
-
'business-ops': 'operations',
|
|
97
|
-
'procurement': 'operations',
|
|
98
|
-
'project-management': 'operations',
|
|
99
|
-
};
|
|
100
|
-
/**
|
|
101
|
-
* Cross-cutting job categories that intentionally resolve to `global` (no domain).
|
|
102
|
-
* Management and framework-meta learnings apply regardless of the work domain, so
|
|
103
|
-
* they are never hidden from any job. Listed explicitly so the completeness test
|
|
104
|
-
* can tell an intentional global category apart from a forgotten mapping.
|
|
105
|
-
*/
|
|
106
|
-
exports.GLOBAL_JOB_CATEGORIES = new Set([
|
|
107
|
-
// ai-employee cross-cutting
|
|
108
|
-
'manager',
|
|
109
|
-
'fraim',
|
|
110
|
-
// ai-manager (all management categories)
|
|
111
|
-
'1-1',
|
|
112
|
-
'coaching',
|
|
113
|
-
'delegation',
|
|
114
|
-
'hiring',
|
|
115
|
-
'productivity',
|
|
116
|
-
'project-setup',
|
|
117
|
-
'setup-guardrails',
|
|
118
|
-
'team-learning',
|
|
119
|
-
'verification',
|
|
120
|
-
]);
|
|
121
|
-
/**
|
|
122
|
-
* One-line authoring voice per domain (R3). Consumed as documentation and by the
|
|
123
|
-
* synthesis jobs/skills (`sleep-on-learnings`, `organizational-learning-synthesis`,
|
|
124
|
-
* `l1-signal-classification`) so a domain-specific learning is written in that
|
|
125
|
-
* domain's language and stays human-auditable.
|
|
126
|
-
*/
|
|
127
|
-
exports.DOMAIN_VOICE = {
|
|
128
|
-
'engineering': 'Precise and technical; file paths, commands, and test names are appropriate.',
|
|
129
|
-
'product': 'Product and user-outcome language; behavior and flow, not code symbols.',
|
|
130
|
-
'go-to-market': 'Marketing and sales language; campaigns, funnels, messaging, audience.',
|
|
131
|
-
'people-ops': 'Plain HR and recruiting language; no engineering jargon.',
|
|
132
|
-
'finance': 'Finance and accounting language; figures, close, forecasts.',
|
|
133
|
-
'legal': 'Contract and IP language; obligations, clauses, risk.',
|
|
134
|
-
'compliance': 'Controls, evidence, and regulation language.',
|
|
135
|
-
'customer': 'Customer-relationship and support language.',
|
|
136
|
-
'company-building': 'Founder and operations language; setup, entity, funding.',
|
|
137
|
-
'operations': 'Business-operations language; process, vendors, planning, coordination.',
|
|
138
|
-
};
|
|
139
36
|
/**
|
|
140
37
|
* Extract the registry job category from a job's registry path.
|
|
141
38
|
*
|
|
@@ -102,11 +102,17 @@ class LocalRegistryResolver {
|
|
|
102
102
|
(0, path_1.join)(this.managerCacheRoot, dir),
|
|
103
103
|
(0, path_1.join)(this.orgCacheRoot, dir),
|
|
104
104
|
(0, path_1.join)(this.workspaceRoot, 'registry', dir),
|
|
105
|
+
// Installed projects have categorized job stubs in the synced roles,
|
|
106
|
+
// without a source registry checkout. Keep authored overrides first.
|
|
107
|
+
...(type === 'jobs' ? [this.getFraimPath('ai-employee', dir), this.getFraimPath('ai-manager', dir)] : []),
|
|
105
108
|
];
|
|
106
109
|
for (const root of searchRoots) {
|
|
107
110
|
const found = this.searchFileRecursively(root, baseName);
|
|
108
111
|
if (!found)
|
|
109
112
|
continue;
|
|
113
|
+
const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.getFraimPath(role, dir));
|
|
114
|
+
if (syncedRole)
|
|
115
|
+
return `${dir}/${syncedRole}/${found.slice(root.length + 1).replace(/\\/g, '/')}`;
|
|
110
116
|
// Convert absolute back to a registry-relative path.
|
|
111
117
|
const rel = found.replace(/\\/g, '/');
|
|
112
118
|
const dirMarker = `/${dir}/`;
|
|
@@ -890,8 +890,9 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
890
890
|
out.push({
|
|
891
891
|
// The store owns key construction. Building the composite key by hand here
|
|
892
892
|
// would be a second definition of what an entry's identity is, and the two
|
|
893
|
-
// would drift the first time normalization changed.
|
|
894
|
-
|
|
893
|
+
// would drift the first time normalization changed. Issue #1752 A1 made
|
|
894
|
+
// that one function rather than four copies of the same expression.
|
|
895
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
|
|
895
896
|
family: 'learning',
|
|
896
897
|
id: current.id,
|
|
897
898
|
title: current.title,
|
|
@@ -939,9 +940,27 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
|
|
|
939
940
|
* listed. `aboveThreshold` is what separates active guidance from the dormant
|
|
940
941
|
* rollup. Nothing here depends on the agent reading anything.
|
|
941
942
|
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
943
|
+
* ## Issue #1752 A2 — one lesson, one offer, stamped or not
|
|
944
|
+
*
|
|
945
|
+
* This deduped by usage key, which sounds like the same thing as the context
|
|
946
|
+
* reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
|
|
947
|
+
* both the org pack copy and the repo-local copy of an org family, because #1002
|
|
948
|
+
* R4 requires that a promoted entry not hide the rest of its family. The context
|
|
949
|
+
* reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
|
|
950
|
+
* the normalized title, so the agent sees one lesson. Keying the offer record on
|
|
951
|
+
* `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
|
|
952
|
+
* in one tier and not the other produced two keys, and both were recorded.
|
|
953
|
+
*
|
|
954
|
+
* That is defect 4 in #1752, and it is why `standing --changes-only` could
|
|
955
|
+
* recommend promoting and demoting the same lesson in one response: the
|
|
956
|
+
* title-keyed twin collected the offers while the id-keyed one collected the
|
|
957
|
+
* firings (the firing path never split, because `buildEntryIndex` dedups by
|
|
958
|
+
* identity first-wins). Measured live: five titles emitting two keys each in a
|
|
959
|
+
* single derived offer batch.
|
|
960
|
+
*
|
|
961
|
+
* So group by the same normalized title the context reader groups by, resolve one
|
|
962
|
+
* identity per group preferring whichever tier carries an id, and let the later
|
|
963
|
+
* tier win on content exactly as before.
|
|
945
964
|
*/
|
|
946
965
|
function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
947
966
|
const roots = getLearningRoots(workspaceRoot);
|
|
@@ -950,13 +969,35 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
|
|
|
950
969
|
const usageClock = resolveUsageClock(workspaceRoot);
|
|
951
970
|
const now = new Date();
|
|
952
971
|
const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
|
|
953
|
-
|
|
972
|
+
// Grouped by family plus normalized title, which is what `titleUsageKey`
|
|
973
|
+
// already computes. Two families holding the same title are two lessons, and
|
|
974
|
+
// that is the existing rule rather than a new one.
|
|
975
|
+
const byLesson = new Map();
|
|
954
976
|
for (const file of [...resolved.l2, ...resolved.l1]) {
|
|
955
977
|
for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
|
|
956
|
-
|
|
978
|
+
const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
|
|
979
|
+
const group = byLesson.get(lessonKey);
|
|
980
|
+
if (group)
|
|
981
|
+
group.push(entry);
|
|
982
|
+
else
|
|
983
|
+
byLesson.set(lessonKey, [entry]);
|
|
957
984
|
}
|
|
958
985
|
}
|
|
959
|
-
|
|
986
|
+
const out = [];
|
|
987
|
+
for (const group of byLesson.values()) {
|
|
988
|
+
// Later tier wins for content, unchanged precedence.
|
|
989
|
+
const winner = group[group.length - 1];
|
|
990
|
+
// The id comes from whichever tier carries one, because `stamp-ids` has not
|
|
991
|
+
// reached the org and manager pack homes for every corpus yet. Latest wins
|
|
992
|
+
// when more than one tier is stamped.
|
|
993
|
+
const id = group.reduce((found, entry) => entry.id ?? found, null);
|
|
994
|
+
out.push({
|
|
995
|
+
...winner,
|
|
996
|
+
id,
|
|
997
|
+
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
960
1001
|
}
|
|
961
1002
|
/**
|
|
962
1003
|
* Issue #1103 R3: the auto-loaded Team Context rule files.
|
|
@@ -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
|
-
|
|
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
|
}
|