pi-roundtable 0.24.2 → 0.26.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 (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
@@ -0,0 +1,62 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+ import type { Speaker, Tier } from "../speakers.ts";
3
+ import type { ActorFacts } from "./actor-facts.ts";
4
+ import type { IdentityLink, Principal, RoleGrant } from "./principal-store.ts";
5
+
6
+ /**
7
+ * Who the host serves: its principals, their identities, and their roles, read-only. Provided as
8
+ * `IDENTITY` by the `identity` plugin; the principals are written by the host's own configuration
9
+ * and the `roundtable principal` CLI, never through it. A change another process makes is seen
10
+ * within half a minute.
11
+ */
12
+ export interface IdentityService {
13
+ /**
14
+ * The speaker behind these facts, or undefined when the access rules serve no one by them: a
15
+ * linked identity is its principal's; an unlinked one first claims the principal of its 0.8
16
+ * id (`legacyId`) when the backfill made it and no identity was ever linked to it, unless it
17
+ * holds the owner role, taking the facts' name when the backfill named it by its id, and is
18
+ * otherwise admitted as a new principal when the rules give it a tier. Both happen only when `provisioning` is `admitted` on the contact's surface; under `linked` only identities
19
+ * already linked are served. A disabled principal is no one. The tier is the higher of the
20
+ * principal's lasting roles and what the rules give the facts on this contact.
21
+ */
22
+ resolve(
23
+ facts: ActorFacts,
24
+ scope?: { conversation?: ChannelKey },
25
+ ): Promise<Speaker | undefined>;
26
+ /**
27
+ * The speaker these facts resolve to now, read only: no link, claim, admission, seen-record or
28
+ * touch, also on a refusal. Its tier is exactly what `resolve` would give (the lasting roles
29
+ * together with what the rules give these facts), never the tier last recorded. Undefined for an
30
+ * unlinked identity, a disabled principal, or no tier.
31
+ */
32
+ peek(
33
+ facts: ActorFacts,
34
+ scope?: { conversation?: ChannelKey },
35
+ ): Promise<Speaker | undefined>;
36
+ principal(id: string): Promise<Principal | undefined>;
37
+ /** Every principal, the oldest first. */
38
+ list(): Promise<readonly Principal[]>;
39
+ /** The identities linked to the principal. */
40
+ identities(principalId: string): Promise<readonly IdentityLink[]>;
41
+ /** The principal's lasting roles, granted by the configuration or the CLI. */
42
+ roles(principalId: string): Promise<readonly RoleGrant[]>;
43
+ /** The tier of the principal's lasting roles; undefined when they hold none, are disabled, or are unknown. */
44
+ tierOf(principalId: string): Promise<Tier | undefined>;
45
+ /**
46
+ * A speaker for a turn started on a principal's behalf, at `tier` or their own, whichever is
47
+ * lower. Their own is their lasting roles' tier, or, when their tier comes only from the rules
48
+ * on contact, the tier they were last seen at, refused once `access.backgroundStaleDays` pass
49
+ * unseen or once a contact of theirs is refused. Throws IdentityError for a principal that is
50
+ * unknown, disabled, or holds no tier, and for the system principal, whose turns only the core
51
+ * starts.
52
+ */
53
+ speakerFor(principalId: string, tier?: Tier): Promise<Speaker>;
54
+ /** The principals holding the owner role, the configured owners first in their order. */
55
+ owners(): Promise<readonly Principal[]>;
56
+ /**
57
+ * The id of the principal an identity, written `<provider>:<subject>`, is linked to, such as the
58
+ * one a plugin's declared identity stands for; undefined when it is linked to no one. Throws
59
+ * IdentityError for text that is no identity.
60
+ */
61
+ principalOf(identity: string): Promise<string | undefined>;
62
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "./principal-store.ts";
11
11
  import {
12
12
  PRINCIPAL_PLUGIN_LINKS,
13
+ PRINCIPAL_PROFILE_CHOICES,
13
14
  PRINCIPAL_SELF_LINKS,
14
15
  PRINCIPAL_TABLES,
15
16
  PRINCIPALS_CLAIMABLE,
@@ -272,6 +273,7 @@ export function identityMigrations(
272
273
  { name: "principals-claimable", up: PRINCIPALS_CLAIMABLE },
273
274
  { name: "principals-plugin-links", up: PRINCIPAL_PLUGIN_LINKS },
274
275
  { name: "principals-self-links", up: PRINCIPAL_SELF_LINKS },
276
+ { name: "principal-profile-choices", up: PRINCIPAL_PROFILE_CHOICES },
275
277
  {
276
278
  name: "backfill",
277
279
  runs: "every-boot",
@@ -17,6 +17,7 @@ import {
17
17
  import { CachingPrincipalStore } from "./caching-principal-store.ts";
18
18
  import { configuredOwnerId, refuseOwnerDeclarations } from "./config-owner.ts";
19
19
  import type { Contact, ContactAssessor } from "./contact.ts";
20
+ import type { IdentityService } from "./identity-port.ts";
20
21
  import {
21
22
  type DeclaredIdentity,
22
23
  refuseUnboundIdentities,
@@ -35,53 +36,7 @@ import {
35
36
  import { SeenThrottle } from "./seen-throttle.ts";
36
37
  import { isPrincipalId, newPrincipalId } from "./ulid.ts";
37
38
 
38
- /**
39
- * Who the host serves: its principals, their identities, and their roles, read-only. Provided as
40
- * `IDENTITY` by the `identity` plugin; the principals are written by the host's own configuration
41
- * and the `roundtable principal` CLI, never through it. A change another process makes is seen
42
- * within half a minute.
43
- */
44
- export interface IdentityService {
45
- /**
46
- * The speaker behind these facts, or undefined when the access rules serve no one by them: a
47
- * linked identity is its principal's; an unlinked one first claims the principal of its 0.8
48
- * id (`legacyId`) when the backfill made it and no identity was ever linked to it, unless it
49
- * holds the owner role, taking the facts' name when the backfill named it by its id, and is
50
- * otherwise admitted as a new principal when the rules give it a tier. Both happen only when `provisioning` is `admitted` on the contact's surface; under `linked` only identities
51
- * already linked are served. A disabled principal is no one. The tier is the higher of the
52
- * principal's lasting roles and what the rules give the facts on this contact.
53
- */
54
- resolve(
55
- facts: ActorFacts,
56
- scope?: { conversation?: ChannelKey },
57
- ): Promise<Speaker | undefined>;
58
- principal(id: string): Promise<Principal | undefined>;
59
- /** Every principal, the oldest first. */
60
- list(): Promise<readonly Principal[]>;
61
- /** The identities linked to the principal. */
62
- identities(principalId: string): Promise<readonly IdentityLink[]>;
63
- /** The principal's lasting roles, granted by the configuration or the CLI. */
64
- roles(principalId: string): Promise<readonly RoleGrant[]>;
65
- /** The tier of the principal's lasting roles; undefined when they hold none, are disabled, or are unknown. */
66
- tierOf(principalId: string): Promise<Tier | undefined>;
67
- /**
68
- * A speaker for a turn started on a principal's behalf, at `tier` or their own, whichever is
69
- * lower. Their own is their lasting roles' tier, or, when their tier comes only from the rules
70
- * on contact, the tier they were last seen at, refused once `access.backgroundStaleDays` pass
71
- * unseen or once a contact of theirs is refused. Throws IdentityError for a principal that is
72
- * unknown, disabled, or holds no tier, and for the system principal, whose turns only the core
73
- * starts.
74
- */
75
- speakerFor(principalId: string, tier?: Tier): Promise<Speaker>;
76
- /** The principals holding the owner role, the configured owners first in their order. */
77
- owners(): Promise<readonly Principal[]>;
78
- /**
79
- * The id of the principal an identity, written `<provider>:<subject>`, is linked to, such as the
80
- * one a plugin's declared identity stands for; undefined when it is linked to no one. Throws
81
- * IdentityError for text that is no identity.
82
- */
83
- principalOf(identity: string): Promise<string | undefined>;
84
- }
39
+ export type { IdentityService } from "./identity-port.ts";
85
40
 
86
41
  /** 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. */
87
42
  export function systemSpeaker(tier: Tier): Speaker {
@@ -97,8 +52,15 @@ const DAY_MS = 24 * 60 * 60_000;
97
52
  /** How many people assessed at a first contact but not yet taken are remembered. */
98
53
  const PROVISIONAL_MAX = 1_000;
99
54
 
55
+ /** What names a principal to the agents: the profile name when the host lists its provider as model-visible. */
56
+ export interface ModelNames {
57
+ modelName(principalId: string): Promise<string | undefined>;
58
+ }
59
+
100
60
  export interface IdentityServiceOptions {
101
61
  logger: Logger;
62
+ /** The profiles that name people to the agents; without them every name is the facts'. */
63
+ profiles?: ModelNames;
102
64
  /** The identities the plugins declare, checked by `declaredIdentities`; `syncConfig` links them. */
103
65
  identities?: readonly DeclaredIdentity[];
104
66
  /** The clock, in milliseconds; a test gives its own. */
@@ -111,6 +73,7 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
111
73
  readonly store: PrincipalStore;
112
74
  readonly #rules: AccessRules;
113
75
  readonly #logger: Logger;
76
+ readonly #profiles: ModelNames | undefined;
114
77
  readonly #now: () => number;
115
78
  /** When each principal is written as seen. */
116
79
  readonly #seen = new SeenThrottle();
@@ -140,6 +103,13 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
140
103
  ...this.#declared.map((declared) => declared.identity),
141
104
  ]);
142
105
  this.#logger = options.logger;
106
+ this.#profiles = options.profiles;
107
+ }
108
+
109
+ /** The speaker under the name the profiles give the principal, when the host lets agents see it. */
110
+ async #named(speaker: Speaker): Promise<Speaker> {
111
+ const name = await this.#profiles?.modelName(speaker.principalId);
112
+ return name ? { ...speaker, name } : speaker;
143
113
  }
144
114
 
145
115
  /** The refusals `syncConfig` makes from the configuration and the declarations alone, for `Roundtable.check()`. */
@@ -227,6 +197,19 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
227
197
  return (await this.assess(facts, scope))?.take();
228
198
  }
229
199
 
200
+ async peek(
201
+ facts: ActorFacts,
202
+ scope: { conversation?: ChannelKey } = {},
203
+ ): Promise<Speaker | undefined> {
204
+ if (facts.provider === LEGACY_PROVIDER) return undefined;
205
+ const link = await this.store.identity(facts.provider, facts.subject);
206
+ if (!link) return undefined;
207
+ const principal = await this.store.get(link.principalId);
208
+ if (!principal || principal.disabled) return undefined;
209
+ const tier = await this.#tier(principal.id, facts, scope.conversation);
210
+ return tier ? this.#named(speakerOf(facts, principal.id, tier)) : undefined;
211
+ }
212
+
230
213
  /**
231
214
  * The contact of these facts, read only: a linked identity's principal, else at a first contact
232
215
  * the principal they would claim or be admitted as, the same new id for the same person until
@@ -255,10 +238,19 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
255
238
  assessed = { principalId: principal.id, tier };
256
239
  } else assessed = await this.#firstContact(facts, conversation);
257
240
  if (!assessed) return undefined;
258
- const speaker = speakerOf(facts, assessed.principalId, assessed.tier);
241
+ const speaker = await this.#named(
242
+ speakerOf(facts, assessed.principalId, assessed.tier),
243
+ );
259
244
  return {
260
245
  speaker,
261
- take: () => this.#take(facts, conversation, assessed.principalId),
246
+ take: async () => {
247
+ const taken = await this.#take(
248
+ facts,
249
+ conversation,
250
+ assessed.principalId,
251
+ );
252
+ return taken && this.#named(taken);
253
+ },
262
254
  };
263
255
  }
264
256
 
@@ -308,12 +300,12 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
308
300
  throw new IdentityError(`principal ${principalId} is disabled`);
309
301
  const cap =
310
302
  (await this.#lastingTier(principalId)) ?? this.#lastSeenTier(principal);
311
- return {
303
+ return this.#named({
312
304
  id: principal.id,
313
305
  name: principal.displayName,
314
306
  tier: tier ? lowest(tier, cap) : cap,
315
307
  principalId: principal.id,
316
- };
308
+ });
317
309
  }
318
310
 
319
311
  async principalOf(identity: string): Promise<string | undefined> {
@@ -372,11 +364,12 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
372
364
  ]);
373
365
  }
374
366
 
375
- /** Whether the facts carry what the rules decide by: the roles, when the rules name a role of their surface. */
367
+ /** Whether the facts carry what the rules decide by: the roles, when the rules name a role of their surface; never for partial facts. */
376
368
  #knowsRoles(
377
369
  facts: ActorFacts,
378
370
  conversation: ChannelKey | undefined,
379
371
  ): boolean {
372
+ if (facts.partial) return false;
380
373
  if (facts.roles !== undefined) return true;
381
374
  const prefix = `${surfaceOf(facts, conversation)}:role:`;
382
375
  return ![this.#rules.admins, this.#rules.members].some((tier) =>
@@ -30,6 +30,7 @@ export function identityView(
30
30
  const view = Object.freeze({
31
31
  resolve: async (facts, scope) =>
32
32
  frozenCopy(await service.resolve(facts, scope)),
33
+ peek: async (facts, scope) => frozenCopy(await service.peek(facts, scope)),
33
34
  principal: async (id) => frozenCopy(await service.principal(id)),
34
35
  list: async () => frozenCopy(await service.list()),
35
36
  identities: async (principalId) =>
@@ -0,0 +1,275 @@
1
+ import type { SQL } from "bun";
2
+ import { IdentityError } from "../domain/errors.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import { ProfileChoices } from "./profile-choices.ts";
5
+ import { cleanName, ExpiringMap } from "./profile-memory.ts";
6
+ import {
7
+ fetchPicture,
8
+ type Picture,
9
+ pictureResponse,
10
+ } from "./profile-picture.ts";
11
+ import type {
12
+ PersonDisplay,
13
+ PersonProfiles,
14
+ ProfileRules,
15
+ ProfileSource,
16
+ } from "./profiles.ts";
17
+
18
+ const HOUR_MS = 60 * 60_000;
19
+ const MISS_MS = 10 * 60_000;
20
+ const CHOICE_MS = 30_000;
21
+ const FETCH_BUDGET_MS = 2_000;
22
+ const PICTURES_MAX_BYTES = 32 * 1024 * 1024;
23
+
24
+ /** What a person's linked identities and display name are, as the directory reads them. */
25
+ export interface ProfileLinks {
26
+ /** The providers and subjects linked to the principal. */
27
+ identities(
28
+ principalId: string,
29
+ ): Promise<readonly { provider: string; subject: string }[]>;
30
+ /** The principal's own display name; undefined for an unknown principal. */
31
+ displayName(principalId: string): Promise<string | undefined>;
32
+ }
33
+
34
+ export interface PersonProfilesOptions {
35
+ sql: SQL;
36
+ links: ProfileLinks;
37
+ rules: ProfileRules;
38
+ logger: Logger;
39
+ /** The clock, in milliseconds; a test gives its own. */
40
+ now?: () => number;
41
+ /** What a source's fetch may take; default two seconds. */
42
+ fetchBudgetMs?: number;
43
+ /** Replaces `fetch` for pictures, for a test. */
44
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>;
45
+ }
46
+
47
+ interface Fetched {
48
+ name?: string;
49
+ pictureUrl?: string;
50
+ }
51
+
52
+ /** A call that did not finish in its budget. */
53
+ class Slow extends Error {}
54
+
55
+ const sleepFor = (ms: number): { done: Promise<never>; cancel(): void } => {
56
+ let timer: ReturnType<typeof setTimeout> | undefined;
57
+ const done = new Promise<never>((_, reject) => {
58
+ timer = setTimeout(() => reject(new Slow("budget")), ms);
59
+ });
60
+ return { done, cancel: () => clearTimeout(timer) };
61
+ };
62
+
63
+ /**
64
+ * The directory behind `PROFILES`: sources, the names they and the sessions report (memory only,
65
+ * an hour at most), each person's stored choice, and the picture proxy. Names reach
66
+ * `Speaker.name` only through `modelName`, which the identity service calls, and only for a
67
+ * provider the host lists in `profiles.modelVisible`.
68
+ */
69
+ export class PersonDirectory implements PersonProfiles {
70
+ readonly #sources = new Map<string, ProfileSource>();
71
+ readonly #observed = new ExpiringMap<string>(10_000);
72
+ readonly #fetched = new ExpiringMap<Fetched | null>(10_000);
73
+ readonly #pending = new Map<string, Promise<Fetched | null>>();
74
+ readonly #pictures = new ExpiringMap<Picture | null>(512);
75
+ readonly #choices: ProfileChoices;
76
+ readonly #chosen = new ExpiringMap<string | null>(10_000);
77
+ readonly #links: ProfileLinks;
78
+ readonly #prefer: readonly string[];
79
+ readonly #modelVisible: ReadonlySet<string>;
80
+ readonly #logger: Logger;
81
+ readonly #now: () => number;
82
+ readonly #budget: number;
83
+ readonly #fetch: PersonProfilesOptions["fetch"];
84
+
85
+ constructor(options: PersonProfilesOptions) {
86
+ this.#choices = new ProfileChoices(options.sql);
87
+ this.#links = options.links;
88
+ this.#prefer = options.rules.prefer;
89
+ this.#modelVisible = new Set(options.rules.modelVisible);
90
+ this.#logger = options.logger;
91
+ this.#now = options.now ?? Date.now;
92
+ this.#budget = options.fetchBudgetMs ?? FETCH_BUDGET_MS;
93
+ this.#fetch = options.fetch;
94
+ }
95
+
96
+ register(source: ProfileSource): void {
97
+ if (source.provider.trim() === "")
98
+ throw new IdentityError("a profile source needs a provider");
99
+ if (this.#sources.has(source.provider))
100
+ throw new IdentityError(
101
+ `a profile source for ${source.provider} is already registered`,
102
+ );
103
+ this.#sources.set(source.provider, source);
104
+ }
105
+
106
+ sources(): readonly { provider: string; label: string }[] {
107
+ return [...this.#sources.values()].map(({ provider, label }) => ({
108
+ provider,
109
+ label,
110
+ }));
111
+ }
112
+
113
+ observed(principalId: string, provider: string, name: string): void {
114
+ const clean = cleanName(name);
115
+ if (clean)
116
+ this.#observed.set(
117
+ `${principalId}\0${provider}`,
118
+ clean,
119
+ this.#now(),
120
+ HOUR_MS,
121
+ );
122
+ }
123
+
124
+ async sourceOf(principalId: string): Promise<string | undefined> {
125
+ const linked = new Set(
126
+ (await this.#links.identities(principalId)).map((link) => link.provider),
127
+ );
128
+ const chosen = await this.#choice(principalId);
129
+ if (chosen && linked.has(chosen)) return chosen;
130
+ return this.#prefer.find((provider) => linked.has(provider));
131
+ }
132
+
133
+ async choose(principalId: string, provider: string | null): Promise<void> {
134
+ if ((await this.#links.displayName(principalId)) === undefined)
135
+ throw new IdentityError(`there is no principal ${principalId}`);
136
+ if (provider === null) await this.#choices.clear(principalId);
137
+ else {
138
+ const linked = (await this.#links.identities(principalId)).some(
139
+ (link) => link.provider === provider,
140
+ );
141
+ if (!linked)
142
+ throw new IdentityError(
143
+ `${provider} is not linked to principal ${principalId}, so it cannot name them`,
144
+ );
145
+ await this.#choices.set(principalId, provider);
146
+ }
147
+ this.#chosen.delete(principalId);
148
+ }
149
+
150
+ async of(principalId: string): Promise<PersonDisplay> {
151
+ const provider = await this.sourceOf(principalId);
152
+ const profile = provider
153
+ ? await this.#profile(principalId, provider)
154
+ : undefined;
155
+ if (provider && profile?.name)
156
+ return {
157
+ name: profile.name,
158
+ source: provider,
159
+ picture: profile.pictureUrl !== undefined,
160
+ };
161
+ return {
162
+ name: (await this.#links.displayName(principalId)) ?? principalId,
163
+ picture: false,
164
+ };
165
+ }
166
+
167
+ async picture(principalId: string): Promise<Response | undefined> {
168
+ const provider = await this.sourceOf(principalId);
169
+ const source = provider ? this.#sources.get(provider) : undefined;
170
+ if (!provider || !source) return undefined;
171
+ const address = (await this.#profile(principalId, provider))?.pictureUrl;
172
+ if (!address) return undefined;
173
+ const now = this.#now();
174
+ let picture = this.#pictures.get(address, now);
175
+ if (picture === undefined) {
176
+ picture =
177
+ (await fetchPicture(address, source.pictureHosts, this.#fetch)) ?? null;
178
+ const at = this.#now();
179
+ this.#pictures.set(address, picture, at, picture ? HOUR_MS : MISS_MS);
180
+ while (
181
+ this.#pictures.weigh((held) => held?.bytes.byteLength ?? 0) >
182
+ PICTURES_MAX_BYTES
183
+ )
184
+ if (!this.#pictures.evictOldest()) break;
185
+ }
186
+ return picture ? pictureResponse(picture) : undefined;
187
+ }
188
+
189
+ /**
190
+ * The name `Speaker.name` takes for the principal: their source's name when the host lists its
191
+ * provider in `profiles.modelVisible`, else undefined. Core-internal: `PROFILES` does not carry it.
192
+ */
193
+ async modelName(principalId: string): Promise<string | undefined> {
194
+ if (this.#modelVisible.size === 0) return undefined;
195
+ const provider = await this.sourceOf(principalId);
196
+ if (!provider || !this.#modelVisible.has(provider)) return undefined;
197
+ return (await this.#profile(principalId, provider))?.name;
198
+ }
199
+
200
+ async #choice(principalId: string): Promise<string | undefined> {
201
+ const now = this.#now();
202
+ const held = this.#chosen.get(principalId, now);
203
+ if (held !== undefined) return held ?? undefined;
204
+ const stored = (await this.#choices.get(principalId)) ?? null;
205
+ this.#chosen.set(principalId, stored, now, CHOICE_MS);
206
+ return stored ?? undefined;
207
+ }
208
+
209
+ /** The name a session reported or the source fetched for this provider, and the picture address. */
210
+ async #profile(
211
+ principalId: string,
212
+ provider: string,
213
+ ): Promise<Fetched | undefined> {
214
+ const observed = this.#observed.get(
215
+ `${principalId}\0${provider}`,
216
+ this.#now(),
217
+ );
218
+ const source = this.#sources.get(provider);
219
+ const link = (await this.#links.identities(principalId)).find(
220
+ (candidate) => candidate.provider === provider,
221
+ );
222
+ const fetched =
223
+ source && link ? await this.#fetchOnce(source, link.subject) : null;
224
+ const name = observed ?? fetched?.name;
225
+ if (!name) return undefined;
226
+ return {
227
+ name,
228
+ ...(fetched?.pictureUrl ? { pictureUrl: fetched.pictureUrl } : {}),
229
+ };
230
+ }
231
+
232
+ #fetchOnce(source: ProfileSource, subject: string): Promise<Fetched | null> {
233
+ const key = `${source.provider}\0${subject}`;
234
+ const held = this.#fetched.get(key, this.#now());
235
+ if (held !== undefined) return Promise.resolve(held);
236
+ const running = this.#pending.get(key);
237
+ if (running) return running;
238
+ const call = this.#ask(source, subject).then((found) => {
239
+ this.#fetched.set(key, found, this.#now(), found ? HOUR_MS : MISS_MS);
240
+ this.#pending.delete(key);
241
+ return found;
242
+ });
243
+ this.#pending.set(key, call);
244
+ return call;
245
+ }
246
+
247
+ /** One fetch inside its budget; a miss, a failure and a timeout all read as nothing. */
248
+ async #ask(source: ProfileSource, subject: string): Promise<Fetched | null> {
249
+ const controller = new AbortController();
250
+ const budget = sleepFor(this.#budget);
251
+ try {
252
+ const found = await Promise.race([
253
+ source.fetch(subject, controller.signal),
254
+ budget.done,
255
+ ]);
256
+ const name = cleanName(found?.name);
257
+ return found && (name || found.pictureUrl)
258
+ ? {
259
+ ...(name ? { name } : {}),
260
+ ...(found.pictureUrl ? { pictureUrl: found.pictureUrl } : {}),
261
+ }
262
+ : null;
263
+ } catch (error) {
264
+ // Only the provider and the kind of failure: no subject, no name, no address.
265
+ this.#logger.warn(
266
+ { provider: source.provider, slow: error instanceof Slow },
267
+ "a profile source gave no profile",
268
+ );
269
+ return null;
270
+ } finally {
271
+ budget.cancel();
272
+ controller.abort();
273
+ }
274
+ }
275
+ }
@@ -59,3 +59,13 @@ export const PRINCIPAL_SELF_LINKS = async (sql: SQL): Promise<void> => {
59
59
  ALTER TABLE principal_identities ADD CONSTRAINT principal_identities_source_check
60
60
  CHECK (source IN ('config', 'plugin', 'cli', 'jit', 'legacy', 'self'))`;
61
61
  };
62
+
63
+ /** Which linked account represents each person (`PROFILES.choose`): only the choice, never a name or a picture address. */
64
+ export const PRINCIPAL_PROFILE_CHOICES = async (sql: SQL): Promise<void> => {
65
+ await sql`
66
+ CREATE TABLE IF NOT EXISTS principal_profile_choices (
67
+ principal_id text PRIMARY KEY REFERENCES principals (id) ON DELETE CASCADE,
68
+ provider text NOT NULL,
69
+ chosen_at timestamptz NOT NULL DEFAULT now()
70
+ )`;
71
+ };
@@ -0,0 +1,45 @@
1
+ import type { SQL } from "bun";
2
+
3
+ /**
4
+ * Which linked account represents each person, the one thing about profiles that is stored. A
5
+ * name or a picture address is never written to a table.
6
+ */
7
+ export class ProfileChoices {
8
+ readonly #sql: SQL;
9
+
10
+ constructor(sql: SQL) {
11
+ this.#sql = sql;
12
+ }
13
+
14
+ async get(principalId: string): Promise<string | undefined> {
15
+ const rows: { provider: string }[] = await this.#sql`
16
+ SELECT provider FROM principal_profile_choices WHERE principal_id = ${principalId}`;
17
+ return rows[0]?.provider;
18
+ }
19
+
20
+ async set(principalId: string, provider: string): Promise<void> {
21
+ await this.#sql`
22
+ INSERT INTO principal_profile_choices (principal_id, provider, chosen_at)
23
+ VALUES (${principalId}, ${provider}, now())
24
+ ON CONFLICT (principal_id)
25
+ DO UPDATE SET provider = EXCLUDED.provider, chosen_at = EXCLUDED.chosen_at`;
26
+ }
27
+
28
+ async clear(principalId: string): Promise<void> {
29
+ await this.#sql`
30
+ DELETE FROM principal_profile_choices WHERE principal_id = ${principalId}`;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The provider a principal chose, read for the CLI beside a host that may not have made the table
36
+ * yet; undefined for no choice or no table.
37
+ */
38
+ export async function chosenProfileSource(
39
+ sql: SQL,
40
+ principalId: string,
41
+ ): Promise<string | undefined> {
42
+ const [table] = await sql`
43
+ SELECT to_regclass('principal_profile_choices') IS NOT NULL AS made`;
44
+ return table?.made ? new ProfileChoices(sql).get(principalId) : undefined;
45
+ }
@@ -0,0 +1,65 @@
1
+ /** A map whose entries expire, holding at most `max`; the oldest goes first. Memory only. */
2
+ export class ExpiringMap<V> {
3
+ readonly #entries = new Map<string, { value: V; until: number }>();
4
+ readonly #max: number;
5
+
6
+ constructor(max: number) {
7
+ this.#max = max;
8
+ }
9
+
10
+ get(key: string, now: number): V | undefined {
11
+ const entry = this.#entries.get(key);
12
+ if (!entry) return undefined;
13
+ if (entry.until <= now) {
14
+ this.#entries.delete(key);
15
+ return undefined;
16
+ }
17
+ return entry.value;
18
+ }
19
+
20
+ set(key: string, value: V, now: number, ttlMs: number): void {
21
+ this.#entries.delete(key);
22
+ this.#entries.set(key, { value, until: now + ttlMs });
23
+ for (const old of this.#entries.keys()) {
24
+ if (this.#entries.size <= this.#max) break;
25
+ this.#entries.delete(old);
26
+ }
27
+ }
28
+
29
+ delete(key: string): void {
30
+ this.#entries.delete(key);
31
+ }
32
+
33
+ /** The summed `weight` of what is held, for a cache bounded by size. */
34
+ weigh(weight: (value: V) => number): number {
35
+ let total = 0;
36
+ for (const entry of this.#entries.values()) total += weight(entry.value);
37
+ return total;
38
+ }
39
+
40
+ /** Drops the oldest entry; false when there is none. */
41
+ evictOldest(): boolean {
42
+ for (const old of this.#entries.keys()) {
43
+ this.#entries.delete(old);
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ }
49
+
50
+ const NAME_MAX = 80;
51
+
52
+ /**
53
+ * A name a provider or a session reported, made safe to show and to give an agent: control and
54
+ * invisible formatting characters become spaces, runs of space one, at most 80 characters.
55
+ * Undefined when nothing is left.
56
+ */
57
+ export function cleanName(value: unknown): string | undefined {
58
+ if (typeof value !== "string") return undefined;
59
+ const text = value
60
+ .replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
61
+ .replace(/\s+/g, " ")
62
+ .trim();
63
+ if (text === "") return undefined;
64
+ return [...text].slice(0, NAME_MAX).join("").trim() || undefined;
65
+ }