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,111 @@
1
+ import type { Tier } from "../speakers.ts";
2
+ import type {
3
+ IdentityRef,
4
+ LinkSource,
5
+ NewPrincipal,
6
+ PrincipalStore,
7
+ Pronouns,
8
+ RoleSource,
9
+ } from "./principal-store.ts";
10
+
11
+ /** How long a read of the store is reused; a change another process makes is seen after it. */
12
+ export const CACHE_MS = 30_000;
13
+
14
+ /**
15
+ * The store with its reads reused for `CACHE_MS`; any write through it drops them all, so this
16
+ * process sees its own changes at once. Recording a principal as seen drops only theirs.
17
+ */
18
+ export class CachingPrincipalStore implements PrincipalStore {
19
+ readonly #store: PrincipalStore;
20
+ readonly #now: () => number;
21
+ readonly #reads = new Map<string, { at: number; value: Promise<unknown> }>();
22
+
23
+ constructor(store: PrincipalStore, now: () => number) {
24
+ this.#store = store;
25
+ this.#now = now;
26
+ }
27
+
28
+ #cached<T>(key: string, read: () => Promise<T>): Promise<T> {
29
+ const now = this.#now();
30
+ const hit = this.#reads.get(key);
31
+ if (hit && now - hit.at < CACHE_MS) return hit.value as Promise<T>;
32
+ const value = read();
33
+ this.#reads.set(key, { at: now, value });
34
+ value.catch(() => this.#reads.delete(key));
35
+ return value;
36
+ }
37
+
38
+ async #write<T>(write: Promise<T>): Promise<T> {
39
+ try {
40
+ return await write;
41
+ } finally {
42
+ this.#reads.clear();
43
+ }
44
+ }
45
+
46
+ get(id: string) {
47
+ return this.#cached(`p\0${id}`, () => this.#store.get(id));
48
+ }
49
+ list() {
50
+ return this.#store.list();
51
+ }
52
+ identity(provider: string, subject: string) {
53
+ return this.#cached(`i\0${provider}\0${subject}`, () =>
54
+ this.#store.identity(provider, subject),
55
+ );
56
+ }
57
+ identitiesOf(principalId: string) {
58
+ return this.#store.identitiesOf(principalId);
59
+ }
60
+ rolesOf(principalId: string) {
61
+ return this.#cached(`r\0${principalId}`, () =>
62
+ this.#store.rolesOf(principalId),
63
+ );
64
+ }
65
+ holders(role: Tier) {
66
+ return this.#store.holders(role);
67
+ }
68
+ linksFrom(source: LinkSource) {
69
+ return this.#store.linksFrom(source);
70
+ }
71
+ create(input: NewPrincipal) {
72
+ return this.#write(this.#store.create(input));
73
+ }
74
+ update(
75
+ id: string,
76
+ change: { displayName?: string; pronouns?: Pronouns | null },
77
+ ) {
78
+ return this.#write(this.#store.update(id, change));
79
+ }
80
+ link(principalId: string, identity: IdentityRef, source: LinkSource) {
81
+ return this.#write(this.#store.link(principalId, identity, source));
82
+ }
83
+ claim(principalId: string, identity: IdentityRef, displayName?: string) {
84
+ return this.#write(this.#store.claim(principalId, identity, displayName));
85
+ }
86
+ admit(identity: IdentityRef, displayName: string, id?: string) {
87
+ return this.#write(this.#store.admit(identity, displayName, id));
88
+ }
89
+ unlink(provider: string, subject: string) {
90
+ return this.#write(this.#store.unlink(provider, subject));
91
+ }
92
+ grant(principalId: string, role: Tier, source: RoleSource) {
93
+ return this.#write(this.#store.grant(principalId, role, source));
94
+ }
95
+ revoke(principalId: string, role: Tier, source?: RoleSource) {
96
+ return this.#write(this.#store.revoke(principalId, role, source));
97
+ }
98
+ disable(id: string) {
99
+ return this.#write(this.#store.disable(id));
100
+ }
101
+ enable(id: string) {
102
+ return this.#write(this.#store.enable(id));
103
+ }
104
+ async touch(id: string, tier: Tier | null, at?: Date) {
105
+ try {
106
+ await this.#store.touch(id, tier, at);
107
+ } finally {
108
+ this.#reads.delete(`p\0${id}`);
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,145 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import type { AccessOwner } from "./access-policy.ts";
4
+ import { identityOf } from "./actor-facts.ts";
5
+ import type { DeclaredIdentity } from "./plugin-identities.ts";
6
+ import type {
7
+ IdentityLink,
8
+ IdentityRef,
9
+ PrincipalStore,
10
+ } from "./principal-store.ts";
11
+
12
+ /** A configured owner, where the configuration lists them, and what the boot knows of the plugins. */
13
+ export interface ConfiguredOwner {
14
+ owner: AccessOwner;
15
+ /** The owner's index in `access.owners`. */
16
+ index: number;
17
+ /** The owner's identities, parsed, in the configuration's order. */
18
+ refs: readonly IdentityRef[];
19
+ /** Each identity the plugins declare, with the plugin and the principal it names. */
20
+ declaredBy: ReadonlyMap<string, DeclaredIdentity>;
21
+ /** The primary owner's principal, which a plugin naming none binds to; undefined while syncing the primary owner. */
22
+ primary: string | undefined;
23
+ logger: Logger;
24
+ }
25
+
26
+ /** A configured owner's identity that stops the start, and how to fix it. */
27
+ export interface OwnerConflict {
28
+ problem: string;
29
+ fix: string;
30
+ }
31
+
32
+ /**
33
+ * Whether a configured owner may list an identity a plugin declares. The principal the plugin binds
34
+ * it to on this boot, its `principal` or else the primary owner, must be this owner, whatever it
35
+ * was bound to before: the boot moves it there. A plugin's credential never tells who the owner is,
36
+ * so an owner nothing else tells conflicts unless they are the primary owner the plugin defaults to
37
+ * and the credential is not a plugin's link to someone already. `primary` undefined with `first`
38
+ * false is a primary owner the start makes.
39
+ */
40
+ export function declaredConflict({
41
+ index: n,
42
+ i,
43
+ identity,
44
+ id,
45
+ link,
46
+ declared: { plugin, principal },
47
+ primary,
48
+ first,
49
+ }: {
50
+ index: number;
51
+ i: number;
52
+ identity: string;
53
+ /** The principal this owner is, as everything but a plugin's credential tells; undefined for one the boot makes. */
54
+ id: string | undefined;
55
+ link: IdentityLink | undefined;
56
+ declared: DeclaredIdentity;
57
+ primary: string | undefined;
58
+ first: boolean;
59
+ }): OwnerConflict | undefined {
60
+ const at = `access.owners[${n}].identities[${i}]: ${identity} is an identity plugin ${plugin} declares`;
61
+ const remove = `Remove it from access.owners[${n}].identities`;
62
+ const untold: OwnerConflict = {
63
+ problem: "a plugin's credential does not tell who this owner is",
64
+ fix: `${remove}, or give this owner its principal and bind plugin ${plugin} to it`,
65
+ };
66
+ if (principal === undefined && first) {
67
+ // Bound to this owner, the primary owner: whoever they are, unless only that link would tell.
68
+ if (id !== undefined || link?.source !== "plugin") return undefined;
69
+ return {
70
+ ...untold,
71
+ problem: `${at}, bound to principal ${link.principalId}, and ${untold.problem}`,
72
+ };
73
+ }
74
+ const target = principal ?? primary;
75
+ if (target !== undefined && target === id) return undefined;
76
+ const bound = `${at}, bound to ${target === undefined ? "the principal the start makes for access.owners[0]" : `principal ${target}`}`;
77
+ return id === undefined
78
+ ? { ...untold, problem: `${bound}, and ${untold.problem}` }
79
+ : {
80
+ problem: `${bound}, not to this owner's ${id}`,
81
+ fix: `${remove}, or bind plugin ${plugin} to ${id} in its options`,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The principal a configured owner is: their `principal`, else the principal an identity of theirs
87
+ * is linked to; undefined for someone the boot makes. A plugin's credential is not the person's own
88
+ * identity, so a plugin's link never tells who the owner is, and one no plugin declares any more is
89
+ * unlinked first, as the boot would anyway. An identity linked to another principal stops the
90
+ * boot, and so does an identity a plugin declares that `declaredConflict` refuses. One a plugin
91
+ * declares and binds to this owner on this boot, still the plugin's link to someone else, is moved
92
+ * now, before the owner's identities are linked.
93
+ */
94
+ export async function configuredOwnerId(
95
+ store: PrincipalStore,
96
+ { owner, index: n, refs, declaredBy, primary, logger }: ConfiguredOwner,
97
+ ): Promise<string | undefined> {
98
+ const links = await Promise.all(
99
+ refs.map(async (ref) => {
100
+ const link = await store.identity(ref.provider, ref.subject);
101
+ if (link?.source !== "plugin" || declaredBy.has(identityOf(link)))
102
+ return link;
103
+ // No plugin declares it any more, so this boot unlinks it anyway: before it tells anyone anything.
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
+ return undefined;
109
+ }),
110
+ );
111
+ let id = owner.principal;
112
+ links.forEach((link, i) => {
113
+ if (!link || link.source === "plugin") return;
114
+ id ??= link.principalId;
115
+ if (link.principalId !== id)
116
+ throw new ConfigError(
117
+ `config access.owners[${n}].identities[${i}]: ${owner.identities[i]} is linked to principal ${link.principalId}, not to this owner's ${id}. Unlink it with roundtable principal unlink ${owner.identities[i]}, or fix the configuration.`,
118
+ );
119
+ });
120
+ for (const [i, ref] of refs.entries()) {
121
+ const declared = declaredBy.get(identityOf(ref));
122
+ if (!declared) continue;
123
+ const link = links[i];
124
+ const conflict = declaredConflict({
125
+ index: n,
126
+ i,
127
+ identity: owner.identities[i] ?? identityOf(ref),
128
+ id,
129
+ link,
130
+ declared,
131
+ primary,
132
+ first: n === 0,
133
+ });
134
+ // The CLI refuses to unlink a plugin's identity: the plugin's options move it.
135
+ if (conflict)
136
+ throw new ConfigError(`config ${conflict.problem}. ${conflict.fix}.`);
137
+ if (link?.source !== "plugin" || link.principalId === id) continue;
138
+ // The plugin binds it to this owner on this boot, and a link refuses an identity linked elsewhere.
139
+ await store.unlink(link.provider, link.subject);
140
+ logger.info(
141
+ `${identityOf(link)} of plugin ${declared.plugin} moved from principal ${link.principalId} to ${id}`,
142
+ );
143
+ }
144
+ return id;
145
+ }
@@ -0,0 +1,27 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { Speaker } from "../speakers.ts";
3
+ import type { ActorFacts } from "./actor-facts.ts";
4
+
5
+ /**
6
+ * A person a surface reports, assessed before any claim takes their message: who they would be,
7
+ * with nothing written yet.
8
+ */
9
+ export interface Contact {
10
+ /** The speaker they would be: their principal's, or at a first contact the principal they would claim or be admitted as. */
11
+ readonly speaker: Speaker;
12
+ /**
13
+ * Records the contact once a claim takes their message: claims their 0.8 principal or admits
14
+ * them first, then records them as seen. The speaker they are now, whose principal differs from
15
+ * the assessed one only when another process linked them meanwhile; undefined when they are no
16
+ * one now.
17
+ */
18
+ take(): Promise<Speaker | undefined>;
19
+ }
20
+
21
+ /** Assesses the people of messages before a claim takes them, so nobody's message that no claim serves writes anything. Core-internal: the router's. */
22
+ export interface ContactAssessor {
23
+ assess(
24
+ facts: ActorFacts,
25
+ scope?: { conversation?: ChannelKey },
26
+ ): Promise<Contact | undefined>;
27
+ }
@@ -0,0 +1,56 @@
1
+ import type { RoundtablePlugin } from "../plugin.ts";
2
+ import { IDENTITY } from "../services.ts";
3
+ import type { AccessRules } from "./access-policy.ts";
4
+ import { type BackfillSummary, identityMigrations } from "./identity-schema.ts";
5
+ import { PgIdentityService } from "./identity-service.ts";
6
+ import { identityView } from "./identity-view.ts";
7
+ import { declaredIdentities } from "./plugin-identities.ts";
8
+ import { PgPrincipalStore } from "./principal-store.ts";
9
+
10
+ /** The name of the identity plugin, as the migration ledger names it. */
11
+ export const IDENTITY_PLUGIN = "identity";
12
+
13
+ export interface IdentityOptions {
14
+ /** Who the host serves; the backfill makes the owners with a principal id first, by their names, and caps what a carried-over author of schedules is seen at by the tiers. */
15
+ rules: AccessRules;
16
+ /** The host's other plugins, whose declared `identities` setup links; checked when this plugin is made. */
17
+ plugins?: readonly RoundtablePlugin[];
18
+ }
19
+
20
+ /** One line for the log: what the boot's backfill made, and from where. */
21
+ export function backfillLine(summary: BackfillSummary): string {
22
+ const sources = Object.entries(summary.sources)
23
+ .map(([source, count]) => `${source} ${count}`)
24
+ .join(", ");
25
+ return `principal backfill: ${summary.created} created; ids found: ${sources}`;
26
+ }
27
+
28
+ /**
29
+ * The principals, their identity links, and their roles: the tables, the backfill that makes a
30
+ * principal of every person 0.8 stored, and the identity service over them provided as
31
+ * `IDENTITY`, read-only, its configured owners and the plugins' declared identities synced at
32
+ * setup. It runs first, so every later plugin may read it.
33
+ */
34
+ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
35
+ const { rules } = options;
36
+ const identities = declaredIdentities(options.plugins ?? []);
37
+ let summary: BackfillSummary | undefined;
38
+ return {
39
+ name: IDENTITY_PLUGIN,
40
+ migrations: identityMigrations(rules, (made) => {
41
+ summary = made;
42
+ }),
43
+ provides: [IDENTITY],
44
+ setup: async ({ database, services, logger }) => {
45
+ if (summary) logger.info(backfillLine(summary));
46
+ const identity = new PgIdentityService(
47
+ await PgPrincipalStore.attach(database()),
48
+ rules,
49
+ { logger, identities },
50
+ );
51
+ await identity.syncConfig();
52
+ services.provide(IDENTITY, identityView(identity));
53
+ return {};
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,282 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "../db/migrations.ts";
3
+ import { type Tier, tierAtLeast } from "../speakers.ts";
4
+ import type { AccessRules, AccessTier } from "./access-policy.ts";
5
+ import { parseIdentity } from "./actor-facts.ts";
6
+ import {
7
+ LEGACY_PROVIDER,
8
+ LEGACY_REMOTE_SPEAKER,
9
+ SYSTEM_PRINCIPAL,
10
+ } from "./principal-store.ts";
11
+ import {
12
+ PRINCIPAL_PLUGIN_LINKS,
13
+ PRINCIPAL_TABLES,
14
+ PRINCIPALS_CLAIMABLE,
15
+ } from "./principal-tables.ts";
16
+
17
+ /** Where the backfill finds the ids of people 0.8 stored: the configured owners, then four tables. */
18
+ export type BackfillSource =
19
+ | "config"
20
+ | "owner_memory"
21
+ | "schedules"
22
+ | "conversations"
23
+ | "held_actions";
24
+
25
+ /** The rules the backfill reads: the owners with a principal id, made first by their names, and the tiers that cap what a carried-over author of schedules is seen at. */
26
+ export type BackfillRules = Pick<AccessRules, "owners" | "admins" | "members">;
27
+
28
+ /** What one backfill made: the principals it created, and how many distinct ids each source held. */
29
+ export interface BackfillSummary {
30
+ created: number;
31
+ sources: Record<BackfillSource, number>;
32
+ }
33
+
34
+ /**
35
+ * The column each table keeps a person's id in, and the column naming them, where there is one.
36
+ * `principal` is a column a newer build fills with the principal beside an actor id: a row with
37
+ * it filled is not 0.8's, so its id makes no principal. Where `principalHold` names the hold the
38
+ * principal was written with, it counts only while that is the row's (`principalHold = held`),
39
+ * as 0.8 writes over a row and leaves the principal as it was.
40
+ */
41
+ const TABLES: readonly {
42
+ table: Exclude<BackfillSource, "config">;
43
+ column: string;
44
+ name?: string;
45
+ order?: string;
46
+ principal?: string;
47
+ principalHold?: { column: string; held: string };
48
+ }[] = [
49
+ { table: "owner_memory", column: "speaker_id" },
50
+ {
51
+ table: "schedules",
52
+ column: "created_by_id",
53
+ name: "created_by_name",
54
+ order: "created_at DESC",
55
+ },
56
+ { table: "conversations", column: "principal_id" },
57
+ {
58
+ table: "held_actions",
59
+ column: "speaker_id",
60
+ principal: "principal_id",
61
+ principalHold: { column: "principal_held_at", held: "held_at" },
62
+ },
63
+ ];
64
+
65
+ /** Whether the tier's rules could hold the person of a 0.8 speaker id, whose surface and roles are unknown here. */
66
+ function couldHold(tier: AccessTier | undefined, id: string): boolean {
67
+ if (!tier) return false;
68
+ const { everyone } = tier;
69
+ if (everyone === true || (Array.isArray(everyone) && everyone.length > 0))
70
+ return true;
71
+ if ((tier.roles?.length ?? 0) > 0) return true;
72
+ return (tier.identities ?? []).some(
73
+ (identity) => identity === id || parseIdentity(identity)?.subject === id,
74
+ );
75
+ }
76
+
77
+ /** The most the rules could give the person of a 0.8 speaker id: admin, member, or nothing; never owner, which only the owners hold. */
78
+ function ceilingOf(
79
+ rules: BackfillRules,
80
+ id: string,
81
+ ): Exclude<Tier, "owner"> | undefined {
82
+ if (couldHold(rules.admins, id)) return "admin";
83
+ if (couldHold(rules.members, id)) return "member";
84
+ return undefined;
85
+ }
86
+
87
+ /** The highest tier each author created a schedule at, where the table records it. */
88
+ async function scheduledTiers(sql: SQL): Promise<Map<string, Tier>> {
89
+ const tiers = new Map<string, Tier>();
90
+ if (
91
+ !(
92
+ (await hasColumn(sql, "schedules", "created_by_id")) &&
93
+ (await hasColumn(sql, "schedules", "created_tier"))
94
+ )
95
+ )
96
+ return tiers;
97
+ const rows = (await sql`
98
+ SELECT created_by_id AS id, created_tier AS tier FROM schedules
99
+ WHERE created_by_id IS NOT NULL`) as { id: string; tier: Tier }[];
100
+ for (const { id, tier } of rows) {
101
+ const best = tiers.get(id);
102
+ if (!best || tierAtLeast(tier, best)) tiers.set(id, tier);
103
+ }
104
+ return tiers;
105
+ }
106
+
107
+ async function hasColumn(
108
+ sql: SQL,
109
+ table: string,
110
+ column: string,
111
+ ): Promise<boolean> {
112
+ const rows = await sql`
113
+ SELECT 1 FROM information_schema.columns
114
+ WHERE table_schema = current_schema() AND table_name = ${table} AND column_name = ${column}`;
115
+ return rows.length > 0;
116
+ }
117
+
118
+ /** The tier a carried-over author of schedules is seen at: the highest they scheduled at, at most what the rules could give them. */
119
+ function seenTier(
120
+ rules: BackfillRules,
121
+ id: string,
122
+ scheduled: ReadonlyMap<string, Tier>,
123
+ ): Tier | null {
124
+ const tier = scheduled.get(id);
125
+ const ceiling = tier && ceilingOf(rules, id);
126
+ if (!tier || !ceiling) return null;
127
+ return tierAtLeast(tier, ceiling) ? ceiling : tier;
128
+ }
129
+
130
+ /** The ids a table holds, each with the name it last gave them, or none. */
131
+ async function idsIn(
132
+ sql: SQL,
133
+ source: (typeof TABLES)[number],
134
+ ): Promise<{ id: string; name: string | null }[]> {
135
+ if (!(await hasColumn(sql, source.table, source.column))) return [];
136
+ // The identifiers are this module's constants, never input.
137
+ const name = source.name ?? "NULL::text";
138
+ const order = source.order ? `, ${source.order}` : "";
139
+ const hold =
140
+ source.principalHold &&
141
+ (await hasColumn(sql, source.table, source.principalHold.column))
142
+ ? ` OR ${source.principalHold.column} IS DISTINCT FROM ${source.principalHold.held}`
143
+ : "";
144
+ const legacy =
145
+ source.principal && (await hasColumn(sql, source.table, source.principal))
146
+ ? ` AND (${source.principal} IS NULL${hold})`
147
+ : "";
148
+ return (await sql.unsafe(
149
+ `SELECT DISTINCT ON (${source.column}) ${source.column} AS id, ${name} AS name
150
+ FROM ${source.table} WHERE ${source.column} IS NOT NULL AND ${source.column} <> ''${legacy}
151
+ ORDER BY ${source.column}${order}`,
152
+ )) as { id: string; name: string | null }[];
153
+ }
154
+
155
+ /**
156
+ * Makes a principal of the same id for every person 0.8 stored: the configured owners, and each
157
+ * id in `owner_memory`, `schedules`, `conversations`, and `held_actions`, skipping a table or a
158
+ * column the database does not have yet, and a held action that names its principal in
159
+ * `principal_id` for the hold it holds (`principal_held_at`), whose `speaker_id` is an actor id
160
+ * this version stored, and any id a surface
161
+ * knows a linked identity by (its subject, or `<provider>:<subject>`, as `ActorFacts.legacyId`
162
+ * names it): that person has a principal already, and this version wrote the row, so the boot
163
+ * after someone is admitted makes no second principal of their actor id. Each but a configured
164
+ * owner is claimable once, by the person of that id at their first contact
165
+ * (IdentityService.resolve); a configured owner never is, even once the configuration names
166
+ * another owner. 0.8's `remote-mcp` speaker, the owner writing over MCP, gets no principal: it is
167
+ * linked as `legacy:remote-mcp` to the primary owner's, so what it created stays the owner's. One that created
168
+ * schedules, other than a configured owner, is recorded as seen now at the highest tier it
169
+ * scheduled at, capped at the most the rules could give it, so its schedules keep running until
170
+ * `backgroundStaleDays` pass unseen. It only inserts, so it changes no row, and a second run
171
+ * makes nothing; an id an older build wrote since gets its principal at the next run. The owners
172
+ * are named as configured, a schedule's author as the schedule names them, anyone else by their
173
+ * id. `dryRun` counts and writes nothing. The system principal is never made.
174
+ */
175
+ export async function backfillPrincipals(
176
+ sql: SQL,
177
+ rules: BackfillRules,
178
+ options: { dryRun?: boolean } = {},
179
+ ): Promise<BackfillSummary> {
180
+ const owners = rules.owners.flatMap((owner) =>
181
+ owner.principal === undefined
182
+ ? []
183
+ : [{ id: owner.principal, name: owner.name }],
184
+ );
185
+ const names = new Map<string, string | undefined>();
186
+ const sources = {
187
+ config: 0,
188
+ owner_memory: 0,
189
+ schedules: 0,
190
+ conversations: 0,
191
+ held_actions: 0,
192
+ } satisfies Record<BackfillSource, number>;
193
+ // 0.8's remote-mcp speaker was the owner over MCP: it stands for the primary owner.
194
+ const primary = owners[0]?.id;
195
+ let remote = false;
196
+ const add = (source: BackfillSource, id: string, name: string | null) => {
197
+ if (id === SYSTEM_PRINCIPAL) return;
198
+ sources[source]++;
199
+ if (primary !== undefined && id === LEGACY_REMOTE_SPEAKER) {
200
+ remote = true;
201
+ return;
202
+ }
203
+ if (!names.get(id)) names.set(id, name?.trim() || undefined);
204
+ };
205
+ for (const owner of owners) add("config", owner.id, owner.name);
206
+ for (const source of TABLES)
207
+ for (const row of await idsIn(sql, source))
208
+ add(source.table, row.id, row.name);
209
+
210
+ const [table] = await sql`SELECT to_regclass('principals') AS made`;
211
+ const existing = new Set<string>(
212
+ table?.made
213
+ ? (await sql`SELECT id FROM principals`).map(
214
+ (row: { id: string }) => row.id,
215
+ )
216
+ : [],
217
+ );
218
+ // The id a surface knows a linked person by is no 0.8 speaker's: this version wrote it.
219
+ const linked = new Set<string>(
220
+ table?.made
221
+ ? (
222
+ (await sql`SELECT provider, subject FROM principal_identities`) as {
223
+ provider: string;
224
+ subject: string;
225
+ }[]
226
+ ).flatMap((row) => [row.subject, `${row.provider}:${row.subject}`])
227
+ : [],
228
+ );
229
+ const missing = [...names].filter(
230
+ ([id]) => !existing.has(id) && !linked.has(id),
231
+ );
232
+ if (options.dryRun) return { created: missing.length, sources };
233
+ const scheduled = await scheduledTiers(sql);
234
+ const configured = new Set(owners.map((owner) => owner.id));
235
+ const upgraded = new Date();
236
+ let created = 0;
237
+ for (const [id, displayName] of missing) {
238
+ const owner = configured.has(id);
239
+ const seen = owner ? null : seenTier(rules, id, scheduled);
240
+ const rows = await sql`
241
+ INSERT INTO principals (id, display_name, claimable, last_tier, last_seen_at)
242
+ VALUES (${id}, ${displayName ?? id}, ${!owner}, ${seen}, ${seen ? upgraded : null})
243
+ ON CONFLICT (id) DO NOTHING
244
+ RETURNING id`;
245
+ created += rows.length;
246
+ }
247
+ if (remote && primary !== undefined)
248
+ await sql`
249
+ INSERT INTO principal_identities (provider, subject, principal_id, source)
250
+ VALUES (${LEGACY_PROVIDER}, ${LEGACY_REMOTE_SPEAKER}, ${primary}, 'legacy')
251
+ ON CONFLICT (provider, subject) DO NOTHING`;
252
+ // An owner is one by the configuration, never by a claim, even after it names someone else.
253
+ if (configured.size > 0)
254
+ await sql`
255
+ UPDATE principals SET claimable = false
256
+ WHERE claimable AND id IN ${sql([...configured])}`;
257
+ return { created, sources };
258
+ }
259
+
260
+ /**
261
+ * The identity plugin's migrations: its tables once, the claimable column and the plugins' link
262
+ * source once each (apart, so a database an earlier build made the tables on gets them too), then
263
+ * the backfill at every boot, which hands its summary to `report`.
264
+ */
265
+ export function identityMigrations(
266
+ rules: BackfillRules,
267
+ report?: (summary: BackfillSummary) => void,
268
+ ): Migration[] {
269
+ return [
270
+ { name: "principals", up: PRINCIPAL_TABLES },
271
+ { name: "principals-claimable", up: PRINCIPALS_CLAIMABLE },
272
+ { name: "principals-plugin-links", up: PRINCIPAL_PLUGIN_LINKS },
273
+ {
274
+ name: "backfill",
275
+ runs: "every-boot",
276
+ up: async (sql) => {
277
+ const summary = await backfillPrincipals(sql, rules);
278
+ report?.(summary);
279
+ },
280
+ },
281
+ ];
282
+ }