fraim-hub 2.0.266 → 2.0.267

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.
@@ -62,8 +62,24 @@ const KNOWN_LABEL_OVERRIDES = {
62
62
  // shouldn't be split on dashes by the default humanizer.
63
63
  '1-1': '1:1',
64
64
  };
65
+ // Issue #1090: this reads JOB-LEVEL `## Intent` / `## Outcome` only.
66
+ //
67
+ // The previous expression was unanchored (`## ${heading}`), which had two
68
+ // consequences on a personalized override that customizes phases:
69
+ //
70
+ // 1. `#### Intent` CONTAINS the substring `## Intent`, so a phase-level heading
71
+ // satisfied a job-level query and the first phase's Intent was promoted to
72
+ // job-level metadata.
73
+ // 2. The terminator `\r?\n## ` requires a space as the 4th character, so it
74
+ // could not stop at `#### Steps` and Outcome swallowed the Steps and Skills
75
+ // blocks until `---` or end of file.
76
+ //
77
+ // The start anchor is written as `(?:^|\r?\n)` rather than using the `m` flag,
78
+ // because under `m` the trailing `$` would match end-of-LINE and truncate a
79
+ // multi-line Outcome to its first bullet. Terminating on any heading level
80
+ // `#{1,6} ` fixes (2).
65
81
  const sectionValue = (content, heading) => {
66
- const match = content.match(new RegExp(`## ${heading}\\r?\\n([\\s\\S]*?)(?:\\r?\\n## |\\r?\\n---|$)`));
82
+ const match = content.match(new RegExp(`(?:^|\\r?\\n)## ${heading}[ \\t]*\\r?\\n([\\s\\S]*?)(?:\\r?\\n#{1,6} |\\r?\\n---|$)`));
67
83
  if (!match)
68
84
  return [];
69
85
  return match[1]
@@ -72,6 +88,62 @@ const sectionValue = (content, heading) => {
72
88
  .filter(Boolean)
73
89
  .map((line) => line.replace(/^[-*]\s*/, '').trim());
74
90
  };
91
+ /**
92
+ * Issue #1090: resolve the baseline stub that an `extends`-only override inherits
93
+ * from, so job-level Intent/Outcome can be inherited rather than scavenged from
94
+ * the override's first phase.
95
+ *
96
+ * `extends` is written as `<category>/<job-name>`. Only NON-personalized layers
97
+ * are searched: an override extending another override is not a supported shape,
98
+ * and following one would risk a cycle.
99
+ */
100
+ function resolveExtendedStubPath(projectPath, extendsValue) {
101
+ const relative = toPosix(extendsValue).replace(/\.md$/i, '');
102
+ if (!relative || relative.includes('..'))
103
+ return null;
104
+ const candidateRoots = [
105
+ path_1.default.join(projectPath, 'fraim', 'ai-employee', 'jobs'),
106
+ path_1.default.join(projectPath, 'fraim', 'ai-manager', 'jobs'),
107
+ path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee'),
108
+ path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager'),
109
+ path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-employee', 'jobs'),
110
+ ];
111
+ for (const root of candidateRoots) {
112
+ const candidate = path_1.default.join(root, `${relative}.md`);
113
+ if (fs_1.default.existsSync(candidate))
114
+ return candidate;
115
+ }
116
+ return null;
117
+ }
118
+ /**
119
+ * Job-level Intent/Outcome for a stub, following `extends` when the stub itself
120
+ * declares none. Returns empty arrays rather than throwing when nothing resolves,
121
+ * so a broken `extends` degrades to the existing "No intent summary available."
122
+ * behaviour instead of breaking catalog discovery.
123
+ */
124
+ function jobLevelSections(content, filePath, projectPath) {
125
+ const intent = sectionValue(content, 'Intent');
126
+ const outcome = sectionValue(content, 'Outcome');
127
+ if (intent.length > 0 || outcome.length > 0)
128
+ return { intent, outcome };
129
+ const frontmatter = readJobFrontmatter(filePath);
130
+ const extendsValue = typeof frontmatter?.extends === 'string' ? frontmatter.extends.trim() : '';
131
+ if (!extendsValue)
132
+ return { intent, outcome };
133
+ const basePath = resolveExtendedStubPath(projectPath, extendsValue);
134
+ if (!basePath)
135
+ return { intent, outcome };
136
+ try {
137
+ const baseContent = fs_1.default.readFileSync(basePath, 'utf8');
138
+ return {
139
+ intent: sectionValue(baseContent, 'Intent'),
140
+ outcome: sectionValue(baseContent, 'Outcome'),
141
+ };
142
+ }
143
+ catch {
144
+ return { intent, outcome };
145
+ }
146
+ }
75
147
  const humanizeName = (raw) => {
76
148
  const lower = raw.toLowerCase();
77
149
  if (KNOWN_LABEL_OVERRIDES[lower])
@@ -122,8 +194,9 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
122
194
  const fileName = path_1.default.basename(filePath, '.md');
123
195
  const frontmatter = readJobFrontmatter(filePath);
124
196
  const title = frontmatter?.displayName?.trim() || humanizeName(fileName);
125
- const intent = sectionValue(content, 'Intent')[0] || 'No intent summary available.';
126
- const outcome = sectionValue(content, 'Outcome');
197
+ const sections = jobLevelSections(content, filePath, projectPath);
198
+ const intent = sections.intent[0] || 'No intent summary available.';
199
+ const outcome = sections.outcome;
127
200
  return {
128
201
  id: fileName,
129
202
  title,
@@ -27,6 +27,7 @@ const HEADER_OMITTED_FIELDS = [
27
27
  'artifacts',
28
28
  'run',
29
29
  'delegation',
30
+ 'handoffSummary',
30
31
  '_bodyLoaded',
31
32
  '_stopping',
32
33
  '_priorEvents',
@@ -174,6 +174,60 @@ function getHubPersonaForJob(jobName) {
174
174
  return null;
175
175
  return getProtectedPersonaForHubJob(jobName) ?? DEFAULT_UNASSIGNED_PERSONA_KEY;
176
176
  }
177
+ /**
178
+ * Issue #1090: derive a conversation's owning persona at read time instead of
179
+ * trusting the value frozen onto the record when it was created.
180
+ *
181
+ * The stored value is a snapshot. Re-assigning a job to a different employee,
182
+ * hiring a custom employee for it, or changing the protected-persona bundle used
183
+ * to leave every existing run reporting the old owner forever, with no way to
184
+ * correct it. Deriving on read means the Hub always reflects current ownership.
185
+ *
186
+ * One case must NOT be derived: a Mandy-delegated child workstream gets its
187
+ * persona per-task from the delegation ledger, which is not a function of jobId.
188
+ * Deriving there would silently reassign every delegated workstream to the job's
189
+ * default owner, so those keep their stored key.
190
+ */
191
+ function resolveConversationPersonaKey(conversation, projectPath, customJobOwners) {
192
+ const isDelegatedChild = Boolean(conversation.managedByRunId || conversation.delegationTaskId);
193
+ if (isDelegatedChild)
194
+ return conversation.personaKey ?? null;
195
+ const jobId = conversation.jobId ?? '';
196
+ if (!jobId || jobId === '__freeform__')
197
+ return null;
198
+ // `customJobOwners` is the hoisted form of getCustomPersonaForJob, used when
199
+ // deriving a whole list. See buildCustomJobOwnerIndex for why.
200
+ const custom = customJobOwners
201
+ ? customJobOwners.get(jobId) ?? null
202
+ : getCustomPersonaForJob(projectPath, jobId);
203
+ return custom ?? getHubPersonaForJob(jobId);
204
+ }
205
+ /**
206
+ * Issue #1090: `getCustomPersonaForJob` calls `readCustomEmployees`, which walks
207
+ * the org, manager, and project employee directories from disk on every call.
208
+ * Deriving persona per conversation in a list would therefore do one full disk
209
+ * walk PER CONVERSATION - O(N) filesystem work on a hot read path, which the Hub
210
+ * performance budgets correctly flag on a project with a large rail.
211
+ *
212
+ * Building the jobId -> persona index once per request keeps the derivation O(1)
213
+ * per conversation and the disk walk at exactly one.
214
+ */
215
+ function buildCustomJobOwnerIndex(projectPath) {
216
+ const index = new Map();
217
+ for (const employee of (0, custom_employees_1.readCustomEmployees)(projectPath)) {
218
+ if (!Array.isArray(employee.jobIds))
219
+ continue;
220
+ for (const jobId of employee.jobIds) {
221
+ if (!index.has(jobId))
222
+ index.set(jobId, employee.key);
223
+ }
224
+ }
225
+ return index;
226
+ }
227
+ /** Apply read-time persona derivation to a conversation record before serving it. */
228
+ function withDerivedPersona(conversation, projectPath, customJobOwners) {
229
+ return { ...conversation, personaKey: resolveConversationPersonaKey(conversation, projectPath, customJobOwners) };
230
+ }
177
231
  // Issue #991: consult custom employee records before falling back to the catalog.
178
232
  // Returns the key of the first custom employee whose jobIds array includes jobId,
179
233
  // or null if no custom employee owns it.
@@ -2758,7 +2812,7 @@ class AiHubServer {
2758
2812
  issueNumber: conversation.issueNumber ?? null,
2759
2813
  agentSwitches: conversation.agentSwitches || [],
2760
2814
  handoffSummary: conversation.handoffSummary || null,
2761
- personaKey: conversation.personaKey ?? getHubPersonaForJob(conversation.jobId),
2815
+ personaKey: resolveConversationPersonaKey(conversation, projectPath),
2762
2816
  orchestratedDelegationTaskIds: conversation.delegation?.tasks
2763
2817
  ?.filter((task) => task.taskId && (task.conversationId || task.runId || ['running', 'submitted', 'completed', 'reviewed', 'blocked'].includes(task.status)))
2764
2818
  .map((task) => task.taskId) || [],
@@ -3886,14 +3940,23 @@ class AiHubServer {
3886
3940
  const conversation = this.conversationStore.loadConversation(key, conversationId);
3887
3941
  if (!conversation)
3888
3942
  return res.status(404).json({ error: 'conversation not found' });
3889
- return res.json({ projectPath: key, scope: scope ?? 'project', conversation, source: 'disk' });
3943
+ // Issue #1090: ownership is derived on read, never served from the frozen record.
3944
+ return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(conversation, key), source: 'disk' });
3890
3945
  }
3891
3946
  if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
3892
- const headers = this.conversationStore.loadProjectHeaders(key);
3947
+ // One disk walk for the whole list, not one per conversation.
3948
+ const customOwners = buildCustomJobOwnerIndex(key);
3949
+ const headers = this.conversationStore.loadProjectHeaders(key)
3950
+ .map((header) => withDerivedPersona(header, key, customOwners));
3893
3951
  return res.json({ projectPath: key, scope: scope ?? 'project', headersOnly: true, conversations: headers, source: 'disk' });
3894
3952
  }
3895
3953
  const loaded = this.conversationStore.loadProject(key);
3896
- return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
3954
+ const fullCustomOwners = buildCustomJobOwnerIndex(key);
3955
+ const derived = {
3956
+ ...loaded,
3957
+ conversations: (loaded.conversations || []).map((conversation) => withDerivedPersona(conversation, key, fullCustomOwners)),
3958
+ };
3959
+ return res.json({ projectPath: key, scope: scope ?? 'project', ...derived, source: 'disk' });
3897
3960
  });
