@wowyuarm/dsh-agent-team 0.1.10 → 0.1.12

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 (135) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/package.json +53 -47
  4. package/packages/agent-team/README.md +6 -4
  5. package/packages/agent-team/README.zh.md +6 -4
  6. package/packages/agent-team/lib/context-management.js +40 -50
  7. package/packages/agent-team/lib/context-projection.js +15 -1
  8. package/packages/agent-team/lib/index.js +244 -154
  9. package/packages/agent-team/lib/invariant.js +38 -4
  10. package/packages/agent-team/lib/ledger.js +775 -383
  11. package/packages/agent-team/lib/member-context.js +6 -2
  12. package/packages/agent-team/lib/member-runtime.js +47 -9
  13. package/packages/agent-team/lib/member-time-context.js +20 -5
  14. package/packages/agent-team/lib/mentions.js +56 -0
  15. package/packages/agent-team/lib/pressure-policy.js +33 -9
  16. package/packages/agent-team/lib/session-event-cursor.js +116 -0
  17. package/packages/agent-team/lib/session-remediation.js +38 -26
  18. package/packages/agent-team/lib/spec.js +47 -34
  19. package/packages/agent-team/lib/stored-session-reader.js +138 -0
  20. package/packages/agent-team/lib/typert.host.js +157 -41
  21. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  22. package/packages/agent-team/lib/typert.remote-client.js +129 -32
  23. package/packages/agent-team/lib/types/context-management.d.ts +7 -0
  24. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
  26. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  27. package/packages/agent-team/lib/types/index.d.ts +77 -7
  28. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  29. package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/ledger.d.ts +251 -20
  31. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  32. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
  34. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/mentions.d.ts +34 -0
  37. package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
  38. package/packages/agent-team/lib/types/pressure-policy.d.ts +18 -0
  39. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -1
  40. package/packages/agent-team/lib/types/session-event-cursor.d.ts +119 -0
  41. package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
  42. package/packages/agent-team/lib/types/session-remediation.d.ts +23 -0
  43. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -1
  44. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  45. package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
  46. package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
  47. package/packages/agent-team/lib/types/types/entities.d.ts +28 -20
  48. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  49. package/packages/agent-team/lib/types/types/operations.d.ts +45 -19
  50. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  51. package/packages/agent-team/lib/types/types/requests-results.d.ts +87 -4
  52. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  53. package/packages/agent-team/preset/team-member/agent.cordis.yml +49 -7
  54. package/packages/client-agent-team/README.md +1 -1
  55. package/packages/client-agent-team/README.zh.md +1 -1
  56. package/packages/client-agent-team/lib/client.js +2357 -1207
  57. package/packages/client-agent-team/lib/client.js.map +1 -1
  58. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  59. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +27 -7
  60. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
  61. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
  62. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
  63. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
  64. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  65. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +167 -35
  66. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  67. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +23 -27
  68. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +11 -5
  69. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  70. package/packages/client-agent-team/lib/types/client/TeamComposer.js +26 -26
  71. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
  72. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
  73. package/packages/client-agent-team/lib/types/client/TeamConversation.js +9 -2
  74. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
  75. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
  76. package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
  77. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +25 -0
  78. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -0
  79. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +139 -0
  80. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.d.ts.map +1 -1
  81. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.js +8 -24
  82. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
  83. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
  84. package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
  85. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
  86. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
  87. package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
  88. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
  89. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
  90. package/packages/client-agent-team/lib/types/client/TeamMessage.js +11 -4
  91. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
  92. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
  93. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
  94. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  95. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +108 -30
  96. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  97. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  98. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +68 -7
  99. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  100. package/packages/client-agent-team/lib/types/client/index.js +19 -9
  101. package/packages/client-agent-team/lib/types/client/locales.d.ts +40 -2
  102. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  103. package/packages/client-agent-team/lib/types/client/locales.js +40 -2
  104. package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
  105. package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
  106. package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
  107. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
  108. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
  109. package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
  110. package/packages/client-agent-team/lib/types/client/slots.d.ts +7 -2
  111. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  112. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +33 -2
  113. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  114. package/packages/client-agent-team/lib/types/client/team-changes.js +107 -25
  115. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts +20 -0
  116. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts.map +1 -0
  117. package/packages/client-agent-team/lib/types/client/team-dialog-save.js +36 -0
  118. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +43 -1
  119. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  120. package/packages/client-agent-team/lib/types/client/team-formatters.js +81 -6
  121. package/packages/tool-agent-team/README.md +2 -2
  122. package/packages/tool-agent-team/README.zh.md +2 -2
  123. package/packages/tool-agent-team/lib/context-tools.js +18 -14
  124. package/packages/tool-agent-team/lib/host-access.js +20 -0
  125. package/packages/tool-agent-team/lib/index.js +44 -32
  126. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
  127. package/packages/tool-agent-team/lib/types/host-access.d.ts +14 -0
  128. package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -0
  129. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  130. package/packages/agent-team/lib/progress-nudge.js +0 -328
  131. package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
  132. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
  133. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
  134. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
  135. package/packages/client-agent-team/lib/types/client/TeamSettings.js +0 -5
@@ -1,14 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { formatTeamTimestamp } from "./time-format.js";
4
+ import { resolveBodyMentions } from "./mentions.js";
4
5
  /** Stable Human Member identity shared by every replay of one dshHome Team. */
5
6
  export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
7
+ /**
8
+ * The handle the Human is addressed by in Message bodies. The body parser and
9
+ * the Member directory both read this one constant, so a configurable display
10
+ * name has a single place to land instead of a hard-coded literal per surface.
11
+ */
12
+ export const AGENT_TEAM_HUMAN_HANDLE = 'human';
6
13
  /** Idempotency identity of the one Host bootstrap operation. */
7
14
  export const AGENT_TEAM_INITIALIZE_REQUEST_ID = 'agent-team:initialize:v1';
8
15
  const HUMAN_ACTOR = Object.freeze({
9
16
  kind: 'human',
10
17
  memberId: AGENT_TEAM_HUMAN_MEMBER_ID,
11
- handle: 'human',
18
+ handle: AGENT_TEAM_HUMAN_HANDLE,
12
19
  });
13
20
  /** Resolve the one Human authority owned by this Team. */
14
21
  export function agentTeamHumanActor() {
@@ -16,18 +23,61 @@ export function agentTeamHumanActor() {
16
23
  }
17
24
  function emptyProjection() {
18
25
  return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
19
- claims: new Map(), messages: [], tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
26
+ claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
20
27
  orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
21
- attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map() };
28
+ messagesByRef: new Map(),
29
+ attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
30
+ anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
31
+ threadsByWriter: new Map(),
32
+ directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
22
33
  }
23
- const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
24
- progress: Object.freeze([]),
25
- claim: Object.freeze([]),
26
- });
27
34
  /** Exhaustiveness guard for the closed operation union: adding a kind must update every dispatch. */
28
35
  function assertUnhandledKind(operation) {
29
36
  throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
30
37
  }
38
+ /**
39
+ * Opening-line preview for a Human Inbox row: the Thread anchor's first line,
40
+ * trimmed, then capped at 120 characters with an explicit mark — the same bound
41
+ * the Thread page applies to its Task title, so the row and the page it opens
42
+ * never disagree about what a Thread is about.
43
+ */
44
+ function boundedInboxPreview(body) {
45
+ const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
46
+ return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
47
+ }
48
+ /**
49
+ * How many Threads one Workspace's 「最近活跃」 slice may carry. It bounds what
50
+ * the Host hands over, not what the reader sees: the Client merges every
51
+ * visible Workspace's slice and trims the merged list to its own visible bound
52
+ * (`RECENT_ROWS_LIMIT`), which must stay at or below this one so no Workspace
53
+ * is cut short before the merge. The unread slice keeps its own `limit`
54
+ * request field.
55
+ */
56
+ const RECENT_INBOX_LIMIT = 10;
57
+ /**
58
+ * Whether one operation's Inbox delta would change nothing. A read that finds
59
+ * no unread fact for its reader and no marker to consume produces exactly this
60
+ * delta, which is why such a read is a no-op rather than a durable operation.
61
+ */
62
+ function isEmptyInboxDelta(delta) {
63
+ return delta.attention.set.length === 0 && delta.attention.removed.length === 0
64
+ && delta.directMarkers.added.length === 0 && delta.directMarkers.removed.length === 0
65
+ && delta.activityMarkers.added.length === 0 && delta.activityMarkers.removed.length === 0;
66
+ }
67
+ /**
68
+ * Which of the two durable Thread-read forms one record holds. Both schemas are
69
+ * strict and structurally disjoint — only the pre-receipt snapshot carries the
70
+ * Thread picture — so `thread` decides it for every stored record.
71
+ */
72
+ export function isThreadReadSnapshot(data) {
73
+ return 'thread' in data;
74
+ }
75
+ /** The Thread and optional Task one read targeted, whichever form the record holds. */
76
+ export function threadReadTargetOf(data) {
77
+ return isThreadReadSnapshot(data)
78
+ ? { threadRef: data.thread.threadRef, ...(data.task === undefined ? {} : { taskRef: data.task.taskRef }) }
79
+ : { threadRef: data.threadRef, ...(data.taskRef === undefined ? {} : { taskRef: data.taskRef }) };
80
+ }
31
81
  /** Deep-freeze a Member capability overlay; absent stays absent. */
