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

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 (66) 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 +21 -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 +46 -4
  15. package/dist/custom/MJConversationDetailEntityExtended.d.ts.map +1 -1
  16. package/dist/custom/MJConversationDetailEntityExtended.js +194 -47
  17. package/dist/custom/MJConversationDetailEntityExtended.js.map +1 -1
  18. package/dist/custom/MJDashboardEntityExtended.d.ts.map +1 -1
  19. package/dist/custom/MJDashboardEntityExtended.js +3 -0
  20. package/dist/custom/MJDashboardEntityExtended.js.map +1 -1
  21. package/dist/custom/MJThemeEntityExtended.d.ts +13 -0
  22. package/dist/custom/MJThemeEntityExtended.d.ts.map +1 -0
  23. package/dist/custom/MJThemeEntityExtended.js +37 -0
  24. package/dist/custom/MJThemeEntityExtended.js.map +1 -0
  25. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts.map +1 -1
  26. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +14 -13
  27. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js.map +1 -1
  28. package/dist/engines/FileStorageEngine.d.ts +40 -0
  29. package/dist/engines/FileStorageEngine.d.ts.map +1 -1
  30. package/dist/engines/FileStorageEngine.js +49 -0
  31. package/dist/engines/FileStorageEngine.js.map +1 -1
  32. package/dist/engines/InstanceConfigEngine.d.ts.map +1 -1
  33. package/dist/engines/InstanceConfigEngine.js +13 -3
  34. package/dist/engines/InstanceConfigEngine.js.map +1 -1
  35. package/dist/engines/PermissionEngine.d.ts +5 -4
  36. package/dist/engines/PermissionEngine.d.ts.map +1 -1
  37. package/dist/engines/PermissionEngine.js +7 -5
  38. package/dist/engines/PermissionEngine.js.map +1 -1
  39. package/dist/engines/QueryEngine.d.ts +81 -6
  40. package/dist/engines/QueryEngine.d.ts.map +1 -1
  41. package/dist/engines/QueryEngine.js +283 -4
  42. package/dist/engines/QueryEngine.js.map +1 -1
  43. package/dist/engines/UserInfoEngine.d.ts +64 -12
  44. package/dist/engines/UserInfoEngine.d.ts.map +1 -1
  45. package/dist/engines/UserInfoEngine.js +100 -33
  46. package/dist/engines/UserInfoEngine.js.map +1 -1
  47. package/dist/engines/artifacts.d.ts.map +1 -1
  48. package/dist/engines/artifacts.js +1 -0
  49. package/dist/engines/artifacts.js.map +1 -1
  50. package/dist/engines/conversations.d.ts +378 -35
  51. package/dist/engines/conversations.d.ts.map +1 -1
  52. package/dist/engines/conversations.js +736 -79
  53. package/dist/engines/conversations.js.map +1 -1
  54. package/dist/generated/entities/__mj.d.ts +3488 -230
  55. package/dist/generated/entities/__mj.d.ts.map +1 -1
  56. package/dist/generated/entities/__mj.js +2927 -18
  57. package/dist/generated/entities/__mj.js.map +1 -1
  58. package/dist/generated/remote_operations.d.ts +17 -5
  59. package/dist/generated/remote_operations.d.ts.map +1 -1
  60. package/dist/generated/remote_operations.js.map +1 -1
  61. package/dist/index.d.ts +4 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -0
  64. package/dist/index.js.map +1 -1
  65. package/package.json +6 -5
  66. package/readme.md +1 -1
@@ -14,19 +14,50 @@ 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
  */
