fraim 2.0.263 → 2.0.264

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.
@@ -8,6 +8,7 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.LEARNING_PRIORITIES = void 0;
10
10
  exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
11
+ exports.getScoreThreshold = getScoreThreshold;
11
12
  exports.computeEffectiveScore = computeEffectiveScore;
12
13
  exports.mergeLearningEntriesAcrossTiers = mergeLearningEntriesAcrossTiers;
13
14
  exports.buildLearningContextSection = buildLearningContextSection;
@@ -1195,9 +1196,14 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1195
1196
  return out;
1196
1197
  }
1197
1198
  // Parse the `## [P-…] Title` entries (and their body prose) out of one learning
1198
- // file. Score/Last seen/Recurrences metadata lines are dropped the Hub shows
1199
- // the human-readable learning, not the bookkeeping.
1200
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier) {
1199
+ // file. Score metadata (Last seen / Recurrences) is captured to compute the
1200
+ // effective decayed score and the injected flag; those bookkeeping lines are
1201
+ // still excluded from the rendered body.
1202
+ //
1203
+ // threshold: entries with score >= threshold (or in a non-gated category) are
1204
+ // marked injected:true. Callers that don't need scoring pass Infinity so
1205
+ // all entries are injected.
1206
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = DEFAULT_THRESHOLD) {
1201
1207
  if (!(0, fs_1.existsSync)(filePath))
1202
1208
  return [];
1203
1209
  let content;
@@ -1207,23 +1213,52 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1207
1213
  catch {
1208
1214
  return [];
1209
1215
  }
1216
+ const fileType = exports.CATEGORY_TO_FILETYPE[category];
1217
+ // preferences and manager-coaching are threshold-free — always injected.
1218
+ const isScoreGated = fileType === 'mistake-patterns' || fileType === 'validated-patterns';
1210
1219
  const out = [];
1211
1220
  let current = null;
1212
1221
  let bodyLines = [];
1222
+ // Scoring metadata for the current entry.
1223
+ let curLastSeen = null;
1224
+ let curRecurrences = 1;
1213
1225
  const flush = () => {
1214
1226
  if (current) {
1215
1227
  current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1228
+ if (isScoreGated) {
1229
+ if (curLastSeen !== null) {
1230
+ const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType);
1231
+ current.score = s;
1232
+ current.injected = s >= threshold;
1233
+ }
1234
+ else {
1235
+ current.score = null;
1236
+ // No date metadata: treat as fresh / always inject.
1237
+ current.injected = true;
1238
+ }
1239
+ }
1240
+ else {
1241
+ current.score = null;
1242
+ current.injected = true;
1243
+ }
1216
1244
  out.push(current);
1217
1245
  }
1218
1246
  current = null;
1219
1247
  bodyLines = [];
1248
+ curLastSeen = null;
1249
+ curRecurrences = 1;
1220
1250
  };
1221
1251
  const headingRe = learningEntryHeadingRegex();
1222
1252
  for (const line of content.split(/\r?\n/)) {
1223
1253
  const header = line.match(headingRe);
1224
1254
  if (header) {
1225
1255
  flush();
1226
- const entry = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level, domain };
1256
+ const entry = {
1257
+ severity: header[1],
1258
+ title: header[2].trim(),
1259
+ body: '', source: displayPath, category, level, domain,
1260
+ score: null, injected: true,
1261
+ };
1227
1262
  if (tier !== undefined)
1228
1263
  entry.tier = tier;
1229
1264
  current = entry;
@@ -1235,10 +1270,19 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1235
1270
  flush();
1236
1271
  continue;
1237
1272
  }
1238
- // Drop the bookkeeping lines — the Hub shows the human-readable learning,
1239
- // not the scoring metadata.
1240
1273
  const t = line.trim();
1241
- if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t))
1274
+ // Capture scoring metadata but exclude from body.
1275
+ const lastSeenMatch = t.match(/^\*\*Last seen\*\*:\s*(.+)/i);
1276
+ if (lastSeenMatch) {
1277
+ curLastSeen = lastSeenMatch[1].trim();
1278
+ continue;
1279
+ }
1280
+ const recurrencesMatch = t.match(/^\*\*Recurrences\*\*:\s*(\d+)/i);
1281
+ if (recurrencesMatch) {
1282
+ curRecurrences = parseInt(recurrencesMatch[1], 10) || 1;
1283
+ continue;
1284
+ }
1285
+ if (/^\*\*(Score|Technical trace|Users|First synthesized)\*\*:/i.test(t))
1242
1286
  continue;
1243
1287
  if (/^(First|Last) synthesized:/i.test(t))
1244
1288
  continue;
@@ -1247,44 +1291,85 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1247
1291
  flush();
1248
1292
  return out;
1249
1293
  }
1250
- function levelDir(roots, level) {
1251
- if (level === 'machine') {
1252
- // Issue #1070: use the configured manager home, not the portable authoring base.
1253
- // For single-machine backends managerHomeBase == globalPersonalBase (no change).
1254
- return { dir: roots.managerHomeBase, displayBase: roots.managerHomeDisplayBase.replace(/\/$/, '') };
1255
- }
1256
- return { dir: roots.repoLearningsBase, displayBase: REPO_LEARNINGS_REL };
1257
- }
1258
1294
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1259
1295
  const roots = getLearningRoots(workspaceRoot);
