pi-roundtable 0.25.0 → 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.
- package/CHANGELOG.md +27 -0
- package/docs/plugins.md +129 -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 +30 -2
- package/src/core/config/conversations.ts +21 -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 +107 -0
- package/src/core/conversations/conversation-store.ts +6 -0
- package/src/core/conversations/conversations-plugin.ts +19 -3
- package/src/core/define-roundtable.ts +9 -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/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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PersonProfiles } from "./profiles.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The directory as plugins get it: the `PersonProfiles` methods and nothing else, so no plugin
|
|
5
|
+
* reaches the name the core gives agents. Each answer is a frozen copy.
|
|
6
|
+
*/
|
|
7
|
+
export function profilesView(directory: PersonProfiles): PersonProfiles {
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
register: (source) => directory.register(source),
|
|
10
|
+
sources: () =>
|
|
11
|
+
Object.freeze(directory.sources().map((s) => Object.freeze({ ...s }))),
|
|
12
|
+
observed: (principalId, provider, name) =>
|
|
13
|
+
directory.observed(principalId, provider, name),
|
|
14
|
+
sourceOf: (principalId) => directory.sourceOf(principalId),
|
|
15
|
+
choose: (principalId, provider) => directory.choose(principalId, provider),
|
|
16
|
+
of: async (principalId) =>
|
|
17
|
+
Object.freeze({ ...(await directory.of(principalId)) }),
|
|
18
|
+
picture: (principalId) => directory.picture(principalId),
|
|
19
|
+
} satisfies PersonProfiles);
|
|
20
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the core names a person. A person's accounts each carry a name and maybe a picture; the
|
|
3
|
+
* person picks which account represents them (or the host's `profiles.prefer` order does), and
|
|
4
|
+
* the core shows that name where it names the person: the web app, transcripts, and, when the
|
|
5
|
+
* host lists the provider in `profiles.modelVisible`, the `Speaker.name` the agents read.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where a provider's names and pictures come from. Example: a support inbox registers a
|
|
10
|
+
* `github` source that reads a user's public profile.
|
|
11
|
+
*/
|
|
12
|
+
export interface ProfileSource {
|
|
13
|
+
/** The identity provider the source reads, an open string such as `line`, `discord` or `github`. */
|
|
14
|
+
provider: string;
|
|
15
|
+
/** What the person sees when choosing, such as "LINE". */
|
|
16
|
+
label: string;
|
|
17
|
+
/**
|
|
18
|
+
* The source's own default. It never widens anything on its own: the names reach
|
|
19
|
+
* `Speaker.name` only for a provider the host lists in `profiles.modelVisible`.
|
|
20
|
+
*/
|
|
21
|
+
modelVisible: boolean;
|
|
22
|
+
/** The https hosts its pictures come from; the picture proxy fetches only these. */
|
|
23
|
+
pictureHosts: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* The name and picture address of one account, or undefined when the provider knows none. It
|
|
26
|
+
* has two seconds; a miss, a failure or a timeout is remembered for ten minutes.
|
|
27
|
+
*/
|
|
28
|
+
fetch(
|
|
29
|
+
subject: string,
|
|
30
|
+
signal: AbortSignal,
|
|
31
|
+
): Promise<{ name?: string; pictureUrl?: string } | undefined>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How one person is shown. */
|
|
35
|
+
export interface PersonDisplay {
|
|
36
|
+
name: string;
|
|
37
|
+
/** The provider the name comes from; absent when it is the principal's own display name. */
|
|
38
|
+
source?: string;
|
|
39
|
+
/** Whether `picture` has one to give. */
|
|
40
|
+
picture: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The names and pictures of the people the host serves, provided as `PROFILES` by the `identity`
|
|
45
|
+
* plugin. Fetched and observed names and picture addresses live in memory for at most an hour and
|
|
46
|
+
* are never stored; only a person's choice of source is.
|
|
47
|
+
*/
|
|
48
|
+
export interface PersonProfiles {
|
|
49
|
+
/** Adds a source; call it from a plugin's `setup`. A second source for one provider throws. */
|
|
50
|
+
register(source: ProfileSource): void;
|
|
51
|
+
/** The registered sources. */
|
|
52
|
+
sources(): readonly { provider: string; label: string }[];
|
|
53
|
+
/**
|
|
54
|
+
* A name a signed-in session reported for one of the person's linked identities, such as an
|
|
55
|
+
* OIDC `name` claim. It is held in memory for an hour, renewed by each sign-in, and never
|
|
56
|
+
* stored, so an identity provider becomes a source without a fetch.
|
|
57
|
+
*/
|
|
58
|
+
observed(principalId: string, provider: string, name: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* The provider that names the person: their stored choice while it stays linked, else the
|
|
61
|
+
* first `profiles.prefer` provider they have linked, else undefined.
|
|
62
|
+
*/
|
|
63
|
+
sourceOf(principalId: string): Promise<string | undefined>;
|
|
64
|
+
/** Stores the person's choice; the provider must be linked to them. `null` returns to the default. */
|
|
65
|
+
choose(principalId: string, provider: string | null): Promise<void>;
|
|
66
|
+
/** How the person is shown; their display name when no source names them. */
|
|
67
|
+
of(principalId: string): Promise<PersonDisplay>;
|
|
68
|
+
/**
|
|
69
|
+
* The person's picture, fetched through the source's allowed hosts, or undefined: an address
|
|
70
|
+
* on another host, a redirect, anything but PNG, JPEG, GIF or WebP, and a body over 2 MiB are
|
|
71
|
+
* all refused. The answer is `nosniff` and cacheable for an hour by the one browser.
|
|
72
|
+
*/
|
|
73
|
+
picture(principalId: string): Promise<Response | undefined>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `profiles` key of the host's configuration, filled. */
|
|
77
|
+
export interface ProfileRules {
|
|
78
|
+
/** Providers in the order that names a person who has not chosen, such as `["line", "oidc:<base64url issuer>"]`. */
|
|
79
|
+
prefer: readonly string[];
|
|
80
|
+
/** Providers whose names may become `Speaker.name`, which agents read. */
|
|
81
|
+
modelVisible: readonly string[];
|
|
82
|
+
}
|
|
@@ -33,7 +33,22 @@ export type { PushPolicy } from "./git-push.ts";
|
|
|
33
33
|
export { simpleCommands } from "./shell-lexer.ts";
|
|
34
34
|
export { YOLO_CARDED_SHELL_CLASSES } from "./shell-programs.ts";
|
|
35
35
|
|
|
36
|
-
const MAX_SHOWN_COMMAND =
|
|
36
|
+
const MAX_SHOWN_COMMAND = 120;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The command as a card's sentence shows it: its first line, cut to a length, in one code span.
|
|
40
|
+
* The card shows the whole command beside it; a second line or a backtick here would break the
|
|
41
|
+
* sentence's Markdown (a `# comment` line reads as a heading).
|
|
42
|
+
*/
|
|
43
|
+
function shownCommand(command: string): string {
|
|
44
|
+
const [line = ""] = command.trim().split("\n", 1);
|
|
45
|
+
const cut = line.length > MAX_SHOWN_COMMAND;
|
|
46
|
+
const more = cut || line.length < command.trim().length;
|
|
47
|
+
const shown = (cut ? line.slice(0, MAX_SHOWN_COMMAND) : line)
|
|
48
|
+
.replaceAll("`", "'")
|
|
49
|
+
.trimEnd();
|
|
50
|
+
return more ? `${shown} …` : shown;
|
|
51
|
+
}
|
|
37
52
|
|
|
38
53
|
/** Pi's built-in shell and file tools, which agent sessions activate. */
|
|
39
54
|
export const SHELL_TOOLS = Object.freeze([
|
|
@@ -382,7 +397,7 @@ function shellHold(
|
|
|
382
397
|
let detail = `the ${toolName} call`;
|
|
383
398
|
try {
|
|
384
399
|
if (toolName === "bash" && typeof input.command === "string")
|
|
385
|
-
detail = `the shell command \`${input.command
|
|
400
|
+
detail = `the shell command \`${shownCommand(input.command)}\``;
|
|
386
401
|
} catch {
|
|
387
402
|
// The input itself cannot be read; the call is held without showing it.
|
|
388
403
|
}
|
|
@@ -410,7 +425,7 @@ function judge(
|
|
|
410
425
|
const [first] = found;
|
|
411
426
|
return first
|
|
412
427
|
? {
|
|
413
|
-
action: `run the shell command \`${command
|
|
428
|
+
action: `run the shell command \`${shownCommand(command)}\` on the host (${first.reason})`,
|
|
414
429
|
classes: [...new Set(found.map((held) => held.class))],
|
|
415
430
|
}
|
|
416
431
|
: undefined;
|