fraim-hub 2.0.274 → 2.0.275

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.
@@ -379,6 +379,27 @@ function readJobFrontmatter(filePath) {
379
379
  return null;
380
380
  }
381
381
  }
382
+ /**
383
+ * Issue #1246: resolve the stub whose phase graph actually governs this job.
384
+ * A personalized override that is `extends`-only (no `phases`/`initialPhase`
385
+ * of its own) wins the layer-precedence override chain in findJobStubPath,
386
+ * but declares no phase graph AND has no `## Steps` section for
387
+ * loadJobPhasesFromSteps to fall back to either — every phase-derivation
388
+ * caller silently degraded to zero phases. Follow `extends` to the baseline
389
+ * stub when the resolved stub itself declares no phase graph.
390
+ */
391
+ function resolveDeclaredPhaseStub(stubPath, projectPath) {
392
+ const fm = readJobFrontmatter(stubPath);
393
+ if (fm?.initialPhase && fm.phases)
394
+ return { fm, path: stubPath };
395
+ const extendsValue = typeof fm?.extends === 'string' ? fm.extends.trim() : '';
396
+ if (!extendsValue)
397
+ return { fm, path: stubPath };
398
+ const basePath = resolveExtendedStubPath(projectPath, extendsValue);
399
+ if (!basePath)
400
+ return { fm, path: stubPath };
401
+ return { fm: readJobFrontmatter(basePath), path: basePath };
402
+ }
382
403
  function findJobStubPath(projectPath, jobId) {
383
404
  // Walk both employee and manager layer sets. The analytics surfaces can
384
405
  // inspect any FRAIM job run, including manager-side jobs such as
@@ -432,9 +453,9 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
432
453
  const stubPath = findJobStubPath(projectPath, jobId);
433
454
  if (!stubPath)
434
455
  return [];
435
- const fm = readJobFrontmatter(stubPath);
456
+ const { fm, path: declaredPath } = resolveDeclaredPhaseStub(stubPath, projectPath);
436
457
  if (!fm || !fm.initialPhase || !fm.phases)
437
- return loadJobPhasesFromSteps(stubPath);
458
+ return loadJobPhasesFromSteps(declaredPath);
438
459
  const visited = new Set();
439
460
  const ordered = [];
440
461
  let cursor = fm.initialPhase;
@@ -461,7 +482,7 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
461
482
  const stubPath = findJobStubPath(projectPath, jobId);
462
483
  if (!stubPath)
463
484
  return null;
464
- const fm = readJobFrontmatter(stubPath);
485
+ const { fm } = resolveDeclaredPhaseStub(stubPath, projectPath);
465
486
  if (!fm || !fm.phases)
466
487
  return null;
467
488
  const phaseDef = fm.phases[phaseId];
@@ -474,9 +495,9 @@ function loadAllJobPhaseIds(jobId, projectPath) {
474
495
  const stubPath = findJobStubPath(projectPath, jobId);
475
496
  if (!stubPath)
476
497
  return new Set();
477
- const fm = readJobFrontmatter(stubPath);
498
+ const { fm, path: declaredPath } = resolveDeclaredPhaseStub(stubPath, projectPath);
478
499
  if (!fm || !fm.phases) {
479
- const phases = loadJobPhasesFromSteps(stubPath);
500
+ const phases = loadJobPhasesFromSteps(declaredPath);
480
501
  return new Set(phases.map((p) => p.id));
481
502
  }
482
503
  return new Set(Object.keys(fm.phases));
@@ -107,7 +107,7 @@ function configureAutoUpdater() {
107
107
  function ensureWordSideload(projectPath, httpsPort) {
108
108
  // Flag version bump: bump this string when new manifests are added so all
109
109
  // users get re-sideloaded on their next launch.
110
- const FLAG_VERSION = 'v5-https-port';
110
+ const FLAG_VERSION = 'v6-excel-addin';
111
111
  const expectedFlag = `${FLAG_VERSION}:${httpsPort}`;
112
112
  const flagPath = path_1.default.join(electron_1.app.getPath('userData'), 'word-sideloaded.flag');
113
113
  const existingFlag = fs_1.default.existsSync(flagPath) ? fs_1.default.readFileSync(flagPath, 'utf8') : null;
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
- * Sideloads Office add-in manifests (Word + PowerPoint) so they appear under
4
- * Insert > My Add-ins > Developer Add-ins without admin rights or AppSource.
3
+ * Sideloads Office add-in manifests (Word + PowerPoint + Excel) so they appear
4
+ * under Insert > My Add-ins > Developer Add-ins without admin rights or AppSource.
5
5
  *
6
6
  * Windows: writes HKCU\SOFTWARE\Microsoft\Office\16.0\WEF\Developer\{<guid>}
7
7
  * (Default) = ABSOLUTE FILE PATH to manifest.xml. This is the registry
@@ -52,6 +52,15 @@ const MANIFESTS = [
52
52
  ],
53
53
  macContainer: 'com.microsoft.Powerpoint',
54
54
  },
55
+ {
56
+ guid: 'da8d22c7-2c00-485a-bdc9-13dda7f80c48',
57
+ candidates: (base) => [
58
+ path_1.default.resolve(base, 'public/ai-hub/excel-taskpane/manifest.xml'),
59
+ path_1.default.resolve(__dirname, '..', '..', 'public/ai-hub/excel-taskpane/manifest.xml'),
60
+ path_1.default.resolve(__dirname, '..', '..', '..', 'public/ai-hub/excel-taskpane/manifest.xml'),
61
+ ],
62
+ macContainer: 'com.microsoft.Excel',
63
+ },
55
64
  ];
56
65
  function resolveManifestPath(entry, projectPath) {
57
66
  return entry.candidates(projectPath).find(c => fs_1.default.existsSync(c)) ?? null;
@@ -157,7 +166,7 @@ function sideloadManifest(projectPath, options = {}) {
157
166
  for (const entry of MANIFESTS) {
158
167
  const manifestPath = resolveManifestPath(entry, projectPath);
159
168
  if (!manifestPath) {
160
- return { ok: false, reason: `Manifest not found for GUID ${entry.guid} — check extensions/office-word/ and public/ai-hub/powerpoint-taskpane/` };
169
+ return { ok: false, reason: `Manifest not found for GUID ${entry.guid} — check extensions/office-word/, public/ai-hub/powerpoint-taskpane/, and public/ai-hub/excel-taskpane/` };
161
170
  }
162
171
  const sideloadPath = prepareManifestForSideload(entry, manifestPath, options);
163
172
  if (process.platform === 'win32') {
@@ -248,15 +248,18 @@ function getCustomPersonaForJob(projectPath, jobId) {
248
248
  const match = employees.find((e) => Array.isArray(e.jobIds) && e.jobIds.includes(jobId));
249
249
  return match ? match.key : null;
250
250
  }
251
+ // This set exists only for jobs that need hiding from the main picker for a
252
+ // reason beyond ownership. `praise-fraim` is already surfaced in the Hub
253
+ // coaching UI, so a picker entry would be a duplicate. Every other FRAIM-meta
254
+ // job (contribute-to-fraim, file-fraim-issue) or persona-owned job
255
+ // (run-on-remote-hub, setup-remote-hub under sreya) is a real, synced
256
+ // capability and belongs in the main picker like any other job.
257
+ // `evolve-fraim-registry` is a personalized-employee-only job local to the
258
+ // FRAIM source repo (no registry/ counterpart ships to any installation), so
259
+ // it has no entry here at all — this set is shared product code and must not
260
+ // carry a job id that is meaningless to every customer.
251
261
  const FRAIM_INTERNAL_JOB_IDS = new Set([
252
- 'contribute-to-fraim',
253
- 'create-registry-asset',
254
- 'extract-ashley-learnings',
255
- 'file-fraim-issue',
256
262
  'praise-fraim',
257
- 'run-on-remote-hub',
258
- 'setup-remote-hub',
259
- 'update-registry-override',
260
263
  ]);
261
264
  // Jobs that operate on machine-level context (~/.fraim/) rather than a project
262
265
  // directory. They must be launchable from the Hub even when no project has been
@@ -1004,6 +1007,23 @@ function readPersistedRunProjection(conversation) {
1004
1007
  runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
1005
1008
  };
1006
1009
  }
1010
+ // Issue #1246: the disk-serving GET-by-id endpoint returned the persisted
1011
+ // `run.stages` snapshot verbatim. That snapshot is written mid-run (e.g. while
1012
+ // a personalized `extends`-only stub was still resolving to zero phases) and
1013
+ // never revisited, so a completed run's tracker could freeze on one stale
1014
+ // phase forever. Re-derive stages from the conversation's own phase history
1015
+ // at read time instead of trusting the stored value.
1016
+ function deriveStagesForConversation(conversation) {
1017
+ const persisted = readPersistedRunProjection(conversation);
1018
+ return deriveStages({
1019
+ jobId: conversation.jobId,
1020
+ status: conversation.status,
1021
+ currentPhase: persisted?.currentPhase ?? null,
1022
+ phaseHistory: persisted?.phaseHistory ?? [],
1023
+ phaseVisits: persisted?.phaseVisits ?? [],
1024
+ runDiscriminant: persisted?.runDiscriminant ?? undefined,
1025
+ }, conversation.projectPath);
1026
+ }
1007
1027
  function boundedText(value, maxChars = 500) {
1008
1028
  const text = typeof value === 'string' ? value.trim() : '';
1009
1029
  if (text.length <= maxChars)
@@ -1226,6 +1246,7 @@ function appendHostMessage(run, hostId, event, channel) {
1226
1246
  }
1227
1247
  const MAX_PROJECTED_EVENT_CHARS = 300;
1228
1248
  const MAX_PROJECTED_EVENTS_PER_RUN = 200;
1249
+ const MAX_HOST_ERROR_THREAD_CHARS = 160;
1229
1250
  function boundedEventText(text) {
1230
1251
  const trimmed = text.trim();
1231
1252
  if (trimmed.length <= MAX_PROJECTED_EVENT_CHARS)
@@ -1378,12 +1399,6 @@ function phaseVisitsForProjection(run) {
1378
1399
  latestText: entry.latestText,
1379
1400
  }));
1380
1401
  }
1381
- // Build the stage list for a run. Combines the FSM's reachable path with
1382
- // any phases the run has actually visited (in case the run took an
1383
- // onFailure back-edge into a phase the simple onSuccess walk wouldn't
1384
- // surface). Each stage is marked done / current / upcoming based on
1385
- // whether it precedes / matches / follows the current phase along the
1386
- // rendered order.
1387
1402
  function deriveStages(run, projectPath) {
1388
1403
  const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, projectPath, run.runDiscriminant || 'feature');
1389
1404
  if (declaredPath.length === 0)
@@ -1832,18 +1847,29 @@ function buildHubRecoveryContinueMessage(run, exitCode, attempt) {
1832
1847
  function hostErrorSuffix(run) {
1833
1848
  return run.lastHostError ? ` Last host error: ${run.lastHostError}` : '';
1834
1849
  }
1835
- // Issue #1221 (PR #1223 review): the interim `Host error: ...` message
1836
- // (pushed the moment a hostError event arrives, see recordHostEvent) tells
1837
- // the manager reading only the main thread that *something* went wrong, but
1838
- // nothing in the thread ever says the run is now actually done and why —
1839
- // that closing statement previously only reached Micro-manage. A manager
1840
- // reading just the conversation would see error chatter, then silence, with
1841
- // no thread-visible statement tying the two together. Call this once at each
1842
- // terminal-park site, alongside the Micro-manage event, so a failure the
1843
- // manager is already reading about in the thread is also closed out there.
1850
+ // Issue #1240: raw API error messages (rate-limit notices, turn.failed payloads)
1851
+ // can be multi-sentence paragraphs with retry hints, URLs, and quota details.
1852
+ // The conversation thread should show only the first sentence, capped at 160
1853
+ // chars; full technical detail remains in run.events (micro-log).
1854
+ function summarizeHostErrorForThread(error) {
1855
+ const raw = String(error || '').trim();
1856
+ if (!raw)
1857
+ return 'An unexpected error occurred.';
1858
+ const firstLine = raw.split(/[\r\n]/)[0].trim();
1859
+ // Split on ". " followed by an uppercase letter to find the sentence boundary.
1860
+ const sentenceEnd = firstLine.search(/\.\s+[A-Z]/);
1861
+ const firstSentence = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
1862
+ if (firstSentence.length <= MAX_HOST_ERROR_THREAD_CHARS)
1863
+ return firstSentence;
1864
+ return firstSentence.slice(0, MAX_HOST_ERROR_THREAD_CHARS - 3) + '…';
1865
+ }
1866
+ // Only surface a host error in the conversation thread when the run has
1867
+ // actually terminated with a failure. Transient errors that resolve through
1868
+ // auto-recovery must not reach the thread so the manager is not alarmed by
1869
+ // noise that the system handled on its own.
1844
1870
  function pushTerminalFailureToThread(run, exitCode) {
1845
1871
  if (exitCode !== 0 && run.lastHostError) {
1846
- run.messages.push((0, hosts_1.createHubMessage)('system', `This run stopped: ${run.lastHostError}`));
1872
+ run.messages.push((0, hosts_1.createHubMessage)('system', `This run stopped: ${summarizeHostErrorForThread(run.lastHostError)}`));
1847
1873
  }
1848
1874
  }
1849
1875
  function createHubRecoveryEvent(run, exitCode, attempt) {
@@ -2236,7 +2262,7 @@ class AiHubServer {
2236
2262
  });
2237
2263
  });
2238
2264
  }
2239
- this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js'], (_req, res) => {
2265
+ this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js', '/excel-taskpane/config.js'], (_req, res) => {
2240
2266
  const port = this.httpPort || 43091;
2241
2267
  const origin = `http://127.0.0.1:${port}`;
2242
2268
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
@@ -2529,6 +2555,16 @@ class AiHubServer {
2529
2555
  ...job,
2530
2556
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
2531
2557
  }));