3898
3961
  this.app.put('/api/ai-hub/conversations', (req, res) => {
3899
3962
  try {
@@ -4089,7 +4152,7 @@ class AiHubServer {
4089
4152
  totals: persistedRun?.totals || emptyTotals(),
4090
4153
  lastStatusChangeAt: now,
4091
4154
  runDiscriminant: persistedRun?.runDiscriminant || undefined,
4092
- personaKey: conversation.personaKey ?? getHubPersonaForJob(conversation.jobId),
4155
+ personaKey: resolveConversationPersonaKey(conversation, bucketKey),
4093
4156
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
4094
4157
  reviewHandoff: conversation.reviewHandoff || null,
4095
4158
  delegation: conversation.delegation || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.266",
3
+ "version": "2.0.267",
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.266",
166
+ "fraim": "2.0.267",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -1028,8 +1028,18 @@ function normalizeGeminiConversationMessages(conv) {
1028
1028
  // request 413s, the error is swallowed, and the client-owned fields in the SAME
1029
1029
  // payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
1030
1030
  // here; the server's PUT handler merges them back from the stored record.
1031
- const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation'];
1032
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_stopping'];
1031
+ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1032
+ // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1033
+ // body, which is a different claim from "the record has body fields". A run can
1034
+ // legitimately finish with no messages, events, artifacts, run, or delegation;
1035
+ // before this marker existed such a body was indistinguishable from one that had
1036
+ // never been hydrated, so the transcript hung on "Loading conversation…" and
1037
+ // re-fetched on every poll tick forever.
1038
+ //
1039
+ // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1040
+ // any inbound value. That is what preserves issue #913: a marker arriving over
1041
+ // the wire is never trusted, only one this client set after a real fetch.
1042
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1033
1043
  function slimConversationForPersist(conv) {
1034
1044
  if (!conv || typeof conv !== 'object') return conv;
1035
1045
  const slim = { ...conv };
@@ -1043,6 +1053,11 @@ function slimConversationsForPersist(list) {
1043
1053
 
1044
1054
  function conversationHasBody(conv) {
1045
1055
  if (!conv) return false;
1056
+ // Issue #1090: a body this client already fetched counts as hydrated even when
1057
+ // it carries none of the server-owned fields, so an empty transcript settles
1058
+ // instead of looping. Issue #913 stays satisfied because `_bodyFetched` is
1059
+ // client-only and stripped from every inbound header.
1060
+ if (conv._bodyFetched === true) return true;
1046
1061
  return SERVER_OWNED_CONV_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(conv, field));
1047
1062
  }
1048
1063
 
@@ -1062,6 +1077,12 @@ function mergeConversationHeader(existing, header) {
1062
1077
  merged[field] = existing[field];
1063
1078
  }
1064
1079
  }
1080
+ // Issue #1090: carry the client's own fetch marker forward. A header refresh
1081
+ // must not erase the fact that we already hydrated this body, or the next
1082
+ // render re-enters the loading state and re-fetches. This is deliberately NOT
1083
+ // recomputed from field presence, which is what made the marker inert before.
1084
+ if (existing._bodyFetched === true) merged._bodyFetched = true;
1085
+ else delete merged._bodyFetched;
1065
1086
  if (conversationHasBody(existing)) merged._bodyLoaded = true;
1066
1087
  else delete merged._bodyLoaded;
1067
1088
  return merged;
@@ -1070,6 +1091,9 @@ function mergeConversationHeader(existing, header) {
1070
1091
  function mergeConversationBody(existing, body) {
1071
1092
  const merged = existing ? { ...existing, ...body } : body;
1072
1093
  merged._bodyLoaded = true;
1094
+ // Issue #1090: the body request came back, so this conversation is hydrated
1095
+ // regardless of which fields the response happened to contain.
1096
+ merged._bodyFetched = true;
1073
1097
  return merged;
1074
1098
  }
1075
1099
 
@@ -3162,9 +3186,12 @@ let renderedConvId = null;
3162
3186
  let renderedMessageCount = 0;
3163
3187
  let renderedMessageFingerprints = [];
3164
3188
  let renderedEventCount = 0;
3189
+ let renderedEventWindowStart = 0;
3165
3190
  let renderedStatus = null;
3166
3191
  let renderedDirectEventCount = 0;
3167
3192
  let threadMessageViewportObserver = null;
3193
+ const MICRO_LOG_VISIBLE_EVENT_LIMIT = 300;
3194
+ const MICRO_LOG_EVENT_TEXT_LIMIT = 1000;
3168
3195
  const conversationFocusState = {
3169
3196
  open: false,
3170
3197
  previousFocus: null,
@@ -3192,6 +3219,43 @@ function tfApplyWorkspaceMode() {
3192
3219
  if (briefNav) briefNav.classList.toggle('active', !hasConv);
3193
3220
  }
3194
3221
 
3222
+ function microLogVisibleWindow(events) {
3223
+ const list = Array.isArray(events) ? events : [];
3224
+ const start = Math.max(0, list.length - MICRO_LOG_VISIBLE_EVENT_LIMIT);
3225
+ return {
3226
+ start,
3227
+ events: list.slice(start),
3228
+ hiddenCount: start,
3229
+ };
3230
+ }
3231
+
3232
+ function formatMicroLogLine(event) {
3233
+ const channel = event && event.channel ? event.channel : 'system';
3234
+ let text = event && event.text !== undefined && event.text !== null ? String(event.text) : '';
3235
+ if (text.length > MICRO_LOG_EVENT_TEXT_LIMIT) {
3236
+ text = text.slice(0, MICRO_LOG_EVENT_TEXT_LIMIT) + '\n... [event truncated in UI; full event is stored in the run history]';
3237
+ }
3238
+ return `[${channel}] ${text}\n`;
3239
+ }
3240
+
3241
+ function renderMicroLogEvents(events) {
3242
+ const windowed = microLogVisibleWindow(events);
3243
+ if (windowed.start !== renderedEventWindowStart || events.length < renderedEventCount) {
3244
+ els['micro-log'].textContent = '';
3245
+ renderedEventCount = windowed.start;
3246
+ renderedEventWindowStart = windowed.start;
3247
+ if (windowed.hiddenCount > 0) {
3248
+ els['micro-log'].appendChild(document.createTextNode(
3249
+ `[system] Showing latest ${windowed.events.length} of ${events.length} events. Full event history remains stored.\n`
3250
+ ));
3251
+ }
3252
+ }
3253
+ for (let i = Math.max(renderedEventCount, windowed.start); i < events.length; i += 1) {
3254
+ els['micro-log'].appendChild(document.createTextNode(formatMicroLogLine(events[i])));
3255
+ }
3256
+ renderedEventCount = events.length;
3257
+ }
3258
+
3195
3259
  function syncConversationFocusTitle(conv) {
3196
3260
  if (!els['conversation-focus-title']) return;
3197
3261
  els['conversation-focus-title'].textContent = conv ? conversationTitle(conv) : 'Focused conversation';
@@ -3304,6 +3368,7 @@ function renderActive() {
3304
3368
  renderedMessageCount = 0;
3305
3369
  renderedMessageFingerprints = [];
3306
3370
  renderedEventCount = 0;
3371
+ renderedEventWindowStart = 0;
3307
3372
  renderedStatus = null;
3308
3373
  renderedDirectEventCount = 0;
3309
3374
  syncConversationRefreshPolling();
@@ -3337,6 +3402,7 @@ function renderActive() {
3337
3402
  renderedMessageCount = 0;
3338
3403
  renderedMessageFingerprints = [];
3339
3404
  renderedEventCount = 0;
3405
+ renderedEventWindowStart = 0;
3340
3406
  renderedDirectEventCount = 0;
3341
3407
  // A pending coaching job is scoped to the active conversation — discard it
3342
3408
  // whenever the user switches to a different conversation.
@@ -3425,15 +3491,7 @@ function renderActive() {
3425
3491
  // Micro-manage — only append new events. textContent assignment on the
3426
3492
  // <pre> wipes the entire log every tick which is wasteful.
3427
3493
  const events = conv.events || [];
3428
- if (events.length < renderedEventCount) {
3429
- els['micro-log'].textContent = '';
3430
- renderedEventCount = 0;
3431
- }
3432
- for (let i = renderedEventCount; i < events.length; i += 1) {
3433
- const line = `[${events[i].channel || 'system'}] ${events[i].text}\n`;
3434
- els['micro-log'].appendChild(document.createTextNode(line));
3435
- }
3436
- renderedEventCount = events.length;
3494
+ renderMicroLogEvents(events);
3437
3495
  renderResumeCommand(conv);
3438
3496
 
3439
3497
  // Coaching state — only enable Send when there's text and the run is resumable.