32
82
  function freezeCapabilities(capabilities) {
33
83
  if (capabilities === undefined)
@@ -50,12 +100,35 @@ export class AgentTeamLedger {
50
100
  createOccurredAt;
51
101
  createRef;
52
102
  operationTail = Promise.resolve();
103
+ /**
104
+ * Head of the durable records the constructor's record-level replay
105
+ * validated, adoptable once by the invariant mount. See `validateAtMount`.
106
+ */
107
+ bootValidation;
108
+ /**
109
+ * Sequence of the newest committed record that invalidated the shared
110
+ * projection — the durable position the Client's change cursors compare
111
+ * against, rebuilt by replay like every other index.
112
+ *
113
+ * Records whose commit is private read progress or audit-only
114
+ * (`changeScopesOf` derives no scope for them) never move it, so one
115
+ * Member's Thread read cannot make a parked Client believe that something it
116
+ * watches changed. Being a ledger position rather than a process counter, it
117
+ * also stays monotone across a restart.
118
+ */
119
+ projectionVersion = 0;
53
120
  constructor(table, options = {}) {
54
121
  this.table = table;
55
122
  this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
56
123
  this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
57
124
  this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
58
125
  this.replay();
126
+ const head = this.state.ordered.at(-1);
127
+ this.bootValidation = Object.freeze({
128
+ records: this.state.ordered.length,
129
+ lastSequence: head?.sequence ?? 0,
130
+ lastOperationId: head?.operationId ?? null,
131
+ });
59
132
  }
60
133
  initialize(request = {
61
134
  requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
@@ -379,80 +452,6 @@ export class AgentTeamLedger {
379
452
  }
380
453
  return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
381
454
  }
382
- /**
383
- * Current progress-nudge candidacy for one Member: threads whose Thread
384
- * progress reminder applies (active Claim, or following a taskless Thread)
385
- * and tasks where the Claim reminder applies (following a still-`todo` Task
386
- * with no Claim history for this Member). A deep read-only projection — the
387
- * caller never re-derives attention, claim, task-resolution, or channel
388
- * archive state. Archived channels and resolved tasks produce no candidacy;
389
- * results are ordered by Thread revision descending, then threadRef.
390
- */
391
- progressNudgeTargets(memberId) {
392
- const member = this.state.members.get(memberId);
393
- if (member === undefined || member.state !== 'enabled')
394
- return EMPTY_PROGRESS_NUDGE_TARGETS;
395
- const progress = [];
396
- const claim = [];
397
- const seenProgress = new Set();
398
- for (const claimRecord of this.state.claims.values()) {
399
- if (claimRecord.owner !== memberId || claimRecord.state !== 'active')
400
- continue;
401
- const task = this.state.tasks.get(claimRecord.taskRef);
402
- const thread = this.state.threads.get(claimRecord.threadRef);
403
- if (task === undefined || thread === undefined || task.resolution !== 'open')
404
- continue;
405
- if (this.state.channels.get(task.channelRef)?.state === 'archived')
406
- continue;
407
- if (seenProgress.has(claimRecord.threadRef))
408
- continue;
409
- seenProgress.add(claimRecord.threadRef);
410
- progress.push({ reason: 'active-claim', threadRef: claimRecord.threadRef, taskRef: claimRecord.taskRef });
411
- }
412
- for (const [threadRef, followers] of this.state.attentionByThread) {
413
- if (!followers.has(memberId))
414
- continue;
415
- const thread = this.state.threads.get(threadRef);
416
- if (thread === undefined)
417
- continue;
418
- const channelRef = this.state.channelRefByThread.get(threadRef);
419
- if (channelRef === undefined || this.state.channels.get(channelRef)?.state === 'archived')
420
- continue;
421
- if (thread.taskRef === undefined) {
422
- if (!seenProgress.has(threadRef)) {
423
- seenProgress.add(threadRef);
424
- progress.push({ reason: 'taskless-follower', threadRef });
425
- }
426
- continue;
427
- }
428
- // Claim reminder: only still-`todo` tasks with zero lifetime claims by
429
- // this Member; once claimed (any state) the Member moved into the
430
- // progress-reminder regime.
431
- if (this.hasLifetimeClaimOnTask(memberId, thread.taskRef))
432
- continue;
433
- const task = this.state.tasks.get(thread.taskRef);
434
- if (task === undefined || task.resolution !== 'open' || this.deriveTaskStatus(task.taskRef, this.state.claims.values()) !== 'todo')
435
- continue;
436
- claim.push({ threadRef, taskRef: thread.taskRef });
437
- }
438
- const byThreadRevision = (left, right) => {
439
- const leftRevision = this.state.threads.get(left.threadRef)?.revision ?? 0;
440
- const rightRevision = this.state.threads.get(right.threadRef)?.revision ?? 0;
441
- return rightRevision - leftRevision || (left.threadRef < right.threadRef ? -1 : left.threadRef > right.threadRef ? 1 : 0);
442
- };
443
- return Object.freeze({
444
- progress: Object.freeze(progress.sort(byThreadRevision)),
445
- claim: Object.freeze(claim.sort(byThreadRevision)),
446
- });
447
- }
448
- /** Whether this Member has ever held a Claim on the Task, in any state. */
449
- hasLifetimeClaimOnTask(memberId, taskRef) {
450
- for (const claim of this.state.claims.values()) {
451
- if (claim.owner === memberId && claim.taskRef === taskRef)
452
- return true;
453
- }
454
- return false;
455
- }
456
455
  listMembers() {
457
456
  return Object.freeze([...this.state.members.values()]);
458
457
  }
@@ -560,12 +559,6 @@ export class AgentTeamLedger {
560
559
  }
561
560
  sendMessage(request) {
562
561
  return this.enqueue(async () => {
563
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
564
- const existing = this.state.byRequest.get(request.requestId);
565
- if (existing !== undefined) {
566
- this.assertSameMessage(existing, request, recipients);
567
- return this.resolved(this.messageResult(existing));
568
- }
569
562
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
570
563
  const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
571
564
  if (actor.kind === 'member')
@@ -573,6 +566,15 @@ export class AgentTeamLedger {
573
566
  const body = request.body.trim();
574
567
  if (body === '')
575
568
  throw new Error('message body must not be empty');
569
+ // Recipients are resolved before the request-id lookup: the stored
570
+ // operation carries the merged set, so a retry has to re-derive the same
571
+ // list for the collision check to prove it is the same request.
572
+ const recipients = this.mergeBodyMentions(actor.memberId, channel.channelRef, body, this.normalizeRecipients(request.actor, request.recipients));
573
+ const existing = this.state.byRequest.get(request.requestId);
574
+ if (existing !== undefined) {
575
+ this.assertSameMessage(existing, request, recipients);
576
+ return this.resolved(this.messageResult(existing));
577
+ }
576
578
  this.assertMentionTargets(channel, recipients);
577
579
  // Top-level Task creation is open to every actor: mentioned Members join
578
580
  // the new Thread as followers. Only existing-Thread invitations stay
@@ -607,32 +609,38 @@ export class AgentTeamLedger {
607
609
  }
608
610
  reply(request) {
609
611
  return this.enqueue(async () => {
610
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
611
- const existing = this.state.byRequest.get(request.requestId);
612
- if (existing !== undefined) {
613
- this.assertSameReply(existing, request, recipients);
614
- return this.resolved(this.replyResult(existing));
615
- }
616
612
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
617
613
  const { task, thread, channelRef } = this.threadContextForActor(actor, request.workspaceId, request);
618
614
  const body = request.body.trim();
619
615
  if (body === '')
620
616
  throw new Error('message body must not be empty');
621
- this.assertMentionTargets(this.requireChannel(request.workspaceId, channelRef), recipients);
617
+ const channel = this.requireChannel(request.workspaceId, channelRef);
618
+ const recipients = this.mergeBodyMentions(actor.memberId, channelRef, body, this.normalizeRecipients(actor, request.recipients));
619
+ const existing = this.state.byRequest.get(request.requestId);
620
+ if (existing !== undefined) {
621
+ this.assertSameReply(existing, request, recipients);
622
+ return this.resolved(this.replyResult(existing, this.undeliverableRecipients(actor, thread.threadRef, recipients)));
623
+ }
624
+ this.assertMentionTargets(channel, recipients);
622
625
  const deferred = this.deferredThreadWrite(actor.memberId, task, thread, request.baseRevision);
623
626
  if (deferred !== undefined)
624
627
  return this.resolved(deferred);
625
628
  if (task?.resolution === 'closed')
626
629
  throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
627
- const unfollowedAgents = recipients.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
628
- if (unfollowedAgents.length > 0 && actor.kind === 'member') {
629
- return this.resolved(this.memberNotFollowing(request.workspaceId, channelRef, unfollowedAgents, task, thread));
630
- }
631
- if (unfollowedAgents.length > 0 && request.confirmationToken === undefined) {
632
- return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, recipients, task, thread));
630
+ // An Agent cannot invite into an existing Thread, so a body mention the
631
+ // Thread has never carried is dropped and reported back rather than
632
+ // failing the send. The Human keeps the confirmation step, which is what
633
+ // actually grants the invitation.
634
+ const undelivered = this.undeliverableRecipients(actor, thread.threadRef, recipients);
635
+ const delivered = undelivered.length === 0 ? recipients : recipients.filter(memberId => !undelivered.includes(memberId));
636
+ const unfollowedAgents = delivered.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
637
+ // A Member that once took part in this Thread is delivered to directly:
638
+ // re-joining a Thread it already belongs to is not an invitation.
639
+ if (unfollowedAgents.length > 0 && actor.kind === 'human' && request.confirmationToken === undefined) {
640
+ return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, delivered, task, thread));
633
641
  }
634
642
  if (request.confirmationToken !== undefined) {
635
- this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, recipients);
643
+ this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, delivered);
636
644
  }
637
645
  const sequence = this.nextSequence();
638
646
  const base = this.operationBase(request, sequence);
@@ -644,15 +652,15 @@ export class AgentTeamLedger {
644
652
  });
645
653
  const nextThread = Object.freeze({ ...thread, revision: sequence });
646
654
  const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
647
- const inbox = this.messageInboxDelta(message, request.actor.memberId, recipients, started);
655
+ const inbox = this.messageInboxDelta(message, request.actor.memberId, delivered, started);
648
656
  const operation = Object.freeze({
649
657
  ...base, kind: 'team/thread-replied',
650
658
  data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
651
- mentions: recipients, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
659
+ mentions: delivered, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
652
660
  });
653
661
  await this.table.put(operation.operationId, operation);
654
662
  this.apply(operation);
655
- return this.committed(this.replyResult(operation));
663
+ return this.committed(this.replyResult(operation, undelivered));
656
664
  });
657
665
  }
658
666
  promoteThread(request) {
@@ -1025,8 +1033,24 @@ export class AgentTeamLedger {
1025
1033
  const limit = request.limit ?? 50;
1026
1034
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1027
1035
  throw new Error('inbox limit must be an integer between 1 and 100');
1036
+ // One unread slice serves every reader: Threads the reader follows or
1037
+ // holds a marker on, admitted whenever they hold an unread fact — mentions
1038
+ // are counted inside it (`directCount`), never a separate admission rule.
1039
+ // Every row carries its rendering material — Channel display name, Task
1040
+ // ordinal, opening line, and the person the row's instant came from — so the
1041
+ // Client renders a row without a Channel or Member view per row. Carrying
1042
+ // the opening line on the agent-facing projection is
1043
+ // safe: model-visible notifications read the refs and the facts, never this.
1044
+ // A Human reader additionally receives the separate 「最近活跃」 slice below;
1045
+ // `items` stays exactly the unread queue for every reader, so the badge
1046
+ // totals and the agent-facing `team_inbox` result cannot drift with it.
1047
+ const taskNumbers = this.taskNumbers(request.workspaceId);
1028
1048
  const items = [];
1029
- for (const thread of this.state.threads.values()) {
1049
+ const unreadThreads = new Set();
1050
+ for (const threadRef of this.inboxCandidateThreads(authorized.memberId)) {
1051
+ const thread = this.state.threads.get(threadRef);
1052
+ if (thread === undefined)
1053
+ continue;
1030
1054
  const channelRef = this.channelRefForThread(thread.threadRef);
1031
1055
  if (channelRef === undefined)
1032
1056
  continue;
@@ -1037,6 +1061,7 @@ export class AgentTeamLedger {
1037
1061
  const unread = this.unreadFor(authorized.memberId, thread.threadRef);
1038
1062
  if (unread.length === 0)
1039
1063
  continue;
1064
+ unreadThreads.add(thread.threadRef);
1040
1065
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1041
1066
  const directCount = unread.filter(item => item.direct).length;
1042
1067
  const attention = this.attentionFor(authorized.memberId, thread.threadRef);
@@ -1044,15 +1069,117 @@ export class AgentTeamLedger {
1044
1069
  // the newest unread fact itself, never a second lookup that could
1045
1070
  // observe a different commit between the two reads.
1046
1071
  const newest = unread.at(-1).fact;
1047
- items.push(Object.freeze({ channelRef, ...(task === undefined ? {} : { task }), thread, unreadCount: unread.length, directCount,
1048
- newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
1072
+ const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
1073
+ items.push(Object.freeze({ channelRef,
1074
+ channelName: this.state.channels.get(channelRef)?.name ?? '',
1075
+ ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1076
+ unreadCount: unread.length, directCount,
1077
+ previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
1078
+ newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
1079
+ newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task),
1080
+ ...(attention === undefined ? {} : { attention }) }));
1049
1081
  }
1050
1082
  items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1051
1083
  const selected = items.slice(0, limit);
1052
- return Object.freeze({ items: Object.freeze(selected),
1084
+ const recent = authorized.kind === 'human'
1085
+ ? this.recentInboxItems(authorized.memberId, request.workspaceId, unreadThreads, taskNumbers)
1086
+ : Object.freeze([]);
1087
+ return Object.freeze({ items: Object.freeze(selected), recent,
1053
1088
  totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
1054
1089
  totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
1055
1090
  }
1091
+ /**
1092
+ * The one person a row names: whoever committed the fact that row's instant
1093
+ * came from — a Message's sender, an activity's actor — resolved to the handle
1094
+ * the row draws. Resolving it here is what spares every Client row a Member
1095
+ * view of its own, and a Member the roster no longer names still reads as
1096
+ * themselves through their raw id rather than as nobody.
1097
+ */
1098
+ inboxActorFor(fact) {
1099
+ return this.memberActor(fact.kind === 'message' ? fact.message.sender : fact.activity.actor);
1100
+ }
1101
+ /** One Member as a row draws them: the id carries the identity hue, the handle the initial. */
1102
+ memberActor(memberId) {
1103
+ return Object.freeze({ memberId, name: this.state.members.get(memberId)?.handle ?? memberId });
1104
+ }
1105
+ /**
1106
+ * The people a Task still has on it, resolved the way a row draws them: owners
1107
+ * of its live Claims, in claim order, deduped. A released Claim is not work,
1108
+ * and a done or closed Task keeps its Claims as history rather than as
1109
+ * presence — deliberately the same rule the Channel feed applies to the same
1110
+ * Task, so 「谁在这个 Task 上」 never acquires a second definition.
1111
+ */
1112
+ liveClaimOwners(task) {
1113
+ if (task === undefined || (task.status !== 'in_progress' && task.status !== 'in_review'))
1114
+ return Object.freeze([]);
1115
+ const owners = [];
1116
+ for (const claim of this.claimsForTask(task.taskRef)) {
1117
+ if (claim.state === 'released' || owners.includes(claim.owner))
1118
+ continue;
1119
+ owners.push(claim.owner);
1120
+ }
1121
+ return Object.freeze(owners.map(owner => this.memberActor(owner)));
1122
+ }
1123
+ /**
1124
+ * The Human Inbox's 「最近活跃」 slice: the Threads this reader has written in
1125
+ * — the durable way back into work instead of a mention-only queue.
1126
+ * Participation is the whole admission rule: a reader who replied to somebody
1127
+ * else's Thread is here whether or not they follow it, and so is one who
1128
+ * started a Thread nobody has answered yet, because starting one is writing
1129
+ * its anchor. Attention deliberately plays no part — it decides what notifies
1130
+ * a reader (a reply does not implicitly follow a Thread), and reading it here
1131
+ * as well is what once hid every Thread a reader had replied to without
1132
+ * following. A Thread the reader later unfollowed stays: they did take part
1133
+ * in it, and unfollowing stops the notifications, not the record. Every
1134
+ * Thread still holding unread is excluded because the queue above already
1135
+ * carries it, and the slice is newest-activity first, bounded by
1136
+ * `RECENT_INBOX_LIMIT`. Rows are the same shape as unread rows with every
1137
+ * count at zero — they are the same row to render.
1138
+ */
1139
+ recentInboxItems(memberId, workspaceId, unreadThreads, taskNumbers) {
1140
+ const recent = [];
1141
+ for (const threadRef of this.state.threadsByWriter.get(memberId) ?? []) {
1142
+ if (unreadThreads.has(threadRef))
1143
+ continue;
1144
+ const thread = this.state.threads.get(threadRef);
1145
+ if (thread === undefined)
1146
+ continue;
1147
+ const channelRef = this.channelRefForThread(thread.threadRef);
1148
+ if (channelRef === undefined)
1149
+ continue;
1150
+ if (this.state.channels.get(channelRef)?.workspaceId !== workspaceId)
1151
+ continue;
1152
+ const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
1153
+ const newest = facts.at(-1);
1154
+ if (newest === undefined)
1155
+ continue;
1156
+ const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1157
+ const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
1158
+ recent.push(Object.freeze({ channelRef,
1159
+ channelName: this.state.channels.get(channelRef)?.name ?? '',
1160
+ ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1161
+ unreadCount: 0, directCount: 0,
1162
+ previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
1163
+ newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
1164
+ newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task) }));
1165
+ }
1166
+ recent.sort((left, right) => right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1167
+ return Object.freeze(recent.slice(0, RECENT_INBOX_LIMIT));
1168
+ }
1169
+ /**
1170
+ * Threads that can hold unread facts for one reader, from the per-reader
1171
+ * derived indexes: Attention follows plus direct and activity markers.
1172
+ * Every unread source is covered — ordinary unread requires Attention, and
1173
+ * marker unread requires a marker — so no full Thread scan is needed.
1174
+ */
1175
+ inboxCandidateThreads(memberId) {
1176
+ const refs = new Set();
1177
+ for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
1178
+ for (const threadRef of source?.keys() ?? [])
1179
+ refs.add(threadRef);
1180
+ }
1181
+ return refs;
1182
+ }
1056
1183
  /** Model-visible notification material derived from the recipient's current durable unread state. */
1057
1184
  notificationFacts(memberId, request) {
1058
1185
  const member = this.requireMember(memberId);
@@ -1067,22 +1194,31 @@ export class AgentTeamLedger {
1067
1194
  const existing = this.state.byRequest.get(request.requestId);
1068
1195
  if (existing !== undefined) {
1069
1196
  this.assertSameThreadRead(existing, request);
1070
- return this.resolved(this.threadReadResult(existing));
1197
+ // A retry reports the original receipt with the picture the current
1198
+ // projection derives, and never advances a watermark a second time.
1199
+ const current = this.prepareRead(existing.data.memberId, existing.data.workspaceId, request);
1200
+ return Object.freeze({ value: Object.freeze({ receipt: this.receipt(existing), ...this.readPicture(current) }), committed: false });
1071
1201
  }
1072
1202
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
1073
1203
  const prepared = this.prepareRead(actor.memberId, request.workspaceId, request);
1204
+ // A read whose Inbox delta is empty writes nothing: the watermark does
1205
+ // not advance and no marker is consumed. It answers with the same picture
1206
+ // and no receipt, so the caller learns "no operation" from the result's
1207
+ // committed flag, never from a sequence this read did not write.
1208
+ if (isEmptyInboxDelta(prepared.inbox))
1209
+ return Object.freeze({ value: this.readPicture(prepared), committed: false });
1210
+ // The record carries progress, not the picture: the Thread, its facts,
1211
+ // the anchor and the reader's Attention are projection state every replay
1212
+ // already holds.
1213
+ const data = Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
1214
+ threadRef: prepared.thread.threadRef, ...(prepared.task === undefined ? {} : { taskRef: prepared.task.taskRef }),
1215
+ readThroughSequence: prepared.readThroughSequence, inbox: prepared.inbox });
1074
1216
  const operation = Object.freeze({
1075
- ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
1076
- data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
1077
- ...(prepared.task === undefined ? {} : { task: prepared.task }),
1078
- thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
1079
- facts: prepared.facts,
1080
- readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
1081
- ...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
1217
+ ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
1082
1218
  });
1083
1219
  await this.table.put(operation.operationId, operation);
1084
1220
  this.apply(operation);
1085
- return this.committed(this.threadReadResult(operation));
1221
+ return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
1086
1222
  });
1087
1223
  }
1088
1224
  threadObservations(actor, request) {
@@ -1092,33 +1228,13 @@ export class AgentTeamLedger {
1092
1228
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1093
1229
  throw new Error('observation limit must be an integer between 1 and 100');
1094
1230
  const threadRef = thread.threadRef;
1095
- const current = new Map();
1096
- const observations = [];
1097
- for (const operation of this.state.ordered) {
1098
- const delta = this.attentionDelta(operation);
1099
- if (delta === undefined)
1100
- continue;
1101
- for (const removed of delta.attention.removed) {
1102
- if (removed.threadRef !== threadRef)
1103
- continue;
1104
- if (!current.delete(removed.memberId))
1105
- continue;
1106
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1107
- memberId: removed.memberId, action: 'unfollow' }));
1108
- }
1109
- for (const next of delta.attention.set) {
1110
- if (next.threadRef !== threadRef)
1111
- continue;
1112
- const prior = current.get(next.memberId);
1113
- current.set(next.memberId, next);
1114
- // Task creation establishes initial Attention; reads only advance its watermark.
1115
- if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
1116
- continue;
1117
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1118
- memberId: next.memberId, action: 'follow' }));
1119
- }
1120
- }
1121
- return Object.freeze({ items: Object.freeze(observations.slice(-limit)), followers: Object.freeze([...current.keys()]) });
1231
+ // Both slices are replay-derived: the observation log is appended by
1232
+ // committed Inbox deltas in ledger order, and the live follower set is
1233
+ // the same Attention index the follow history converges to.
1234
+ const observations = (this.state.observationsByThread.get(threadRef) ?? []).slice(-limit)
1235
+ .map(event => Object.freeze({ sequence: event.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1236
+ memberId: event.memberId, action: event.action }));
1237
+ return Object.freeze({ items: Object.freeze(observations), followers: Object.freeze([...this.state.attentionByThread.get(threadRef) ?? []]) });
1122
1238
  }
1123
1239
  /** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
1124
1240
  attentionDelta(operation) {
@@ -1216,7 +1332,8 @@ export class AgentTeamLedger {
1216
1332
  && channel.state !== 'archived'
1217
1333
  && (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
1218
1334
  const channelRefs = new Set(channels.map(channel => channel.channelRef));
1219
- const allFacts = this.state.orderedFacts.filter(fact => {
1335
+ const before = request.cursor ?? this.state.ordered.length + 1;
1336
+ const matches = (fact) => {
1220
1337
  const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
1221
1338
  const channelRef = this.channelRefForThread(threadRef);
1222
1339
  if (channelRef === undefined || !channelRefs.has(channelRef))
@@ -1229,9 +1346,40 @@ export class AgentTeamLedger {
1229
1346
  return false;
1230
1347
  if (request.includeActivities === false && fact.kind === 'activity')
1231
1348
  return false;
1232
- return direction === 'before' ? fact.sequence < (request.cursor ?? this.state.ordered.length + 1) : fact.sequence > cursor;
1233
- });
1234
- const selected = direction === 'before' ? allFacts.slice(-limit) : allFacts.slice(0, limit);
1349
+ return direction === 'before' ? fact.sequence < before : fact.sequence > cursor;
1350
+ };
1351
+ // Walk from the end the direction starts at and stop once `limit` matches
1352
+ // are collected plus one more proves `hasMore` — `limit` bounds only the
1353
+ // returned items, never the scan below a single extra match. A sidebar
1354
+ // asking for one item no longer sweeps the whole fact ledger.
1355
+ const selected = [];
1356
+ let hasMore = false;
1357
+ if (direction === 'before') {
1358
+ for (let index = this.state.orderedFacts.length - 1; index >= 0; index -= 1) {
1359
+ const fact = this.state.orderedFacts[index];
1360
+ if (!matches(fact))
1361
+ continue;
1362
+ if (selected.length < limit)
1363
+ selected.push(fact);
1364
+ else {
1365
+ hasMore = true;
1366
+ break;
1367
+ }
1368
+ }
1369
+ selected.reverse();
1370
+ }
1371
+ else {
1372
+ for (const fact of this.state.orderedFacts) {
1373
+ if (!matches(fact))
1374
+ continue;
1375
+ if (selected.length < limit)
1376
+ selected.push(fact);
1377
+ else {
1378
+ hasMore = true;
1379
+ break;
1380
+ }
1381
+ }
1382
+ }
1235
1383
  const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
1236
1384
  && (request.channelRef === undefined || task.channelRef === request.channelRef)
1237
1385
  && (request.threadRef === undefined || task.threadRef === request.threadRef));
@@ -1266,7 +1414,7 @@ export class AgentTeamLedger {
1266
1414
  claims: this.claimsForVisibleTasks(visibleTasks),
1267
1415
  activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
1268
1416
  cursor: nextCursor,
1269
- hasMore: allFacts.length > selected.length,
1417
+ hasMore,
1270
1418
  });
1271
1419
  }
1272
1420
  status() {
@@ -1277,6 +1425,31 @@ export class AgentTeamLedger {
1277
1425
  validate() {
1278
1426
  this.validateRecords(this.sortedRecords());
1279
1427
  }
1428
+ /**
1429
+ * Validate the durable ledger for the invariant's mount check, reusing the
1430
+ * constructor's record-level replay once instead of paying a second identical
1431
+ * one at startup.
1432
+ *
1433
+ * The adoption is gated on the operations table's single-writer identity —
1434
+ * this ledger's commit path is the only caller of `table.put` — so an
1435
+ * unchanged record count, head sequence and head operation id mean a
1436
+ * mount-time replay would re-derive precisely the records the constructor
1437
+ * already re-derived against its own scratch projection. Any commit between
1438
+ * construction and mount, and every later call, falls back to the full
1439
+ * replay. The check is never narrowed: the reused conclusion is still that
1440
+ * same independent replay of every durable record.
1441
+ */
1442
+ validateAtMount() {
1443
+ const boot = this.bootValidation;
1444
+ this.bootValidation = undefined;
1445
+ const head = this.state.ordered.at(-1);
1446
+ if (boot !== undefined
1447
+ && boot.records === this.state.ordered.length
1448
+ && boot.lastSequence === (head?.sequence ?? 0)
1449
+ && boot.lastOperationId === (head?.operationId ?? null))
1450
+ return;
1451
+ this.validate();
1452
+ }
1280
1453
  hasCommitted(requestId) {
1281
1454
  return this.state.byRequest.has(requestId);
1282
1455
  }
@@ -1284,6 +1457,33 @@ export class AgentTeamLedger {
1284
1457
  getOperation(operationId) {
1285
1458
  return this.state.byOperation.get(operationId);
1286
1459
  }
1460
+ /**
1461
+ * Claim releases are thread-visible facts: an open Channel page and every
1462
+ * affected Thread page must refetch alongside the workspace-wide change. Each
1463
+ * released activity contributes its own Thread scope plus the Channel that
1464
+ * owns its Task, deduplicated against the caller's initial scopes — which is
1465
+ * why the caller passes them in rather than the helper inventing them.
1466
+ */
1467
+ withReleasedActivityScopes(scopes, tasks, activities) {
1468
+ const channelByTask = new Map(tasks.map(task => [task.taskRef, task.channelRef]));
1469
+ for (const activity of activities) {
1470
+ scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1471
+ const channelRef = channelByTask.get(activity.taskRef);
1472
+ if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1473
+ scopes.push({ kind: 'channel', channelRef });
1474
+ }
1475
+ }
1476
+ return scopes;
1477
+ }
1478
+ /**
1479
+ * Durable position of the newest shared-projection commit: the version every
1480
+ * change waiter outside the presence scope observes. It only moves when a
1481
+ * commit has scopes somebody could refetch (`changeScopesOf`), never for
1482
+ * private read progress or an audit-only record.
1483
+ */
1484
+ projectionSequence() {
1485
+ return this.projectionVersion;
1486
+ }
1287
1487
  /** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
1288
1488
  changeScopesOf(operation) {
1289
1489
  switch (operation.kind) {
@@ -1304,37 +1504,15 @@ export class AgentTeamLedger {
1304
1504
  case 'team/member-updated':
1305
1505
  case 'team/member-removed':
1306
1506
  return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1307
- case 'team/member-archived': {
1308
- // Claim releases are thread-visible facts: open Channel and Thread
1309
- // pages refetch alongside the workspace-wide roster change.
1310
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1311
- const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1312
- for (const activity of operation.data.activities) {
1313
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1314
- const channelRef = channelByTask.get(activity.taskRef);
1315
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1316
- scopes.push({ kind: 'channel', channelRef });
1317
- }
1318
- }
1319
- return scopes;
1320
- }
1507
+ case 'team/member-archived':
1508
+ return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
1321
1509
  case 'team/channel-member-added':
1322
1510
  return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
1323
- case 'team/channel-member-removed': {
1324
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1325
- const scopes = [
1511
+ case 'team/channel-member-removed':
1512
+ return this.withReleasedActivityScopes([
1326
1513
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
1327
1514
  { kind: 'channel', channelRef: operation.data.channelRef },
1328
- ];
1329
- for (const activity of operation.data.activities) {
1330
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1331
- const channelRef = channelByTask.get(activity.taskRef);
1332
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1333
- scopes.push({ kind: 'channel', channelRef });
1334
- }
1335
- }
1336
- return scopes;
1337
- }
1515
+ ], operation.data.tasks, operation.data.activities);
1338
1516
  case 'team/channel-archived': {
1339
1517
  const scopes = [
1340
1518
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
@@ -1632,7 +1810,7 @@ export class AgentTeamLedger {
1632
1810
  || operation.data.channel.createdAtSequence !== prior.createdAtSequence)
1633
1811
  throw new Error('invalid Channel archival');
1634
1812
  const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
1635
- this.validateChannelArchivalCleanup(operation.data, projection, threadRefs, operation.sequence, refs);
1813
+ this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
1636
1814
  return;
1637
1815
  }
1638
1816
  if (operation.kind === 'team/member-removed') {
@@ -1683,20 +1861,41 @@ export class AgentTeamLedger {
1683
1861
  assertMember();
1684
1862
  else
1685
1863
  assertHuman();
1686
- if (operation.data.memberId !== operation.actor.memberId)
1864
+ const data = operation.data;
1865
+ if (data.memberId !== operation.actor.memberId)
1687
1866
  throw new Error('Thread read has wrong actor');
1688
- const expected = this.prepareReadFrom(projection, operation.data.memberId, operation.data.workspaceId, {
1689
- threadRef: operation.data.thread.threadRef,
1690
- ...(operation.data.task === undefined ? {} : { taskRef: operation.data.task.taskRef }),
1691
- });
1692
- const expectedData = Object.freeze({ workspaceId: operation.data.workspaceId, memberId: operation.data.memberId,
1693
- ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
1694
- anchorMentions: expected.anchorMentions, facts: expected.facts,
1695
- readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
1696
- ...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
1697
- if (!isDeepStrictEqual(operation.data, expectedData))
1698
- throw new Error('invalid Thread read projection');
1699
- this.validateInboxDelta(operation.data.inbox, projection, refs);
1867
+ const target = threadReadTargetOf(data);
1868
+ if (isThreadReadSnapshot(data)) {
1869
+ // The pre-receipt form froze the whole picture, so its expected value
1870
+ // is re-derived the same way it was written: from this record's prior
1871
+ // projection only, never from the live one. That shape stays frozen —
1872
+ // a field added after it, like the orientation count, is not compared
1873
+ // here, because no stored legacy record carries it and inventing one
1874
+ // would reject every upgrade of a ledger written before the field.
1875
+ const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
1876
+ const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
1877
+ ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
1878
+ anchorMentions: expected.anchorMentions, facts: expected.facts,
1879
+ readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
1880
+ ...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
1881
+ if (!isDeepStrictEqual(data, expectedData))
1882
+ throw new Error('invalid Thread read projection');
1883
+ }
1884
+ else {
1885
+ // A receipt claims progress and its Inbox delta, nothing else, so the
1886
+ // independent derivation stops there too: the picture and the unread
1887
+ // count left behind are projection state a replay re-derives for the
1888
+ // surfaces that need them, not content of this record. The Task ref is
1889
+ // derived rather than copied back, so a Task Thread read cannot drop
1890
+ // the Task it belongs to and shrink its own claim unchecked.
1891
+ const expected = this.prepareReadReceiptFrom(projection, data.memberId, data.workspaceId, target);
1892
+ const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId, threadRef: data.threadRef,
1893
+ ...(expected.task === undefined ? {} : { taskRef: expected.task.taskRef }),
1894
+ readThroughSequence: expected.readThroughSequence, inbox: expected.inbox });
1895
+ if (!isDeepStrictEqual(data, expectedData))
1896
+ throw new Error('invalid Thread read receipt');
1897
+ }
1898
+ this.validateInboxDelta(data.inbox, projection, refs);
1700
1899
  return;
1701
1900
  }
1702
1901
  if (operation.kind === 'team/thread-promoted') {
@@ -1874,11 +2073,23 @@ export class AgentTeamLedger {
1874
2073
  }
1875
2074
  assertUnhandledKind(operation);
1876
2075
  }
2076
+ /**
2077
+ * Validation of one operation's Inbox delta against the projection it was
2078
+ * derived from.
2079
+ *
2080
+ * Direct marker references resolve through the replay-derived Message index:
2081
+ * this runs once per inbox-carrying record, so a rescan of the Message list
2082
+ * here would make every record cost the whole ledger. The index is written by
2083
+ * the same fact appends that build `factsByThread`, so a lookup sees exactly
2084
+ * the records replayed before this one and can never reach a Message that
2085
+ * arrives later — which is why the operation's own entity, not yet in the
2086
+ * projection, needs the `additional*` fallbacks. The commit path resolved the
2087
+ * projection first and then its own record, and that precedence is kept.
2088
+ */
1877
2089
  validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
1878
2090
  const attentionKeys = new Set();
1879
- const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
1880
2091
  for (const attention of delta.attention.set) {
1881
- if (!knownThreadRefs.has(attention.threadRef) || attention.startSequence < 1
2092
+ if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
1882
2093
  || attention.readThroughSequence < attention.startSequence - 1) {
1883
2094
  throw new Error('invalid Attention delta');
1884
2095
  }
@@ -1892,13 +2103,12 @@ export class AgentTeamLedger {
1892
2103
  throw new Error('conflicting Attention delta');
1893
2104
  }
1894
2105
  const markerKeys = new Set();
1895
- const messages = [...projection.messages, ...additionalMessages];
1896
2106
  for (const marker of delta.directMarkers.added) {
1897
2107
  const key = this.directMarkerKey(marker);
1898
2108
  if (markerKeys.has(key) || projection.directMarkers.has(key))
1899
2109
  throw new Error('invalid direct marker addition');
1900
2110
  markerKeys.add(key);
1901
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2111
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1902
2112
  if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
1903
2113
  || !this.validMentionTarget(projection, message.channelRef, marker.memberId))
1904
2114
  throw new Error('invalid direct marker addition');
@@ -1909,17 +2119,20 @@ export class AgentTeamLedger {
1909
2119
  throw new Error('invalid direct marker removal');
1910
2120
  markerKeys.add(key);
1911
2121
  const current = projection.directMarkers.get(key);
1912
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2122
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1913
2123
  if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
1914
2124
  || message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
1915
2125
  throw new Error('invalid direct marker removal');
1916
2126
  }
1917
2127
  }
1918
2128
  const activityMarkerKeys = new Set();
1919
- const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
1920
2129
  for (const marker of delta.activityMarkers.added) {
1921
2130
  const key = this.activityMarkerKey(marker);
1922
- const activity = activities.find(candidate => candidate.activityRef === marker.activityRef);
2131
+ // Every activity marker is minted for the activity the same record
2132
+ // carries (promotion, close, reopen, acceptance), and a removal never
2133
+ // looks an activity up, so this record's own list is the whole
2134
+ // resolution space: scanning recorded activities would add nothing.
2135
+ const activity = additionalActivities.find(candidate => candidate.activityRef === marker.activityRef);
1923
2136
  if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
1924
2137
  || activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
1925
2138
  throw new Error('invalid activity marker addition');
@@ -1933,73 +2146,31 @@ export class AgentTeamLedger {
1933
2146
  activityMarkerKeys.add(key);
1934
2147
  }
1935
2148
  }
2149
+ /**
2150
+ * Replay validation of one departure's release snapshot against the
2151
+ * projection it was derived from. `memberId` scopes everything to a single
2152
+ * departing Member; `undefined` validates every owner, which is what
2153
+ * archiving a whole Channel releases. The two scopes differ in exactly four
2154
+ * ways, all of them visible here rather than spread over two copies:
2155
+ *
2156
+ * 1. released Claims are owner-filtered for a Member, unfiltered for a Channel;
2157
+ * 2. Activities group per (owner, Thread) with owners sorted, which for a
2158
+ * single departing Member collapses to one Activity per Thread — the shape
2159
+ * the commit path writes;
2160
+ * 3. the inbox cleanup covers the departing Member's Attention and markers, or
2161
+ * every Member's on the archived Channel's Threads;
2162
+ * 4. the failure message names which of the two paths lost its cleanup.
2163
+ */
1936
2164
  validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
1937
2165
  const releasedClaims = [...projection.claims.values()]
1938
- .filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
1939
- .map(claim => Object.freeze({ ...claim, state: 'released' }));
1940
- if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1941
- const expected = releasedClaims[index];
1942
- return expected === undefined || !this.sameClaim(expected, claim);
1943
- }))
1944
- throw new Error('invalid released Claim projection');
1945
- const byThread = new Map();
1946
- for (const claim of releasedClaims)
1947
- byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
1948
- const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
1949
- kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
1950
- claimRefs: claims.map(claim => claim.claimRef).sort(),
1951
- }));
1952
- if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
1953
- const expected = expectedActivities[index];
1954
- return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
1955
- || activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
1956
- || !this.sameList(activity.claimRefs, expected.claimRefs);
1957
- }))
1958
- throw new Error('invalid released Claim activities');
1959
- for (const claim of data.claims) {
1960
- const prior = projection.claims.get(claim.claimRef);
1961
- if (prior === undefined)
1962
- throw new Error('invalid released Claim reference');
1963
- }
1964
- for (const activity of data.activities)
1965
- this.addRef(refs, activity.activityRef);
1966
- const projectedClaims = new Map(projection.claims);
1967
- for (const claim of releasedClaims)
1968
- projectedClaims.set(claim.claimRef, claim);
1969
- const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
1970
- const task = projection.tasks.get(taskRef);
1971
- return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
1972
- });
1973
- const expectedThreads = expectedActivities.map(activity => {
1974
- const thread = projection.threads.get(activity.threadRef);
1975
- return Object.freeze({ ...thread, revision: sequence });
1976
- });
1977
- if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
1978
- throw new Error('invalid released Claim Task or Thread projection');
1979
- }
1980
- const expectedAttention = [...projection.attention.values()]
1981
- .filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
1982
- .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
1983
- const expectedMarkers = [...projection.directMarkers.values()]
1984
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1985
- const expectedActivityMarkers = [...projection.activityMarkers.values()]
1986
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1987
- const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
1988
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
1989
- throw new Error('invalid Member inbox cleanup');
1990
- this.validateInboxDelta(data.inbox, projection, refs);
1991
- }
1992
- /** Replay validation of one Channel archival's release snapshot across every owner. */
1993
- validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
1994
- const releasedClaims = [...projection.claims.values()]
1995
- .filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
2166
+ .filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
1996
2167
  .map(claim => Object.freeze({ ...claim, state: 'released' }));
