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(
|
|
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
|
|
126
|
-
const
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|