@memberjunction/core-entities 6.2.0-edge.3 → 6.2.0-edge.4

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 (47) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/custom/ConversationForkAccess.d.ts +15 -0
  3. package/dist/custom/ConversationForkAccess.d.ts.map +1 -0
  4. package/dist/custom/ConversationForkAccess.js +28 -0
  5. package/dist/custom/ConversationForkAccess.js.map +1 -0
  6. package/dist/custom/ConversationWriteAccess.d.ts +16 -0
  7. package/dist/custom/ConversationWriteAccess.d.ts.map +1 -0
  8. package/dist/custom/ConversationWriteAccess.js +34 -0
  9. package/dist/custom/ConversationWriteAccess.js.map +1 -0
  10. package/dist/custom/MJConversationBranchEntityExtended.d.ts +30 -0
  11. package/dist/custom/MJConversationBranchEntityExtended.d.ts.map +1 -0
  12. package/dist/custom/MJConversationBranchEntityExtended.js +120 -0
  13. package/dist/custom/MJConversationBranchEntityExtended.js.map +1 -0
  14. package/dist/custom/MJConversationDetailEntityExtended.d.ts.map +1 -1
  15. package/dist/custom/MJConversationDetailEntityExtended.js +15 -25
  16. package/dist/custom/MJConversationDetailEntityExtended.js.map +1 -1
  17. package/dist/custom/MJDashboardEntityExtended.d.ts.map +1 -1
  18. package/dist/custom/MJDashboardEntityExtended.js +3 -0
  19. package/dist/custom/MJDashboardEntityExtended.js.map +1 -1
  20. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts.map +1 -1
  21. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +14 -13
  22. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js.map +1 -1
  23. package/dist/engines/FileStorageEngine.d.ts +40 -0
  24. package/dist/engines/FileStorageEngine.d.ts.map +1 -1
  25. package/dist/engines/FileStorageEngine.js +49 -0
  26. package/dist/engines/FileStorageEngine.js.map +1 -1
  27. package/dist/engines/UserInfoEngine.d.ts +16 -10
  28. package/dist/engines/UserInfoEngine.d.ts.map +1 -1
  29. package/dist/engines/UserInfoEngine.js +35 -25
  30. package/dist/engines/UserInfoEngine.js.map +1 -1
  31. package/dist/engines/artifacts.d.ts.map +1 -1
  32. package/dist/engines/artifacts.js +1 -0
  33. package/dist/engines/artifacts.js.map +1 -1
  34. package/dist/engines/conversations.d.ts +359 -34
  35. package/dist/engines/conversations.d.ts.map +1 -1
  36. package/dist/engines/conversations.js +611 -57
  37. package/dist/engines/conversations.js.map +1 -1
  38. package/dist/generated/entities/__mj.d.ts +1730 -190
  39. package/dist/generated/entities/__mj.d.ts.map +1 -1
  40. package/dist/generated/entities/__mj.js +696 -0
  41. package/dist/generated/entities/__mj.js.map +1 -1
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -0
  45. package/dist/index.js.map +1 -1
  46. package/package.json +5 -5
  47. package/readme.md +1 -1
@@ -14,7 +14,32 @@ const CONVERSATIONS_RESOURCE_TYPE_ID = '81D4BC3D-9FEB-EF11-B01A-286B35C04427';
14
14
  * {@link ConversationWindowSourceRow} — single-sourced so server callers cannot drift
15
15
  * from the assembler's requirements.
16
16
  */
17
- export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt'];
17
+ export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt', 'ReplacedAt'];
18
+ /** The fork kinds, keyed by {@link ForkKind} so a value CodeGen adds to the union fails to compile until it is listed. */
19
+ const FORK_KIND_KEYS = { Fork: true, Edit: true, Regenerate: true };
20
+ /** True when `value` is a key of {@link FORK_KIND_KEYS}. */
21
+ function isForkKind(value) {
22
+ return Object.prototype.hasOwnProperty.call(FORK_KIND_KEYS, value);
23
+ }
24
+ /** Every fork kind. */
25
+ export const FORK_KINDS = Object.keys(FORK_KIND_KEYS).filter(isForkKind);
26
+ /** Longest branch chain the path predicate will build. */
27
+ export const MAX_BRANCH_DEPTH = 32;
28
+ /** The Fields a fresh load of fork rows selects. */
29
+ export const ConversationBranchFields = ['ID', 'ConversationID', 'ParentBranchID', 'ForkFromSequence', 'Name', 'Kind', 'SourceDetailID', 'UserID', 'User', '__mj_CreatedAt'];
30
+ /** The Fields the fork-summary reads select. */
31
+ export const ForkSummaryRowFields = ['ID', 'BranchID', 'Sequence', 'Role', 'Message', 'UserID', 'AgentID', 'User', 'Agent', 'HiddenToUser', '__mj_CreatedAt'];
32
+ /** Most participants a fork summary lists. */
33
+ export const MAX_FORK_PARTICIPANTS = 3;
34
+ const FORK_NAME_MAX_WORDS = 6;
35
+ const FORK_NAME_MAX_CHARS = 40;
36
+ const FORK_PREVIEW_MAX_CHARS = 120;
37
+ function sameId(a, b) {
38
+ if (a == null || b == null) {
39
+ return a == null && b == null;
40
+ }
41
+ return NormalizeUUID(a) === NormalizeUUID(b);
42
+ }
18
43
  /**
19
44
  * Helper: parse a raw ConversationDetailComplete row into typed arrays.
20
45
  */
