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.
@@ -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 = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
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(/^---\r?\n([\s\S]*?)\r?\n---/);
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(fileName, activeUserEmail) {
171
- return fileName.startsWith(`${activeUserEmail}-`);
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 archiveDir = node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings', 'archive');
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(fileName, activeUserEmail))
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 retrospectivesDir = node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives');
198
- for (const filePath of listFiles(retrospectivesDir)) {
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(fileName, activeUserEmail))
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 undefined;
516
+ return {};
507
517
  const lookup = projection.buildUsageLookup(loaded);
508
- return (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null;
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 undefined;
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
- const usageClock = resolveUsageClock(workspaceRoot);
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', `org-${fileType}.md`);
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, `${resolvedUserId}-${fileType}.md`);
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
- * Issue #1752 — the durable half of the usage record.
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
- * 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.
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
- * 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.
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 restarted = from > log.size;
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 length = log.size - start;
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, start);
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: log.size - partialBytes, restarted };
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
- * 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.
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, 0);
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
- result.kept += retained.length;
242
- result.dropped += read.records.length - retained.length;
243
- if (retained.length === read.records.length && read.skippedLines === 0)
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
- (0, fs_1.writeFileSync)(temp, retained.length ? `${retained.map((r) => JSON.stringify(r)).join('\n')}\n` : '', 'utf8');
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