2558
+ // Issue #1247: expose internal jobs in a separate bootstrap field so next-job
2559
+ // recommendation chips can resolve and launch them. They are intentionally
2560
+ // excluded from `jobs` (the main picker list) but must be resolvable for chips.
2561
+ // Internal jobs default to the fraimworker persona rather than the unassigned default.
2562
+ const internalJobs = rawJobs
2563
+ .filter((job) => FRAIM_INTERNAL_JOB_IDS.has(job.id))
2564
+ .map((job) => ({
2565
+ ...job,
2566
+ requiredPersonaKey: getProtectedPersonaForHubJob(job.id) ?? GENERIC_WORKER_PERSONA_KEY,
2567
+ }));
2532
2568
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
2533
2569
  // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
2534
2570
  // override, no ai-hub-state.json copy, no fallback chain.
@@ -2579,6 +2615,7 @@ class AiHubServer {
2579
2615
  preferences: { ...preferences, apiKey: resolvedApiKey },
2580
2616
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
2581
2617
  jobs,
2618
+ internalJobs,
2582
2619
  managerTemplates,
2583
2620
  employees,
2584
2621
  configuredAgents,
@@ -2755,14 +2792,15 @@ class AiHubServer {
2755
2792
  }
2756
2793
  }
2757
2794
  // Issue #1221: a host-reported error/turn-failure must never be dropped
