@wowyuarm/dsh-agent-team 0.1.11 → 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 (83) hide show
  1. package/README.md +4 -4
  2. package/README.zh.md +4 -4
  3. package/package.json +3 -2
  4. package/packages/agent-team/README.md +2 -2
  5. package/packages/agent-team/README.zh.md +2 -2
  6. package/packages/agent-team/lib/context-projection.js +5 -1
  7. package/packages/agent-team/lib/index.js +11 -44
  8. package/packages/agent-team/lib/ledger.js +251 -142
  9. package/packages/agent-team/lib/member-runtime.js +47 -9
  10. package/packages/agent-team/lib/mentions.js +56 -0
  11. package/packages/agent-team/lib/spec.js +1 -0
  12. package/packages/agent-team/lib/typert.host.js +80 -31
  13. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  14. package/packages/agent-team/lib/typert.remote-client.js +71 -26
  15. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  16. package/packages/agent-team/lib/types/index.d.ts +1 -7
  17. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  18. package/packages/agent-team/lib/types/ledger.d.ts +75 -17
  19. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  20. package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
  21. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  22. package/packages/agent-team/lib/types/mentions.d.ts +34 -0
  23. package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
  24. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/types/entities.d.ts +0 -18
  26. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  27. package/packages/agent-team/lib/types/types/requests-results.d.ts +57 -15
  28. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  29. package/packages/agent-team/preset/team-member/agent.cordis.yml +48 -8
  30. package/packages/client-agent-team/lib/client.js +1366 -909
  31. package/packages/client-agent-team/lib/client.js.map +1 -1
  32. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  33. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +3 -3
  34. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
  35. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
  36. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
  37. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
  38. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  39. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +125 -13
  40. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  41. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +10 -3
  42. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  43. package/packages/client-agent-team/lib/types/client/TeamComposer.js +11 -17
  44. package/packages/client-agent-team/lib/types/client/TeamConversation.js +1 -1
  45. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
  46. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
  47. package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
  48. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +9 -5
  49. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -1
  50. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +82 -14
  51. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
  52. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
  53. package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
  54. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
  55. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
  56. package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
  57. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  58. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +6 -2
  59. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  60. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +28 -10
  61. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  62. package/packages/client-agent-team/lib/types/client/index.js +3 -4
  63. package/packages/client-agent-team/lib/types/client/locales.d.ts +36 -16
  64. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  65. package/packages/client-agent-team/lib/types/client/locales.js +36 -16
  66. package/packages/client-agent-team/lib/types/client/slots.d.ts +0 -1
  67. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  68. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +7 -1
  69. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  70. package/packages/client-agent-team/lib/types/client/team-changes.js +62 -31
  71. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +38 -5
  72. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  73. package/packages/client-agent-team/lib/types/client/team-formatters.js +54 -7
  74. package/packages/tool-agent-team/README.md +2 -2
  75. package/packages/tool-agent-team/README.zh.md +2 -2
  76. package/packages/tool-agent-team/lib/index.js +29 -16
  77. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  78. package/packages/agent-team/lib/progress-nudge.js +0 -328
  79. package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
  80. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
  81. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
  82. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
  83. 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() {
@@ -21,26 +28,32 @@ function emptyProjection() {
21
28
  messagesByRef: new Map(),
22
29
  attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
23
30
  anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
31
+ threadsByWriter: new Map(),
24
32
  directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
25
33
  }
26
- const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
27
- progress: Object.freeze([]),
28
- claim: Object.freeze([]),
29
- });
30
34
  /** Exhaustiveness guard for the closed operation union: adding a kind must update every dispatch. */
31
35
  function assertUnhandledKind(operation) {
32
36
  throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
33
37
  }
34
38
  /**
35
- * Opening-line preview for a direct-only Human Inbox row: the Thread anchor's
36
- * first line, trimmed, then capped at 120 characters with an explicit mark —
37
- * the same bound the Thread page applies to its Task title, so the row and
38
- * the page it opens never disagree about what a Thread is about.
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.
39
43
  */