1997
2168
  if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1998
2169
  const expected = releasedClaims[index];
1999
2170
  return expected === undefined || !this.sameClaim(expected, claim);
2000
2171
  }))
2001
2172
  throw new Error('invalid released Claim projection');
2002
- // One activity per (owner, Thread), owners sorted like the commit path.
2173
+ // One Activity per (owner, Thread), owners sorted like the commit path.
2003
2174
  const byOwner = new Map();
2004
2175
  for (const claim of releasedClaims) {
2005
2176
  const byThread = byOwner.get(claim.owner) ?? new Map();
@@ -2038,17 +2209,17 @@ export class AgentTeamLedger {
2038
2209
  if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
2039
2210
  throw new Error('invalid released Claim Task or Thread projection');
2040
2211
  }
2041
- // Attention and marker cleanup covers EVERY Member on the archived Threads.
2042
2212
  const expectedAttention = [...projection.attention.values()]
2043
- .filter(attention => threadRefs.has(attention.threadRef))
2213
+ .filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
2044
2214
  .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
2045
2215
  const expectedMarkers = [...projection.directMarkers.values()]
2046
- .filter(marker => threadRefs.has(marker.threadRef));
2216
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2047
2217
  const expectedActivityMarkers = [...projection.activityMarkers.values()]
2048
- .filter(marker => threadRefs.has(marker.threadRef));
2218
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2049
2219
  const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
2050
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
2051
- throw new Error('invalid Channel archival inbox cleanup');
2220
+ if (!isDeepStrictEqual(data.inbox, expectedInbox)) {
2221
+ throw new Error(memberId === undefined ? 'invalid Channel archival inbox cleanup' : 'invalid Member inbox cleanup');
2222
+ }
2052
2223
  this.validateInboxDelta(data.inbox, projection, refs);
2053
2224
  }
