fraim-hub 2.0.232 → 2.0.234
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.
|
@@ -154,6 +154,14 @@ function getHubPersonaForJob(jobName) {
|
|
|
154
154
|
return null;
|
|
155
155
|
return getProtectedPersonaForHubJob(jobName) ?? DEFAULT_UNASSIGNED_PERSONA_KEY;
|
|
156
156
|
}
|
|
157
|
+
// Issue #991: consult custom employee records before falling back to the catalog.
|
|
158
|
+
// Returns the key of the first custom employee whose jobIds array includes jobId,
|
|
159
|
+
// or null if no custom employee owns it.
|
|
160
|
+
function getCustomPersonaForJob(projectPath, jobId) {
|
|
161
|
+
const employees = (0, custom_employees_1.readCustomEmployees)(projectPath);
|
|
162
|
+
const match = employees.find((e) => Array.isArray(e.jobIds) && e.jobIds.includes(jobId));
|
|
163
|
+
return match ? match.key : null;
|
|
164
|
+
}
|
|
157
165
|
const FRAIM_INTERNAL_JOB_IDS = new Set([
|
|
158
166
|
'contribute-to-fraim',
|
|
159
167
|
'create-registry-asset',
|
|
@@ -2452,7 +2460,7 @@ class AiHubServer {
|
|
|
2452
2460
|
id: employeeJob.id,
|
|
2453
2461
|
title: employeeJob.title,
|
|
2454
2462
|
stubPath: employeeJob.stubPath,
|
|
2455
|
-
personaKey: employeeJob.requiredPersonaKey ?? getHubPersonaForJob(employeeJob.id),
|
|
2463
|
+
personaKey: employeeJob.requiredPersonaKey ?? getCustomPersonaForJob(projectPath, employeeJob.id) ?? getHubPersonaForJob(employeeJob.id),
|
|
2456
2464
|
};
|
|
2457
2465
|
}
|
|
2458
2466
|
const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.234",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js"
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"electron": "^41.2.2",
|
|
159
159
|
"electron-updater": "^6.8.9",
|
|
160
160
|
"express": "^5.2.1",
|
|
161
|
-
"fraim": "2.0.
|
|
161
|
+
"fraim": "2.0.234",
|
|
162
162
|
"mongodb": "^7.0.0",
|
|
163
163
|
"node-cron": "4.2.1",
|
|
164
164
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -73,6 +73,8 @@ const state = {
|
|
|
73
73
|
// the active employee; the user never sees the raw invocation syntax.
|
|
74
74
|
pendingCoachingJobId: null,
|
|
75
75
|
pendingCoachingLabel: null,
|
|
76
|
+
// Issue #989 R1-R6: area preserved when a next-job chip opens step-2 pre-filled.
|
|
77
|
+
pendingNextJobArea: null,
|
|
76
78
|
conversationPersistTimer: null,
|
|
77
79
|
conversationRefreshHandle: null,
|
|
78
80
|
conversationDiskAvailable: true,
|
|
@@ -300,7 +302,11 @@ function tfRefreshPersonaDependentSurfaces() {
|
|
|
300
302
|
else if (tf.area === 'brain') tfRenderBrain();
|
|
301
303
|
if (tf.area === 'projects') {
|
|
302
304
|
if (tf.projectView === 'overview') tfRenderOverview();
|
|
303
|
-
else
|
|
305
|
+
else {
|
|
306
|
+
tfRenderTree();
|
|
307
|
+
// tfRenderTree rebuilds tree chrome only; renderRail rebuilds per-employee accordion groups.
|
|
308
|
+
if (typeof renderRail === 'function') renderRail();
|
|
309
|
+
}
|
|
304
310
|
}
|
|
305
311
|
}
|
|
306
312
|
|
|
@@ -1305,6 +1311,19 @@ function stripHubInjectedPromptBlocks(text) {
|
|
|
1305
1311
|
.trim();
|
|
1306
1312
|
}
|
|
1307
1313
|
|
|
1314
|
+
// Returns a validated http/https URL string suitable for use in an href, or null if unsafe.
|
|
1315
|
+
function _safeHref(raw) {
|
|
1316
|
+
try {
|
|
1317
|
+
const url = new URL(raw);
|
|
1318
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : null;
|
|
1319
|
+
} catch (_) { return null; }
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Escape & and " in a URL for safe insertion into an href="..." attribute.
|
|
1323
|
+
function _escapeHref(href) {
|
|
1324
|
+
return href.replace(/&/g, '&').replace(/"/g, '"');
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1308
1327
|
// R8: render markdown subset safely. HTML is escaped first.
|
|
1309
1328
|
function formatEmployeeText(text) {
|
|
1310
1329
|
if (!text) return '';
|
|
@@ -1320,6 +1339,27 @@ function formatEmployeeText(text) {
|
|
|
1320
1339
|
s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
|
|
1321
1340
|
// 3. Inline code (single backtick, non-greedy, no newlines).
|
|
1322
1341
|
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
1342
|
+
// 3.5. Markdown links [text](url) — only http/https hrefs allowed; link text is already HTML-escaped.
|
|
1343
|
+
// Empty-text links and non-absolute URLs fall back to plain text.
|
|
1344
|
+
s = s.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (match, linkText, rawUrl) => {
|
|
1345
|
+
const trimmed = linkText.trim();
|
|
1346
|
+
// Empty link text: render as plain text; sentinel prevents step 3.6 auto-linkify.
|
|
1347
|
+
if (!trimmed) return `\x00NOLINK\x00${rawUrl.trim()}\x00ENDNOLINK\x00`;
|
|
1348
|
+
// Reverse HTML entities step 1 introduced inside the URL before structural validation.
|
|
1349
|
+
const decodedUrl = rawUrl.trim().replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
1350
|
+
const href = _safeHref(decodedUrl);
|
|
1351
|
+
if (!href) return trimmed;
|
|
1352
|
+
return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${trimmed}</a>`;
|
|
1353
|
+
});
|
|
1354
|
+
// 3.6. Auto-linkify bare https:// / http:// URLs not already inside href="...".
|
|
1355
|
+
// Sentinel prefix prevents double-linkifying empty-text [](url) patterns.
|
|
1356
|
+
s = s.replace(/(?<!href=")(?<!href=')(?<!\x00NOLINK\x00)(https?:\/\/[^\s<>"'\x00]+)/g, (url) => {
|
|
1357
|
+
const href = _safeHref(url);
|
|
1358
|
+
if (!href) return url;
|
|
1359
|
+
return `<a href="${_escapeHref(href)}" target="_blank" rel="noopener noreferrer">${_escapeHref(href)}</a>`;
|
|
1360
|
+
});
|
|
1361
|
+
// Strip sentinels: emit the URL as plain text (no anchor).
|
|
1362
|
+
s = s.replace(/\x00NOLINK\x00(.*?)\x00ENDNOLINK\x00/g, (_, url) => url);
|
|
1323
1363
|
// 4. Bold (**text**).
|
|
1324
1364
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
1325
1365
|
// 5–7. Process line by line for lists and paragraphs.
|
|
@@ -1664,7 +1704,7 @@ function renderRail() {
|
|
|
1664
1704
|
titleSpan.className = 'conv-title';
|
|
1665
1705
|
titleSpan.textContent = conv.title || '';
|
|
1666
1706
|
bodyDiv.appendChild(titleSpan);
|
|
1667
|
-
appendHubTimestamp(bodyDiv, conv
|
|
1707
|
+
appendHubTimestamp(bodyDiv, conversationStartTimestamp(conv), 'conv-time', 'Job started');
|
|
1668
1708
|
btn.appendChild(bodyDiv);
|
|
1669
1709
|
// Issue #566 R7: mark runs of a personalized (taught/customized) job.
|
|
1670
1710
|
if (isTaughtJob(conv.jobId)) {
|
|
@@ -4851,7 +4891,14 @@ function renderNextJobRecommendations(conv) {
|
|
|
4851
4891
|
: conv.scope === 'company' ? 'company'
|
|
4852
4892
|
: 'projects';
|
|
4853
4893
|
const employeeId = conversationAgentName(conv) || (state.selectedEmployeeId) || 'claude';
|
|
4854
|
-
|
|
4894
|
+
// Issue #989 R1-R6: open step-2 with job pre-selected and instructions pre-filled
|
|
4895
|
+
// so the manager can review/edit before starting, instead of calling startRun directly.
|
|
4896
|
+
state.selectedJob = job;
|
|
4897
|
+
state.selectedEmployeeId = employeeId;
|
|
4898
|
+
state.pendingNextJobArea = area;
|
|
4899
|
+
showStep2(job);
|
|
4900
|
+
els['instructions'].value = instructions;
|
|
4901
|
+
els['start'].disabled = instructions.trim().length === 0;
|
|
4855
4902
|
});
|
|
4856
4903
|
}
|
|
4857
4904
|
chips.appendChild(btn);
|
|
@@ -6018,6 +6065,7 @@ function closeModal() {
|
|
|
6018
6065
|
els['modal'].classList.remove('open');
|
|
6019
6066
|
els['modal'].hidden = true;
|
|
6020
6067
|
state.activeFilter = null;
|
|
6068
|
+
state.pendingNextJobArea = null;
|
|
6021
6069
|
}
|
|
6022
6070
|
|
|
6023
6071
|
// Build the unified picker list. Employee jobs are organized by their
|
|
@@ -6707,7 +6755,15 @@ function foldRunIntoConversation(conv, run) {
|
|
|
6707
6755
|
if (run.configuredAgentLabel) conv.configuredAgentLabel = run.configuredAgentLabel;
|
|
6708
6756
|
if (run.baseHostId) conv.baseHostId = run.baseHostId;
|
|
6709
6757
|
if (run.personaKey !== undefined) {
|
|
6710
|
-
|
|
6758
|
+
// Issue #991: do not let a server-side catalog/default key overwrite a custom:*
|
|
6759
|
+
// key the client already holds. The client stamps custom:* at kickoff from the
|
|
6760
|
+
// user's explicit action (cpPersonaOverride); the server resolves it via
|
|
6761
|
+
// getCustomPersonaForJob, but the client guard is defense-in-depth.
|
|
6762
|
+
const isClientCustom = conv.personaKey && conv.personaKey.startsWith('custom:');
|
|
6763
|
+
const isServerCustom = run.personaKey && run.personaKey.startsWith('custom:');
|
|
6764
|
+
if (!isClientCustom || isServerCustom) {
|
|
6765
|
+
conv.personaKey = run.personaKey;
|
|
6766
|
+
}
|
|
6711
6767
|
}
|
|
6712
6768
|
// Issue #347: keep the latest server snapshot under conv.run so the
|
|
6713
6769
|
// tracker / totals renderers can read it without re-doing work. Stages,
|
|
@@ -7561,6 +7617,9 @@ function wireEvents() {
|
|
|
7561
7617
|
if (!job || !text) return;
|
|
7562
7618
|
const employeeId = els['employee-select'].value || state.selectedEmployeeId;
|
|
7563
7619
|
state.selectedEmployeeId = employeeId;
|
|
7620
|
+
// Issue #989 R4: consume pendingNextJobArea so the run lands in the right tab.
|
|
7621
|
+
const invokedArea = state.pendingNextJobArea || undefined;
|
|
7622
|
+
state.pendingNextJobArea = null;
|
|
7564
7623
|
closeModal();
|
|
7565
7624
|
// R2: if no project path is set, show the inline project picker instead.
|
|
7566
7625
|
if (!state.projectPath) {
|
|
@@ -7574,7 +7633,7 @@ function wireEvents() {
|
|
|
7574
7633
|
showHireStrip(job, pendingConvId, text, employeeId);
|
|
7575
7634
|
return;
|
|
7576
7635
|
}
|
|
7577
|
-
await startRun(job, text, employeeId);
|
|
7636
|
+
await startRun(job, text, employeeId, undefined, invokedArea);
|
|
7578
7637
|
});
|
|
7579
7638
|
els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
|
|
7580
7639
|
els['modal'].addEventListener('click', (e) => {
|
|
@@ -10486,7 +10545,7 @@ function tfBuildManagerRunItem(conv) {
|
|
|
10486
10545
|
const body = document.createElement('span'); body.className = 'conv-body';
|
|
10487
10546
|
const title = document.createElement('span'); title.className = 'conv-title';
|
|
10488
10547
|
title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
|
|
10489
|
-
body.appendChild(title); appendHubTimestamp(body, conv
|
|
10548
|
+
body.appendChild(title); appendHubTimestamp(body, conversationStartTimestamp(conv), 'conv-time', 'Job started'); btn.appendChild(body);
|
|
10490
10549
|
const dotClass = conversationStateDotClass(conv);
|
|
10491
10550
|
const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
10492
10551
|
if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -1669,6 +1669,15 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
1669
1669
|
border: 1px solid var(--line);
|
|
1670
1670
|
color: var(--text);
|
|
1671
1671
|
}
|
|
1672
|
+
.message.employee .bubble a {
|
|
1673
|
+
color: var(--accent);
|
|
1674
|
+
text-decoration: underline;
|
|
1675
|
+
text-underline-offset: 2px;
|
|
1676
|
+
word-break: break-all;
|
|
1677
|
+
}
|
|
1678
|
+
.message.employee .bubble a:hover {
|
|
1679
|
+
color: var(--accent-strong);
|
|
1680
|
+
}
|
|
1672
1681
|
.typing-indicator {
|
|
1673
1682
|
animation: none;
|
|
1674
1683
|
justify-items: start;
|