21
46
  export function ParseConversationDetailComplete(queryResult) {
47
+ const rawArtifacts = queryResult.ArtifactsJSON
48
+ ? JSON.parse(queryResult.ArtifactsJSON)
49
+ : [];
50
+ const artifacts = rawArtifacts.slice().sort((a, b) => {
51
+ const aTime = ToEpochMs(a.VersionCreatedAt) ?? ToEpochMs(a.__mj_CreatedAt) ?? 0;
52
+ const bTime = ToEpochMs(b.VersionCreatedAt) ?? ToEpochMs(b.__mj_CreatedAt) ?? 0;
53
+ return aTime - bTime;
54
+ });
22
55
  return {
23
56
  ...queryResult,
24
57
  agentRuns: queryResult.AgentRunsJSON
25
58
  ? JSON.parse(queryResult.AgentRunsJSON)
26
59
  : [],
27
- artifacts: queryResult.ArtifactsJSON
28
- ? JSON.parse(queryResult.ArtifactsJSON)
29
- : [],
60
+ artifacts,
30
61
  ratings: queryResult.RatingsJSON
31
62
  ? JSON.parse(queryResult.RatingsJSON)
32
63
  : []
@@ -88,12 +119,63 @@ function detailIdInFilter(detailIds) {
88
119
  function idInFilter(ids) {
89
120
  return `ID IN (${quoteIdList(ids)})`;
90
121
  }
91
- /** Keys agent runs by the detail they belong to. */
122
+ /**
123
+ * Priority tier for choosing which `AIAgentRun` represents a `ConversationDetail` in the UI
124
+ * (gear icon -> "View Agent Run", token/cost stats, duration):
125
+ * - Tier 0: Root primary orchestrating run (`ParentRunID == null` and `ContinuationDepth == 0`)
126
+ * - Tier 1: Root post-workflow continuation synthesis run (`ParentRunID == null` and `ContinuationDepth > 0`)
127
+ * - Tier 2: Child / sub-agent run (`ParentRunID != null`)
128
+ */
129
+ function agentRunPriorityTier(run) {
130
+ if (run.ParentRunID) {
131
+ return 2;
132
+ }
133
+ return (run.ContinuationDepth ?? 0) > 0 ? 1 : 0;
134
+ }
135
+ /**
136
+ * Returns true when `candidate` should replace `existing` as the representative agent run
137
+ * for a single `ConversationDetailID`. Lower tier wins (primary root run beats continuation
138
+ * synthesis run, which beats child sub-agent run); within the same tier, a strictly newer run
139
+ * wins (so user retries of a message replace the earlier run while DESC-ordered queries keep
140
+ * the first row when timestamps are tied or omitted in test fixtures).
141
+ */
142
+ export function ShouldReplaceDetailAgentRun(existing, candidate) {
143
+ if (!existing) {
144
+ return true;
145
+ }
146
+ if (existing.ID && candidate.ID && UUIDsEqual(existing.ID, candidate.ID)) {
147
+ return true;
148
+ }
149
+ const existingTier = agentRunPriorityTier(existing);
150
+ const candidateTier = agentRunPriorityTier(candidate);
151
+ if (candidateTier !== existingTier) {
152
+ return candidateTier < existingTier;
153
+ }
154
+ const existingTime = ToEpochMs(existing.__mj_CreatedAt ?? existing.StartedAt) ?? 0;
155
+ const candidateTime = ToEpochMs(candidate.__mj_CreatedAt ?? candidate.StartedAt) ?? 0;
156
+ if (candidateTime !== existingTime) {
157
+ return candidateTime > existingTime;
158
+ }
159
+ return false;
160
+ }
161
+ function selectPreferredDetailAgentRun(runs) {
162
+ let best;
163
+ for (const run of runs) {
164
+ if (ShouldReplaceDetailAgentRun(best, run)) {
165
+ best = run;
166
+ }
167
+ }
168
+ return best;
169
+ }
170
+ /** Keys agent runs by the detail they belong to, preferring the primary root orchestrating run. */
92
171
  function groupAgentRunsByDetailId(runs) {
93
172
  const byDetailId = new Map();
94
173
  for (const run of runs) {
95
174
  if (run.ConversationDetailID) {
96
- byDetailId.set(run.ConversationDetailID, run);
175
+ const existing = byDetailId.get(run.ConversationDetailID);
176
+ if (ShouldReplaceDetailAgentRun(existing, run)) {
177
+ byDetailId.set(run.ConversationDetailID, run);
178
+ }
97
179
  }
98
180
  }
99
181
  return byDetailId;
@@ -592,6 +674,29 @@ export class ConversationEngine extends BaseEngine {
592
674
  async MoveConversationToProject(conversationId, projectId, contextUser) {
593
675
  return this.SaveConversation(conversationId, { ProjectID: projectId }, contextUser);
594
676
  }
677
+ /**
678
+ * Creates a fork (an `MJ: Conversation Branches` row) started by `contextUser`. A fork before the
679
+ * first message shares nothing, so its parent is always Main. Never reads or writes the
680
+ * conversation row. Write access is the branch entity's rule (owner, or an Edit/Owner share).
681
+ * The server refuses it unless the person holds the `Conversations: Fork` authorization.
682
+ * @throws when the fork cannot be saved; the message carries the save result
683
+ */
684
+ async CreateFork(input, contextUser) {
685
+ const branch = await this.ProviderToUse.GetEntityObject('MJ: Conversation Branches', contextUser);
686
+ branch.NewRecord();
687
+ branch.ConversationID = input.ConversationID;
688
+ branch.Kind = input.Kind;
689
+ branch.ForkFromSequence = input.ForkFromSequence;
690
+ branch.ParentBranchID = input.ForkFromSequence == null ? null : input.ParentBranchID;
691
+ branch.SourceDetailID = input.SourceDetailID ?? null;
692
+ branch.UserID = contextUser.ID;
693
+ const name = input.Name?.trim();
694
+ branch.Name = name ? name : null;
695
+ if (!(await branch.Save())) {
696
+ throw new Error(`Failed to create the fork: ${branch.LatestResult?.CompleteMessage ?? 'unknown error'}`);
697
+ }
698
+ return branch;
699
+ }
595
700
  /**
596
701
  * Reparents a folder (project) under another folder, or to the top level when
597
702
  * parentId is null. Callers are responsible for preventing cycles (don't pass a
@@ -988,14 +1093,29 @@ export class ConversationEngine extends BaseEngine {
988
1093
  throw new Error('Conversation not found');
989
1094
  }
990
1095
  }
1096
+ const previousValues = {};
1097
+ for (const key of Object.keys(updates)) {
1098
+ previousValues[key] = conversation[key];
1099
+ }
991
1100
  this.mergeDataOntoRecord(conversation, updates);
1101
+ // Emit immediately after mutating the shared cached entity so Angular subscribers
1102
+ // (e.g. ConversationListComponent) run change detection synchronously rather than
1103
+ // seeing an uncommitted mutation during the async Save() round-trip (NG0100).
1104
+ this._conversations$.next([...this._conversations$.value]);
992
1105
  this._selfMutating = true;
993
1106
  try {
994
1107
  const saved = await conversation.Save();
995
1108
  if (!saved) {
1109
+ this.mergeDataOntoRecord(conversation, previousValues);
1110
+ this._conversations$.next([...this._conversations$.value]);
996
1111
  throw new Error(conversation.LatestResult?.Message || 'Failed to update conversation');
997
1112
  }
998
1113
  }
1114
+ catch (err) {
1115
+ this.mergeDataOntoRecord(conversation, previousValues);
1116
+ this._conversations$.next([...this._conversations$.value]);
1117
+ throw err;
1118
+ }
999
1119
  finally {
1000
1120
  this._selfMutating = false;
1001
1121
  }
@@ -1184,36 +1304,33 @@ export class ConversationEngine extends BaseEngine {
1184
1304
  // ========================================================================
1185
1305
  // CONVERSATION DETAILS (Messages)
1186
1306
  // ========================================================================
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
1307
  /**
1197
1308
  * Loads conversation details using the efficient GetConversationComplete query
1198
1309
  * which returns messages, agent runs, artifacts, ratings, and user avatars in one round-trip.
1199
- * Results are cached for instant retrieval on subsequent calls.
1310
+ * The cache holds every row of the conversation, on every branch path; a scope limits
1311
+ * only what this call returns.
1200
1312
  *
1201
1313
  * @param conversationId - The conversation to load details for
1202
1314
  * @param contextUser - The current user context
1203
1315
  * @param forceRefresh - If true, reloads even if cached
1204
- * @returns The full cache entry with all peripheral data
1316
+ * @param scope - When given, the returned entry holds only the rows in this scope
1317
+ * (see {@link scopedEntry}); when omitted, every row of the conversation
1318
+ * @returns The cache entry with all peripheral data, limited to the scope when one is given
1319
+ * @throws when the scope belongs to another conversation
1205
1320
  */
1206
- async LoadConversationDetails(conversationId, contextUser, forceRefresh = false) {
1321
+ async LoadConversationDetails(conversationId, contextUser, forceRefresh = false, scope) {
1322
+ ConversationEngine.assertScopeOf(conversationId, scope);
1207
1323
  const key = NormalizeUUID(conversationId);
1208
1324
  // Return cached if available and not forcing
1209
1325
  if (!forceRefresh) {
1210
1326
  const cached = this._detailCache.get(key);
1211
1327
  if (cached) {
1212
- return cached;
1328
+ return ConversationEngine.scopedEntry(cached, scope);
1213
1329
  }
1214
1330
  }
1215
1331
  // Use GetConversationComplete for one-round-trip loading of all data
1216
1332
  const rq = new RunQuery();
1333
+ // conversation-scope: whole-conversation cache; every consumer filters to its scope
1217
1334
  const result = await rq.RunQuery({
1218
1335
  QueryName: 'GetConversationComplete',
1219
1336
  CategoryPath: 'MJ/Conversations',
@@ -1232,13 +1349,41 @@ export class ConversationEngine extends BaseEngine {
1232
1349
  LoadedAt: new Date(),
1233
1350
  PeripheralDataStale: false
1234
1351
  };
1235
- return empty;
1352
+ return ConversationEngine.scopedEntry(empty, scope);
1236
1353
  }
1237
1354
  const rawData = result.Results;
1238
1355
  // Build the cache entry from the rich query result
1239
1356
  const cacheEntry = await this.buildDetailCacheFromRawData(rawData, contextUser);
1240
1357
  this._detailCache.set(key, cacheEntry);
1241
- return cacheEntry;
1358
+ return ConversationEngine.scopedEntry(cacheEntry, scope);
1359
+ }
1360
+ /**
1361
+ * Throws when a scope is given and belongs to another conversation.
1362
+ * @throws when the scope belongs to another conversation
1363
+ */
1364
+ static assertScopeOf(conversationId, scope) {
1365
+ if (scope && !sameId(scope.ConversationID, conversationId)) {
1366
+ throw new Error(`Conversation ${conversationId} is not the scope's conversation ${scope.ConversationID}`);
1367
+ }
1368
+ }
1369
+ /**
1370
+ * A cache entry limited to a scope: a copy whose `Details` hold only the rows in the scope,
1371
+ * and whose `RawData` holds the raw rows of those details, both in their cached order. The
1372
+ * peripheral maps are the cached ones, shared. The entry itself when no scope is given. The
1373
+ * cached entry is never changed. Callers check the scope's conversation first
1374
+ * ({@link assertScopeOf}).
1375
+ */
1376
+ static scopedEntry(entry, scope) {
1377
+ if (!scope) {
1378
+ return entry;
1379
+ }
1380
+ const details = ConversationEngine.FilterToScope(scope, entry.Details);
1381
+ const detailIds = new Set(details.map(d => NormalizeUUID(d.ID)));
1382
+ return {
1383
+ ...entry,
1384
+ Details: details,
1385
+ RawData: entry.RawData.filter(r => !!r.ID && detailIds.has(NormalizeUUID(r.ID))),
1386
+ };
1242
1387
  }
1243
1388
  /**
1244
1389
  * Loads ONE page of a conversation's transcript — the chat area's windowed history read.
@@ -1254,19 +1399,25 @@ export class ConversationEngine extends BaseEngine {
1254
1399
  * a partial entry there would silently starve the agent of everything before the summary
1255
1400
  * boundary, with no error. Use a separate partial cache if incremental caching is needed.
1256
1401
  *
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.
1402
+ * Every detail read is limited to the path of `BranchID` (the trunk when omitted).
1403
+ * Replaced rows (`ReplacedAt` set) are never read.
1404
+ *
1405
+ * Does not throw for a read that FAILS — a failed branch-path read, like a failed page
1406
+ * read, returns an empty window flagged {@link DetailWindowLoadResult.Failed} so the
1407
+ * caller can leave its paging state intact rather than mistaking the failure for the
1408
+ * start of the conversation. A failed branch-path read never falls back to the trunk.
1260
1409
  *
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.
1410
+ * It is not, however, exception-proof: the detail reads have no `try/catch`, so a
1411
+ * provider that REJECTS rather than returning `Success: false` propagates out to the
1412
+ * caller. That path is left deliberately — it degrades better than the handled one,
1413
+ * because an exception skips the cursor write entirely and the caller's paging state
1414
+ * survives untouched.
1265
1415
  *
1266
1416
  * ### Round-trip profile — the counterweight to the payload win
1267
1417
  *
1268
1418
  * This trades ONE fat query for several thin ones. Per page:
1269
1419
  *
1420
+ * 0. {@link BranchPathFilterFresh} — one branch read, only when `BranchID` is set
1270
1421
  * 1. {@link fetchDetailRowsBySequence} — always
1271
1422
  * 2. {@link expandOldestSession} — only when the oldest row is session-stamped
1272
1423
  * 3. {@link hasOlderDetails} — always, in parallel with (4)
@@ -1288,13 +1439,18 @@ export class ConversationEngine extends BaseEngine {
1288
1439
  * over more content — and unlike first paint, a reader who has scrolled up has already
1289
1440
  * committed to reading back. If paging up ever feels slow, that is the number to raise.
1290
1441
  *
1291
- * @param params - Conversation, optional `Sequence` bound, and page sizing
1442
+ * @param params - Conversation, optional branch, optional `Sequence` bound, and page sizing
1292
1443
  * @param contextUser - The requesting user (entity RLS applies)
1293
1444
  */
1294
1445
  async LoadDetailWindow(params, contextUser) {
1295
1446
  const pageSize = params.PageSize ?? DEFAULT_WINDOW_PAGE_SIZE;
1296
1447
  const overread = params.RawOverread ?? pageSize * DETAIL_WINDOW_OVERREAD_FACTOR;
1297
- const page = await this.fetchDetailRowsBySequence(params.ConversationID, params.BeforeSequence, overread, contextUser);
1448
+ const pathFilter = await this.resolveWindowPathFilter(params, contextUser);
1449
+ if (pathFilter === null) {
1450
+ return emptyDetailWindowResult(true);
1451
+ }
1452
+ const liveFilter = ConversationEngine.LiveRowsFilter(pathFilter);
1453
+ const page = await this.fetchDetailRowsBySequence(liveFilter, params.BeforeSequence, overread, contextUser);
1298
1454
  // Deliberately two branches, not one. `null` is a FAILED read; an empty array is a
1299
1455
  // range that genuinely holds no rows. Collapsing them is what lets a transport blip
1300
1456
  // read downstream as "you have reached the start of the conversation".
@@ -1304,7 +1460,7 @@ export class ConversationEngine extends BaseEngine {
1304
1460
  if (page.length === 0) {
1305
1461
  return emptyDetailWindowResult(false);
1306
1462
  }
1307
- const details = await this.expandOldestSession(params.ConversationID, page, contextUser);
1463
+ const details = await this.expandOldestSession(liveFilter, page, contextUser);
1308
1464
  const oldestSequence = details[0].Sequence;
1309
1465
  const newestSequence = details[details.length - 1].Sequence;
1310
1466
  // Concurrent, not sequential: the probe needs only `oldestSequence` and the peripherals
@@ -1312,7 +1468,7 @@ export class ConversationEngine extends BaseEngine {
1312
1468
  // to an empty/false result on a failed read — so `Promise.all` cannot introduce a
1313
1469
  // rejection path this method did not already have.
1314
1470
  const [olderProbe, peripherals] = await Promise.all([
1315
- this.hasOlderDetails(params.ConversationID, oldestSequence, contextUser),
1471
+ this.hasOlderDetails(liveFilter, oldestSequence, contextUser),
1316
1472
  this.buildWindowPeripherals(details, contextUser)
1317
1473
  ]);
1318
1474
  return {
@@ -1327,6 +1483,22 @@ export class ConversationEngine extends BaseEngine {
1327
1483
  Failed: olderProbe.Failed
1328
1484
  };
1329
1485
  }
1486
+ /**
1487
+ * The `ExtraFilter` predicate for the window's branch path.
1488
+ *
1489
+ * @returns The predicate, or null when the branch rows could not be read or the branch
1490
+ * path could not be built.
1491
+ */
1492
+ async resolveWindowPathFilter(params, contextUser) {
1493
+ try {
1494
+ return await ConversationEngine.BranchPathFilterFresh(params.ConversationID, params.BranchID, contextUser, this.ProviderToUse);
1495
+ }
1496
+ catch (error) {
1497
+ console.error(`[ConversationEngine] Failed to build the branch path for conversation ${params.ConversationID}, `
1498
+ + `branch ${params.BranchID}:`, error);
1499
+ return null;
1500
+ }
1501
+ }
1330
1502
  /**
1331
1503
  * Reads the newest `maxRows` detail rows below an optional `Sequence` bound and returns
1332
1504
  * them in CHRONOLOGICAL order.
@@ -1334,13 +1506,14 @@ export class ConversationEngine extends BaseEngine {
1334
1506
  * The query is `Sequence DESC` because the interesting end of a transcript is the tail;
1335
1507
  * the reversal happens here so every caller downstream sees oldest-to-newest.
1336
1508
  *
1509
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1337
1510
  * @returns The page in ascending `Sequence` order, or null when the read failed.
1338
1511
  */
1339
- async fetchDetailRowsBySequence(conversationId, beforeSequence, maxRows, contextUser) {
1512
+ async fetchDetailRowsBySequence(pathFilter, beforeSequence, maxRows, contextUser) {
1340
1513
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1341
1514
  const filter = beforeSequence == null
1342
- ? `ConversationID='${conversationId}'`
1343
- : `ConversationID='${conversationId}' AND Sequence < ${beforeSequence}`;
1515
+ ? pathFilter
1516
+ : `${pathFilter} AND Sequence < ${beforeSequence}`;
1344
1517
  const result = await rv.RunView({
1345
1518
  EntityName: 'MJ: Conversation Details',
1346
1519
  ExtraFilter: filter,
@@ -1362,9 +1535,10 @@ export class ConversationEngine extends BaseEngine {
1362
1535
  * boundary — without this read the card renders from a partial row set AND the same
1363
1536
  * session reappears on the next older page.
1364
1537
  *
1538
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1365
1539
  * @returns The page with any missing session rows prepended, still chronological.
1366
1540
  */
1367
- async expandOldestSession(conversationId, details, contextUser) {
1541
+ async expandOldestSession(pathFilter, details, contextUser) {
1368
1542
  const sessionId = details[0].AgentSessionID?.trim();
1369
1543
  if (!sessionId) {
1370
1544
  return details;
@@ -1372,7 +1546,7 @@ export class ConversationEngine extends BaseEngine {
1372
1546
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1373
1547
  const result = await rv.RunView({
1374
1548
  EntityName: 'MJ: Conversation Details',
1375
- ExtraFilter: `ConversationID='${conversationId}' AND AgentSessionID='${sessionId}' `
1549
+ ExtraFilter: `${pathFilter} AND AgentSessionID='${sessionId}' `
1376
1550
  + `AND Sequence < ${details[0].Sequence}`,
1377
1551
  OrderBy: 'Sequence DESC',
1378
1552
  MaxRows: MAX_SESSION_EXPANSION_ROWS,
@@ -1392,12 +1566,14 @@ export class ConversationEngine extends BaseEngine {
1392
1566
  *
1393
1567
  * Returns a TRI-STATE rather than a boolean. "No older rows" and "could not find out"
1394
1568
  * are both `HasOlder: false`, and only the second must leave the caller's cursor alone.
1569
+ *
1570
+ * @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
1395
1571
  */
1396
- async hasOlderDetails(conversationId, oldestSequence, contextUser) {
1572
+ async hasOlderDetails(pathFilter, oldestSequence, contextUser) {
1397
1573
  const rv = RunView.FromMetadataProvider(this.ProviderToUse);
1398
1574
  const probe = await rv.RunView({
1399
1575
  EntityName: 'MJ: Conversation Details',
1400
- ExtraFilter: `ConversationID='${conversationId}' AND Sequence < ${oldestSequence}`,
1576
+ ExtraFilter: `${pathFilter} AND Sequence < ${oldestSequence}`,
1401
1577
  OrderBy: 'Sequence DESC',
1402
1578
  MaxRows: 1,
1403
1579
  Fields: ['ID'],
@@ -1427,14 +1603,16 @@ export class ConversationEngine extends BaseEngine {
1427
1603
  if (!row.ID)
1428
1604
  continue;
1429
1605
  const parsed = ParseConversationDetailComplete(row);
1430
- // Agent runs
1431
- if (parsed.agentRuns.length > 0) {
1432
- const agentRunData = parsed.agentRuns[0];
1606
+ // Agent runs (prefer primary root orchestrating run over continuation synthesis or child runs)
1607
+ const agentRunData = selectPreferredDetailAgentRun(parsed.agentRuns);
1608
+ if (agentRunData) {
1433
1609
  const agentRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
1434
1610
  agentRun.LoadFromData({
1435
1611
  ID: agentRunData.ID,
1436
1612
  AgentID: agentRunData.AgentID,
1437
1613
  Agent: agentRunData.Agent,
1614
+ ParentRunID: agentRunData.ParentRunID ?? null,
1615
+ ContinuationDepth: agentRunData.ContinuationDepth ?? 0,
1438
1616
  Status: agentRunData.Status,
1439
1617
  __mj_CreatedAt: agentRunData.__mj_CreatedAt,
1440
1618
  __mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
@@ -1496,6 +1674,7 @@ export class ConversationEngine extends BaseEngine {
1496
1674
  {
1497
1675
  EntityName: 'MJ: Conversation Detail Artifacts',
1498
1676
  ExtraFilter: `${byDetail} AND Direction='Output'`,
1677
+ OrderBy: '__mj_CreatedAt ASC',
1499
1678
  ResultType: 'simple'
1500
1679
  }
1501
1680
  ], contextUser);
@@ -1566,6 +1745,14 @@ export class ConversationEngine extends BaseEngine {
1566
1745
  list.push(mergeArtifactJSON(junction, version, artifact));
1567
1746
  byDetailId.set(junction.ConversationDetailID, list);
1568
1747
  }
1748
+ for (const [detailId, list] of byDetailId) {
1749
+ list.sort((a, b) => {
1750
+ const aTime = ToEpochMs(a.VersionCreatedAt) ?? ToEpochMs(a.__mj_CreatedAt) ?? 0;
1751
+ const bTime = ToEpochMs(b.VersionCreatedAt) ?? ToEpochMs(b.__mj_CreatedAt) ?? 0;
1752
+ return aTime - bTime;
1753
+ });
1754
+ byDetailId.set(detailId, list);
1755
+ }
1569
1756
  return byDetailId;
1570
1757
  }
1571
1758
  /** Reads the artifact rows behind a window's artifact versions. */
@@ -1589,21 +1776,27 @@ export class ConversationEngine extends BaseEngine {
1589
1776
  * - Artifacts/ratings: replaced per-detail (cheap — plain data, not entity objects)
1590
1777
  * - User avatars: merged (new users added)
1591
1778
  *
1592
- * If no cache exists yet, falls back to a full load.
1779
+ * If no cache exists yet, falls back to a full load. The cache holds every row of the
1780
+ * conversation; a scope limits only what this call returns.
1593
1781
  *
1594
1782
  * @param conversationId - The conversation to refresh
1595
1783
  * @param contextUser - The current user context
1596
- * @returns The updated cache entry
1784
+ * @param scope - When given, the returned entry holds only the rows in this scope
1785
+ * (see {@link scopedEntry}); when omitted, every row of the conversation
1786
+ * @returns The updated cache entry, limited to the scope when one is given
1787
+ * @throws when the scope belongs to another conversation
1597
1788
  */
1598
- async RefreshConversationDetails(conversationId, contextUser) {
1789
+ async RefreshConversationDetails(conversationId, contextUser, scope) {
1790
+ ConversationEngine.assertScopeOf(conversationId, scope);
1599
1791
  const key = NormalizeUUID(conversationId);
1600
1792
  const existing = this._detailCache.get(key);
1601
1793
  // No cache yet — do a full load
1602
1794
  if (!existing) {
1603
- return this.LoadConversationDetails(conversationId, contextUser);
1795
+ return this.LoadConversationDetails(conversationId, contextUser, false, scope);
1604
1796
  }
1605
1797
  // Run the query
1606
1798
  const rq = new RunQuery();
1799
+ // conversation-scope: whole-conversation cache; every consumer filters to its scope
1607
1800
  const result = await rq.RunQuery({
1608
1801
  QueryName: 'GetConversationComplete',
1609
1802
  CategoryPath: 'MJ/Conversations',
@@ -1611,7 +1804,7 @@ export class ConversationEngine extends BaseEngine {
1611
1804
  }, contextUser);
1612
1805
  if (!result.Success || !result.Results) {
1613
1806
  console.error('[ConversationEngine] Failed to refresh conversation details:', result.ErrorMessage);
1614
- return existing;
1807
+ return ConversationEngine.scopedEntry(existing, scope);
1615
1808
  }
1616
1809
  const freshRows = result.Results;
1617
1810
  const md = this.ProviderToUse;
@@ -1646,12 +1839,14 @@ export class ConversationEngine extends BaseEngine {
1646
1839
  // check for `LoadFromData` before calling it, and hydrate a real entity when
1647
1840
  // we only have plain data. Mirrors the pattern in
1648
1841
  // `conversation-chat-area.component.ts#onMessageComplete` (line ~909).
1649
- if (parsed.agentRuns.length > 0) {
1650
- const agentRunData = parsed.agentRuns[0];
1842
+ const agentRunData = selectPreferredDetailAgentRun(parsed.agentRuns);
1843
+ if (agentRunData) {
1651
1844
  const mergeFields = {
1652
1845
  ID: agentRunData.ID,
1653
1846
  AgentID: agentRunData.AgentID,
1654
1847
  Agent: agentRunData.Agent,
1848
+ ParentRunID: agentRunData.ParentRunID ?? null,
1849
+ ContinuationDepth: agentRunData.ContinuationDepth ?? 0,
1655
1850
  Status: agentRunData.Status,
1656
1851
  __mj_CreatedAt: agentRunData.__mj_CreatedAt,
1657
1852
  __mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
@@ -1661,15 +1856,17 @@ export class ConversationEngine extends BaseEngine {
1661
1856
  ConversationDetailID: agentRunData.ConversationDetailID
1662
1857
  };
1663
1858
  const existingRun = existing.AgentRunsByDetailId.get(row.ID);
1664
- const existingRunIsEntity = !!existingRun && typeof existingRun.LoadFromData === 'function';
1665
- if (existingRunIsEntity) {
1666
- existingRun.LoadFromData(mergeFields);
1667
- }
1668
- else {
1669
- // Either no existing run, or existing value is plain JSON — hydrate fresh.
1670
- const newRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
1671
- newRun.LoadFromData(existingRun ? { ...existingRun, ...mergeFields } : agentRunData);
1672
- existing.AgentRunsByDetailId.set(row.ID, newRun);
1859
+ if (ShouldReplaceDetailAgentRun(existingRun, agentRunData)) {
1860
+ const existingRunIsEntity = !!existingRun && typeof existingRun.LoadFromData === 'function';
1861
+ if (existingRunIsEntity) {
1862
+ existingRun.LoadFromData(mergeFields);
1863
+ }
1864
+ else {
1865
+ // Either no existing run, or existing value is plain JSON — hydrate fresh.
1866
+ const newRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
1867
+ newRun.LoadFromData(existingRun ? { ...existingRun, ...mergeFields } : agentRunData);
1868
+ existing.AgentRunsByDetailId.set(row.ID, newRun);
1869
+ }
1673
1870
  }
1674
1871
  }
1675
1872
  // Replace artifacts per-detail (plain data, cheap to replace)
@@ -1692,7 +1889,7 @@ export class ConversationEngine extends BaseEngine {
1692
1889
  existing.RawData = freshRows;
1693
1890
  existing.LoadedAt = new Date();
1694
1891
  existing.PeripheralDataStale = false;
1695
- return existing;
1892
+ return ConversationEngine.scopedEntry(existing, scope);
1696
1893
  }
1697
1894
  /**
1698
1895
  * Returns cached conversation details (messages only) without hitting the database.
@@ -1752,22 +1949,55 @@ export class ConversationEngine extends BaseEngine {
1752
1949
  * covers everything before it, and cutting into the post-boundary tail would create
1753
1950
  * a coverage gap.
1754
1951
  * @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
1952
+ * @param scope - When given, the window is built from the rows in this scope only; when
1953
+ * omitted, from every cached row of the conversation
1755
1954
  * @returns Messages in chronological order, each stamped with
1756
1955
  * {@link ConversationContextMetadata}
1956
+ * @throws when the scope belongs to another conversation
1757
1957
  */
1758
- async GetAgentContextWindow(conversationId, contextUser, options) {
1958
+ async GetAgentContextWindow(conversationId, contextUser, options, scope) {
1759
1959
  await this.Config(false, contextUser);
1760
- const cache = await this.LoadConversationDetails(conversationId, contextUser);
1960
+ const cache = await this.LoadConversationDetails(conversationId, contextUser, false, scope);
1761
1961
  return ConversationEngine.AssembleContextWindow(cache.Details, options);
1762
1962
  }
1763
1963
  /**
1764
- * Loads a conversation's window source rows FRESH — one RunView through the given
1964
+ * Loads a conversation's branch rows FRESH through the given provider (entity RLS applies).
1965
+ * Ordered by creation so alternatives at a fork point list in the order they were made.
1966
+ * @throws when the RunView reports failure
1967
+ */
1968
+ static async LoadBranchesFresh(conversationId, contextUser, provider) {
1969
+ const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
1970
+ const rows = await rv.RunView({
1971
+ EntityName: 'MJ: Conversation Branches',
1972
+ ExtraFilter: `ConversationID='${EscapeSQLString(conversationId)}'`,
1973
+ OrderBy: '__mj_CreatedAt ASC',
1974
+ Fields: [...ConversationBranchFields],
1975
+ ResultType: 'simple',
1976
+ }, contextUser);
1977
+ if (!rows.Success) {
1978
+ throw new Error(`Failed to load branches for conversation ${conversationId}: ${rows.ErrorMessage}`);
1979
+ }
1980
+ return rows.Results || [];
1981
+ }
1982
+ /**
1983
+ * {@link ScopeFilter} of the scope from {@link LoadScope}. The trunk (an empty, whitespace-only
1984
+ * or missing branch id) needs no load and costs no query.
1985
+ * @throws When the branch read fails ({@link LoadBranchesFresh}), when `branchId` is not
1986
+ * among the conversation's branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
1987
+ */
1988
+ static async BranchPathFilterFresh(conversationId, branchId, contextUser, provider) {
1989
+ const scope = await ConversationEngine.LoadScope(conversationId, branchId, contextUser, provider);
1990
+ return ConversationEngine.ScopeFilter(scope);
1991
+ }
1992
+ /**
1993
+ * Loads the window source rows on a conversation's branch path FRESH — through the given
1765
1994
  * 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,
1995
+ * process-global detail cache. The trunk costs one RunView; a branch costs two (its branch
1996
+ * rows via {@link BranchPathFilterFresh}, then the path's rows). The single source of the
1997
+ * fresh-load query shape (entity name, filter, order, fields) for every server-side
1998
+ * caller: the agent resolver's history loader and the cross-turn compaction pass both consume this,
1769
1999
  * so the two can never drift apart. THROWS on load failure — servers must fail
1770
- * loudly rather than proceed against an empty history.
2000
+ * loudly rather than proceed against an empty history. Replaced rows never leave the database.
1771
2001
  *
1772
2002
  * @param conversationId - The conversation whose detail rows to load
1773
2003
  * @param contextUser - The requesting user (entity RLS is applied under this user)
@@ -1775,18 +2005,21 @@ export class ConversationEngine extends BaseEngine {
1775
2005
  * @param historyFrom - Optional history floor: only rows written at or after it are loaded,
1776
2006
  * so nothing before it leaves the database. Pair it with the same `historyFrom` on
1777
2007
  * {@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) {
2008
+ * @param branchId - The branch whose path to load; null or omitted reads the trunk.
2009
+ * @returns The rows on the branch's path (the trunk when no branch) in Sequence order,
2010
+ * shaped for {@link AssembleContextWindow}
2011
+ * @throws When a RunView reports failure (never returns a silent empty set), when
2012
+ * `historyFrom` is an invalid date, when `branchId` is not among the conversation's
2013
+ * branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
2014
+ */
2015
+ static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom, branchId) {
1783
2016
  const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
1784
- const conversationFilter = `ConversationID='${conversationId}'`;
2017
+ const pathFilter = await ConversationEngine.BranchPathFilterFresh(conversationId, branchId, contextUser, provider);
1785
2018
  const rows = await rv.RunView({
1786
2019
  EntityName: 'MJ: Conversation Details',
1787
- ExtraFilter: historyFrom
1788
- ? `${conversationFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
1789
- : conversationFilter,
2020
+ ExtraFilter: ConversationEngine.LiveRowsFilter(historyFrom
2021
+ ? `${pathFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
2022
+ : pathFilter),
1790
2023
  OrderBy: 'Sequence ASC',
1791
2024
  Fields: [...ConversationWindowFields],
1792
2025
  ResultType: 'simple',
@@ -1814,12 +2047,14 @@ export class ConversationEngine extends BaseEngine {
1814
2047
  * floor none is used, and the window is the most recent `maxTailMessages` rows at or
1815
2048
  * after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
1816
2049
  * to be after the floor, so it is dropped too.
2050
+ *
2051
+ * Replaced rows (`ReplacedAt` set) are always left out.
1817
2052
  */
1818
2053
  static AssembleContextWindow(details, options) {
1819
2054
  const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
1820
2055
  const floor = options?.historyFrom ?? null;
1821
2056
  const ordered = details
1822
- .filter(d => !excluded.has(NormalizeUUID(d.ID)))
2057
+ .filter(d => !excluded.has(NormalizeUUID(d.ID)) && !ConversationEngine.IsReplacedRow(d))
1823
2058
  .filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
1824
2059
  .sort((a, b) => a.Sequence - b.Sequence);
1825
2060
  const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
@@ -1844,6 +2079,412 @@ export class ConversationEngine extends BaseEngine {
1844
2079
  static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
1845
2080
  return `${column} >= '${historyFrom.toISOString()}'`;
1846
2081
  }
2082
+ /**
2083
+ * `filter` limited to rows no rerun replaced (`ReplacedAt` is NULL). A replaced row is an answer a
2084
+ * rerun of its turn superseded: it is kept, but never shown and never sent to an agent. Every reader
2085
+ * of conversation rows for display or for agent context applies this.
2086
+ */
2087
+ static LiveRowsFilter(filter) {
2088
+ return `${filter} AND [ReplacedAt] IS NULL`;
2089
+ }
2090
+ /** True when a rerun of its turn replaced the row (`ReplacedAt` is set). */
2091
+ static IsReplacedRow(row) {
2092
+ return row.ReplacedAt != null;
2093
+ }
2094
+ /**
2095
+ * The branch and its ancestors, current branch first. Empty for the trunk.
2096
+ * @throws when the branch is not in `branches`, or the chain is longer than
2097
+ * {@link MAX_BRANCH_DEPTH} (which also catches a cycle).
2098
+ */
2099
+ static BuildBranchChain(branches, branchId) {
2100
+ const chain = [];
2101
+ let current = branchId ?? null;
2102
+ while (current != null) {
2103
+ if (chain.length >= MAX_BRANCH_DEPTH) {
2104
+ throw new Error(`Branch chain for ${branchId} exceeds ${MAX_BRANCH_DEPTH} levels`);
2105
+ }
2106
+ const id = current;
2107
+ const branch = branches.find(b => sameId(b.ID, id));
2108
+ if (!branch) {
2109
+ throw new Error(`Branch ${id} not found`);
2110
+ }
2111
+ chain.push(branch);
2112
+ current = branch.ParentBranchID ?? null;
2113
+ }
2114
+ return chain;
2115
+ }
2116
+ /**
2117
+ * The `ExtraFilter` predicate for a branch path: the branch's own rows, plus each
2118
+ * ancestor's rows up to that ancestor's fork sequence, plus the trunk up to the last
2119
+ * fork sequence. A null branch is the trunk alone.
2120
+ *
2121
+ * Column names are bracketed, so the predicate also works inside a subquery on
2122
+ * `vwConversationDetails` from another entity's filter: the PostgreSQL provider converts
2123
+ * bracketed names itself but quotes bare names only when they are fields of the outer entity.
2124
+ */
2125
+ static BuildBranchPathFilter(conversationId, branchId, branches) {
2126
+ const conversationFilter = `[ConversationID]='${EscapeSQLString(conversationId)}'`;
2127
+ const chain = ConversationEngine.BuildBranchChain(branches, branchId);
2128
+ if (chain.length === 0) {
2129
+ return `${conversationFilter} AND [BranchID] IS NULL`;
2130
+ }
2131
+ const clauses = [];
2132
+ let cap = null;
2133
+ for (const branch of chain) {
2134
+ const id = EscapeSQLString(branch.ID);
2135
+ clauses.push(cap == null ? `[BranchID]='${id}'` : `([BranchID]='${id}' AND [Sequence] <= ${cap})`);
2136
+ cap = branch.ForkFromSequence;
2137
+ if (cap == null) {
2138
+ return `${conversationFilter} AND (${clauses.join(' OR ')})`;
2139
+ }
2140
+ }
2141
+ clauses.push(`([BranchID] IS NULL AND [Sequence] <= ${cap})`);
2142
+ return `${conversationFilter} AND (${clauses.join(' OR ')})`;
2143
+ }
2144
+ /** True when a row is on the path described by a chain from {@link BuildBranchChain}. */
2145
+ static IsRowOnBranchPath(row, chain) {
2146
+ let cap = null;
2147
+ for (const branch of chain) {
2148
+ if (sameId(row.BranchID, branch.ID)) {
2149
+ return cap == null || row.Sequence <= cap;
2150
+ }
2151
+ cap = branch.ForkFromSequence;
2152
+ if (cap == null) {
2153
+ return false;
2154
+ }
2155
+ }
2156
+ return row.BranchID == null && (cap == null || row.Sequence <= cap);
2157
+ }
2158
+ /** In-memory counterpart of {@link BuildBranchPathFilter} for rows already loaded. */
2159
+ static FilterRowsToBranchPath(rows, branchId, branches) {
2160
+ const chain = ConversationEngine.BuildBranchChain(branches, branchId);
2161
+ return rows.filter(r => ConversationEngine.IsRowOnBranchPath(r, chain));
2162
+ }
2163
+ /** A scope for the trunk: no branch rows are needed. */
2164
+ static TrunkScope(conversationId) {
2165
+ return { ConversationID: conversationId, BranchID: null, Branches: [] };
2166
+ }
2167
+ /**
2168
+ * Builds a scope for an explicit branch. An empty, whitespace-only or missing branch id is the
2169
+ * trunk. A branch id loads the conversation's branch rows once and must name one of them
2170
+ * (compared without regard to case); the scope's `BranchID` is that row's `ID`.
2171
+ * @throws when the branch rows cannot be read or the branch is not one of them.
2172
+ */
2173
+ static async LoadScope(conversationId, branchId, contextUser, provider) {
2174
+ const normalized = branchId && branchId.trim().length > 0 ? branchId : null;
2175
+ if (normalized === null) {
2176
+ return ConversationEngine.TrunkScope(conversationId);
2177
+ }
2178
+ const branches = await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
2179
+ const branch = branches.find(b => sameId(b.ID, normalized));
2180
+ if (!branch) {
2181
+ throw new Error(`Branch ${normalized} is not a branch of conversation ${conversationId}`);
2182
+ }
2183
+ return { ConversationID: conversationId, BranchID: branch.ID, Branches: branches };
2184
+ }
2185
+ /**
2186
+ * Predicate over MJ: Conversation Details for the rows in scope. Same text as
2187
+ * {@link BuildBranchPathFilter}.
2188
+ * @throws when the scope's `BranchID`, or the parent of a branch on its chain, is not in the
2189
+ * scope's `Branches`, or when the chain is longer than {@link MAX_BRANCH_DEPTH}.
2190
+ */
2191
+ static ScopeFilter(scope) {
2192
+ return ConversationEngine.BuildBranchPathFilter(scope.ConversationID, scope.BranchID, scope.Branches);
2193
+ }
2194
+ /**
2195
+ * `[column] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE <ScopeFilter>)` for entities
2196
+ * anchored by a conversation detail id column.
2197
+ */
2198
+ static ScopeSubquery(scope, column = 'ConversationDetailID') {
2199
+ return `[${column}] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${ConversationEngine.ScopeFilter(scope)})`;
2200
+ }
2201
+ /** In-memory form of {@link ScopeFilter} for one row already loaded. */
2202
+ static IsInScope(scope, row) {
2203
+ const chain = ConversationEngine.BuildBranchChain(scope.Branches, scope.BranchID);
2204
+ return ConversationEngine.IsRowOnBranchPath(row, chain);
2205
+ }
2206
+ /** In-memory form of {@link ScopeFilter} for rows already loaded. Keeps their order. */
2207
+ static FilterToScope(scope, rows) {
2208
+ return ConversationEngine.FilterRowsToBranchPath(rows, scope.BranchID, scope.Branches);
2209
+ }
2210
+ /**
2211
+ * Filter over MJ: Artifact Versions for the versions of one artifact visible in scope: versions
2212
+ * linked (in any direction) from a message on the path, and versions with no conversation link
2213
+ * at all.
2214
+ */
2215
+ static ArtifactVersionScopeFilter(scope, artifactId) {
2216
+ const path = ConversationEngine.ScopeFilter(scope);
2217
+ const id = EscapeSQLString(artifactId);
2218
+ return `[ArtifactID]='${id}' AND (` +
2219
+ `[ID] IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ConversationDetailID IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${path}))` +
2220
+ ` OR [ID] NOT IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ArtifactVersionID IS NOT NULL)` +
2221
+ `)`;
2222
+ }
2223
+ /** The kind of a fork row; a missing or unknown value is 'Fork'. */
2224
+ static NormalizeForkKind(kind) {
2225
+ return FORK_KINDS.find(k => k === kind) ?? 'Fork';
2226
+ }
2227
+ /**
2228
+ * Plain text of a stored message: each `@{...}` mention becomes `@<name>`, or is dropped when it
2229
+ * has no name, and runs of whitespace become one space. Null when nothing is left.
2230
+ */
2231
+ static ForkPlainText(message) {
2232
+ if (!message) {
2233
+ return null;
2234
+ }
2235
+ const plain = message
2236
+ .replace(/@\{[^{}]*\}/g, token => {
2237
+ try {
2238
+ const parsed = JSON.parse(token.slice(1));
2239
+ return typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? `@${parsed.name.trim()}` : ' ';
2240
+ }
2241
+ catch {
2242
+ return ' ';
2243
+ }
2244
+ })
2245
+ .replace(/\s+/g, ' ')
2246
+ .trim();
2247
+ return plain.length > 0 ? plain : null;
2248
+ }
2249
+ /** The first words of a message, for a fork with no name: at most 6 words and 40 characters, '…' when cut. */
2250
+ static ForkFirstWords(message) {
2251
+ const plain = ConversationEngine.ForkPlainText(message);
2252
+ if (!plain) {
2253
+ return null;
2254
+ }
2255
+ const words = plain.split(' ');
2256
+ let text = words.slice(0, FORK_NAME_MAX_WORDS).join(' ');
2257
+ let cut = words.length > FORK_NAME_MAX_WORDS;
2258
+ if (text.length > FORK_NAME_MAX_CHARS) {
2259
+ text = text.slice(0, FORK_NAME_MAX_CHARS).trimEnd();
2260
+ cut = true;
2261
+ }
2262
+ return cut ? `${text}…` : text;
2263
+ }
2264
+ /** A one-line preview of a message: at most 120 characters, '…' when cut. */
2265
+ static ForkPreviewText(message) {
2266
+ const plain = ConversationEngine.ForkPlainText(message);
2267
+ if (!plain) {
2268
+ return null;
2269
+ }
2270
+ return plain.length > FORK_PREVIEW_MAX_CHARS ? `${plain.slice(0, FORK_PREVIEW_MAX_CHARS).trimEnd()}…` : plain;
2271
+ }
2272
+ /**
2273
+ * The label of a fork: its name, else a default from its kind — Fork: the first words of its first
2274
+ * message, else "Fork"; Edit: "Edited version"; Regenerate: "Other answer".
2275
+ */
2276
+ static ForkDisplayName(branch, firstOwnMessage) {
2277
+ const name = branch.Name?.trim();
2278
+ if (name) {
2279
+ return name;
2280
+ }
2281
+ switch (ConversationEngine.NormalizeForkKind(branch.Kind)) {
2282
+ case 'Edit':
2283
+ return 'Edited version';
2284
+ case 'Regenerate':
2285
+ return 'Other answer';
2286
+ default:
2287
+ return ConversationEngine.ForkFirstWords(firstOwnMessage) ?? 'Fork';
2288
+ }
2289
+ }
2290
+ /** The fork point at a message: the new fork starts after the message itself, on its branch. */
2291
+ static ForkPointFrom(message) {
2292
+ return { ParentBranchID: message.BranchID ?? null, ForkFromSequence: message.Sequence };
2293
+ }
2294
+ /**
2295
+ * Edit of a message, or Fork from here on it (the fork replaces it): the new fork starts at the row
2296
+ * before it on its path (both null when it is the first message). Undefined when that row is not
2297
+ * known: the message is not in `pathRows`, or it is the first loaded row while older rows exist
2298
+ * (`hasMoreAbove`).
2299
+ */
2300
+ static EditForkPoint(message, pathRows, hasMoreAbove = false) {
2301
+ const sorted = [...pathRows].sort((a, b) => a.Sequence - b.Sequence);
2302
+ const index = sorted.findIndex(r => sameId(r.ID, message.ID));
2303
+ if (index < 0) {
2304
+ return undefined;
2305
+ }
2306
+ if (index === 0) {
2307
+ return hasMoreAbove ? undefined : { ParentBranchID: null, ForkFromSequence: null };
2308
+ }
2309
+ return ConversationEngine.ForkPointFrom(sorted[index - 1]);
2310
+ }
2311
+ /** Regenerate of an answer: the new fork starts at the user message the answer replied to. */
2312
+ static RegenerateForkPoint(userMessage) {
2313
+ return ConversationEngine.ForkPointFrom(userMessage);
2314
+ }
2315
+ /**
2316
+ * Predicate over MJ: Conversation Details for the anchor rows of the given forks (one clause per
2317
+ * distinct fork point); null when no fork has a fork point.
2318
+ */
2319
+ static AnchorRowsFilter(conversationId, branches) {
2320
+ const clauses = [];
2321
+ for (const branch of branches) {
2322
+ if (branch.ForkFromSequence == null) {
2323
+ continue;
2324
+ }
2325
+ const onParent = branch.ParentBranchID == null
2326
+ ? '[BranchID] IS NULL'
2327
+ : `[BranchID]='${EscapeSQLString(branch.ParentBranchID)}'`;
2328
+ const clause = `(${onParent} AND [Sequence]=${Math.trunc(branch.ForkFromSequence)})`;
2329
+ if (!clauses.includes(clause)) {
2330
+ clauses.push(clause);
2331
+ }
2332
+ }
2333
+ if (clauses.length === 0) {
2334
+ return null;
2335
+ }
2336
+ return `[ConversationID]='${EscapeSQLString(conversationId)}' AND (${clauses.join(' OR ')})`;
2337
+ }
2338
+ /**
2339
+ * Predicate over MJ: Conversation Details for the source rows of the given forks (each distinct
2340
+ * SourceDetailID once); null when no fork has a source.
2341
+ */
2342
+ static SourceRowsFilter(conversationId, branches) {
2343
+ const ids = [];
2344
+ for (const branch of branches) {
2345
+ const id = branch.SourceDetailID;
2346
+ if (id && !ids.some(known => sameId(known, id))) {
2347
+ ids.push(id);
2348
+ }
2349
+ }
2350
+ if (ids.length === 0) {
2351
+ return null;
2352
+ }
2353
+ const list = ids.map(id => `'${EscapeSQLString(id)}'`).join(', ');
2354
+ return `[ConversationID]='${EscapeSQLString(conversationId)}' AND [ID] IN (${list})`;
2355
+ }
2356
+ /**
2357
+ * One summary per fork, in the order of `branches`. Pure: `ownRows` are the conversation's rows
2358
+ * with a BranchID, `anchorRows` the rows {@link AnchorRowsFilter} selects, `sourceRows` the rows
2359
+ * {@link SourceRowsFilter} selects.
2360
+ */
2361
+ static BuildForkSummaries(branches, ownRows, anchorRows, sourceRows = []) {
2362
+ const ownByFork = new Map();
2363
+ for (const row of ownRows) {
2364
+ if (row.BranchID == null || row.HiddenToUser === true) {
2365
+ continue;
2366
+ }
2367
+ const key = NormalizeUUID(row.BranchID);
2368
+ const list = ownByFork.get(key);
2369
+ if (list) {
2370
+ list.push(row);
2371
+ }
2372
+ else {
2373
+ ownByFork.set(key, [row]);
2374
+ }
2375
+ }
2376
+ return branches.map(branch => {
2377
+ const own = [...(ownByFork.get(NormalizeUUID(branch.ID)) ?? [])].sort((a, b) => a.Sequence - b.Sequence);
2378
+ const anchor = branch.ForkFromSequence == null
2379
+ ? undefined
2380
+ : anchorRows.find(r => r.Sequence === branch.ForkFromSequence && sameId(r.BranchID, branch.ParentBranchID ?? null));
2381
+ const sourceId = branch.SourceDetailID;
2382
+ const source = sourceId ? sourceRows.find(r => sameId(r.ID, sourceId)) : undefined;
2383
+ const placement = sourceId ? source : anchor;
2384
+ const first = own.find(r => ConversationEngine.ForkPlainText(r.Message) != null);
2385
+ const last = own.length > 0 ? own[own.length - 1] : undefined;
2386
+ const authors = [];
2387
+ for (const r of own) {
2388
+ if (r.Role === 'User' && r.UserID && !authors.some(a => sameId(a, r.UserID))) {
2389
+ authors.push(r.UserID);
2390
+ }
2391
+ }
2392
+ return {
2393
+ Branch: branch,
2394
+ Kind: ConversationEngine.NormalizeForkKind(branch.Kind),
2395
+ DisplayName: ConversationEngine.ForkDisplayName(branch, first?.Message ?? null),
2396
+ MessageCount: own.length,
2397
+ Participants: ConversationEngine.forkParticipants(own),
2398
+ AuthorUserIDs: authors,
2399
+ LastActivityAt: (last ? ConversationEngine.toForkDate(last.__mj_CreatedAt) : null)
2400
+ ?? ConversationEngine.toForkDate(branch.__mj_CreatedAt)
2401
+ ?? new Date(0),
2402
+ LastMessagePreview: last ? ConversationEngine.ForkPreviewText(last.Message) : null,
2403
+ LastMessageAuthorName: last ? ConversationEngine.rowAuthorName(last) : null,
2404
+ PlacementDetailID: branch.SourceDetailID ?? anchor?.ID ?? null,
2405
+ PlacementPosition: placement ? { BranchID: placement.BranchID ?? null, Sequence: placement.Sequence } : null,
2406
+ AnchorDetailID: anchor?.ID ?? null,
2407
+ AnchorAuthorName: anchor ? ConversationEngine.rowAuthorName(anchor) : null,
2408
+ AnchorAt: anchor ? ConversationEngine.toForkDate(anchor.__mj_CreatedAt) : null,
2409
+ SourceAuthorName: source ? ConversationEngine.rowAuthorName(source) : null,
2410
+ StartedByUserID: branch.UserID ?? null,
2411
+ StartedByName: branch.User ?? null,
2412
+ ParentBranchID: branch.ParentBranchID ?? null,
2413
+ };
2414
+ });
2415
+ }
2416
+ /**
2417
+ * Loads the fork summaries of a conversation FRESH (entity RLS applies): the fork rows (unless
2418
+ * given), then the forks' own rows, their anchor rows and their source rows in one RunViews batch.
2419
+ * @throws when a read fails
2420
+ */
2421
+ static async LoadForkSummaries(conversationId, contextUser, provider, branches) {
2422
+ const forks = branches ?? await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
2423
+ if (forks.length === 0) {
2424
+ return [];
2425
+ }
2426
+ const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
2427
+ const params = [{
2428
+ EntityName: 'MJ: Conversation Details',
2429
+ ExtraFilter: `[ConversationID]='${EscapeSQLString(conversationId)}' AND [BranchID] IS NOT NULL`,
2430
+ OrderBy: 'Sequence ASC',
2431
+ Fields: [...ForkSummaryRowFields],
2432
+ ResultType: 'simple',
2433
+ }];
2434
+ const rowsParams = (filter) => ({
2435
+ EntityName: 'MJ: Conversation Details',
2436
+ ExtraFilter: filter,
2437
+ Fields: [...ForkSummaryRowFields],
2438
+ ResultType: 'simple',
2439
+ });
2440
+ const anchorFilter = ConversationEngine.AnchorRowsFilter(conversationId, forks);
2441
+ const anchorIndex = anchorFilter ? params.push(rowsParams(anchorFilter)) - 1 : -1;
2442
+ const sourceFilter = ConversationEngine.SourceRowsFilter(conversationId, forks);
2443
+ const sourceIndex = sourceFilter ? params.push(rowsParams(sourceFilter)) - 1 : -1;
2444
+ const results = await rv.RunViews(params, contextUser);
2445
+ const own = results[0];
2446
+ const anchors = anchorIndex >= 0 ? results[anchorIndex] : undefined;
2447
+ const sources = sourceIndex >= 0 ? results[sourceIndex] : undefined;
2448
+ if (!own?.Success) {
2449
+ throw new Error(`Failed to load the forks of conversation ${conversationId}: ${own?.ErrorMessage}`);
2450
+ }
2451
+ if (anchors && !anchors.Success) {
2452
+ throw new Error(`Failed to load the fork anchors of conversation ${conversationId}: ${anchors.ErrorMessage}`);
2453
+ }
2454
+ if (sources && !sources.Success) {
2455
+ throw new Error(`Failed to load the fork source messages of conversation ${conversationId}: ${sources.ErrorMessage}`);
2456
+ }
2457
+ return ConversationEngine.BuildForkSummaries(forks, own.Results ?? [], anchors?.Results ?? [], sources?.Results ?? []);
2458
+ }
2459
+ /** Distinct agents and people of rows in order, at most {@link MAX_FORK_PARTICIPANTS}. */
2460
+ static forkParticipants(rows) {
2461
+ const out = [];
2462
+ for (const r of rows) {
2463
+ const participant = r.AgentID
2464
+ ? { Kind: 'Agent', ID: r.AgentID, Name: r.Agent ?? null }
2465
+ : r.UserID ? { Kind: 'User', ID: r.UserID, Name: r.User ?? null } : null;
2466
+ if (!participant || out.some(p => p.Kind === participant.Kind && sameId(p.ID, participant.ID))) {
2467
+ continue;
2468
+ }
2469
+ out.push(participant);
2470
+ if (out.length === MAX_FORK_PARTICIPANTS) {
2471
+ break;
2472
+ }
2473
+ }
2474
+ return out;
2475
+ }
2476
+ /** The agent's name for an agent row, else the person's name; null when the row names neither. */
2477
+ static rowAuthorName(row) {
2478
+ return row.AgentID ? (row.Agent ?? null) : (row.User ?? null);
2479
+ }
2480
+ /** A Date from a stored timestamp; null when missing or unreadable. */
2481
+ static toForkDate(value) {
2482
+ if (value == null) {
2483
+ return null;
2484
+ }
2485
+ const date = value instanceof Date ? value : new Date(value);
2486
+ return Number.isNaN(date.getTime()) ? null : date;
2487
+ }
1847
2488
  /** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
1848
2489
  static isAtOrAfter(createdAt, floor) {
1849
2490
  if (createdAt == null) {
@@ -1969,7 +2610,9 @@ export class ConversationEngine extends BaseEngine {
1969
2610
  return this._detailCache.get(key)?.AgentRunsByDetailId ?? new Map();
1970
2611
  }
1971
2612
  /**
1972
- * Adds or updates an agent run in the cache for a specific detail.
2613
+ * Adds or updates an agent run in the cache for a specific detail, honoring
2614
+ * {@link ShouldReplaceDetailAgentRun} so a continuation/sub-agent run never overwrites
2615
+ * the primary root orchestrating run for that detail.
1973
2616
  *
1974
2617
  * @param conversationId - The conversation ID
1975
2618
  * @param detailId - The detail ID the agent run is associated with
@@ -1978,8 +2621,22 @@ export class ConversationEngine extends BaseEngine {
1978
2621
  SetAgentRunForDetail(conversationId, detailId, agentRun) {
1979
2622
  const key = NormalizeUUID(conversationId);
1980
2623
  const cached = this._detailCache.get(key);
1981
- if (cached) {
1982
- cached.AgentRunsByDetailId.set(detailId, agentRun);
2624
+ if (!cached) {
2625
+ return;
2626
+ }
2627
+ let targetDetailKey = detailId;
2628
+ let existing = cached.AgentRunsByDetailId.get(detailId);
2629
+ if (!existing) {
2630
+ for (const [cachedDetailId, cachedRun] of cached.AgentRunsByDetailId) {
2631
+ if (UUIDsEqual(cachedDetailId, detailId)) {
2632
+ targetDetailKey = cachedDetailId;
2633
+ existing = cachedRun;
2634
+ break;
2635
+ }
2636
+ }
2637
+ }
2638
+ if (ShouldReplaceDetailAgentRun(existing, agentRun)) {
2639
+ cached.AgentRunsByDetailId.set(targetDetailKey, agentRun);
1983
2640
  }
1984
2641
  }
1985
2642
  // ========================================================================