fraim-hub 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.
@@ -57,7 +57,11 @@ class RestartRecoveryPolicy {
57
57
  if (conversation.status !== 'running')
58
58
  return { action: 'skip', reason: 'not_running' };
59
59
  const pauseReason = typeof conversation.pauseReason === 'string' ? conversation.pauseReason : '';
60
- if (['stopped', 'done', 'awaiting_review', 'awaiting_user', 'error'].includes(pauseReason)) {
60
+ // Issue #1076: `error` only means terminal failure when the durable status
61
+ // has actually left running. A running conversation can carry a stale
62
+ // pauseReason from an earlier transient tool/process error, and restart
63
+ // recovery must reattach it instead of promoting that stale field to Failed.
64
+ if (['stopped', 'done', 'awaiting_review', 'awaiting_user'].includes(pauseReason)) {
61
65
  return { action: 'skip', reason: `pause_${pauseReason}` };
62
66
  }
63
67
  if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
@@ -3665,8 +3665,10 @@ class AiHubServer {
3665
3665
  if (scope !== 'org' && !userEmail) {
3666
3666
  return res.json({ scope, level, entries: [] });
3667
3667
  }
3668
- const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
3669
- return res.json({ scope, level, entries });
3668
+ const projectPath = resolveProjectPath(req);
3669
+ const entries = (0, learning_context_builder_1.readPreservedLearnings)(projectPath, userEmail, scope, level);
3670
+ const threshold = (0, learning_context_builder_1.getScoreThreshold)(projectPath);
3671
+ return res.json({ scope, level, entries, threshold });
3670
3672
  }
3671
3673
  catch (error) {
3672
3674
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
@@ -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-hub",
3
- "version": "2.0.263",
3
+ "version": "2.0.264",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -163,7 +163,7 @@
163
163
  "electron": "^41.2.2",
164
164
  "electron-updater": "^6.8.9",
165
165
  "express": "^5.2.1",
166
- "fraim": "2.0.263",
166
+ "fraim": "2.0.264",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -1332,6 +1332,8 @@ async function bgRefreshConversations() {
1332
1332
  state.conversations[state.projectPath] = existing;
1333
1333
  renderRail();
1334
1334
  renderActive();
1335
+ // Issue #1075: keep overview run/status counts current after background refresh.
1336
+ if (typeof tfRenderOverview === 'function' && typeof tf !== 'undefined' && tf.projectView === 'overview') tfRenderOverview();
1335
1337
  }
1336
1338
  ensureActiveConversationBody();
1337
1339
  // #866 R4: the 30s header poll may be the first to observe an onboarding run
@@ -8799,6 +8801,12 @@ function wireEvents() {
8799
8801
  tfOpenRequestedConnectedSurface();
8800
8802
  }
8801
8803
  await hydrateConversationsFromServer();
8804
+ // Issue #1075: re-render overview so run counts reflect the freshly hydrated
8805
+ // conversations. Deferred via setTimeout so the rail/active renders that
8806
+ // complete hydration are not blocked by the overview card iteration.
8807
+ setTimeout(() => {
8808
+ if (typeof tfRenderOverview === 'function' && typeof tf !== 'undefined' && tf.projectView === 'overview') tfRenderOverview();
8809
+ }, 0);
8802
8810
  startBgConvPoll();
8803
8811
  // #769: post-Stripe checkout redirect — start hire-pending polling so the new
8804
8812
  // employee appears as soon as the webhook completes, without a manual reload.
@@ -10242,8 +10250,18 @@ function tfRenderOverview() {
10242
10250
  const briefText = proj.brief || proj.intent || '';
10243
10251
  const team = document.createElement('div');
10244
10252
  team.className = 'proj-team';
10253
+ // Look up conversations for this project; fall back to state.projectPath for the active project
10254
+ const convKey = (state.conversations[proj.folderPath] ? proj.folderPath
10255
+ : (tf.activeProjectId === proj.id ? state.projectPath : null));
10256
+ const projConvs = (convKey ? state.conversations[convKey] : []).filter((c) => !c.managedByRunId);
10257
+ // Issue #1075 R3: show only employees who have actual runs in this project, not all hired personas.
10258
+ // Derive from conv.personaKey to scope avatars to who actually worked here. Fall back to
10259
+ // project assignments when no conversations exist yet.
10260
+ const convPersonaKeys = Array.from(new Set(projConvs.map((c) => c.personaKey).filter(Boolean)));
10245
10261
  const assigned = tfProjectAssignments(proj.id);
10246
- const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
10262
+ const empKeys = convPersonaKeys.length
10263
+ ? convPersonaKeys
10264
+ : Array.from(new Set(assigned.map((a) => a.employeeKey).filter(Boolean)));
10247
10265
  empKeys.slice(0, 6).forEach((key, i) => {
10248
10266
  const persona = tfPersonaByKey(key);
10249
10267
  const av = tfAvatarFor(persona ? persona.displayName : key, i);
@@ -10259,14 +10277,9 @@ function tfRenderOverview() {
10259
10277
  chip.appendChild(pip);
10260
10278
  team.appendChild(chip);
10261
10279
  });
10262
- // Look up conversations for this project; fall back to state.projectPath for the active project
10263
- const convKey = (state.conversations[proj.folderPath] ? proj.folderPath
10264
- : (tf.activeProjectId === proj.id ? state.projectPath : null));
10265
- const projConvs = (convKey ? state.conversations[convKey] : []).filter((c) => !c.managedByRunId);
10266
10280
  const runCount = projConvs.filter((c) => conversationUiState(c) === 'working').length;
10267
10281
  const waitCount = projConvs.filter((c) => ['waiting', 'stopped'].includes(conversationUiState(c))).length;
10268
10282
  const blockCount = projConvs.filter((c) => c.blocked).length;
10269
- const totalRuns = projConvs.length;
10270
10283
  const parts = [];
10271
10284
  if (blockCount) parts.push(blockCount + ' blocked');
10272
10285
  if (runCount) parts.push(runCount + ' in progress');
@@ -10274,9 +10287,8 @@ function tfRenderOverview() {
10274
10287
  const badge = document.createElement('span');
10275
10288
  const needsAttention = waitCount > 0 || blockCount > 0;
10276
10289
  badge.className = 'proj-badge' + (needsAttention ? '' : ' quiet');
10277
- badge.textContent = parts.length ? parts.join(' · ')
10278
- : totalRuns ? totalRuns + (totalRuns === 1 ? ' run' : ' runs')
10279
- : 'No runs yet';
10290
+ // Issue #1075 R4: show only active run counts; omit total run count when everything is done.
10291
+ badge.textContent = parts.length ? parts.join(' · ') : '';
10280
10292
  team.appendChild(badge);
10281
10293
  card.appendChild(name);
10282
10294
  if (briefText) {
@@ -12652,6 +12664,7 @@ async function tfRunShareLearnings(scope) {
12652
12664
  // GET /api/ai-hub/learnings, so each section can DISPLAY and edit them. Keyed by
12653
12665
  // `${scope}:${level}` (e.g. manager:machine, manager:project); `undefined` = unloaded.
12654
12666
  function tfPreservedCache() { if (!state._preservedLearnings) state._preservedLearnings = {}; return state._preservedLearnings; }
12667
+ function tfThresholdCache() { if (!state._learningThresholds) state._learningThresholds = {}; return state._learningThresholds; }
12655
12668
  function tfLearnKey(scope, level) { return scope + ':' + level; }
12656
12669
  async function tfFetchPreservedLearnings(scope, level) {
12657
12670
  const params = new URLSearchParams({ scope, level });
@@ -12659,6 +12672,7 @@ async function tfFetchPreservedLearnings(scope, level) {
12659
12672
  try {
12660
12673
  const data = await requestJson('/api/ai-hub/learnings?' + params.toString());
12661
12674
  tfPreservedCache()[tfLearnKey(scope, level)] = Array.isArray(data.entries) ? data.entries : [];
12675
+ if (typeof data.threshold === 'number') tfThresholdCache()[tfLearnKey(scope, level)] = data.threshold;
12662
12676
  } catch {
12663
12677
  tfPreservedCache()[tfLearnKey(scope, level)] = [];
12664
12678
  }
@@ -12697,6 +12711,7 @@ async function tfWriteLearning(payload, scope, level) {
12697
12711
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
12698
12712
  });
12699
12713
  delete tfPreservedCache()[tfLearnKey(scope, level)];
12714
+ delete tfThresholdCache()[tfLearnKey(scope, level)];
12700
12715
  tfReRenderScope(scope, level);
12701
12716
  }
12702
12717
 
@@ -12868,14 +12883,37 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
12868
12883
  if (Array.isArray(preserved)) {
12869
12884
  const hotEntries = preserved.filter((e) => e.tier !== 'cold');
12870
12885
  const coldEntries = preserved.filter((e) => e.tier === 'cold');
12871
- for (const e of hotEntries) list.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
12886
+
12887
+ // Split hot entries into injected (would be fed to agent) and below-threshold.
12888
+ // Preferences and coaching have injected:true on all entries. Only
12889
+ // mistake-patterns and validated-patterns have a score gate.
12890
+ const injectedEntries = hotEntries.filter((e) => e.injected !== false);
12891
+ const dormantHotEntries = hotEntries.filter((e) => e.injected === false);
12892
+
12893
+ for (const e of injectedEntries) list.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
12894
+
12895
+ if (dormantHotEntries.length) {
12896
+ // Cut line: learnings below the score threshold. Agent won't see these.
12897
+ const cut = document.createElement('details');
12898
+ cut.className = 'learn-dormant-section learn-cutline-section';
12899
+ cut.setAttribute('data-testid', 'cutline-section');
12900
+ const cutSummary = document.createElement('summary');
12901
+ cutSummary.className = 'learn-dormant-header';
12902
+ const threshold = tfThresholdCache()[key];
12903
+ const thresholdLabel = typeof threshold === 'number' ? ` (score < ${threshold})` : '';
12904
+ cutSummary.textContent = `Below threshold${thresholdLabel} — not injected into agent context (${dormantHotEntries.length})`;
12905
+ cut.appendChild(cutSummary);
12906
+ for (const e of dormantHotEntries) cut.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
12907
+ list.appendChild(cut);
12908
+ }
12909
+
12872
12910
  if (coldEntries.length) {
12873
12911
  const dormant = document.createElement('details');
12874
12912
  dormant.className = 'learn-dormant-section';
12875
12913
  dormant.setAttribute('data-testid', 'dormant-section');
12876
12914
  const summary = document.createElement('summary');
12877
12915
  summary.className = 'learn-dormant-header';
12878
- summary.textContent = `Dormant (${coldEntries.length})`;
12916
+ summary.textContent = `Archived (${coldEntries.length})`;
12879
12917
  dormant.appendChild(summary);
12880
12918
  for (const e of coldEntries) dormant.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
12881
12919
  list.appendChild(dormant);