1296
+ const threshold = getScoreThreshold(workspaceRoot);
1260
1297
  const out = [];
1298
+ // Helper: parse + merge a list of tier paths for one file family.
1299
+ // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1300
+ const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1301
+ const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1302
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold),
1303
+ })));
1261
1304
  if (scope === 'org') {
1262
1305
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
1263
1306
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1264
1307
  const f = `org-${fileType}.md`;
1265
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'org'));
1266
- // #806: domain-scoped org learnings alongside the flat global file.
1267
- for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType)) {
1268
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain));
1308
+ // org pack home first, repo-local override second; repo-local wins on title collision.
1309
+ out.push(...parseTiers(resolveOrgLearningFileTiers(roots.repoLearningsBase, f), cat));
1310
+ // #806: domain-scoped org learnings union from both locations.
1311
+ const allDomainFiles = [
1312
+ ...listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType).map((d) => ({ ...d, base: roots.repoLearningsBase })),
1313
+ ...listDomainLearningFiles((0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings'), 'org', fileType)
1314
+ .map((d) => ({ ...d, base: (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings') })),
1315
+ ];
1316
+ const seenDomainFile = new Set();
1317
+ for (const { fileName, domain, base } of allDomainFiles) {
1318
+ if (seenDomainFile.has(fileName))
1319
+ continue;
1320
+ seenDomainFile.add(fileName);
1321
+ const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
1322
+ if (domainTiers.length === 0) {
1323
+ out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
1324
+ }
1325
+ else {
1326
+ out.push(...parseTiers(domainTiers, cat, undefined, domain));
1327
+ }
1269
1328
  }
1270
1329
  }
1271
1330
  return out;
1272
1331
  }
1332
+ // manager + reverse: use the same three-tier resolution as buildLearningContextSection.
1273
1333
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1274
- const { dir, displayBase } = levelDir(roots, level);
1275
1334
  const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
1335
+ if (level === 'project') {
1336
+ // Project level: repo-local only (single tier, no merge needed).
1337
+ for (const cat of cats) {
1338
+ const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1339
+ const f = `${resolvedUserId}-${fileType}.md`;
1340
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
1341
+ const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
1342
+ if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
1343
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
1344
+ }
1345
+ for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
1346
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
1347
+ }
1348
+ }
1349
+ return out;
1350
+ }
1351
+ // machine level: all three personal tiers (managerCacheBase, globalPersonalBase, repoLearningsBase).
1352
+ // Cold siblings are read from each tier that has the hot file.
1276
1353
  for (const cat of cats) {
1277
1354
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1278
1355
  const f = `${resolvedUserId}-${fileType}.md`;
1279
- out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level, 'global', 'hot'));
1280
- // Cold sibling — only read when it exists alongside the hot file (#861).
1356
+ const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, f);
1357
+ out.push(...parseTiers(tiers, cat, 'hot'));
1358
+ // Cold sibling from each tier that has the hot file.
1281
1359
  const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
1282
- if ((0, fs_1.existsSync)((0, path_1.join)(dir, coldF))) {
1283
- out.push(...parseLearningEntries((0, path_1.join)(dir, coldF), `${displayBase}/${coldF}`, cat, level, 'global', 'cold'));
1284
- }
1285
- // #806: domain-scoped personal learnings alongside the flat global file.
1286
- for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
1287
- out.push(...parseLearningEntries((0, path_1.join)(dir, fileName), `${displayBase}/${fileName}`, cat, level, domain, 'hot'));
1360
+ const coldTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, coldF);
1361
+ out.push(...parseTiers(coldTiers, cat, 'cold'));
1362
+ // #806: domain files from managerHomeBase and repo base.
1363
+ const domainDirs = [roots.globalPersonalBase, roots.managerCacheBase, roots.repoLearningsBase];
1364
+ const seenDomain = new Set();
1365
+ for (const dir of domainDirs) {
1366
+ for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
1367
+ if (seenDomain.has(fileName))
1368
+ continue;
1369
+ seenDomain.add(fileName);
1370
+ const domTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
1371
+ out.push(...parseTiers(domTiers, cat, 'hot', domain));
1372
+ }
1288
1373
  }
1289
1374
  }
1290
1375
  return out;
@@ -1292,8 +1377,12 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1292
1377
  function resolveLearningFilePath(workspaceRoot, userId, ref) {
1293
1378
  const roots = getLearningRoots(workspaceRoot);
1294
1379
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
1295
- if (ref.scope === 'org')
1296
- return (0, path_1.join)(roots.repoLearningsBase, `org-${fileType}.md`);
1380
+ if (ref.scope === 'org') {
1381
+ // Write org entries to the org pack home — same location share-with-others uses.
1382
+ // The Hub read path already merges pack home + repo-local, so edits land where
1383
+ // the agent reads them without needing a repo commit.
1384
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', `org-${fileType}.md`);
1385
+ }
1297
1386
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1298
1387
  // Issue #1070: for the 'machine' level use the configured manager home.
1299
1388
  const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.263",
3
+ "version": "2.0.264",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {