fraim-hub 2.0.228 → 2.0.231
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 +1 -1
- package/dist/src/ai-hub/custom-employees.js +148 -0
- package/dist/src/ai-hub/hosts.js +46 -0
- package/dist/src/ai-hub/preferences.js +1 -1
- package/dist/src/ai-hub/server.js +198 -32
- package/dist/src/first-run/types.js +9 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +65 -0
- package/public/ai-hub/script.js +914 -46
- package/public/ai-hub/styles.css +389 -0
|
@@ -757,7 +757,7 @@ class AiHubConversationStore {
|
|
|
757
757
|
}
|
|
758
758
|
// Update the index header for one conversation and return the (header-shaped) project state.
|
|
759
759
|
reindexAfterUpsert(bucketDir, bucketKey, conv, activeId) {
|
|
760
|
-
const idx = this.
|
|
760
|
+
const idx = this.loadIndex(bucketDir, bucketKey);
|
|
761
761
|
const headers = idx.headers.filter((h) => h.id !== conv.id);
|
|
762
762
|
headers.push(toHeader(conv));
|
|
763
763
|
headers.sort(newestFirst);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readCustomEmployees = readCustomEmployees;
|
|
7
|
+
exports.writeCustomEmployee = writeCustomEmployee;
|
|
8
|
+
exports.deleteCustomEmployee = deleteCustomEmployee;
|
|
9
|
+
exports.slugifyDisplayName = slugifyDisplayName;
|
|
10
|
+
exports.resolveEmployee = resolveEmployee;
|
|
11
|
+
exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
|
|
12
|
+
// Issue #945: read/write store for user-authored employees, slug utilities,
|
|
13
|
+
// and the display/attribution resolver union.
|
|
14
|
+
//
|
|
15
|
+
// INVARIANT: resolveEmployee is only for display and run attribution.
|
|
16
|
+
// Entitlement reads (isPersonaHireKey, getProtectedPersonaForJob,
|
|
17
|
+
// evaluatePersonaAccess) must never call resolveEmployee.
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const persona_hiring_1 = require("../config/persona-hiring");
|
|
21
|
+
const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
|
|
22
|
+
function employeesDir(projectDir) {
|
|
23
|
+
return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
|
|
24
|
+
}
|
|
25
|
+
function slugFor(key) {
|
|
26
|
+
// key is 'custom:<slug>' — file is named '<slug>.json'
|
|
27
|
+
return key.replace(/^custom:/, '');
|
|
28
|
+
}
|
|
29
|
+
function safeSlug(key) {
|
|
30
|
+
// Reject keys whose slug component contains path separators or traversal sequences.
|
|
31
|
+
const slug = slugFor(key);
|
|
32
|
+
if (!slug || /[/\\]|\.\./.test(slug))
|
|
33
|
+
return null;
|
|
34
|
+
return slug;
|
|
35
|
+
}
|
|
36
|
+
function filePath(projectDir, key) {
|
|
37
|
+
const slug = safeSlug(key);
|
|
38
|
+
if (!slug)
|
|
39
|
+
return null;
|
|
40
|
+
return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
|
|
41
|
+
}
|
|
42
|
+
function readCustomEmployees(projectDir) {
|
|
43
|
+
const dir = employeesDir(projectDir);
|
|
44
|
+
if (!fs_1.default.existsSync(dir))
|
|
45
|
+
return [];
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const file of fs_1.default.readdirSync(dir)) {
|
|
48
|
+
if (!file.endsWith('.json'))
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
|
|
54
|
+
results.push(parsed);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// silently skip malformed files
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
function writeCustomEmployee(projectDir, employee) {
|
|
64
|
+
const fp = filePath(projectDir, employee.key);
|
|
65
|
+
if (!fp)
|
|
66
|
+
throw new Error(`Invalid employee key: ${employee.key}`);
|
|
67
|
+
const dir = employeesDir(projectDir);
|
|
68
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
69
|
+
fs_1.default.writeFileSync(fp, JSON.stringify(employee, null, 2), 'utf8');
|
|
70
|
+
}
|
|
71
|
+
function deleteCustomEmployee(projectDir, key) {
|
|
72
|
+
const fp = filePath(projectDir, key);
|
|
73
|
+
if (!fp || !fs_1.default.existsSync(fp))
|
|
74
|
+
return false;
|
|
75
|
+
fs_1.default.unlinkSync(fp);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function slugifyDisplayName(displayName, existingKeys = []) {
|
|
79
|
+
const base = displayName
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
82
|
+
.replace(/^-+|-+$/g, '')
|
|
83
|
+
|| 'employee';
|
|
84
|
+
let candidate = `custom:${base}`;
|
|
85
|
+
if (!existingKeys.includes(candidate))
|
|
86
|
+
return candidate;
|
|
87
|
+
let i = 2;
|
|
88
|
+
while (existingKeys.includes(`${candidate}-${i}`))
|
|
89
|
+
i++;
|
|
90
|
+
return `${candidate}-${i}`;
|
|
91
|
+
}
|
|
92
|
+
function resolveEmployee(key, customEmployees) {
|
|
93
|
+
// Custom: key starts with 'custom:'
|
|
94
|
+
if (key.startsWith('custom:')) {
|
|
95
|
+
const emp = customEmployees.find((e) => e.key === key);
|
|
96
|
+
if (!emp)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
key: emp.key,
|
|
100
|
+
displayName: emp.displayName,
|
|
101
|
+
role: emp.role,
|
|
102
|
+
avatarUrl: buildCustomAvatarUrl(emp),
|
|
103
|
+
origin: 'custom',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Catalog
|
|
107
|
+
if ((0, persona_hiring_1.isPersonaHireKey)(key)) {
|
|
108
|
+
const entry = persona_hiring_1.PERSONA_HIRE_CATALOG[key];
|
|
109
|
+
return {
|
|
110
|
+
key,
|
|
111
|
+
displayName: entry.displayName,
|
|
112
|
+
role: entry.role,
|
|
113
|
+
avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(key),
|
|
114
|
+
origin: 'catalog',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Converts a CustomEmployee to an AiHubPersona for bootstrap / rail rendering.
|
|
120
|
+
// status is always 'hired' (no lock on custom employees); no hireUrl; no seat accounting.
|
|
121
|
+
// jobIds is included so the UI can scope the delegate picker without a second fetch.
|
|
122
|
+
function buildCustomEmployeePersona(emp) {
|
|
123
|
+
return {
|
|
124
|
+
key: emp.key,
|
|
125
|
+
displayName: emp.displayName,
|
|
126
|
+
role: emp.role,
|
|
127
|
+
avatarUrl: buildCustomAvatarUrl(emp),
|
|
128
|
+
pricingLabel: '',
|
|
129
|
+
status: 'hired',
|
|
130
|
+
hireUrl: '',
|
|
131
|
+
seatCount: 0,
|
|
132
|
+
seatsInUse: 0,
|
|
133
|
+
origin: 'custom',
|
|
134
|
+
jobIds: emp.jobIds,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Builds an avatar URL for a custom employee.
|
|
138
|
+
// - 'emoji': renders a simple letter-based DiceBear avatar seeded on the display name
|
|
139
|
+
// - 'generated': uses the value as the DiceBear seed directly
|
|
140
|
+
// - 'image': returns an empty string (image upload is deferred; caller falls back to icon)
|
|
141
|
+
function buildCustomAvatarUrl(emp) {
|
|
142
|
+
const { icon, displayName } = emp;
|
|
143
|
+
if (icon.kind === 'image')
|
|
144
|
+
return '';
|
|
145
|
+
const seed = icon.kind === 'generated' ? icon.value : displayName;
|
|
146
|
+
const params = new URLSearchParams({ seed, backgroundColor: 'e0e7ff', radius: '50' });
|
|
147
|
+
return `https://api.dicebear.com/9.x/notionists/svg?${params.toString()}`;
|
|
148
|
+
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -683,12 +683,15 @@ const EMPLOYEE_LABELS = {
|
|
|
683
683
|
claude: 'Claude Code',
|
|
684
684
|
gemini: 'Gemini CLI',
|
|
685
685
|
copilot: 'GitHub Copilot CLI',
|
|
686
|
+
antigravity: 'Antigravity CLI',
|
|
686
687
|
};
|
|
687
688
|
// GitHub Copilot CLI binary name after `npm install -g @github/copilot`.
|
|
688
689
|
// The @github/copilot package installs a binary named `copilot` on PATH.
|
|
689
690
|
// Note: the package name is @github/copilot (NOT @github/copilot-cli which
|
|
690
691
|
// does not exist on npm). The binary is `copilot` (NOT `github-copilot-cli`).
|
|
691
692
|
const COPILOT_BINARY = 'copilot';
|
|
693
|
+
// Issue #928: agy has no npm package; installed via https://antigravity.google/cli/
|
|
694
|
+
const AGY_BINARY = 'agy';
|
|
692
695
|
const executableName = (command) => command;
|
|
693
696
|
function quoteWindowsArg(value) {
|
|
694
697
|
if (value.length === 0) {
|
|
@@ -721,6 +724,8 @@ const availableByVersionProbe = (command) => {
|
|
|
721
724
|
function agentBinaryName(id) {
|
|
722
725
|
if (id === 'copilot')
|
|
723
726
|
return COPILOT_BINARY;
|
|
727
|
+
if (id === 'antigravity')
|
|
728
|
+
return AGY_BINARY;
|
|
724
729
|
return executableName(id);
|
|
725
730
|
}
|
|
726
731
|
function detectEmployees() {
|
|
@@ -989,6 +994,18 @@ function buildStartPlan(hostId, message, sessionId) {
|
|
|
989
994
|
env: browser.env,
|
|
990
995
|
};
|
|
991
996
|
}
|
|
997
|
+
// Issue #928: agy (Antigravity CLI) is TUI-only — no subprocess/headless mode.
|
|
998
|
+
// supportsSubprocessStream:false is a capability flag for future Branch A
|
|
999
|
+
// (subprocess mode). Currently unused by spawnHostProcess; TUI output is
|
|
1000
|
+
// handled gracefully by the antigravity branch in parseHostLine.
|
|
1001
|
+
if (hostId === 'antigravity') {
|
|
1002
|
+
return {
|
|
1003
|
+
command: AGY_BINARY,
|
|
1004
|
+
args: ['--dangerously-skip-permissions'],
|
|
1005
|
+
stdin: transformHeadlessFraimMessage(message, 'start'),
|
|
1006
|
+
supportsSubprocessStream: false,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
992
1009
|
const browser = sharedBrowserHostConfig('claude');
|
|
993
1010
|
return {
|
|
994
1011
|
command: executableName('claude'),
|
|
@@ -1038,6 +1055,15 @@ function buildContinuePlan(hostId, sessionId, message) {
|
|
|
1038
1055
|
env: browser.env,
|
|
1039
1056
|
};
|
|
1040
1057
|
}
|
|
1058
|
+
// Issue #928: agy resume uses --conversation <sessionId>.
|
|
1059
|
+
if (hostId === 'antigravity') {
|
|
1060
|
+
return {
|
|
1061
|
+
command: AGY_BINARY,
|
|
1062
|
+
args: ['--dangerously-skip-permissions', '--conversation', sessionId],
|
|
1063
|
+
stdin: transformHeadlessFraimMessage(message, 'continue'),
|
|
1064
|
+
supportsSubprocessStream: false,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1041
1067
|
const browser = sharedBrowserHostConfig('claude');
|
|
1042
1068
|
return {
|
|
1043
1069
|
command: executableName('claude'),
|
|
@@ -1097,6 +1123,14 @@ function buildDirectStartPlan(hostId, message, sessionId) {
|
|
|
1097
1123
|
stdin: DIRECT_PREAMBLE + message,
|
|
1098
1124
|
};
|
|
1099
1125
|
}
|
|
1126
|
+
if (hostId === 'antigravity') {
|
|
1127
|
+
return {
|
|
1128
|
+
command: AGY_BINARY,
|
|
1129
|
+
args: ['--dangerously-skip-permissions'],
|
|
1130
|
+
stdin: DIRECT_PREAMBLE + message,
|
|
1131
|
+
supportsSubprocessStream: false,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1100
1134
|
return {
|
|
1101
1135
|
command: executableName('claude'),
|
|
1102
1136
|
args: [
|
|
@@ -1204,6 +1238,11 @@ function parseHostLine(hostId, line) {
|
|
|
1204
1238
|
return withSignal({ raw: trimmed });
|
|
1205
1239
|
}
|
|
1206
1240
|
catch {
|
|
1241
|
+
// Issue #928: detect IneligibleTierError before other notice checks so
|
|
1242
|
+
// the Hub can surface the Antigravity migration prompt.
|
|
1243
|
+
if (trimmed.startsWith('IneligibleTierError')) {
|
|
1244
|
+
return withSignal({ raw: trimmed, geminiDeprecated: true });
|
|
1245
|
+
}
|
|
1207
1246
|
if (isGeminiCliNotice(trimmed)) {
|
|
1208
1247
|
return withSignal({ raw: trimmed });
|
|
1209
1248
|
}
|
|
@@ -1211,6 +1250,11 @@ function parseHostLine(hostId, line) {
|
|
|
1211
1250
|
return withSignal(message ? { message, raw: trimmed } : { raw: trimmed });
|
|
1212
1251
|
}
|
|
1213
1252
|
}
|
|
1253
|
+
// Issue #928: antigravity (agy) is TUI-only. Return raw for any line received;
|
|
1254
|
+
// TUI output is not a structured event stream.
|
|
1255
|
+
if (hostId === 'antigravity') {
|
|
1256
|
+
return withSignal({ raw: trimmed });
|
|
1257
|
+
}
|
|
1214
1258
|
// GitHub Copilot CLI output: JSON stream where each event carries a `type`
|
|
1215
1259
|
// field. Known event shapes (from the agentic CLI stream):
|
|
1216
1260
|
// { "type": "session.started", "session_id": "..." } — session id
|
|
@@ -1441,6 +1485,7 @@ class FakeHostRuntime {
|
|
|
1441
1485
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
1442
1486
|
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
1443
1487
|
{ id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
|
|
1488
|
+
{ id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Test double agent tool.', supportsRaw: false },
|
|
1444
1489
|
];
|
|
1445
1490
|
// Remembered across turns like a resumed agent session: the job label from the
|
|
1446
1491
|
// start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
|
|
@@ -1531,6 +1576,7 @@ class ScriptedHostRuntime {
|
|
|
1531
1576
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1532
1577
|
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1533
1578
|
{ id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1579
|
+
{ id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Scripted test double.', supportsRaw: false },
|
|
1534
1580
|
];
|
|
1535
1581
|
// Track each active run so the test can emit signals at it. The Hub
|
|
1536
1582
|
// passes the run id as the requested start session id in test/demo paths;
|
|
@@ -147,7 +147,7 @@ class AiHubPreferencesStore {
|
|
|
147
147
|
const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths);
|
|
148
148
|
return {
|
|
149
149
|
projectPath: raw.projectPath || projectPath,
|
|
150
|
-
employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
|
|
150
|
+
employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot' || raw.employeeId === 'antigravity') ? raw.employeeId : DEFAULT_EMPLOYEE,
|
|
151
151
|
categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
|
|
152
152
|
recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
|
|
153
153
|
recentJobInstructions: (typeof raw.recentJobInstructions === 'object' && raw.recentJobInstructions !== null && !Array.isArray(raw.recentJobInstructions))
|
|
@@ -51,6 +51,7 @@ const learning_context_builder_1 = require("../local-mcp-server/learning-context
|
|
|
51
51
|
const brand_store_1 = require("../core/brand-store");
|
|
52
52
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
53
53
|
const catalog_1 = require("./catalog");
|
|
54
|
+
const custom_employees_1 = require("./custom-employees");
|
|
54
55
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
55
56
|
const hosts_1 = require("./hosts");
|
|
56
57
|
const configured_agents_1 = require("./configured-agents");
|
|
@@ -66,6 +67,7 @@ const org_publish_1 = require("../cli/utils/org-publish");
|
|
|
66
67
|
const version_utils_1 = require("../cli/utils/version-utils");
|
|
67
68
|
const hub_latest_version_1 = require("./hub-latest-version");
|
|
68
69
|
const semver = __importStar(require("semver"));
|
|
70
|
+
const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
|
|
69
71
|
let personaHiringModule;
|
|
70
72
|
let managerHiringModule;
|
|
71
73
|
function loadPersonaHiringModule() {
|
|
@@ -311,7 +313,7 @@ class AiHubRunRegistry {
|
|
|
311
313
|
}
|
|
312
314
|
}
|
|
313
315
|
// ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
|
|
314
|
-
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
316
|
+
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot', 'antigravity'];
|
|
315
317
|
const SCHEDULED_FIRE_LEASE_TTL_MS = 2 * 60 * 1000;
|
|
316
318
|
const SCHEDULED_FIRE_LEASE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
317
319
|
function startSessionSeedForHost(hostId, runId) {
|
|
@@ -1777,13 +1779,16 @@ class AiHubServer {
|
|
|
1777
1779
|
this.managedBrowser.stop();
|
|
1778
1780
|
}
|
|
1779
1781
|
getHttpsPort() { return this.httpsPort; }
|
|
1780
|
-
knownProjects(projectPath, extras = []) {
|
|
1782
|
+
knownProjects(projectPath, extras = [], options = {}) {
|
|
1781
1783
|
// #866 R2: guard against path.resolve('') === cwd. When there is no active
|
|
1782
1784
|
// project, keep the path empty so no invocation-directory project is injected.
|
|
1783
1785
|
const activeProjectPath = projectPath || this.projectPath;
|
|
1784
1786
|
const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
|
|
1785
1787
|
const preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1786
|
-
const
|
|
1788
|
+
const includeConversationProjects = options.includeConversationProjects !== false;
|
|
1789
|
+
const conversationProjects = includeConversationProjects
|
|
1790
|
+
? this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }))
|
|
1791
|
+
: [];
|
|
1787
1792
|
return (0, preferences_1.normalizeAiHubProjectList)([
|
|
1788
1793
|
...(preferences.projects || []),
|
|
1789
1794
|
...conversationProjects,
|
|
@@ -1825,10 +1830,25 @@ class AiHubServer {
|
|
|
1825
1830
|
// Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
|
|
1826
1831
|
// override, no ai-hub-state.json copy, no fallback chain.
|
|
1827
1832
|
const resolvedApiKey = resolveApiKey();
|
|
1828
|
-
const
|
|
1829
|
-
|
|
1833
|
+
const managerTeamPromise = this.computeManagerTeam(resolvedApiKey).catch((err) => {
|
|
1834
|
+
console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
|
|
1835
|
+
return [];
|
|
1836
|
+
});
|
|
1837
|
+
const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise);
|
|
1838
|
+
const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey);
|
|
1839
|
+
const personaProjection = await this.withFirstPaintBudget(personaProjectionPromise, fallbackPersonaProjection, 'persona projection');
|
|
1840
|
+
const managerTeam = personaProjection === fallbackPersonaProjection
|
|
1841
|
+
? []
|
|
1842
|
+
: await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team');
|
|
1843
|
+
const { personas, subscriptionActive, workspaceId, userKey } = personaProjection;
|
|
1844
|
+
void personaProjectionPromise.catch(() => undefined);
|
|
1845
|
+
void managerTeamPromise.catch(() => undefined);
|
|
1830
1846
|
const resolvedUserEmail = userKey ?? null;
|
|
1831
|
-
|
|
1847
|
+
// Issue #975: do not scan every conversation-store bucket on first paint.
|
|
1848
|
+
// Large long-lived Hub installs can have thousands of buckets; the Projects
|
|
1849
|
+
// API still performs the full merge, and the client refreshes it after the
|
|
1850
|
+
// shell is visible.
|
|
1851
|
+
const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
|
|
1832
1852
|
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1833
1853
|
this.preferencesStore.save(preferences);
|
|
1834
1854
|
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
@@ -1986,11 +2006,15 @@ class AiHubServer {
|
|
|
1986
2006
|
...(run.pauseReason !== undefined && { pauseReason: run.pauseReason }),
|
|
1987
2007
|
createdAt: run.createdAt,
|
|
1988
2008
|
lastUpdatedAt,
|
|
1989
|
-
messages: run.messages.map((message) =>
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2009
|
+
messages: run.messages.map((message) => {
|
|
2010
|
+
const at = Date.parse(message.createdAt);
|
|
2011
|
+
return {
|
|
2012
|
+
role: message.role,
|
|
2013
|
+
text: message.text,
|
|
2014
|
+
createdAt: message.createdAt,
|
|
2015
|
+
...(Number.isFinite(at) && at > 0 ? { at } : {}),
|
|
2016
|
+
};
|
|
2017
|
+
}),
|
|
1994
2018
|
events: run.events.map((event) => ({
|
|
1995
2019
|
channel: event.channel,
|
|
1996
2020
|
text: event.text,
|
|
@@ -2550,19 +2574,9 @@ class AiHubServer {
|
|
|
2550
2574
|
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
2551
2575
|
return { message, display };
|
|
2552
2576
|
}
|
|
2553
|
-
async computePersonas(apiKey) {
|
|
2577
|
+
async computePersonas(apiKey, managerTeamPromise) {
|
|
2554
2578
|
const allBundles = listHubPersonaBundles();
|
|
2555
|
-
const
|
|
2556
|
-
key: bundle.personaKey,
|
|
2557
|
-
displayName: bundle.catalogMetadata.displayName,
|
|
2558
|
-
role: bundle.catalogMetadata.role,
|
|
2559
|
-
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
2560
|
-
pricingLabel: bundle.catalogMetadata.pricingLabel,
|
|
2561
|
-
status: 'locked',
|
|
2562
|
-
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2563
|
-
seatCount: 0,
|
|
2564
|
-
seatsInUse: 0,
|
|
2565
|
-
}));
|
|
2579
|
+
const fallbackProjection = this.fallbackPersonaProjection(apiKey);
|
|
2566
2580
|
// Issue #701: persona state comes from the hosted server (GET /api/personas/me)
|
|
2567
2581
|
// via the user's API key — never a local MongoDB connection. Issue #925: resolve
|
|
2568
2582
|
// it in a transport-aware way so a transient outage does not collapse to the same
|
|
@@ -2579,15 +2593,16 @@ class AiHubServer {
|
|
|
2579
2593
|
// (feature-off, legacy bypass, per-entitlement gating) lives solely in
|
|
2580
2594
|
// persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
|
|
2581
2595
|
// re-derive it.
|
|
2596
|
+
const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
|
|
2582
2597
|
if (!state) {
|
|
2583
|
-
return {
|
|
2598
|
+
return { ...fallbackProjection, userKey };
|
|
2584
2599
|
}
|
|
2585
2600
|
try {
|
|
2586
2601
|
const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
|
|
2587
2602
|
// seatsInUse (manager-team assignments) is display-only accounting fetched
|
|
2588
2603
|
// separately; it is not part of the access verdict.
|
|
2589
2604
|
const seatsInUseByKey = {};
|
|
2590
|
-
const team = await this.remoteGateway.listManagerTeam(apiKey);
|
|
2605
|
+
const team = managerTeamPromise ? await managerTeamPromise : await this.remoteGateway.listManagerTeam(apiKey);
|
|
2591
2606
|
for (const entry of team) {
|
|
2592
2607
|
seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
|
|
2593
2608
|
}
|
|
@@ -2604,14 +2619,56 @@ class AiHubServer {
|
|
|
2604
2619
|
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2605
2620
|
seatCount: verdict?.seatCount ?? 0,
|
|
2606
2621
|
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
2622
|
+
origin: 'catalog',
|
|
2607
2623
|
};
|
|
2608
2624
|
});
|
|
2609
|
-
|
|
2625
|
+
// Issue #945: union custom employees — they are always 'hired' and carry no
|
|
2626
|
+
// entitlement. Custom personas are appended so catalog order is unchanged.
|
|
2627
|
+
return { personas: [...personas, ...customPersonas], subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey };
|
|
2610
2628
|
}
|
|
2611
2629
|
catch (err) {
|
|
2612
2630
|
console.error('[ai-hub] persona lookup failed:', err);
|
|
2613
2631
|
// Keep the (already-resolved) identity even if per-persona accounting fails.
|
|
2614
|
-
return {
|
|
2632
|
+
return { ...fallbackProjection, userKey };
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
fallbackPersonaProjection(apiKey) {
|
|
2636
|
+
const fallbackPersonas = listHubPersonaBundles().map((bundle) => ({
|
|
2637
|
+
key: bundle.personaKey,
|
|
2638
|
+
displayName: bundle.catalogMetadata.displayName,
|
|
2639
|
+
role: bundle.catalogMetadata.role,
|
|
2640
|
+
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
2641
|
+
pricingLabel: bundle.catalogMetadata.pricingLabel,
|
|
2642
|
+
status: 'locked',
|
|
2643
|
+
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2644
|
+
seatCount: 0,
|
|
2645
|
+
seatsInUse: 0,
|
|
2646
|
+
origin: 'catalog',
|
|
2647
|
+
}));
|
|
2648
|
+
const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
|
|
2649
|
+
return {
|
|
2650
|
+
personas: [...fallbackPersonas, ...customPersonas],
|
|
2651
|
+
subscriptionActive: false,
|
|
2652
|
+
workspaceId: null,
|
|
2653
|
+
userKey: apiKey ? this.lastResolvedIdentityByApiKey.get(apiKey) ?? null : null,
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
async withFirstPaintBudget(promise, fallback, label) {
|
|
2657
|
+
let timeout;
|
|
2658
|
+
try {
|
|
2659
|
+
return await Promise.race([
|
|
2660
|
+
promise,
|
|
2661
|
+
new Promise((resolve) => {
|
|
2662
|
+
timeout = setTimeout(() => {
|
|
2663
|
+
console.warn(`[ai-hub] bootstrap ${label} exceeded first-paint budget; hydrating after render`);
|
|
2664
|
+
resolve(fallback);
|
|
2665
|
+
}, BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS);
|
|
2666
|
+
}),
|
|
2667
|
+
]);
|
|
2668
|
+
}
|
|
2669
|
+
finally {
|
|
2670
|
+
if (timeout)
|
|
2671
|
+
clearTimeout(timeout);
|
|
2615
2672
|
}
|
|
2616
2673
|
}
|
|
2617
2674
|
// Issue #925: prefer the transport-aware gateway result when available; fall back to
|
|
@@ -2747,6 +2804,29 @@ class AiHubServer {
|
|
|
2747
2804
|
// Hub project, not whatever directory happened to launch the process.
|
|
2748
2805
|
res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
|
|
2749
2806
|
});
|
|
2807
|
+
this.app.get('/api/ai-hub/personas', async (req, res) => {
|
|
2808
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2809
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2810
|
+
: this.defaultProjectPath();
|
|
2811
|
+
const apiKey = resolveApiKey();
|
|
2812
|
+
const managerTeamPromise = this.computeManagerTeam(apiKey).catch((err) => {
|
|
2813
|
+
console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
|
|
2814
|
+
return [];
|
|
2815
|
+
});
|
|
2816
|
+
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise);
|
|
2817
|
+
const managerTeam = await managerTeamPromise;
|
|
2818
|
+
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
|
|
2819
|
+
.filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
|
|
2820
|
+
.length;
|
|
2821
|
+
res.json({
|
|
2822
|
+
personas,
|
|
2823
|
+
subscriptionActive,
|
|
2824
|
+
workspaceId,
|
|
2825
|
+
userEmail: userKey ?? null,
|
|
2826
|
+
managerTeam,
|
|
2827
|
+
firstRun: this.computeFirstRun(projectPath, jobCount, personas),
|
|
2828
|
+
});
|
|
2829
|
+
});
|
|
2750
2830
|
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
2751
2831
|
// same projection folded into bootstrap. Useful for the avatar→Brain view
|
|
2752
2832
|
// without re-fetching the whole bootstrap payload.
|
|
@@ -2927,19 +3007,30 @@ class AiHubServer {
|
|
|
2927
3007
|
return res.status(400).json({ error: 'projectPath required for project conversations' });
|
|
2928
3008
|
}
|
|
2929
3009
|
const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath);
|
|
2930
|
-
const
|
|
3010
|
+
const patch = { ...body };
|
|
3011
|
+
delete patch.projectPath;
|
|
3012
|
+
delete patch.scope;
|
|
3013
|
+
delete patch.activeId;
|
|
3014
|
+
const patchFields = Object.keys(patch);
|
|
3015
|
+
let saved = null;
|
|
2931
3016
|
if (patchFields.length > 0) {
|
|
2932
|
-
this.conversationStore.patchConversation(projectPath, req.params.conversationId,
|
|
3017
|
+
saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, patch);
|
|
2933
3018
|
}
|
|
2934
3019
|
if (body.activeId !== undefined) {
|
|
2935
3020
|
// Body-safe: set activeId via the index, never by rewriting conversation bodies (#820).
|
|
2936
|
-
this.conversationStore.setActiveId(projectPath, body.activeId);
|
|
3021
|
+
saved = this.conversationStore.setActiveId(projectPath, body.activeId);
|
|
2937
3022
|
}
|
|
2938
3023
|
if (patchFields.length === 0 && body.activeId !== undefined) {
|
|
2939
3024
|
return res.json({ projectPath, scope: scope ?? 'project', activeId: body.activeId, source: 'disk' });
|
|
2940
3025
|
}
|
|
2941
|
-
const
|
|
2942
|
-
return res.json({
|
|
3026
|
+
const conversation = saved?.conversations.find((entry) => entry.id === req.params.conversationId) ?? null;
|
|
3027
|
+
return res.json({
|
|
3028
|
+
projectPath,
|
|
3029
|
+
scope: scope ?? 'project',
|
|
3030
|
+
activeId: saved?.activeId ?? null,
|
|
3031
|
+
conversation,
|
|
3032
|
+
source: 'disk',
|
|
3033
|
+
});
|
|
2943
3034
|
}
|
|
2944
3035
|
catch (error) {
|
|
2945
3036
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
|
|
@@ -4452,6 +4543,81 @@ class AiHubServer {
|
|
|
4452
4543
|
return res.status(500).json({ error: msg });
|
|
4453
4544
|
}
|
|
4454
4545
|
});
|
|
4546
|
+
// ─── Issue #945: Custom Employee CRUD ────────────────────────────────────
|
|
4547
|
+
// GET /api/ai-hub/custom-employees — list project-scoped custom employees.
|
|
4548
|
+
this.app.get('/api/ai-hub/custom-employees', (req, res) => {
|
|
4549
|
+
const projectPath = req.query.projectPath || this.projectPath;
|
|
4550
|
+
const employees = (0, custom_employees_1.readCustomEmployees)(projectPath);
|
|
4551
|
+
return res.json(employees);
|
|
4552
|
+
});
|
|
4553
|
+
// POST /api/ai-hub/custom-employees — create a new custom employee.
|
|
4554
|
+
this.app.post('/api/ai-hub/custom-employees', (req, res) => {
|
|
4555
|
+
const { projectPath: reqProjectPath, displayName, role, icon, jobIds } = req.body;
|
|
4556
|
+
const projectPath = reqProjectPath || this.projectPath;
|
|
4557
|
+
if (!displayName || typeof displayName !== 'string' || !displayName.trim()) {
|
|
4558
|
+
return res.status(400).json({ error: 'displayName is required.' });
|
|
4559
|
+
}
|
|
4560
|
+
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
|
4561
|
+
return res.status(400).json({ error: 'jobIds must be a non-empty array.' });
|
|
4562
|
+
}
|
|
4563
|
+
const existing = (0, custom_employees_1.readCustomEmployees)(projectPath).map((e) => e.key);
|
|
4564
|
+
const key = (0, custom_employees_1.slugifyDisplayName)(displayName.trim(), existing);
|
|
4565
|
+
const VALID_ICON_KINDS = new Set(['emoji', 'generated', 'image']);
|
|
4566
|
+
const iconKind = (icon?.kind && VALID_ICON_KINDS.has(icon.kind) ? icon.kind : 'generated');
|
|
4567
|
+
const employee = {
|
|
4568
|
+
key,
|
|
4569
|
+
displayName: displayName.trim(),
|
|
4570
|
+
role: (role ?? '').trim() || 'AI Employee',
|
|
4571
|
+
icon: { kind: iconKind, value: icon?.value ?? displayName.trim() },
|
|
4572
|
+
jobIds,
|
|
4573
|
+
createdBy: '',
|
|
4574
|
+
scope: 'project',
|
|
4575
|
+
createdAt: new Date().toISOString(),
|
|
4576
|
+
};
|
|
4577
|
+
(0, custom_employees_1.writeCustomEmployee)(projectPath, employee);
|
|
4578
|
+
return res.status(201).json(employee);
|
|
4579
|
+
});
|
|
4580
|
+
// PATCH /api/ai-hub/custom-employees/:key — update an existing custom employee.
|
|
4581
|
+
this.app.patch('/api/ai-hub/custom-employees/:key', (req, res) => {
|
|
4582
|
+
const key = decodeURIComponent(req.params.key);
|
|
4583
|
+
if (!key.startsWith('custom:') || /[/\\]|\.\./.test(key.replace(/^custom:/, ''))) {
|
|
4584
|
+
return res.status(400).json({ error: 'Invalid employee key.' });
|
|
4585
|
+
}
|
|
4586
|
+
const projectPath = req.body.projectPath || this.projectPath;
|
|
4587
|
+
const existing = (0, custom_employees_1.readCustomEmployees)(projectPath);
|
|
4588
|
+
const record = existing.find((e) => e.key === key);
|
|
4589
|
+
if (!record) {
|
|
4590
|
+
return res.status(404).json({ error: `Custom employee ${key} not found.` });
|
|
4591
|
+
}
|
|
4592
|
+
const { displayName, role, icon, jobIds } = req.body;
|
|
4593
|
+
const VALID_ICON_KINDS_PATCH = new Set(['emoji', 'generated', 'image']);
|
|
4594
|
+
const patchedIcon = icon !== undefined
|
|
4595
|
+
? { kind: (VALID_ICON_KINDS_PATCH.has(icon.kind) ? icon.kind : record.icon.kind), value: icon.value ?? record.icon.value }
|
|
4596
|
+
: undefined;
|
|
4597
|
+
const updated = {
|
|
4598
|
+
...record,
|
|
4599
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
4600
|
+
...(role !== undefined ? { role } : {}),
|
|
4601
|
+
...(patchedIcon !== undefined ? { icon: patchedIcon } : {}),
|
|
4602
|
+
...(jobIds !== undefined ? { jobIds } : {}),
|
|
4603
|
+
};
|
|
4604
|
+
(0, custom_employees_1.writeCustomEmployee)(projectPath, updated);
|
|
4605
|
+
return res.json(updated);
|
|
4606
|
+
});
|
|
4607
|
+
// DELETE /api/ai-hub/custom-employees/:key — remove a custom employee.
|
|
4608
|
+
this.app.delete('/api/ai-hub/custom-employees/:key', (req, res) => {
|
|
4609
|
+
const key = decodeURIComponent(req.params.key);
|
|
4610
|
+
if (!key.startsWith('custom:') || /[/\\]|\.\./.test(key.replace(/^custom:/, ''))) {
|
|
4611
|
+
return res.status(400).json({ error: 'Invalid employee key.' });
|
|
4612
|
+
}
|
|
4613
|
+
const projectPath = req.query.projectPath || this.projectPath;
|
|
4614
|
+
const removed = (0, custom_employees_1.deleteCustomEmployee)(projectPath, key);
|
|
4615
|
+
if (!removed) {
|
|
4616
|
+
return res.status(404).json({ error: `Custom employee ${key} not found.` });
|
|
4617
|
+
}
|
|
4618
|
+
return res.json({ key, removed: true });
|
|
4619
|
+
});
|
|
4620
|
+
// ─── End Issue #945 ───────────────────────────────────────────────────────
|
|
4455
4621
|
// GET /api/ai-hub/hosts — list registered remote hosts with health status.
|
|
4456
4622
|
this.app.get('/api/ai-hub/hosts', async (_req, res) => {
|
|
4457
4623
|
const hosts = this.hostConfigStore.load();
|
|
@@ -58,6 +58,15 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
|
|
|
58
58
|
launchCommand: 'copilot',
|
|
59
59
|
installPackage: '@github/copilot',
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
id: 'antigravity-cli',
|
|
63
|
+
label: 'Antigravity CLI',
|
|
64
|
+
detectAliases: ['agy', 'antigravity', 'antigravity-cli'],
|
|
65
|
+
loginCommand: 'agy auth login',
|
|
66
|
+
launchCommand: 'agy',
|
|
67
|
+
// agy has no npm package — install via https://antigravity.google/cli/
|
|
68
|
+
installPackage: '',
|
|
69
|
+
},
|
|
61
70
|
];
|
|
62
71
|
/**
|
|
63
72
|
* The canonical row set, in display order. Each row starts in `pending`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.231",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js"
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"electron": "^41.2.2",
|
|
159
159
|
"electron-updater": "^6.8.9",
|
|
160
160
|
"express": "^5.2.1",
|
|
161
|
-
"fraim": "2.0.
|
|
161
|
+
"fraim": "2.0.231",
|
|
162
162
|
"mongodb": "^7.0.0",
|
|
163
163
|
"node-cron": "4.2.1",
|
|
164
164
|
"node-edge-tts": "^1.2.10",
|