2758
- // as opaque raw JSON. Record it for the terminal exit message, surface it
2759
- // in the regular transcript as a distinct system message (not just a side
2760
- // panel), and put it in Micro-manage unconditionally — bypassing
2795
+ // as opaque raw JSON. Record it for the terminal exit message and put it
2796
+ // in Micro-manage unconditionally bypassing
2761
2797
  // projectHostEventForConversation's raw-payload filter, which exists for
2762
2798
  // routine protocol chatter, not for a failure the manager needs to see.
2799
+ // Issue #1240: do NOT push to run.messages here; only surface in the
2800
+ // conversation thread via pushTerminalFailureToThread when the run actually
2801
+ // fails, so transient errors resolved by auto-recovery stay silent to the user.
2763
2802
  if (event.hostError) {
2764
2803
  run.lastHostError = event.hostError;
2765
- run.messages.push((0, hosts_1.createHubMessage)('system', `Host error: ${event.hostError}`));
2766
2804
  run.events.push((0, hosts_1.createHubEvent)('stderr', boundedEventText(event.hostError)));
2767
2805
  }
2768
2806
  const projected = projectHostEventForConversation(event, channel);
@@ -4198,6 +4236,45 @@ class AiHubServer {
4198
4236
  res.end(data);
4199
4237
  });
4200
4238
  });
