fraim-hub 2.0.339 → 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 +96 -19
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-store.js +86 -55
- package/package.json +4 -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
|
@@ -28,9 +28,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
|
28
28
|
'learning_usage',
|
|
29
29
|
'hub_event_logs',
|
|
30
30
|
];
|
|
31
|
+
function contentRoot(workspaceRoot) {
|
|
32
|
+
const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
|
|
33
|
+
return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
|
|
34
|
+
}
|
|
31
35
|
function readWorkspaceConfig(workspaceRoot) {
|
|
32
36
|
try {
|
|
33
|
-
const configPath = (
|
|
37
|
+
const configPath = contentRoot(workspaceRoot) === node_path_1.default.join(workspaceRoot, 'personalized-employee')
|
|
38
|
+
? node_path_1.default.join(workspaceRoot, 'config.json') : (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
|
|
34
39
|
if (!node_fs_1.default.existsSync(configPath))
|
|
35
40
|
return {};
|
|
36
41
|
return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
|
|
@@ -143,7 +148,7 @@ function loadGitLastChangeDates(workspaceRoot, relativeDir) {
|
|
|
143
148
|
return dates;
|
|
144
149
|
}
|
|
145
150
|
function readFrontmatter(content) {
|
|
146
|
-
const match = content.match(
|
|
151
|
+
const match = content.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/);
|
|
147
152
|
if (!match)
|
|
148
153
|
return {};
|
|
149
154
|
const values = {};
|
|
@@ -167,8 +172,20 @@ function synthesizedDate(absolutePath) {
|
|
|
167
172
|
return undefined;
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
|
-
function userScoped(
|
|
171
|
-
|
|
175
|
+
function userScoped(filePath, activeUserEmail) {
|
|
176
|
+
try {
|
|
177
|
+
const body = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
178
|
+
const author = readFrontmatter(body).author;
|
|
179
|
+
if (author !== undefined) {
|
|
180
|
+
const value = author.replace(/^(?:"([^"]*)"|'([^']*)')$/, (_match, double, single) => double ?? single);
|
|
181
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value === activeUserEmail;
|
|
182
|
+
}
|
|
183
|
+
// Older artifacts used the author in their filename.
|
|
184
|
+
return node_path_1.default.basename(filePath).startsWith(`${activeUserEmail}-`);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
172
189
|
}
|
|
173
190
|
function evidenceEligibilityDate(workspaceRoot, absolutePath, gitDates) {
|
|
174
191
|
const relativePath = normalizeRelative(workspaceRoot, absolutePath);
|
|
@@ -187,17 +204,22 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
187
204
|
eligibilityDate,
|
|
188
205
|
});
|
|
189
206
|
};
|
|
190
|
-
const
|
|
207
|
+
const content = contentRoot(workspaceRoot);
|
|
208
|
+
const archiveDir = node_path_1.default.join(content, 'learnings', 'archive');
|
|
191
209
|
for (const filePath of listFiles(archiveDir)) {
|
|
192
210
|
const fileName = node_path_1.default.basename(filePath);
|
|
193
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
211
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
194
212
|
continue;
|
|
195
213
|
add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
|
|
196
214
|
}
|
|
197
|
-
const
|
|
198
|
-
|
|
215
|
+
const retrospectives = new Set([
|
|
216
|
+
node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives'),
|
|
217
|
+
node_path_1.default.join(workspaceRoot, 'retrospectives'),
|
|
218
|
+
node_path_1.default.join(content, 'retrospectives'),
|
|
219
|
+
].flatMap(listFiles));
|
|
220
|
+
for (const filePath of retrospectives) {
|
|
199
221
|
const fileName = node_path_1.default.basename(filePath);
|
|
200
|
-
if (!fileName.endsWith('.md') || !userScoped(
|
|
222
|
+
if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
|
|
201
223
|
continue;
|
|
202
224
|
const date = synthesizedDate(filePath);
|
|
203
225
|
if (date)
|
|
@@ -209,6 +231,11 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
209
231
|
const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, 'docs/evidence');
|
|
210
232
|
for (const filePath of listFiles(evidenceDir)) {
|
|
211
233
|
const fileName = node_path_1.default.basename(filePath);
|
|
234
|
+
// A surviving run journal is unfinished recovery; completion removes it.
|
|
235
|
+
if (fileName.endsWith('.queue.jsonl')) {
|
|
236
|
+
add('evidence', filePath, false, 'Unfinished Sleep run journal; removed by run completion.');
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
212
239
|
const isMarkdown = fileName.endsWith('.md');
|
|
213
240
|
const isJson = fileName.endsWith('.json');
|
|
214
241
|
const eligibilityDate = evidenceEligibilityDate(workspaceRoot, filePath, evidenceGitDates);
|
|
@@ -25,6 +25,7 @@ exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
|
|
|
25
25
|
exports.countPreservedLearnings = countPreservedLearnings;
|
|
26
26
|
exports.collectBrainLearningDots = collectBrainLearningDots;
|
|
27
27
|
exports.readPreservedLearnings = readPreservedLearnings;
|
|
28
|
+
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
28
29
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
29
30
|
exports.isTruthyFlag = isTruthyFlag;
|
|
30
31
|
const fs_1 = require("fs");
|
|
@@ -496,6 +497,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
|
|
|
496
497
|
* behaviour instead of merely equivalent.
|
|
497
498
|
*/
|
|
498
499
|
function resolveUsageClock(workspaceRoot) {
|
|
500
|
+
return resolveUsageViews(workspaceRoot).clock;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Issue #1755: one store read, two views of it: the clock the scorer needs and
|
|
504
|
+
* the counters a surface renders. They are resolved together because reading the
|
|
505
|
+
* store twice for the same pass would let the two disagree, and because the
|
|
506
|
+
* identity resolution that finds a record is the projection's, not each caller's.
|
|
507
|
+
*/
|
|
508
|
+
function resolveUsageViews(workspaceRoot) {
|
|
499
509
|
try {
|
|
500
510
|
// Required lazily: the store imports the retention config, which imports the
|
|
501
511
|
// workspace path helpers, and a cycle through this module would be fragile.
|
|
@@ -503,14 +513,31 @@ function resolveUsageClock(workspaceRoot) {
|
|
|
503
513
|
const projection = require('./learning-usage-projection');
|
|
504
514
|
const loaded = store.readUsageStore();
|
|
505
515
|
if (Object.keys(loaded.entries).length === 0)
|
|
506
|
-
return
|
|
516
|
+
return {};
|
|
507
517
|
const lookup = projection.buildUsageLookup(loaded);
|
|
508
|
-
return
|
|
518
|
+
return {
|
|
519
|
+
clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
|
|
520
|
+
stats: (id, fileType, title) => {
|
|
521
|
+
const found = projection.lookupUsage(lookup, id, fileType, title);
|
|
522
|
+
if (!found)
|
|
523
|
+
return null;
|
|
524
|
+
return {
|
|
525
|
+
offered: found.offered,
|
|
526
|
+
offeredAboveThreshold: found.offeredAboveThreshold,
|
|
527
|
+
applied: found.byOutcome.applied || 0,
|
|
528
|
+
prevented: found.byOutcome.prevented || 0,
|
|
529
|
+
ignored: found.byOutcome.ignored || 0,
|
|
530
|
+
notApplicable: found.byOutcome['not-applicable'] || 0,
|
|
531
|
+
lastOfferedAt: found.lastOffered,
|
|
532
|
+
lastFiredAt: found.lastFired,
|
|
533
|
+
};
|
|
534
|
+
},
|
|
535
|
+
};
|
|
509
536
|
}
|
|
510
537
|
catch {
|
|
511
538
|
// Usage data is derived and optional. A failure here must never change what
|
|
512
539
|
// the agent is given.
|
|
513
|
-
return
|
|
540
|
+
return {};
|
|
514
541
|
}
|
|
515
542
|
}
|
|
516
543
|
function readFrontmatter(content) {
|
|
@@ -1216,7 +1243,7 @@ function isTeamContextKey(value) {
|
|
|
1216
1243
|
* user-level `~/.fraim/…` path (where org onboarding writes).
|
|
1217
1244
|
* - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
|
|
1218
1245
|
*/
|
|
1219
|
-
function resolveTeamContextFile(workspaceRoot, key) {
|
|
1246
|
+
function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
1220
1247
|
const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
|
|
1221
1248
|
const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
|
|
1222
1249
|
const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
|
|
@@ -1245,7 +1272,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1245
1272
|
// destination (R5.1), so writePath stays empty and the caller must route
|
|
1246
1273
|
// edits through the org backend's propose-and-approve flow.
|
|
1247
1274
|
if (key === 'org' || key === 'orgRules') {
|
|
1248
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1275
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
|
|
1249
1276
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1250
1277
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1251
1278
|
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
@@ -1262,7 +1289,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
|
|
|
1262
1289
|
}
|
|
1263
1290
|
}
|
|
1264
1291
|
if (key === 'manager' || key === 'managerRules') {
|
|
1265
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
1292
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
|
|
1266
1293
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1267
1294
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1268
1295
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
@@ -1697,7 +1724,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
|
|
|
1697
1724
|
// threshold: entries with score >= threshold (or in a non-gated category) are
|
|
1698
1725
|
// marked injected:true. Callers that don't need scoring pass Infinity so
|
|
1699
1726
|
// all entries are injected.
|
|
1700
|
-
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
|
|
1727
|
+
function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
|
|
1701
1728
|
if (!(0, fs_1.existsSync)(filePath))
|
|
1702
1729
|
return [];
|
|
1703
1730
|
let content;
|
|
@@ -1719,6 +1746,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1719
1746
|
const flush = () => {
|
|
1720
1747
|
if (current) {
|
|
1721
1748
|
current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1749
|
+
// Issue #1755: resolve usage once per entry, before scoring, so the
|
|
1750
|
+
// counters a surface renders and the clock the score uses come from
|
|
1751
|
+
// the same record rather than two independent lookups.
|
|
1752
|
+
current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
|
|
1722
1753
|
if (isScoreGated) {
|
|
1723
1754
|
if (curLastSeen !== null) {
|
|
1724
1755
|
const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
|
|
@@ -1753,7 +1784,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
|
|
|
1753
1784
|
title: header[2].trim(),
|
|
1754
1785
|
id: null,
|
|
1755
1786
|
body: '', source: displayPath, category, level, domain,
|
|
1756
|
-
score: null, injected: true,
|
|
1787
|
+
score: null, injected: true, usage: null,
|
|
1757
1788
|
};
|
|
1758
1789
|
if (tier !== undefined)
|
|
1759
1790
|
entry.tier = tier;
|
|
@@ -1799,13 +1830,14 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1799
1830
|
const threshold = getScoreThreshold(workspaceRoot);
|
|
1800
1831
|
// Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
|
|
1801
1832
|
// read the same clock. One store read serves the whole call.
|
|
1802
|
-
|
|
1833
|
+
// Issue #1755: the same read also yields the counters the Hub renders.
|
|
1834
|
+
const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
|
|
1803
1835
|
const out = [];
|
|
1804
1836
|
// Helper: parse + merge a list of tier paths for one file family.
|
|
1805
1837
|
// ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
|
|
1806
1838
|
const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
|
|
1807
1839
|
const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
|
|
1808
|
-
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
|
|
1840
|
+
entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
|
|
1809
1841
|
})));
|
|
1810
1842
|
if (scope === 'org') {
|
|
1811
1843
|
for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
|
|
@@ -1826,7 +1858,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1826
1858
|
seenDomainFile.add(fileName);
|
|
1827
1859
|
const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
|
|
1828
1860
|
if (domainTiers.length === 0) {
|
|
1829
|
-
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
|
|
1861
|
+
out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
|
|
1830
1862
|
}
|
|
1831
1863
|
else {
|
|
1832
1864
|
out.push(...parseTiers(domainTiers, cat, undefined, domain));
|
|
@@ -1843,13 +1875,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1843
1875
|
for (const cat of cats) {
|
|
1844
1876
|
const fileType = exports.CATEGORY_TO_FILETYPE[cat];
|
|
1845
1877
|
const f = `${resolvedUserId}-${fileType}.md`;
|
|
1846
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
|
|
1878
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
|
|
1847
1879
|
const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
|
|
1848
1880
|
if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
|
|
1849
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
|
|
1881
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
|
|
1850
1882
|
}
|
|
1851
1883
|
for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
|
|
1852
|
-
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
|
|
1884
|
+
out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
|
|
1853
1885
|
}
|
|
1854
1886
|
}
|
|
1855
1887
|
return out;
|
|
@@ -1880,6 +1912,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1880
1912
|
}
|
|
1881
1913
|
return out;
|
|
1882
1914
|
}
|
|
1915
|
+
/** True for a domain value that means "the undecorated global file". */
|
|
1916
|
+
function isGlobalDomain(domain) {
|
|
1917
|
+
return !domain || domain === 'global';
|
|
1918
|
+
}
|
|
1919
|
+
/** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
|
|
1920
|
+
function normalizeLearningDomain(domain) {
|
|
1921
|
+
if (isGlobalDomain(domain))
|
|
1922
|
+
return 'global';
|
|
1923
|
+
const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
|
|
1924
|
+
return found ?? 'global';
|
|
1925
|
+
}
|
|
1926
|
+
function learningFileName(userId, fileType, domain) {
|
|
1927
|
+
// Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
|
|
1928
|
+
// layer already allowlists the domain, but this function builds a path and a
|
|
1929
|
+
// direct caller passing `../../escape` would otherwise write outside the
|
|
1930
|
+
// learnings root. Anything not in the known set resolves to the global file.
|
|
1931
|
+
const safe = normalizeLearningDomain(domain);
|
|
1932
|
+
return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
|
|
1933
|
+
}
|
|
1883
1934
|
function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
1884
1935
|
const roots = getLearningRoots(workspaceRoot);
|
|
1885
1936
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
@@ -1887,12 +1938,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
|
1887
1938
|
// Write org entries to the org pack home — same location share-with-others uses.
|
|
1888
1939
|
// The Hub read path already merges pack home + repo-local, so edits land where
|
|
1889
1940
|
// the agent reads them without needing a repo commit.
|
|
1890
|
-
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings',
|
|
1941
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
1891
1942
|
}
|
|
1892
1943
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1893
1944
|
// Issue #1070: for the 'machine' level use the configured manager home.
|
|
1894
1945
|
const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
|
|
1895
|
-
return (0, path_1.join)(base,
|
|
1946
|
+
return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
|
|
1896
1947
|
}
|
|
1897
1948
|
function titleCaseFromFileType(fileType) {
|
|
1898
1949
|
return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
@@ -1916,9 +1967,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1916
1967
|
const body = String(payload.body || '').trim();
|
|
1917
1968
|
const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
|
|
1918
1969
|
const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
1970
|
+
const addDomain = normalizeLearningDomain(ref.domain);
|
|
1919
1971
|
const preamble = existing.trim()
|
|
1920
1972
|
? existing.replace(/\s*$/, '')
|
|
1921
|
-
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
|
|
1973
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
|
|
1922
1974
|
write(preamble + nl + nl + block);
|
|
1923
1975
|
return { ok: true, path: filePath };
|
|
1924
1976
|
}
|
|
@@ -1935,8 +1987,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1935
1987
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1936
1988
|
return { ok: true, path: filePath };
|
|
1937
1989
|
}
|
|
1938
|
-
// edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
|
|
1990
|
+
// edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
|
|
1939
1991
|
// replace only the heading (priority + title) and the prose body.
|
|
1992
|
+
//
|
|
1993
|
+
// Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
|
|
1994
|
+
// the entry's stable identifier and the usage record keyed to it (#1752) was
|
|
1995
|
+
// orphaned: silently, and permanently once the title also changed.
|
|
1940
1996
|
const headerMatch = lines[start].match(headingRe);
|
|
1941
1997
|
const sev = (payload.severity || headerMatch[1]);
|
|
1942
1998
|
const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
|
|
@@ -1945,11 +2001,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
|
|
|
1945
2001
|
const t = l.trim();
|
|
1946
2002
|
if (t === '---')
|
|
1947
2003
|
continue;
|
|
1948
|
-
if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
2004
|
+
if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
|
|
1949
2005
|
meta.push(l);
|
|
1950
2006
|
}
|
|
1951
2007
|
const body = String(payload.body !== undefined ? payload.body : '').trim();
|
|
1952
2008
|
const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
|
|
2009
|
+
// Issue #1755 R2/R4: a domain change is a move between files, because the domain
|
|
2010
|
+
// IS the filename. Cut the rebuilt block from the source file and append it to the
|
|
2011
|
+
// destination, so the entry keeps its id, score and history rather than being
|
|
2012
|
+
// re-created as a new entry with no past.
|
|
2013
|
+
const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
|
|
2014
|
+
if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
|
|
2015
|
+
const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
|
|
2016
|
+
const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
|
|
2017
|
+
const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
|
|
2018
|
+
const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
|
|
2019
|
+
const destPreamble = destExisting.trim()
|
|
2020
|
+
? destExisting.replace(/\s*$/, '')
|
|
2021
|
+
: `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
|
|
2022
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
|
|
2023
|
+
(0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
|
|
2024
|
+
// Remove from the source only after the destination write succeeded, so a
|
|
2025
|
+
// failure mid-move duplicates rather than loses the entry.
|
|
2026
|
+
const without = [...lines.slice(0, start), ...lines.slice(end)];
|
|
2027
|
+
write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
2028
|
+
return { ok: true, path: destPath };
|
|
2029
|
+
}
|
|
1953
2030
|
const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
|
|
1954
2031
|
write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
|
|
1955
2032
|
return { ok: true, path: filePath };
|
|
@@ -7,43 +7,17 @@ exports.listEventLogs = listEventLogs;
|
|
|
7
7
|
exports.readEventsSince = readEventsSince;
|
|
8
8
|
exports.compactEventLogs = compactEventLogs;
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Per-machine usage journals: job runs append complete JSON lines; sleep folds
|
|
11
|
+
* them into a recoverable snapshot and removes processed records.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
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.
|
|
19
|
+
* Maintenance requires no concurrent writers or sync activity. Ordinary writers
|
|
20
|
+
* append one payload; different machines use different filenames.
|
|
47
21
|
*/
|
|
48
22
|
const fs_1 = require("fs");
|
|
49
23
|
const os_1 = require("os");
|
|
@@ -51,6 +25,7 @@ const path_1 = require("path");
|
|
|
51
25
|
/** Subdirectory of the usage folder holding the per-machine logs. */
|
|
52
26
|
const EVENT_DIR_NAME = 'events';
|
|
53
27
|
const LOG_SUFFIX = '.jsonl';
|
|
28
|
+
const CHECKPOINT_MARKER = '_usageJournalThroughBytes';
|
|
54
29
|
/** The log directory that belongs to a snapshot: `<usage>/events/`. */
|
|
55
30
|
function resolveEventDir(snapshotPath) {
|
|
56
31
|
return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
|
|
@@ -136,10 +111,25 @@ function listEventLogs(eventDir) {
|
|
|
136
111
|
continue;
|
|
137
112
|
const path = (0, path_1.join)(eventDir, name);
|
|
138
113
|
let size = 0;
|
|
114
|
+
let baseOffset = 0;
|
|
115
|
+
let headerBytes = 0;
|
|
139
116
|
try {
|
|
140
117
|
const fd = (0, fs_1.openSync)(path, 'r');
|
|
141
118
|
try {
|
|
142
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
|
+
}
|
|
143
133
|
}
|
|
144
134
|
finally {
|
|
145
135
|
(0, fs_1.closeSync)(fd);
|
|
@@ -148,14 +138,20 @@ function listEventLogs(eventDir) {
|
|
|
148
138
|
catch {
|
|
149
139
|
continue;
|
|
150
140
|
}
|
|
151
|
-
logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size });
|
|
141
|
+
logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size, baseOffset, headerBytes });
|
|
152
142
|
}
|
|
153
143
|
return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
|
|
154
144
|
}
|
|
155
145
|
function readTail(log, from) {
|
|
156
|
-
const
|
|
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.');
|
|
157
152
|
const start = restarted ? 0 : Math.max(0, from);
|
|
158
|
-
const
|
|
153
|
+
const physicalStart = start - log.baseOffset + log.headerBytes;
|
|
154
|
+
const length = log.size - physicalStart;
|
|
159
155
|
if (length <= 0) {
|
|
160
156
|
return { ...log, records: [], skippedLines: 0, through: start, restarted };
|
|
161
157
|
}
|
|
@@ -164,7 +160,7 @@ function readTail(log, from) {
|
|
|
164
160
|
const fd = (0, fs_1.openSync)(log.path, 'r');
|
|
165
161
|
try {
|
|
166
162
|
const buffer = Buffer.alloc(length);
|
|
167
|
-
const read = (0, fs_1.readSync)(fd, buffer, 0, length,
|
|
163
|
+
const read = (0, fs_1.readSync)(fd, buffer, 0, length, physicalStart);
|
|
168
164
|
text = buffer.subarray(0, read).toString('utf8');
|
|
169
165
|
}
|
|
170
166
|
finally {
|
|
@@ -202,7 +198,7 @@ function readTail(log, from) {
|
|
|
202
198
|
skippedLines += 1;
|
|
203
199
|
}
|
|
204
200
|
}
|
|
205
|
-
return { ...log, records, skippedLines, through:
|
|
201
|
+
return { ...log, records, skippedLines, through: end - partialBytes, restarted };
|
|
206
202
|
}
|
|
207
203
|
/**
|
|
208
204
|
* Read every log's tail past its checkpoint.
|
|
@@ -220,32 +216,42 @@ function readEventsSince(eventDir, checkpoints) {
|
|
|
220
216
|
};
|
|
221
217
|
}
|
|
222
218
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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.
|
|
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.
|
|
233
223
|
*/
|
|
234
|
-
function compactEventLogs(eventDir, keep) {
|
|
235
|
-
const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [] };
|
|
224
|
+
function compactEventLogs(eventDir, keep, checkpoints) {
|
|
225
|
+
const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [], checkpoints: { ...checkpoints } };
|
|
236
226
|
for (const log of listEventLogs(eventDir)) {
|
|
237
|
-
const read = readTail(log,
|
|
227
|
+
const read = readTail(log, log.baseOffset);
|
|
238
228
|
result.scanned += read.records.length;
|
|
239
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;
|
|
240
236
|
const retained = read.records.filter(keep);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (retained.length
|
|
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)
|
|
244
240
|
continue;
|
|
245
241
|
const temp = `${log.path}.compacting`;
|
|
246
242
|
try {
|
|
247
|
-
|
|
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
|
+
}
|
|
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;
|
|
249
255
|
}
|
|
250
256
|
catch {
|
|
251
257
|
// The log is authoritative and the original is still intact, so a failed
|