pi-roundtable 0.8.0 → 0.9.0

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 (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,3 +1,5 @@
1
+ import type { Pronouns } from "./config/config.ts";
2
+
1
3
  /** Who the agents work for, as prompts and tool descriptions name them. */
2
4
  export interface OwnerIdentity {
3
5
  name: string;
@@ -7,11 +9,35 @@ export interface OwnerIdentity {
7
9
 
8
10
  const capital = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
9
11
 
10
- /** The owner's name and pronouns as prompt text uses them, capitalized for a sentence's start. */
11
- export function ownerWords(owner: OwnerIdentity): OwnerWords {
12
- const { subject, object, possessive } = owner.pronouns;
12
+ /** Each pronoun choice in the words prompts use. */
13
+ export const PRONOUNS: Readonly<Record<Pronouns, OwnerIdentity["pronouns"]>> = {
14
+ he: { subject: "he", object: "him", possessive: "his" },
15
+ she: { subject: "she", object: "her", possessive: "her" },
16
+ they: { subject: "they", object: "them", possessive: "their" },
17
+ };
18
+
19
+ /**
20
+ * How prompts name a person: by their pronouns when they gave them, and otherwise by their name,
21
+ * since a name needs no guess at pronouns.
22
+ */
23
+ export function addresseeOf(person: {
24
+ displayName: string;
25
+ pronouns?: Pronouns;
26
+ }): OwnerIdentity {
27
+ const name = person.displayName;
13
28
  return {
14
- name: owner.name,
29
+ name,
30
+ pronouns: person.pronouns
31
+ ? PRONOUNS[person.pronouns]
32
+ : { subject: name, object: name, possessive: `${name}'s` },
33
+ };
34
+ }
35
+
36
+ /** The name and pronouns of whom prompt text addresses, capitalized for a sentence's start. */
37
+ export function addresseeWords(addressee: OwnerIdentity): OwnerWords {
38
+ const { subject, object, possessive } = addressee.pronouns;
39
+ return {
40
+ name: addressee.name,
15
41
  he: subject,
16
42
  He: capital(subject),
17
43
  him: object,
@@ -20,7 +46,10 @@ export function ownerWords(owner: OwnerIdentity): OwnerWords {
20
46
  };
21
47
  }
22
48
 
23
- /** The owner's words as `ownerWords` gives them. */
49
+ /** `addresseeWords`, by its name from when every prompt addressed the owner. */
50
+ export const ownerWords = addresseeWords;
51
+
52
+ /** The words of whom prompt text addresses, as `addresseeWords` gives them. */
24
53
  export interface OwnerWords {
25
54
  name: string;
26
55
  he: string;
@@ -0,0 +1,127 @@
1
+ import type { ConversationVisibility } from "../conversations/conversation-registry.ts";
2
+ import type { TurnRequest } from "../domain/ports.ts";
3
+ import type { Speaker, Tier } from "../speakers.ts";
4
+
5
+ /**
6
+ * How a prompt ended: answered yes or no, unanswered until it expired, or cancelled when its turn
7
+ * stopped.
8
+ */
9
+ export type Approval = "approved" | "declined" | "expired" | "cancelled";
10
+
11
+ export interface AskOption {
12
+ label: string;
13
+ description?: string;
14
+ }
15
+
16
+ export interface OwnerQuestion {
17
+ question: string;
18
+ /** Up to 25; none means a free-text answer. */
19
+ options: readonly AskOption[];
20
+ /** More than one option may be chosen. */
21
+ multi: boolean;
22
+ /** They may write an answer of their own besides the options. */
23
+ allowOther: boolean;
24
+ }
25
+
26
+ export interface OwnerAnswer {
27
+ /** The chosen options' labels, in the order they were offered. */
28
+ choices: string[];
29
+ /** What they wrote themselves, if anything. */
30
+ text?: string;
31
+ }
32
+
33
+ /**
34
+ * Who may answer a running turn's prompts: the person whose turn asks, and, in a conversation
35
+ * others share, the owners. The runtime makes it from the turn's speaker and the conversation's
36
+ * visibility with `promptScope`.
37
+ */
38
+ export interface PromptScope {
39
+ /**
40
+ * The principal whose turn asks. They are the person who answers on any of their identities:
41
+ * a surface accepts an answer from each identity it knows of theirs, and, where it has none of
42
+ * theirs, the prompt is one their tier would not reach.
43
+ */
44
+ principalId: string;
45
+ /**
46
+ * The identity that spoke, as `Speaker.id`: whom a surface addresses the prompt to when it is
47
+ * one of its own, or else every identity it knows of the principal. A background turn's may be
48
+ * no surface's at all.
49
+ */
50
+ speakerId: string;
51
+ /**
52
+ * Their tier when the turn asked; an approval above it is not theirs, and the surface checks it
53
+ * again when they answer where it can. An owner-tier approval is theirs only when the principal
54
+ * is an owner: a turn's tier may default to owner without its person being one.
55
+ */
56
+ tier: Tier;
57
+ /**
58
+ * Who else may answer: `owners` in a shared conversation, where an approval above the
59
+ * speaker's tier goes to the owners; `none` in a private one, which no one else sees, so such
60
+ * an approval expires at once.
61
+ */
62
+ escalate: "owners" | "none";
63
+ }
64
+
65
+ /**
66
+ * Prompts inside a running turn, on its conversation's surface: an approval of a held action and an
67
+ * `ask_user` question, answered by those its `PromptScope` names. Unanswered prompts expire; a
68
+ * stopped turn cancels its open ones.
69
+ */
70
+ export interface Prompts {
71
+ /**
72
+ * Asks to approve an action; `signal` cancels the prompt when the turn stops. `minTier` is the
73
+ * tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
74
+ * prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
75
+ */
76
+ confirm(
77
+ title: string,
78
+ message: string,
79
+ signal?: AbortSignal,
80
+ minTier?: Tier,
81
+ ): Promise<Approval>;
82
+ /** Asks a question; undefined when it expired or was cancelled. */
83
+ ask(
84
+ title: string,
85
+ question: OwnerQuestion,
86
+ signal?: AbortSignal,
87
+ ): Promise<OwnerAnswer | undefined>;
88
+ }
89
+
90
+ /** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
91
+ export type OwnerPrompts = Prompts;
92
+
93
+ /**
94
+ * The scope of a turn's prompts: the speaker's own, escalating to the owners unless the
95
+ * conversation is private to one principal.
96
+ */
97
+ export function promptScope(
98
+ speaker: Speaker,
99
+ visibility: ConversationVisibility = "shared",
100
+ ): PromptScope {
101
+ return {
102
+ principalId: speaker.principalId,
103
+ speakerId: speaker.id,
104
+ tier: speaker.tier,
105
+ escalate: visibility === "private" ? "none" : "owners",
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The scope of a turn's prompts, from its speaker and its conversation, shared when the request
111
+ * names none; undefined for a turn without a speaker, whose prompts are the owners'.
112
+ */
113
+ export function promptScopeOf(
114
+ request: Pick<TurnRequest, "speaker" | "conversation">,
115
+ ): PromptScope | undefined {
116
+ return (
117
+ request.speaker &&
118
+ promptScope(request.speaker, request.conversation?.visibility)
119
+ );
120
+ }
121
+
122
+ /** Whether a `prompts` argument is a scope, not the 0.8 speaker that the port still takes. */
123
+ export function isPromptScope(
124
+ value: PromptScope | Speaker,
125
+ ): value is PromptScope {
126
+ return "escalate" in value;
127
+ }
@@ -1,10 +1,12 @@
1
- import { OWNER_TARGET } from "../../agents/agent-claim.ts";
2
1
  import type {
2
+ BackgroundRunsAs,
3
+ BackgroundTurn,
3
4
  ConversationPort,
4
5
  ScheduledOutcome,
5
6
  } from "../../contract/channels.ts";
6
7
  import type { ChannelKey } from "../../domain/conversation.ts";
7
8
  import type { Logger } from "../../log.ts";
9
+ import { systemTurn } from "../../routing/system-turns.ts";
8
10
  import type { BackgroundTurns } from "../../services.ts";
9
11
  import { zonedStamp } from "../../time.ts";
10
12
  import {
@@ -15,11 +17,17 @@ import {
15
17
  import type { PrecheckFinding } from "../schedules/prechecks.ts";
16
18
  import type { Schedule } from "../schedules/schedule-store.ts";
17
19
  import { scheduledTurnText } from "../schedules/schedule-tools.ts";
20
+ import { PERSONAL_TARGET } from "./personal-target.ts";
18
21
 
19
22
  export interface BackgroundTurnsOptions {
20
- conversations: Pick<ConversationPort, "background">;
23
+ conversations: Pick<ConversationPort, "background" | "runsAs">;
21
24
  /** Who a turn the process itself starts, such as a logged error's report, is written by. */
22
25
  system: { id: string; name: string };
26
+ /**
27
+ * The principal a schedule's creator id stands for, such as the primary owner for 0.8's
28
+ * `remote-mcp`; without it, or when it knows none, the id is taken as the principal's.
29
+ */
30
+ principalOf?: (id: string) => Promise<string | undefined>;
23
31
  logger: Logger;
24
32
  }
25
33
 
@@ -35,19 +43,40 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
35
43
  }
36
44
 
37
45
  /** A due schedule's turn, run as its creator's; with what its precheck found, when it has one. */
38
- runScheduled(
46
+ async runScheduled(
39
47
  schedule: Schedule,
40
48
  firedAt: Date,
41
49
  finding?: PrecheckFinding,
42
50
  ): Promise<ScheduledOutcome> {
43
- return this.#options.conversations.background({
51
+ return this.#options.conversations.background(
52
+ await this.#scheduled(schedule, firedAt, finding),
53
+ );
54
+ }
55
+
56
+ /** Who a due schedule's turn would run as now, checked as the turn is. */
57
+ async runsAs(schedule: Schedule): Promise<BackgroundRunsAs> {
58
+ return this.#options.conversations.runsAs(
59
+ await this.#scheduled(schedule, new Date()),
60
+ );
61
+ }
62
+
63
+ /** A due schedule's turn, by its creator: the principal their id stands for, at the schedule's tier. */
64
+ async #scheduled(
65
+ schedule: Schedule,
66
+ firedAt: Date,
67
+ finding?: PrecheckFinding,
68
+ ): Promise<BackgroundTurn> {
69
+ const { createdById } = schedule;
70
+ const principalId =
71
+ (await this.#options.principalOf?.(createdById)) ?? createdById;
72
+ return {
44
73
  channel: schedule.channel,
45
74
  target: schedule.target,
46
- author: { id: schedule.createdById, name: schedule.createdByName },
75
+ author: { principalId, id: createdById, name: schedule.createdByName },
47
76
  tier: schedule.createdTier,
48
77
  turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
49
78
  text: scheduledTurnText(schedule, firedAt, finding),
50
- });
79
+ };
51
80
  }
52
81
 
53
82
  /** A delegated task's report, answered in its channel under the same rules as a schedule. */
@@ -58,8 +87,12 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
58
87
  const outcome = await this.#options.conversations.background({
59
88
  channel: job.channel,
60
89
  target: job.target,
61
- author: job.author,
62
- ...(job.author.tier ? { tier: job.author.tier } : {}),
90
+ author: {
91
+ principalId: job.author.principalId,
92
+ id: job.author.id,
93
+ name: job.author.name,
94
+ },
95
+ tier: job.author.tier,
63
96
  turnId: `delegate-${job.id}-${job.startedAt.getTime()}`,
64
97
  text: delegatedTurnText(job, result, zonedStamp(job.startedAt)),
65
98
  report: true,
@@ -71,15 +104,21 @@ export class ConversationBackgroundTurns implements BackgroundTurns {
71
104
  );
72
105
  }
73
106
 
74
- /** The process's own logged error, reported to an agent in its channel as a report turn. */
107
+ /**
108
+ * The process's own logged error, reported to an agent in its channel as a report turn: the
109
+ * host's own, at the owner tier, as in 0.8.
110
+ */
75
111
  runErrorReport(channel: ChannelKey, text: string): Promise<ScheduledOutcome> {
76
- return this.#options.conversations.background({
77
- channel,
78
- target: OWNER_TARGET.name,
79
- author: this.#options.system,
80
- turnId: `error-${Date.now()}`,
81
- text,
82
- report: true,
83
- });
112
+ return this.#options.conversations.background(
113
+ systemTurn({
114
+ channel,
115
+ target: PERSONAL_TARGET.name,
116
+ author: this.#options.system,
117
+ tier: "owner",
118
+ turnId: `error-${Date.now()}`,
119
+ text,
120
+ report: true,
121
+ }),
122
+ );
84
123
  }
85
124
  }
@@ -0,0 +1,50 @@
1
+ import type { BackgroundTarget } from "../../contract/channels.ts";
2
+ import { messages } from "../../i18n/index.ts";
3
+
4
+ /**
5
+ * The host's own background target: every person's conversations whose claim answers background
6
+ * turns, such as the owner's and the agent server's on Discord. The modules plugin contributes it
7
+ * on every host, so schedules and delegated tasks made there carry its name and limits; its name
8
+ * stays "owner", as 0.8 stored it. Its label is read when a list is shown, never when this module
9
+ * loads, because the host applies its catalog at startup.
10
+ */
11
+ export const PERSONAL_TARGET: BackgroundTarget = {
12
+ name: "owner",
13
+ label: () => messages().scheduleModeOwner,
14
+ schedules: { perChannel: 20, promptChars: 8_000, aheadDays: 366 },
15
+ delegation: { maxRunning: 3 },
16
+ };
17
+
18
+ /** How many schedules, and running delegated tasks, one person may have across their conversations. */
19
+ export interface PerPrincipalLimits {
20
+ schedules?: number;
21
+ delegations?: number;
22
+ }
23
+
24
+ /**
25
+ * `PERSONAL_TARGET` holding each person to `limits` across all their conversations: as many
26
+ * schedules (`perPrincipal`) and running delegated tasks (`maxRunningPerPrincipal`) as given; a
27
+ * limit left out is unset, as in `PERSONAL_TARGET`.
28
+ */
29
+ export function personalTarget(
30
+ limits: PerPrincipalLimits = {},
31
+ ): BackgroundTarget {
32
+ const { schedules, delegation } = PERSONAL_TARGET;
33
+ if (!schedules || !delegation)
34
+ throw new Error("the personal target schedules and delegates");
35
+ return {
36
+ ...PERSONAL_TARGET,
37
+ schedules: {
38
+ ...schedules,
39
+ ...(limits.schedules === undefined
40
+ ? {}
41
+ : { perPrincipal: limits.schedules }),
42
+ },
43
+ delegation: {
44
+ ...delegation,
45
+ ...(limits.delegations === undefined
46
+ ? {}
47
+ : { maxRunningPerPrincipal: limits.delegations }),
48
+ },
49
+ };
50
+ }
@@ -1,28 +1,27 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
- import { OWNER_TARGET } from "../../agents/agent-claim.ts";
3
2
  import type { ChannelKey } from "../../domain/conversation.ts";
4
3
  import { DelegationError } from "../../domain/errors.ts";
5
- import type { OwnerIdentity } from "../../identity.ts";
4
+ import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
6
5
  import { textToolsExtension } from "../../runtime/text-tools.ts";
7
6
  import type { Delegator } from "../../services.ts";
8
7
  import { DELEGATE_TOOL_SPEC } from "../../shared/delegate-tool.ts";
9
8
  import type { Speaker } from "../../speakers.ts";
9
+ import { PERSONAL_TARGET } from "../background/personal-target.ts";
10
10
 
11
11
  export interface OwnerDelegation {
12
12
  delegator: Pick<Delegator, "start">;
13
- owner: { id: string; name: string };
14
- /** The chat channel a conversation's reports go to, as for schedules. */
15
- channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
13
+ /** The chat channel a conversation's reports go to, as for schedules, asked with the asker's principal. */
14
+ channelFor: (channel: ChannelKey, principalId: string) => Promise<ChannelKey>;
16
15
  }
17
16
 
18
17
  /**
19
- * Registers delegate_task for one owner conversation. `origin` is the channel its turns run
20
- * in, where each job's thread opens: a group's, for an agent's seat in one.
18
+ * Registers delegate_task for one conversation; a job reports as the person whose turn started
19
+ * it, and in a turn nobody is named for, or the host's own, the tool refuses. `origin` is the
20
+ * channel its turns run in, where each job's thread opens: a group's, for an agent's seat in one.
21
21
  */
22
22
  export function delegateExtension(
23
23
  delegation: OwnerDelegation,
24
24
  channel: ChannelKey,
25
- identity: OwnerIdentity,
26
25
  origin: ChannelKey = channel,
27
26
  /** The person the running turn is for; the report comes back at their tier. */
28
27
  speaker: () => Speaker | undefined = () => undefined,
@@ -32,20 +31,35 @@ export function delegateExtension(
32
31
  {
33
32
  ...DELEGATE_TOOL_SPEC,
34
33
  run: async (input) => {
34
+ const author = speaker();
35
+ if (!author)
36
+ throw new DelegationError(
37
+ "a task is delegated only in a turn someone is named for, whose report it is",
38
+ );
39
+ if (author.principalId === SYSTEM_PRINCIPAL)
40
+ throw new DelegationError(
41
+ "the host's own turns, such as a report's, delegate no tasks; ask the owner to",
42
+ );
35
43
  const { title, task } = input as { title: string; task: string };
36
- const target = await delegation.channelFor(channel);
44
+ const target = await delegation.channelFor(
45
+ channel,
46
+ author.principalId,
47
+ );
37
48
  const job = delegation.delegator.start({
38
49
  channel: target,
39
50
  origin,
40
- target: OWNER_TARGET.name,
41
- author: speaker() ?? delegation.owner,
51
+ target: PERSONAL_TARGET.name,
52
+ author: {
53
+ principalId: author.principalId,
54
+ id: author.id,
55
+ name: author.name,
56
+ tier: author.tier,
57
+ },
42
58
  title,
43
59
  task,
44
60
  });
45
61
  const where =
46
- target === channel
47
- ? "here"
48
- : `in ${identity.name}'s Discord direct messages`;
62
+ target === channel ? "here" : `in ${author.name}'s direct messages`;
49
63
  return `Delegated as task #${job.id}; the report comes back ${where} as a new turn.`;
50
64
  },
51
65
  },
@@ -19,8 +19,11 @@ export interface DelegationJob {
19
19
  origin?: ChannelKey;
20
20
  /** The name of the background target the report is answered as. */
21
21
  target: string;
22
- /** Who asked; the report is answered as this person's turn. */
23
- author: { id: string; name: string; tier?: Tier };
22
+ /**
23
+ * Who asked: their principal, the id and name they spoke as, and their tier; the report is
24
+ * answered as this person's turn, at that tier or theirs then, whichever is lower.
25
+ */
26
+ author: { principalId: string; id: string; name: string; tier: Tier };
24
27
  title: string;
25
28
  task: string;
26
29
  startedAt: Date;
@@ -78,18 +81,31 @@ export class DefaultDelegator implements Delegator {
78
81
  throw new DelegationError(
79
82
  `the task is ${task.length} characters; keep it within ${MAX_TASK_CHARS}`,
80
83
  );
81
- const busy = [...this.#running.values()].filter(
82
- ({ job }) => job.channel === request.channel,
84
+ const running = [...this.#running.values()].map(({ job }) => job);
85
+ const busy = running.filter(
86
+ (job) => job.channel === request.channel,
83
87
  ).length;
84
- const limit = this.#options.targets(request.target)?.delegation?.maxRunning;
85
- if (limit === undefined)
88
+ const limits = this.#options.targets(request.target)?.delegation;
89
+ if (limits === undefined)
86
90
  throw new DelegationError(
87
91
  `delegated tasks are not available for ${request.target} conversations`,
88
92
  );
89
- if (busy >= limit)
93
+ if (busy >= limits.maxRunning)
90
94
  throw new DelegationError(
91
95
  `this channel already has ${busy} delegated tasks running; wait for one to report back`,
92
96
  );
97
+ const perPrincipal = limits.maxRunningPerPrincipal;
98
+ if (perPrincipal !== undefined) {
99
+ const theirs = running.filter(
100
+ (job) =>
101
+ job.target === request.target &&
102
+ job.author.principalId === request.author.principalId,
103
+ ).length;
104
+ if (theirs >= perPrincipal)
105
+ throw new DelegationError(
106
+ `you already have ${theirs} delegated tasks running, the most one person may have here; wait for one to report back`,
107
+ );
108
+ }
93
109
  const job: DelegationJob = {
94
110
  ...request,
95
111
  title,