@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.
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/package.json +3 -2
- package/packages/agent-team/README.md +2 -2
- package/packages/agent-team/README.zh.md +2 -2
- package/packages/agent-team/lib/context-projection.js +5 -1
- package/packages/agent-team/lib/index.js +11 -44
- package/packages/agent-team/lib/ledger.js +251 -142
- package/packages/agent-team/lib/member-runtime.js +47 -9
- package/packages/agent-team/lib/mentions.js +56 -0
- package/packages/agent-team/lib/spec.js +1 -0
- package/packages/agent-team/lib/typert.host.js +80 -31
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +71 -26
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
- package/packages/agent-team/lib/types/index.d.ts +1 -7
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +75 -17
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/mentions.d.ts +34 -0
- package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/entities.d.ts +0 -18
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +57 -15
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +48 -8
- package/packages/client-agent-team/lib/client.js +1366 -909
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +3 -3
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +125 -13
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +10 -3
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamComposer.js +11 -17
- package/packages/client-agent-team/lib/types/client/TeamConversation.js +1 -1
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +9 -5
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +82 -14
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +6 -2
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +28 -10
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +3 -4
- package/packages/client-agent-team/lib/types/client/locales.d.ts +36 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +36 -16
- package/packages/client-agent-team/lib/types/client/slots.d.ts +0 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts +7 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.js +62 -31
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +38 -5
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-formatters.js +54 -7
- package/packages/tool-agent-team/README.md +2 -2
- package/packages/tool-agent-team/README.zh.md +2 -2
- package/packages/tool-agent-team/lib/index.js +29 -16
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/progress-nudge.js +0 -328
- package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
- 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:
|
|
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
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
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.
|
|
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.
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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 =
|
|
1072
|
+
const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
|
|
1121
1073
|
items.push(Object.freeze({ channelRef,
|
|
1122
|
-
|
|
1074
|
+
channelName: this.state.channels.get(channelRef)?.name ?? '',
|
|
1123
1075
|
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1124
1076
|
unreadCount: unread.length, directCount,
|
|
1125
|
-
|
|
1126
|
-
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
}
|