fraim-framework 2.0.199 → 2.0.200
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 +102 -2
- package/dist/src/ai-hub/preferences.js +3 -1
- package/dist/src/ai-hub/server.js +164 -183
- package/package.json +1 -1
- package/public/ai-hub/script.js +17 -13
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
|
|
7
7
|
exports.conversationScopeKey = conversationScopeKey;
|
|
8
|
+
exports.migrateConversationStore = migrateConversationStore;
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
10
11
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
@@ -93,6 +94,103 @@ function migrateScopeBuckets(store) {
|
|
|
93
94
|
}
|
|
94
95
|
return { version: 1, projects };
|
|
95
96
|
}
|
|
97
|
+
// Canonical comparison key for a project path (case-insensitive on win32), matching the
|
|
98
|
+
// convention used by removeProject and the preferences store.
|
|
99
|
+
function canonicalProjectPathKey(value) {
|
|
100
|
+
const resolved = path_1.default.resolve(value);
|
|
101
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
102
|
+
}
|
|
103
|
+
// How much run history a record carries. Used to decide the winner when the same conversation id
|
|
104
|
+
// exists in two buckets: the copy with real history must never be overwritten by a header-only
|
|
105
|
+
// shell (which is exactly what a mis-filed slim PUT produces).
|
|
106
|
+
function conversationRichness(conv) {
|
|
107
|
+
const value = conv;
|
|
108
|
+
const messages = Array.isArray(value?.messages) ? value.messages.length : 0;
|
|
109
|
+
const events = Array.isArray(value?.events) ? value.events.length : 0;
|
|
110
|
+
return messages + events;
|
|
111
|
+
}
|
|
112
|
+
// Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
|
|
113
|
+
// richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
|
|
114
|
+
function placeConversationInBucket(bucket, conv) {
|
|
115
|
+
const value = conv;
|
|
116
|
+
const id = value && typeof value.id === 'string' ? value.id : '';
|
|
117
|
+
if (!id) {
|
|
118
|
+
bucket.conversations.push(conv);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const idx = bucket.conversations.findIndex((entry) => entry && entry.id === id);
|
|
122
|
+
if (idx < 0) {
|
|
123
|
+
bucket.conversations.push(conv);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const existing = bucket.conversations[idx];
|
|
127
|
+
const existingScore = conversationRichness(existing);
|
|
128
|
+
const incomingScore = conversationRichness(conv);
|
|
129
|
+
if (incomingScore > existingScore) {
|
|
130
|
+
bucket.conversations[idx] = conv;
|
|
131
|
+
}
|
|
132
|
+
else if (incomingScore === existingScore
|
|
133
|
+
&& timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
|
|
134
|
+
bucket.conversations[idx] = conv;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// A conversation's identity is unique only WITHIN a bucket, and a bucket key is a project path.
|
|
138
|
+
// The buggy client PUT path could file a conversation whose own projectPath points at project B
|
|
139
|
+
// into project A's bucket (see docs/rca/hub-conversation-cross-project-leak.md), so the same run
|
|
140
|
+
// appeared under two projects and the mis-filed copy was a header-only shell. This one-time,
|
|
141
|
+
// idempotent, read-time migration relocates every project-scoped record back to the bucket that
|
|
142
|
+
// matches its own projectPath, deduping so the copy with real run history wins. A bucket activeId
|
|
143
|
+
// that pointed at a relocated record is cleared. Sentinel scope buckets (@manager/@company)
|
|
144
|
+
// legitimately hold records whose projectPath points at a project, so they are left untouched.
|
|
145
|
+
function migrateProjectBuckets(store) {
|
|
146
|
+
const projects = {};
|
|
147
|
+
const ensure = (key) => {
|
|
148
|
+
if (!projects[key])
|
|
149
|
+
projects[key] = { activeId: null, conversations: [] };
|
|
150
|
+
return projects[key];
|
|
151
|
+
};
|
|
152
|
+
// Preserve every existing bucket key and its activeId up front.
|
|
153
|
+
for (const [key, state] of Object.entries(store.projects || {})) {
|
|
154
|
+
ensure(key).activeId = state.activeId ?? null;
|
|
155
|
+
}
|
|
156
|
+
for (const [bucketKey, state] of Object.entries(store.projects || {})) {
|
|
157
|
+
const isSentinel = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY;
|
|
158
|
+
for (const conv of (state.conversations || [])) {
|
|
159
|
+
if (!conv || typeof conv !== 'object')
|
|
160
|
+
continue;
|
|
161
|
+
const scope = conv.scope
|
|
162
|
+
?? conv.invokedArea;
|
|
163
|
+
const ownPath = typeof conv.projectPath === 'string'
|
|
164
|
+
? conv.projectPath
|
|
165
|
+
: '';
|
|
166
|
+
const mismatched = !isSentinel
|
|
167
|
+
&& scope !== 'manager' && scope !== 'company'
|
|
168
|
+
&& ownPath.length > 0
|
|
169
|
+
&& canonicalProjectPathKey(ownPath) !== canonicalProjectPathKey(bucketKey);
|
|
170
|
+
if (!mismatched) {
|
|
171
|
+
placeConversationInBucket(ensure(bucketKey), conv);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Relocate to the record's own project bucket.
|
|
175
|
+
placeConversationInBucket(ensure(path_1.default.resolve(ownPath)), conv);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Any activeId that no longer resolves to a conversation in its bucket (because that record was
|
|
179
|
+
// relocated out) is stale — clear it so a project never points at another project's run.
|
|
180
|
+
for (const state of Object.values(projects)) {
|
|
181
|
+
if (state.activeId && !state.conversations.some((entry) => entry && entry.id === state.activeId)) {
|
|
182
|
+
state.activeId = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { version: 1, projects };
|
|
186
|
+
}
|
|
187
|
+
// The full on-read migration pipeline, exported so a one-off maintenance/cleanup pass can heal an
|
|
188
|
+
// existing store file through the EXACT same code path the store uses at runtime (rather than a
|
|
189
|
+
// duplicated, drift-prone reimplementation). Operates on raw records, so it never drops a record
|
|
190
|
+
// via normalization.
|
|
191
|
+
function migrateConversationStore(store) {
|
|
192
|
+
return migrateProjectBuckets(migrateScopeBuckets(store));
|
|
193
|
+
}
|
|
96
194
|
function normalizeConversation(projectPath, raw) {
|
|
97
195
|
if (!raw || typeof raw !== 'object')
|
|
98
196
|
return null;
|
|
@@ -242,8 +340,10 @@ class AiHubConversationStore {
|
|
|
242
340
|
if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
|
|
243
341
|
return { version: 1, projects: {} };
|
|
244
342
|
}
|
|
245
|
-
// Issue #708: fold legacy manager/company-invoked runs into scope buckets on read
|
|
246
|
-
|
|
343
|
+
// Issue #708: fold legacy manager/company-invoked runs into scope buckets on read, then
|
|
344
|
+
// relocate any project-scoped record mis-filed under the wrong project bucket back to its
|
|
345
|
+
// own project (see docs/rca/hub-conversation-cross-project-leak.md).
|
|
346
|
+
return migrateProjectBuckets(migrateScopeBuckets({ version: 1, projects: raw.projects }));
|
|
247
347
|
}
|
|
248
348
|
catch {
|
|
249
349
|
return { version: 1, projects: {} };
|
|
@@ -140,7 +140,9 @@ class AiHubPreferencesStore {
|
|
|
140
140
|
? raw.recentJobInstructions
|
|
141
141
|
: {},
|
|
142
142
|
personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
|
|
143
|
-
|
|
143
|
+
// Issue #750: apiKey is no longer a persisted field here at all — any
|
|
144
|
+
// legacy apiKey left over from a pre-#750 ai-hub-state.json is silently
|
|
145
|
+
// dropped, never read back. Identity comes from ~/.fraim/config.json only.
|
|
144
146
|
projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath, { removedProjectPaths }),
|
|
145
147
|
removedProjectPaths,
|
|
146
148
|
};
|
|
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
40
|
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
41
|
+
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
41
42
|
exports.findAvailablePort = findAvailablePort;
|
|
42
43
|
exports.findAvailablePortExcluding = findAvailablePortExcluding;
|
|
43
44
|
const express_1 = __importDefault(require("express"));
|
|
@@ -61,6 +62,7 @@ const conversation_store_1 = require("./conversation-store");
|
|
|
61
62
|
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
62
63
|
const managed_browser_1 = require("./managed-browser");
|
|
63
64
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
65
|
+
const user_config_1 = require("../cli/utils/user-config");
|
|
64
66
|
let personaHiringModule;
|
|
65
67
|
let managerHiringModule;
|
|
66
68
|
function loadPersonaHiringModule() {
|
|
@@ -973,28 +975,43 @@ function resolveSafeArtifactPath(rawPath, projectPath) {
|
|
|
973
975
|
];
|
|
974
976
|
return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
|
|
975
977
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
978
|
+
// Builds the OS "open this file with its default app" invocation. Exported for regression
|
|
979
|
+
// testing (see tests/isolated/test-ai-hub-open-file.ts).
|
|
980
|
+
//
|
|
981
|
+
// Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
|
|
982
|
+
// argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
|
|
983
|
+
// it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
|
|
984
|
+
// received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
|
|
985
|
+
// path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
|
|
986
|
+
// directly from the environment and is not re-parsed, so the path arrives intact (verified against
|
|
987
|
+
// paths containing spaces and backslashes).
|
|
988
|
+
function buildOpenFileInvocation(filePath, platform = process.platform) {
|
|
989
|
+
if (platform === 'win32') {
|
|
990
|
+
return {
|
|
991
|
+
command: 'powershell.exe',
|
|
992
|
+
args: [
|
|
980
993
|
'-NoProfile',
|
|
981
994
|
'-ExecutionPolicy',
|
|
982
995
|
'Bypass',
|
|
983
996
|
'-Command',
|
|
984
|
-
'
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
997
|
+
'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
|
|
998
|
+
],
|
|
999
|
+
envPatch: { FRAIM_OPEN_PATH: filePath },
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
command: platform === 'darwin' ? 'open' : 'xdg-open',
|
|
1004
|
+
args: [filePath],
|
|
1005
|
+
envPatch: {},
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
function hubOpenFile(filePath) {
|
|
1009
|
+
return new Promise((resolve, reject) => {
|
|
1010
|
+
const { command, args, envPatch } = buildOpenFileInvocation(filePath);
|
|
995
1011
|
const child = (0, child_process_1.spawn)(command, args, {
|
|
996
1012
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
997
1013
|
windowsHide: process.platform === 'win32',
|
|
1014
|
+
env: { ...process.env, ...envPatch },
|
|
998
1015
|
});
|
|
999
1016
|
let stderr = '';
|
|
1000
1017
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -1051,53 +1068,15 @@ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath
|
|
|
1051
1068
|
// ---------------------------------------------------------------------------
|
|
1052
1069
|
// Issue #512 (S3) — Hub bootstrap projection helpers.
|
|
1053
1070
|
// ---------------------------------------------------------------------------
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
|
|
1064
|
-
return raw.userEmail;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
catch {
|
|
1068
|
-
// fall through to default
|
|
1069
|
-
}
|
|
1070
|
-
// #533: preferences.json may not carry the email yet — the proxy persists it on
|
|
1071
|
-
// fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
|
|
1072
|
-
// stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
|
|
1073
|
-
// from the on-disk files. This is what makes the avatar + personal learnings work.
|
|
1074
|
-
const stamped = resolveSingleStampedUser();
|
|
1075
|
-
if (stamped)
|
|
1076
|
-
return stamped;
|
|
1077
|
-
return 'fraim-user';
|
|
1078
|
-
}
|
|
1079
|
-
// Return the one user prefix stamped on the global learning files, or null if
|
|
1080
|
-
// there is not exactly one (so we never guess between multiple accounts).
|
|
1081
|
-
function resolveSingleStampedUser() {
|
|
1082
|
-
try {
|
|
1083
|
-
const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
|
|
1084
|
-
if (!fs_1.default.existsSync(dir))
|
|
1085
|
-
return null;
|
|
1086
|
-
const prefixes = new Set();
|
|
1087
|
-
for (const f of fs_1.default.readdirSync(dir)) {
|
|
1088
|
-
if (f.startsWith('org-'))
|
|
1089
|
-
continue;
|
|
1090
|
-
const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
|
|
1091
|
-
if (m)
|
|
1092
|
-
prefixes.add(m[1]);
|
|
1093
|
-
}
|
|
1094
|
-
if (prefixes.size === 1)
|
|
1095
|
-
return Array.from(prefixes)[0];
|
|
1096
|
-
}
|
|
1097
|
-
catch {
|
|
1098
|
-
// ignore unreadable dir
|
|
1099
|
-
}
|
|
1100
|
-
return null;
|
|
1071
|
+
// Issue #750: the Hub's identity — for persona/entitlement resolution, the
|
|
1072
|
+
// displayed "signed in as" email, and personal (L1) learnings keying — comes
|
|
1073
|
+
// from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
|
|
1074
|
+
// stores only identity"), the same credential every other FRAIM CLI/MCP
|
|
1075
|
+
// surface already reads via readUserFraimConfig(). There is no fallback: a
|
|
1076
|
+
// missing or invalid apiKey resolves to null (rendered as a "not connected"
|
|
1077
|
+
// state), never a guess from another local file.
|
|
1078
|
+
function resolveApiKey() {
|
|
1079
|
+
return (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
1101
1080
|
}
|
|
1102
1081
|
// Read persisted Get-started step states from ~/.fraim/install-state.json
|
|
1103
1082
|
// (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
|
|
@@ -1177,21 +1156,13 @@ class AiHubServer {
|
|
|
1177
1156
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
1178
1157
|
});
|
|
1179
1158
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
1180
|
-
// Issue #701: the AI Hub is a loopback companion that
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
// A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
|
|
1184
|
-
// Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
|
|
1185
|
-
this.dbService = options.dbService;
|
|
1186
|
-
this.ownsDbService = false;
|
|
1159
|
+
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
1160
|
+
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
1161
|
+
// through the remote gateway; the hosted server is the sole owner of DB access.
|
|
1187
1162
|
this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
|
|
1188
1163
|
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1189
1164
|
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1190
1165
|
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
1191
|
-
if (this.dbService) {
|
|
1192
|
-
const { registerPaymentRoutes } = require('../routes/payment-routes');
|
|
1193
|
-
registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
|
|
1194
|
-
}
|
|
1195
1166
|
// CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
|
|
1196
1167
|
// calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
|
|
1197
1168
|
this.app.use((_req, res, next) => {
|
|
@@ -1207,8 +1178,7 @@ class AiHubServer {
|
|
|
1207
1178
|
});
|
|
1208
1179
|
// Payment success redirect: sync entitlement then bounce to the hub.
|
|
1209
1180
|
// Stripe redirects here after a completed persona-hire checkout.
|
|
1210
|
-
this.app.get('/ai-hub/payment-success',
|
|
1211
|
-
const sessionId = req.query['session_id'];
|
|
1181
|
+
this.app.get('/ai-hub/payment-success', (req, res) => {
|
|
1212
1182
|
const email = req.query['email'];
|
|
1213
1183
|
// Persist the buyer's email so bootstrap can look up entitlements by userId.
|
|
1214
1184
|
if (email) {
|
|
@@ -1223,17 +1193,8 @@ class AiHubServer {
|
|
|
1223
1193
|
console.warn('[ai-hub] could not persist userEmail:', err);
|
|
1224
1194
|
}
|
|
1225
1195
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
|
|
1229
|
-
const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
|
|
1230
|
-
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
1231
|
-
await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
|
|
1232
|
-
}
|
|
1233
|
-
catch (err) {
|
|
1234
|
-
console.warn('[ai-hub] payment-success entitlement sync failed:', err);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1196
|
+
// Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
|
|
1197
|
+
// Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
|
|
1237
1198
|
res.redirect('/ai-hub/?hired=1');
|
|
1238
1199
|
});
|
|
1239
1200
|
this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
|
|
@@ -1322,20 +1283,6 @@ class AiHubServer {
|
|
|
1322
1283
|
}
|
|
1323
1284
|
async start(port) {
|
|
1324
1285
|
this.httpPort = port;
|
|
1325
|
-
if (this.dbService) {
|
|
1326
|
-
try {
|
|
1327
|
-
await this.dbService.connect();
|
|
1328
|
-
const mongoClient = this.dbService.getClient();
|
|
1329
|
-
if (mongoClient) {
|
|
1330
|
-
const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
|
|
1331
|
-
this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
catch (err) {
|
|
1335
|
-
console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
|
|
1336
|
-
this.dbService = undefined;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
1286
|
await new Promise((resolve, reject) => {
|
|
1340
1287
|
this.httpServer = this.app.listen(port, '127.0.0.1');
|
|
1341
1288
|
this.httpServer.once('listening', () => resolve());
|
|
@@ -1406,10 +1353,6 @@ class AiHubServer {
|
|
|
1406
1353
|
// #521: tear down the shared browser if WE launched it (stop() no-ops on a
|
|
1407
1354
|
// browser the manager owns).
|
|
1408
1355
|
this.managedBrowser.stop();
|
|
1409
|
-
if (this.ownsDbService && this.dbService) {
|
|
1410
|
-
await this.dbService.close();
|
|
1411
|
-
this.dbService = undefined;
|
|
1412
|
-
}
|
|
1413
1356
|
}
|
|
1414
1357
|
getHttpsPort() { return this.httpsPort; }
|
|
1415
1358
|
knownProjects(projectPath, extras = []) {
|
|
@@ -1422,7 +1365,7 @@ class AiHubServer {
|
|
|
1422
1365
|
...extras,
|
|
1423
1366
|
], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
|
|
1424
1367
|
}
|
|
1425
|
-
async bootstrapResponse(projectPath
|
|
1368
|
+
async bootstrapResponse(projectPath) {
|
|
1426
1369
|
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1427
1370
|
const employees = this.hostRuntime.detectEmployees();
|
|
1428
1371
|
let preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
@@ -1449,9 +1392,12 @@ class AiHubServer {
|
|
|
1449
1392
|
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
1450
1393
|
}));
|
|
1451
1394
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1452
|
-
|
|
1395
|
+
// Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
|
|
1396
|
+
// override, no ai-hub-state.json copy, no fallback chain.
|
|
1397
|
+
const resolvedApiKey = resolveApiKey();
|
|
1453
1398
|
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
|
|
1454
1399
|
const managerTeam = await this.computeManagerTeam(resolvedApiKey);
|
|
1400
|
+
const resolvedUserEmail = userKey ?? null;
|
|
1455
1401
|
const projects = this.knownProjects(normalizedProjectPath);
|
|
1456
1402
|
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1457
1403
|
this.preferencesStore.save(preferences);
|
|
@@ -1464,7 +1410,11 @@ class AiHubServer {
|
|
|
1464
1410
|
title: 'AI Hub',
|
|
1465
1411
|
remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
|
|
1466
1412
|
project,
|
|
1467
|
-
|
|
1413
|
+
// Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
|
|
1414
|
+
// it is overlaid on the wire response only, freshly resolved from
|
|
1415
|
+
// ~/.fraim/config.json, so public/ai-hub/script.js's existing
|
|
1416
|
+
// tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
|
|
1417
|
+
preferences: { ...preferences, apiKey: resolvedApiKey },
|
|
1468
1418
|
categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
|
|
1469
1419
|
jobs,
|
|
1470
1420
|
managerTemplates,
|
|
@@ -1476,10 +1426,11 @@ class AiHubServer {
|
|
|
1476
1426
|
// Issue #512 (S3) — additive manager-flow projections.
|
|
1477
1427
|
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1478
1428
|
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
1479
|
-
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
|
|
1480
|
-
// #533: the resolved account email
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1429
|
+
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
|
|
1430
|
+
// #533/#750: the resolved account email (from the same ~/.fraim/config.json
|
|
1431
|
+
// apiKey used for personas above), or null when not connected — so the
|
|
1432
|
+
// profile card and personas can never disagree about who's signed in.
|
|
1433
|
+
userEmail: resolvedUserEmail,
|
|
1483
1434
|
// #744: the org cobrand identity (name/color/logo) from the org context
|
|
1484
1435
|
// storage, or null when unset so the Hub falls back to FRAIM identity.
|
|
1485
1436
|
orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
|
|
@@ -1866,8 +1817,19 @@ class AiHubServer {
|
|
|
1866
1817
|
}
|
|
1867
1818
|
// Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
|
|
1868
1819
|
// registry catalog counts. A read projection; no new storage.
|
|
1869
|
-
|
|
1870
|
-
|
|
1820
|
+
// Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
|
|
1821
|
+
// not connected. `organization`/`rawSignals` are identity-independent (org-*
|
|
1822
|
+
// files and repo-level raw signals) and must always be counted regardless of
|
|
1823
|
+
// connection state — only `manager`/`project` (L1, individual-keyed) are
|
|
1824
|
+
// zeroed when not connected, rather than guessing an identity via
|
|
1825
|
+
// resolveLearningUserId's single-stamped-user fallback (a legitimate
|
|
1826
|
+
// convenience for MCP auto-load context, but not appropriate here).
|
|
1827
|
+
computeBrain(projectPath, jobCount, userEmail) {
|
|
1828
|
+
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
|
|
1829
|
+
if (!userEmail) {
|
|
1830
|
+
learnings.manager = 0;
|
|
1831
|
+
learnings.project = 0;
|
|
1832
|
+
}
|
|
1871
1833
|
return {
|
|
1872
1834
|
learnings,
|
|
1873
1835
|
catalog: {
|
|
@@ -2053,51 +2015,48 @@ class AiHubServer {
|
|
|
2053
2015
|
// Issue #701: manager team comes from the hosted server, not local Mongo.
|
|
2054
2016
|
return this.remoteGateway.listManagerTeam(apiKey);
|
|
2055
2017
|
}
|
|
2018
|
+
// Issue #750: identity for routes that don't already call computePersonas()
|
|
2019
|
+
// (which fetches this same hosted state for personas/manager-team). Always
|
|
2020
|
+
// sources the apiKey from ~/.fraim/config.json; null means "not connected" —
|
|
2021
|
+
// no local-guess fallback.
|
|
2022
|
+
async resolveHubIdentity() {
|
|
2023
|
+
const apiKey = resolveApiKey();
|
|
2024
|
+
if (!apiKey)
|
|
2025
|
+
return null;
|
|
2026
|
+
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
2027
|
+
return state?.userId ?? null;
|
|
2028
|
+
}
|
|
2029
|
+
// Issue #750: shared by the two learnings routes below. 'org' scope never
|
|
2030
|
+
// needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
|
|
2031
|
+
// userId param for it); every other scope resolves through resolveHubIdentity,
|
|
2032
|
+
// returning '' (not a guess) when not connected — callers distinguish
|
|
2033
|
+
// "org" from "not connected" via the scope check they already have to make.
|
|
2034
|
+
async resolveLearningIdentity(scope) {
|
|
2035
|
+
if (scope === 'org')
|
|
2036
|
+
return '';
|
|
2037
|
+
return (await this.resolveHubIdentity()) || '';
|
|
2038
|
+
}
|
|
2056
2039
|
registerRoutes() {
|
|
2057
|
-
// Issue #512:
|
|
2058
|
-
//
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
});
|
|
2078
|
-
}
|
|
2079
|
-
else {
|
|
2080
|
-
this.app.get('/account', (_req, res) => {
|
|
2081
|
-
const filePath = path_1.default.join(publicDir, 'account', 'index.html');
|
|
2082
|
-
if (fs_1.default.existsSync(filePath)) {
|
|
2083
|
-
res.sendFile(filePath);
|
|
2084
|
-
}
|
|
2085
|
-
else {
|
|
2086
|
-
res.status(404).send('Account page not found.');
|
|
2087
|
-
}
|
|
2088
|
-
});
|
|
2089
|
-
this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
|
|
2090
|
-
this.app.get('/analytics', (_req, res) => {
|
|
2091
|
-
const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
|
|
2092
|
-
if (fs_1.default.existsSync(filePath)) {
|
|
2093
|
-
res.sendFile(filePath);
|
|
2094
|
-
}
|
|
2095
|
-
else {
|
|
2096
|
-
res.status(404).send('Analytics page not found.');
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
|
-
this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
|
|
2100
|
-
}
|
|
2040
|
+
// Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
|
|
2041
|
+
// on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
|
|
2042
|
+
// server, which owns the authenticated, DB-backed pages.
|
|
2043
|
+
this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
|
|
2044
|
+
const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
|
|
2045
|
+
if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
|
|
2046
|
+
const rawSurface = parsed.searchParams.get('surface');
|
|
2047
|
+
const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
|
|
2048
|
+
? rawSurface
|
|
2049
|
+
: 'analytics';
|
|
2050
|
+
parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
|
|
2051
|
+
}
|
|
2052
|
+
res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
|
|
2053
|
+
});
|
|
2054
|
+
this.app.get(['/account', '/account/'], (req, res) => {
|
|
2055
|
+
res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
|
|
2056
|
+
});
|
|
2057
|
+
this.app.get(['/analytics', '/analytics/'], (req, res) => {
|
|
2058
|
+
res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
|
|
2059
|
+
});
|
|
2101
2060
|
// Issue #478: Serve the PowerPoint task pane HTML and manifest.
|
|
2102
2061
|
// Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
|
|
2103
2062
|
// request, so we must strip them before resolving the file path. Use a custom
|
|
@@ -2149,8 +2108,6 @@ class AiHubServer {
|
|
|
2149
2108
|
}
|
|
2150
2109
|
catch { }
|
|
2151
2110
|
}
|
|
2152
|
-
// Read API key from header — query-param API keys are prohibited (§3.14)
|
|
2153
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2154
2111
|
// #719: a bare reload (no projectPath query) must land on the last-recorded
|
|
2155
2112
|
// workspace project, not the launch folder. The bootstrap saves its resolved
|
|
2156
2113
|
// current path back into the projects list, so defaulting to the launch
|
|
@@ -2164,17 +2121,18 @@ class AiHubServer {
|
|
|
2164
2121
|
projectPath = recorded;
|
|
2165
2122
|
}
|
|
2166
2123
|
}
|
|
2167
|
-
res.json(await this.bootstrapResponse(projectPath || this.projectPath
|
|
2124
|
+
res.json(await this.bootstrapResponse(projectPath || this.projectPath));
|
|
2168
2125
|
});
|
|
2169
2126
|
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
2170
2127
|
// same projection folded into bootstrap. Useful for the avatar→Brain view
|
|
2171
2128
|
// without re-fetching the whole bootstrap payload.
|
|
2172
|
-
this.app.get('/api/ai-hub/brain', (req, res) => {
|
|
2129
|
+
this.app.get('/api/ai-hub/brain', async (req, res) => {
|
|
2173
2130
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2174
2131
|
? path_1.default.resolve(req.query.projectPath)
|
|
2175
2132
|
: this.projectPath;
|
|
2176
2133
|
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
|
|
2177
|
-
|
|
2134
|
+
const userEmail = await this.resolveHubIdentity();
|
|
2135
|
+
return res.json(this.computeBrain(projectPath, jobCount, userEmail));
|
|
2178
2136
|
});
|
|
2179
2137
|
// #533: read the PRESERVED learnings for a section + storage level so the
|
|
2180
2138
|
// Company/Manager sections (machine level) and the project workspace (project
|
|
@@ -2187,7 +2145,7 @@ class AiHubServer {
|
|
|
2187
2145
|
: (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
|
|
2188
2146
|
? path_1.default.resolve(req.body.projectPath)
|
|
2189
2147
|
: this.projectPath);
|
|
2190
|
-
this.app.get('/api/ai-hub/learnings', (req, res) => {
|
|
2148
|
+
this.app.get('/api/ai-hub/learnings', async (req, res) => {
|
|
2191
2149
|
const scope = req.query.scope;
|
|
2192
2150
|
if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
|
|
2193
2151
|
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
@@ -2195,7 +2153,11 @@ class AiHubServer {
|
|
|
2195
2153
|
const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
|
|
2196
2154
|
? req.query.level : 'machine';
|
|
2197
2155
|
try {
|
|
2198
|
-
const
|
|
2156
|
+
const userEmail = await this.resolveLearningIdentity(scope);
|
|
2157
|
+
if (scope !== 'org' && !userEmail) {
|
|
2158
|
+
return res.json({ scope, level, entries: [] });
|
|
2159
|
+
}
|
|
2160
|
+
const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
|
|
2199
2161
|
return res.json({ scope, level, entries });
|
|
2200
2162
|
}
|
|
2201
2163
|
catch (error) {
|
|
@@ -2205,7 +2167,7 @@ class AiHubServer {
|
|
|
2205
2167
|
// #533 §3: add / edit / delete a single learning entry — writes the real file
|
|
2206
2168
|
// at the section's level (machine for the Manager/Company tabs, project for the
|
|
2207
2169
|
// project workspace). The action targets exactly the card's file.
|
|
2208
|
-
this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
|
|
2170
|
+
this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
|
|
2209
2171
|
const b = (req.body || {});
|
|
2210
2172
|
if (!['add', 'edit', 'delete'].includes(b.action || '')) {
|
|
2211
2173
|
return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
|
|
@@ -2216,8 +2178,14 @@ class AiHubServer {
|
|
|
2216
2178
|
return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
|
|
2217
2179
|
const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
|
|
2218
2180
|
const ref = { scope: b.scope, level, category: b.category };
|
|
2181
|
+
// Issue #750: writes to a personal-scope file need a resolved identity —
|
|
2182
|
+
// fail explicitly rather than writing to a guessed or empty-string file.
|
|
2183
|
+
const userEmail = await this.resolveLearningIdentity(b.scope);
|
|
2184
|
+
if (b.scope !== 'org' && !userEmail) {
|
|
2185
|
+
return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
|
|
2186
|
+
}
|
|
2219
2187
|
try {
|
|
2220
|
-
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req),
|
|
2188
|
+
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
|
|
2221
2189
|
originalTitle: b.originalTitle,
|
|
2222
2190
|
severity: b.severity,
|
|
2223
2191
|
title: b.title,
|
|
@@ -2229,24 +2197,22 @@ class AiHubServer {
|
|
|
2229
2197
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
|
|
2230
2198
|
}
|
|
2231
2199
|
});
|
|
2232
|
-
// Issue #540/#701: POST /api/ai-hub/manager-team/assign
|
|
2233
|
-
// Proxies to the hosted server (which owns the DB + seat enforcement)
|
|
2234
|
-
//
|
|
2200
|
+
// Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
|
|
2201
|
+
// Proxies to the hosted server (which owns the DB + seat enforcement), using
|
|
2202
|
+
// the Hub's own ~/.fraim/config.json apiKey — not a caller-supplied header.
|
|
2235
2203
|
// Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
|
|
2236
2204
|
this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
|
|
2237
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2238
2205
|
const { personaKey } = (req.body ?? {});
|
|
2239
2206
|
if (!personaKey)
|
|
2240
2207
|
return res.status(400).json({ error: 'personaKey required' });
|
|
2241
|
-
const result = await this.remoteGateway.assignManagerTeam(
|
|
2208
|
+
const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
|
|
2242
2209
|
return res.status(result.status).json(result.body);
|
|
2243
2210
|
});
|
|
2244
|
-
// Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2211
|
+
// Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2245
2212
|
// Proxies the seat release to the hosted server.
|
|
2246
2213
|
this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
|
|
2247
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2248
2214
|
const { personaKey } = req.params;
|
|
2249
|
-
await this.remoteGateway.removeManagerTeam(
|
|
2215
|
+
await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
|
|
2250
2216
|
return res.status(204).end();
|
|
2251
2217
|
});
|
|
2252
2218
|
// Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
|
|
@@ -2277,7 +2243,27 @@ class AiHubServer {
|
|
|
2277
2243
|
// deletes are honored (a conversation absent here is dropped).
|
|
2278
2244
|
const prior = this.conversationStore.loadProject(key);
|
|
2279
2245
|
const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
|
|
2280
|
-
|
|
2246
|
+
// Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
|
|
2247
|
+
// a project PUT persists the client's current-project list, but that list can transiently
|
|
2248
|
+
// include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
|
|
2249
|
+
// project the user switched away from). Never file such a record into this project bucket —
|
|
2250
|
+
// its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
|
|
2251
|
+
// their sentinel buckets legitimately hold records whose projectPath points at a project.
|
|
2252
|
+
const belongsInBucket = (incoming) => {
|
|
2253
|
+
if (scope)
|
|
2254
|
+
return true;
|
|
2255
|
+
const incomingScope = incoming.scope
|
|
2256
|
+
?? incoming.invokedArea;
|
|
2257
|
+
if (incomingScope === 'manager' || incomingScope === 'company')
|
|
2258
|
+
return false;
|
|
2259
|
+
const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
|
|
2260
|
+
if (!own)
|
|
2261
|
+
return true;
|
|
2262
|
+
return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
|
|
2263
|
+
};
|
|
2264
|
+
const conversations = body.conversations
|
|
2265
|
+
.filter((incoming) => belongsInBucket(incoming))
|
|
2266
|
+
.map((incoming) => {
|
|
2281
2267
|
const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
|
|
2282
2268
|
return existing ? { ...existing, ...incoming } : incoming;
|
|
2283
2269
|
});
|
|
@@ -2375,14 +2361,9 @@ class AiHubServer {
|
|
|
2375
2361
|
const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
|
|
2376
2362
|
return res.json({ ok: true, projects });
|
|
2377
2363
|
});
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
return res.status(400).json({ error: 'apiKey required' });
|
|
2382
|
-
const prefs = this.preferencesStore.load(this.projectPath);
|
|
2383
|
-
this.preferencesStore.save({ ...prefs, apiKey });
|
|
2384
|
-
return res.json({ ok: true });
|
|
2385
|
-
});
|
|
2364
|
+
// Issue #750: POST /api/ai-hub/api-key is removed — there is no Hub-local
|
|
2365
|
+
// apiKey to persist. Identity always comes from ~/.fraim/config.json
|
|
2366
|
+
// (set by `fraim setup`), read fresh via resolveApiKey() on every request.
|
|
2386
2367
|
this.app.post('/api/ai-hub/preferences', (req, res) => {
|
|
2387
2368
|
const { personaKey } = req.body;
|
|
2388
2369
|
const prefs = this.preferencesStore.load(this.projectPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.200",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/script.js
CHANGED
|
@@ -9631,23 +9631,15 @@ async function tfHandleConnectedAuthMessage(event) {
|
|
|
9631
9631
|
}
|
|
9632
9632
|
}
|
|
9633
9633
|
|
|
9634
|
+
// Issue #750: the Hub's identity comes from ~/.fraim/config.json (set by
|
|
9635
|
+
// `fraim setup`), not a Hub-local copy — there is nothing to persist here.
|
|
9636
|
+
// A key recovered via embedded sign-in is used for the rest of this browser
|
|
9637
|
+
// session only (in-memory); it does not survive a Hub restart, by design.
|
|
9634
9638
|
async function tfRememberConnectedApiKey(apiKey) {
|
|
9635
|
-
const changed = apiKey !== state.storedApiKey;
|
|
9636
9639
|
state.storedApiKey = apiKey;
|
|
9637
9640
|
if (state.bootstrap && state.bootstrap.preferences) {
|
|
9638
9641
|
state.bootstrap.preferences.apiKey = apiKey;
|
|
9639
9642
|
}
|
|
9640
|
-
if (changed) {
|
|
9641
|
-
try {
|
|
9642
|
-
await requestJson('/api/ai-hub/api-key', {
|
|
9643
|
-
method: 'POST',
|
|
9644
|
-
headers: { 'Content-Type': 'application/json' },
|
|
9645
|
-
body: JSON.stringify({ apiKey }),
|
|
9646
|
-
});
|
|
9647
|
-
} catch (error) {
|
|
9648
|
-
console.warn('[ai-hub] Could not persist connected auth key:', error);
|
|
9649
|
-
}
|
|
9650
|
-
}
|
|
9651
9643
|
}
|
|
9652
9644
|
|
|
9653
9645
|
// ---------------------------------------------------------------------------
|
|
@@ -10352,6 +10344,12 @@ async function tfSwitchProjectFolder(folderPath) {
|
|
|
10352
10344
|
return;
|
|
10353
10345
|
}
|
|
10354
10346
|
state.projectPath = normalized.folderPath;
|
|
10347
|
+
// Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
|
|
10348
|
+
// project's active conversation on switch. Otherwise the just-finished run stays state.activeId
|
|
10349
|
+
// while state.projectPath is the new project, and a debounced conversation PUT during the
|
|
10350
|
+
// cached-bucket fast render below would flush that foreign conversation into the new project's
|
|
10351
|
+
// bucket. hydrateConversationsFromServer recomputes the correct activeId for this project.
|
|
10352
|
+
state.activeId = null;
|
|
10355
10353
|
// #521 fix: the context cache (state._ctxCache) is keyed by context key only
|
|
10356
10354
|
// (projectContext/projectBrief/projectRules), NOT by project — so without this
|
|
10357
10355
|
// the new project's Brief section would render the PREVIOUS project's content
|
|
@@ -10850,6 +10848,12 @@ function tfWireShell() {
|
|
|
10850
10848
|
// replacing the hardcoded "SM" placeholder. Falls back gracefully when the email
|
|
10851
10849
|
// isn't known yet (e.g. before the proxy has persisted it).
|
|
10852
10850
|
function tfAccountIdentity() {
|
|
10851
|
+
// Issue #750: bootstrap.userEmail is `null` (not just missing) once the server
|
|
10852
|
+
// has definitively resolved "no ~/.fraim/config.json apiKey" — distinct from
|
|
10853
|
+
// the transient pre-bootstrap window where state.bootstrap doesn't exist yet.
|
|
10854
|
+
if (state.bootstrap && state.bootstrap.userEmail === null) {
|
|
10855
|
+
return { email: '', name: 'You', initials: '·', notConnected: true };
|
|
10856
|
+
}
|
|
10853
10857
|
const email = (state.bootstrap && state.bootstrap.userEmail) || '';
|
|
10854
10858
|
if (!email || !email.includes('@')) {
|
|
10855
10859
|
return { email: '', name: 'You', initials: '·' };
|
|
@@ -10869,7 +10873,7 @@ function tfPopulateAccountMenu() {
|
|
|
10869
10873
|
const avEl = document.getElementById('am-av');
|
|
10870
10874
|
const avatarBtn = document.getElementById('avatar-btn');
|
|
10871
10875
|
if (nameEl) nameEl.textContent = id.name;
|
|
10872
|
-
if (emailEl) emailEl.textContent = id.email;
|
|
10876
|
+
if (emailEl) emailEl.textContent = id.notConnected ? 'Not connected — run `fraim setup`' : id.email;
|
|
10873
10877
|
if (avEl) avEl.textContent = id.initials;
|
|
10874
10878
|
if (avatarBtn) avatarBtn.textContent = id.initials;
|
|
10875
10879
|
}
|