fraim 2.0.160 → 2.0.162
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/dist/src/ai-hub/conversation-store.js +164 -0
- package/dist/src/ai-hub/desktop-main.js +34 -12
- package/dist/src/ai-hub/hosts.js +383 -27
- package/dist/src/ai-hub/managed-browser.js +269 -0
- package/dist/src/ai-hub/manager-turns.js +13 -0
- package/dist/src/ai-hub/office-sideload.js +21 -3
- package/dist/src/ai-hub/preferences.js +10 -1
- package/dist/src/ai-hub/server.js +1243 -65
- package/dist/src/cli/commands/init-project.js +7 -1
- package/dist/src/cli/utils/agent-adapters.js +1 -1
- package/dist/src/core/fraim-config-schema.generated.js +50 -13
- package/dist/src/core/quality-evidence.js +4 -1
- package/dist/src/local-mcp-server/agent-token-prices.js +23 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +438 -2
- package/dist/src/local-mcp-server/stdio-server.js +73 -15
- package/package.json +5 -4
- package/public/ai-hub/index.html +456 -7
- package/public/ai-hub/powerpoint-taskpane/index.html +2 -1
- package/public/ai-hub/review.css +354 -0
- package/public/ai-hub/script.js +5945 -1279
- package/public/ai-hub/styles.css +1805 -16
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
5
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
39
|
exports.AiHubServer = void 0;
|
|
7
40
|
exports.findAvailablePort = findAvailablePort;
|
|
41
|
+
exports.findAvailablePortExcluding = findAvailablePortExcluding;
|
|
8
42
|
const express_1 = __importDefault(require("express"));
|
|
9
43
|
const path_1 = __importDefault(require("path"));
|
|
10
44
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -14,6 +48,8 @@ const crypto_1 = require("crypto");
|
|
|
14
48
|
const child_process_1 = require("child_process");
|
|
15
49
|
const https_1 = __importDefault(require("https"));
|
|
16
50
|
const types_1 = require("../first-run/types");
|
|
51
|
+
const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
|
|
52
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
53
|
const PERSONA_AVATAR_SEEDS = {
|
|
18
54
|
maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
|
|
19
55
|
beza: { seed: 'BEZA-strategist', bg: 'c7d2fe' },
|
|
@@ -43,6 +79,8 @@ const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
|
43
79
|
const hosts_1 = require("./hosts");
|
|
44
80
|
const manager_turns_1 = require("./manager-turns");
|
|
45
81
|
const preferences_1 = require("./preferences");
|
|
82
|
+
const conversation_store_1 = require("./conversation-store");
|
|
83
|
+
const managed_browser_1 = require("./managed-browser");
|
|
46
84
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
47
85
|
function loadPersonaCapabilityModule() {
|
|
48
86
|
try {
|
|
@@ -136,6 +174,151 @@ class AiHubRunRegistry {
|
|
|
136
174
|
dispose(runId) {
|
|
137
175
|
this.children.delete(runId);
|
|
138
176
|
}
|
|
177
|
+
// #521: terminate the agent process for a run (manager clicked Stop). Returns
|
|
178
|
+
// true if a live child was signalled. The child's onExit handler then fires and
|
|
179
|
+
// parks the run in its waiting state.
|
|
180
|
+
stop(runId) {
|
|
181
|
+
const child = this.children.get(runId);
|
|
182
|
+
if (!child || typeof child.kill !== 'function')
|
|
183
|
+
return false;
|
|
184
|
+
try {
|
|
185
|
+
child.kill();
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function safeHttpUrl(value) {
|
|
194
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
195
|
+
return null;
|
|
196
|
+
try {
|
|
197
|
+
const parsed = new URL(value.trim());
|
|
198
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
199
|
+
return null;
|
|
200
|
+
return parsed.href;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function normalizeReviewArtifact(raw, index = 0) {
|
|
207
|
+
if (!raw || typeof raw !== 'object')
|
|
208
|
+
return null;
|
|
209
|
+
const value = raw;
|
|
210
|
+
const artifactPath = typeof value.path === 'string' && value.path.trim().length > 0 ? value.path.trim() : null;
|
|
211
|
+
const url = safeHttpUrl(value.url);
|
|
212
|
+
const type = typeof value.type === 'string' && value.type.trim().length > 0 ? value.type.trim() : 'file';
|
|
213
|
+
const label = typeof value.label === 'string' && value.label.trim().length > 0
|
|
214
|
+
? value.label.trim()
|
|
215
|
+
: artifactPath
|
|
216
|
+
? artifactPath.split(/[\\/]/).filter(Boolean).pop() || `Artifact ${index + 1}`
|
|
217
|
+
: url || `Artifact ${index + 1}`;
|
|
218
|
+
if (!artifactPath && !url)
|
|
219
|
+
return null;
|
|
220
|
+
return { type, label, path: artifactPath, url };
|
|
221
|
+
}
|
|
222
|
+
function normalizeReviewHandoff(raw) {
|
|
223
|
+
if (!raw || typeof raw !== 'object')
|
|
224
|
+
return null;
|
|
225
|
+
const value = raw;
|
|
226
|
+
if (typeof value.reviewRequired !== 'boolean')
|
|
227
|
+
return null;
|
|
228
|
+
const artifacts = Array.isArray(value.artifacts)
|
|
229
|
+
? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
|
|
230
|
+
: [];
|
|
231
|
+
const rawTarget = value.reviewTarget && typeof value.reviewTarget === 'object' ? value.reviewTarget : null;
|
|
232
|
+
const targetType = rawTarget && typeof rawTarget.type === 'string' ? rawTarget.type.trim() : '';
|
|
233
|
+
const targetLabel = rawTarget && typeof rawTarget.label === 'string' && rawTarget.label.trim().length > 0
|
|
234
|
+
? rawTarget.label.trim()
|
|
235
|
+
: '';
|
|
236
|
+
if (!value.reviewRequired) {
|
|
237
|
+
return {
|
|
238
|
+
reviewRequired: false,
|
|
239
|
+
reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
|
|
240
|
+
artifacts,
|
|
241
|
+
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
242
|
+
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : '',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (targetType === 'pull_request') {
|
|
246
|
+
const url = safeHttpUrl(rawTarget?.url);
|
|
247
|
+
if (!url)
|
|
248
|
+
return null;
|
|
249
|
+
return {
|
|
250
|
+
reviewRequired: true,
|
|
251
|
+
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
252
|
+
artifacts,
|
|
253
|
+
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
254
|
+
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (targetType === 'artifact_set' && artifacts.length > 0) {
|
|
258
|
+
return {
|
|
259
|
+
reviewRequired: true,
|
|
260
|
+
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
261
|
+
artifacts,
|
|
262
|
+
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
263
|
+
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function extractReviewHandoffFromText(text) {
|
|
269
|
+
if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
|
|
270
|
+
return null;
|
|
271
|
+
const candidates = [];
|
|
272
|
+
for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
273
|
+
candidates.push(match[1]);
|
|
274
|
+
const tagged = String(text).match(/<review_handoff>\s*([\s\S]*?)\s*<\/review_handoff>/i);
|
|
275
|
+
if (tagged)
|
|
276
|
+
candidates.push(tagged[1]);
|
|
277
|
+
const inline = String(text).match(/(\{\s*"reviewRequired"[\s\S]*\})/i);
|
|
278
|
+
if (inline)
|
|
279
|
+
candidates.push(inline[1]);
|
|
280
|
+
for (const candidate of candidates) {
|
|
281
|
+
try {
|
|
282
|
+
const handoff = normalizeReviewHandoff(JSON.parse(candidate.trim()));
|
|
283
|
+
if (handoff)
|
|
284
|
+
return handoff;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Malformed snippets are ignored; the UI can still surface legacy fallback state.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-.\\/]*?(?:docs|public|src|tests)[\\/][A-Za-z0-9_\-.\\/]+\.[A-Za-z0-9]+)/;
|
|
293
|
+
const ARTIFACT_EXCLUDE_RE = /(^|[\\/])(retrospectives|evidence|learnings|mocks|raw|archive)[\\/]/i;
|
|
294
|
+
function extractReviewArtifactFromText(text, projectPath) {
|
|
295
|
+
if (!text)
|
|
296
|
+
return null;
|
|
297
|
+
const match = text.match(ARTIFACT_PATH_RE);
|
|
298
|
+
if (!match)
|
|
299
|
+
return null;
|
|
300
|
+
const relativePath = match[1].replace(/\\/g, '/');
|
|
301
|
+
if (ARTIFACT_EXCLUDE_RE.test(relativePath))
|
|
302
|
+
return null;
|
|
303
|
+
const parts = relativePath.split('/').filter(Boolean);
|
|
304
|
+
const name = parts[parts.length - 1] || relativePath;
|
|
305
|
+
const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
|
|
306
|
+
return { type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath };
|
|
307
|
+
}
|
|
308
|
+
function applyReviewProjection(run, text) {
|
|
309
|
+
const handoff = extractReviewHandoffFromText(text);
|
|
310
|
+
if (handoff) {
|
|
311
|
+
run.reviewHandoff = handoff;
|
|
312
|
+
run.artifacts = handoff.artifacts;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const artifact = extractReviewArtifactFromText(text, run.projectPath);
|
|
316
|
+
if (!artifact)
|
|
317
|
+
return;
|
|
318
|
+
run.artifacts = run.artifacts || [];
|
|
319
|
+
if (!run.artifacts.some((entry) => entry.path === artifact.path || entry.url === artifact.url)) {
|
|
320
|
+
run.artifacts.push(artifact);
|
|
321
|
+
}
|
|
139
322
|
}
|
|
140
323
|
function emptyTotals() {
|
|
141
324
|
return {
|
|
@@ -201,6 +384,20 @@ function applyAgentIdentitySignal(run, identity) {
|
|
|
201
384
|
run.agentName = identity.agentName;
|
|
202
385
|
run.agentModel = identity.agentModel;
|
|
203
386
|
}
|
|
387
|
+
function appendHostMessage(run, hostId, event, channel) {
|
|
388
|
+
if (!event.message || channel !== 'stdout')
|
|
389
|
+
return;
|
|
390
|
+
applyReviewProjection(run, event.message);
|
|
391
|
+
if (hostId === 'gemini') {
|
|
392
|
+
const last = run.messages[run.messages.length - 1];
|
|
393
|
+
if (last?.role === 'employee') {
|
|
394
|
+
last.text = `${last.text}\n${event.message}`;
|
|
395
|
+
last.createdAt = new Date().toISOString();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
run.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
400
|
+
}
|
|
204
401
|
// Apply a parsed seekMentoring tool-use signal from the host stream to
|
|
205
402
|
// the run state. Returns the updated currentPhase.
|
|
206
403
|
function applySeekMentoringSignal(run, signal) {
|
|
@@ -214,6 +411,8 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
214
411
|
const callJobId = signal.jobId || signal.jobName;
|
|
215
412
|
if (callJobId && targetJobId && callJobId !== targetJobId)
|
|
216
413
|
return;
|
|
414
|
+
if (signal.reviewHandoff)
|
|
415
|
+
run.reviewHandoff = signal.reviewHandoff;
|
|
217
416
|
// Discriminant signals are routing hints only — they don't move the
|
|
218
417
|
// tracker, but they do change which phase id will be considered
|
|
219
418
|
// reachable next time stages are derived. Persist on the run.
|
|
@@ -285,7 +484,7 @@ function deriveStages(run, projectPath) {
|
|
|
285
484
|
currentIndex < 0 &&
|
|
286
485
|
historyMap.size === 0;
|
|
287
486
|
if (completedWithoutPhaseTelemetry) {
|
|
288
|
-
return
|
|
487
|
+
return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
|
|
289
488
|
}
|
|
290
489
|
return declaredPath.map((phase, index) => {
|
|
291
490
|
let state;
|
|
@@ -385,6 +584,37 @@ function hubOpenTerminal(command) {
|
|
|
385
584
|
}
|
|
386
585
|
(0, child_process_1.spawn)('bash', ['-c', command], { detached: true, stdio: 'ignore' }).unref();
|
|
387
586
|
}
|
|
587
|
+
function pathWithin(root, candidate) {
|
|
588
|
+
const resolvedRoot = path_1.default.resolve(root);
|
|
589
|
+
const resolvedCandidate = path_1.default.resolve(candidate);
|
|
590
|
+
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path_1.default.sep);
|
|
591
|
+
}
|
|
592
|
+
function resolveSafeArtifactPath(rawPath, projectPath) {
|
|
593
|
+
const trimmed = rawPath.trim();
|
|
594
|
+
if (!trimmed || /^https?:\/\//i.test(trimmed))
|
|
595
|
+
return null;
|
|
596
|
+
const resolved = path_1.default.resolve(path_1.default.isAbsolute(trimmed) ? trimmed : path_1.default.join(projectPath, trimmed));
|
|
597
|
+
const safeRoots = [
|
|
598
|
+
path_1.default.resolve(projectPath),
|
|
599
|
+
path_1.default.resolve(os_1.default.homedir()),
|
|
600
|
+
];
|
|
601
|
+
return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
|
|
602
|
+
}
|
|
603
|
+
function hubOpenFile(filePath) {
|
|
604
|
+
if (process.platform === 'win32') {
|
|
605
|
+
(0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-Command', 'Start-Process -LiteralPath $args[0]', filePath], {
|
|
606
|
+
detached: true,
|
|
607
|
+
stdio: 'ignore',
|
|
608
|
+
windowsHide: true,
|
|
609
|
+
}).unref();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (process.platform === 'darwin') {
|
|
613
|
+
(0, child_process_1.spawn)('open', [filePath], { detached: true, stdio: 'ignore' }).unref();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
(0, child_process_1.spawn)('xdg-open', [filePath], { detached: true, stdio: 'ignore' }).unref();
|
|
617
|
+
}
|
|
388
618
|
function buildManagedLoginCommand(command) {
|
|
389
619
|
const managedPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
|
|
390
620
|
if (process.platform === 'win32') {
|
|
@@ -407,16 +637,130 @@ function ensureDirectoryPath(projectPath) {
|
|
|
407
637
|
}
|
|
408
638
|
return resolved;
|
|
409
639
|
}
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// Issue #512 (S3) — Hub bootstrap projection helpers.
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Resolve the user email used to key personal (L1) learnings. Reads
|
|
644
|
+
// ~/.fraim/preferences.json (the existing user-prefs file) when present so the
|
|
645
|
+
// Brain counts match the auto-load context; falls back to a sensible default
|
|
646
|
+
// that resolveLearningUserId can refine against on-disk files.
|
|
647
|
+
function getHubUserEmail() {
|
|
648
|
+
try {
|
|
649
|
+
const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
|
|
650
|
+
if (fs_1.default.existsSync(prefsPath)) {
|
|
651
|
+
const raw = JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'));
|
|
652
|
+
if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
|
|
653
|
+
return raw.userEmail;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// fall through to default
|
|
658
|
+
}
|
|
659
|
+
// #533: preferences.json may not carry the email yet — the proxy persists it on
|
|
660
|
+
// fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
|
|
661
|
+
// stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
|
|
662
|
+
// from the on-disk files. This is what makes the avatar + personal learnings work.
|
|
663
|
+
const stamped = resolveSingleStampedUser();
|
|
664
|
+
if (stamped)
|
|
665
|
+
return stamped;
|
|
666
|
+
return 'fraim-user';
|
|
667
|
+
}
|
|
668
|
+
// Return the one user prefix stamped on the global learning files, or null if
|
|
669
|
+
// there is not exactly one (so we never guess between multiple accounts).
|
|
670
|
+
function resolveSingleStampedUser() {
|
|
671
|
+
try {
|
|
672
|
+
const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
|
|
673
|
+
if (!fs_1.default.existsSync(dir))
|
|
674
|
+
return null;
|
|
675
|
+
const prefixes = new Set();
|
|
676
|
+
for (const f of fs_1.default.readdirSync(dir)) {
|
|
677
|
+
if (f.startsWith('org-'))
|
|
678
|
+
continue;
|
|
679
|
+
const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
|
|
680
|
+
if (m)
|
|
681
|
+
prefixes.add(m[1]);
|
|
682
|
+
}
|
|
683
|
+
if (prefixes.size === 1)
|
|
684
|
+
return Array.from(prefixes)[0];
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
// ignore unreadable dir
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
// Read persisted Get-started step states from ~/.fraim/install-state.json
|
|
692
|
+
// (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
|
|
693
|
+
// missing key falls back to derivation. Persisted `true` is sticky so the rail
|
|
694
|
+
// never re-shows once a step is completed (R13.4).
|
|
695
|
+
function readPersistedFirstRun() {
|
|
696
|
+
const out = {};
|
|
697
|
+
const apply = (source) => {
|
|
698
|
+
if (!source || typeof source !== 'object')
|
|
699
|
+
return;
|
|
700
|
+
// Support both a flat shape ({ company: true }) and a nested
|
|
701
|
+
// ({ firstRun: { company: true } }) one — the rail mirrors to whichever.
|
|
702
|
+
const nested = source.firstRun;
|
|
703
|
+
const candidates = [source, nested].filter((c) => !!c && typeof c === 'object');
|
|
704
|
+
for (const c of candidates) {
|
|
705
|
+
for (const key of ['install', 'company', 'hire', 'project']) {
|
|
706
|
+
if (c[key] === true)
|
|
707
|
+
out[key] = true;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
const readJson = (fileName) => {
|
|
712
|
+
try {
|
|
713
|
+
const p = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), fileName);
|
|
714
|
+
if (!fs_1.default.existsSync(p))
|
|
715
|
+
return null;
|
|
716
|
+
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
apply(readJson('install-state.json'));
|
|
723
|
+
apply(readJson('preferences.json'));
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
726
|
+
// Count .md files recursively under a directory (skills/rules catalog counts).
|
|
727
|
+
function countMarkdownFilesRecursive(dirPath) {
|
|
728
|
+
if (!fs_1.default.existsSync(dirPath))
|
|
729
|
+
return 0;
|
|
730
|
+
let total = 0;
|
|
731
|
+
try {
|
|
732
|
+
for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
|
|
733
|
+
const child = path_1.default.join(dirPath, entry.name);
|
|
734
|
+
if (entry.isDirectory()) {
|
|
735
|
+
total += countMarkdownFilesRecursive(child);
|
|
736
|
+
}
|
|
737
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
738
|
+
total += 1;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// ignore unreadable dirs
|
|
744
|
+
}
|
|
745
|
+
return total;
|
|
746
|
+
}
|
|
410
747
|
class AiHubServer {
|
|
411
748
|
constructor(options = {}) {
|
|
412
749
|
this.app = (0, express_1.default)();
|
|
413
750
|
this.runRegistry = new AiHubRunRegistry();
|
|
414
751
|
this.projectPath = options.projectPath || process.cwd();
|
|
415
752
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
753
|
+
this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
|
|
416
754
|
this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
|
|
417
755
|
this.folderPicker = options.folderPicker ?? pickProjectPath;
|
|
418
756
|
this.httpsPort = options.httpsPort;
|
|
419
757
|
this.certBundle = options.certBundle;
|
|
758
|
+
this.managedBrowser = options.managedBrowser || new managed_browser_1.ManagedBrowser({
|
|
759
|
+
channel: process.env.FRAIM_BROWSER_CHANNEL || 'auto',
|
|
760
|
+
port: process.env.FRAIM_BROWSER_PORT ? Number(process.env.FRAIM_BROWSER_PORT) : undefined,
|
|
761
|
+
userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
|
|
762
|
+
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
763
|
+
});
|
|
420
764
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
421
765
|
if (options.dbService !== undefined) {
|
|
422
766
|
this.dbService = options.dbService;
|
|
@@ -427,11 +771,15 @@ class AiHubServer {
|
|
|
427
771
|
this.ownsDbService = this.dbService !== undefined;
|
|
428
772
|
}
|
|
429
773
|
this.app.use(express_1.default.json());
|
|
774
|
+
if (this.dbService) {
|
|
775
|
+
const { registerPaymentRoutes } = require('../routes/payment-routes');
|
|
776
|
+
registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
|
|
777
|
+
}
|
|
430
778
|
// CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
|
|
431
779
|
// calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
|
|
432
780
|
this.app.use((_req, res, next) => {
|
|
433
781
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
434
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
782
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
435
783
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
436
784
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
437
785
|
if (_req.method === 'OPTIONS') {
|
|
@@ -440,6 +788,37 @@ class AiHubServer {
|
|
|
440
788
|
}
|
|
441
789
|
next();
|
|
442
790
|
});
|
|
791
|
+
// Payment success redirect: sync entitlement then bounce to the hub.
|
|
792
|
+
// Stripe redirects here after a completed persona-hire checkout.
|
|
793
|
+
this.app.get('/ai-hub/payment-success', async (req, res) => {
|
|
794
|
+
const sessionId = req.query['session_id'];
|
|
795
|
+
const email = req.query['email'];
|
|
796
|
+
// Persist the buyer's email so bootstrap can look up entitlements by userId.
|
|
797
|
+
if (email) {
|
|
798
|
+
try {
|
|
799
|
+
const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
|
|
800
|
+
const existing = fs_1.default.existsSync(prefsPath)
|
|
801
|
+
? JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'))
|
|
802
|
+
: {};
|
|
803
|
+
fs_1.default.writeFileSync(prefsPath, JSON.stringify({ ...existing, userEmail: email }, null, 2));
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
console.warn('[ai-hub] could not persist userEmail:', err);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (sessionId && this.dbService) {
|
|
810
|
+
try {
|
|
811
|
+
const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
|
|
812
|
+
const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
|
|
813
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
814
|
+
await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
console.warn('[ai-hub] payment-success entitlement sync failed:', err);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
res.redirect('/ai-hub/?hired=1');
|
|
821
|
+
});
|
|
443
822
|
this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
|
|
444
823
|
// Issue #489: Serve the Word task pane assets at /word-taskpane/*.
|
|
445
824
|
// Office JS appends ?_host_Info=Word$Win32$... to every request — we must
|
|
@@ -488,6 +867,13 @@ class AiHubServer {
|
|
|
488
867
|
});
|
|
489
868
|
});
|
|
490
869
|
}
|
|
870
|
+
this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js'], (_req, res) => {
|
|
871
|
+
const port = this.httpPort || 43091;
|
|
872
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
873
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
874
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
875
|
+
res.end(`window.FRAIM_HUB_ORIGIN=${JSON.stringify(origin)};\n`);
|
|
876
|
+
});
|
|
491
877
|
this.app.get('/health', (_req, res) => {
|
|
492
878
|
res.json({ status: 'ok', service: 'fraim-ai-hub' });
|
|
493
879
|
});
|
|
@@ -501,9 +887,15 @@ class AiHubServer {
|
|
|
501
887
|
return this.app;
|
|
502
888
|
}
|
|
503
889
|
async start(port) {
|
|
890
|
+
this.httpPort = port;
|
|
504
891
|
if (this.dbService) {
|
|
505
892
|
try {
|
|
506
893
|
await this.dbService.connect();
|
|
894
|
+
const mongoClient = this.dbService.getClient();
|
|
895
|
+
if (mongoClient) {
|
|
896
|
+
const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
|
|
897
|
+
this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
|
|
898
|
+
}
|
|
507
899
|
}
|
|
508
900
|
catch (err) {
|
|
509
901
|
console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
|
|
@@ -515,6 +907,13 @@ class AiHubServer {
|
|
|
515
907
|
this.httpServer.once('listening', () => resolve());
|
|
516
908
|
this.httpServer.once('error', (error) => reject(error));
|
|
517
909
|
});
|
|
910
|
+
// #521: publish where the shared browser lives + how to bring it up, so every
|
|
911
|
+
// agent inherits them. The browser is NOT launched here — it's started lazily
|
|
912
|
+
// (the browser-use skill POSTs /browser/start when it actually needs one, which
|
|
913
|
+
// also relaunches it if the manager closed the window). Deterministic endpoint,
|
|
914
|
+
// so it's known even before the browser is running.
|
|
915
|
+
process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
|
|
916
|
+
process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
|
|
518
917
|
// Start HTTPS server when a cert bundle and port are provided.
|
|
519
918
|
// Word Online requires HTTPS; the HTTPS server shares the same Express app
|
|
520
919
|
// so all routes (including /word-taskpane/*) are available over both protocols.
|
|
@@ -526,6 +925,18 @@ class AiHubServer {
|
|
|
526
925
|
this.httpsServer.once('error', (error) => reject(error));
|
|
527
926
|
});
|
|
528
927
|
}
|
|
928
|
+
// #521: when the shared browser is enabled, bring it up at boot so the CDP
|
|
929
|
+
// endpoint is already published when the first run spawns. Best-effort — a
|
|
930
|
+
// missing browser must not stop the Hub from serving.
|
|
931
|
+
if (process.env.FRAIM_BROWSER_ENABLED === '1') {
|
|
932
|
+
try {
|
|
933
|
+
const r = await this.ensureManagedBrowser();
|
|
934
|
+
console.log(`[ai-hub] shared browser ready at ${r.endpoint}${r.reused ? ' (reused)' : ''}`);
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
console.warn('[ai-hub] shared browser not started:', err instanceof Error ? err.message : err);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
529
940
|
}
|
|
530
941
|
async stop() {
|
|
531
942
|
const closeServer = (srv) => new Promise((resolve, reject) => {
|
|
@@ -542,6 +953,9 @@ class AiHubServer {
|
|
|
542
953
|
await closeServer(this.httpServer);
|
|
543
954
|
this.httpServer = undefined;
|
|
544
955
|
}
|
|
956
|
+
// #521: tear down the shared browser if WE launched it (stop() no-ops on a
|
|
957
|
+
// browser the manager owns).
|
|
958
|
+
this.managedBrowser.stop();
|
|
545
959
|
if (this.ownsDbService && this.dbService) {
|
|
546
960
|
await this.dbService.close();
|
|
547
961
|
this.dbService = undefined;
|
|
@@ -569,7 +983,8 @@ class AiHubServer {
|
|
|
569
983
|
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
570
984
|
}));
|
|
571
985
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
|
|
572
|
-
const { personas, subscriptionActive } = await this.computePersonas(apiKey || preferences.apiKey);
|
|
986
|
+
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
|
|
987
|
+
const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
|
|
573
988
|
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
574
989
|
// so the bootstrap surface (used on first paint) carries stages and
|
|
575
990
|
// live totals — not just the raw run state.
|
|
@@ -586,6 +1001,104 @@ class AiHubServer {
|
|
|
586
1001
|
personas,
|
|
587
1002
|
subscriptionActive,
|
|
588
1003
|
activeRun,
|
|
1004
|
+
// Issue #512 (S3) — additive manager-flow projections.
|
|
1005
|
+
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1006
|
+
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
1007
|
+
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
|
|
1008
|
+
// #533: the resolved account email, so the profile card shows the real
|
|
1009
|
+
// identity (not a hardcoded placeholder) and the client can display it.
|
|
1010
|
+
userEmail: getHubUserEmail(),
|
|
1011
|
+
assignments: { byProject: {}, source: 'client-localStorage' },
|
|
1012
|
+
// Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
|
|
1013
|
+
// (not a top-level import) so the lightweight client CLI paths that import this
|
|
1014
|
+
// module for findAvailablePort do not eagerly load server-only config (repro #422).
|
|
1015
|
+
managerHiring: require('../config/ai-manager-hiring').buildManagerHiringCatalog(),
|
|
1016
|
+
// Issue #540: server-authoritative manager team (personas assigned by this manager).
|
|
1017
|
+
managerTeam,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
conversationRecordFromRun(run) {
|
|
1021
|
+
const lastUpdatedAt = run.updatedAt || new Date().toISOString();
|
|
1022
|
+
return {
|
|
1023
|
+
id: run.conversationId || run.id,
|
|
1024
|
+
projectPath: path_1.default.resolve(run.projectPath),
|
|
1025
|
+
title: run.conversationTitle || run.jobTitle || run.jobId,
|
|
1026
|
+
jobId: run.jobId,
|
|
1027
|
+
jobTitle: run.jobTitle || run.jobId,
|
|
1028
|
+
agentName: run.hostId,
|
|
1029
|
+
personaKey: run.personaKey ?? null,
|
|
1030
|
+
runId: run.id,
|
|
1031
|
+
sessionId: run.sessionId || null,
|
|
1032
|
+
status: run.status,
|
|
1033
|
+
createdAt: run.createdAt,
|
|
1034
|
+
lastUpdatedAt,
|
|
1035
|
+
messages: run.messages.map((message) => ({
|
|
1036
|
+
role: message.role,
|
|
1037
|
+
text: message.text,
|
|
1038
|
+
at: Date.parse(message.createdAt) || Date.now(),
|
|
1039
|
+
})),
|
|
1040
|
+
events: run.events.map((event) => ({
|
|
1041
|
+
channel: event.channel,
|
|
1042
|
+
text: event.text,
|
|
1043
|
+
})),
|
|
1044
|
+
artifacts: run.artifacts || [],
|
|
1045
|
+
reviewHandoff: run.reviewHandoff || null,
|
|
1046
|
+
compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
|
|
1047
|
+
compareRunId: run.compareRunId || null,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
persistRunConversation(run, activeId) {
|
|
1051
|
+
try {
|
|
1052
|
+
this.conversationStore.upsertConversation(run.projectPath, this.conversationRecordFromRun(run), activeId);
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// Issue #512 (S3, R13) — derive the four Get-started step states, then let any
|
|
1059
|
+
// persisted `true` in ~/.fraim/{install-state,preferences}.json override the
|
|
1060
|
+
// derivation (so a completed step stays completed even if its signal vanishes).
|
|
1061
|
+
computeFirstRun(projectPath, jobCount, personas) {
|
|
1062
|
+
const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
1063
|
+
// company = any organization-layer context written by org onboarding.
|
|
1064
|
+
const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
|
|
1065
|
+
// hire = any persona hired (entitlement active).
|
|
1066
|
+
const hireDone = personas.some((p) => p.status === 'hired');
|
|
1067
|
+
// project = a project brief exists OR the workspace has FRAIM jobs set up.
|
|
1068
|
+
const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
|
|
1069
|
+
const derived = {
|
|
1070
|
+
install: true, // the Hub is running, so the toolchain install step is satisfied.
|
|
1071
|
+
company: companyDone,
|
|
1072
|
+
hire: hireDone,
|
|
1073
|
+
project: projectDone,
|
|
1074
|
+
};
|
|
1075
|
+
const persisted = readPersistedFirstRun();
|
|
1076
|
+
return {
|
|
1077
|
+
install: persisted.install ?? derived.install,
|
|
1078
|
+
company: persisted.company || derived.company,
|
|
1079
|
+
hire: persisted.hire || derived.hire,
|
|
1080
|
+
project: persisted.project || derived.project,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
// Issue #512 (S3, R3) — presence + display paths of the three-layer context
|
|
1084
|
+
// files. Reuses the learning-context-builder resolvers (same layering as the
|
|
1085
|
+
// auto-load Team Context block).
|
|
1086
|
+
computeTeamContext(projectPath) {
|
|
1087
|
+
return (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
1088
|
+
}
|
|
1089
|
+
// Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
|
|
1090
|
+
// registry catalog counts. A read projection; no new storage.
|
|
1091
|
+
computeBrain(projectPath, jobCount) {
|
|
1092
|
+
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, getHubUserEmail());
|
|
1093
|
+
return {
|
|
1094
|
+
learnings,
|
|
1095
|
+
catalog: {
|
|
1096
|
+
jobs: jobCount,
|
|
1097
|
+
skills: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'skills')) +
|
|
1098
|
+
countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'skills')),
|
|
1099
|
+
rules: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'rules')) +
|
|
1100
|
+
countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'rules')),
|
|
1101
|
+
},
|
|
589
1102
|
};
|
|
590
1103
|
}
|
|
591
1104
|
resolveHubJob(projectPath, jobId) {
|
|
@@ -597,16 +1110,61 @@ class AiHubServer {
|
|
|
597
1110
|
return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
|
|
598
1111
|
return null;
|
|
599
1112
|
}
|
|
1113
|
+
// Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
|
|
1114
|
+
// content) export routes so a conversational deliverable with no on-disk file
|
|
1115
|
+
// can still be downloaded for Word annotation.
|
|
1116
|
+
async markdownToDocxBuffer(md) {
|
|
1117
|
+
const html = md
|
|
1118
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1119
|
+
// headings
|
|
1120
|
+
.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
|
1121
|
+
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
1122
|
+
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
1123
|
+
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
1124
|
+
// bold, italic, code
|
|
1125
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
1126
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
1127
|
+
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
1128
|
+
// unordered lists
|
|
1129
|
+
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
|
|
1130
|
+
// horizontal rules
|
|
1131
|
+
.replace(/^---+$/gm, '<hr/>')
|
|
1132
|
+
// paragraphs (double newlines)
|
|
1133
|
+
.replace(/\n{2,}/g, '</p><p>')
|
|
1134
|
+
.replace(/^/, '<p>')
|
|
1135
|
+
.replace(/$/, '</p>')
|
|
1136
|
+
// clean up list items into a ul
|
|
1137
|
+
.replace(/(<li>.+<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`);
|
|
1138
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
1139
|
+
const htmlToDocx = require('html-to-docx');
|
|
1140
|
+
return await htmlToDocx(`<html><body>${html}</body></html>`, null, {
|
|
1141
|
+
table: { row: { cantSplit: true } },
|
|
1142
|
+
footer: false,
|
|
1143
|
+
pageNumber: false,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
600
1146
|
prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
|
|
601
1147
|
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
602
1148
|
const resolvedJobId = explicit?.jobId || selectedJobId;
|
|
603
1149
|
if (!resolvedJobId) {
|
|
604
1150
|
throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
|
|
605
1151
|
}
|
|
1152
|
+
// #521: `display` is what the manager's conversation bubble shows — only their own
|
|
1153
|
+
// words. The FRAIM invocation (`$fraim <job>`), the stub path, and the shared-
|
|
1154
|
+
// browser / communication-style notes are system context for the AGENT, kept out
|
|
1155
|
+
// of the bubble so the thread isn't cluttered with machinery. `message` (with all
|
|
1156
|
+
// of it) is what the agent actually receives.
|
|
1157
|
+
const display = (explicit?.remainder || instructions || '').trim();
|
|
1158
|
+
// #521: the shared-browser guidance is injected HERE, at the Hub layer — never
|
|
1159
|
+
// baked into the registry job/skill. It only appears when a shared browser is
|
|
1160
|
+
// available (env published at boot).
|
|
1161
|
+
const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
1162
|
+
const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
606
1163
|
if (resolvedJobId === '__freeform__') {
|
|
607
1164
|
return {
|
|
608
1165
|
jobId: resolvedJobId,
|
|
609
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions),
|
|
1166
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
|
|
1167
|
+
display,
|
|
610
1168
|
};
|
|
611
1169
|
}
|
|
612
1170
|
const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
|
|
@@ -615,7 +1173,8 @@ class AiHubServer {
|
|
|
615
1173
|
: undefined;
|
|
616
1174
|
return {
|
|
617
1175
|
jobId: resolvedJobId,
|
|
618
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath),
|
|
1176
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
|
|
1177
|
+
display,
|
|
619
1178
|
};
|
|
620
1179
|
}
|
|
621
1180
|
prepareContinueMessage(run, instructions, coachingJobId) {
|
|
@@ -625,7 +1184,16 @@ class AiHubServer {
|
|
|
625
1184
|
// invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
|
|
626
1185
|
// passes raw invocation syntax.
|
|
627
1186
|
const effectiveJobId = coachingJobId || run.jobId;
|
|
628
|
-
|
|
1187
|
+
// #521: `display` = the manager's own words for the conversation bubble. When the
|
|
1188
|
+
// manager typed nothing (a quick-coach button), fall back to the bare invocation
|
|
1189
|
+
// so the bubble still shows what was triggered — but never the style note.
|
|
1190
|
+
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1191
|
+
const userText = (explicit?.remainder || instructions || '').trim();
|
|
1192
|
+
const display = userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
|
|
1193
|
+
return {
|
|
1194
|
+
message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
|
|
1195
|
+
display,
|
|
1196
|
+
};
|
|
629
1197
|
}
|
|
630
1198
|
async computePersonas(apiKey) {
|
|
631
1199
|
const allBundles = listHubPersonaBundles();
|
|
@@ -637,16 +1205,36 @@ class AiHubServer {
|
|
|
637
1205
|
pricingLabel: bundle.catalogMetadata.pricingLabel,
|
|
638
1206
|
status: 'locked',
|
|
639
1207
|
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1208
|
+
seatCount: 0,
|
|
1209
|
+
seatsInUse: 0,
|
|
640
1210
|
}));
|
|
641
|
-
if (!
|
|
642
|
-
return { personas: fallbackPersonas, subscriptionActive: false };
|
|
1211
|
+
if (!this.dbService) {
|
|
1212
|
+
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1213
|
+
}
|
|
643
1214
|
try {
|
|
644
|
-
const state = await getHubWorkspacePersonaState(this.dbService,
|
|
645
|
-
if (!state)
|
|
646
|
-
return { personas: fallbackPersonas, subscriptionActive: false };
|
|
1215
|
+
const state = await getHubWorkspacePersonaState(this.dbService, getHubUserEmail(), apiKey ?? '');
|
|
1216
|
+
if (!state) {
|
|
1217
|
+
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1218
|
+
}
|
|
647
1219
|
const hiredKeys = new Set(state.entitlements
|
|
648
1220
|
.filter((e) => e.status === 'active')
|
|
649
1221
|
.map((e) => e.personaKey));
|
|
1222
|
+
// Build per-persona seat counts from entitlement records.
|
|
1223
|
+
// jobCreditsRemaining represents the number of job-credits (seats) purchased.
|
|
1224
|
+
const seatCountByKey = {};
|
|
1225
|
+
for (const e of state.entitlements) {
|
|
1226
|
+
if (e.status === 'active') {
|
|
1227
|
+
seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
// seatsInUse is the count of manager-assignment rows for this workspace/persona.
|
|
1231
|
+
const workspaceId = state.workspaceId;
|
|
1232
|
+
const seatsInUseByKey = {};
|
|
1233
|
+
if (this.dbService) {
|
|
1234
|
+
await Promise.all(Object.keys(seatCountByKey).map(async (pKey) => {
|
|
1235
|
+
seatsInUseByKey[pKey] = await this.dbService.countHubManagerAssignments(workspaceId, pKey);
|
|
1236
|
+
}));
|
|
1237
|
+
}
|
|
650
1238
|
const personas = allBundles.map((bundle) => ({
|
|
651
1239
|
key: bundle.personaKey,
|
|
652
1240
|
displayName: bundle.catalogMetadata.displayName,
|
|
@@ -655,15 +1243,51 @@ class AiHubServer {
|
|
|
655
1243
|
pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
|
|
656
1244
|
status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
|
|
657
1245
|
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1246
|
+
seatCount: seatCountByKey[bundle.personaKey] ?? 0,
|
|
1247
|
+
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
658
1248
|
}));
|
|
659
|
-
return { personas, subscriptionActive: state.subscriptionActive };
|
|
1249
|
+
return { personas, subscriptionActive: state.subscriptionActive, workspaceId, userKey: state.userId ?? null };
|
|
660
1250
|
}
|
|
661
1251
|
catch (err) {
|
|
662
1252
|
console.error('[ai-hub] persona entitlement lookup failed:', err);
|
|
663
|
-
return { personas: fallbackPersonas, subscriptionActive: false };
|
|
1253
|
+
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async computeManagerTeam(workspaceId, userKey) {
|
|
1257
|
+
if (!workspaceId || !userKey || !this.dbService)
|
|
1258
|
+
return [];
|
|
1259
|
+
try {
|
|
1260
|
+
const rows = await this.dbService.getHubManagerAssignments(workspaceId, userKey);
|
|
1261
|
+
return rows.map((r) => ({ personaKey: r.personaKey, assignedAt: r.assignedAt.toISOString() }));
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
return [];
|
|
664
1265
|
}
|
|
665
1266
|
}
|
|
666
1267
|
registerRoutes() {
|
|
1268
|
+
// Issue #512: Serve the account and analytics pages from public/.
|
|
1269
|
+
// These live outside of /ai-hub so they need explicit routes.
|
|
1270
|
+
const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
|
|
1271
|
+
this.app.get('/account', (_req, res) => {
|
|
1272
|
+
const filePath = path_1.default.join(publicDir, 'account', 'index.html');
|
|
1273
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
1274
|
+
res.sendFile(filePath);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
res.status(404).send('Account page not found.');
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
|
|
1281
|
+
this.app.get('/analytics', (_req, res) => {
|
|
1282
|
+
const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
|
|
1283
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
1284
|
+
res.sendFile(filePath);
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
res.status(404).send('Analytics page not found.');
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
|
|
667
1291
|
// Issue #478: Serve the PowerPoint task pane HTML and manifest.
|
|
668
1292
|
// Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
|
|
669
1293
|
// request, so we must strip them before resolving the file path. Use a custom
|
|
@@ -719,6 +1343,160 @@ class AiHubServer {
|
|
|
719
1343
|
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
720
1344
|
res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
|
|
721
1345
|
});
|
|
1346
|
+
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
1347
|
+
// same projection folded into bootstrap. Useful for the avatar→Brain view
|
|
1348
|
+
// without re-fetching the whole bootstrap payload.
|
|
1349
|
+
this.app.get('/api/ai-hub/brain', (req, res) => {
|
|
1350
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
1351
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
1352
|
+
: this.projectPath;
|
|
1353
|
+
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
|
|
1354
|
+
return res.json(this.computeBrain(projectPath, jobCount));
|
|
1355
|
+
});
|
|
1356
|
+
// #533: read the PRESERVED learnings for a section + storage level so the
|
|
1357
|
+
// Company/Manager sections (machine level) and the project workspace (project
|
|
1358
|
+
// level) can DISPLAY and edit them.
|
|
1359
|
+
const VALID_SCOPES = ['org', 'manager', 'reverse'];
|
|
1360
|
+
const VALID_LEVELS = ['machine', 'project'];
|
|
1361
|
+
const VALID_CATEGORIES = ['avoid', 'preference', 'repeat', 'coaching'];
|
|
1362
|
+
const resolveProjectPath = (req) => typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
1363
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
1364
|
+
: (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
|
|
1365
|
+
? path_1.default.resolve(req.body.projectPath)
|
|
1366
|
+
: this.projectPath);
|
|
1367
|
+
this.app.get('/api/ai-hub/learnings', (req, res) => {
|
|
1368
|
+
const scope = req.query.scope;
|
|
1369
|
+
if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
|
|
1370
|
+
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
1371
|
+
}
|
|
1372
|
+
const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
|
|
1373
|
+
? req.query.level : 'machine';
|
|
1374
|
+
try {
|
|
1375
|
+
const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), getHubUserEmail(), scope, level);
|
|
1376
|
+
return res.json({ scope, level, entries });
|
|
1377
|
+
}
|
|
1378
|
+
catch (error) {
|
|
1379
|
+
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
// #533 §3: add / edit / delete a single learning entry — writes the real file
|
|
1383
|
+
// at the section's level (machine for the Manager/Company tabs, project for the
|
|
1384
|
+
// project workspace). The action targets exactly the card's file.
|
|
1385
|
+
this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
|
|
1386
|
+
const b = (req.body || {});
|
|
1387
|
+
if (!['add', 'edit', 'delete'].includes(b.action || '')) {
|
|
1388
|
+
return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
|
|
1389
|
+
}
|
|
1390
|
+
if (!b.scope || !VALID_SCOPES.includes(b.scope))
|
|
1391
|
+
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
1392
|
+
if (!b.category || !VALID_CATEGORIES.includes(b.category))
|
|
1393
|
+
return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
|
|
1394
|
+
const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
|
|
1395
|
+
const ref = { scope: b.scope, level, category: b.category };
|
|
1396
|
+
try {
|
|
1397
|
+
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), getHubUserEmail(), ref, b.action, {
|
|
1398
|
+
originalTitle: b.originalTitle,
|
|
1399
|
+
severity: b.severity,
|
|
1400
|
+
title: b.title,
|
|
1401
|
+
body: b.body,
|
|
1402
|
+
});
|
|
1403
|
+
return res.json({ ok: true, path: result.path });
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
// Issue #540: POST /api/ai-hub/manager-team/assign
|
|
1410
|
+
// Assigns a persona to the authenticated manager's team (uses X-Fraim-Api-Key header).
|
|
1411
|
+
// Returns 404 {error:'no_company_seat'} when the workspace has purchased no seats for the persona.
|
|
1412
|
+
// Returns 409 {error:'out_of_stock'} when all company seats are already assigned to other managers.
|
|
1413
|
+
this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
|
|
1414
|
+
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
1415
|
+
const { personaKey } = (req.body ?? {});
|
|
1416
|
+
if (!personaKey)
|
|
1417
|
+
return res.status(400).json({ error: 'personaKey required' });
|
|
1418
|
+
if (!this.dbService) {
|
|
1419
|
+
return res.status(404).json({ error: 'no_company_seat', personaKey });
|
|
1420
|
+
}
|
|
1421
|
+
try {
|
|
1422
|
+
const { workspaceId, userKey, personas } = await this.computePersonas(apiKey);
|
|
1423
|
+
if (!workspaceId || !userKey) {
|
|
1424
|
+
return res.status(404).json({ error: 'no_company_seat', personaKey });
|
|
1425
|
+
}
|
|
1426
|
+
const persona = personas.find((p) => p.key === personaKey);
|
|
1427
|
+
if (!persona || persona.seatCount === 0) {
|
|
1428
|
+
return res.status(404).json({ error: 'no_company_seat', personaKey });
|
|
1429
|
+
}
|
|
1430
|
+
if (persona.seatsInUse >= persona.seatCount) {
|
|
1431
|
+
return res.status(409).json({ error: 'out_of_stock', personaKey, seatCount: persona.seatCount });
|
|
1432
|
+
}
|
|
1433
|
+
await this.dbService.addHubManagerAssignment(workspaceId, userKey, personaKey);
|
|
1434
|
+
const team = await this.computeManagerTeam(workspaceId, userKey);
|
|
1435
|
+
return res.json({ managerTeam: team });
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
console.error('[ai-hub] assign manager seat failed:', err);
|
|
1439
|
+
return res.status(500).json({ error: 'internal_error' });
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
// Issue #540: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
1443
|
+
// Removes a persona from the authenticated manager's team.
|
|
1444
|
+
this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
|
|
1445
|
+
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
1446
|
+
const { personaKey } = req.params;
|
|
1447
|
+
if (!this.dbService)
|
|
1448
|
+
return res.status(204).end();
|
|
1449
|
+
try {
|
|
1450
|
+
const { workspaceId, userKey } = await this.computePersonas(apiKey);
|
|
1451
|
+
if (workspaceId && userKey) {
|
|
1452
|
+
await this.dbService.removeHubManagerAssignment(workspaceId, userKey, personaKey);
|
|
1453
|
+
}
|
|
1454
|
+
return res.status(204).end();
|
|
1455
|
+
}
|
|
1456
|
+
catch (err) {
|
|
1457
|
+
console.error('[ai-hub] remove manager seat failed:', err);
|
|
1458
|
+
return res.status(500).json({ error: 'internal_error' });
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
this.app.get('/api/ai-hub/conversations', (req, res) => {
|
|
1462
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
1463
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
1464
|
+
: this.projectPath;
|
|
1465
|
+
const loaded = this.conversationStore.loadProject(projectPath);
|
|
1466
|
+
return res.json({ projectPath, ...loaded, source: 'disk' });
|
|
1467
|
+
});
|
|
1468
|
+
this.app.put('/api/ai-hub/conversations', (req, res) => {
|
|
1469
|
+
try {
|
|
1470
|
+
const body = (req.body ?? {});
|
|
1471
|
+
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
1472
|
+
if (!Array.isArray(body.conversations)) {
|
|
1473
|
+
return res.status(400).json({ error: 'conversations array required' });
|
|
1474
|
+
}
|
|
1475
|
+
const saved = this.conversationStore.replaceProject(projectPath, {
|
|
1476
|
+
activeId: body.activeId ?? null,
|
|
1477
|
+
conversations: body.conversations,
|
|
1478
|
+
});
|
|
1479
|
+
return res.json({ projectPath, ...saved, source: 'disk' });
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
|
|
1486
|
+
try {
|
|
1487
|
+
const body = (req.body ?? {});
|
|
1488
|
+
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
1489
|
+
const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
|
|
1490
|
+
if (body.activeId !== undefined) {
|
|
1491
|
+
const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
|
|
1492
|
+
return res.json({ projectPath, ...withActive, source: 'disk' });
|
|
1493
|
+
}
|
|
1494
|
+
return res.json({ projectPath, ...saved, source: 'disk' });
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
722
1500
|
this.app.post('/api/ai-hub/api-key', (req, res) => {
|
|
723
1501
|
const { apiKey } = req.body;
|
|
724
1502
|
if (!apiKey || typeof apiKey !== 'string')
|
|
@@ -737,6 +1515,8 @@ class AiHubServer {
|
|
|
737
1515
|
try {
|
|
738
1516
|
const projectPath = await this.folderPicker();
|
|
739
1517
|
if (!projectPath) {
|
|
1518
|
+
// User cancelled the native dialog, or no interactive dialog was
|
|
1519
|
+
// available (e.g. CI/headless). Front-end treats 204 as "no change".
|
|
740
1520
|
return res.status(204).end();
|
|
741
1521
|
}
|
|
742
1522
|
return res.json({ path: projectPath });
|
|
@@ -745,6 +1525,152 @@ class AiHubServer {
|
|
|
745
1525
|
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
|
|
746
1526
|
}
|
|
747
1527
|
});
|
|
1528
|
+
// ── Issue #512 R3/R8: Team Context inline read/write ────────────────────
|
|
1529
|
+
// GET /api/ai-hub/context[?key=<key>] → file content for the editor.
|
|
1530
|
+
// With ?key=<one of eight>: { key, present, displayPath, scope, content }.
|
|
1531
|
+
// Without key: { files: { <key>: {present, displayPath, scope, content} } }.
|
|
1532
|
+
// POST /api/ai-hub/context { key, content } → persists to the correct
|
|
1533
|
+
// destination (user-level for org/manager/orgRules/managerRules unless a repo-local
|
|
1534
|
+
// override exists; repo-local for project*), creating parent dirs.
|
|
1535
|
+
// Loopback-only (the Hub binds 127.0.0.1); writes are constrained to live
|
|
1536
|
+
// under a personalized-employee/ directory (path-traversal guard).
|
|
1537
|
+
const readContextFile = (projectPath, key) => {
|
|
1538
|
+
const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, key);
|
|
1539
|
+
let content = '';
|
|
1540
|
+
if (loc.present && loc.readPath) {
|
|
1541
|
+
try {
|
|
1542
|
+
content = fs_1.default.readFileSync(loc.readPath, 'utf8');
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
content = '';
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
key,
|
|
1550
|
+
present: loc.present,
|
|
1551
|
+
displayPath: loc.displayPath,
|
|
1552
|
+
scope: loc.scope,
|
|
1553
|
+
content,
|
|
1554
|
+
};
|
|
1555
|
+
};
|
|
1556
|
+
this.app.get('/api/ai-hub/context', (req, res) => {
|
|
1557
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
1558
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
1559
|
+
: this.projectPath;
|
|
1560
|
+
const rawKey = typeof req.query.key === 'string' ? req.query.key : undefined;
|
|
1561
|
+
if (rawKey !== undefined) {
|
|
1562
|
+
if (!(0, learning_context_builder_1.isTeamContextKey)(rawKey)) {
|
|
1563
|
+
return res.status(400).json({ error: `Unknown context key: ${rawKey}` });
|
|
1564
|
+
}
|
|
1565
|
+
return res.json(readContextFile(projectPath, rawKey));
|
|
1566
|
+
}
|
|
1567
|
+
const keys = ['org', 'manager', 'orgRules', 'managerRules', 'projectContext', 'projectBrief', 'projectRules', 'projectQa'];
|
|
1568
|
+
const files = {};
|
|
1569
|
+
for (const key of keys)
|
|
1570
|
+
files[key] = readContextFile(projectPath, key);
|
|
1571
|
+
return res.json({ files });
|
|
1572
|
+
});
|
|
1573
|
+
this.app.post('/api/ai-hub/context', (req, res) => {
|
|
1574
|
+
const body = (req.body ?? {});
|
|
1575
|
+
if (!(0, learning_context_builder_1.isTeamContextKey)(body.key)) {
|
|
1576
|
+
return res.status(400).json({ error: 'key must be one of org|manager|orgRules|managerRules|projectContext|projectBrief|projectRules|projectQa.' });
|
|
1577
|
+
}
|
|
1578
|
+
if (typeof body.content !== 'string') {
|
|
1579
|
+
return res.status(400).json({ error: 'content (string) is required.' });
|
|
1580
|
+
}
|
|
1581
|
+
const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
|
|
1582
|
+
? path_1.default.resolve(body.projectPath)
|
|
1583
|
+
: this.projectPath;
|
|
1584
|
+
const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
|
|
1585
|
+
const dest = path_1.default.resolve(loc.writePath);
|
|
1586
|
+
// Path-traversal guard: the resolved destination must live under a
|
|
1587
|
+
// personalized-employee directory (covers both ~/.fraim/... and repo-local).
|
|
1588
|
+
const segments = dest.split(path_1.default.sep);
|
|
1589
|
+
if (!segments.includes('personalized-employee')) {
|
|
1590
|
+
return res.status(403).json({ error: 'Write destination outside personalized-employee.' });
|
|
1591
|
+
}
|
|
1592
|
+
try {
|
|
1593
|
+
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
|
1594
|
+
fs_1.default.writeFileSync(dest, body.content, 'utf8');
|
|
1595
|
+
}
|
|
1596
|
+
catch (err) {
|
|
1597
|
+
return res.status(500).json({ error: err instanceof Error ? err.message : 'Write failed.' });
|
|
1598
|
+
}
|
|
1599
|
+
// Re-read so the client gets the canonical post-write state (present flips).
|
|
1600
|
+
return res.json(readContextFile(projectPath, body.key));
|
|
1601
|
+
});
|
|
1602
|
+
// ── Issue #512 R7: Artifact export (md → docx) ──────────────────────────
|
|
1603
|
+
// GET /api/ai-hub/artifact/export-docx?path=<abs-path-to-md>
|
|
1604
|
+
// Converts a markdown file to .docx using html-to-docx and streams the result.
|
|
1605
|
+
// The manager downloads it, annotates in Word, and saves it in place on disk
|
|
1606
|
+
// (no upload). The agent then reads the comments + tracked changes via the
|
|
1607
|
+
// `apply-docx-changes-to-md` skill (registry script extract-docx-edits.js)
|
|
1608
|
+
// during the address-feedback phase.
|
|
1609
|
+
this.app.get('/api/ai-hub/artifact/export-docx', async (req, res) => {
|
|
1610
|
+
const rawPath = typeof req.query.path === 'string' ? req.query.path : '';
|
|
1611
|
+
if (!rawPath)
|
|
1612
|
+
return res.status(400).json({ error: 'path is required.' });
|
|
1613
|
+
// Safety: path must be under the current workspace or a known safe root.
|
|
1614
|
+
const resolved = resolveSafeArtifactPath(rawPath, this.projectPath);
|
|
1615
|
+
if (!resolved)
|
|
1616
|
+
return res.status(403).json({ error: 'Path outside allowed roots.' });
|
|
1617
|
+
if (!fs_1.default.existsSync(resolved))
|
|
1618
|
+
return res.status(404).json({ error: 'File not found.' });
|
|
1619
|
+
try {
|
|
1620
|
+
const md = fs_1.default.readFileSync(resolved, 'utf8');
|
|
1621
|
+
const docxBuf = await this.markdownToDocxBuffer(md);
|
|
1622
|
+
const basename = path_1.default.basename(resolved, path_1.default.extname(resolved));
|
|
1623
|
+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
1624
|
+
res.setHeader('Content-Disposition', `attachment; filename="${basename}.docx"`);
|
|
1625
|
+
res.send(docxBuf);
|
|
1626
|
+
}
|
|
1627
|
+
catch (err) {
|
|
1628
|
+
res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
// POST /api/ai-hub/artifact/export-docx { content, filename? }
|
|
1632
|
+
// Converts inline markdown (the employee's conversational deliverable) to .docx
|
|
1633
|
+
// when the run produced no on-disk file — e.g. an onboarding answer to an empty
|
|
1634
|
+
// repo. Keeps the "annotate in Word" review flow working instead of erroring
|
|
1635
|
+
// with "no local artifact path available".
|
|
1636
|
+
this.app.post('/api/ai-hub/artifact/export-docx', async (req, res) => {
|
|
1637
|
+
const content = typeof req.body?.content === 'string' ? req.body.content : '';
|
|
1638
|
+
if (!content.trim())
|
|
1639
|
+
return res.status(400).json({ error: 'content is required.' });
|
|
1640
|
+
const rawName = typeof req.body?.filename === 'string' && req.body.filename.trim()
|
|
1641
|
+
? req.body.filename.trim()
|
|
1642
|
+
: 'deliverable';
|
|
1643
|
+
const safeName = rawName.replace(/[^a-zA-Z0-9._ -]/g, '').replace(/\.docx?$/i, '').slice(0, 80) || 'deliverable';
|
|
1644
|
+
try {
|
|
1645
|
+
const docxBuf = await this.markdownToDocxBuffer(content);
|
|
1646
|
+
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
1647
|
+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.docx"`);
|
|
1648
|
+
res.send(docxBuf);
|
|
1649
|
+
}
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
this.app.post('/api/ai-hub/artifact/open', (req, res) => {
|
|
1655
|
+
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
|
|
1656
|
+
const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
|
|
1657
|
+
? path_1.default.resolve(req.body.projectPath)
|
|
1658
|
+
: this.projectPath;
|
|
1659
|
+
if (!rawPath)
|
|
1660
|
+
return res.status(400).json({ error: 'path is required.' });
|
|
1661
|
+
const resolved = resolveSafeArtifactPath(rawPath, projectPath);
|
|
1662
|
+
if (!resolved)
|
|
1663
|
+
return res.status(403).json({ error: 'Path outside allowed roots.' });
|
|
1664
|
+
if (!fs_1.default.existsSync(resolved))
|
|
1665
|
+
return res.status(404).json({ error: 'File not found.' });
|
|
1666
|
+
try {
|
|
1667
|
+
hubOpenFile(resolved);
|
|
1668
|
+
return res.json({ ok: true, path: resolved });
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
return res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to open artifact.' });
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
748
1674
|
this.app.post('/api/ai-hub/install-agent', async (req, res) => {
|
|
749
1675
|
const { hubId } = req.body;
|
|
750
1676
|
if (!hubId)
|
|
@@ -834,22 +1760,29 @@ class AiHubServer {
|
|
|
834
1760
|
: {
|
|
835
1761
|
jobId: req.body.jobId,
|
|
836
1762
|
message: legacyMessage,
|
|
1763
|
+
display: legacyMessage,
|
|
837
1764
|
};
|
|
838
1765
|
const jobId = prepared.jobId;
|
|
839
1766
|
const message = prepared.message;
|
|
1767
|
+
// #521: store only the manager's own words in the conversation; the agent
|
|
1768
|
+
// still receives `message` (with the Hub-injected invocation + notes).
|
|
1769
|
+
const managerDisplay = prepared.display || message;
|
|
840
1770
|
if (!jobId) {
|
|
841
1771
|
throw new Error('Choose a FRAIM job before starting a run.');
|
|
842
1772
|
}
|
|
843
1773
|
const startTimestamp = new Date().toISOString();
|
|
844
1774
|
const run = {
|
|
845
1775
|
id: (0, crypto_1.randomUUID)(),
|
|
1776
|
+
conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
|
|
1777
|
+
conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
|
|
1778
|
+
jobTitle: typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim() ? req.body.jobTitle.trim() : jobId,
|
|
846
1779
|
jobId,
|
|
847
1780
|
hostId,
|
|
848
1781
|
projectPath,
|
|
849
1782
|
status: 'running',
|
|
850
1783
|
createdAt: startTimestamp,
|
|
851
1784
|
updatedAt: startTimestamp,
|
|
852
|
-
messages: [(0, hosts_1.createHubMessage)('manager',
|
|
1785
|
+
messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
|
|
853
1786
|
events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
|
|
854
1787
|
// Issue #347 — seed phase + totals state on creation.
|
|
855
1788
|
currentPhase: null,
|
|
@@ -857,14 +1790,11 @@ class AiHubServer {
|
|
|
857
1790
|
totals: emptyTotals(),
|
|
858
1791
|
lastStatusChangeAt: startTimestamp,
|
|
859
1792
|
personaKey: getProtectedPersonaForHubJob(jobId),
|
|
860
|
-
// Gemini CLI does not emit a session ID in its output stream;
|
|
861
|
-
// pre-seed one so the Send button is enabled and the continue
|
|
862
|
-
// endpoint can proceed. continueRun for Gemini ignores it.
|
|
863
|
-
...(hostId === 'gemini' ? { sessionId: (0, crypto_1.randomUUID)() } : {}),
|
|
864
1793
|
// Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
|
|
865
1794
|
...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
|
|
866
1795
|
};
|
|
867
1796
|
this.runRegistry.create(run, {});
|
|
1797
|
+
this.persistRunConversation(run, run.conversationId || run.id);
|
|
868
1798
|
// Issue #442: create the Direct (B) run before spawning either process
|
|
869
1799
|
// so we can cross-link both runs via compareRunId before any events arrive.
|
|
870
1800
|
// directMsg is the plain user instructions — no FRAIM invocation prefix.
|
|
@@ -903,10 +1833,11 @@ class AiHubServer {
|
|
|
903
1833
|
this.runRegistry.update(run.id, (current) => {
|
|
904
1834
|
if (event.sessionId)
|
|
905
1835
|
current.sessionId = event.sessionId;
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (event.raw)
|
|
1836
|
+
appendHostMessage(current, hostId, event, channel);
|
|
1837
|
+
if (event.raw) {
|
|
909
1838
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1839
|
+
applyReviewProjection(current, event.raw);
|
|
1840
|
+
}
|
|
910
1841
|
if (event.agentIdentity)
|
|
911
1842
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
912
1843
|
if (event.seekMentoring)
|
|
@@ -914,16 +1845,29 @@ class AiHubServer {
|
|
|
914
1845
|
if (event.usage)
|
|
915
1846
|
applyUsageSignal(current, event.usage);
|
|
916
1847
|
});
|
|
1848
|
+
const updated = this.runRegistry.get(run.id);
|
|
1849
|
+
if (updated)
|
|
1850
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
917
1851
|
},
|
|
918
1852
|
onExit: (exitCode) => {
|
|
919
1853
|
this.runRegistry.update(run.id, (current) => {
|
|
920
1854
|
current.exitCode = exitCode;
|
|
921
|
-
current.
|
|
922
|
-
|
|
1855
|
+
if (current.stoppedByUser) {
|
|
1856
|
+
// Manager stopped it — park in "waiting on you", don't call it a failure.
|
|
1857
|
+
current.status = 'failed';
|
|
1858
|
+
current.events.push((0, hosts_1.createHubEvent)('system', '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'));
|
|
1859
|
+
}
|
|
1860
|
+
else {
|
|
1861
|
+
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1862
|
+
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
1863
|
+
}
|
|
923
1864
|
});
|
|
1865
|
+
const updated = this.runRegistry.get(run.id);
|
|
1866
|
+
if (updated)
|
|
1867
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
924
1868
|
this.runRegistry.dispose(run.id);
|
|
925
1869
|
},
|
|
926
|
-
});
|
|
1870
|
+
}, run.sessionId);
|
|
927
1871
|
this.runRegistry.create(run, child);
|
|
928
1872
|
// Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
|
|
929
1873
|
// uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
|
|
@@ -935,8 +1879,7 @@ class AiHubServer {
|
|
|
935
1879
|
this.runRegistry.update(directId, (current) => {
|
|
936
1880
|
if (event.sessionId)
|
|
937
1881
|
current.sessionId = event.sessionId;
|
|
938
|
-
|
|
939
|
-
current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
1882
|
+
appendHostMessage(current, hostId, event, channel);
|
|
940
1883
|
if (event.raw)
|
|
941
1884
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
942
1885
|
if (event.agentIdentity)
|
|
@@ -953,7 +1896,7 @@ class AiHubServer {
|
|
|
953
1896
|
});
|
|
954
1897
|
this.runRegistry.dispose(directId);
|
|
955
1898
|
},
|
|
956
|
-
});
|
|
1899
|
+
}, directRun.sessionId);
|
|
957
1900
|
this.runRegistry.create(directRun, directChild);
|
|
958
1901
|
}
|
|
959
1902
|
const existingPreferences = this.preferencesStore.load(projectPath);
|
|
@@ -962,7 +1905,7 @@ class AiHubServer {
|
|
|
962
1905
|
projectPath,
|
|
963
1906
|
employeeId: hostId,
|
|
964
1907
|
recentJobIds: existingPreferences.recentJobIds,
|
|
965
|
-
}, jobId);
|
|
1908
|
+
}, jobId, typeof instructions === 'string' ? instructions : undefined);
|
|
966
1909
|
const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
|
|
967
1910
|
const responsePayload = directRun
|
|
968
1911
|
? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
|
|
@@ -981,6 +1924,60 @@ class AiHubServer {
|
|
|
981
1924
|
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
|
|
982
1925
|
}
|
|
983
1926
|
});
|
|
1927
|
+
// #521: Shared persistent browser. start() launches (or reuses) the one
|
|
1928
|
+
// FRAIM-owned Chrome/Edge and publishes its CDP endpoint to agents via env so
|
|
1929
|
+
// the browser-use skill connects to it instead of launching a throwaway one.
|
|
1930
|
+
this.app.get('/api/ai-hub/browser/status', async (_req, res) => {
|
|
1931
|
+
const running = await this.managedBrowser.isRunning();
|
|
1932
|
+
res.json({ running, ...this.managedBrowser.status() });
|
|
1933
|
+
});
|
|
1934
|
+
this.app.post('/api/ai-hub/browser/start', async (_req, res) => {
|
|
1935
|
+
try {
|
|
1936
|
+
const result = await this.ensureManagedBrowser();
|
|
1937
|
+
res.json({
|
|
1938
|
+
ok: true,
|
|
1939
|
+
...result,
|
|
1940
|
+
loginHint: 'Log into your sites in the FRAIM browser window — the session persists across agent turns.',
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
catch (err) {
|
|
1944
|
+
res.status(500).json({ error: err instanceof Error ? err.message : 'Could not start the browser.' });
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
this.app.post('/api/ai-hub/browser/stop', (_req, res) => {
|
|
1948
|
+
// Kill the process; keep FRAIM_BROWSER_CDP_ENDPOINT published — it's the
|
|
1949
|
+
// deterministic location, so the agent can re-ensure and reconnect later.
|
|
1950
|
+
this.managedBrowser.stop();
|
|
1951
|
+
res.json({ ok: true });
|
|
1952
|
+
});
|
|
1953
|
+
// #521: Stop — the manager interrupts a running agent. Kills the process and
|
|
1954
|
+
// parks the run in a "waiting on you" state so they can give the next
|
|
1955
|
+
// instruction (continue) or leave it. Idempotent for already-finished runs.
|
|
1956
|
+
this.app.post('/api/ai-hub/runs/:runId/stop', (req, res) => {
|
|
1957
|
+
const run = this.runRegistry.get(req.params.runId);
|
|
1958
|
+
if (!run) {
|
|
1959
|
+
return res.status(404).json({ error: 'Run not found.' });
|
|
1960
|
+
}
|
|
1961
|
+
if (run.status !== 'running') {
|
|
1962
|
+
// Nothing to stop — return the current state unchanged.
|
|
1963
|
+
return res.json(this.enrichRunForResponse(run));
|
|
1964
|
+
}
|
|
1965
|
+
this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
|
|
1966
|
+
const killed = this.runRegistry.stop(run.id);
|
|
1967
|
+
// Park it immediately (don't wait for onExit, which may lag or not fire on a
|
|
1968
|
+
// host that already detached). onExit, if it fires, keeps this same state.
|
|
1969
|
+
this.runRegistry.update(run.id, (current) => {
|
|
1970
|
+
current.status = 'failed';
|
|
1971
|
+
current.events.push((0, hosts_1.createHubEvent)('system', killed
|
|
1972
|
+
? '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'
|
|
1973
|
+
: '⏹ Stop requested. The employee was already wrapping up — send the next instruction to continue.'));
|
|
1974
|
+
});
|
|
1975
|
+
const stopped = this.runRegistry.get(run.id);
|
|
1976
|
+
if (stopped)
|
|
1977
|
+
this.persistRunConversation(stopped, stopped.conversationId || stopped.id);
|
|
1978
|
+
this.runRegistry.dispose(run.id);
|
|
1979
|
+
return res.json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
|
|
1980
|
+
});
|
|
984
1981
|
this.app.post('/api/ai-hub/runs/:runId/messages', (req, res) => {
|
|
985
1982
|
try {
|
|
986
1983
|
const run = this.runRegistry.get(req.params.runId);
|
|
@@ -995,28 +1992,34 @@ class AiHubServer {
|
|
|
995
1992
|
// When coachingJobId is present (user picked a manager template via the UI),
|
|
996
1993
|
// it overrides the run's own jobId in the invocation. The server always adds
|
|
997
1994
|
// the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
|
|
998
|
-
const
|
|
1995
|
+
const prepared = instructions
|
|
999
1996
|
? this.prepareContinueMessage(run, instructions, coachingJobId)
|
|
1000
1997
|
: coachingJobId
|
|
1001
1998
|
? this.prepareContinueMessage(run, '', coachingJobId)
|
|
1002
|
-
: (req.body.message || '').trim();
|
|
1999
|
+
: { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
|
|
2000
|
+
const message = prepared.message;
|
|
1003
2001
|
if (!message) {
|
|
1004
2002
|
return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
|
|
1005
2003
|
}
|
|
1006
2004
|
this.runRegistry.update(run.id, (current) => {
|
|
1007
2005
|
current.status = 'running';
|
|
1008
|
-
|
|
2006
|
+
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
2007
|
+
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
|
|
1009
2008
|
});
|
|
2009
|
+
const started = this.runRegistry.get(run.id);
|
|
2010
|
+
if (started)
|
|
2011
|
+
this.persistRunConversation(started, started.conversationId || started.id);
|
|
1010
2012
|
this.runRegistry.create(run, {});
|
|
1011
2013
|
const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
|
|
1012
2014
|
onEvent: (event, channel) => {
|
|
1013
2015
|
this.runRegistry.update(run.id, (current) => {
|
|
1014
2016
|
if (event.sessionId)
|
|
1015
2017
|
current.sessionId = event.sessionId;
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
if (event.raw)
|
|
2018
|
+
appendHostMessage(current, run.hostId, event, channel);
|
|
2019
|
+
if (event.raw) {
|
|
1019
2020
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2021
|
+
applyReviewProjection(current, event.raw);
|
|
2022
|
+
}
|
|
1020
2023
|
if (event.agentIdentity)
|
|
1021
2024
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1022
2025
|
if (event.seekMentoring)
|
|
@@ -1024,6 +2027,9 @@ class AiHubServer {
|
|
|
1024
2027
|
if (event.usage)
|
|
1025
2028
|
applyUsageSignal(current, event.usage);
|
|
1026
2029
|
});
|
|
2030
|
+
const updated = this.runRegistry.get(run.id);
|
|
2031
|
+
if (updated)
|
|
2032
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1027
2033
|
},
|
|
1028
2034
|
onExit: (exitCode) => {
|
|
1029
2035
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -1031,6 +2037,9 @@ class AiHubServer {
|
|
|
1031
2037
|
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1032
2038
|
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
1033
2039
|
});
|
|
2040
|
+
const updated = this.runRegistry.get(run.id);
|
|
2041
|
+
if (updated)
|
|
2042
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1034
2043
|
this.runRegistry.dispose(run.id);
|
|
1035
2044
|
},
|
|
1036
2045
|
});
|
|
@@ -1042,6 +2051,93 @@ class AiHubServer {
|
|
|
1042
2051
|
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
|
|
1043
2052
|
}
|
|
1044
2053
|
});
|
|
2054
|
+
// #521: resume a conversation whose Hub run was lost (e.g. a server restart)
|
|
2055
|
+
// but whose agent session still exists on disk. Recreates a run bound to the
|
|
2056
|
+
// existing sessionId and continues it via the host's resume path — so a
|
|
2057
|
+
// conversation can be carried forward without losing its context.
|
|
2058
|
+
this.app.post('/api/ai-hub/runs/resume', (req, res) => {
|
|
2059
|
+
try {
|
|
2060
|
+
const body = (req.body ?? {});
|
|
2061
|
+
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2062
|
+
const hostId = body.hostId;
|
|
2063
|
+
const sessionId = (body.sessionId || '').trim();
|
|
2064
|
+
const jobId = (body.jobId || '').trim();
|
|
2065
|
+
const instructions = (body.instructions || '').trim();
|
|
2066
|
+
const coachingJobId = body.coachingJobId?.trim() || undefined;
|
|
2067
|
+
if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
|
|
2068
|
+
throw new Error('Choose an available employee to resume.');
|
|
2069
|
+
}
|
|
2070
|
+
if (!sessionId)
|
|
2071
|
+
throw new Error('A host sessionId is required to resume.');
|
|
2072
|
+
if (!jobId)
|
|
2073
|
+
throw new Error('A jobId is required to resume.');
|
|
2074
|
+
if (!instructions)
|
|
2075
|
+
throw new Error('Provide an instruction to continue.');
|
|
2076
|
+
const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
|
|
2077
|
+
if (!employee?.available) {
|
|
2078
|
+
throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
|
|
2079
|
+
}
|
|
2080
|
+
const now = new Date().toISOString();
|
|
2081
|
+
const run = {
|
|
2082
|
+
id: (0, crypto_1.randomUUID)(),
|
|
2083
|
+
conversationId: typeof body.conversationId === 'string' && body.conversationId.trim() ? body.conversationId.trim() : undefined,
|
|
2084
|
+
conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
|
|
2085
|
+
jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
|
|
2086
|
+
jobId, hostId, projectPath, status: 'running', sessionId,
|
|
2087
|
+
createdAt: now, updatedAt: now, messages: [],
|
|
2088
|
+
events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
|
|
2089
|
+
currentPhase: null, phaseHistory: [], totals: emptyTotals(), lastStatusChangeAt: now,
|
|
2090
|
+
personaKey: getProtectedPersonaForHubJob(jobId),
|
|
2091
|
+
};
|
|
2092
|
+
// Continue-turn message (FRAIM invocation for the job + instructions) plus
|
|
2093
|
+
// the shared-browser note so the resumed agent knows about it.
|
|
2094
|
+
const preparedResume = this.prepareContinueMessage(run, instructions, coachingJobId);
|
|
2095
|
+
const message = preparedResume.message
|
|
2096
|
+
+ (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
2097
|
+
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
2098
|
+
run.messages.push((0, hosts_1.createHubMessage)('manager', preparedResume.display || message));
|
|
2099
|
+
this.runRegistry.create(run, {});
|
|
2100
|
+
this.persistRunConversation(run, run.conversationId || run.id);
|
|
2101
|
+
const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
|
|
2102
|
+
onEvent: (event, channel) => {
|
|
2103
|
+
this.runRegistry.update(run.id, (current) => {
|
|
2104
|
+
if (event.sessionId)
|
|
2105
|
+
current.sessionId = event.sessionId;
|
|
2106
|
+
appendHostMessage(current, hostId, event, channel);
|
|
2107
|
+
if (event.raw) {
|
|
2108
|
+
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2109
|
+
applyReviewProjection(current, event.raw);
|
|
2110
|
+
}
|
|
2111
|
+
if (event.agentIdentity)
|
|
2112
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2113
|
+
if (event.seekMentoring)
|
|
2114
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
2115
|
+
if (event.usage)
|
|
2116
|
+
applyUsageSignal(current, event.usage);
|
|
2117
|
+
});
|
|
2118
|
+
const updated = this.runRegistry.get(run.id);
|
|
2119
|
+
if (updated)
|
|
2120
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2121
|
+
},
|
|
2122
|
+
onExit: (exitCode) => {
|
|
2123
|
+
this.runRegistry.update(run.id, (current) => {
|
|
2124
|
+
current.exitCode = exitCode;
|
|
2125
|
+
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
2126
|
+
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
2127
|
+
});
|
|
2128
|
+
const updated = this.runRegistry.get(run.id);
|
|
2129
|
+
if (updated)
|
|
2130
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2131
|
+
this.runRegistry.dispose(run.id);
|
|
2132
|
+
},
|
|
2133
|
+
});
|
|
2134
|
+
this.runRegistry.create(run, child);
|
|
2135
|
+
res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
|
|
2136
|
+
}
|
|
2137
|
+
catch (error) {
|
|
2138
|
+
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not resume the conversation.' });
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
1045
2141
|
// Issue #442: continue the Direct (B) run without FRAIM MCP servers.
|
|
1046
2142
|
this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
|
|
1047
2143
|
try {
|
|
@@ -1065,8 +2161,7 @@ class AiHubServer {
|
|
|
1065
2161
|
this.runRegistry.update(run.id, (current) => {
|
|
1066
2162
|
if (event.sessionId)
|
|
1067
2163
|
current.sessionId = event.sessionId;
|
|
1068
|
-
|
|
1069
|
-
current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
2164
|
+
appendHostMessage(current, run.hostId, event, channel);
|
|
1070
2165
|
if (event.raw)
|
|
1071
2166
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1072
2167
|
if (event.usage)
|
|
@@ -1158,8 +2253,7 @@ class AiHubServer {
|
|
|
1158
2253
|
this.runRegistry.update(run.id, (current) => {
|
|
1159
2254
|
if (event.sessionId)
|
|
1160
2255
|
current.sessionId = event.sessionId;
|
|
1161
|
-
|
|
1162
|
-
current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
2256
|
+
appendHostMessage(current, hostId, event, channel);
|
|
1163
2257
|
if (event.raw)
|
|
1164
2258
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1165
2259
|
if (event.agentIdentity)
|
|
@@ -1178,7 +2272,7 @@ class AiHubServer {
|
|
|
1178
2272
|
});
|
|
1179
2273
|
this.runRegistry.dispose(run.id);
|
|
1180
2274
|
},
|
|
1181
|
-
});
|
|
2275
|
+
}, run.sessionId);
|
|
1182
2276
|
// Update the registry entry with the real child process handle.
|
|
1183
2277
|
this.runRegistry.create(run, child);
|
|
1184
2278
|
return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
|
|
@@ -1188,6 +2282,14 @@ class AiHubServer {
|
|
|
1188
2282
|
}
|
|
1189
2283
|
});
|
|
1190
2284
|
}
|
|
2285
|
+
// #521: start (or reuse) the shared browser and publish its CDP endpoint so
|
|
2286
|
+
// every agent spawned afterward inherits FRAIM_BROWSER_CDP_ENDPOINT and its
|
|
2287
|
+
// browser-use skill connects to the long-lived window instead of a throwaway.
|
|
2288
|
+
async ensureManagedBrowser() {
|
|
2289
|
+
const result = await this.managedBrowser.start();
|
|
2290
|
+
process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
|
|
2291
|
+
return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
|
|
2292
|
+
}
|
|
1191
2293
|
// Issue #347 — assemble the read-side projection of a run. Stages are
|
|
1192
2294
|
// derived from job frontmatter + visited phases; totalDurationMs ticks
|
|
1193
2295
|
// forward while the run is still running so the UI's totals line
|
|
@@ -1233,8 +2335,15 @@ class AiHubServer {
|
|
|
1233
2335
|
}
|
|
1234
2336
|
exports.AiHubServer = AiHubServer;
|
|
1235
2337
|
async function findAvailablePort(preferredPort) {
|
|
2338
|
+
return findAvailablePortExcluding(preferredPort, new Set());
|
|
2339
|
+
}
|
|
2340
|
+
async function findAvailablePortExcluding(preferredPort, excludedPorts) {
|
|
1236
2341
|
let port = preferredPort;
|
|
1237
2342
|
while (port < preferredPort + 20) {
|
|
2343
|
+
if (excludedPorts.has(port)) {
|
|
2344
|
+
port += 1;
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
1238
2347
|
const available = await new Promise((resolve) => {
|
|
1239
2348
|
const server = net_1.default.createServer();
|
|
1240
2349
|
server.once('error', () => resolve(false));
|
|
@@ -1290,36 +2399,105 @@ function resolveTaskpaneDir(pane) {
|
|
|
1290
2399
|
}
|
|
1291
2400
|
return taskpaneDir;
|
|
1292
2401
|
}
|
|
2402
|
+
/**
|
|
2403
|
+
* Open the OS-native "choose a folder" dialog and resolve to the selected
|
|
2404
|
+
* absolute path, or null if the user cancelled / no dialog was available.
|
|
2405
|
+
*
|
|
2406
|
+
* This is the genuinely native picker: it spawns the platform's own folder
|
|
2407
|
+
* chooser (WinForms FolderBrowserDialog on Windows, `choose folder` on macOS,
|
|
2408
|
+
* zenity/kdialog on Linux). The Hub runs on loopback on the user's machine, so
|
|
2409
|
+
* it can launch the real dialog itself.
|
|
2410
|
+
*
|
|
2411
|
+
* Implementation notes (kept in lockstep with src/first-run/server.ts, which
|
|
2412
|
+
* proved this out — see the long-form comment there):
|
|
2413
|
+
* - ASYNC (`spawn`, not `spawnSync`). The dialog blocks until the user
|
|
2414
|
+
* dismisses it, which can be many seconds. With `spawnSync` the entire Node
|
|
2415
|
+
* event loop freezes for that whole time — every other Hub HTTP request
|
|
2416
|
+
* (bootstrap, run polling) stalls and the Hub looks dead. `spawn` keeps the
|
|
2417
|
+
* server responsive while the dialog is up.
|
|
2418
|
+
* - Windows needs `-STA` (Single-Threaded Apartment) for FolderBrowserDialog
|
|
2419
|
+
* to work reliably, plus a hidden TopMost `$owner` form so the picker comes
|
|
2420
|
+
* to the foreground instead of opening behind the browser window.
|
|
2421
|
+
* - A hard timeout guarantees the endpoint can never hang forever: if nothing
|
|
2422
|
+
* is selected within the window (or the spawn wedges) we resolve null and
|
|
2423
|
+
* the caller degrades to "no change".
|
|
2424
|
+
* - NON-INTERACTIVE GUARD: a real OS folder dialog blocks waiting for a human.
|
|
2425
|
+
* In CI / automated UI tests (which DO reach this live endpoint) that would
|
|
2426
|
+
* hang. When AI_HUB_DISABLE_NATIVE_PICKER is set (or NODE_ENV=test) we skip
|
|
2427
|
+
* the dialog entirely and resolve null, so the endpoint returns 204 without
|
|
2428
|
+
* ever popping a window. Real desktop/loopback usage leaves it unset and
|
|
2429
|
+
* gets the genuine native dialog.
|
|
2430
|
+
*/
|
|
1293
2431
|
function pickProjectPath() {
|
|
2432
|
+
if (process.env.AI_HUB_DISABLE_NATIVE_PICKER === '1' ||
|
|
2433
|
+
process.env.NODE_ENV === 'test') {
|
|
2434
|
+
return Promise.resolve(null);
|
|
2435
|
+
}
|
|
2436
|
+
if (process.platform === 'win32') {
|
|
2437
|
+
const script = [
|
|
2438
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
2439
|
+
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
2440
|
+
'$dialog.Description = "Select a FRAIM project folder"',
|
|
2441
|
+
'$dialog.ShowNewFolderButton = $true',
|
|
2442
|
+
// Hidden owner form forces the dialog above the user's browser. Without
|
|
2443
|
+
// this the dialog often appears behind the browser tab and the user sees
|
|
2444
|
+
// nothing happen when they click the folder button.
|
|
2445
|
+
'$owner = New-Object System.Windows.Forms.Form',
|
|
2446
|
+
'$owner.TopMost = $true',
|
|
2447
|
+
'$owner.ShowInTaskbar = $false',
|
|
2448
|
+
'if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {',
|
|
2449
|
+
' Write-Output $dialog.SelectedPath',
|
|
2450
|
+
'}',
|
|
2451
|
+
'$owner.Dispose()',
|
|
2452
|
+
].join('; ');
|
|
2453
|
+
return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
|
|
2454
|
+
}
|
|
2455
|
+
if (process.platform === 'darwin') {
|
|
2456
|
+
return runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")']);
|
|
2457
|
+
}
|
|
2458
|
+
return runPickerProcess('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null']);
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* Spawn a native folder-dialog process and resolve to its trimmed stdout (the
|
|
2462
|
+
* chosen path) or null. Hardened so it can never hang the server: any spawn
|
|
2463
|
+
* error resolves null, and a hard timeout kills a wedged child and resolves
|
|
2464
|
+
* null. The timeout is generous (the user may take a while to browse) but
|
|
2465
|
+
* finite.
|
|
2466
|
+
*/
|
|
2467
|
+
function runPickerProcess(command, args, timeoutMs = 5 * 60_000) {
|
|
1294
2468
|
return new Promise((resolve) => {
|
|
1295
|
-
let
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
else if (process.platform === 'darwin') {
|
|
1310
|
-
cmd = 'osascript';
|
|
1311
|
-
args = ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'];
|
|
2469
|
+
let settled = false;
|
|
2470
|
+
const finish = (value) => {
|
|
2471
|
+
if (settled)
|
|
2472
|
+
return;
|
|
2473
|
+
settled = true;
|
|
2474
|
+
clearTimeout(timer);
|
|
2475
|
+
resolve(value);
|
|
2476
|
+
};
|
|
2477
|
+
let proc;
|
|
2478
|
+
try {
|
|
2479
|
+
proc = (0, child_process_1.spawn)(command, args, {
|
|
2480
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2481
|
+
windowsHide: true,
|
|
2482
|
+
});
|
|
1312
2483
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
2484
|
+
catch {
|
|
2485
|
+
finish(null);
|
|
2486
|
+
return;
|
|
1316
2487
|
}
|
|
2488
|
+
const timer = setTimeout(() => {
|
|
2489
|
+
try {
|
|
2490
|
+
proc.kill();
|
|
2491
|
+
}
|
|
2492
|
+
catch {
|
|
2493
|
+
/* ignore */
|
|
2494
|
+
}
|
|
2495
|
+
finish(null);
|
|
2496
|
+
}, timeoutMs);
|
|
1317
2497
|
let stdout = '';
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
});
|
|
1323
|
-
child.on('error', () => resolve(null));
|
|
2498
|
+
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
2499
|
+
proc.stderr?.on('data', () => { });
|
|
2500
|
+
proc.on('close', () => finish(stdout.trim() || null));
|
|
2501
|
+
proc.on('error', () => finish(null));
|
|
1324
2502
|
});
|
|
1325
2503
|
}
|