fraim-hub 2.0.204

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.
Files changed (120) hide show
  1. package/README.md +467 -0
  2. package/bin/fraim-hub.js +17 -0
  3. package/dist/src/ai-hub/brand-store.js +17 -0
  4. package/dist/src/ai-hub/catalog.js +412 -0
  5. package/dist/src/ai-hub/cert-store.js +70 -0
  6. package/dist/src/ai-hub/cli.js +212 -0
  7. package/dist/src/ai-hub/configured-agents.js +283 -0
  8. package/dist/src/ai-hub/conversation-store.js +428 -0
  9. package/dist/src/ai-hub/desktop-main.js +449 -0
  10. package/dist/src/ai-hub/hosts.js +1669 -0
  11. package/dist/src/ai-hub/hub-latest-version.js +52 -0
  12. package/dist/src/ai-hub/hub-launch-decision.js +57 -0
  13. package/dist/src/ai-hub/hub-runtime-file.js +43 -0
  14. package/dist/src/ai-hub/managed-browser.js +269 -0
  15. package/dist/src/ai-hub/manager-turns.js +64 -0
  16. package/dist/src/ai-hub/office-sideload.js +156 -0
  17. package/dist/src/ai-hub/openclaw-bridge.js +250 -0
  18. package/dist/src/ai-hub/preferences.js +201 -0
  19. package/dist/src/ai-hub/remote-hub-gateway.js +88 -0
  20. package/dist/src/ai-hub/server.js +4013 -0
  21. package/dist/src/ai-hub/types.js +2 -0
  22. package/dist/src/ai-hub/url-safety.js +19 -0
  23. package/dist/src/api/admin/payments.js +33 -0
  24. package/dist/src/api/admin/sales-leads.js +21 -0
  25. package/dist/src/api/payment/create-session.js +338 -0
  26. package/dist/src/api/payment/dashboard-link.js +149 -0
  27. package/dist/src/api/payment/session-details.js +31 -0
  28. package/dist/src/api/payment/webhook.js +587 -0
  29. package/dist/src/api/sales/contact.js +44 -0
  30. package/dist/src/cli/api/get-provider-client.js +41 -0
  31. package/dist/src/cli/api/provider-client.js +107 -0
  32. package/dist/src/cli/commands/add-ide.js +462 -0
  33. package/dist/src/cli/fraim-hub.js +50 -0
  34. package/dist/src/cli/internal/device-flow-service.js +83 -0
  35. package/dist/src/cli/mcp/command-resolution.js +81 -0
  36. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  37. package/dist/src/cli/mcp/ide-formats.js +317 -0
  38. package/dist/src/cli/mcp/mcp-server-builder.js +48 -0
  39. package/dist/src/cli/mcp/mcp-server-registry.js +173 -0
  40. package/dist/src/cli/providers/local-provider-registry.js +165 -0
  41. package/dist/src/cli/providers/provider-registry.js +230 -0
  42. package/dist/src/cli/setup/claude-code-telemetry.js +59 -0
  43. package/dist/src/cli/setup/codex-local-config.js +37 -0
  44. package/dist/src/cli/setup/ide-detector.js +383 -0
  45. package/dist/src/cli/setup/ide-global-integration.js +100 -0
  46. package/dist/src/cli/setup/ide-invocation-surfaces.js +160 -0
  47. package/dist/src/cli/setup/mcp-config-generator.js +225 -0
  48. package/dist/src/cli/setup/provider-prompts.js +339 -0
  49. package/dist/src/cli/utils/managed-agent-paths.js +118 -0
  50. package/dist/src/cli/utils/script-sync-utils.js +221 -0
  51. package/dist/src/cli/utils/user-config.js +100 -0
  52. package/dist/src/cli/utils/version-utils.js +35 -0
  53. package/dist/src/config/ai-manager-hiring.js +121 -0
  54. package/dist/src/config/feature-flags.js +25 -0
  55. package/dist/src/config/persona-capability-bundles.js +277 -0
  56. package/dist/src/config/persona-hiring.js +270 -0
  57. package/dist/src/config/portfolio-slug-overrides.js +17 -0
  58. package/dist/src/config/pricing.js +37 -0
  59. package/dist/src/config/stripe.js +43 -0
  60. package/dist/src/core/brand-store.js +232 -0
  61. package/dist/src/core/quality-evidence.js +331 -0
  62. package/dist/src/core/utils/git-utils.js +194 -0
  63. package/dist/src/core/utils/ports.js +32 -0
  64. package/dist/src/core/utils/project-fraim-paths.js +220 -0
  65. package/dist/src/db/payment-repository.js +61 -0
  66. package/dist/src/first-run/types.js +93 -0
  67. package/dist/src/fraim/db-service.js +2409 -0
  68. package/dist/src/local-mcp-server/agent-token-prices.js +167 -0
  69. package/dist/src/local-mcp-server/learning-context-builder.js +975 -0
  70. package/dist/src/middleware/rate-limit.js +110 -0
  71. package/dist/src/models/payment.js +2 -0
  72. package/dist/src/routes/payment-routes.js +186 -0
  73. package/dist/src/routes/persona-catalog-routes.js +84 -0
  74. package/dist/src/services/dashboard-access.js +27 -0
  75. package/dist/src/services/email-service.js +951 -0
  76. package/dist/src/services/persona-entitlement-service.js +352 -0
  77. package/dist/src/services/workspace-identity.js +21 -0
  78. package/dist/src/types/analytics.js +2 -0
  79. package/dist/src/utils/payment-calculator.js +52 -0
  80. package/extensions/office-word/favicon.ico +0 -0
  81. package/extensions/office-word/icon-64.png +0 -0
  82. package/extensions/office-word/manifest.xml +33 -0
  83. package/extensions/office-word/taskpane.html +242 -0
  84. package/index.js +85 -0
  85. package/package.json +106 -0
  86. package/public/ai-hub/index.html +910 -0
  87. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  88. package/public/ai-hub/powerpoint-taskpane/index.html +236 -0
  89. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  90. package/public/ai-hub/review.css +444 -0
  91. package/public/ai-hub/script.js +11737 -0
  92. package/public/ai-hub/styles.css +5102 -0
  93. package/public/first-run/error-frame.js +100 -0
  94. package/public/first-run/index.html +35 -0
  95. package/public/first-run/script.js +739 -0
  96. package/public/first-run/styles.css +929 -0
  97. package/public/portfolio/ashley.html +523 -0
  98. package/public/portfolio/auditya.html +83 -0
  99. package/public/portfolio/banke.html +83 -0
  100. package/public/portfolio/beza.html +659 -0
  101. package/public/portfolio/careena.html +632 -0
  102. package/public/portfolio/casey.html +568 -0
  103. package/public/portfolio/celia.html +490 -0
  104. package/public/portfolio/deidre.html +642 -0
  105. package/public/portfolio/gautam.html +597 -0
  106. package/public/portfolio/hari.html +469 -0
  107. package/public/portfolio/huxley.html +1354 -0
  108. package/public/portfolio/index.html +741 -0
  109. package/public/portfolio/maestro.html +518 -0
  110. package/public/portfolio/mandy.html +590 -0
  111. package/public/portfolio/mona.html +597 -0
  112. package/public/portfolio/pam.html +887 -0
  113. package/public/portfolio/procella.html +107 -0
  114. package/public/portfolio/qasm.html +569 -0
  115. package/public/portfolio/ricardo.html +489 -0
  116. package/public/portfolio/sade.html +560 -0
  117. package/public/portfolio/sam.html +654 -0
  118. package/public/portfolio/sechar.html +580 -0
  119. package/public/portfolio/sreya.html +599 -0
  120. package/public/portfolio/swen.html +601 -0
