pi-roundtable 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/docs/plugins.md +156 -6
- package/package.json +1 -1
- package/src/cli/checks/agent-tools.ts +23 -0
- package/src/cli/doctor.ts +6 -0
- package/src/cli/principal.ts +9 -2
- package/src/core/agents/agent-prompt.ts +6 -1
- package/src/core/agents/agent-team.ts +2 -0
- package/src/core/agents/team-editing.ts +4 -0
- package/src/core/agents/team-options.ts +3 -1
- package/src/core/agents/team-selection.ts +6 -2
- package/src/core/builtin/agent-server.ts +1 -0
- package/src/core/builtin/channel-context.ts +21 -0
- package/src/core/builtin/discord.ts +4 -1
- package/src/core/config/config-schema.ts +22 -6
- package/src/core/config/config.ts +38 -2
- package/src/core/config/conversations.ts +40 -0
- package/src/core/config/profiles.ts +53 -0
- package/src/core/contract/channel-context.ts +106 -0
- package/src/core/contract/surface.ts +16 -0
- package/src/core/conversations/conversation-registry.ts +6 -0
- package/src/core/conversations/conversation-retention.ts +123 -0
- package/src/core/conversations/conversation-store.ts +6 -0
- package/src/core/conversations/conversations-plugin.ts +25 -3
- package/src/core/define-roundtable.ts +13 -2
- package/src/core/discord/channel-context.ts +47 -78
- package/src/core/discord/discord-surface.ts +16 -0
- package/src/core/events.ts +1 -0
- package/src/core/holds.ts +9 -0
- package/src/core/host-registration.ts +4 -0
- package/src/core/host.ts +3 -1
- package/src/core/identity/actor-facts.ts +7 -0
- package/src/core/identity/identity-plugin.ts +27 -5
- package/src/core/identity/identity-port.ts +62 -0
- package/src/core/identity/identity-schema.ts +2 -0
- package/src/core/identity/identity-service.ts +45 -52
- package/src/core/identity/identity-view.ts +1 -0
- package/src/core/identity/person-profiles.ts +275 -0
- package/src/core/identity/principal-tables.ts +10 -0
- package/src/core/identity/profile-choices.ts +45 -0
- package/src/core/identity/profile-memory.ts +65 -0
- package/src/core/identity/profile-picture.ts +117 -0
- package/src/core/identity/profiles-view.ts +20 -0
- package/src/core/identity/profiles.ts +82 -0
- package/src/core/modules/host-shell/shell-policy.ts +18 -3
- package/src/core/plugin.ts +40 -0
- package/src/core/registry/contributions.ts +8 -1
- package/src/core/runtime/chat-only.ts +32 -0
- package/src/core/runtime/conversation-sessions.ts +2 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +5 -9
- package/src/core/runtime/extensions/gate-refusals.ts +32 -0
- package/src/core/runtime/extensions/hold-lookups.ts +10 -2
- package/src/core/runtime/extensions/late-answer.ts +5 -2
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +3 -1
- package/src/core/runtime/session-factory.ts +19 -30
- package/src/core/runtime/session-helpers.ts +27 -0
- package/src/core/services.ts +9 -0
- package/src/index.ts +18 -0
- package/src/kit/index.ts +1 -0
- package/src/kit/oidc.ts +16 -0
- package/src/testing.ts +1 -0
|
@@ -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 =
|
|
241
|
+
const speaker = await this.#named(
|
|
242
|
+
speakerOf(facts, assessed.principalId, assessed.tier),
|
|
243
|
+
);
|
|
259
244
|
return {
|
|
260
245
|
speaker,
|
|
261
|
-
take: () =>
|
|
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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** The most a picture may weigh. */
|
|
2
|
+
export const PICTURE_MAX_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
const PICTURE_BUDGET_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
/** A picture the proxy accepted: its bytes and the type they really are. */
|
|
6
|
+
export interface Picture {
|
|
7
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
8
|
+
type: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
|
|
13
|
+
/** The type a body's first bytes show, for the four accepted formats. */
|
|
14
|
+
function sniff(bytes: Uint8Array): string | undefined {
|
|
15
|
+
const starts = (...values: number[]) =>
|
|
16
|
+
values.every((value, i) => bytes[i] === value);
|
|
17
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
|
18
|
+
return "image/png";
|
|
19
|
+
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
|
20
|
+
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
|
|
21
|
+
if (
|
|
22
|
+
starts(0x52, 0x49, 0x46, 0x46) &&
|
|
23
|
+
bytes[8] === 0x57 &&
|
|
24
|
+
bytes[9] === 0x45 &&
|
|
25
|
+
bytes[10] === 0x42 &&
|
|
26
|
+
bytes[11] === 0x50
|
|
27
|
+
)
|
|
28
|
+
return "image/webp";
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reads a body up to the cap; undefined when it is longer. */
|
|
33
|
+
async function readCapped(
|
|
34
|
+
response: Response,
|
|
35
|
+
): Promise<Uint8Array<ArrayBuffer> | undefined> {
|
|
36
|
+
const length = Number(response.headers.get("content-length"));
|
|
37
|
+
if (Number.isFinite(length) && length > PICTURE_MAX_BYTES) {
|
|
38
|
+
await response.body?.cancel().catch(() => undefined);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const reader = response.body?.getReader();
|
|
42
|
+
if (!reader) return undefined;
|
|
43
|
+
const chunks: Uint8Array[] = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
total += value.byteLength;
|
|
49
|
+
if (total > PICTURE_MAX_BYTES) {
|
|
50
|
+
await reader.cancel().catch(() => undefined);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
chunks.push(value);
|
|
54
|
+
}
|
|
55
|
+
const bytes = new Uint8Array(total);
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const chunk of chunks) {
|
|
58
|
+
bytes.set(chunk, at);
|
|
59
|
+
at += chunk.byteLength;
|
|
60
|
+
}
|
|
61
|
+
return bytes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fetches a picture for the proxy: only an `https` address on one of the allowed hosts, no
|
|
66
|
+
* redirect followed, only PNG, JPEG, GIF or WebP whose header and first bytes agree (so an SVG is
|
|
67
|
+
* refused), at most 2 MiB. Undefined for anything else, a failure included.
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchPicture(
|
|
70
|
+
address: string,
|
|
71
|
+
hosts: readonly string[],
|
|
72
|
+
fetchImpl: Fetch = fetch,
|
|
73
|
+
): Promise<Picture | undefined> {
|
|
74
|
+
const url = URL.parse(address);
|
|
75
|
+
if (!url) return undefined;
|
|
76
|
+
if (
|
|
77
|
+
url.protocol !== "https:" ||
|
|
78
|
+
url.username !== "" ||
|
|
79
|
+
url.password !== "" ||
|
|
80
|
+
url.port !== "" ||
|
|
81
|
+
!hosts.some((host) => host.toLowerCase() === url.hostname)
|
|
82
|
+
)
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetchImpl(url.href, {
|
|
86
|
+
redirect: "manual",
|
|
87
|
+
signal: AbortSignal.timeout(PICTURE_BUDGET_MS),
|
|
88
|
+
headers: { accept: "image/png,image/jpeg,image/gif,image/webp" },
|
|
89
|
+
});
|
|
90
|
+
if (response.status !== 200) {
|
|
91
|
+
await response.body?.cancel().catch(() => undefined);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const declared = (response.headers.get("content-type") ?? "")
|
|
95
|
+
.split(";")[0]
|
|
96
|
+
?.trim()
|
|
97
|
+
.toLowerCase();
|
|
98
|
+
const bytes = await readCapped(response);
|
|
99
|
+
if (!bytes) return undefined;
|
|
100
|
+
const type = sniff(bytes);
|
|
101
|
+
return type && type === declared ? { bytes, type } : undefined;
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The proxy's answer: `nosniff`, kept by the one browser for an hour. */
|
|
108
|
+
export function pictureResponse(picture: Picture): Response {
|
|
109
|
+
return new Response(picture.bytes, {
|
|
110
|
+
headers: {
|
|
111
|
+
"content-type": picture.type,
|
|
112
|
+
"content-length": String(picture.bytes.byteLength),
|
|
113
|
+
"x-content-type-options": "nosniff",
|
|
114
|
+
"cache-control": "private, max-age=3600",
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|