4239
+ // Issue #1248: Serve the Excel task pane HTML and manifest, mirroring the
4240
+ // PowerPoint route above. Office JS appends query strings
4241
+ // (?_host_Info=Excel$Win32$...) to every request, so strip them before
4242
+ // resolving the file path.
4243
+ this.app.get(/^\/excel-taskpane(\/.*)?$/, (req, res) => {
4244
+ const taskpaneDir = resolveTaskpaneDir('excel-taskpane');
4245
+ const { pathname } = new URL(req.url, 'http://localhost');
4246
+ const relativePath = pathname.replace(/^\/excel-taskpane\/?/, '') || 'index.html';
4247
+ const filePath = path_1.default.join(taskpaneDir, relativePath);
4248
+ // Path traversal guard. A bare `startsWith(taskpaneDir)` (the pattern
4249
+ // the PowerPoint route above uses) would also accept a sibling
4250
+ // directory whose name happens to start with "excel-taskpane" (e.g.
4251
+ // "excel-taskpane-evil") — require the separator (or an exact match)
4252
+ // like the /_fraim-hub-ui asset guard above does.
4253
+ if (!filePath.startsWith(taskpaneDir + path_1.default.sep) && filePath !== taskpaneDir) {
4254
+ res.status(403).end();
4255
+ return;
4256
+ }
4257
+ const ext = path_1.default.extname(filePath).toLowerCase();
4258
+ const contentTypeMap = {
4259
+ '.html': 'text/html; charset=utf-8',
4260
+ '.xml': 'application/xml',
4261
+ '.js': 'application/javascript',
4262
+ '.css': 'text/css',
4263
+ '.png': 'image/png',
4264
+ '.ico': 'image/x-icon',
4265
+ '.svg': 'image/svg+xml',
4266
+ };
4267
+ const contentType = contentTypeMap[ext] || 'application/octet-stream';
4268
+ fs_1.default.readFile(filePath, (err, data) => {
4269
+ if (err) {
4270
+ res.status(404).end('Not found');
4271
+ return;
4272
+ }
4273
+ res.setHeader('Content-Type', contentType);
4274
+ res.setHeader('Access-Control-Allow-Origin', '*');
4275
+ res.end(data);
4276
+ });
4277
+ });
4201
4278
  this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
4202
4279
  let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
4203
4280
  ? req.query.projectPath
@@ -4365,8 +4442,13 @@ class AiHubServer {
4365
4442
  const conversation = this.conversationStore.loadConversation(key, conversationId);
4366
4443
  if (!conversation)
4367
4444
  return res.status(404).json({ error: 'conversation not found' });
4445
+ // Issue #1246: re-derive stages at read time rather than trusting the
4446
+ // persisted mid-run snapshot; see deriveStagesForConversation.
4447
+ const withFreshStages = conversation.run
4448
+ ? { ...conversation, run: { ...conversation.run, stages: deriveStagesForConversation(conversation) } }
4449
+ : conversation;
4368
4450
  // Issue #1090: ownership is derived on read, never served from the frozen record.
4369
- return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(conversation, key), source: 'disk' });
4451
+ return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(withFreshStages, key), source: 'disk' });
4370
4452
  }
4371
4453
  if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
4372
4454
  // One disk walk for the whole list, not one per conversation.
@@ -219,7 +219,9 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
219
219
  personaKey: 'sreya',
220
220
  bundleId: 'persona-sreya-core',
221
221
  catalogMetadata: buildCatalogMetadata('sreya', ['slo-design-and-implementation', 'incident-response', 'reliability-review']),
222
- protectedJobs: ['slo-design-and-implementation', 'incident-response', 'reliability-review', 'production-readiness-review'],
222
+ // run-on-remote-hub and setup-remote-hub are owned by SReya, the infra
223
+ // persona, rather than the display-only FRAIMworker default.
224
+ protectedJobs: ['slo-design-and-implementation', 'incident-response', 'reliability-review', 'production-readiness-review', 'run-on-remote-hub', 'setup-remote-hub'],
223
225
  protectedAliases: ['sre', 'reliability', 'incident-response', 'on-call'],
224
226
  defaultHireMode: 'job',
225
227
  lockCopy: 'Hire SREya to unlock SLO governance, incident response, and production reliability work for this request.'
@@ -3,9 +3,9 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>FRAIM Hub</title>
7
- <script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js" type="text/javascript"></script>
8
- <script src="config.js" type="text/javascript"></script>
6
+ <title>FRAIM Hub</title>
7
+ <script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js" type="text/javascript"></script>
8
+ <script src="config.js" type="text/javascript"></script>
9
9
  <style>
10
10
  * { margin: 0; padding: 0; box-sizing: border-box; }
11
11
  html, body { height: 100%; overflow: hidden; }
@@ -19,9 +19,9 @@
19
19
  // Loading an HTTP iframe from an HTTPS page is mixed-content-blocked, so use the
20
20
  // same origin as the taskpane when running over HTTPS (the ssl-proxy forwards all
21
21
  // routes to the Hub). HTTP (isolated tests) keeps the direct Hub address.
22
- var HUB_ORIGIN = window.location.protocol === 'https:'
23
- ? window.location.origin
24
- : (window.FRAIM_HUB_ORIGIN || 'http://127.0.0.1:43091');
22
+ var HUB_ORIGIN = window.location.protocol === 'https:'
23
+ ? window.location.origin
24
+ : (window.FRAIM_HUB_ORIGIN || 'http://127.0.0.1:43091');
25
25
  var hubFrame = document.getElementById('hub');
26
26
  var pendingPush = null; // context queued before hub-ready fires
27
27
  var hubReady = false;
@@ -70,12 +70,21 @@
70
70
  }
71
71
 
72
72
  function readBodyAsync(cb) {
73
+ // Issue #1239: getSelectedDataAsync (used by readSelectionAsync above) only
74
+ // ever returns the current selection, never the full document — passing a
75
+ // different `valueFormat`/omitting `filterType` does not change that. Use
76
+ // the Word-specific API (already used below for comments/insert/append) to
77
+ // read the actual document body.
73
78
  try {
74
- Office.context.document.getSelectedDataAsync(
75
- Office.CoercionType.Text,
76
- { valueFormat: 'unformatted' },
77
- function(r) { cb(r.status === Office.AsyncResultStatus.Succeeded ? String(r.value || '') : ''); }
78
- );
79
+ if (typeof Word !== 'undefined' && Word.run) {
80
+ Word.run(function(ctx) {
81
+ var body = ctx.document.body;
82
+ body.load('text');
83
+ return ctx.sync().then(function() { cb(body.text || ''); });
84
+ }).catch(function() { cb(''); });
85
+ } else {
86
+ cb('');
87
+ }
79
88
  } catch(e) { cb(''); }
80
89
  }