@@ -0,0 +1,428 @@
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.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
7
+ exports.conversationScopeKey = conversationScopeKey;
8
+ exports.migrateConversationStore = migrateConversationStore;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
12
+ // Issue #708: reserved, project-independent bucket keys for non-project scopes.
13
+ // They are NOT filesystem paths and must never be passed through path.resolve.
14
+ exports.MANAGER_SCOPE_KEY = '@manager';
15
+ exports.COMPANY_SCOPE_KEY = '@company';
16
+ /**
17
+ * Issue #708: resolve the conversation store bucket key for a given scope.
18
+ * - 'manager'/'company' → a stable sentinel key (project-independent home).
19
+ * - 'project' (or undefined) → the resolved project path (existing behavior).
20
+ */
21
+ function conversationScopeKey(scope, projectPath) {
22
+ if (scope === 'manager')
23
+ return exports.MANAGER_SCOPE_KEY;
24
+ if (scope === 'company')
25
+ return exports.COMPANY_SCOPE_KEY;
26
+ return normalizeConversationKey(projectPath);
27
+ }
28
+ const emptyProjectState = () => ({
29
+ activeId: null,
30
+ conversations: [],
31
+ });
32
+ const emptyStoreFile = () => ({
33
+ version: 1,
34
+ projects: {},
35
+ });
36
+ function timestampValue(value) {
37
+ if (typeof value === 'number')
38
+ return Number.isFinite(value) ? value : 0;
39
+ if (typeof value === 'string') {
40
+ const parsed = Date.parse(value);
41
+ if (Number.isFinite(parsed))
42
+ return parsed;
43
+ const numeric = Number(value);
44
+ return Number.isFinite(numeric) ? numeric : 0;
45
+ }
46
+ return 0;
47
+ }
48
+ // Issue #708: sentinel scope keys (e.g. '@manager') are stable bucket keys, not paths —
49
+ // pass them through untouched; resolve everything else as a real project path.
50
+ function normalizeConversationKey(key) {
51
+ if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
52
+ return key;
53
+ }
54
+ return path_1.default.resolve(key || process.cwd());
55
+ }
56
+ // Retained name for project-path stamping; now sentinel-aware so manager/company
57
+ // records keep their scope key rather than being resolved into cwd/@manager.
58
+ function normalizeProjectPath(projectPath) {
59
+ return normalizeConversationKey(projectPath);
60
+ }
61
+ // Issue #708: one-time, idempotent migration. Legacy manager/company-invoked runs were
62
+ // stored inside project buckets and distinguished only by the client-side `invokedArea`
63
+ // field. Fold them into the reserved scope buckets (stamping `scope`) so they have a
64
+ // stable, project-independent home. Runs already in a scope bucket are left untouched.
65
+ function migrateScopeBuckets(store) {
66
+ const projects = {};
67
+ const ensure = (key) => {
68
+ if (!projects[key])
69
+ projects[key] = { activeId: null, conversations: [] };
70
+ return projects[key];
71
+ };
72
+ // Seed existing scope buckets first so their activeId is preserved.
73
+ for (const [key, state] of Object.entries(store.projects || {})) {
74
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY) {
75
+ ensure(key).conversations.push(...(state.conversations || []));
76
+ projects[key].activeId = state.activeId ?? null;
77
+ }
78
+ }
79
+ for (const [key, state] of Object.entries(store.projects || {})) {
80
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
81
+ continue;
82
+ const stay = [];
83
+ for (const conv of (state.conversations || [])) {
84
+ const scope = conv.scope
85
+ ?? conv.invokedArea;
86
+ if (scope === 'manager' || scope === 'company') {
87
+ const bucketKey = scope === 'manager' ? exports.MANAGER_SCOPE_KEY : exports.COMPANY_SCOPE_KEY;
88
+ const bucket = ensure(bucketKey);
89
+ if (!bucket.conversations.some((c) => c && c.id === conv.id)) {
90
+ bucket.conversations.push({ ...conv, scope: scope });
91
+ }
92
+ }
93
+ else {
94
+ stay.push(conv);
95
+ }
96
+ }
97
+ projects[key] = { activeId: state.activeId ?? null, conversations: stay };
98
+ }
99
+ return { version: 1, projects };
100
+ }
101
+ // Canonical comparison key for a project path (case-insensitive on win32), matching the
102
+ // convention used by removeProject and the preferences store.
103
+ function canonicalProjectPathKey(value) {
104
+ const resolved = path_1.default.resolve(value);
105
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
106
+ }
107
+ // How much run history a record carries. Used to decide the winner when the same conversation id
108
+ // exists in two buckets: the copy with real history must never be overwritten by a header-only
109
+ // shell (which is exactly what a mis-filed slim PUT produces).
110
+ function conversationRichness(conv) {
111
+ const value = conv;
112
+ const messages = Array.isArray(value?.messages) ? value.messages.length : 0;
113
+ const events = Array.isArray(value?.events) ? value.events.length : 0;
114
+ return messages + events;
115
+ }
116
+ // Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
117
+ // richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
118
+ function placeConversationInBucket(bucket, conv) {
119
+ const value = conv;
120
+ const id = value && typeof value.id === 'string' ? value.id : '';
121
+ if (!id) {
122
+ bucket.conversations.push(conv);
123
+ return;
124
+ }
125
+ const idx = bucket.conversations.findIndex((entry) => entry && entry.id === id);
126
+ if (idx < 0) {
127
+ bucket.conversations.push(conv);
128
+ return;
129
+ }
130
+ const existing = bucket.conversations[idx];
131
+ const existingScore = conversationRichness(existing);
132
+ const incomingScore = conversationRichness(conv);
133
+ if (incomingScore > existingScore) {
134
+ bucket.conversations[idx] = conv;
135
+ }
136
+ else if (incomingScore === existingScore
137
+ && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
138
+ bucket.conversations[idx] = conv;
139
+ }
140
+ }
141
+ // A conversation's identity is unique only WITHIN a bucket, and a bucket key is a project path.
142
+ // The buggy client PUT path could file a conversation whose own projectPath points at project B
143
+ // into project A's bucket (see docs/rca/hub-conversation-cross-project-leak.md), so the same run
144
+ // appeared under two projects and the mis-filed copy was a header-only shell. This one-time,
145
+ // idempotent, read-time migration relocates every project-scoped record back to the bucket that
146
+ // matches its own projectPath, deduping so the copy with real run history wins. A bucket activeId
147
+ // that pointed at a relocated record is cleared. Sentinel scope buckets (@manager/@company)
148
+ // legitimately hold records whose projectPath points at a project, so they are left untouched.
149
+ function migrateProjectBuckets(store) {
150
+ const projects = {};
151
+ const ensure = (key) => {
152
+ if (!projects[key])
153
+ projects[key] = { activeId: null, conversations: [] };
154
+ return projects[key];
155
+ };
156
+ // Preserve every existing bucket key and its activeId up front.
157
+ for (const [key, state] of Object.entries(store.projects || {})) {
158
+ ensure(key).activeId = state.activeId ?? null;
159
+ }
160
+ for (const [bucketKey, state] of Object.entries(store.projects || {})) {
161
+ const isSentinel = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY;
162
+ for (const conv of (state.conversations || [])) {
163
+ if (!conv || typeof conv !== 'object')
164
+ continue;
165
+ const scope = conv.scope
166
+ ?? conv.invokedArea;
167
+ const ownPath = typeof conv.projectPath === 'string'
168
+ ? conv.projectPath
169
+ : '';
170
+ const mismatched = !isSentinel
171
+ && scope !== 'manager' && scope !== 'company'
172
+ && ownPath.length > 0
173
+ && canonicalProjectPathKey(ownPath) !== canonicalProjectPathKey(bucketKey);
174
+ if (!mismatched) {
175
+ placeConversationInBucket(ensure(bucketKey), conv);
176
+ continue;
177
+ }
178
+ // Relocate to the record's own project bucket.
179
+ placeConversationInBucket(ensure(path_1.default.resolve(ownPath)), conv);
180
+ }
181
+ }
182
+ // Any activeId that no longer resolves to a conversation in its bucket (because that record was
183
+ // relocated out) is stale — clear it so a project never points at another project's run.
184
+ for (const state of Object.values(projects)) {
185
+ if (state.activeId && !state.conversations.some((entry) => entry && entry.id === state.activeId)) {
186
+ state.activeId = null;
187
+ }
188
+ }
189
+ return { version: 1, projects };
190
+ }
191
+ // The full on-read migration pipeline, exported so a one-off maintenance/cleanup pass can heal an
192
+ // existing store file through the EXACT same code path the store uses at runtime (rather than a
193
+ // duplicated, drift-prone reimplementation). Operates on raw records, so it never drops a record
194
+ // via normalization.
195
+ function migrateConversationStore(store) {
196
+ return migrateProjectBuckets(migrateScopeBuckets(store));
197
+ }
198
+ function sameStoreMetadata(left, right) {
199
+ return left.ctimeMs === right.ctimeMs
200
+ && left.mtimeMs === right.mtimeMs
201
+ && left.size === right.size;
202
+ }
203
+ function cloneJsonValue(value) {
204
+ if (Array.isArray(value)) {
205
+ return value.map((entry) => cloneJsonValue(entry));
206
+ }
207
+ if (value && typeof value === 'object') {
208
+ const cloned = {};
209
+ for (const [key, entry] of Object.entries(value)) {
210
+ cloned[key] = cloneJsonValue(entry);
211
+ }
212
+ return cloned;
213
+ }
214
+ return value;
215
+ }
216
+ function cloneConversationRecord(record) {
217
+ return cloneJsonValue(record);
218
+ }
219
+ function cloneProjectState(state) {
220
+ return {
221
+ activeId: state?.activeId ?? null,
222
+ conversations: Array.isArray(state?.conversations)
223
+ ? state.conversations.map((record) => cloneConversationRecord(record))
224
+ : [],
225
+ };
226
+ }
227
+ function cloneStoreFile(store) {
228
+ const projects = {};
229
+ for (const [key, state] of Object.entries(store.projects || {})) {
230
+ projects[key] = cloneProjectState(state);
231
+ }
232
+ return { version: 1, projects };
233
+ }
234
+ function normalizeConversation(projectPath, raw) {
235
+ if (!raw || typeof raw !== 'object')
236
+ return null;
237
+ const value = raw;
238
+ if (typeof value.id !== 'string' || value.id.length === 0)
239
+ return null;
240
+ if (typeof value.title !== 'string')
241
+ return null;
242
+ if (typeof value.jobId !== 'string')
243
+ return null;
244
+ if (value.agentName !== 'codex' && value.agentName !== 'claude' && value.agentName !== 'gemini')
245
+ return null;
246
+ if (value.status !== 'running' && value.status !== 'completed' && value.status !== 'failed')
247
+ return null;
248
+ return {
249
+ ...value,
250
+ id: value.id,
251
+ projectPath: normalizeProjectPath(typeof value.projectPath === 'string' ? value.projectPath : projectPath),
252
+ title: value.title,
253
+ jobId: value.jobId,
254
+ agentName: value.agentName,
255
+ status: value.status,
256
+ createdAt: value.createdAt ?? new Date().toISOString(),
257
+ lastUpdatedAt: value.lastUpdatedAt ?? value.createdAt ?? new Date().toISOString(),
258
+ };
259
+ }
260
+ function normalizeProjectState(projectPath, raw) {
261
+ if (!raw || typeof raw !== 'object')
262
+ return emptyProjectState();
263
+ const value = raw;
264
+ const conversations = Array.isArray(value.conversations)
265
+ ? value.conversations.map((entry) => normalizeConversation(projectPath, entry)).filter((entry) => Boolean(entry))
266
+ : [];
267
+ const activeId = typeof value.activeId === 'string' && conversations.some((entry) => entry.id === value.activeId)
268
+ ? value.activeId
269
+ : null;
270
+ return { activeId, conversations };
271
+ }
272
+ class AiHubConversationStore {
273
+ constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations.json')) {
274
+ this.stateFilePath = stateFilePath;
275
+ this.cachedStore = null;
276
+ }
277
+ listProjectPaths() {
278
+ const state = this.readStore();
279
+ return Object.keys(state.projects || {}).map((projectPath) => normalizeProjectPath(projectPath));
280
+ }
281
+ loadProject(projectPath) {
282
+ const state = this.readStore();
283
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
284
+ return cloneProjectState(normalizeProjectState(normalizedProjectPath, state.projects[normalizedProjectPath]));
285
+ }
286
+ // Issue #719: delete a project's KEY from the store entirely. Writing an empty
287
+ // conversation list is not enough — knownProjects re-derives a project from
288
+ // every stored key (listProjectPaths), so removal must drop the key itself.
289
+ // Sentinel scope buckets (@manager/@company) are not projects and never removable.
290
+ removeProject(projectPath) {
291
+ if (projectPath === exports.MANAGER_SCOPE_KEY || projectPath === exports.COMPANY_SCOPE_KEY)
292
+ return false;
293
+ const canonicalKey = (value) => {
294
+ const resolved = path_1.default.resolve(value);
295
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
296
+ };
297
+ const target = canonicalKey(projectPath);
298
+ const state = cloneStoreFile(this.readStore());
299
+ let removed = false;
300
+ for (const key of Object.keys(state.projects)) {
301
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
302
+ continue;
303
+ if (canonicalKey(key) === target) {
304
+ delete state.projects[key];
305
+ removed = true;
306
+ }
307
+ }
308
+ if (removed)
309
+ this.writeStore(state);
310
+ return removed;
311
+ }
312
+ replaceProject(projectPath, next) {
313
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
314
+ const normalized = normalizeProjectState(normalizedProjectPath, next);
315
+ const state = cloneStoreFile(this.readStore());
316
+ state.projects[normalizedProjectPath] = normalized;
317
+ this.writeStore(state);
318
+ return cloneProjectState(normalized);
319
+ }
320
+ upsertConversation(projectPath, conversation, activeId) {
321
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
322
+ const normalizedConversation = normalizeConversation(normalizedProjectPath, conversation);
323
+ if (!normalizedConversation) {
324
+ return this.loadProject(normalizedProjectPath);
325
+ }
326
+ const current = this.loadProject(normalizedProjectPath);
327
+ const idx = current.conversations.findIndex((entry) => entry.id === normalizedConversation.id);
328
+ if (idx >= 0) {
329
+ const existing = current.conversations[idx];
330
+ if (timestampValue(normalizedConversation.lastUpdatedAt) < timestampValue(existing.lastUpdatedAt)) {
331
+ return current;
332
+ }
333
+ current.conversations[idx] = normalizedConversation;
334
+ }
335
+ else {
336
+ current.conversations.unshift(normalizedConversation);
337
+ }
338
+ current.activeId = activeId === undefined ? current.activeId : activeId;
339
+ if (current.activeId && !current.conversations.some((entry) => entry.id === current.activeId)) {
340
+ current.activeId = null;
341
+ }
342
+ return this.replaceProject(normalizedProjectPath, current);
343
+ }
344
+ patchConversation(projectPath, conversationId, patch) {
345
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
346
+ const current = this.loadProject(normalizedProjectPath);
347
+ const idx = current.conversations.findIndex((entry) => entry.id === conversationId);
348
+ if (idx < 0) {
349
+ const candidate = normalizeConversation(normalizedProjectPath, {
350
+ ...patch,
351
+ id: conversationId,
352
+ projectPath: normalizedProjectPath,
353
+ });
354
+ if (candidate) {
355
+ current.conversations.unshift(candidate);
356
+ return this.replaceProject(normalizedProjectPath, current);
357
+ }
358
+ return current;
359
+ }
360
+ const existing = current.conversations[idx];
361
+ if (patch.lastUpdatedAt !== undefined
362
+ && timestampValue(patch.lastUpdatedAt) < timestampValue(existing.lastUpdatedAt)) {
363
+ return current;
364
+ }
365
+ current.conversations[idx] = {
366
+ ...existing,
367
+ ...patch,
368
+ id: existing.id,
369
+ projectPath: normalizedProjectPath,
370
+ agentName: patch.agentName || existing.agentName,
371
+ lastUpdatedAt: patch.lastUpdatedAt ?? new Date().toISOString(),
372
+ };
373
+ return this.replaceProject(normalizedProjectPath, current);
374
+ }
375
+ readStore() {
376
+ const metadata = this.readStoreMetadata();
377
+ if (!metadata) {
378
+ this.cachedStore = null;
379
+ return emptyStoreFile();
380
+ }
381
+ if (this.cachedStore && sameStoreMetadata(this.cachedStore.metadata, metadata)) {
382
+ return this.cachedStore.store;
383
+ }
384
+ try {
385
+ const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
386
+ if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
387
+ const empty = emptyStoreFile();
388
+ this.cachedStore = { metadata, store: empty };
389
+ return empty;
390
+ }
391
+ // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read, then
392
+ // relocate any project-scoped record mis-filed under the wrong project bucket back to its
393
+ // own project (see docs/rca/hub-conversation-cross-project-leak.md).
394
+ const migrated = migrateConversationStore({ version: 1, projects: raw.projects });
395
+ this.cachedStore = { metadata, store: migrated };
396
+ return migrated;
397
+ }
398
+ catch {
399
+ const empty = emptyStoreFile();
400
+ this.cachedStore = { metadata, store: empty };
401
+ return empty;
402
+ }
403
+ }
404
+ writeStore(store) {
405
+ fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
406
+ const tempPath = `${this.stateFilePath}.${process.pid}.tmp`;
407
+ fs_1.default.writeFileSync(tempPath, JSON.stringify(store, null, 2), 'utf8');
408
+ fs_1.default.renameSync(tempPath, this.stateFilePath);
409
+ const metadata = this.readStoreMetadata();
410
+ this.cachedStore = metadata ? { metadata, store: cloneStoreFile(store) } : null;
411
+ }
412
+ readStoreMetadata() {
413
+ try {
414
+ const stat = fs_1.default.statSync(this.stateFilePath);
415
+ if (!stat.isFile())
416
+ return null;
417
+ return {
418
+ ctimeMs: stat.ctimeMs,
419
+ mtimeMs: stat.mtimeMs,
420
+ size: stat.size,
421
+ };
422
+ }
423
+ catch {
424
+ return null;
425
+ }
426
+ }
427
+ }
428
+ exports.AiHubConversationStore = AiHubConversationStore;