40
44
  function boundedInboxPreview(body) {
41
45
  const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
42
46
  return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
43
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;
44
57
  /**
45
58
  * Whether one operation's Inbox delta would change nothing. A read that finds
46
59
  * no unread fact for its reader and no marker to consume produces exactly this
@@ -439,80 +452,6 @@ export class AgentTeamLedger {
439
452
  }
440
453
  return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
441
454
  }
442
- /**
443
- * Current progress-nudge candidacy for one Member: threads whose Thread
444
- * progress reminder applies (active Claim, or following a taskless Thread)
445
- * and tasks where the Claim reminder applies (following a still-`todo` Task
446
- * with no Claim history for this Member). A deep read-only projection — the
447
- * caller never re-derives attention, claim, task-resolution, or channel
448
- * archive state. Archived channels and resolved tasks produce no candidacy;
449
- * results are ordered by Thread revision descending, then threadRef.
450
- */
451
- progressNudgeTargets(memberId) {
452
- const member = this.state.members.get(memberId);
453
- if (member === undefined || member.state !== 'enabled')
454
- return EMPTY_PROGRESS_NUDGE_TARGETS;
455
- const progress = [];
456
- const claim = [];
457
- const seenProgress = new Set();
458
- for (const claimRecord of this.state.claims.values()) {
459
- if (claimRecord.owner !== memberId || claimRecord.state !== 'active')
460
- continue;
461
- const task = this.state.tasks.get(claimRecord.taskRef);
462
- const thread = this.state.threads.get(claimRecord.threadRef);
463
- if (task === undefined || thread === undefined || task.resolution !== 'open')
464
- continue;
465
- if (this.state.channels.get(task.channelRef)?.state === 'archived')
466
- continue;
467
- if (seenProgress.has(claimRecord.threadRef))
468
- continue;
469
- seenProgress.add(claimRecord.threadRef);
470
- progress.push({ reason: 'active-claim', threadRef: claimRecord.threadRef, taskRef: claimRecord.taskRef });
471
- }
472
- for (const [threadRef, followers] of this.state.attentionByThread) {
473
- if (!followers.has(memberId))
474
- continue;
475
- const thread = this.state.threads.get(threadRef);
476
- if (thread === undefined)
477
- continue;
478
- const channelRef = this.state.channelRefByThread.get(threadRef);
479
- if (channelRef === undefined || this.state.channels.get(channelRef)?.state === 'archived')
480
- continue;
481
- if (thread.taskRef === undefined) {
482
- if (!seenProgress.has(threadRef)) {
483
- seenProgress.add(threadRef);
484
- progress.push({ reason: 'taskless-follower', threadRef });
485
- }
486
- continue;
487
- }
488
- // Claim reminder: only still-`todo` tasks with zero lifetime claims by
489
- // this Member; once claimed (any state) the Member moved into the
490
- // progress-reminder regime.
491
- if (this.hasLifetimeClaimOnTask(memberId, thread.taskRef))
492
- continue;
493
- const task = this.state.tasks.get(thread.taskRef);
494
- if (task === undefined || task.resolution !== 'open' || this.deriveTaskStatus(task.taskRef, this.state.claims.values()) !== 'todo')
495
- continue;
496
- claim.push({ threadRef, taskRef: thread.taskRef });
497
- }
498
- const byThreadRevision = (left, right) => {
499
- const leftRevision = this.state.threads.get(left.threadRef)?.revision ?? 0;
500
- const rightRevision = this.state.threads.get(right.threadRef)?.revision ?? 0;
501
- return rightRevision - leftRevision || (left.threadRef < right.threadRef ? -1 : left.threadRef > right.threadRef ? 1 : 0);
502
- };
503
- return Object.freeze({
504
- progress: Object.freeze(progress.sort(byThreadRevision)),
505
- claim: Object.freeze(claim.sort(byThreadRevision)),
506
- });
507
- }
508
- /** Whether this Member has ever held a Claim on the Task, in any state. */
509
- hasLifetimeClaimOnTask(memberId, taskRef) {
510
- for (const claim of this.state.claims.values()) {
511
- if (claim.owner === memberId && claim.taskRef === taskRef)
512
- return true;
513
- }
514
- return false;
515
- }
516
455
  listMembers() {
517
456
  return Object.freeze([...this.state.members.values()]);
518
457
  }
@@ -620,12 +559,6 @@ export class AgentTeamLedger {
620
559
  }
621
560
  sendMessage(request) {
622
561
  return this.enqueue(async () => {
623
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
624
- const existing = this.state.byRequest.get(request.requestId);
625
- if (existing !== undefined) {
626
- this.assertSameMessage(existing, request, recipients);
627
- return this.resolved(this.messageResult(existing));
628
- }
629
562
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
630
563
  const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
631
564
  if (actor.kind === 'member')
@@ -633,6 +566,15 @@ export class AgentTeamLedger {
633
566
  const body = request.body.trim();
634
567
  if (body === '')
635
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
+ }
636
578
  this.assertMentionTargets(channel, recipients);
637
579
  // Top-level Task creation is open to every actor: mentioned Members join
638
580
  // the new Thread as followers. Only existing-Thread invitations stay
@@ -667,32 +609,38 @@ export class AgentTeamLedger {
667
609
  }
668
610
  reply(request) {
669
611
  return this.enqueue(async () => {
670
- const recipients = this.normalizeRecipients(request.actor, request.recipients);
671
- const existing = this.state.byRequest.get(request.requestId);
672
- if (existing !== undefined) {
673
- this.assertSameReply(existing, request, recipients);
674
- return this.resolved(this.replyResult(existing));
675
- }
676
612
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
677
613
  const { task, thread, channelRef } = this.threadContextForActor(actor, request.workspaceId, request);
678
614
  const body = request.body.trim();
679
615
  if (body === '')
680
616
  throw new Error('message body must not be empty');
681
- 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);
682
625
  const deferred = this.deferredThreadWrite(actor.memberId, task, thread, request.baseRevision);
683
626
  if (deferred !== undefined)
684
627
  return this.resolved(deferred);
685
628
  if (task?.resolution === 'closed')
686
629
  throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
687
- const unfollowedAgents = recipients.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
688
- if (unfollowedAgents.length > 0 && actor.kind === 'member') {
689
- return this.resolved(this.memberNotFollowing(request.workspaceId, channelRef, unfollowedAgents, task, thread));
690
- }
691
- if (unfollowedAgents.length > 0 && request.confirmationToken === undefined) {
692
- 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));
693
641
  }
694
642
  if (request.confirmationToken !== undefined) {
695
- 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);
696
644
  }
697
645
  const sequence = this.nextSequence();
698
646
  const base = this.operationBase(request, sequence);
@@ -704,15 +652,15 @@ export class AgentTeamLedger {
704
652
  });
705
653
  const nextThread = Object.freeze({ ...thread, revision: sequence });
706
654
  const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
707
- const inbox = this.messageInboxDelta(message, request.actor.memberId, recipients, started);
655
+ const inbox = this.messageInboxDelta(message, request.actor.memberId, delivered, started);
708
656
  const operation = Object.freeze({
709
657
  ...base, kind: 'team/thread-replied',
710
658
  data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
711
- mentions: recipients, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
659
+ mentions: delivered, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
712
660
  });
713
661
  await this.table.put(operation.operationId, operation);
714
662
  this.apply(operation);
715
- return this.committed(this.replyResult(operation));
663
+ return this.committed(this.replyResult(operation, undelivered));
716
664
  });