2054
2225
  validateMessageInbox(operation, projection) {
@@ -2057,7 +2228,7 @@ export class AgentTeamLedger {
2057
2228
  // Top-level mentions are open to every actor; only a Member reply may not
2058
2229
  // pull an unfollowed Member into an existing Thread.
2059
2230
  if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
2060
- && mentionedAgents.some(memberId => !this.isFollowingFrom(projection, message.threadRef, memberId))) {
2231
+ && mentionedAgents.some(memberId => !this.everParticipatedFrom(projection, message.threadRef, memberId))) {
2061
2232
  throw new Error('invalid Agent mention projection');
2062
2233
  }
2063
2234
  const started = operation.kind === 'team/message-sent'
@@ -2088,6 +2259,12 @@ export class AgentTeamLedger {
2088
2259
  }
2089
2260
  apply(operation) {
2090
2261
  this.applyTo(this.state, operation);
2262
+ // Live apply only: the record-validation replay re-derives each record
2263
+ // against its own scratch projection, so a scope derivation there would be
2264
+ // discarded.
2265
+ const scopes = this.changeScopesOf(operation);
2266
+ if (scopes === undefined || scopes.length !== 0)
2267
+ this.projectionVersion = operation.sequence;
2091
2268
  }
2092
2269
  applyTo(target, operation) {
2093
2270
  target.ordered.push(operation);
@@ -2157,15 +2334,7 @@ export class AgentTeamLedger {
2157
2334
  }
2158
2335
  if (operation.kind === 'team/channel-member-removed') {
2159
2336
  target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
2160
- for (const claim of operation.data.claims)
2161
- target.claims.set(claim.claimRef, claim);
2162
- for (const activity of operation.data.activities)
2163
- this.appendActivityFact(target, activity, operation.occurredAt);
2164
- for (const task of operation.data.tasks)
2165
- target.tasks.set(task.taskRef, task);
2166
- for (const thread of operation.data.threads)
2167
- target.threads.set(thread.threadRef, thread);
2168
- this.applyInboxDelta(target, operation.data.inbox);
2337
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2169
2338
  return;
2170
2339
  }
2171
2340
  if (operation.kind === 'team/channel-archived') {
@@ -2173,30 +2342,14 @@ export class AgentTeamLedger {
2173
2342
  // restore returns every Member to the Channel. Visibility filters by
2174
2343
  // channel state instead.
2175
2344
  target.channels.set(operation.data.channel.channelRef, operation.data.channel);
2176
- for (const claim of operation.data.claims)
2177
- target.claims.set(claim.claimRef, claim);
2178
- for (const activity of operation.data.activities)
2179
- this.appendActivityFact(target, activity, operation.occurredAt);
2180
- for (const task of operation.data.tasks)
2181
- target.tasks.set(task.taskRef, task);
2182
- for (const thread of operation.data.threads)
2183
- target.threads.set(thread.threadRef, thread);
2184
- this.applyInboxDelta(target, operation.data.inbox);
2345
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2185
2346
  return;
2186
2347
  }
2187
2348
  if (operation.kind === 'team/member-removed') {
2188
2349
  target.members.set(operation.data.member.memberId, operation.data.member);
2189
2350
  for (const membership of target.memberships.values())
2190
2351
  membership.delete(operation.data.member.memberId);
2191
- for (const claim of operation.data.claims)
2192
- target.claims.set(claim.claimRef, claim);
2193
- for (const activity of operation.data.activities)
2194
- this.appendActivityFact(target, activity, operation.occurredAt);
2195
- for (const task of operation.data.tasks)
2196
- target.tasks.set(task.taskRef, task);
2197
- for (const thread of operation.data.threads)
2198
- target.threads.set(thread.threadRef, thread);
2199
- this.applyInboxDelta(target, operation.data.inbox);
2352
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2200
2353
  return;
2201
2354
  }
2202
2355
  if (operation.kind === 'team/member-archived') {
@@ -2204,34 +2357,37 @@ export class AgentTeamLedger {
2204
2357
  // restore returns the Member to its Channels. Visibility filters by
2205
2358
  // member state instead.
2206
2359
  target.members.set(operation.data.member.memberId, operation.data.member);
2207
- for (const claim of operation.data.claims)
2208
- target.claims.set(claim.claimRef, claim);
2209
- for (const activity of operation.data.activities)
2210
- this.appendActivityFact(target, activity, operation.occurredAt);
2211
- for (const task of operation.data.tasks)
2212
- target.tasks.set(task.taskRef, task);
2213
- for (const thread of operation.data.threads)
2214
- target.threads.set(thread.threadRef, thread);
2215
- this.applyInboxDelta(target, operation.data.inbox);
2360
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2216
2361
  return;
2217
2362
  }
2218
2363
  if (operation.kind === 'team/thread-promoted') {
2219
2364
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2220
2365
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2221
2366
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2367
+ this.recordTaskNumber(target, operation.data.task);
2368
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2222
2369
  this.applyInboxDelta(target, operation.data.inbox);
2223
2370
  return;
2224
2371
  }
2225
2372
  if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
2226
2373
  const { message, mentions } = operation.data;
2227
- target.messages.push(message);
2228
2374
  target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
2229
2375
  this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
2230
2376
  if (operation.data.task !== undefined)
2231
2377
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2232
2378
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2233
- if (message.topLevel)
2379
+ if (message.topLevel) {
2234
2380
  target.channelRefByThread.set(message.threadRef, message.channelRef);
2381
+ // The anchor is the Thread's first topLevel Message; a later topLevel
2382
+ // Message (none today) must not displace it, matching the linear scan.
2383
+ if (!target.anchorByThread.has(message.threadRef))
2384
+ target.anchorByThread.set(message.threadRef, message);
2385
+ }
2386
+ // Only message-sent mints a Task ordinal; thread-replied may echo the
2387
+ // Task without renumbering it, like the operation scan it replaces.
2388
+ if (operation.kind === 'team/message-sent' && operation.data.task !== undefined)
2389
+ this.recordTaskNumber(target, operation.data.task);
2390
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2235
2391
  this.applyInboxDelta(target, operation.data.inbox);
2236
2392
  return;
2237
2393
  }
@@ -2240,6 +2396,7 @@ export class AgentTeamLedger {
2240
2396
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2241
2397
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2242
2398
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2399
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2243
2400
  this.applyInboxDelta(target, operation.data.inbox);
2244
2401
  return;
2245
2402
  }
@@ -2249,10 +2406,12 @@ export class AgentTeamLedger {
2249
2406
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2250
2407
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2251
2408
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2409
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2252
2410
  this.applyInboxDelta(target, operation.data.inbox);
2253
2411
  return;
2254
2412
  }
2255
2413
  if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
2414
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2256
2415
  this.applyInboxDelta(target, operation.data.inbox);
2257
2416
  return;
2258
2417
  }
@@ -2263,6 +2422,25 @@ export class AgentTeamLedger {
2263
2422
  }
2264
2423
  assertUnhandledKind(operation);
2265
2424
  }
2425
+ /**
2426
+ * Replay the departure snapshot a releasing operation carries. The order is
2427
+ * replay semantics, not style: Claims land before the Activities that
2428
+ * reference them, and Tasks/Threads before the inbox delta that reads them.
2429
+ * Each caller still applies its own identifying writes (member, membership,
2430
+ * channel) around this call.
2431
+ */
2432
+ applyReleaseSnapshot(target, operation, data, occurredAt) {
2433
+ for (const claim of data.claims)
2434
+ target.claims.set(claim.claimRef, claim);
2435
+ for (const activity of data.activities)
2436
+ this.appendActivityFact(target, activity, occurredAt);
2437
+ for (const task of data.tasks)
2438
+ target.tasks.set(task.taskRef, task);
2439
+ for (const thread of data.threads)
2440
+ target.threads.set(thread.threadRef, thread);
2441
+ this.recordAttentionObservations(target, operation, data.inbox);
2442
+ this.applyInboxDelta(target, data.inbox);
2443
+ }
2266
2444
  /** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
2267
2445
  appendMessageFact(target, message, mentions, occurredAt) {
2268
2446
  const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
@@ -2271,6 +2449,14 @@ export class AgentTeamLedger {
2271
2449
  facts.push(fact);
2272
2450
  target.factsByThread.set(message.threadRef, facts);
2273
2451
  target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
2452
+ // The ref index is written in this same step, so it is exactly the set of
2453
+ // replayed Messages: a validator lookup can never reach a later record.
2454
+ target.messagesByRef.set(message.messageRef, message);
2455
+ // Participation is indexed per writer as the fact lands, so the Human
2456
+ // recent slice asks who wrote where without scanning the whole ledger.
2457
+ const written = target.threadsByWriter.get(message.sender) ?? new Set();
2458
+ written.add(message.threadRef);
2459
+ target.threadsByWriter.set(message.sender, written);
2274
2460
  }
2275
2461
  appendActivityFact(target, activity, occurredAt) {
2276
2462
  const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
@@ -2279,6 +2465,44 @@ export class AgentTeamLedger {
2279
2465
  facts.push(fact);
2280
2466
  target.factsByThread.set(activity.threadRef, facts);
2281
2467
  }
2468
+ /** Display ordinals are a per-Channel creation counter; the workspace filter happens at read time. */
2469
+ recordTaskNumber(target, task) {
2470
+ const ordinal = (target.taskCountByChannel.get(task.channelRef) ?? 0) + 1;
2471
+ target.taskCountByChannel.set(task.channelRef, ordinal);
2472
+ target.taskNumberByTask.set(task.taskRef, ordinal);
2473
+ }
2474
+ /**
2475
+ * Replay-order Attention observations, appended from committed Inbox deltas
2476
+ * only (the hypothetical read projection applies its delta directly). The
2477
+ * follow/unfollow decision mirrors the full replay scan it replaces: a
2478
+ * removal records only when Attention existed, and a set records unless it
2479
+ * is the Thread-creating Message (initial Attention) or merely advances the
2480
+ * same follow's watermark. Removals are evaluated before sets, exactly like
2481
+ * the scan's loop order.
2482
+ */
2483
+ recordAttentionObservations(target, operation, delta) {
2484
+ if (delta.attention.removed.length === 0 && delta.attention.set.length === 0)
2485
+ return;
2486
+ const retired = new Set();
2487
+ for (const key of delta.attention.removed) {
2488
+ retired.add(this.attentionKey(key.memberId, key.threadRef));
2489
+ if (!target.attention.has(this.attentionKey(key.memberId, key.threadRef)))
2490
+ continue;
2491
+ this.appendObservation(target, key.threadRef, { sequence: operation.sequence, memberId: key.memberId, action: 'unfollow' });
2492
+ }
2493
+ for (const next of delta.attention.set) {
2494
+ const key = this.attentionKey(next.memberId, next.threadRef);
2495
+ const prior = retired.has(key) ? undefined : target.attention.get(key);
2496
+ if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
2497
+ continue;
2498
+ this.appendObservation(target, next.threadRef, { sequence: operation.sequence, memberId: next.memberId, action: 'follow' });
2499
+ }
2500
+ }
2501
+ appendObservation(target, threadRef, observation) {
2502
+ const observations = target.observationsByThread.get(threadRef) ?? [];
2503
+ observations.push(Object.freeze(observation));
2504
+ target.observationsByThread.set(threadRef, observations);
2505
+ }
2282
2506
  applyInboxDelta(target, delta) {
2283
2507
  for (const key of delta.attention.removed) {
2284
2508
  target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
@@ -2288,27 +2512,76 @@ export class AgentTeamLedger {
2288
2512
  if (followers.size === 0)
2289
2513
  target.attentionByThread.delete(key.threadRef);
2290
2514
  }
2515
+ const threads = target.attentionThreadsByMember.get(key.memberId);
2516
+ if (threads !== undefined) {
2517
+ threads.delete(key.threadRef);
2518
+ if (threads.size === 0)
2519
+ target.attentionThreadsByMember.delete(key.memberId);
2520
+ }
2291
2521
  }
2292
2522
  for (const attention of delta.attention.set) {
2293
2523
  target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
2294
2524
  const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
2295
2525
  followers.add(attention.memberId);
2296
2526
  target.attentionByThread.set(attention.threadRef, followers);
2527
+ const threads = target.attentionThreadsByMember.get(attention.memberId) ?? new Set();
2528
+ threads.add(attention.threadRef);
2529
+ target.attentionThreadsByMember.set(attention.memberId, threads);
2297
2530
  }
2298
- for (const marker of delta.directMarkers.removed)
2531
+ for (const marker of delta.directMarkers.removed) {
2299
2532
  target.directMarkers.delete(this.directMarkerKey(marker));
2300
- for (const marker of delta.directMarkers.added)
2533
+ this.removeBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, this.directMarkerKey(marker), candidate => this.directMarkerKey(candidate));
2534
+ }
2535
+ for (const marker of delta.directMarkers.added) {
2301
2536
  target.directMarkers.set(this.directMarkerKey(marker), marker);
2302
- for (const marker of delta.activityMarkers.removed)
2537
+ this.insertBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, marker);
2538
+ }
2539
+ for (const marker of delta.activityMarkers.removed) {
2303
2540
  target.activityMarkers.delete(this.activityMarkerKey(marker));
2304
- for (const marker of delta.activityMarkers.added)
2541
+ this.removeBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, this.activityMarkerKey(marker), candidate => this.activityMarkerKey(candidate));
2542
+ }
2543
+ for (const marker of delta.activityMarkers.added) {
2305
2544
  target.activityMarkers.set(this.activityMarkerKey(marker), marker);
2545
+ this.insertBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, marker);
2546
+ }
2547
+ }
2548
+ /** Insert into the Member's Thread bucket keeping the sequence order the read path must see. */
2549
+ insertBucketedMarker(index, memberId, threadRef, marker) {
2550
+ const threads = index.get(memberId) ?? new Map();
2551
+ index.set(memberId, threads);
2552
+ const bucket = threads.get(threadRef) ?? [];
2553
+ threads.set(threadRef, bucket);
2554
+ let position = bucket.length;
2555
+ while (position > 0 && bucket[position - 1].sequence > marker.sequence)
2556
+ position -= 1;
2557
+ bucket.splice(position, 0, marker);
2558
+ }
2559
+ removeBucketedMarker(index, memberId, threadRef, key, identityOf) {
2560
+ const threads = index.get(memberId);
2561
+ const bucket = threads?.get(threadRef);
2562
+ if (bucket === undefined)
2563
+ return;
2564
+ const position = bucket.findIndex(item => identityOf(item) === key);
2565
+ if (position >= 0)
2566
+ bucket.splice(position, 1);
2567
+ if (bucket.length === 0) {
2568
+ threads.delete(threadRef);
2569
+ if (threads.size === 0)
2570
+ index.delete(memberId);
2571
+ }
2306
2572
  }