81
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.274",
3
+ "version": "2.0.275",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -168,7 +168,7 @@
168
168
  "electron-updater": "^6.8.9",
169
169
  "express": "^5.2.1",
170
170
  "extract-zip": "^2.0.1",
171
- "fraim": "2.0.274",
171
+ "fraim": "2.0.275",
172
172
  "mongodb": "^7.0.0",
173
173
  "node-cron": "4.2.1",
174
174
  "node-edge-tts": "^1.2.10",
@@ -0,0 +1,298 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>FRAIM Hub</title>
7
+ <script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js" type="text/javascript"></script>
8
+ <script src="config.js" type="text/javascript"></script>
9
+ <style>
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+ html, body { height: 100%; overflow: hidden; }
12
+ iframe { width: 100%; height: 100vh; border: none; display: block; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <iframe id="hub" src="" allow="clipboard-read; clipboard-write"></iframe>
17
+ <script>
18
+ // Excel task pane — mirrors extensions/office-word/taskpane.html and
19
+ // public/ai-hub/powerpoint-taskpane/index.html so Excel gets the identical
20
+ // full Hub experience (issue #1248). Mounts the Hub /ai-hub/ surface in an
21
+ // iframe and bridges sheet/selection context over postMessage using the
22
+ // same word-context / word-request / word-response contract the Hub
23
+ // already speaks for every host (the names are the generic host-bridge
24
+ // protocol, not Word-specific).
25
+ //
26
+ // In Excel Online the pane is served over HTTPS (ssl-proxy at localhost:43092,
27
+ // same mechanism Word Online already uses). Loading an HTTP iframe from an
28
+ // HTTPS page is mixed-content-blocked, so use the pane's own origin there.
29
+ // On desktop (HTTP) use the direct Hub address.
30
+ var HUB_ORIGIN = window.location.protocol === 'https:'
31
+ ? window.location.origin
32
+ : (window.FRAIM_HUB_ORIGIN || 'http://127.0.0.1:43091');
33
+ var hubFrame = document.getElementById('hub');
34
+ var pendingPush = null; // context queued before hub-ready fires
35
+ var hubReady = false;
36
+ var selectionHandlerAdded = false;
37
+ // Selection captured the last time context was read (on mount, on
38
+ // get-context/get-selection, and on every DocumentSelectionChanged fire).
39
+ // Write-back (R4) needs this to decide adjacent-cell vs append-new-row,
40
+ // since Excel's hasSelection is always true and can't gate that branch
41
+ // the way Word's optional selection does.
42
+ var lastSelectionInfo = null;
43
+
44
+ // ── postMessage bridge (inbound from Hub) ─────────────────────────────────
45
+ window.addEventListener('message', function(event) {
46
+ if (event.origin !== HUB_ORIGIN) return;
47
+ var msg = event.data || {};
48
+ if (msg.type === 'hub-ready') {
49
+ hubReady = true;
50
+ if (pendingPush) { pushToHub(pendingPush); pendingPush = null; }
51
+ } else if (msg.type === 'word-request') {
52
+ handleExcelRequest(msg.action, msg.requestId, msg.payload || {});
53
+ }
54
+ });
55
+
56
+ function pushToHub(msg) {
57
+ if (hubFrame && hubFrame.contentWindow) {
58
+ hubFrame.contentWindow.postMessage(msg, HUB_ORIGIN);
59
+ }
60
+ }
61
+
62
+ // ── Reading Excel context ───────────────────────────────────────────────────
63
+ function docMeta() {
64
+ var meta = { docUrl: '', docTitle: '' };
65
+ try {
66
+ var doc = window.Office && Office.context && Office.context.document;
67
+ meta.docUrl = (doc && doc.url) ? doc.url : '';
68
+ if (meta.docUrl) {
69
+ var parts = meta.docUrl.replace(/\\/g, '/').split('/');
70
+ meta.docTitle = (parts[parts.length - 1] || '').replace(/\.[^.]+$/, '');
71
+ }
72
+ } catch(e) {}
73
+ return meta;
74
+ }
75
+
76
+ // R2 edge case: Excel always has an active cell — there is no true "nothing
77
+ // selected" state the way Word has. Only a single cell at the sheet's bare
78
+ // A1 default counts as "nothing meaningful" for the R4 write-back branch.
79
+ function isDefaultA1(info) {
80
+ if (!info || !info.address) return true;
81
+ var bare = String(info.address).split('!').pop();
82
+ return bare === 'A1' || bare === '$A$1';
83
+ }
84
+
85
+ function csvEscapeCell(v) {
86
+ var s = (v === null || v === undefined) ? '' : String(v);
87
+ if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
88
+ return s;
89
+ }
90
+
91
+ // Bounded CSV serialization of the used/visible range (R2, R8). Callers
92
+ // must still cap the RESULT to 800 chars — this only shapes the rows.
93
+ function rangeToCsv(values) {
94
+ return (values || []).map(function(row) {
95
+ return (row || []).map(csvEscapeCell).join(',');
96
+ }).join('\n');
97
+ }
98
+
99
+ // Build the human-readable selection label, e.g. "D5 (Engineering OpEx,
100
+ // Q2 Forecast): 344400" — address, inferred row/column headers from the
101
+ // used range's first column/first row, and the cell's own value. Falls
102
+ // back to a bare "address: value" when no header is discoverable.
103
+ function describeSelection(selRange, usedRange) {
104
+ try {
105
+ var addr = String(selRange.address || '').split('!').pop();
106
+ var rows = (selRange.values && selRange.values[0]) || [];
107
+ var val = rows[0] !== undefined ? rows[0] : '';
108
+ var rowLabel = '', colLabel = '';
109
+ if (usedRange && !usedRange.isNullObject && usedRange.values && usedRange.values.length > 1) {
110
+ var relRow = selRange.rowIndex - usedRange.rowIndex;
111
+ var relCol = selRange.columnIndex - usedRange.columnIndex;
112
+ var headerRow = usedRange.values[0] || [];
113
+ var rowAtCol0 = (usedRange.values[relRow] || [])[0];
114
+ if (relRow > 0 && rowAtCol0 !== undefined && rowAtCol0 !== '') rowLabel = String(rowAtCol0);
115
+ if (relCol > 0 && headerRow[relCol] !== undefined && headerRow[relCol] !== '') colLabel = String(headerRow[relCol]);
116
+ }
117
+ var parts = [rowLabel, colLabel].filter(Boolean);
118
+ return parts.length ? (addr + ' (' + parts.join(', ') + '): ' + val) : (addr + ': ' + val);
119
+ } catch(e) { return ''; }
120
+ }
121
+
122
+ function emptyContext() {
123
+ var meta = docMeta();
124
+ return { docUrl: meta.docUrl, docTitle: meta.docTitle, selection: '', hasSelection: true, bodyPreview: '', wordCount: 0, comments: [] };
125
+ }
126
+
127
+ function readFullContext(cb) {
128
+ var meta = docMeta();
129
+ if (typeof Excel === 'undefined' || !Excel.run) { cb(emptyContext()); return; }
130
+ Excel.run(function(ctx) {
131
+ var sheet = ctx.workbook.worksheets.getActiveWorksheet();
132
+ sheet.load('name');
133
+ var selRange = ctx.workbook.getSelectedRange();
134
+ selRange.load('address,values,rowIndex,columnIndex');
135
+ var usedRange = sheet.getUsedRangeOrNullObject();
136
+ usedRange.load('values,isNullObject,rowIndex,columnIndex,rowCount');
137
+ var comments = sheet.comments;
138
+ comments.load('items/content,items/authorName,items/resolved');
139
+ return ctx.sync().then(function() {
140
+ lastSelectionInfo = { address: selRange.address, rowIndex: selRange.rowIndex, columnIndex: selRange.columnIndex };
141
+ var body = usedRange.isNullObject ? '' : rangeToCsv(usedRange.values);
142
+ var commentList = (comments.items || []).slice(0, 20).map(function(c) {
143
+ return { author: c.authorName || '', text: c.content || '', resolved: !!c.resolved };
144
+ });
145
+ cb({
146
+ docUrl: meta.docUrl,
147
+ docTitle: sheet.name ? (meta.docTitle + ' — ' + sheet.name) : meta.docTitle,
148
+ selection: describeSelection(selRange, usedRange),
149
+ hasSelection: true,
150
+ bodyPreview: body.slice(0, 800),
151
+ wordCount: body ? body.split(/\s+/).filter(Boolean).length : 0,
152
+ comments: commentList,
153
+ });
154
+ });
155
+ }).catch(function() { cb(emptyContext()); });
156
+ }
157
+
158
+ // Lighter-weight read used by get-selection and the selection-change
159
+ // handler — just the label, not the whole used range + comments.
160
+ function refreshSelectionLabel(cb) {
161
+ if (typeof Excel === 'undefined' || !Excel.run) { cb(''); return; }
162
+ Excel.run(function(ctx) {
163
+ var sheet = ctx.workbook.worksheets.getActiveWorksheet();
164
+ var selRange = ctx.workbook.getSelectedRange();
165
+ selRange.load('address,values,rowIndex,columnIndex');
166
+ var usedRange = sheet.getUsedRangeOrNullObject();
167
+ usedRange.load('values,isNullObject,rowIndex,columnIndex');
168
+ return ctx.sync().then(function() {
169
+ lastSelectionInfo = { address: selRange.address, rowIndex: selRange.rowIndex, columnIndex: selRange.columnIndex };
170
+ cb(describeSelection(selRange, usedRange));
171
+ });
172
+ }).catch(function() { cb(''); });
173
+ }
174
+
175
+ // ── Selection-change handler ───────────────────────────────────────────────
176
+ // Reuses the same Office.context.document common-API event Word/PowerPoint
177
+ // already wire (issue #1248 spec: "the same Office JS event exists on
178
+ // Excel's document object") — it only signals that something changed; the
179
+ // actual range data is re-fetched via Excel.run() above, same as Word/PPT.
180
+ function addSelectionHandler() {
181
+ if (selectionHandlerAdded) return;
182
+ try {
183
+ Office.context.document.addHandlerAsync(
184
+ Office.EventType.DocumentSelectionChanged,
185
+ function() {
186
+ refreshSelectionLabel(function(sel) {
187
+ var m = docMeta();
188
+ pushToHub({ type: 'word-context-update', payload: {
189
+ docUrl: m.docUrl, docTitle: m.docTitle,
190
+ selection: sel, hasSelection: true,
191
+ }});
192
+ });
193
+ },
194
+ function(r) { if (r.status === Office.AsyncResultStatus.Succeeded) selectionHandlerAdded = true; }
195
+ );
196
+ } catch(e) {}
197
+ }
198
+
199
+ // ── Write-back (R4) ─────────────────────────────────────────────────────────
200
+ // Never overwrites the cell that was selected when the job started: if that
201
+ // selection was meaningful (anything but the sheet's bare A1 default),
202
+ // write into the cell immediately adjacent to it (same row, next column).
203
+ // Otherwise append a new row at the end of the used range. Either way, add
204
+ // a cell comment noting the write for audit/undo-by-review (R4's own
205
+ // recommended default) and rely on Excel's native Ctrl+Z for reject.
206
+ function writeBackNonDestructive(text, jobLabel, cb) {
207
+ if (typeof Excel === 'undefined' || !Excel.run) { cb(false, 'Excel API 1.10+ not available'); return; }
208
+ var info = lastSelectionInfo;
209
+ var meaningful = !!(info && !isDefaultA1(info));
210
+ var note = 'Written by FRAIM' + (jobLabel ? ' (' + jobLabel + ')' : '') + ' - ' + new Date().toISOString();
211
+ try {
212
+ Excel.run(function(ctx) {
213
+ var sheet = ctx.workbook.worksheets.getActiveWorksheet();
214
+ if (meaningful) {
215
+ var addr = String(info.address).split('!').pop();
216
+ var selRange = sheet.getRange(addr);
217
+ var target = selRange.getOffsetRange(0, 1);
218
+ target.values = [[text]];
219
+ sheet.comments.add(target, note);
220
+ return ctx.sync();
221
+ }
222
+ var used = sheet.getUsedRangeOrNullObject();
223
+ used.load('rowCount,rowIndex,isNullObject');
224
+ return ctx.sync().then(function() {
225
+ var nextRow = used.isNullObject ? 0 : (used.rowIndex + used.rowCount);
226
+ var target = sheet.getRangeByIndexes(nextRow, 0, 1, 1);
227
+ target.values = [[text]];
228
+ sheet.comments.add(target, note);
229
+ return ctx.sync();
230
+ });
231
+ }).then(function() { cb(true); }).catch(function(e) { cb(false, (e && e.message) || String(e)); });
232
+ } catch(e) { cb(false, e.message); }
233
+ }
234
+
235
+ // ── Handling requests from Hub ─────────────────────────────────────────────
236
+ function handleExcelRequest(action, requestId, payload) {
237
+ function respond(result) {
238
+ pushToHub({ type: 'word-response', requestId: requestId, payload: result });
239
+ }
240
+ if (action === 'get-context') {
241
+ readFullContext(function(ctx) { respond(ctx); });
242
+ } else if (action === 'get-selection') {
243
+ refreshSelectionLabel(function(sel) { respond({ selection: sel, hasSelection: true }); });
244
+ } else if (action === 'insert-text') {
245
+ // Symmetry with Word/PowerPoint's action surface; not part of the
246
+ // write-back flow (which only ever calls insert-after/append-to-doc —
247
+ // see tryWordWriteBack in public/ai-hub/script.js). Sets the selected
248
+ // cell's own value directly, matching Word's "replace selection".
249
+ try {
250
+ if (typeof Excel === 'undefined' || !Excel.run) { respond({ ok: false, error: 'Excel API 1.10+ not available' }); return; }
251
+ Excel.run(function(ctx) {
252
+ var range = ctx.workbook.getSelectedRange();
253
+ range.values = [[payload.text || '']];
254
+ return ctx.sync();
255
+ }).then(function() { respond({ ok: true }); }).catch(function(e) { respond({ ok: false, error: e.message }); });
256
+ } catch(e) { respond({ ok: false, error: e.message }); }
257
+ } else if (action === 'insert-after' || action === 'append-to-doc') {
258
+ writeBackNonDestructive(payload.text || '', payload.jobLabel || '', function(ok, err) {
259
+ if (ok) respond({ ok: true }); else respond({ ok: false, error: err });
260
+ });
261
+ } else if (action === 'track-changes-on' || action === 'track-changes-off') {
262
+ // R4: Excel has no Office-JS-exposed equivalent to Word's tracked-changes
263
+ // mode. Respond with an explicit ok, not the generic unknown-action
264
+ // fallthrough PowerPoint's host script currently falls into for these
265
+ // same two action names.
266
+ respond({ ok: true });
267
+ } else {
268
+ respond({ ok: false, error: 'Unknown action: ' + action });
269
+ }
270
+ }
271
+
272
+ // ── Mount ─────────────────────────────────────────────────────────────────
273
+ function mountHub() {
274
+ var meta = docMeta();
275
+ var params = new URLSearchParams({ surface: 'task-pane' });
276
+ if (meta.docUrl) params.set('docUrl', meta.docUrl);
277
+ if (meta.docTitle) params.set('docTitle', meta.docTitle);
278
+ hubFrame.src = HUB_ORIGIN + '/ai-hub/?' + params.toString();
279
+
280
+ hubFrame.addEventListener('load', function() {
281
+ setTimeout(function() {
282
+ readFullContext(function(ctx) {
283
+ var msg = { type: 'word-context', payload: ctx };
284
+ if (hubReady) { pushToHub(msg); } else { pendingPush = msg; }
285
+ });
286
+ }, 300);
287
+ addSelectionHandler();
288
+ });
289
+ }
290
+
291
+ if (typeof Office !== 'undefined') {
292
+ Office.onReady(function() { mountHub(); });
293
+ } else {
294
+ window.addEventListener('DOMContentLoaded', mountHub);
295
+ }
296
+ </script>
297
+ </body>
298
+ </html>
@@ -0,0 +1,33 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
3
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
+ xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
5
+ xsi:type="TaskPaneApp">
6
+ <Id>da8d22c7-2c00-485a-bdc9-13dda7f80c48</Id>
7
+ <Version>1.0.0.0</Version>
8
+ <ProviderName>FRAIM</ProviderName>
9
+ <DefaultLocale>en-US</DefaultLocale>
10
+ <DisplayName DefaultValue="FRAIM"/>
11
+ <Description DefaultValue="AI Hub for your workbook - any FRAIM job, with sheet and selection context, in one task pane."/>
12
+ <IconUrl DefaultValue="https://localhost:43092/excel-taskpane/icon-64.png"/>
13
+ <HighResolutionIconUrl DefaultValue="https://localhost:43092/excel-taskpane/icon-64.png"/>
14
+ <AppDomains>
15
+ <AppDomain>https://appsforoffice.microsoft.com</AppDomain>
16
+ </AppDomains>
17
+
18
+ <Hosts>
19
+ <Host Name="Workbook"/>
20
+ </Hosts>
21
+
22
+ <Requirements>
23
+ <Sets>
24
+ <Set Name="ExcelApi" MinVersion="1.10"/>
25
+ </Sets>
26
+ </Requirements>
27
+
28
+ <DefaultSettings>
29
+ <SourceLocation DefaultValue="https://localhost:43092/excel-taskpane/"/>
30
+ </DefaultSettings>
31
+
32
+ <Permissions>ReadWriteDocument</Permissions>
33
+ </OfficeApp>
@@ -5578,6 +5578,9 @@ function resolveNextJobFromCatalog(jobId) {
5578
5578
  const all = [
5579
5579
  ...((state.bootstrap && state.bootstrap.jobs) || []),
5580
5580
  ...((state.bootstrap && state.bootstrap.managerTemplates) || []),
5581
+ // Issue #1247: internal jobs are excluded from the main picker list but must
5582
+ // be resolvable so next-job recommendation chips can launch them.
5583
+ ...((state.bootstrap && state.bootstrap.internalJobs) || []),
5581
5584
  ];
5582
5585
  return all.find((j) => j.id === jobId) || null;
5583
5586
  }
@@ -7663,6 +7666,22 @@ function deriveTitle(jobTitle, instructions) {
7663
7666
  // The browser sends raw manager instructions. AI Hub normalizes assigned
7664
7667
  // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
7665
7668
  // share one start/continue contract.
7669
+ // Issue #1239: shared by startRun() and continueRun() — in a task-pane/
7670
+ // extension surface, fetches a fresh selection snapshot, merges it with the
7671
+ // last-pushed state.wordContext, and prepends the resulting document-context
7672
+ // block to outgoing text so the agent knows what the user is looking at. A
7673
+ // no-op outside task-pane/extension surfaces.
7674
+ async function withWordContext(text) {
7675
+ let effective = text || '';
7676
+ if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
7677
+ const fresh = await requestWordContext('get-selection').catch(() => null);
7678
+ const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
7679
+ const ctxBlock = buildWordContextBlock(wc);
7680
+ if (ctxBlock) effective = ctxBlock + '\n\n---\n\n' + effective;
7681
+ }
7682
+ return effective;
7683
+ }
7684
+
7666
7685
  async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
7667
7686
  if (!hubEmployeeIsAvailable(employeeId)) {
7668
7687
  renderHubAgentSetupPanel();
@@ -7674,13 +7693,7 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
7674
7693
  // In task-pane/extension mode: get a fresh selection snapshot and prepend
7675
7694
  // document context to the instructions so the agent knows what the user is
7676
7695
  // looking at. stubPath resolution and FRAIM invocation are now server-side.
7677
- let effectiveInstructions = instructions || '';
7678
- if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
7679
- const fresh = await requestWordContext('get-selection').catch(() => null);
7680
- const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
7681
- const ctxBlock = buildWordContextBlock(wc);
7682
- if (ctxBlock) effectiveInstructions = ctxBlock + '\n\n---\n\n' + effectiveInstructions;
7683
- }
7696
+ const effectiveInstructions = await withWordContext(instructions);
7684
7697
 
7685
7698
  // Issue #442: read the A/B toggle state from the modal before it closes.
7686
7699
  const abToggle = document.getElementById('ab-toggle');
@@ -7859,13 +7872,20 @@ async function continueRun(text, options) {
7859
7872
  upsertConversation(conv);
7860
7873
  refreshStatusSurfaces(); // #533 R5: also recolor the tree/area dots back to working
7861
7874
  renderActive();
7875
+ // Issue #1239: task-pane/extension surfaces must attach current Word document
7876
+ // context to every follow-up message, the same way startRun() does for the
7877
+ // job-start instructions — otherwise a highlighted selection or a question
7878
+ // asked mid-conversation reaches the agent with zero document context. Runs
7879
+ // after the render above so the UI flips to "running" immediately on send,
7880
+ // not after the (up to few-hundred-ms) context round trip.
7881
+ const effectiveText = await withWordContext(text);
7862
7882
  try {
7863
7883
  let run;
7864
7884
  try {
7865
7885
  run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
7866
7886
  method: 'POST',
7867
7887
  headers: { 'Content-Type': 'application/json' },
7868
- body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}), ...(deliveryIntent ? { deliveryIntent } : {}) }),
7888
+ body: JSON.stringify({ instructions: effectiveText, ...(coachingJobId ? { coachingJobId } : {}), ...(deliveryIntent ? { deliveryIntent } : {}) }),
7869
7889
  });