717
665
  }
718
666
  promoteThread(request) {
@@ -1085,16 +1033,21 @@ export class AgentTeamLedger {
1085
1033
  const limit = request.limit ?? 50;
1086
1034
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1087
1035
  throw new Error('inbox limit must be an integer between 1 and 100');
1088
- // The Human direct-only slice serves the Client's mention queue: rows show
1089
- // only Threads with an unread mention, `totalUnreadCount` collapses to the
1090
- // direct total so a badge on this call cannot bypass follow unread, and
1091
- // each row carries its Channel display name plus the Thread's opening line
1092
- // so the Client renders without a Channel view per row. The ordinary
1093
- // (agent-facing) projection stays body-free.
1094
- const directOnly = request.directOnly === true;
1095
- const taskNumbers = directOnly ? this.taskNumbers(request.workspaceId) : undefined;
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);
1096
1048
  const items = [];
1097
- for (const threadRef of this.inboxCandidateThreads(authorized.memberId, directOnly)) {
1049
+ const unreadThreads = new Set();
1050
+ for (const threadRef of this.inboxCandidateThreads(authorized.memberId)) {
1098
1051
  const thread = this.state.threads.get(threadRef);
1099
1052
  if (thread === undefined)
1100
1053
  continue;
@@ -1108,42 +1061,118 @@ export class AgentTeamLedger {
1108
1061
  const unread = this.unreadFor(authorized.memberId, thread.threadRef);
1109
1062
  if (unread.length === 0)
1110
1063
  continue;
1064
+ unreadThreads.add(thread.threadRef);
1111
1065
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1112
1066
  const directCount = unread.filter(item => item.direct).length;
1113
- if (directOnly && directCount === 0)
1114
- continue;
1115
1067
  const attention = this.attentionFor(authorized.memberId, thread.threadRef);
1116
1068
  // Same snapshot, same source as newestSequence: the instant hangs off
1117
1069
  // the newest unread fact itself, never a second lookup that could
1118
1070
  // observe a different commit between the two reads.
1119
1071
  const newest = unread.at(-1).fact;
1120
- const taskNumber = directOnly && task !== undefined ? taskNumbers?.get(task.taskRef) : undefined;
1072
+ const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
1121
1073
  items.push(Object.freeze({ channelRef,
1122
- ...(directOnly ? { channelName: this.state.channels.get(channelRef)?.name ?? '' } : {}),
1074
+ channelName: this.state.channels.get(channelRef)?.name ?? '',
1123
1075
  ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1124
1076
  unreadCount: unread.length, directCount,
1125
- ...(directOnly ? { previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body) } : {}),
1126
- newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
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 }) }));
1127
1081
  }