2307
2573
  prepareRead(memberId, workspaceId, request) {
2308
2574
  return this.prepareReadFrom(this.state, memberId, workspaceId, request);
2309
2575
  }
2310
- /** Derive the only legal durable result of one Thread read from a prior projection. */
2311
- prepareReadFrom(projection, memberId, workspaceId, request) {
2576
+ /**
2577
+ * Derive the durable receipt of one Thread read from a prior projection: the
2578
+ * Thread it targeted, the watermark it reaches, the Attention row it writes
2579
+ * and the Inbox delta it consumes. This is the whole durable content of a
2580
+ * receipt-shaped read and the only thing `validateRecords` re-derives for
2581
+ * one; the picture derivation below builds on the same result, so the two can
2582
+ * never drift.
2583
+ */
2584
+ prepareReadReceiptFrom(projection, memberId, workspaceId, request) {
2312
2585
  const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
2313
2586
  if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
2314
2587
  const member = projection.members.get(memberId);
@@ -2316,21 +2589,9 @@ export class AgentTeamLedger {
2316
2589
  throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
2317
2590
  }
2318
2591
  }
2319
- const anchor = this.threadAnchorFrom(projection, thread.threadRef);
2320
2592
  const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
2321
2593
  const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
2322
2594
  const unreadFacts = unread.slice(0, 20);
2323
- const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
2324
- const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
2325
- const background = firstRead
2326
- ? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
2327
- .map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
2328
- mentions: projection.mentionsByMessage.get(message.messageRef) ?? [],
2329
- occurredAt: this.occurredAtForFactFrom(projection, message.sequence, message.occurredAt) }))
2330
- .filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
2331
- : [];
2332
- const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
2333
- .sort((left, right) => left.fact.sequence - right.fact.sequence);
2334
2595
  // Direct markers are sparse acknowledgements, not part of the contiguous
