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
|
-
|
|
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
|
|
3669
|
-
|
|
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
|
|
1199
|
-
//
|
|
1200
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
10278
|
-
|
|
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
|
-
|
|
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 = `
|
|
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);
|