1128
1082
  items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1129
1083
  const selected = items.slice(0, limit);
1130
- return Object.freeze({ items: Object.freeze(selected),
1131
- totalUnreadCount: directOnly
1132
- ? items.reduce((sum, item) => sum + item.directCount, 0)
1133
- : items.reduce((sum, item) => sum + item.unreadCount, 0),
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,
1088
+ totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
1134
1089
  totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
1135
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
+ }
1136
1169
  /**
1137
1170
  * Threads that can hold unread facts for one reader, from the per-reader
1138
1171
  * derived indexes: Attention follows plus direct and activity markers.
1139
1172
  * Every unread source is covered — ordinary unread requires Attention, and
1140
- * marker unread requires a marker — so no full Thread scan is needed. The
1141
- * direct-only slice needs only the direct-marker threads, since a row
1142
- * requires at least one direct unread.
1173
+ * marker unread requires a marker — so no full Thread scan is needed.
1143
1174
  */
1144
- inboxCandidateThreads(memberId, directOnly) {
1145
- if (directOnly)
1146
- return this.state.directMarkersByMember.get(memberId)?.keys() ?? [];
1175
+ inboxCandidateThreads(memberId) {
1147
1176
  const refs = new Set();
1148
1177
  for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
1149
1178
  for (const threadRef of source?.keys() ?? [])
@@ -1839,7 +1868,10 @@ export class AgentTeamLedger {
1839
1868
  if (isThreadReadSnapshot(data)) {
1840
1869
  // The pre-receipt form froze the whole picture, so its expected value
1841
1870
  // is re-derived the same way it was written: from this record's prior
1842
- // projection only, never from the live one.
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.
1843
1875
  const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
1844
1876
  const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
1845
1877
  ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
@@ -2196,7 +2228,7 @@ export class AgentTeamLedger {
2196
2228
  // Top-level mentions are open to every actor; only a Member reply may not
2197
2229
  // pull an unfollowed Member into an existing Thread.
2198
2230
  if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
2199
- && mentionedAgents.some(memberId => !this.isFollowingFrom(projection, message.threadRef, memberId))) {
2231
+ && mentionedAgents.some(memberId => !this.everParticipatedFrom(projection, message.threadRef, memberId))) {
2200
2232
  throw new Error('invalid Agent mention projection');
2201
2233
  }
2202
2234
  const started = operation.kind === 'team/message-sent'
@@ -2420,6 +2452,11 @@ export class AgentTeamLedger {
2420
2452
  // The ref index is written in this same step, so it is exactly the set of
2421
2453
  // replayed Messages: a validator lookup can never reach a later record.
2422
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);
2423
2460
  }
2424
2461
  appendActivityFact(target, activity, occurredAt) {
2425
2462
  const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
@@ -2596,9 +2633,21 @@ export class AgentTeamLedger {
2596
2633
  const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
2597
2634
  .sort((left, right) => left.fact.sequence - right.fact.sequence);
2598
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;
2599
2648
  return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2600
2649
  anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2601
- facts: Object.freeze(combined), remainingUnreadCount });
2650
+ facts: Object.freeze(combined), remainingUnreadCount, earlierFactCount });
2602
2651
  }
2603
2652
  readFactFrom(projection, memberId, fact, unread) {
2604
2653
  return Object.freeze({ fact, unread, direct: fact.kind === 'message'
@@ -2854,10 +2903,6 @@ export class AgentTeamLedger {
2854
2903
  }
2855
2904
  }
2856
2905
  }
2857
- memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
2858
- return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
2859
- ...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
2860
- }
2861
2906
  unreadRequired(task, thread, unread) {
2862
2907
  return Object.freeze({ kind: 'unread_required', ...(task === undefined ? {} : { taskRef: task.taskRef }), threadRef: thread.threadRef,
2863
2908
  revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
@@ -2917,10 +2962,11 @@ export class AgentTeamLedger {
2917
2962
  return undefined;
2918
2963
  }
2919
2964
  /** Commit projections shared by the Message-sent and Thread-replied results. */
2920
- committedMessageResult(operation) {
2965
+ committedMessageResult(operation, undelivered = []) {
2921
2966
  return { kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
2922
2967
  ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread, attention: operation.data.inbox.attention.set,
2923
- directMarkers: operation.data.inbox.directMarkers.added };
2968
+ directMarkers: operation.data.inbox.directMarkers.added,
2969
+ ...(undelivered.length === 0 ? {} : { undeliveredMentions: undelivered }) };
2924
2970
  }
2925
2971
  threadAnchor(threadRef) {
2926
2972
  return this.threadAnchorFrom(this.state, threadRef);
@@ -3193,6 +3239,68 @@ export class AgentTeamLedger {
3193
3239
  throw new Error(`${label} contains duplicate Member refs`);
3194
3240
  return Object.freeze([...unique].sort());
3195
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
+ }
3196
3304
  normalizeDirection(direction) {
3197
3305
  return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
3198
3306
  }
@@ -3463,8 +3571,8 @@ export class AgentTeamLedger {
3463
3571
  messageResult(operation) {
3464
3572
  return Object.freeze(this.committedMessageResult(operation));
3465
3573
  }
3466
- replyResult(operation) {
3467
- return Object.freeze(this.committedMessageResult(operation));
3574
+ replyResult(operation, undelivered = []) {
3575
+ return Object.freeze(this.committedMessageResult(operation, undelivered));
3468
3576
  }
3469
3577
  claimResult(operation) {
3470
3578
  const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
@@ -3494,6 +3602,7 @@ export class AgentTeamLedger {
3494
3602
  return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
3495
3603
  claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
3496
3604
  readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
3605
+ earlierFactCount: prepared.earlierFactCount,
3497
3606
  ...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
3498
3607
  consumedDirectMarkers: prepared.inbox.directMarkers.removed });
3499
3608
  }