7870
7890
  } catch (e) {
7871
7891
  const isNotFound = /not found/i.test((e && e.message) || '');
@@ -7888,7 +7908,7 @@ async function continueRun(text, options) {
7888
7908
  conversationId: conv.id,
7889
7909
  conversationTitle: conv.title,
7890
7910
  sessionId: conv.sessionId,
7891
- instructions: text,
7911
+ instructions: effectiveText,
7892
7912
  ...(coachingJobId ? { coachingJobId } : {}),
7893
7913
  }),
7894
7914
  });
@@ -7908,7 +7928,7 @@ async function continueRun(text, options) {
7908
7928
  jobTitle: conv.jobTitle || conv.jobId,
7909
7929
  conversationId: conv.id,
7910
7930
  conversationTitle: conv.title,
7911
- instructions: text,
7931
+ instructions: effectiveText,
7912
7932
  ...(coachingJobId ? { coachingJobId } : {}),
7913
7933
  }),
7914
7934
  });
@@ -8159,7 +8179,9 @@ async function tryWordWriteBack(conv) {
8159
8179
  try {
8160
8180
  await requestWordContext('track-changes-on');
8161
8181
  const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
8162
- await requestWordContext(action, { text: outputText });
8182
+ // jobLabel (issue #1248): Word/PowerPoint ignore this extra field; Excel's
8183
+ // write-back audit comment (R4) uses it to name the job that wrote the cell.
8184
+ await requestWordContext(action, { text: outputText, jobLabel: conv.jobTitle || conv.jobId });
8163
8185
  await requestWordContext('track-changes-off');
8164
8186
  showStatus('Result written to document. Review tracked changes.');
8165
8187
  } catch (e) {