pi-roundtable 0.7.19 → 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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -0,0 +1,489 @@
1
+ import { ConfigError, IdentityError } from "../domain/errors.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import type { ChannelKey } from "../sessions.ts";
4
+ import { type Speaker, type Tier, tierAtLeast } from "../speakers.ts";
5
+ import {
6
+ type AccessRules,
7
+ checkAccessRules,
8
+ factsTier,
9
+ } from "./access-policy.ts";
10
+ import {
11
+ type ActorFacts,
12
+ identityOf,
13
+ parseIdentity,
14
+ surfaceOf,
15
+ } from "./actor-facts.ts";
16
+ import { CachingPrincipalStore } from "./caching-principal-store.ts";
17
+ import { configuredOwnerId } from "./config-owner.ts";
18
+ import type { Contact, ContactAssessor } from "./contact.ts";
19
+ import {
20
+ type DeclaredIdentity,
21
+ syncDeclaredIdentities,
22
+ } from "./plugin-identities.ts";
23
+ import { highest, lowest, publicOf, speakerOf } from "./principal-reads.ts";
24
+ import {
25
+ type IdentityLink,
26
+ LEGACY_PROVIDER,
27
+ type Principal,
28
+ type PrincipalRecord,
29
+ type PrincipalStore,
30
+ type RoleGrant,
31
+ SYSTEM_PRINCIPAL,
32
+ } from "./principal-store.ts";
33
+ import { SeenThrottle } from "./seen-throttle.ts";
34
+ import { isPrincipalId, newPrincipalId } from "./ulid.ts";
35
+
36
+ /**
37
+ * Who the host serves: its principals, their identities, and their roles, read-only. Provided as
38
+ * `IDENTITY` by the `identity` plugin; the principals are written by the host's own configuration
39
+ * and the `roundtable principal` CLI, never through it. A change another process makes is seen
40
+ * within half a minute.
41
+ */
42
+ export interface IdentityService {
43
+ /**
44
+ * The speaker behind these facts, or undefined when the access rules serve no one by them: a
45
+ * linked identity is its principal's; an unlinked one first claims the principal of its 0.8
46
+ * id (`legacyId`) when the backfill made it and no identity was ever linked to it, unless it
47
+ * holds the owner role, taking the facts' name when the backfill named it by its id, and is
48
+ * otherwise admitted as a new principal when the rules give it a tier. Both happen only when `provisioning` is `admitted`; under `linked` only identities
49
+ * already linked are served. A disabled principal is no one. The tier is the higher of the
50
+ * principal's lasting roles and what the rules give the facts on this contact.
51
+ */
52
+ resolve(
53
+ facts: ActorFacts,
54
+ scope?: { conversation?: ChannelKey },
55
+ ): Promise<Speaker | undefined>;
56
+ principal(id: string): Promise<Principal | undefined>;
57
+ /** Every principal, the oldest first. */
58
+ list(): Promise<readonly Principal[]>;
59
+ /** The identities linked to the principal. */
60
+ identities(principalId: string): Promise<readonly IdentityLink[]>;
61
+ /** The principal's lasting roles, granted by the configuration or the CLI. */
62
+ roles(principalId: string): Promise<readonly RoleGrant[]>;
63
+ /** The tier of the principal's lasting roles; undefined when they hold none, are disabled, or are unknown. */
64
+ tierOf(principalId: string): Promise<Tier | undefined>;
65
+ /**
66
+ * A speaker for a turn started on a principal's behalf, at `tier` or their own, whichever is
67
+ * lower. Their own is their lasting roles' tier, or, when their tier comes only from the rules
68
+ * on contact, the tier they were last seen at, refused once `access.backgroundStaleDays` pass
69
+ * unseen or once a contact of theirs is refused. Throws IdentityError for a principal that is
70
+ * unknown, disabled, or holds no tier, and for the system principal, whose turns only the core
71
+ * starts.
72
+ */
73
+ speakerFor(principalId: string, tier?: Tier): Promise<Speaker>;
74
+ /** The principals holding the owner role, the configured owners first in their order. */
75
+ owners(): Promise<readonly Principal[]>;
76
+ /**
77
+ * The id of the principal an identity, written `<provider>:<subject>`, is linked to, such as the
78
+ * one a plugin's declared identity stands for; undefined when it is linked to no one. Throws
79
+ * IdentityError for text that is no identity.
80
+ */
81
+ principalOf(identity: string): Promise<string | undefined>;
82
+ }
83
+
84
+ /** The system principal's speaker for a turn the core itself starts, such as an ops report, at the tier its starter gives. Core-internal: no plugin reaches it. */
85
+ export function systemSpeaker(tier: Tier): Speaker {
86
+ return {
87
+ id: SYSTEM_PRINCIPAL,
88
+ name: SYSTEM_PRINCIPAL,
89
+ tier,
90
+ principalId: SYSTEM_PRINCIPAL,
91
+ };
92
+ }
93
+
94
+ const DAY_MS = 24 * 60 * 60_000;
95
+ /** How many people assessed at a first contact but not yet taken are remembered. */
96
+ const PROVISIONAL_MAX = 1_000;
97
+
98
+ export interface IdentityServiceOptions {
99
+ logger: Logger;
100
+ /** The identities the plugins declare, checked by `declaredIdentities`; `syncConfig` links them. */
101
+ identities?: readonly DeclaredIdentity[];
102
+ /** The clock, in milliseconds; a test gives its own. */
103
+ now?: () => number;
104
+ }
105
+
106
+ /** The identity service over the principal store and the configured access rules. */
107
+ export class PgIdentityService implements IdentityService, ContactAssessor {
108
+ /** The stored principals, written through it seen at once by this process. Core-internal: plugins get `identityView`. */
109
+ readonly store: PrincipalStore;
110
+ readonly #rules: AccessRules;
111
+ readonly #logger: Logger;
112
+ readonly #now: () => number;
113
+ /** When each principal is written as seen. */
114
+ readonly #seen = new SeenThrottle();
115
+ /** The new principal id each person assessed at a first contact would get, until it is taken. */
116
+ readonly #provisional = new Map<string, string>();
117
+ /** The identities the plugins declare. */
118
+ readonly #declared: readonly DeclaredIdentity[];
119
+ /**
120
+ * The identities linked only at boot, as `<provider>:<subject>`: those the configuration lists
121
+ * under an owner and those the plugins declare.
122
+ */
123
+ readonly #bootIdentities: ReadonlySet<string>;
124
+ /** The configured owners' principal ids, in the configuration's order, once synced. */
125
+ #configOwners: string[] = [];
126
+
127
+ constructor(
128
+ store: PrincipalStore,
129
+ rules: AccessRules,
130
+ options: IdentityServiceOptions,
131
+ ) {
132
+ this.#now = options.now ?? Date.now;
133
+ this.store = new CachingPrincipalStore(store, this.#now);
134
+ this.#rules = checkAccessRules(rules);
135
+ this.#declared = options.identities ?? [];
136
+ this.#bootIdentities = new Set([
137
+ ...this.#rules.owners.flatMap((owner) => owner.identities),
138
+ ...this.#declared.map((declared) => declared.identity),
139
+ ]);
140
+ this.#logger = options.logger;
141
+ }
142
+
143
+ /**
144
+ * Makes the stored owners match the configuration: each configured owner's principal made or
145
+ * renamed, their identities linked, and the owner role granted, all as the configuration's;
146
+ * the configuration's owner roles and links it no longer names are removed, the CLI's kept.
147
+ * An identity of an owner that is linked to another principal stops the boot. Then the plugins'
148
+ * declared identities are linked, as `syncDeclaredIdentities` says.
149
+ */
150
+ async syncConfig(): Promise<void> {
151
+ const store = this.store;
152
+ const owners: string[] = [];
153
+ const identities = new Set<string>();
154
+ const declaredBy = new Map(
155
+ this.#declared.map((declared) => [declared.identity, declared]),
156
+ );
157
+ for (const [n, owner] of this.#rules.owners.entries()) {
158
+ const refs = owner.identities.map((identity) => {
159
+ const ref = parseIdentity(identity);
160
+ // checkAccessRules refused any identity that does not parse.
161
+ if (!ref)
162
+ throw new ConfigError(`config access.owners[${n}]: ${identity}`);
163
+ return ref;
164
+ });
165
+ const id = await configuredOwnerId(store, {
166
+ owner,
167
+ index: n,
168
+ refs,
169
+ declaredBy,
170
+ primary: owners[0],
171
+ logger: this.#logger,
172
+ });
173
+ const pronouns = owner.pronouns ?? null;
174
+ const existing = id === undefined ? undefined : await store.get(id);
175
+ const principal = existing
176
+ ? ((await store.update(existing.id, {
177
+ displayName: owner.name,
178
+ pronouns,
179
+ })) ?? existing)
180
+ : await store.create({
181
+ ...(id === undefined ? {} : { id }),
182
+ displayName: owner.name,
183
+ ...(owner.pronouns ? { pronouns: owner.pronouns } : {}),
184
+ });
185
+ for (const ref of refs) {
186
+ await store.link(principal.id, ref, "config");
187
+ identities.add(identityOf(ref));
188
+ }
189
+ await store.grant(principal.id, "owner", "config");
190
+ owners.push(principal.id);
191
+ }
192
+ for (const holder of await store.holders("owner"))
193
+ if (holder.source === "config" && !owners.includes(holder.principalId)) {
194
+ await store.revoke(holder.principalId, "owner", "config");
195
+ this.#logger.info(
196
+ `principal ${holder.principalId} is no longer a configured owner; its configured owner role was revoked`,
197
+ );
198
+ }
199
+ for (const link of await store.linksFrom("config"))
200
+ if (!identities.has(identityOf(link))) {
201
+ await store.unlink(link.provider, link.subject);
202
+ this.#logger.info(
203
+ `${identityOf(link)} is no longer a configured owner's identity; it was unlinked from principal ${link.principalId}`,
204
+ );
205
+ }
206
+ this.#configOwners = owners;
207
+ await syncDeclaredIdentities(
208
+ store,
209
+ this.#declared,
210
+ owners[0],
211
+ this.#logger,
212
+ );
213
+ }
214
+
215
+ async resolve(
216
+ facts: ActorFacts,
217
+ scope: { conversation?: ChannelKey } = {},
218
+ ): Promise<Speaker | undefined> {
219
+ return (await this.assess(facts, scope))?.take();
220
+ }
221
+
222
+ /**
223
+ * The contact of these facts, read only: a linked identity's principal, else at a first contact
224
+ * the principal they would claim or be admitted as, the same new id for the same person until
225
+ * it is taken; undefined when the rules serve no one by them. A linked person refused here is
226
+ * recorded as seen at no tier, when the facts carry what the rules decide by.
227
+ */
228
+ async assess(
229
+ facts: ActorFacts,
230
+ scope: { conversation?: ChannelKey } = {},
231
+ ): Promise<Contact | undefined> {
232
+ // A 0.8 id standing for another principal is no surface's identity.
233
+ if (facts.provider === LEGACY_PROVIDER) return undefined;
234
+ const { conversation } = scope;
235
+ const link = await this.store.identity(facts.provider, facts.subject);
236
+ let assessed: { principalId: string; tier: Tier } | undefined;
237
+ if (link) {
238
+ const principal = await this.store.get(link.principalId);
239
+ if (!principal || principal.disabled) return undefined;
240
+ const tier = await this.#tier(principal.id, facts, conversation);
241
+ if (!tier) {
242
+ // Refused now, so seen at no tier: their background turns stop with it.
243
+ if (this.#knowsRoles(facts, conversation))
244
+ await this.#touch(principal, null);
245
+ return undefined;
246
+ }
247
+ assessed = { principalId: principal.id, tier };
248
+ } else assessed = await this.#firstContact(facts, conversation);
249
+ if (!assessed) return undefined;
250
+ const speaker = speakerOf(facts, assessed.principalId, assessed.tier);
251
+ return {
252
+ speaker,
253
+ take: () => this.#take(facts, conversation, assessed.principalId),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The principal a person id a 0.8 row names stands for, such as a schedule's author: the
259
+ * principal it was attributed to at the upgrade (the primary owner for `remote-mcp`), or else
260
+ * the principal of that id; undefined for none. Core-internal.
261
+ */
262
+ async principalOfLegacyId(id: string): Promise<string | undefined> {
263
+ const alias = await this.store.identity(LEGACY_PROVIDER, id);
264
+ if (alias) return alias.principalId;
265
+ return (await this.store.get(id))?.id;
266
+ }
267
+
268
+ async principal(id: string): Promise<Principal | undefined> {
269
+ const record = await this.store.get(id);
270
+ return record && publicOf(record);
271
+ }
272
+
273
+ async list(): Promise<readonly Principal[]> {
274
+ return (await this.store.list()).map(publicOf);
275
+ }
276
+
277
+ identities(principalId: string): Promise<readonly IdentityLink[]> {
278
+ return this.store.identitiesOf(principalId);
279
+ }
280
+
281
+ roles(principalId: string): Promise<readonly RoleGrant[]> {
282
+ return this.store.rolesOf(principalId);
283
+ }
284
+
285
+ async tierOf(principalId: string): Promise<Tier | undefined> {
286
+ const principal = await this.store.get(principalId);
287
+ if (!principal || principal.disabled) return undefined;
288
+ return this.#lastingTier(principalId);
289
+ }
290
+
291
+ async speakerFor(principalId: string, tier?: Tier): Promise<Speaker> {
292
+ if (principalId === SYSTEM_PRINCIPAL)
293
+ throw new IdentityError(
294
+ `only the host itself speaks as the system principal "${SYSTEM_PRINCIPAL}"`,
295
+ );
296
+ const principal = await this.store.get(principalId);
297
+ if (!principal)
298
+ throw new IdentityError(`there is no principal ${principalId}`);
299
+ if (principal.disabled)
300
+ throw new IdentityError(`principal ${principalId} is disabled`);
301
+ const cap =
302
+ (await this.#lastingTier(principalId)) ?? this.#lastSeenTier(principal);
303
+ return {
304
+ id: principal.id,
305
+ name: principal.displayName,
306
+ tier: tier ? lowest(tier, cap) : cap,
307
+ principalId: principal.id,
308
+ };
309
+ }
310
+
311
+ async principalOf(identity: string): Promise<string | undefined> {
312
+ const ref = parseIdentity(identity);
313
+ if (!ref)
314
+ throw new IdentityError(
315
+ `${JSON.stringify(identity)} is not an identity written <provider>:<subject>`,
316
+ );
317
+ return (await this.store.identity(ref.provider, ref.subject))?.principalId;
318
+ }
319
+
320
+ async owners(): Promise<readonly Principal[]> {
321
+ const holders = (await this.store.holders("owner")).map(
322
+ (holder) => holder.principalId,
323
+ );
324
+ const order = (id: string) => {
325
+ const at = this.#configOwners.indexOf(id);
326
+ return at < 0 ? this.#configOwners.length : at;
327
+ };
328
+ holders.sort((a, b) => order(a) - order(b) || a.localeCompare(b));
329
+ const owners: Principal[] = [];
330
+ for (const id of holders) {
331
+ const principal = await this.principal(id);
332
+ if (principal && !principal.disabled) owners.push(principal);
333
+ }
334
+ return owners;
335
+ }
336
+
337
+ /** The tier of the principal's lasting roles, if any. */
338
+ async #lastingTier(principalId: string): Promise<Tier | undefined> {
339
+ const roles: RoleGrant[] = await this.store.rolesOf(principalId);
340
+ return highest(roles.map((grant) => grant.role));
341
+ }
342
+
343
+ /** The tier a principal without lasting roles was last seen at, while it is fresh. */
344
+ #lastSeenTier(principal: PrincipalRecord): Tier {
345
+ const { lastTier, lastSeenAt } = principal;
346
+ if (!lastTier || !lastSeenAt)
347
+ throw new IdentityError(`principal ${principal.id} holds no tier`);
348
+ const days = this.#rules.backgroundStaleDays;
349
+ if (this.#now() - lastSeenAt.getTime() > days * DAY_MS)
350
+ throw new IdentityError(
351
+ `principal ${principal.id} holds no lasting role and was last seen more than ${days} days ago (access.backgroundStaleDays)`,
352
+ );
353
+ return lastTier;
354
+ }
355
+
356
+ async #tier(
357
+ principalId: string,
358
+ facts: ActorFacts,
359
+ conversation: ChannelKey | undefined,
360
+ ): Promise<Tier | undefined> {
361
+ return highest([
362
+ await this.#lastingTier(principalId),
363
+ factsTier(this.#rules, facts, conversation),
364
+ ]);
365
+ }
366
+
367
+ /** Whether the facts carry what the rules decide by: the roles, when the rules name a role of their surface. */
368
+ #knowsRoles(
369
+ facts: ActorFacts,
370
+ conversation: ChannelKey | undefined,
371
+ ): boolean {
372
+ if (facts.roles !== undefined) return true;
373
+ const prefix = `${surfaceOf(facts, conversation)}:role:`;
374
+ return ![this.#rules.admins, this.#rules.members].some((tier) =>
375
+ tier?.roles?.some((role) => role.startsWith(prefix)),
376
+ );
377
+ }
378
+
379
+ /** The principal of the facts' 0.8 id while it may be claimed: made by the backfill, no identity ever linked to it, and no owner. */
380
+ async #claimable(facts: ActorFacts): Promise<PrincipalRecord | undefined> {
381
+ const id = facts.legacyId;
382
+ if (id === undefined || id === SYSTEM_PRINCIPAL || isPrincipalId(id))
383
+ return undefined;
384
+ const principal = await this.store.get(id);
385
+ if (!principal?.claimable) return undefined;
386
+ if ((await this.#lastingTier(id)) === "owner") return undefined;
387
+ return principal;
388
+ }
389
+
390
+ /** Who an unlinked person would be: the principal of their 0.8 id, or a new one the rules admit, when the host admits people at first contact. */
391
+ async #firstContact(
392
+ facts: ActorFacts,
393
+ conversation: ChannelKey | undefined,
394
+ ): Promise<{ principalId: string; tier: Tier } | undefined> {
395
+ if (this.#rules.provisioning !== "admitted") return undefined;
396
+ // An owner's identity, or a plugin's, is its principal's alone, linked at boot: unlinked
397
+ // meanwhile, it is no one until then.
398
+ if (this.#bootIdentities.has(identityOf(facts))) return undefined;
399
+ const claimable = await this.#claimable(facts);
400
+ if (claimable) {
401
+ // Disabled before they came back: no one, and no new principal either.
402
+ if (claimable.disabled) return undefined;
403
+ const tier = await this.#tier(claimable.id, facts, conversation);
404
+ return tier && { principalId: claimable.id, tier };
405
+ }
406
+ const tier = factsTier(this.#rules, facts, conversation);
407
+ if (!tier) return undefined;
408
+ const key = `${facts.provider}\0${facts.subject}`;
409
+ let id = this.#provisional.get(key);
410
+ if (!id) {
411
+ id = newPrincipalId();
412
+ this.#provisional.set(key, id);
413
+ // Those never taken, such as people writing where no claim answers, are forgotten oldest first.
414
+ if (this.#provisional.size > PROVISIONAL_MAX)
415
+ for (const old of this.#provisional.keys()) {
416
+ this.#provisional.delete(old);
417
+ break;
418
+ }
419
+ }
420
+ return { principalId: id, tier };
421
+ }
422
+
423
+ /**
424
+ * Records a contact a claim took: links an unlinked person by claiming the principal of their
425
+ * 0.8 id, named by the facts when the backfill knew no name of them, or else admitting them as
426
+ * the assessed new principal, then records them as seen.
427
+ */
428
+ async #take(
429
+ facts: ActorFacts,
430
+ conversation: ChannelKey | undefined,
431
+ assessed: string,
432
+ ): Promise<Speaker | undefined> {
433
+ const ref = { provider: facts.provider, subject: facts.subject };
434
+ let link = await this.store.identity(ref.provider, ref.subject);
435
+ if (
436
+ !link &&
437
+ this.#rules.provisioning === "admitted" &&
438
+ !this.#bootIdentities.has(identityOf(ref))
439
+ ) {
440
+ if (assessed === facts.legacyId)
441
+ link = await this.store.claim(assessed, ref, facts.name);
442
+ if (!link && factsTier(this.#rules, facts, conversation)) {
443
+ link = await this.store.admit(
444
+ ref,
445
+ facts.name,
446
+ isPrincipalId(assessed) ? assessed : undefined,
447
+ );
448
+ this.#provisional.delete(`${ref.provider}\0${ref.subject}`);
449
+ }
450
+ }
451
+ if (!link) return undefined;
452
+ const principal = await this.store.get(link.principalId);
453
+ if (!principal || principal.disabled) return undefined;
454
+ const tier = await this.#tier(principal.id, facts, conversation);
455
+ if (!tier) {
456
+ if (this.#knowsRoles(facts, conversation))
457
+ await this.#touch(principal, null);
458
+ return undefined;
459
+ }
460
+ // Facts without the roles the rules decide by may show less than the person holds: they lower nothing.
461
+ if (
462
+ this.#knowsRoles(facts, conversation) ||
463
+ !principal.lastTier ||
464
+ tierAtLeast(tier, principal.lastTier)
465
+ )
466
+ await this.#touch(principal, tier);
467
+ return speakerOf(facts, principal.id, tier);
468
+ }
469
+
470
+ /**
471
+ * Records the principal as seen at the tier, null when refused, as `SeenThrottle` lets it.
472
+ * A failure is logged, not thrown.
473
+ */
474
+ async #touch(principal: PrincipalRecord, tier: Tier | null): Promise<void> {
475
+ const principalId = principal.id;
476
+ const now = this.#now();
477
+ const stored = tier !== null || principal.lastTier === undefined;
478
+ if (!this.#seen.take(principalId, tier, now, stored)) return;
479
+ try {
480
+ await this.store.touch(principalId, tier, new Date(now));
481
+ } catch (error) {
482
+ this.#seen.forget(principalId);
483
+ this.#logger.warn(
484
+ { err: error, principal: principalId },
485
+ "could not record that a principal was seen",
486
+ );
487
+ }
488
+ }
489
+ }
@@ -0,0 +1,77 @@
1
+ import { freeze } from "../freeze.ts";
2
+ import type { ContactAssessor } from "./contact.ts";
3
+ import type { IdentityService } from "./identity-service.ts";
4
+
5
+ /** The core's own assessor behind each view it made, for the router. */
6
+ const assessors = new WeakMap<IdentityService, ContactAssessor>();
7
+ /** The core's reading of a 0.8 row's person id behind each view it made, for the background turns. */
8
+ const legacies = new WeakMap<
9
+ IdentityService,
10
+ (id: string) => Promise<string | undefined>
11
+ >();
12
+
13
+ /** A copy of what the service read, frozen all the way down, so no caller can change what the service holds. */
14
+ function frozenCopy<T>(value: T): T {
15
+ return value === undefined
16
+ ? value
17
+ : (freeze(structuredClone(value) as object) as T);
18
+ }
19
+
20
+ /**
21
+ * The service as plugins get it: only its reads, frozen, with no way to its store. Each returns a
22
+ * frozen copy, so a plugin that sorts or changes what it got changes no one's tier.
23
+ */
24
+ export function identityView(
25
+ service: IdentityService &
26
+ ContactAssessor & {
27
+ principalOfLegacyId?(id: string): Promise<string | undefined>;
28
+ },
29
+ ): IdentityService {
30
+ const view = Object.freeze({
31
+ resolve: async (facts, scope) =>
32
+ frozenCopy(await service.resolve(facts, scope)),
33
+ principal: async (id) => frozenCopy(await service.principal(id)),
34
+ list: async () => frozenCopy(await service.list()),
35
+ identities: async (principalId) =>
36
+ frozenCopy(await service.identities(principalId)),
37
+ roles: async (principalId) => frozenCopy(await service.roles(principalId)),
38
+ tierOf: (principalId) => service.tierOf(principalId),
39
+ speakerFor: async (principalId, tier) =>
40
+ frozenCopy(await service.speakerFor(principalId, tier)),
41
+ owners: async () => frozenCopy(await service.owners()),
42
+ principalOf: (identity) => service.principalOf(identity),
43
+ } satisfies IdentityService);
44
+ assessors.set(view, service);
45
+ const legacy = service.principalOfLegacyId?.bind(service);
46
+ if (legacy) legacies.set(view, legacy);
47
+ return view;
48
+ }
49
+
50
+ /**
51
+ * The principal a person id a 0.8 row names stands for, such as a schedule's creator, through the
52
+ * host's `IDENTITY`: the core's service reads 0.8's `remote-mcp` as the primary owner; a plugin's
53
+ * replacement takes an id as the principal of that id. Core-internal.
54
+ */
55
+ export function legacyPrincipalsOf(
56
+ identity: IdentityService,
57
+ ): (id: string) => Promise<string | undefined> {
58
+ return (
59
+ legacies.get(identity) ?? (async (id) => (await identity.principal(id))?.id)
60
+ );
61
+ }
62
+
63
+ /**
64
+ * How the router assesses the people of messages through the host's `IDENTITY`: the core's
65
+ * service assesses before a claim takes a message and writes only once one does; a plugin's
66
+ * replacement, which only resolves, resolves at once.
67
+ */
68
+ export function contactsOf(identity: IdentityService): ContactAssessor {
69
+ return (
70
+ assessors.get(identity) ?? {
71
+ assess: async (facts, scope) => {
72
+ const speaker = await identity.resolve(facts, scope);
73
+ return speaker && { speaker, take: async () => speaker };
74
+ },
75
+ }
76
+ );
77
+ }
@@ -0,0 +1,109 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import type { PluginIdentity, RoundtablePlugin } from "../plugin.ts";
4
+ import { identityOf, parseIdentity } from "./actor-facts.ts";
5
+ import { type PrincipalStore, SYSTEM_PRINCIPAL } from "./principal-store.ts";
6
+
7
+ /** The only provider a plugin declares identities of: credentials of its own, which no surface reports. */
8
+ const PLUGIN_PROVIDER = "token";
9
+
10
+ /** An identity a plugin declares, with the plugin that declares it. */
11
+ export interface DeclaredIdentity extends PluginIdentity {
12
+ plugin: string;
13
+ }
14
+
15
+ /**
16
+ * Every identity the plugins declare, in their order, checked before anything is written: each
17
+ * written `token:<subject>`, none bound to the system principal, and none declared twice.
18
+ */
19
+ export function declaredIdentities(
20
+ plugins: readonly RoundtablePlugin[],
21
+ ): DeclaredIdentity[] {
22
+ const declared: DeclaredIdentity[] = [];
23
+ const by = new Map<string, string>();
24
+ for (const plugin of plugins)
25
+ for (const [i, { identity, principal }] of (
26
+ plugin.identities ?? []
27
+ ).entries()) {
28
+ const path = `plugin ${plugin.name}: identities[${i}]`;
29
+ const ref = parseIdentity(identity);
30
+ if (!ref)
31
+ throw new ConfigError(
32
+ `${path}: ${JSON.stringify(identity)} is not an identity written <provider>:<subject>`,
33
+ );
34
+ // No surface reports a token: identity, so linking it, which spends no claim, admits no one
35
+ // as someone new, and goes when the plugin stops declaring it, touches no person's own account.
36
+ if (ref.provider !== PLUGIN_PROVIDER)
37
+ throw new ConfigError(
38
+ `${path}: ${identity} is not a ${PLUGIN_PROVIDER}: identity. A plugin declares only the credentials it serves itself, written ${PLUGIN_PROVIDER}:<name>; a person's own ${ref.provider} identity is linked by access.owners or roundtable principal link.`,
39
+ );
40
+ if (principal === SYSTEM_PRINCIPAL)
41
+ throw new ConfigError(
42
+ `${path}: ${identity} is bound to the host's own principal "${SYSTEM_PRINCIPAL}", whose turns only the core starts; bind it to a person's principal`,
43
+ );
44
+ const text = identityOf(ref);
45
+ const other = by.get(text);
46
+ if (other !== undefined)
47
+ throw new ConfigError(
48
+ `${path}: ${text} is declared by plugin ${other} too. An identity stands for one principal; give one of the two another identity.`,
49
+ );
50
+ by.set(text, plugin.name);
51
+ declared.push({
52
+ plugin: plugin.name,
53
+ identity: text,
54
+ ...(principal === undefined ? {} : { principal }),
55
+ });
56
+ }
57
+ return declared;
58
+ }
59
+
60
+ /**
61
+ * Links each identity a plugin declares to its principal, `primaryOwner` by default, as the
62
+ * plugin's: one the plugins bound to another principal at an earlier boot moves, one the
63
+ * configuration or the CLI linked to the same principal stays linked, and one the plugins no
64
+ * longer declare is unlinked. A principal that does not exist, or an identity linked to someone
65
+ * else in another way, stops the boot.
66
+ */
67
+ export async function syncDeclaredIdentities(
68
+ store: PrincipalStore,
69
+ identities: readonly DeclaredIdentity[],
70
+ primaryOwner: string | undefined,
71
+ logger: Logger,
72
+ ): Promise<void> {
73
+ const declared = new Set<string>();
74
+ for (const { plugin, identity, principal } of identities) {
75
+ // declaredIdentities refused any identity that does not parse.
76
+ const ref = parseIdentity(identity);
77
+ if (!ref) throw new ConfigError(`plugin ${plugin}: ${identity}`);
78
+ const id = principal ?? primaryOwner;
79
+ if (id === undefined)
80
+ throw new ConfigError(
81
+ `plugin ${plugin}: ${identity} names no principal, and access.owners lists no primary owner to bind it to. Give the plugin the principal it stands for.`,
82
+ );
83
+ if (id === SYSTEM_PRINCIPAL || !(await store.get(id)))
84
+ throw new ConfigError(
85
+ `plugin ${plugin}: ${identity} is bound to principal ${id}, and there is no principal ${id}. roundtable principal list shows the principals, and roundtable principal create makes one.`,
86
+ );
87
+ const link = await store.identity(ref.provider, ref.subject);
88
+ if (link && link.principalId !== id) {
89
+ if (link.source !== "plugin")
90
+ throw new ConfigError(
91
+ `plugin ${plugin}: ${identity} is linked to principal ${link.principalId}, not to ${id} the plugin binds it to. ${link.source === "config" ? "access.owners lists it under that owner: remove it there," : `Unlink it with roundtable principal unlink ${identity},`} or bind the plugin to ${link.principalId}.`,
92
+ );
93
+ // Bound to another principal than at the last boot: it moves.
94
+ await store.unlink(ref.provider, ref.subject);
95
+ logger.info(
96
+ `${identity} of plugin ${plugin} moved from principal ${link.principalId} to ${id}`,
97
+ );
98
+ }
99
+ await store.link(id, ref, "plugin");
100
+ declared.add(identity);
101
+ }
102
+ for (const link of await store.linksFrom("plugin"))
103
+ if (!declared.has(identityOf(link))) {
104
+ await store.unlink(link.provider, link.subject);
105
+ logger.info(
106
+ `${identityOf(link)} is no longer an identity a plugin declares; it was unlinked from principal ${link.principalId}`,
107
+ );
108
+ }
109
+ }