fraim-hub 2.0.266 → 2.0.268
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,63 @@ 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
|
+
if (!custom && conversation.personaKey?.startsWith('custom:')) {
|
|
204
|
+
return conversation.personaKey;
|
|
205
|
+
}
|
|
206
|
+
return custom ?? getHubPersonaForJob(jobId);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Issue #1090: `getCustomPersonaForJob` calls `readCustomEmployees`, which walks
|
|
210
|
+
* the org, manager, and project employee directories from disk on every call.
|
|
211
|
+
* Deriving persona per conversation in a list would therefore do one full disk
|
|
212
|
+
* walk PER CONVERSATION - O(N) filesystem work on a hot read path, which the Hub
|
|
213
|
+
* performance budgets correctly flag on a project with a large rail.
|
|
214
|
+
*
|
|
215
|
+
* Building the jobId -> persona index once per request keeps the derivation O(1)
|
|
216
|
+
* per conversation and the disk walk at exactly one.
|
|
217
|
+
*/
|
|
218
|
+
function buildCustomJobOwnerIndex(projectPath) {
|
|
219
|
+
const index = new Map();
|
|
220
|
+
for (const employee of (0, custom_employees_1.readCustomEmployees)(projectPath)) {
|
|
221
|
+
if (!Array.isArray(employee.jobIds))
|
|
222
|
+
continue;
|
|
223
|
+
for (const jobId of employee.jobIds) {
|
|
224
|
+
if (!index.has(jobId))
|
|
225
|
+
index.set(jobId, employee.key);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return index;
|
|
229
|
+
}
|
|
230
|
+
/** Apply read-time persona derivation to a conversation record before serving it. */
|
|
231
|
+
function withDerivedPersona(conversation, projectPath, customJobOwners) {
|
|
232
|
+
return { ...conversation, personaKey: resolveConversationPersonaKey(conversation, projectPath, customJobOwners) };
|
|
233
|
+
}
|
|
177
234
|
// Issue #991: consult custom employee records before falling back to the catalog.
|
|
178
235
|
// Returns the key of the first custom employee whose jobIds array includes jobId,
|
|
179
236
|
// or null if no custom employee owns it.
|
|
@@ -2758,7 +2815,7 @@ class AiHubServer {
|
|
|
2758
2815
|
issueNumber: conversation.issueNumber ?? null,
|
|
2759
2816
|
agentSwitches: conversation.agentSwitches || [],
|
|
2760
2817
|
handoffSummary: conversation.handoffSummary || null,
|
|
2761
|
-
personaKey: conversation
|
|
2818
|
+
personaKey: resolveConversationPersonaKey(conversation, projectPath),
|
|
2762
2819
|
orchestratedDelegationTaskIds: conversation.delegation?.tasks
|
|
2763
2820
|
?.filter((task) => task.taskId && (task.conversationId || task.runId || ['running', 'submitted', 'completed', 'reviewed', 'blocked'].includes(task.status)))
|
|
2764
2821
|
.map((task) => task.taskId) || [],
|
|
@@ -3886,14 +3943,23 @@ class AiHubServer {
|
|
|
3886
3943
|
const conversation = this.conversationStore.loadConversation(key, conversationId);
|
|
3887
3944
|
if (!conversation)
|
|
3888
3945
|
return res.status(404).json({ error: 'conversation not found' });
|
|
3889
|
-
|
|
3946
|
+
// Issue #1090: ownership is derived on read, never served from the frozen record.
|
|
3947
|
+
return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(conversation, key), source: 'disk' });
|
|
3890
3948
|
}
|
|
3891
3949
|
if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
|
|
3892
|
-
|
|
3950
|
+
// One disk walk for the whole list, not one per conversation.
|
|
3951
|
+
const customOwners = buildCustomJobOwnerIndex(key);
|
|
3952
|
+
const headers = this.conversationStore.loadProjectHeaders(key)
|
|
3953
|
+
.map((header) => withDerivedPersona(header, key, customOwners));
|
|
3893
3954
|
return res.json({ projectPath: key, scope: scope ?? 'project', headersOnly: true, conversations: headers, source: 'disk' });
|
|
3894
3955
|
}
|
|
3895
3956
|
const loaded = this.conversationStore.loadProject(key);
|
|
3896
|
-
|
|
3957
|
+
const fullCustomOwners = buildCustomJobOwnerIndex(key);
|
|
3958
|
+
const derived = {
|
|
3959
|
+
...loaded,
|
|
3960
|
+
conversations: (loaded.conversations || []).map((conversation) => withDerivedPersona(conversation, key, fullCustomOwners)),
|
|
3961
|
+
};
|
|
3962
|
+
return res.json({ projectPath: key, scope: scope ?? 'project', ...derived, source: 'disk' });
|
|
3897
3963
|
});
|
|
3898
3964
|
this.app.put('/api/ai-hub/conversations', (req, res) => {
|
|
3899
3965
|
try {
|
|
@@ -4089,7 +4155,7 @@ class AiHubServer {
|
|
|
4089
4155
|
totals: persistedRun?.totals || emptyTotals(),
|
|
4090
4156
|
lastStatusChangeAt: now,
|
|
4091
4157
|
runDiscriminant: persistedRun?.runDiscriminant || undefined,
|
|
4092
|
-
personaKey: conversation
|
|
4158
|
+
personaKey: resolveConversationPersonaKey(conversation, bucketKey),
|
|
4093
4159
|
artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
|
|
4094
4160
|
reviewHandoff: conversation.reviewHandoff || null,
|
|
4095
4161
|
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.268",
|
|
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.268",
|
|
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
|
@@ -760,13 +760,19 @@ function ensureTaskPaneLauncher() {
|
|
|
760
760
|
root.appendChild(actions);
|
|
761
761
|
|
|
762
762
|
document.body.insertBefore(root, document.body.firstChild);
|
|
763
|
-
document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
|
|
764
|
-
const statusEl = document.getElementById('task-pane-start-status');
|
|
765
|
-
const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
|
|
766
|
-
if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
|
|
763
|
+
document.getElementById('task-pane-project-select')?.addEventListener('change', (event) => {
|
|
764
|
+
const statusEl = document.getElementById('task-pane-start-status');
|
|
765
|
+
const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
|
|
766
|
+
if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
|
|
767
|
+
});
|
|
768
|
+
document.getElementById('task-pane-employee-select')?.addEventListener('change', (event) => {
|
|
769
|
+
const value = event.target && event.target.value;
|
|
770
|
+
if (value && hubConfiguredAgents().some((agent) => agent.id === value)) {
|
|
771
|
+
state.selectedEmployeeId = value;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
return root;
|
|
775
|
+
}
|
|
770
776
|
|
|
771
777
|
function renderTaskPaneLauncher() {
|
|
772
778
|
if (!isTaskPaneSurface() || !state.bootstrap) return;
|
|
@@ -851,20 +857,23 @@ async function taskPaneStartSelectedJob() {
|
|
|
851
857
|
if (start) start.disabled = true;
|
|
852
858
|
if (statusEl) statusEl.textContent = 'Starting...';
|
|
853
859
|
try {
|
|
854
|
-
const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
|
|
855
|
-
if (result && result.ok) {
|
|
856
|
-
taskPaneLauncherForcedOpen = false;
|
|
857
|
-
if (statusEl) statusEl.textContent = 'Started.';
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
860
|
+
const result = await startRun(job, instructions ? instructions.value : '', employeeId, undefined, 'projects');
|
|
861
|
+
if (result && result.ok) {
|
|
862
|
+
taskPaneLauncherForcedOpen = false;
|
|
863
|
+
if (statusEl) statusEl.textContent = 'Started.';
|
|
864
|
+
renderActive();
|
|
865
|
+
syncTaskPaneSurfaceMode();
|
|
866
|
+
} else if (statusEl) {
|
|
867
|
+
statusEl.textContent = (result && result.error) || 'Could not start.';
|
|
868
|
+
}
|
|
861
869
|
} catch (error) {
|
|
862
870
|
if (statusEl) statusEl.textContent = 'Could not start.';
|
|
863
871
|
console.warn('[ai-hub] task-pane start failed:', error);
|
|
864
|
-
} finally {
|
|
865
|
-
renderTaskPaneLauncher();
|
|
866
|
-
|
|
867
|
-
}
|
|
872
|
+
} finally {
|
|
873
|
+
renderTaskPaneLauncher();
|
|
874
|
+
syncTaskPaneSurfaceMode();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
868
877
|
|
|
869
878
|
// ---------------------------------------------------------------------------
|
|
870
879
|
// Concept popover population (drives the welcome-line "see ... jobs" lists)
|
|
@@ -1028,8 +1037,18 @@ function normalizeGeminiConversationMessages(conv) {
|
|
|
1028
1037
|
// request 413s, the error is swallowed, and the client-owned fields in the SAME
|
|
1029
1038
|
// payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
|
|
1030
1039
|
// 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
|
-
|
|
1040
|
+
const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
|
|
1041
|
+
// Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
|
|
1042
|
+
// body, which is a different claim from "the record has body fields". A run can
|
|
1043
|
+
// legitimately finish with no messages, events, artifacts, run, or delegation;
|
|
1044
|
+
// before this marker existed such a body was indistinguishable from one that had
|
|
1045
|
+
// never been hydrated, so the transcript hung on "Loading conversation…" and
|
|
1046
|
+
// re-fetched on every poll tick forever.
|
|
1047
|
+
//
|
|
1048
|
+
// It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
|
|
1049
|
+
// any inbound value. That is what preserves issue #913: a marker arriving over
|
|
1050
|
+
// the wire is never trusted, only one this client set after a real fetch.
|
|
1051
|
+
const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
|
|
1033
1052
|
function slimConversationForPersist(conv) {
|
|
1034
1053
|
if (!conv || typeof conv !== 'object') return conv;
|
|
1035
1054
|
const slim = { ...conv };
|
|
@@ -1043,6 +1062,11 @@ function slimConversationsForPersist(list) {
|
|
|
1043
1062
|
|
|
1044
1063
|
function conversationHasBody(conv) {
|
|
1045
1064
|
if (!conv) return false;
|
|
1065
|
+
// Issue #1090: a body this client already fetched counts as hydrated even when
|
|
1066
|
+
// it carries none of the server-owned fields, so an empty transcript settles
|
|
1067
|
+
// instead of looping. Issue #913 stays satisfied because `_bodyFetched` is
|
|
1068
|
+
// client-only and stripped from every inbound header.
|
|
1069
|
+
if (conv._bodyFetched === true) return true;
|
|
1046
1070
|
return SERVER_OWNED_CONV_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(conv, field));
|
|
1047
1071
|
}
|
|
1048
1072
|
|
|
@@ -1062,6 +1086,12 @@ function mergeConversationHeader(existing, header) {
|
|
|
1062
1086
|
merged[field] = existing[field];
|
|
1063
1087
|
}
|
|
1064
1088
|
}
|
|
1089
|
+
// Issue #1090: carry the client's own fetch marker forward. A header refresh
|
|
1090
|
+
// must not erase the fact that we already hydrated this body, or the next
|
|
1091
|
+
// render re-enters the loading state and re-fetches. This is deliberately NOT
|
|
1092
|
+
// recomputed from field presence, which is what made the marker inert before.
|
|
1093
|
+
if (existing._bodyFetched === true) merged._bodyFetched = true;
|
|
1094
|
+
else delete merged._bodyFetched;
|
|
1065
1095
|
if (conversationHasBody(existing)) merged._bodyLoaded = true;
|
|
1066
1096
|
else delete merged._bodyLoaded;
|
|
1067
1097
|
return merged;
|
|
@@ -1070,6 +1100,9 @@ function mergeConversationHeader(existing, header) {
|
|
|
1070
1100
|
function mergeConversationBody(existing, body) {
|
|
1071
1101
|
const merged = existing ? { ...existing, ...body } : body;
|
|
1072
1102
|
merged._bodyLoaded = true;
|
|
1103
|
+
// Issue #1090: the body request came back, so this conversation is hydrated
|
|
1104
|
+
// regardless of which fields the response happened to contain.
|
|
1105
|
+
merged._bodyFetched = true;
|
|
1073
1106
|
return merged;
|
|
1074
1107
|
}
|
|
1075
1108
|
|
|
@@ -3162,9 +3195,12 @@ let renderedConvId = null;
|
|
|
3162
3195
|
let renderedMessageCount = 0;
|
|
3163
3196
|
let renderedMessageFingerprints = [];
|
|
3164
3197
|
let renderedEventCount = 0;
|
|
3198
|
+
let renderedEventWindowStart = 0;
|
|
3165
3199
|
let renderedStatus = null;
|
|
3166
3200
|
let renderedDirectEventCount = 0;
|
|
3167
3201
|
let threadMessageViewportObserver = null;
|
|
3202
|
+
const MICRO_LOG_VISIBLE_EVENT_LIMIT = 300;
|
|
3203
|
+
const MICRO_LOG_EVENT_TEXT_LIMIT = 1000;
|
|
3168
3204
|
const conversationFocusState = {
|
|
3169
3205
|
open: false,
|
|
3170
3206
|
previousFocus: null,
|
|
@@ -3192,6 +3228,43 @@ function tfApplyWorkspaceMode() {
|
|
|
3192
3228
|
if (briefNav) briefNav.classList.toggle('active', !hasConv);
|
|
3193
3229
|
}
|
|
3194
3230
|
|
|
3231
|
+
function microLogVisibleWindow(events) {
|
|
3232
|
+
const list = Array.isArray(events) ? events : [];
|
|
3233
|
+
const start = Math.max(0, list.length - MICRO_LOG_VISIBLE_EVENT_LIMIT);
|
|
3234
|
+
return {
|
|
3235
|
+
start,
|
|
3236
|
+
events: list.slice(start),
|
|
3237
|
+
hiddenCount: start,
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
function formatMicroLogLine(event) {
|
|
3242
|
+
const channel = event && event.channel ? event.channel : 'system';
|
|
3243
|
+
let text = event && event.text !== undefined && event.text !== null ? String(event.text) : '';
|
|
3244
|
+
if (text.length > MICRO_LOG_EVENT_TEXT_LIMIT) {
|
|
3245
|
+
text = text.slice(0, MICRO_LOG_EVENT_TEXT_LIMIT) + '\n... [event truncated in UI; full event is stored in the run history]';
|
|
3246
|
+
}
|
|
3247
|
+
return `[${channel}] ${text}\n`;
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
function renderMicroLogEvents(events) {
|
|
3251
|
+
const windowed = microLogVisibleWindow(events);
|
|
3252
|
+
if (windowed.start !== renderedEventWindowStart || events.length < renderedEventCount) {
|
|
3253
|
+
els['micro-log'].textContent = '';
|
|
3254
|
+
renderedEventCount = windowed.start;
|
|
3255
|
+
renderedEventWindowStart = windowed.start;
|
|
3256
|
+
if (windowed.hiddenCount > 0) {
|
|
3257
|
+
els['micro-log'].appendChild(document.createTextNode(
|
|
3258
|
+
`[system] Showing latest ${windowed.events.length} of ${events.length} events. Full event history remains stored.\n`
|
|
3259
|
+
));
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
for (let i = Math.max(renderedEventCount, windowed.start); i < events.length; i += 1) {
|
|
3263
|
+
els['micro-log'].appendChild(document.createTextNode(formatMicroLogLine(events[i])));
|
|
3264
|
+
}
|
|
3265
|
+
renderedEventCount = events.length;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3195
3268
|
function syncConversationFocusTitle(conv) {
|
|
3196
3269
|
if (!els['conversation-focus-title']) return;
|
|
3197
3270
|
els['conversation-focus-title'].textContent = conv ? conversationTitle(conv) : 'Focused conversation';
|
|
@@ -3304,6 +3377,7 @@ function renderActive() {
|
|
|
3304
3377
|
renderedMessageCount = 0;
|
|
3305
3378
|
renderedMessageFingerprints = [];
|
|
3306
3379
|
renderedEventCount = 0;
|
|
3380
|
+
renderedEventWindowStart = 0;
|
|
3307
3381
|
renderedStatus = null;
|
|
3308
3382
|
renderedDirectEventCount = 0;
|
|
3309
3383
|
syncConversationRefreshPolling();
|
|
@@ -3337,6 +3411,7 @@ function renderActive() {
|
|
|
3337
3411
|
renderedMessageCount = 0;
|
|
3338
3412
|
renderedMessageFingerprints = [];
|
|
3339
3413
|
renderedEventCount = 0;
|
|
3414
|
+
renderedEventWindowStart = 0;
|
|
3340
3415
|
renderedDirectEventCount = 0;
|
|
3341
3416
|
// A pending coaching job is scoped to the active conversation — discard it
|
|
3342
3417
|
// whenever the user switches to a different conversation.
|
|
@@ -3425,15 +3500,7 @@ function renderActive() {
|
|
|
3425
3500
|
// Micro-manage — only append new events. textContent assignment on the
|
|
3426
3501
|
// <pre> wipes the entire log every tick which is wasteful.
|
|
3427
3502
|
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;
|
|
3503
|
+
renderMicroLogEvents(events);
|
|
3437
3504
|
renderResumeCommand(conv);
|
|
3438
3505
|
|
|
3439
3506
|
// Coaching state — only enable Send when there's text and the run is resumable.
|