2335
2596
  // follower watermark. Consuming an old marker after a later follow must
2336
2597
  // never move that watermark backwards.
@@ -2346,24 +2607,48 @@ export class AgentTeamLedger {
2346
2607
  const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
2347
2608
  .filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
2348
2609
  const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
2349
- // The hypothetical projection copies every map that its inbox delta can
2350
- // mutate, including the follower sets inside attentionByThread; the fact
2351
- // indexes are read-only here and stay shared.
2352
- const nextProjection = {
2353
- ...projection,
2354
- attention: new Map(projection.attention),
2355
- directMarkers: new Map(projection.directMarkers),
2356
- activityMarkers: new Map(projection.activityMarkers),
2357
- attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
2358
- };
2359
- this.applyInboxDelta(nextProjection, inbox);
2360
- const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
2361
- return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2362
- anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2363
- facts: Object.freeze(combined),
2364
- readThroughSequence, remainingUnreadCount, ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
2610
+ return Object.freeze({ ...(task === undefined ? {} : { task }), thread, unread,
2611
+ ...(attention === undefined ? {} : { attentionBefore: attention }), readThroughSequence,
2612
+ ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
2365
2613
  consumedDirectMarkers: Object.freeze(consumed), inbox });
2366
2614
  }
2615
+ /** Derive the only legal durable result of one Thread read from a prior projection. */
2616
+ prepareReadFrom(projection, memberId, workspaceId, request) {
2617
+ const receipt = this.prepareReadReceiptFrom(projection, memberId, workspaceId, request);
2618
+ const { task, thread } = receipt;
2619
+ const anchor = this.threadAnchorFrom(projection, thread.threadRef);
2620
+ // The first read of a Thread after following also shows the bounded
2621
+ // background that preceded the follow, so a reader sees what they joined.
2622
+ const firstRead = receipt.attentionBefore !== undefined && receipt.attentionBefore.readThroughSequence < receipt.attentionBefore.startSequence;
2623
+ const unreadFactKeys = new Set(receipt.unread.map(item => this.threadFactKey(item.fact)));
2624
+ const background = firstRead
2625
+ ? this.threadFactsFrom(projection, thread.threadRef)
2626
+ .filter((fact) => fact.kind === 'message'
2627
+ && fact.sequence < receipt.attentionBefore.startSequence)
2628
+ .map(fact => Object.freeze({ kind: 'message', sequence: fact.sequence, message: fact.message,
2629
+ mentions: projection.mentionsByMessage.get(fact.message.messageRef) ?? [],
2630
+ occurredAt: this.occurredAtForFactFrom(projection, fact.sequence, fact.message.occurredAt) }))
2631
+ .filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
2632
+ : [];
2633
+ const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
2634
+ .sort((left, right) => left.fact.sequence - right.fact.sequence);
2635
+ const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
2636
+ // What this bounded read leaves behind it: the Thread facts that precede the
2637
+ // watermark it reaches, which is where this reader now stands in the Thread
2638
+ // and how much of it that position has never shown it. Anchoring on the
2639
+ // watermark rather than on the response's own oldest fact is what keeps a
2640
+ // returning reader honest — the background window starts at the Thread's
2641
+ // first fact whenever the Thread is short enough to fit it, so measuring
2642
+ // from the response would report nothing behind a reader that has in fact
2643
+ // never read the Thread. Derived from the projection the read resolved
2644
+ // against, like every other field of this picture, so a replay re-derives
2645
+ // the same number.
2646
+ const earlierFactCount = this.threadFactsFrom(projection, thread.threadRef)
2647
+ .filter(fact => fact.sequence < receipt.readThroughSequence).length;
2648
+ return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2649
+ anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2650
+ facts: Object.freeze(combined), remainingUnreadCount, earlierFactCount });
2651
+ }
2367
2652
  readFactFrom(projection, memberId, fact, unread) {
2368
2653
  return Object.freeze({ fact, unread, direct: fact.kind === 'message'
2369
2654
  && projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
@@ -2374,23 +2659,60 @@ export class AgentTeamLedger {
2374
2659
  }
2375
2660
  unreadForFrom(projection, memberId, threadRef) {
2376
2661
  const attention = this.attentionForFrom(projection, memberId, threadRef);
2377
- const facts = this.threadFactsFrom(projection, threadRef);
2378
- const direct = this.directMarkersForFrom(projection, memberId, threadRef);
2379
- const directKeys = new Set(direct.map(marker => this.directMarkerKey(marker)));
2662
+ const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
2380
2663
  const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
2381
2664
  const result = [];
2382
- for (const fact of facts) {
2383
- const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
2384
- messageRef: fact.message.messageRef, sequence: fact.sequence }));
2385
- const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
2386
- activityRef: fact.activity.activityRef, sequence: fact.sequence }));
2387
- const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
2388
- && fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
2389
- if (marker || activityMarker || ordinary)
2665
+ for (const fact of this.threadFactsFrom(projection, threadRef)) {
2666
+ if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
2390
2667
  result.push(this.readFactFrom(projection, memberId, fact, true));
2668
+ }
2391
2669
  }