@@ -592,6 +617,29 @@ export class ConversationEngine extends BaseEngine {
592
617
  async MoveConversationToProject(conversationId, projectId, contextUser) {
593
618
  return this.SaveConversation(conversationId, { ProjectID: projectId }, contextUser);
594
619
  }
620
+ /**
621
+ * Creates a fork (an `MJ: Conversation Branches` row) started by `contextUser`. A fork before the
622
+ * first message shares nothing, so its parent is always Main. Never reads or writes the
623
+ * conversation row. Write access is the branch entity's rule (owner, or an Edit/Owner share).
624
+ * The server refuses it unless the person holds the `Conversations: Fork` authorization.
625
+ * @throws when the fork cannot be saved; the message carries the save result
626
+ */
627
+ async CreateFork(input, contextUser) {
628
+ const branch = await this.ProviderToUse.GetEntityObject('MJ: Conversation Branches', contextUser);
629
+ branch.NewRecord();
630
+ branch.ConversationID = input.ConversationID;
631
+ branch.Kind = input.Kind;
632
+ branch.ForkFromSequence = input.ForkFromSequence;
633
+ branch.ParentBranchID = input.ForkFromSequence == null ? null : input.ParentBranchID;
634
+ branch.SourceDetailID = input.SourceDetailID ?? null;
635
+ branch.UserID = contextUser.ID;
636
+ const name = input.Name?.trim();
637
+ branch.Name = name ? name : null;
638
+ if (!(await branch.Save())) {
639
+ throw new Error(`Failed to create the fork: ${branch.LatestResult?.CompleteMessage ?? 'unknown error'}`);
640
+ }
641
+ return branch;
642
+ }
595
643
  /**
596
644
  * Reparents a folder (project) under another folder, or to the top level when
597
645
  * parentId is null. Callers are responsible for preventing cycles (don't pass a
@@ -1184,36 +1232,33 @@ export class ConversationEngine extends BaseEngine {
1184
1232
  // ========================================================================
1185
1233
  // CONVERSATION DETAILS (Messages)
1186
1234
  // ========================================================================
1187
- /**
1188
- * Loads conversation details (messages) for a specific conversation.
1189
- * Results are cached for instant retrieval on subsequent calls.
1190
- *
1191
- * @param conversationId - The conversation to load details for
1192
- * @param contextUser - The current user context
1193
- * @param forceRefresh - If true, reloads even if cached
1194
- * @returns Array of conversation detail entities
1195
- */
1196
1235
  /**
1197
1236
  * Loads conversation details using the efficient GetConversationComplete query
1198
1237
  * which returns messages, agent runs, artifacts, ratings, and user avatars in one round-trip.
1199
- * Results are cached for instant retrieval on subsequent calls.
1238
+ * The cache holds every row of the conversation, on every branch path; a scope limits
1239
+ * only what this call returns.
1200
1240
  *
1201
1241
  * @param conversationId - The conversation to load details for
1202
1242
  * @param contextUser - The current user context
1203
1243
  * @param forceRefresh - If true, reloads even if cached
1204
- * @returns The full cache entry with all peripheral data
1244
+ * @param scope - When given, the returned entry holds only the rows in this scope
1245
+ * (see {@link scopedEntry}); when omitted, every row of the conversation
1246
+ * @returns The cache entry with all peripheral data, limited to the scope when one is given
1247
+ * @throws when the scope belongs to another conversation
1205
1248
  */
1206
- async LoadConversationDetails(conversationId, contextUser, forceRefresh = false) {
1249
+ async LoadConversationDetails(conversationId, contextUser, forceRefresh = false, scope) {
1250
+ ConversationEngine.assertScopeOf(conversationId, scope);
1207
1251
  const key = NormalizeUUID(conversationId);
1208
1252
  // Return cached if available and not forcing
1209
1253
  if (!forceRefresh) {
1210
1254
  const cached = this._detailCache.get(key);
1211
1255
  if (cached) {
1212
- return cached;
1256
+ return ConversationEngine.scopedEntry(cached, scope);
1213
1257
  }
1214
1258
  }
1215
1259
  // Use GetConversationComplete for one-round-trip loading of all data
1216
1260
  const rq = new RunQuery();
1261
+ // conversation-scope: whole-conversation cache; every consumer filters to its scope
1217
1262
  const result = await rq.RunQuery({
1218
1263
  QueryName: 'GetConversationComplete',
1219
1264
  CategoryPath: 'MJ/Conversations',
@@ -1232,13 +1277,41 @@ export class ConversationEngine extends BaseEngine {
1232
1277
  LoadedAt: new Date(),
1233
1278
  PeripheralDataStale: false
1234
1279
  };
1235
- return empty;
1280
+ return ConversationEngine.scopedEntry(empty, scope);
1236
1281
  }
1237
1282
  const rawData = result.Results;
1238
1283
  // Build the cache entry from the rich query result
1239
1284
  const cacheEntry = await this.buildDetailCacheFromRawData(rawData, contextUser);
1240
1285
  this._detailCache.set(key, cacheEntry);
1241
- return cacheEntry;
1286
+ return ConversationEngine.scopedEntry(cacheEntry, scope);
1287
+ }
1288
+ /**
1289
+ * Throws when a scope is given and belongs to another conversation.
1290
+ * @throws when the scope belongs to another conversation
1291
+ */
1292
+ static assertScopeOf(conversationId, scope) {
1293
+ if (scope && !sameId(scope.ConversationID, conversationId)) {
1294
+ throw new Error(`Conversation ${conversationId} is not the scope's conversation ${scope.ConversationID}`);
1295
+ }
1296
+ }
1297
+ /**
1298
+ * A cache entry limited to a scope: a copy whose `Details` hold only the rows in the scope,
1299
+ * and whose `RawData` holds the raw rows of those details, both in their cached order. The
1300
+ * peripheral maps are the cached ones, shared. The entry itself when no scope is given. The
1301
+ * cached entry is never changed. Callers check the scope's conversation first
1302
+ * ({@link assertScopeOf}).
1303
+ */
1304
+ static scopedEntry(entry, scope) {
1305
+ if (!scope) {
1306
+ return entry;
1307
+ }
1308
+ const details = ConversationEngine.FilterToScope(scope, entry.Details);
1309
+ const detailIds = new Set(details.map(d => NormalizeUUID(d.ID)));
1310
+ return {
1311
+ ...entry,
1312
+ Details: details,
1313
+ RawData: entry.RawData.filter(r => !!r.ID && detailIds.has(NormalizeUUID(r.ID))),
1314
+ };
1242
1315
  }
1243
1316
  /**
1244
1317
  * Loads ONE page of a conversation's transcript — the chat area's windowed history read.
@@ -1254,19 +1327,25 @@ export class ConversationEngine extends BaseEngine {
1254
1327
  * a partial entry there would silently starve the agent of everything before the summary
1255
1328
  * boundary, with no error. Use a separate partial cache if incremental caching is needed.
1256
1329
  *
1257
- * Does not throw for a read that FAILS — a failed load returns an empty window flagged
1258
- * {@link DetailWindowLoadResult.Failed} so the caller can leave its paging state intact
1259
- * rather than mistaking the failure for the start of the conversation.
1330
+ * Every detail read is limited to the path of `BranchID` (the trunk when omitted).
1331
+ * Replaced rows (`ReplacedAt` set) are never read.
1332
+ *
1333
+ * Does not throw for a read that FAILS — a failed branch-path read, like a failed page
1334
+ * read, returns an empty window flagged {@link DetailWindowLoadResult.Failed} so the
1335
+ * caller can leave its paging state intact rather than mistaking the failure for the
1336
+ * start of the conversation. A failed branch-path read never falls back to the trunk.
1260
1337
  *
1261
- * It is not, however, exception-proof: there is no `try/catch` here, so a provider that
1262
- * REJECTS rather than returning `Success: false` propagates out to the caller. That path
1263
- * is left deliberately — it degrades better than the handled one, because an exception
1264
- * skips the cursor write entirely and the caller's paging state survives untouched.
1338
+ * It is not, however, exception-proof: the detail reads have no `try/catch`, so a
1339
+ * provider that REJECTS rather than returning `Success: false` propagates out to the
1340
+ * caller. That path is left deliberately — it degrades better than the handled one,
1341
+ * because an exception skips the cursor write entirely and the caller's paging state
1342
+ * survives untouched.
1265
1343
  *
1266
1344
  * ### Round-trip profile — the counterweight to the payload win
1267
1345
  *
1268
1346
  * This trades ONE fat query for several thin ones. Per page:
1269
1347
  *
1348
+ * 0. {@link BranchPathFilterFresh} — one branch read, only when `BranchID` is set
1270
1349
  * 1. {@link fetchDetailRowsBySequence} — always
1271
1350
  * 2. {@link expandOldestSession} — only when the oldest row is session-stamped
1272
1351
  * 3. {@link hasOlderDetails} — always, in parallel with (4)
@@ -1288,13 +1367,18 @@ export class ConversationEngine extends BaseEngine {
1288
1367
  * over more content — and unlike first paint, a reader who has scrolled up has already
1289
1368
  * committed to reading back. If paging up ever feels slow, that is the number to raise.
1290
1369
  *
1291
- * @param params - Conversation, optional `Sequence` bound, and page sizing
1370
+ * @param params - Conversation, optional branch, optional `Sequence` bound, and page sizing
1292
1371
  * @param contextUser - The requesting user (entity RLS applies)
1293
1372
  */
1294
1373
  async LoadDetailWindow(params, contextUser) {
1295
1374
  const pageSize = params.PageSize ?? DEFAULT_WINDOW_PAGE_SIZE;
1296
1375
  const overread = params.RawOverread ?? pageSize * DETAIL_WINDOW_OVERREAD_FACTOR;
1297
- const page = await this.fetchDetailRowsBySequence(params.ConversationID, params.BeforeSequence, overread, contextUser);
1376
+ const pathFilter = await this.resolveWindowPathFilter(params, contextUser);
1377
+ if (pathFilter === null) {
1378
+ return emptyDetailWindowResult(true);
1379
+ }
1380
+ const liveFilter = ConversationEngine.LiveRowsFilter(pathFilter);
1381
+ const page = await this.fetchDetailRowsBySequence(liveFilter, params.BeforeSequence, overread, contextUser);
1298
1382
  // Deliberately two branches, not one. `null` is a FAILED read; an empty array is a
1299
1383
  // range that genuinely holds no rows. Collapsing them is what lets a transport blip
1300
1384
  // read downstream as "you have reached the start of the conversation".
@@ -1304,7 +1388,7 @@ export class ConversationEngine extends BaseEngine {
1304
1388
  if (page.length === 0) {
1305
1389
  return emptyDetailWindowResult(false);
1306
1390
  }
1307
- const details = await this.expandOldestSession(params.ConversationID, page, contextUser);
1391
+ const details = await this.expandOldestSession(liveFilter, page, contextUser);
1308
1392
  const oldestSequence = details[0].Sequence;
1309
1393
  const newestSequence = details[details.length - 1].Sequence;
1310
1394
  // Concurrent, not sequential: the probe needs only `oldestSequence` and the peripherals
@@ -1312,7 +1396,7 @@ export class ConversationEngine extends BaseEngine {
1312
1396
  // to an empty/false result on a failed read — so `Promise.all` cannot introduce a
1313
1397
  // rejection path this method did not already have.
1314
1398
  const [olderProbe, peripherals] = await Promise.all([
1315
- this.hasOlderDetails(params.ConversationID, oldestSequence, contextUser),
1399
+ this.hasOlderDetails(liveFilter, oldestSequence, contextUser),
1316
1400
  this.buildWindowPeripherals(details, contextUser)
1317
1401
  ]);
1318
1402
  return {
@@ -1327,6 +1411,22 @@ export class ConversationEngine extends BaseEngine {
1327
1411
  Failed: olderProbe.Failed
1328
1412
  };
1329
1413
  }
1414
+ /**
1415
+ * The `ExtraFilter` predicate for the window's branch path.
1416
+ *
1417
+ * @returns The predicate, or null when the branch rows could not be read or the branch
1418
+ * path could not be built.
1419
+ */
1420
+ async resolveWindowPathFilter(params, contextUser) {
1421
+ try {
1422
+ return await ConversationEngine.BranchPathFilterFresh(params.ConversationID, params.BranchID, contextUser, this.ProviderToUse);
1423
+ }
1424
+ catch (error) {
1425
+ console.error(`[ConversationEngine] Failed to build the branch path for conversation ${params.ConversationID}, `
1426
+ + `branch ${params.BranchID}:`, error);
1427
+ return null;
1428
+ }
1429
+ }
1330
1430
  /**
1331
1431
  * Reads the newest `maxRows` detail rows below an optional `Sequence` bound and returns
1332
1432
  * them in CHRONOLOGICAL order.
@@ -1334,13 +1434,14 @@ export class ConversationEngine extends BaseEngine {
1334
1434
  * The query is `Sequence DESC` because the interesting end of a transcript is the tail;
1335
1435
  * the reversal happens here so every caller downstream sees oldest-to-newest.
1336
1436
  *
1437
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1337
1438
  * @returns The page in ascending `Sequence` order, or null when the read failed.
1338
1439
  */
1339
- async fetchDetailRowsBySequence(conversationId, beforeSequence, maxRows, contextUser) {
1440
+ async fetchDetailRowsBySequence(pathFilter, beforeSequence, maxRows, contextUser) {
1340
1441
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1341
1442
  const filter = beforeSequence == null
1342
- ? `ConversationID='${conversationId}'`
1343
- : `ConversationID='${conversationId}' AND Sequence < ${beforeSequence}`;
1443
+ ? pathFilter
1444
+ : `${pathFilter} AND Sequence < ${beforeSequence}`;
1344
1445
  const result = await rv.RunView({
1345
1446
  EntityName: 'MJ: Conversation Details',
1346
1447
  ExtraFilter: filter,
@@ -1362,9 +1463,10 @@ export class ConversationEngine extends BaseEngine {
1362
1463
  * boundary — without this read the card renders from a partial row set AND the same
1363
1464
  * session reappears on the next older page.
1364
1465
  *
1466
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1365
1467
  * @returns The page with any missing session rows prepended, still chronological.
1366
1468
  */
1367
- async expandOldestSession(conversationId, details, contextUser) {
1469
+ async expandOldestSession(pathFilter, details, contextUser) {
1368
1470
  const sessionId = details[0].AgentSessionID?.trim();
1369
1471
  if (!sessionId) {
1370
1472
  return details;
@@ -1372,7 +1474,7 @@ export class ConversationEngine extends BaseEngine {
1372
1474
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1373
1475
  const result = await rv.RunView({
1374
1476
  EntityName: 'MJ: Conversation Details',
1375
- ExtraFilter: `ConversationID='${conversationId}' AND AgentSessionID='${sessionId}' `
1477
+ ExtraFilter: `${pathFilter} AND AgentSessionID='${sessionId}' `
1376
1478
  + `AND Sequence < ${details[0].Sequence}`,
1377
1479
  OrderBy: 'Sequence DESC',
1378
1480
  MaxRows: MAX_SESSION_EXPANSION_ROWS,
@@ -1392,12 +1494,14 @@ export class ConversationEngine extends BaseEngine {
1392
1494
  *
1393
1495
  * Returns a TRI-STATE rather than a boolean. "No older rows" and "could not find out"
1394
1496
  * are both `HasOlder: false`, and only the second must leave the caller's cursor alone.
1497
+ *
1498
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1395
1499
  */
1396
- async hasOlderDetails(conversationId, oldestSequence, contextUser) {
1500
+ async hasOlderDetails(pathFilter, oldestSequence, contextUser) {
1397
1501
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1398
1502
  const probe = await rv.RunView({
1399
1503
  EntityName: 'MJ: Conversation Details',
1400
- ExtraFilter: `ConversationID='${conversationId}' AND Sequence < ${oldestSequence}`,
1504
+ ExtraFilter: `${pathFilter} AND Sequence < ${oldestSequence}`,
1401
1505
  OrderBy: 'Sequence DESC',
1402
1506
  MaxRows: 1,
1403
1507
  Fields: ['ID'],
@@ -1589,21 +1693,27 @@ export class ConversationEngine extends BaseEngine {
1589
1693
  * - Artifacts/ratings: replaced per-detail (cheap — plain data, not entity objects)
1590
1694
  * - User avatars: merged (new users added)
1591
1695
  *
1592
- * If no cache exists yet, falls back to a full load.
1696
+ * If no cache exists yet, falls back to a full load. The cache holds every row of the
1697
+ * conversation; a scope limits only what this call returns.
1593
1698
  *
1594
1699
  * @param conversationId - The conversation to refresh
1595
1700
  * @param contextUser - The current user context
1596
- * @returns The updated cache entry
1701
+ * @param scope - When given, the returned entry holds only the rows in this scope
1702
+ * (see {@link scopedEntry}); when omitted, every row of the conversation
1703
+ * @returns The updated cache entry, limited to the scope when one is given
1704
+ * @throws when the scope belongs to another conversation
1597
1705
  */
1598
- async RefreshConversationDetails(conversationId, contextUser) {
1706
+ async RefreshConversationDetails(conversationId, contextUser, scope) {
1707
+ ConversationEngine.assertScopeOf(conversationId, scope);
1599
1708
  const key = NormalizeUUID(conversationId);
1600
1709
  const existing = this._detailCache.get(key);
1601
1710
  // No cache yet — do a full load
1602
1711
  if (!existing) {
1603
- return this.LoadConversationDetails(conversationId, contextUser);
1712
+ return this.LoadConversationDetails(conversationId, contextUser, false, scope);
1604
1713
  }
1605
1714
  // Run the query
1606
1715
  const rq = new RunQuery();
1716
+ // conversation-scope: whole-conversation cache; every consumer filters to its scope
1607
1717
  const result = await rq.RunQuery({
1608
1718
  QueryName: 'GetConversationComplete',
1609
1719
  CategoryPath: 'MJ/Conversations',
@@ -1611,7 +1721,7 @@ export class ConversationEngine extends BaseEngine {
1611
1721
  }, contextUser);
1612
1722
  if (!result.Success || !result.Results) {
1613
1723
  console.error('[ConversationEngine] Failed to refresh conversation details:', result.ErrorMessage);
1614
- return existing;
1724
+ return ConversationEngine.scopedEntry(existing, scope);
1615
1725
  }
1616
1726
  const freshRows = result.Results;
1617
1727
  const md = this.ProviderToUse;
@@ -1692,7 +1802,7 @@ export class ConversationEngine extends BaseEngine {
1692
1802
  existing.RawData = freshRows;
1693
1803
  existing.LoadedAt = new Date();
1694
1804
  existing.PeripheralDataStale = false;
1695
- return existing;
1805
+ return ConversationEngine.scopedEntry(existing, scope);
1696
1806
  }
1697
1807
  /**
1698
1808
  * Returns cached conversation details (messages only) without hitting the database.
@@ -1752,22 +1862,55 @@ export class ConversationEngine extends BaseEngine {
1752
1862
  * covers everything before it, and cutting into the post-boundary tail would create
1753
1863
  * a coverage gap.
1754
1864
  * @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
1865
+ * @param scope - When given, the window is built from the rows in this scope only; when
1866
+ * omitted, from every cached row of the conversation
1755
1867
  * @returns Messages in chronological order, each stamped with
1756
1868
  * {@link ConversationContextMetadata}
1869
+ * @throws when the scope belongs to another conversation
1757
1870
  */
1758
- async GetAgentContextWindow(conversationId, contextUser, options) {
1871
+ async GetAgentContextWindow(conversationId, contextUser, options, scope) {
1759
1872
  await this.Config(false, contextUser);
1760
- const cache = await this.LoadConversationDetails(conversationId, contextUser);
1873
+ const cache = await this.LoadConversationDetails(conversationId, contextUser, false, scope);
1761
1874
  return ConversationEngine.AssembleContextWindow(cache.Details, options);
1762
1875
  }
1763
1876
  /**
1764
- * Loads a conversation's window source rows FRESH — one RunView through the given
1877
+ * Loads a conversation's branch rows FRESH through the given provider (entity RLS applies).
1878
+ * Ordered by creation so alternatives at a fork point list in the order they were made.
1879
+ * @throws when the RunView reports failure
1880
+ */
1881
+ static async LoadBranchesFresh(conversationId, contextUser, provider) {
1882
+ const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
1883
+ const rows = await rv.RunView({
1884
+ EntityName: 'MJ: Conversation Branches',
1885
+ ExtraFilter: `ConversationID='${EscapeSQLString(conversationId)}'`,
1886
+ OrderBy: '__mj_CreatedAt ASC',
1887
+ Fields: [...ConversationBranchFields],
1888
+ ResultType: 'simple',
1889
+ }, contextUser);
1890
+ if (!rows.Success) {
1891
+ throw new Error(`Failed to load branches for conversation ${conversationId}: ${rows.ErrorMessage}`);
1892
+ }
1893
+ return rows.Results || [];
1894
+ }
1895
+ /**
1896
+ * {@link ScopeFilter} of the scope from {@link LoadScope}. The trunk (an empty, whitespace-only
1897
+ * or missing branch id) needs no load and costs no query.
1898
+ * @throws When the branch read fails ({@link LoadBranchesFresh}), when `branchId` is not
1899
+ * among the conversation's branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
1900
+ */
1901
+ static async BranchPathFilterFresh(conversationId, branchId, contextUser, provider) {
1902
+ const scope = await ConversationEngine.LoadScope(conversationId, branchId, contextUser, provider);
1903
+ return ConversationEngine.ScopeFilter(scope);
1904
+ }
1905
+ /**
1906
+ * Loads the window source rows on a conversation's branch path FRESH — through the given
1765
1907
  * provider with the contextUser (entity RLS applies), never touching this engine's
1766
- * process-global detail cache. The single source of the fresh-load query shape
1767
- * (entity name, filter, order, fields) for every server-side caller: the agent
1768
- * resolver's history loader and the cross-turn compaction pass both consume this,
1908
+ * process-global detail cache. The trunk costs one RunView; a branch costs two (its branch
1909
+ * rows via {@link BranchPathFilterFresh}, then the path's rows). The single source of the
1910
+ * fresh-load query shape (entity name, filter, order, fields) for every server-side
1911
+ * caller: the agent resolver's history loader and the cross-turn compaction pass both consume this,
1769
1912
  * so the two can never drift apart. THROWS on load failure — servers must fail
1770
- * loudly rather than proceed against an empty history.
1913
+ * loudly rather than proceed against an empty history. Replaced rows never leave the database.
1771
1914
  *
1772
1915
  * @param conversationId - The conversation whose detail rows to load
1773
1916
  * @param contextUser - The requesting user (entity RLS is applied under this user)
@@ -1775,18 +1918,21 @@ export class ConversationEngine extends BaseEngine {
1775
1918
  * @param historyFrom - Optional history floor: only rows written at or after it are loaded,
1776
1919
  * so nothing before it leaves the database. Pair it with the same `historyFrom` on
1777
1920
  * {@link AssembleContextWindow}, which also drops the summary of earlier messages.
1778
- * @returns The conversation's rows in Sequence order, shaped for {@link AssembleContextWindow}
1779
- * @throws When the underlying RunView reports failure (never returns a silent empty set),
1780
- * or when `historyFrom` is an invalid date
1781
- */
1782
- static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom) {
1921
+ * @param branchId - The branch whose path to load; null or omitted reads the trunk.
1922
+ * @returns The rows on the branch's path (the trunk when no branch) in Sequence order,
1923
+ * shaped for {@link AssembleContextWindow}
1924
+ * @throws When a RunView reports failure (never returns a silent empty set), when
1925
+ * `historyFrom` is an invalid date, when `branchId` is not among the conversation's
1926
+ * branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
1927
+ */
1928
+ static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom, branchId) {
1783
1929
  const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
1784
- const conversationFilter = `ConversationID='${conversationId}'`;
1930
+ const pathFilter = await ConversationEngine.BranchPathFilterFresh(conversationId, branchId, contextUser, provider);
1785
1931
  const rows = await rv.RunView({
1786
1932
  EntityName: 'MJ: Conversation Details',
1787
- ExtraFilter: historyFrom
1788
- ? `${conversationFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
1789
- : conversationFilter,
1933
+ ExtraFilter: ConversationEngine.LiveRowsFilter(historyFrom
1934
+ ? `${pathFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
1935
+ : pathFilter),
1790
1936
  OrderBy: 'Sequence ASC',
1791
1937
  Fields: [...ConversationWindowFields],
1792
1938
  ResultType: 'simple',
@@ -1814,12 +1960,14 @@ export class ConversationEngine extends BaseEngine {
1814
1960
  * floor none is used, and the window is the most recent `maxTailMessages` rows at or
1815
1961
  * after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
1816
1962
  * to be after the floor, so it is dropped too.
1963
+ *
1964
+ * Replaced rows (`ReplacedAt` set) are always left out.
1817
1965
  */
1818
1966
  static AssembleContextWindow(details, options) {
1819
1967
  const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
1820
1968
  const floor = options?.historyFrom ?? null;
1821
1969
  const ordered = details
1822
- .filter(d => !excluded.has(NormalizeUUID(d.ID)))
1970
+ .filter(d => !excluded.has(NormalizeUUID(d.ID)) && !ConversationEngine.IsReplacedRow(d))
1823
1971
  .filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
1824
1972
  .sort((a, b) => a.Sequence - b.Sequence);
1825
1973
  const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
@@ -1844,6 +1992,412 @@ export class ConversationEngine extends BaseEngine {
1844
1992
  static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
1845
1993
  return `${column} >= '${historyFrom.toISOString()}'`;
1846
1994
  }
1995
+ /**
1996
+ * `filter` limited to rows no rerun replaced (`ReplacedAt` is NULL). A replaced row is an answer a
1997
+ * rerun of its turn superseded: it is kept, but never shown and never sent to an agent. Every reader
1998
+ * of conversation rows for display or for agent context applies this.
1999
+ */
2000
+ static LiveRowsFilter(filter) {
2001
+ return `${filter} AND [ReplacedAt] IS NULL`;
2002
+ }
2003
+ /** True when a rerun of its turn replaced the row (`ReplacedAt` is set). */
2004
+ static IsReplacedRow(row) {
2005
+ return row.ReplacedAt != null;
2006
+ }
2007
+ /**
2008
+ * The branch and its ancestors, current branch first. Empty for the trunk.
2009
+ * @throws when the branch is not in `branches`, or the chain is longer than
2010
+ * {@link MAX_BRANCH_DEPTH} (which also catches a cycle).
2011
+ */
2012
+ static BuildBranchChain(branches, branchId) {
2013
+ const chain = [];
2014
+ let current = branchId ?? null;
2015
+ while (current != null) {
2016
+ if (chain.length >= MAX_BRANCH_DEPTH) {
2017
+ throw new Error(`Branch chain for ${branchId} exceeds ${MAX_BRANCH_DEPTH} levels`);
2018
+ }
2019
+ const id = current;
2020
+ const branch = branches.find(b => sameId(b.ID, id));
2021
+ if (!branch) {
2022
+ throw new Error(`Branch ${id} not found`);
2023
+ }
2024
+ chain.push(branch);
2025
+ current = branch.ParentBranchID ?? null;
2026
+ }
2027
+ return chain;
2028
+ }
2029
+ /**
2030
+ * The `ExtraFilter` predicate for a branch path: the branch's own rows, plus each
2031
+ * ancestor's rows up to that ancestor's fork sequence, plus the trunk up to the last
2032
+ * fork sequence. A null branch is the trunk alone.
2033
+ *
2034
+ * Column names are bracketed, so the predicate also works inside a subquery on
2035
+ * `vwConversationDetails` from another entity's filter: the PostgreSQL provider converts
2036
+ * bracketed names itself but quotes bare names only when they are fields of the outer entity.
2037
+ */
2038
+ static BuildBranchPathFilter(conversationId, branchId, branches) {
2039
+ const conversationFilter = `[ConversationID]='${EscapeSQLString(conversationId)}'`;
2040
+ const chain = ConversationEngine.BuildBranchChain(branches, branchId);
2041
+ if (chain.length === 0) {
2042
+ return `${conversationFilter} AND [BranchID] IS NULL`;
2043
+ }
2044
+ const clauses = [];
2045
+ let cap = null;
2046
+ for (const branch of chain) {
2047
+ const id = EscapeSQLString(branch.ID);
2048
+ clauses.push(cap == null ? `[BranchID]='${id}'` : `([BranchID]='${id}' AND [Sequence] <= ${cap})`);
2049
+ cap = branch.ForkFromSequence;
2050
+ if (cap == null) {
2051
+ return `${conversationFilter} AND (${clauses.join(' OR ')})`;
2052
+ }
2053
+ }
2054
+ clauses.push(`([BranchID] IS NULL AND [Sequence] <= ${cap})`);
2055
+ return `${conversationFilter} AND (${clauses.join(' OR ')})`;
2056
+ }
2057
+ /** True when a row is on the path described by a chain from {@link BuildBranchChain}. */
2058
+ static IsRowOnBranchPath(row, chain) {
2059
+ let cap = null;
2060
+ for (const branch of chain) {
2061
+ if (sameId(row.BranchID, branch.ID)) {
2062
+ return cap == null || row.Sequence <= cap;
2063
+ }
2064
+ cap = branch.ForkFromSequence;
2065
+ if (cap == null) {
2066
+ return false;
2067
+ }
2068
+ }
2069
+ return row.BranchID == null && (cap == null || row.Sequence <= cap);
2070
+ }
2071
+ /** In-memory counterpart of {@link BuildBranchPathFilter} for rows already loaded. */
2072
+ static FilterRowsToBranchPath(rows, branchId, branches) {
2073
+ const chain = ConversationEngine.BuildBranchChain(branches, branchId);
2074
+ return rows.filter(r => ConversationEngine.IsRowOnBranchPath(r, chain));
2075
+ }
2076
+ /** A scope for the trunk: no branch rows are needed. */
2077
+ static TrunkScope(conversationId) {
2078
+ return { ConversationID: conversationId, BranchID: null, Branches: [] };
2079
+ }
2080
+ /**
2081
+ * Builds a scope for an explicit branch. An empty, whitespace-only or missing branch id is the
2082
+ * trunk. A branch id loads the conversation's branch rows once and must name one of them
2083
+ * (compared without regard to case); the scope's `BranchID` is that row's `ID`.
2084
+ * @throws when the branch rows cannot be read or the branch is not one of them.
2085
+ */
2086
+ static async LoadScope(conversationId, branchId, contextUser, provider) {
2087
+ const normalized = branchId && branchId.trim().length > 0 ? branchId : null;
2088
+ if (normalized === null) {
2089
+ return ConversationEngine.TrunkScope(conversationId);
2090
+ }
2091
+ const branches = await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
2092
+ const branch = branches.find(b => sameId(b.ID, normalized));
2093
+ if (!branch) {
2094
+ throw new Error(`Branch ${normalized} is not a branch of conversation ${conversationId}`);
2095
+ }
2096
+ return { ConversationID: conversationId, BranchID: branch.ID, Branches: branches };
2097
+ }
2098
+ /**
2099
+ * Predicate over MJ: Conversation Details for the rows in scope. Same text as
2100
+ * {@link BuildBranchPathFilter}.
2101
+ * @throws when the scope's `BranchID`, or the parent of a branch on its chain, is not in the
2102
+ * scope's `Branches`, or when the chain is longer than {@link MAX_BRANCH_DEPTH}.
2103
+ */
2104
+ static ScopeFilter(scope) {
2105
+ return ConversationEngine.BuildBranchPathFilter(scope.ConversationID, scope.BranchID, scope.Branches);
2106
+ }
2107
+ /**
2108
+ * `[column] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE <ScopeFilter>)` for entities
2109
+ * anchored by a conversation detail id column.
2110
+ */
2111
+ static ScopeSubquery(scope, column = 'ConversationDetailID') {
2112
+ return `[${column}] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${ConversationEngine.ScopeFilter(scope)})`;
2113
+ }
2114
+ /** In-memory form of {@link ScopeFilter} for one row already loaded. */
2115
+ static IsInScope(scope, row) {
2116
+ const chain = ConversationEngine.BuildBranchChain(scope.Branches, scope.BranchID);
2117
+ return ConversationEngine.IsRowOnBranchPath(row, chain);
2118
+ }
2119
+ /** In-memory form of {@link ScopeFilter} for rows already loaded. Keeps their order. */
2120
+ static FilterToScope(scope, rows) {
2121
+ return ConversationEngine.FilterRowsToBranchPath(rows, scope.BranchID, scope.Branches);
2122
+ }
2123
+ /**
2124
+ * Filter over MJ: Artifact Versions for the versions of one artifact visible in scope: versions
2125
+ * linked (in any direction) from a message on the path, and versions with no conversation link
2126
+ * at all.
2127
+ */
2128
+ static ArtifactVersionScopeFilter(scope, artifactId) {
2129
+ const path = ConversationEngine.ScopeFilter(scope);
2130
+ const id = EscapeSQLString(artifactId);
2131
+ return `[ArtifactID]='${id}' AND (` +
2132
+ `[ID] IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ConversationDetailID IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${path}))` +
2133
+ ` OR [ID] NOT IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ArtifactVersionID IS NOT NULL)` +
2134
+ `)`;
2135
+ }
2136
+ /** The kind of a fork row; a missing or unknown value is 'Fork'. */
2137
+ static NormalizeForkKind(kind) {
2138
+ return FORK_KINDS.find(k => k === kind) ?? 'Fork';
2139
+ }
2140
+ /**
2141
+ * Plain text of a stored message: each `@{...}` mention becomes `@<name>`, or is dropped when it
2142
+ * has no name, and runs of whitespace become one space. Null when nothing is left.
2143
+ */
2144
+ static ForkPlainText(message) {
2145
+ if (!message) {
2146
+ return null;
2147
+ }
2148
+ const plain = message
2149
+ .replace(/@\{[^{}]*\}/g, token => {
2150
+ try {
2151
+ const parsed = JSON.parse(token.slice(1));
2152
+ return typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? `@${parsed.name.trim()}` : ' ';
2153
+ }
2154
+ catch {
2155
+ return ' ';
2156
+ }
2157
+ })
2158
+ .replace(/\s+/g, ' ')
2159
+ .trim();
2160
+ return plain.length > 0 ? plain : null;
2161
+ }
2162
+ /** The first words of a message, for a fork with no name: at most 6 words and 40 characters, '…' when cut. */
2163
+ static ForkFirstWords(message) {
2164
+ const plain = ConversationEngine.ForkPlainText(message);
2165
+ if (!plain) {
2166
+ return null;
2167
+ }
2168
+ const words = plain.split(' ');
2169
+ let text = words.slice(0, FORK_NAME_MAX_WORDS).join(' ');
2170
+ let cut = words.length > FORK_NAME_MAX_WORDS;
2171
+ if (text.length > FORK_NAME_MAX_CHARS) {
2172
+ text = text.slice(0, FORK_NAME_MAX_CHARS).trimEnd();
2173
+ cut = true;
2174
+ }
2175
+ return cut ? `${text}…` : text;
2176
+ }
2177
+ /** A one-line preview of a message: at most 120 characters, '…' when cut. */
2178
+ static ForkPreviewText(message) {
2179
+ const plain = ConversationEngine.ForkPlainText(message);
2180
+ if (!plain) {
2181
+ return null;
2182
+ }
2183
+ return plain.length > FORK_PREVIEW_MAX_CHARS ? `${plain.slice(0, FORK_PREVIEW_MAX_CHARS).trimEnd()}…` : plain;
2184
+ }
2185
+ /**
2186
+ * The label of a fork: its name, else a default from its kind — Fork: the first words of its first
2187
+ * message, else "Fork"; Edit: "Edited version"; Regenerate: "Other answer".
2188
+ */
2189
+ static ForkDisplayName(branch, firstOwnMessage) {
2190
+ const name = branch.Name?.trim();
2191
+ if (name) {
2192
+ return name;
2193
+ }
2194
+ switch (ConversationEngine.NormalizeForkKind(branch.Kind)) {
2195
+ case 'Edit':
2196
+ return 'Edited version';
2197
+ case 'Regenerate':
2198
+ return 'Other answer';
2199
+ default:
2200
+ return ConversationEngine.ForkFirstWords(firstOwnMessage) ?? 'Fork';
2201
+ }
2202
+ }
2203
+ /** The fork point at a message: the new fork starts after the message itself, on its branch. */
2204
+ static ForkPointFrom(message) {
2205
+ return { ParentBranchID: message.BranchID ?? null, ForkFromSequence: message.Sequence };
2206
+ }
2207
+ /**
2208
+ * Edit of a message, or Fork from here on it (the fork replaces it): the new fork starts at the row
2209
+ * before it on its path (both null when it is the first message). Undefined when that row is not
2210
+ * known: the message is not in `pathRows`, or it is the first loaded row while older rows exist
2211
+ * (`hasMoreAbove`).
2212
+ */
2213
+ static EditForkPoint(message, pathRows, hasMoreAbove = false) {
2214
+ const sorted = [...pathRows].sort((a, b) => a.Sequence - b.Sequence);
2215
+ const index = sorted.findIndex(r => sameId(r.ID, message.ID));
2216
+ if (index < 0) {
2217
+ return undefined;
2218
+ }
2219
+ if (index === 0) {
2220
+ return hasMoreAbove ? undefined : { ParentBranchID: null, ForkFromSequence: null };
2221
+ }
2222
+ return ConversationEngine.ForkPointFrom(sorted[index - 1]);
2223
+ }
2224
+ /** Regenerate of an answer: the new fork starts at the user message the answer replied to. */
2225
+ static RegenerateForkPoint(userMessage) {
2226
+ return ConversationEngine.ForkPointFrom(userMessage);
2227
+ }
2228
+ /**
2229
+ * Predicate over MJ: Conversation Details for the anchor rows of the given forks (one clause per
2230
+ * distinct fork point); null when no fork has a fork point.
2231
+ */
2232
+ static AnchorRowsFilter(conversationId, branches) {
2233
+ const clauses = [];
2234
+ for (const branch of branches) {
2235
+ if (branch.ForkFromSequence == null) {
2236
+ continue;
2237
+ }
2238
+ const onParent = branch.ParentBranchID == null
2239
+ ? '[BranchID] IS NULL'
2240
+ : `[BranchID]='${EscapeSQLString(branch.ParentBranchID)}'`;
2241
+ const clause = `(${onParent} AND [Sequence]=${Math.trunc(branch.ForkFromSequence)})`;
2242
+ if (!clauses.includes(clause)) {
2243
+ clauses.push(clause);
2244
+ }
2245
+ }
2246
+ if (clauses.length === 0) {
2247
+ return null;
2248
+ }
2249
+ return `[ConversationID]='${EscapeSQLString(conversationId)}' AND (${clauses.join(' OR ')})`;
2250
+ }
2251
+ /**
2252
+ * Predicate over MJ: Conversation Details for the source rows of the given forks (each distinct
2253
+ * SourceDetailID once); null when no fork has a source.
2254
+ */
2255
+ static SourceRowsFilter(conversationId, branches) {
2256
+ const ids = [];
2257
+ for (const branch of branches) {
2258
+ const id = branch.SourceDetailID;
2259
+ if (id && !ids.some(known => sameId(known, id))) {
2260
+ ids.push(id);
2261
+ }
2262
+ }
2263
+ if (ids.length === 0) {
2264
+ return null;
2265
+ }
2266
+ const list = ids.map(id => `'${EscapeSQLString(id)}'`).join(', ');
2267
+ return `[ConversationID]='${EscapeSQLString(conversationId)}' AND [ID] IN (${list})`;
2268
+ }
2269
+ /**
2270
+ * One summary per fork, in the order of `branches`. Pure: `ownRows` are the conversation's rows
2271
+ * with a BranchID, `anchorRows` the rows {@link AnchorRowsFilter} selects, `sourceRows` the rows
2272
+ * {@link SourceRowsFilter} selects.
2273
+ */
2274
+ static BuildForkSummaries(branches, ownRows, anchorRows, sourceRows = []) {
2275
+ const ownByFork = new Map();
2276
+ for (const row of ownRows) {
2277
+ if (row.BranchID == null || row.HiddenToUser === true) {
2278
+ continue;
2279
+ }
2280
+ const key = NormalizeUUID(row.BranchID);
2281
+ const list = ownByFork.get(key);
2282
+ if (list) {
2283
+ list.push(row);
2284
+ }
2285
+ else {
2286
+ ownByFork.set(key, [row]);
2287
+ }
2288
+ }
2289
+ return branches.map(branch => {
2290
+ const own = [...(ownByFork.get(NormalizeUUID(branch.ID)) ?? [])].sort((a, b) => a.Sequence - b.Sequence);
2291
+ const anchor = branch.ForkFromSequence == null
2292
+ ? undefined
2293
+ : anchorRows.find(r => r.Sequence === branch.ForkFromSequence && sameId(r.BranchID, branch.ParentBranchID ?? null));
2294
+ const sourceId = branch.SourceDetailID;
2295
+ const source = sourceId ? sourceRows.find(r => sameId(r.ID, sourceId)) : undefined;
2296
+ const placement = sourceId ? source : anchor;
2297
+ const first = own.find(r => ConversationEngine.ForkPlainText(r.Message) != null);
2298
+ const last = own.length > 0 ? own[own.length - 1] : undefined;
2299
+ const authors = [];
2300
+ for (const r of own) {
2301
+ if (r.Role === 'User' && r.UserID && !authors.some(a => sameId(a, r.UserID))) {
2302
+ authors.push(r.UserID);
2303
+ }
2304
+ }
2305
+ return {
2306
+ Branch: branch,
2307
+ Kind: ConversationEngine.NormalizeForkKind(branch.Kind),
2308
+ DisplayName: ConversationEngine.ForkDisplayName(branch, first?.Message ?? null),
2309
+ MessageCount: own.length,
2310
+ Participants: ConversationEngine.forkParticipants(own),
2311
+ AuthorUserIDs: authors,
2312
+ LastActivityAt: (last ? ConversationEngine.toForkDate(last.__mj_CreatedAt) : null)
2313
+ ?? ConversationEngine.toForkDate(branch.__mj_CreatedAt)
2314
+ ?? new Date(0),
2315
+ LastMessagePreview: last ? ConversationEngine.ForkPreviewText(last.Message) : null,
2316
+ LastMessageAuthorName: last ? ConversationEngine.rowAuthorName(last) : null,
2317
+ PlacementDetailID: branch.SourceDetailID ?? anchor?.ID ?? null,
2318
+ PlacementPosition: placement ? { BranchID: placement.BranchID ?? null, Sequence: placement.Sequence } : null,
2319
+ AnchorDetailID: anchor?.ID ?? null,
2320
+ AnchorAuthorName: anchor ? ConversationEngine.rowAuthorName(anchor) : null,
2321
+ AnchorAt: anchor ? ConversationEngine.toForkDate(anchor.__mj_CreatedAt) : null,
2322
+ SourceAuthorName: source ? ConversationEngine.rowAuthorName(source) : null,
2323
+ StartedByUserID: branch.UserID ?? null,
2324
+ StartedByName: branch.User ?? null,
2325
+ ParentBranchID: branch.ParentBranchID ?? null,
2326
+ };
2327
+ });
2328
+ }
2329
+ /**
2330
+ * Loads the fork summaries of a conversation FRESH (entity RLS applies): the fork rows (unless
2331
+ * given), then the forks' own rows, their anchor rows and their source rows in one RunViews batch.
2332
+ * @throws when a read fails
2333
+ */
2334
+ static async LoadForkSummaries(conversationId, contextUser, provider, branches) {
2335
+ const forks = branches ?? await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
2336
+ if (forks.length === 0) {
2337
+ return [];
2338
+ }
2339
+ const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
2340
+ const params = [{
2341
+ EntityName: 'MJ: Conversation Details',
2342
+ ExtraFilter: `[ConversationID]='${EscapeSQLString(conversationId)}' AND [BranchID] IS NOT NULL`,
2343
+ OrderBy: 'Sequence ASC',
2344
+ Fields: [...ForkSummaryRowFields],
2345
+ ResultType: 'simple',
2346
+ }];
2347
+ const rowsParams = (filter) => ({
2348
+ EntityName: 'MJ: Conversation Details',
2349
+ ExtraFilter: filter,
2350
+ Fields: [...ForkSummaryRowFields],
2351
+ ResultType: 'simple',
2352
+ });
2353
+ const anchorFilter = ConversationEngine.AnchorRowsFilter(conversationId, forks);
2354
+ const anchorIndex = anchorFilter ? params.push(rowsParams(anchorFilter)) - 1 : -1;
2355
+ const sourceFilter = ConversationEngine.SourceRowsFilter(conversationId, forks);
2356
+ const sourceIndex = sourceFilter ? params.push(rowsParams(sourceFilter)) - 1 : -1;
2357
+ const results = await rv.RunViews(params, contextUser);
2358
+ const own = results[0];
2359
+ const anchors = anchorIndex >= 0 ? results[anchorIndex] : undefined;
2360
+ const sources = sourceIndex >= 0 ? results[sourceIndex] : undefined;
2361
+ if (!own?.Success) {
2362
+ throw new Error(`Failed to load the forks of conversation ${conversationId}: ${own?.ErrorMessage}`);
2363
+ }
2364
+ if (anchors && !anchors.Success) {
2365
+ throw new Error(`Failed to load the fork anchors of conversation ${conversationId}: ${anchors.ErrorMessage}`);
2366
+ }
2367
+ if (sources && !sources.Success) {
2368
+ throw new Error(`Failed to load the fork source messages of conversation ${conversationId}: ${sources.ErrorMessage}`);
2369
+ }
2370
+ return ConversationEngine.BuildForkSummaries(forks, own.Results ?? [], anchors?.Results ?? [], sources?.Results ?? []);
2371
+ }
2372
+ /** Distinct agents and people of rows in order, at most {@link MAX_FORK_PARTICIPANTS}. */
2373
+ static forkParticipants(rows) {
2374
+ const out = [];
2375
+ for (const r of rows) {
2376
+ const participant = r.AgentID
2377
+ ? { Kind: 'Agent', ID: r.AgentID, Name: r.Agent ?? null }
2378
+ : r.UserID ? { Kind: 'User', ID: r.UserID, Name: r.User ?? null } : null;
2379
+ if (!participant || out.some(p => p.Kind === participant.Kind && sameId(p.ID, participant.ID))) {
2380
+ continue;
2381
+ }
2382
+ out.push(participant);
2383
+ if (out.length === MAX_FORK_PARTICIPANTS) {
2384
+ break;
2385
+ }
2386
+ }
2387
+ return out;
2388
+ }
2389
+ /** The agent's name for an agent row, else the person's name; null when the row names neither. */
2390
+ static rowAuthorName(row) {
2391
+ return row.AgentID ? (row.Agent ?? null) : (row.User ?? null);
2392
+ }
2393
+ /** A Date from a stored timestamp; null when missing or unreadable. */
2394
+ static toForkDate(value) {
2395
+ if (value == null) {
2396
+ return null;
2397
+ }
2398
+ const date = value instanceof Date ? value : new Date(value);
2399
+ return Number.isNaN(date.getTime()) ? null : date;
2400
+ }
1847
2401
  /** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
1848
2402
  static isAtOrAfter(createdAt, floor) {
1849
2403
  if (createdAt == null) {