fraim 2.0.159 → 2.0.160
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.
- package/README.md +1 -1
- package/dist/src/ai-hub/cert-store.js +70 -0
- package/dist/src/ai-hub/desktop-main.js +225 -50
- package/dist/src/ai-hub/manager-turns.js +38 -0
- package/dist/src/ai-hub/office-sideload.js +138 -0
- package/dist/src/ai-hub/openclaw-bridge.js +239 -0
- package/dist/src/ai-hub/server.js +346 -115
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/add-ide.js +9 -0
- package/dist/src/cli/commands/login.js +1 -2
- package/dist/src/cli/commands/setup.js +0 -2
- package/dist/src/cli/commands/sync.js +19 -10
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
- package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
- package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
- package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
- package/dist/src/cli/utils/fraim-gitignore.js +11 -0
- package/dist/src/cli/utils/remote-sync.js +1 -1
- package/dist/src/core/config-loader.js +1 -2
- package/dist/src/core/fraim-config-schema.generated.js +0 -5
- package/dist/src/core/types.js +0 -1
- package/dist/src/first-run/session-service.js +3 -3
- package/package.json +2 -1
- package/public/ai-hub/index.html +20 -2
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
- package/public/ai-hub/script.js +337 -120
- package/public/ai-hub/styles.css +456 -135
package/public/ai-hub/script.js
CHANGED
|
@@ -24,6 +24,13 @@ const state = {
|
|
|
24
24
|
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
25
25
|
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
26
26
|
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
27
|
+
wordContext: null, // WordContext pushed from taskpane.html via postMessage
|
|
28
|
+
// Pending coaching job selected via a quick-coach button or template picker.
|
|
29
|
+
// Sent as coachingJobId in the next continueRun call and then cleared.
|
|
30
|
+
// The invocation prefix ($fraim / /fraim) is added server-side based on
|
|
31
|
+
// the active employee; the user never sees the raw invocation syntax.
|
|
32
|
+
pendingCoachingJobId: null,
|
|
33
|
+
pendingCoachingLabel: null,
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
const els = {};
|
|
@@ -60,6 +67,9 @@ function gatherElements() {
|
|
|
60
67
|
// Issue #442: A/B mode elements.
|
|
61
68
|
'ab-toggle-wrap', 'ab-direct-panel',
|
|
62
69
|
'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
|
|
70
|
+
// Issue #489: Word context elements.
|
|
71
|
+
'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
|
|
72
|
+
'word-context-card', 'word-ctx-card-label', 'word-ctx-card-body', 'word-ctx-card-toggle',
|
|
63
73
|
];
|
|
64
74
|
for (const id of ids) {
|
|
65
75
|
els[id] = document.getElementById(id);
|
|
@@ -67,6 +77,14 @@ function gatherElements() {
|
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
async function requestJson(url, options) {
|
|
80
|
+
// On localhost inject x-api-key: local-dev so the dev server auth bypass fires.
|
|
81
|
+
// The server's isLocalDev check already gates this to NODE_ENV !== 'production',
|
|
82
|
+
// so this header is harmless if accidentally sent to a non-dev server.
|
|
83
|
+
const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
84
|
+
if (isLocalHost) {
|
|
85
|
+
const headers = { 'x-api-key': 'local-dev', ...((options && options.headers) || {}) };
|
|
86
|
+
options = { ...(options || {}), headers };
|
|
87
|
+
}
|
|
70
88
|
const response = await fetch(url, options);
|
|
71
89
|
let payload = null;
|
|
72
90
|
try { payload = await response.json(); } catch { /* may be empty */ }
|
|
@@ -78,10 +96,13 @@ async function requestJson(url, options) {
|
|
|
78
96
|
return payload;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
async function loadBootstrap(projectPath) {
|
|
82
|
-
const
|
|
99
|
+
async function loadBootstrap(projectPath, docUrl) {
|
|
100
|
+
const params = new URLSearchParams();
|
|
101
|
+
if (projectPath) params.set('projectPath', projectPath);
|
|
102
|
+
if (docUrl) params.set('docUrl', docUrl);
|
|
103
|
+
const query = params.toString() ? '?' + params.toString() : '';
|
|
83
104
|
const fetchOptions = {};
|
|
84
|
-
if (state.storedApiKey) fetchOptions.headers = { 'x-
|
|
105
|
+
if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
|
|
85
106
|
const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
|
|
86
107
|
state.bootstrap = bootstrap;
|
|
87
108
|
state.projectPath = bootstrap.project.path;
|
|
@@ -94,6 +115,20 @@ async function loadBootstrap(projectPath) {
|
|
|
94
115
|
els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
|
|
95
116
|
els['status-line'].textContent = bootstrap.project.message || '';
|
|
96
117
|
els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
|
|
118
|
+
// Update the compact project label shown in task-pane surface rail.
|
|
119
|
+
const tpLabel = document.getElementById('tp-project-label');
|
|
120
|
+
if (tpLabel) {
|
|
121
|
+
const projectName = friendlyProjectName(bootstrap.project.path);
|
|
122
|
+
const resolvedDocUrl = docUrl || new URLSearchParams(window.location.search).get('docUrl') || '';
|
|
123
|
+
let docName = '';
|
|
124
|
+
if (resolvedDocUrl) {
|
|
125
|
+
try {
|
|
126
|
+
const rawName = resolvedDocUrl.replace(/\\/g, '/').split('/').pop() || '';
|
|
127
|
+
docName = decodeURIComponent(rawName).replace(/\.[^.]+$/, '');
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
tpLabel.textContent = docName ? `${projectName} · ${docName}` : projectName;
|
|
131
|
+
}
|
|
97
132
|
// Populate the welcome-line popovers with REAL jobs from the bootstrap.
|
|
98
133
|
populateConceptPopovers();
|
|
99
134
|
// Render rail and active state for this project.
|
|
@@ -101,6 +136,121 @@ async function loadBootstrap(projectPath) {
|
|
|
101
136
|
renderActive();
|
|
102
137
|
}
|
|
103
138
|
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Word context bridge (postMessage from taskpane.html parent frame)
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
// Apply a full or partial Word context update and re-render dependent UI.
|
|
144
|
+
// Called by the postMessage bridge and usable directly in tests.
|
|
145
|
+
function applyWordContext(ctx, partial) {
|
|
146
|
+
if (partial) {
|
|
147
|
+
if (!state.wordContext) state.wordContext = {};
|
|
148
|
+
Object.assign(state.wordContext, ctx || {});
|
|
149
|
+
} else {
|
|
150
|
+
state.wordContext = ctx || null;
|
|
151
|
+
}
|
|
152
|
+
renderWordContextBar();
|
|
153
|
+
renderWordContextInModal();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setupWordBridge() {
|
|
157
|
+
if (window.parent === window) return; // not inside an iframe — no bridge needed
|
|
158
|
+
window.addEventListener('message', function(event) {
|
|
159
|
+
if (event.source !== window.parent) return;
|
|
160
|
+
const msg = event.data || {};
|
|
161
|
+
if (msg.type === 'word-context') {
|
|
162
|
+
applyWordContext(msg.payload, false);
|
|
163
|
+
} else if (msg.type === 'word-context-update') {
|
|
164
|
+
applyWordContext(msg.payload, true);
|
|
165
|
+
}
|
|
166
|
+
// word-response messages are handled by per-request listeners in requestWordContext()
|
|
167
|
+
});
|
|
168
|
+
// Signal taskpane.html that Hub is ready to receive context.
|
|
169
|
+
window.parent.postMessage({ type: 'hub-ready' }, '*');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Send a word-request to taskpane.html and await the word-response.
|
|
173
|
+
// Resolves null if not in iframe, no response, or timeout (3 s).
|
|
174
|
+
function requestWordContext(action, payload) {
|
|
175
|
+
return new Promise(function(resolve) {
|
|
176
|
+
if (window.parent === window) { resolve(null); return; }
|
|
177
|
+
const requestId = Math.random().toString(36).slice(2);
|
|
178
|
+
const timer = setTimeout(function() { cleanup(); resolve(null); }, 3000);
|
|
179
|
+
function onMsg(event) {
|
|
180
|
+
if (event.source !== window.parent) return;
|
|
181
|
+
const m = event.data || {};
|
|
182
|
+
if (m.type === 'word-response' && m.requestId === requestId) {
|
|
183
|
+
cleanup(); resolve(m.payload || null);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function cleanup() { clearTimeout(timer); window.removeEventListener('message', onMsg); }
|
|
187
|
+
window.addEventListener('message', onMsg);
|
|
188
|
+
window.parent.postMessage({ type: 'word-request', requestId, action, payload: payload || {} }, '*');
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildWordContextBlock(wc) {
|
|
193
|
+
if (!wc) return '';
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (wc.docTitle) parts.push(`Document: ${wc.docTitle}`);
|
|
196
|
+
if (wc.selection) {
|
|
197
|
+
const wordCount = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
198
|
+
parts.push(`Selected text (${wordCount} word${wordCount !== 1 ? 's' : ''}):\n"""\n${wc.selection.slice(0, 500)}\n"""`);
|
|
199
|
+
} else if (wc.bodyPreview) {
|
|
200
|
+
parts.push(`Document preview:\n"""\n${wc.bodyPreview.slice(0, 800)}\n"""`);
|
|
201
|
+
}
|
|
202
|
+
if (wc.wordCount) parts.push(`Document length: ~${wc.wordCount} words`);
|
|
203
|
+
if (wc.comments && wc.comments.length > 0) {
|
|
204
|
+
const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
|
|
205
|
+
parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
|
|
206
|
+
}
|
|
207
|
+
if (parts.length === 0) return '';
|
|
208
|
+
return `[Word Document Context]\n${parts.join('\n\n')}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderWordContextBar() {
|
|
212
|
+
const bar = els['word-context-bar'];
|
|
213
|
+
const txt = els['word-ctx-text'];
|
|
214
|
+
if (!bar || !txt) return;
|
|
215
|
+
const wc = state.wordContext;
|
|
216
|
+
if (!wc || (!wc.docTitle && !wc.selection && !wc.bodyPreview)) {
|
|
217
|
+
bar.hidden = true;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
bar.hidden = false;
|
|
221
|
+
if (wc.selection) {
|
|
222
|
+
const words = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
223
|
+
txt.textContent = `${words} word${words !== 1 ? 's' : ''} selected — "${wc.selection.slice(0, 60)}${wc.selection.length > 60 ? '…' : ''}"`;
|
|
224
|
+
} else if (wc.bodyPreview) {
|
|
225
|
+
const words = wc.wordCount || 0;
|
|
226
|
+
txt.textContent = `${wc.docTitle || 'Document'} · ${words} words — full doc context active`;
|
|
227
|
+
} else {
|
|
228
|
+
txt.textContent = wc.docTitle || 'Document context active';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderWordContextInModal() {
|
|
233
|
+
const card = els['word-context-card'];
|
|
234
|
+
const label = els['word-ctx-card-label'];
|
|
235
|
+
const body = els['word-ctx-card-body'];
|
|
236
|
+
if (!card || !label || !body) return;
|
|
237
|
+
const wc = state.wordContext;
|
|
238
|
+
if (!wc || (!wc.selection && !wc.bodyPreview && !wc.comments?.length)) {
|
|
239
|
+
card.hidden = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
card.hidden = false;
|
|
243
|
+
// Label line: "3 words selected · MyDoc" or "MyDoc · 1,234 words"
|
|
244
|
+
if (wc.selection) {
|
|
245
|
+
const words = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
246
|
+
label.textContent = `${words} word${words !== 1 ? 's' : ''} selected${wc.docTitle ? ' · ' + wc.docTitle : ''}`;
|
|
247
|
+
} else {
|
|
248
|
+
label.textContent = (wc.docTitle || 'Document') + (wc.wordCount ? ` · ${wc.wordCount} words` : '');
|
|
249
|
+
}
|
|
250
|
+
// Body: shows block that will be sent to the agent
|
|
251
|
+
body.textContent = buildWordContextBlock(wc);
|
|
252
|
+
}
|
|
253
|
+
|
|
104
254
|
// ---------------------------------------------------------------------------
|
|
105
255
|
// Concept popover population (drives the welcome-line "see ... jobs" lists)
|
|
106
256
|
// ---------------------------------------------------------------------------
|
|
@@ -714,6 +864,9 @@ function renderActive() {
|
|
|
714
864
|
renderedMessageCount = 0;
|
|
715
865
|
renderedEventCount = 0;
|
|
716
866
|
renderedDirectEventCount = 0;
|
|
867
|
+
// A pending coaching job is scoped to the active conversation — discard it
|
|
868
|
+
// whenever the user switches to a different conversation.
|
|
869
|
+
clearPendingCoachingJob();
|
|
717
870
|
}
|
|
718
871
|
const statusChanged = renderedStatus !== conv.status;
|
|
719
872
|
syncConversationPanels(conv, switchedConv);
|
|
@@ -1238,26 +1391,21 @@ function closeTemplatePopover() {
|
|
|
1238
1391
|
}
|
|
1239
1392
|
|
|
1240
1393
|
function applyTemplateInvocation(managerJobId) {
|
|
1241
|
-
|
|
1242
|
-
//
|
|
1243
|
-
// the
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
const
|
|
1394
|
+
// The invocation prefix ($fraim / /fraim) is an implementation detail that
|
|
1395
|
+
// the server adds based on the active employee — the user should never see it.
|
|
1396
|
+
// Store the job ID as a pending coaching job. The #coach-note shows a
|
|
1397
|
+
// human-readable label; the textarea stays focused for optional extra context.
|
|
1398
|
+
// When the user clicks Send, continueRun sends { coachingJobId, instructions }.
|
|
1399
|
+
const label = managerJobId.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
1400
|
+
// Strip any prior /fraim invocation text already in the textarea (backward-compat
|
|
1401
|
+
// for sessions that stored raw invocation text before this change).
|
|
1247
1402
|
const textarea = els['coach-text'];
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
} else {
|
|
1253
|
-
const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
|
|
1254
|
-
combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
|
|
1255
|
-
}
|
|
1256
|
-
textarea.value = combined;
|
|
1257
|
-
// Caret at the end.
|
|
1258
|
-
textarea.setSelectionRange(combined.length, combined.length);
|
|
1403
|
+
textarea.value = textarea.value
|
|
1404
|
+
.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
|
|
1405
|
+
.replace(/\s+/g, ' ')
|
|
1406
|
+
.trim();
|
|
1259
1407
|
textarea.focus();
|
|
1260
|
-
|
|
1408
|
+
setPendingCoachingJob(managerJobId, label);
|
|
1261
1409
|
closeTemplatePopover();
|
|
1262
1410
|
}
|
|
1263
1411
|
|
|
@@ -1326,20 +1474,6 @@ function surfaceText(role, text, conv) {
|
|
|
1326
1474
|
return raw;
|
|
1327
1475
|
}
|
|
1328
1476
|
|
|
1329
|
-
function extractExplicitFraimInvocation(text) {
|
|
1330
|
-
const raw = String(text || '');
|
|
1331
|
-
const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
|
|
1332
|
-
if (!match || match.index == null) return null;
|
|
1333
|
-
const before = raw.slice(0, match.index).trim();
|
|
1334
|
-
const after = raw.slice(match.index + match[0].length).trim();
|
|
1335
|
-
const remainder = [before, after].filter(Boolean).join('\n\n').trim();
|
|
1336
|
-
return {
|
|
1337
|
-
symbol: match[1],
|
|
1338
|
-
jobId: match[2],
|
|
1339
|
-
remainder,
|
|
1340
|
-
};
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
1477
|
function latestEmployeeSurfaceText(conv) {
|
|
1344
1478
|
const messages = conv.messages || [];
|
|
1345
1479
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -1449,7 +1583,39 @@ function syncSendButton() {
|
|
|
1449
1583
|
// coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
|
|
1450
1584
|
// is for; the server only requires sessionId).
|
|
1451
1585
|
const resumable = !!(conv && conv.sessionId);
|
|
1452
|
-
|
|
1586
|
+
// A pending coaching job (set by quick-coach buttons or template picker)
|
|
1587
|
+
// is enough to enable Send even with an empty textarea — the user just
|
|
1588
|
+
// wants to fire that coaching directive, optionally with added context.
|
|
1589
|
+
const hasPendingJob = !!state.pendingCoachingJobId;
|
|
1590
|
+
els['send'].disabled = !((hasText || hasPendingJob) && resumable);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// ---------------------------------------------------------------------------
|
|
1594
|
+
// Pending coaching job helpers
|
|
1595
|
+
// ---------------------------------------------------------------------------
|
|
1596
|
+
|
|
1597
|
+
// Set a pending coaching job selected via a quick-coach button or template
|
|
1598
|
+
// picker. The invocation is NOT put into the textarea — the server adds it
|
|
1599
|
+
// based on the active employee. The coach-note shows a human-readable label
|
|
1600
|
+
// so the user knows what will fire when they hit Send.
|
|
1601
|
+
function setPendingCoachingJob(jobId, label) {
|
|
1602
|
+
state.pendingCoachingJobId = jobId;
|
|
1603
|
+
state.pendingCoachingLabel = label;
|
|
1604
|
+
if (els['coach-note']) {
|
|
1605
|
+
els['coach-note'].textContent = `▶ ${label} — add context below (optional) then send`;
|
|
1606
|
+
els['coach-note'].classList.add('pending-job');
|
|
1607
|
+
}
|
|
1608
|
+
syncSendButton();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function clearPendingCoachingJob() {
|
|
1612
|
+
state.pendingCoachingJobId = null;
|
|
1613
|
+
state.pendingCoachingLabel = null;
|
|
1614
|
+
if (els['coach-note']) {
|
|
1615
|
+
els['coach-note'].classList.remove('pending-job');
|
|
1616
|
+
// Note text is restored to normal status by the next renderActive call.
|
|
1617
|
+
}
|
|
1618
|
+
syncSendButton();
|
|
1453
1619
|
}
|
|
1454
1620
|
|
|
1455
1621
|
// ---------------------------------------------------------------------------
|
|
@@ -1897,50 +2063,22 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
1897
2063
|
// (per developers.openai.com/codex/skills: "type $ to mention a skill")
|
|
1898
2064
|
// - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
|
|
1899
2065
|
//
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
// lose which FRAIM workflow the manager meant to run.
|
|
1904
|
-
const FRAIM_INVOCATION_SYMBOL = {
|
|
1905
|
-
codex: '$fraim',
|
|
1906
|
-
claude: '/fraim',
|
|
1907
|
-
gemini: '/fraim',
|
|
1908
|
-
};
|
|
1909
|
-
|
|
1910
|
-
function fraimInvocationFor(employeeId, jobId, kind) {
|
|
1911
|
-
if (jobId === '__freeform__') return null;
|
|
1912
|
-
const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
|
|
1913
|
-
return `${symbol} ${jobId}`;
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
// Wrap the manager's typed instructions with the host-appropriate FRAIM
|
|
1917
|
-
// invocation. The wrapped text is what we ACTUALLY send to the host CLI
|
|
1918
|
-
// AND what we show in the timeline so the manager sees what the agent
|
|
1919
|
-
// received. For freeform jobs (no FRAIM job assigned), the instructions
|
|
1920
|
-
// are sent verbatim with no invocation prefix.
|
|
1921
|
-
function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
|
|
1922
|
-
const trimmed = (instructions || '').trim();
|
|
1923
|
-
const explicit = extractExplicitFraimInvocation(trimmed);
|
|
1924
|
-
const effectiveJobId = explicit?.jobId || jobId;
|
|
1925
|
-
const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
|
|
1926
|
-
// Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
|
|
1927
|
-
if (!invocation) return explicit?.remainder || trimmed;
|
|
1928
|
-
const remainder = explicit ? explicit.remainder : trimmed;
|
|
1929
|
-
const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
|
|
1930
|
-
if (!remainder) return `${invocation}${stub}`;
|
|
1931
|
-
return `${invocation}${stub}\n\n${remainder}`;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
2066
|
+
// The browser sends raw manager instructions. AI Hub normalizes assigned
|
|
2067
|
+
// FRAIM jobs into host-facing invocations so the Hub UI and channel callers
|
|
2068
|
+
// share one start/continue contract.
|
|
1934
2069
|
async function startRun(job, instructions, employeeId) {
|
|
1935
|
-
// Prefix the manager's typed instructions with the FRAIM invocation so
|
|
1936
|
-
// the underlying host actually launches the right job. The prefixed
|
|
1937
|
-
// text is what the host receives AND what we show in the timeline.
|
|
1938
|
-
// Freeform jobs carry no job stub and no FRAIM invocation prefix.
|
|
1939
2070
|
const isFreeform = job.id === '__freeform__';
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2071
|
+
// In task-pane/extension mode: get a fresh selection snapshot and prepend
|
|
2072
|
+
// document context to the instructions so the agent knows what the user is
|
|
2073
|
+
// looking at. stubPath resolution and FRAIM invocation are now server-side.
|
|
2074
|
+
let effectiveInstructions = instructions || '';
|
|
2075
|
+
if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
|
|
2076
|
+
const fresh = await requestWordContext('get-selection').catch(() => null);
|
|
2077
|
+
const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
|
|
2078
|
+
const ctxBlock = buildWordContextBlock(wc);
|
|
2079
|
+
if (ctxBlock) effectiveInstructions = ctxBlock + '\n\n---\n\n' + effectiveInstructions;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1944
2082
|
// Issue #442: read the A/B toggle state from the modal before it closes.
|
|
1945
2083
|
const abToggle = document.getElementById('ab-toggle');
|
|
1946
2084
|
const isAB = !isFreeform && abToggle && abToggle.checked;
|
|
@@ -1957,7 +2095,7 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1957
2095
|
runId: null,
|
|
1958
2096
|
sessionId: null,
|
|
1959
2097
|
status: 'running',
|
|
1960
|
-
messages: [
|
|
2098
|
+
messages: [],
|
|
1961
2099
|
events: [],
|
|
1962
2100
|
artifacts: [],
|
|
1963
2101
|
lastUpdatedAt: Date.now(),
|
|
@@ -1965,6 +2103,8 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1965
2103
|
compareMode: isAB ? 'ab' : undefined,
|
|
1966
2104
|
compareRunId: null,
|
|
1967
2105
|
compareRun: null,
|
|
2106
|
+
// Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
|
|
2107
|
+
wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
|
|
1968
2108
|
};
|
|
1969
2109
|
upsertConversation(conv);
|
|
1970
2110
|
state.activeId = conv.id;
|
|
@@ -1980,8 +2120,8 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1980
2120
|
projectPath: state.projectPath,
|
|
1981
2121
|
hostId: employeeId,
|
|
1982
2122
|
jobId: job.id,
|
|
1983
|
-
|
|
1984
|
-
...(isAB ? { compareMode: 'ab', directInstructions:
|
|
2123
|
+
instructions: effectiveInstructions,
|
|
2124
|
+
...(isAB ? { compareMode: 'ab', directInstructions: effectiveInstructions } : {}),
|
|
1985
2125
|
}),
|
|
1986
2126
|
});
|
|
1987
2127
|
conv.runId = run.id;
|
|
@@ -2008,10 +2148,14 @@ async function startRun(job, instructions, employeeId) {
|
|
|
2008
2148
|
// Start a NEW run on an existing conversation with a different agent.
|
|
2009
2149
|
// The conversation's message history stays intact; only the run restarts.
|
|
2010
2150
|
async function restartConvWithAgent(conv, newAgentId, text) {
|
|
2011
|
-
|
|
2151
|
+
// Snapshot both the message history and the event log so they survive the
|
|
2152
|
+
// restart. foldRunIntoConversation replaces these arrays on every call
|
|
2153
|
+
// (including poll ticks), so we must store them on the conv object itself —
|
|
2154
|
+
// not as local variables that only live until the first poll fires.
|
|
2155
|
+
conv._priorMessages = Array.isArray(conv.messages) ? [...conv.messages] : [];
|
|
2156
|
+
conv._priorEvents = Array.isArray(conv.events) ? [...conv.events] : [];
|
|
2012
2157
|
conv.status = 'running';
|
|
2013
2158
|
conv.sessionId = null;
|
|
2014
|
-
conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
|
|
2015
2159
|
upsertConversation(conv);
|
|
2016
2160
|
renderRail();
|
|
2017
2161
|
renderActive();
|
|
@@ -2023,11 +2167,13 @@ async function restartConvWithAgent(conv, newAgentId, text) {
|
|
|
2023
2167
|
projectPath: state.projectPath,
|
|
2024
2168
|
hostId: newAgentId,
|
|
2025
2169
|
jobId: conv.jobId,
|
|
2026
|
-
|
|
2170
|
+
instructions: text,
|
|
2027
2171
|
}),
|
|
2028
2172
|
});
|
|
2029
2173
|
conv.runId = run.id;
|
|
2030
2174
|
foldRunIntoConversation(conv, run);
|
|
2175
|
+
// Note: foldRunIntoConversation now prepends _priorMessages/_priorEvents
|
|
2176
|
+
// on every call, so the history is preserved through poll ticks too.
|
|
2031
2177
|
upsertConversation(conv);
|
|
2032
2178
|
renderRail();
|
|
2033
2179
|
renderActive();
|
|
@@ -2045,11 +2191,12 @@ async function restartConvWithAgent(conv, newAgentId, text) {
|
|
|
2045
2191
|
async function continueRun(text) {
|
|
2046
2192
|
const conv = activeConversation();
|
|
2047
2193
|
if (!conv || !conv.runId) return;
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
|
|
2194
|
+
// Consume any pending coaching job selected via quick-coach or template picker.
|
|
2195
|
+
// The server adds the correct invocation prefix ($fraim / /fraim) based on
|
|
2196
|
+
// the active employee — the user never sees raw invocation syntax.
|
|
2197
|
+
const coachingJobId = state.pendingCoachingJobId || undefined;
|
|
2198
|
+
clearPendingCoachingJob();
|
|
2051
2199
|
conv.status = 'running';
|
|
2052
|
-
conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
|
|
2053
2200
|
upsertConversation(conv);
|
|
2054
2201
|
renderRail();
|
|
2055
2202
|
renderActive();
|
|
@@ -2057,7 +2204,7 @@ async function continueRun(text) {
|
|
|
2057
2204
|
const run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
|
|
2058
2205
|
method: 'POST',
|
|
2059
2206
|
headers: { 'Content-Type': 'application/json' },
|
|
2060
|
-
body: JSON.stringify({
|
|
2207
|
+
body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
|
|
2061
2208
|
});
|
|
2062
2209
|
foldRunIntoConversation(conv, run);
|
|
2063
2210
|
upsertConversation(conv);
|
|
@@ -2086,15 +2233,19 @@ function foldRunIntoConversation(conv, run) {
|
|
|
2086
2233
|
totals: run.totals || null,
|
|
2087
2234
|
};
|
|
2088
2235
|
// Replace the conversation's events with the run's events (single source of truth on the server).
|
|
2089
|
-
|
|
2090
|
-
//
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2236
|
+
// If this conversation was restarted on a different agent, prepend the prior run's events so
|
|
2237
|
+
// the micro-log shows the full diagnostic history across all run attempts (Issue #495).
|
|
2238
|
+
const newEvents = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
|
|
2239
|
+
const priorEvents = Array.isArray(conv._priorEvents) ? conv._priorEvents : [];
|
|
2240
|
+
conv.events = priorEvents.length > 0 ? [...priorEvents, ...newEvents] : newEvents;
|
|
2241
|
+
// Same treatment for messages: prepend any cross-run history captured at restart time.
|
|
2242
|
+
const newMessages = (run.messages || []).map((m) => ({
|
|
2243
|
+
role: m.role,
|
|
2244
|
+
text: m.text,
|
|
2245
|
+
at: Date.parse(m.createdAt) || Date.now(),
|
|
2246
|
+
}));
|
|
2247
|
+
const priorMessages = Array.isArray(conv._priorMessages) ? conv._priorMessages : [];
|
|
2248
|
+
conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
|
|
2098
2249
|
// Track session for resumption.
|
|
2099
2250
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
2100
2251
|
// R4: persist the persona key from the server-side run record.
|
|
@@ -2179,6 +2330,7 @@ function startPolling() {
|
|
|
2179
2330
|
if (nowFraimDone && nowDirectDone) {
|
|
2180
2331
|
window.clearInterval(state.pollHandle);
|
|
2181
2332
|
state.pollHandle = null;
|
|
2333
|
+
tryWordWriteBack(conv);
|
|
2182
2334
|
}
|
|
2183
2335
|
} catch (error) {
|
|
2184
2336
|
console.warn('Polling failed:', error);
|
|
@@ -2186,6 +2338,35 @@ function startPolling() {
|
|
|
2186
2338
|
}, 1000);
|
|
2187
2339
|
}
|
|
2188
2340
|
|
|
2341
|
+
// Issue #489: When a job completes in Word task-pane surface, write the final
|
|
2342
|
+
// agent output back into the document with tracked changes so the user can
|
|
2343
|
+
// accept/reject via Word's normal review flow.
|
|
2344
|
+
async function tryWordWriteBack(conv) {
|
|
2345
|
+
if (conv.wordWrittenBack) return;
|
|
2346
|
+
if (document.body.dataset.surface !== 'task-pane') return;
|
|
2347
|
+
if (!state.wordContext || !state.wordContext.docTitle) return;
|
|
2348
|
+
if (conv.status !== 'completed') return;
|
|
2349
|
+
|
|
2350
|
+
const employeeMessages = (conv.messages || []).filter(m => m.role === 'employee');
|
|
2351
|
+
if (employeeMessages.length === 0) return;
|
|
2352
|
+
const outputText = employeeMessages[employeeMessages.length - 1].text;
|
|
2353
|
+
if (!outputText || !outputText.trim()) return;
|
|
2354
|
+
|
|
2355
|
+
// Mark before async calls to guard against concurrent poll ticks.
|
|
2356
|
+
conv.wordWrittenBack = true;
|
|
2357
|
+
upsertConversation(conv);
|
|
2358
|
+
|
|
2359
|
+
try {
|
|
2360
|
+
await requestWordContext('track-changes-on');
|
|
2361
|
+
const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
|
|
2362
|
+
await requestWordContext(action, { text: outputText });
|
|
2363
|
+
await requestWordContext('track-changes-off');
|
|
2364
|
+
showStatus('Result written to document — review tracked changes in Word.');
|
|
2365
|
+
} catch (e) {
|
|
2366
|
+
console.warn('Word write-back failed:', e);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2189
2370
|
function convNeedsPolling(conv) {
|
|
2190
2371
|
if (!conv || !conv.runId) return false;
|
|
2191
2372
|
const fraimRunning = conv.status === 'running';
|
|
@@ -2218,11 +2399,9 @@ function showStatus(text, isError) {
|
|
|
2218
2399
|
|
|
2219
2400
|
async function pickProject() {
|
|
2220
2401
|
try {
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
if (!response.ok) throw new Error(payload.error || 'Could not choose a folder.');
|
|
2225
|
-
if (payload.path) {
|
|
2402
|
+
// 204 = user cancelled the OS folder picker; requestJson returns null for empty bodies.
|
|
2403
|
+
const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
|
|
2404
|
+
if (payload && payload.path) {
|
|
2226
2405
|
await loadBootstrap(payload.path);
|
|
2227
2406
|
}
|
|
2228
2407
|
} catch (error) {
|
|
@@ -2252,6 +2431,7 @@ function showStep2(job) {
|
|
|
2252
2431
|
els['start'].disabled = true;
|
|
2253
2432
|
els['step1'].hidden = true;
|
|
2254
2433
|
els['step2'].hidden = false;
|
|
2434
|
+
renderWordContextInModal();
|
|
2255
2435
|
setTimeout(() => els['instructions'].focus(), 50);
|
|
2256
2436
|
}
|
|
2257
2437
|
|
|
@@ -2307,7 +2487,9 @@ function wireEvents() {
|
|
|
2307
2487
|
els['coach-text'].addEventListener('input', syncSendButton);
|
|
2308
2488
|
els['send'].addEventListener('click', async () => {
|
|
2309
2489
|
const text = els['coach-text'].value.trim();
|
|
2310
|
-
|
|
2490
|
+
// Allow send when textarea is empty IFF a coaching job is pending — the
|
|
2491
|
+
// user clicked a quick-coach button and just wants to fire that job.
|
|
2492
|
+
if (!text && !state.pendingCoachingJobId) return;
|
|
2311
2493
|
els['coach-text'].value = '';
|
|
2312
2494
|
syncSendButton();
|
|
2313
2495
|
// If the user changed the agent via the inline selector, restart with the
|
|
@@ -2402,22 +2584,19 @@ function wireEvents() {
|
|
|
2402
2584
|
}
|
|
2403
2585
|
|
|
2404
2586
|
// R5: quick-access coaching buttons.
|
|
2587
|
+
// The invocation syntax ($fraim / /fraim) stays out of the UI entirely —
|
|
2588
|
+
// the server adds it based on the active employee. Clicking a button sets a
|
|
2589
|
+
// pending coaching job (shown in #coach-note); the user can add optional
|
|
2590
|
+
// context in the textarea and then click Send.
|
|
2405
2591
|
if (els['quick-coach-btns']) {
|
|
2406
2592
|
els['quick-coach-btns'].addEventListener('click', (e) => {
|
|
2407
2593
|
const btn = e.target.closest('.quick-coach-btn');
|
|
2408
2594
|
if (!btn) return;
|
|
2409
2595
|
const jobId = btn.dataset.job;
|
|
2410
2596
|
if (!jobId) return;
|
|
2411
|
-
const
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
const textarea = els['coach-text'];
|
|
2415
|
-
const existing = textarea.value;
|
|
2416
|
-
const sep = existing && !existing.endsWith(' ') ? ' ' : '';
|
|
2417
|
-
textarea.value = existing + sep + invocation;
|
|
2418
|
-
textarea.focus();
|
|
2419
|
-
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
2420
|
-
syncSendButton();
|
|
2597
|
+
const label = btn.textContent.trim();
|
|
2598
|
+
setPendingCoachingJob(jobId, label);
|
|
2599
|
+
els['coach-text'].focus();
|
|
2421
2600
|
});
|
|
2422
2601
|
}
|
|
2423
2602
|
if (els['other-manager-jobs-btn']) {
|
|
@@ -2459,6 +2638,24 @@ function wireEvents() {
|
|
|
2459
2638
|
}
|
|
2460
2639
|
bindEnterToButton(els['coach-text'], els['send']);
|
|
2461
2640
|
bindEnterToButton(els['instructions'], els['start']);
|
|
2641
|
+
|
|
2642
|
+
// Word context bar refresh button.
|
|
2643
|
+
if (els['word-ctx-refresh']) {
|
|
2644
|
+
els['word-ctx-refresh'].addEventListener('click', async () => {
|
|
2645
|
+
const fresh = await requestWordContext('get-context');
|
|
2646
|
+
if (fresh) { state.wordContext = fresh; renderWordContextBar(); renderWordContextInModal(); }
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
// Word context card expand/collapse toggle.
|
|
2650
|
+
if (els['word-ctx-card-toggle']) {
|
|
2651
|
+
els['word-ctx-card-toggle'].addEventListener('click', () => {
|
|
2652
|
+
const body = els['word-ctx-card-body'];
|
|
2653
|
+
if (!body) return;
|
|
2654
|
+
const expanded = !body.hidden;
|
|
2655
|
+
body.hidden = expanded;
|
|
2656
|
+
els['word-ctx-card-toggle'].textContent = expanded ? '▸' : '▾';
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2462
2659
|
}
|
|
2463
2660
|
|
|
2464
2661
|
// ---------------------------------------------------------------------------
|
|
@@ -2466,6 +2663,9 @@ function wireEvents() {
|
|
|
2466
2663
|
// ---------------------------------------------------------------------------
|
|
2467
2664
|
|
|
2468
2665
|
(async function init() {
|
|
2666
|
+
// html.electron is already set by the inline script in <head> for zero-flash.
|
|
2667
|
+
// This is a belt-and-suspenders fallback in case the inline script was skipped.
|
|
2668
|
+
|
|
2469
2669
|
gatherElements();
|
|
2470
2670
|
loadConversationsFromStorage();
|
|
2471
2671
|
wirePopovers();
|
|
@@ -2473,14 +2673,34 @@ function wireEvents() {
|
|
|
2473
2673
|
|
|
2474
2674
|
const urlParams = new URLSearchParams(window.location.search);
|
|
2475
2675
|
const isFirstRun = urlParams.get('firstRun') === 'true';
|
|
2676
|
+
const surface = urlParams.get('surface') || 'hub';
|
|
2677
|
+
const docUrl = urlParams.get('docUrl') || '';
|
|
2678
|
+
|
|
2679
|
+
// Apply surface class before bootstrap so CSS takes effect immediately.
|
|
2680
|
+
if (surface !== 'hub') document.body.dataset.surface = surface;
|
|
2681
|
+
|
|
2682
|
+
// Register the Word context bridge (no-op outside iframe context).
|
|
2683
|
+
setupWordBridge();
|
|
2476
2684
|
|
|
2477
2685
|
try {
|
|
2478
|
-
await loadBootstrap();
|
|
2686
|
+
await loadBootstrap(null, docUrl);
|
|
2479
2687
|
} catch (error) {
|
|
2480
2688
|
showStatus(error.message, true);
|
|
2481
2689
|
return;
|
|
2482
2690
|
}
|
|
2483
2691
|
|
|
2692
|
+
// Task-pane and extension surfaces: skip welcome/onboarding, show active job
|
|
2693
|
+
// or open the new-job picker. Layout is compact (rail hidden via CSS).
|
|
2694
|
+
if (surface === 'task-pane' || surface === 'extension') {
|
|
2695
|
+
const conv = activeConversation();
|
|
2696
|
+
if (conv && conv.projectPath === state.projectPath) {
|
|
2697
|
+
if (convNeedsPolling(conv)) startPolling();
|
|
2698
|
+
} else {
|
|
2699
|
+
openModal();
|
|
2700
|
+
}
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2484
2704
|
if (isFirstRun) {
|
|
2485
2705
|
renderFirstRunLanding();
|
|
2486
2706
|
return;
|
|
@@ -2621,11 +2841,8 @@ function renderFirstRunLanding(mode) {
|
|
|
2621
2841
|
].join('');
|
|
2622
2842
|
browseBtn.addEventListener('click', async () => {
|
|
2623
2843
|
try {
|
|
2624
|
-
const
|
|
2625
|
-
if (
|
|
2626
|
-
const payload = await response.json();
|
|
2627
|
-
if (!response.ok) throw new Error(payload.error || 'Could not pick folder.');
|
|
2628
|
-
if (payload.path) {
|
|
2844
|
+
const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
|
|
2845
|
+
if (payload && payload.path) {
|
|
2629
2846
|
await loadBootstrap(payload.path);
|
|
2630
2847
|
folderInput.value = state.projectPath;
|
|
2631
2848
|
folderInput.style.color = 'var(--text,#1f2a24)';
|