2392
2670
  return Object.freeze(result);
2393
2671
  }
2672
+ /**
2673
+ * The single authority on "this fact is unread for this reader". The picture
2674
+ * derivation and the remaining-count derivation both go through it, so the
2675
+ * count a read reports can never disagree with the facts it lists. Marker
2676
+ * membership arrives as key sets: the caller may be looking at a state the
2677
+ * projection has not applied yet.
2678
+ */
2679
+ isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys) {
2680
+ const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
2681
+ messageRef: fact.message.messageRef, sequence: fact.sequence }));
2682
+ const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
2683
+ activityRef: fact.activity.activityRef, sequence: fact.sequence }));
2684
+ const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
2685
+ && fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
2686
+ return marker || activityMarker || ordinary;
2687
+ }
2688
+ /**
2689
+ * Unread count once this read's own Inbox delta has been applied, derived
2690
+ * without materializing a hypothetical projection. The delta can only touch
2691
+ * the reader's own Attention row and its own marker keys in one Thread, and
2692
+ * every lookup in `isUnreadFact` is scoped to that same (member, Thread)
2693
+ * pair, so the post-delta state is the Attention row the receipt already
2694
+ * derived plus those marker keys with the delta's own removals and additions
2695
+ * applied.
2696
+ */
2697
+ remainingUnreadAfter(projection, memberId, receipt) {
2698
+ const threadRef = receipt.thread.threadRef;
2699
+ const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
2700
+ for (const marker of receipt.inbox.directMarkers.removed)
2701
+ directKeys.delete(this.directMarkerKey(marker));
2702
+ for (const marker of receipt.inbox.directMarkers.added)
2703
+ directKeys.add(this.directMarkerKey(marker));
2704
+ const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
2705
+ for (const marker of receipt.inbox.activityMarkers.removed)
2706
+ activityKeys.delete(this.activityMarkerKey(marker));
2707
+ for (const marker of receipt.inbox.activityMarkers.added)
2708
+ activityKeys.add(this.activityMarkerKey(marker));
2709
+ let count = 0;
2710
+ for (const fact of this.threadFactsFrom(projection, threadRef)) {
2711
+ if (this.isUnreadFact(fact, memberId, threadRef, receipt.attention, directKeys, activityKeys))
2712
+ count += 1;
2713
+ }
2714
+ return count;
2715
+ }
2394
2716
  visibleToFollower(fact, memberId) {
2395
2717
  const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
2396
2718
  return sender !== memberId;
@@ -2535,12 +2857,14 @@ export class AgentTeamLedger {
2535
2857
  return this.directMarkersForFrom(this.state, memberId, threadRef);
2536
2858
  }
2537
2859
  directMarkersForFrom(projection, memberId, threadRef) {
2538
- return Object.freeze([...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2539
- .sort((left, right) => left.sequence - right.sequence));
2860
+ // A frozen copy, not the live bucket: callers may hand the array to an
2861
+ // Inbox delta that freezes it, which must not freeze the derived index.
2862
+ const markers = projection.directMarkersByMember.get(memberId)?.get(threadRef);
2863
+ return markers === undefined ? [] : Object.freeze([...markers]);
2540
2864
  }
2541
2865
  activityMarkersForFrom(projection, memberId, threadRef) {
2542
- return Object.freeze([...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2543
- .sort((left, right) => left.sequence - right.sequence));
2866
+ const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
2867
+ return markers === undefined ? [] : Object.freeze([...markers]);
2544
2868
  }
2545
2869
  issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
2546
2870
  if (actor.kind !== 'human')
@@ -2579,10 +2903,6 @@ export class AgentTeamLedger {
2579
2903
  }
2580
2904
  }
2581
2905
  }
2582
- memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
2583
- return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
2584
- ...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
2585
- }
2586
2906
  unreadRequired(task, thread, unread) {
2587
2907
  return Object.freeze({ kind: 'unread_required', ...(task === undefined ? {} : { taskRef: task.taskRef }), threadRef: thread.threadRef,
2588
2908
  revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
@@ -2642,16 +2962,17 @@ export class AgentTeamLedger {
2642
2962
  return undefined;
2643
2963
  }
2644
2964
  /** Commit projections shared by the Message-sent and Thread-replied results. */
2645
- committedMessageResult(operation) {
2965
+ committedMessageResult(operation, undelivered = []) {
2646
2966
  return { kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
2647
2967
  ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread, attention: operation.data.inbox.attention.set,
2648
- directMarkers: operation.data.inbox.directMarkers.added };
2968
+ directMarkers: operation.data.inbox.directMarkers.added,
2969
+ ...(undelivered.length === 0 ? {} : { undeliveredMentions: undelivered }) };
2649
2970
  }
2650
2971
  threadAnchor(threadRef) {
2651
2972
  return this.threadAnchorFrom(this.state, threadRef);
2652
2973
  }
2653
2974
  threadAnchorFrom(projection, threadRef) {
2654
- const anchor = projection.messages.find(message => message.threadRef === threadRef && message.topLevel);
2975
+ const anchor = projection.anchorByThread.get(threadRef);
2655
2976
  if (anchor === undefined)
2656
2977
  throw new Error(`Thread '${threadRef}' has no anchor Message`);
2657
2978
  return anchor;
@@ -2663,20 +2984,17 @@ export class AgentTeamLedger {
2663
2984
  * Display numbers for Tasks: one counter per home Channel, in creation
2664
2985
  * order. This is the single numbering authority — Channel cards, Thread
2665
2986
  * headings, cross-channel ref resolution, and inbox renders all show the
2666
- * ordinal the Task holds inside its own Channel.
2987
+ * ordinal the Task holds inside its own Channel. The ordinals are derived
2988
+ * at replay time (`taskNumberByTask`); this read only filters them to one
2989
+ * Workspace.
2667
2990
  */
2668
2991
  taskNumbers(workspaceId) {
2669
2992
  const numbers = new Map();
2670
- const next = new Map();
2671
- for (const operation of this.state.ordered) {
2672
- const task = operation.kind === 'team/message-sent' || operation.kind === 'team/thread-promoted' ? operation.data.task : undefined;
2673
- if (task === undefined)
2674
- continue;
2675
- if (this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
2993
+ for (const [taskRef, taskNumber] of this.state.taskNumberByTask) {
2994
+ const task = this.state.tasks.get(taskRef);
2995
+ if (task === undefined || this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
2676
2996
  continue;
2677
- const ordinal = (next.get(task.channelRef) ?? 0) + 1;
2678
- next.set(task.channelRef, ordinal);
2679
- numbers.set(task.taskRef, ordinal);
2997
+ numbers.set(taskRef, taskNumber);
2680
2998
  }
2681
2999
  return numbers;
2682
3000
  }
@@ -2921,6 +3239,68 @@ export class AgentTeamLedger {
2921
3239
  throw new Error(`${label} contains duplicate Member refs`);
2922
3240
  return Object.freeze([...unique].sort());
2923
3241
  }
3242
+ /**
3243
+ * Names a Message body may address in one Channel: every live Member of that
3244
+ * Channel plus the Human. A name outside this set stays prose, which is what
3245
+ * keeps an incidental name-drop from reaching someone the Channel cannot
3246
+ * deliver to.
3247
+ */
3248
+ mentionCandidatesFor(channelRef) {
3249
+ const candidates = [{ memberId: AGENT_TEAM_HUMAN_MEMBER_ID, handle: AGENT_TEAM_HUMAN_HANDLE }];
3250
+ for (const member of this.state.members.values()) {
3251
+ if (member.state === 'inactive' || member.state === 'archived')
3252
+ continue;
3253
+ if (!this.isChannelMember(channelRef, member.memberId))
3254
+ continue;
3255
+ candidates.push({ memberId: member.memberId, handle: member.handle });
3256
+ }
3257
+ return Object.freeze(candidates);
3258
+ }
3259
+ /**
3260
+ * Merge the `@Handle` mentions authored in `body` into an explicit recipient
3261
+ * set. Body mentions are the primary channel now: an Agent has no recipient
3262
+ * parameter to forget, and the same scan serves Human input typed by hand.
3263
+ * The result stays a plain recipient set, so every downstream projection —
3264
+ * delivery markers, chip rendering, confirmation — is unchanged.
3265
+ */
3266
+ mergeBodyMentions(sender, channelRef, body, explicit) {
3267
+ const candidates = this.mentionCandidatesFor(channelRef);
3268
+ const resolution = resolveBodyMentions(body, candidates, sender);
3269
+ // `@all` stands for its expansion as of this write: the Member set is
3270
+ // snapshotted into the recipient list, so a later roster change cannot
3271
+ // retroactively alter what this operation delivered.
3272
+ const authored = resolution.all ? candidates.map(candidate => candidate.memberId) : resolution.memberIds;
3273
+ const merged = new Set([...explicit, ...authored]);
3274
+ merged.delete(sender);
3275
+ return Object.freeze([...merged].sort());
3276
+ }
3277
+ /**
3278
+ * Whether one Member has ever held Attention on one Thread. Committed Inbox
3279
+ * deltas append a follow/unfollow observation, and the live index covers the
3280
+ * Thread-creating Message whose initial Attention is never observed — so the
3281
+ * two together answer "was this Member ever part of this Thread" without
3282
+ * adding a second durable authority.
3283
+ */
3284
+ everParticipated(threadRef, memberId) {
3285
+ return this.everParticipatedFrom(this.state, threadRef, memberId);
3286
+ }
3287
+ everParticipatedFrom(projection, threadRef, memberId) {
3288
+ if (this.isFollowingFrom(projection, threadRef, memberId))
3289
+ return true;
3290
+ return (projection.observationsByThread.get(threadRef) ?? []).some(observation => observation.memberId === memberId);
3291
+ }
3292
+ /**
3293
+ * Split the Agent recipients an existing Thread cannot deliver to: those the
3294
+ * body named that the Thread has never carried. The send still commits — a
3295
+ * text mention must never fail the write — and the author is told through the
3296
+ * result, because inviting a Member into an existing Thread stays a Human
3297
+ * decision.
3298
+ */
3299
+ undeliverableRecipients(actor, threadRef, recipients) {
3300
+ if (actor.kind !== 'member')
3301
+ return Object.freeze([]);
3302
+ return Object.freeze(recipients.filter(memberId => this.state.members.has(memberId) && !this.everParticipated(threadRef, memberId)));
3303
+ }
2924
3304
  normalizeDirection(direction) {
2925
3305
  return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
2926
3306
  }
@@ -3106,8 +3486,8 @@ export class AgentTeamLedger {
3106
3486
  assertSameThreadRead(operation, request) {
3107
3487
  if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
3108
3488
  || operation.data.workspaceId !== request.workspaceId
3109
- || (request.threadRef !== undefined && operation.data.thread.threadRef !== request.threadRef)
3110
- || (request.taskRef !== undefined && operation.data.task?.taskRef !== request.taskRef)
3489
+ || (request.threadRef !== undefined && threadReadTargetOf(operation.data).threadRef !== request.threadRef)
3490
+ || (request.taskRef !== undefined && threadReadTargetOf(operation.data).taskRef !== request.taskRef)
3111
3491
  || (request.threadRef === undefined && request.taskRef === undefined))
3112
3492
  this.throwRequestCollision(request.requestId);
3113
3493
  }
@@ -3191,8 +3571,8 @@ export class AgentTeamLedger {
3191
3571
  messageResult(operation) {
3192
3572
  return Object.freeze(this.committedMessageResult(operation));
3193
3573
  }
3194
- replyResult(operation) {
3195
- return Object.freeze(this.committedMessageResult(operation));
3574
+ replyResult(operation, undelivered = []) {
3575
+ return Object.freeze(this.committedMessageResult(operation, undelivered));
3196
3576
  }
3197
3577
  claimResult(operation) {
3198
3578
  const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
@@ -3212,14 +3592,19 @@ export class AgentTeamLedger {
3212
3592
  return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
3213
3593
  task: operation.data.task, thread: operation.data.thread });
3214
3594
  }
3215
- threadReadResult(operation) {
3216
- // Loaded records are normalized by sortedRecords(); fresh writes always carry instants.
3217
- const { anchor, facts } = operation.data;
3218
- return Object.freeze({ receipt: this.receipt(operation), ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread,
3219
- claims: operation.data.claims, anchor, anchorMentions: operation.data.anchorMentions, facts,
3220
- readThroughSequence: operation.data.readThroughSequence, remainingUnreadCount: operation.data.remainingUnreadCount,
3221
- ...(operation.data.attention === undefined ? {} : { attention: operation.data.attention }),
3222
- consumedDirectMarkers: operation.data.inbox.directMarkers.removed });
3595
+ /**
3596
+ * The Thread picture a read answers with, derived from the projection the
3597
+ * read resolved against. Committed reads, no-op reads and retries all share
3598
+ * this one derivation, so a read that writes nothing still returns the same
3599
+ * Attention, facts, watermark and unread count a committed one would.
3600
+ */
3601
+ readPicture(prepared) {
3602
+ return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
3603
+ claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
3604
+ readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
3605
+ earlierFactCount: prepared.earlierFactCount,
3606
+ ...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
3607
+ consumedDirectMarkers: prepared.inbox.directMarkers.removed });
3223
3608
  }
3224
3609
  receipt(operation) {
3225
3610
  return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
@@ -3300,10 +3685,17 @@ export class AgentTeamLedger {
3300
3685
  legacyChannelThreadRefs(projection, channelRef) {
3301
3686
  return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
3302
3687
  }
3303
- /** Ledgers written before message occurredAt existed store bare messages; Thread reads resolve instants from the originating operations. */
3688
+ /**
3689
+ * Ledgers written before message occurredAt existed store bare messages;
3690
+ * snapshot-shaped Thread reads resolve instants from the originating
3691
+ * operations. A receipt-shaped read carries no message at all, so it passes
3692
+ * through untouched — the load path must never touch a shape it does not
3693
+ * have, and it never writes either form back.
3694
+ */
3304
3695
  normalizeOperation(operation, occurrences, instants) {
3305
- if (operation.kind !== 'team/thread-read')
3696
+ if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
3306
3697
  return operation;
3698
+ const data = operation.data;
3307
3699
  const stamp = (message) => (message.occurredAt === undefined
3308
3700
  ? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
3309
3701
  : { ...message, occurredAt: message.occurredAt });
@@ -3312,8 +3704,8 @@ export class AgentTeamLedger {
3312
3704
  occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
3313
3705
  : { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
3314
3706
  occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
3315
- const facts = operation.data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
3316
- return { ...operation, data: { ...operation.data, anchor: stamp(operation.data.anchor), facts } };
3707
+ const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
3708
+ return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
3317
3709
  }
3318
3710
  enqueue(operation) {
3319
3711
  const result = this.operationTail.then(operation);