pi-roundtable-web 0.7.19 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +72 -34
- package/dist/chunk-0vqnvf92.js +36 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/api-types.ts +33 -3
- package/src/cloudflare-access.ts +35 -9
- package/src/console-api.ts +155 -41
- package/src/console-server.ts +35 -13
- package/src/conversations.ts +49 -5
- package/src/index.ts +7 -2
- package/src/options.ts +11 -5
- package/src/verifier.ts +23 -5
- package/src/visitors.ts +89 -0
- package/src/web-plugin.ts +22 -5
- package/dist/chunk-mr32cg74.js +0 -36
package/src/conversations.ts
CHANGED
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
statSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
type ChannelKey,
|
|
14
|
+
type ConversationRecord,
|
|
15
|
+
parseChannelKey,
|
|
16
|
+
} from "pi-roundtable";
|
|
17
|
+
import { channelSegment, textOf } from "pi-roundtable/kit";
|
|
14
18
|
import type { TranscriptEntry } from "./api-types.ts";
|
|
15
19
|
|
|
16
20
|
const FIRST_MESSAGE_CHARS = 80;
|
|
@@ -41,8 +45,11 @@ export interface ConversationFiles {
|
|
|
41
45
|
lastActive?: string;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The kinds of session directory the console reads: the three it recognises by name, and any
|
|
50
|
+
* other conversation the host's registry records.
|
|
51
|
+
*/
|
|
52
|
+
export type StoredKind = "discord" | "group" | "mcp" | "registered";
|
|
46
53
|
|
|
47
54
|
/** An owner, agent, group-member, or outside-agent conversation found on disk. */
|
|
48
55
|
export interface StoredConversation extends ConversationFiles {
|
|
@@ -52,9 +59,11 @@ export interface StoredConversation extends ConversationFiles {
|
|
|
52
59
|
id: string;
|
|
53
60
|
/** Group conversations only: the agent whose conversation inside the group this is. */
|
|
54
61
|
member?: string;
|
|
55
|
-
/** Outside-agent conversations only. */
|
|
62
|
+
/** Outside-agent and registered conversations only. */
|
|
56
63
|
firstMessage?: string;
|
|
57
64
|
startedAt?: string;
|
|
65
|
+
/** Registered conversations only: the name the registry gives it. */
|
|
66
|
+
title?: string;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
/** The session directory a channel key names, or undefined when the key is not one the console reads. */
|
|
@@ -253,6 +262,41 @@ export function storedConversations(
|
|
|
253
262
|
);
|
|
254
263
|
}
|
|
255
264
|
|
|
265
|
+
/** The session directory of a conversation the registry records, by its key. */
|
|
266
|
+
export function registeredDir(key: ChannelKey): string {
|
|
267
|
+
return channelSegment(key);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The conversations the host's registry records and the directory scan does not recognise, with
|
|
272
|
+
* their files; one whose first turn left no session file has nothing to read and is left out.
|
|
273
|
+
*/
|
|
274
|
+
export function registeredConversations(
|
|
275
|
+
sessionsDir: string,
|
|
276
|
+
records: readonly ConversationRecord[],
|
|
277
|
+
options: {
|
|
278
|
+
excluded: (key: ChannelKey) => boolean;
|
|
279
|
+
relayNotes: readonly string[];
|
|
280
|
+
},
|
|
281
|
+
): StoredConversation[] {
|
|
282
|
+
const found: StoredConversation[] = [];
|
|
283
|
+
for (const record of records) {
|
|
284
|
+
if (parseKey(record.key) || options.excluded(record.key)) continue;
|
|
285
|
+
const dir = join(sessionsDir, registeredDir(record.key));
|
|
286
|
+
const files = conversationFiles(dir);
|
|
287
|
+
if (!files) continue;
|
|
288
|
+
found.push({
|
|
289
|
+
key: record.key,
|
|
290
|
+
kind: "registered",
|
|
291
|
+
id: parseChannelKey(record.key).id,
|
|
292
|
+
...files,
|
|
293
|
+
...opening(dir, options.relayNotes),
|
|
294
|
+
...(record.title === undefined ? {} : { title: record.title }),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return found;
|
|
298
|
+
}
|
|
299
|
+
|
|
256
300
|
/** The archive folders of a conversation, newest first. */
|
|
257
301
|
export function archiveNames(dir: string): string[] {
|
|
258
302
|
const archiveDir = join(dir, "archive");
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type {
|
|
|
18
18
|
OverviewView,
|
|
19
19
|
PaneName,
|
|
20
20
|
PartyView,
|
|
21
|
+
PrincipalName,
|
|
22
|
+
PrincipalsView,
|
|
21
23
|
SkillDetailView,
|
|
22
24
|
SkillView,
|
|
23
25
|
TranscriptEntry,
|
|
@@ -26,12 +28,15 @@ export type {
|
|
|
26
28
|
} from "./api-types.ts";
|
|
27
29
|
export { PANES } from "./api-types.ts";
|
|
28
30
|
export type { CloudflareAccessOptions } from "./cloudflare-access.ts";
|
|
29
|
-
export {
|
|
31
|
+
export {
|
|
32
|
+
cloudflareAccess,
|
|
33
|
+
cloudflareAccessIdentity,
|
|
34
|
+
} from "./cloudflare-access.ts";
|
|
30
35
|
/** Summary for a trusted host's separately stored party sessions. */
|
|
31
36
|
export { conversationFiles as sessionSummary } from "./conversations.ts";
|
|
32
37
|
export type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
|
|
33
38
|
export type { WebConsoleOptions } from "./options.ts";
|
|
34
39
|
export { DEFAULT_RELAY_NOTE } from "./options.ts";
|
|
35
40
|
export type { RequestVerifier, Verdict } from "./verifier.ts";
|
|
36
|
-
export { admit, refuse } from "./verifier.ts";
|
|
41
|
+
export { admit, admitAs, refuse } from "./verifier.ts";
|
|
37
42
|
export { webConsole } from "./web-plugin.ts";
|
package/src/options.ts
CHANGED
|
@@ -14,8 +14,10 @@ export const DEFAULT_RELAY_NOTE =
|
|
|
14
14
|
|
|
15
15
|
export interface WebConsoleOptions {
|
|
16
16
|
/**
|
|
17
|
-
* Decides whether a request comes from the
|
|
18
|
-
* built-in one. Without it the plugin refuses to
|
|
17
|
+
* Decides whether a request comes from someone the operator's proxy authenticated, and reports
|
|
18
|
+
* who; required. `cloudflareAccess(...)` is the built-in one. Without it the plugin refuses to
|
|
19
|
+
* start rather than serve without authentication. Only a person whose principal holds the owner
|
|
20
|
+
* role gets in.
|
|
19
21
|
*/
|
|
20
22
|
verifier: RequestVerifier;
|
|
21
23
|
/** Host-specific data and actions, resolved against public plugin setup context. */
|
|
@@ -31,7 +33,11 @@ export interface WebConsoleOptions {
|
|
|
31
33
|
* must carry it as their `Origin`, and the dashboard line links to it.
|
|
32
34
|
*/
|
|
33
35
|
origin: string;
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Deprecated: the owner's principal that a verifier reporting no actor speaks for; default the
|
|
38
|
+
* primary owner (the first of `access.owners`). The notes pane shows each visitor their own
|
|
39
|
+
* notes, and any principal's on request.
|
|
40
|
+
*/
|
|
35
41
|
ownerId?: string;
|
|
36
42
|
/** The host's data directory, whose `sessions/` holds the conversations; default `./data`. */
|
|
37
43
|
dataDir?: string;
|
|
@@ -145,9 +151,9 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
|
|
|
145
151
|
}
|
|
146
152
|
const panes = checkPanes(options.panes);
|
|
147
153
|
const ownerId = options.ownerId?.trim();
|
|
148
|
-
if (
|
|
154
|
+
if (options.ownerId !== undefined && !ownerId)
|
|
149
155
|
fail(
|
|
150
|
-
"ownerId is
|
|
156
|
+
"ownerId is empty. Leave it out: the console finds each visitor's principal, and a verifier that reports no actor speaks for the primary owner",
|
|
151
157
|
);
|
|
152
158
|
const title = options.title?.trim() ?? "Roundtable";
|
|
153
159
|
if (!title) fail("title is empty");
|
package/src/verifier.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import type { ActorFacts } from "pi-roundtable";
|
|
2
|
+
|
|
1
3
|
/** The verdict on one request: admitted, or refused with a reason for the log. */
|
|
2
4
|
export type Verdict =
|
|
3
|
-
| {
|
|
5
|
+
| {
|
|
6
|
+
admitted: true;
|
|
7
|
+
/**
|
|
8
|
+
* Who signed in, as the proxy vouched for them; the console finds their principal through
|
|
9
|
+
* `IDENTITY` and admits only an owner. Without it, the request is taken as the primary
|
|
10
|
+
* owner's, as before 0.9, and the console warns once.
|
|
11
|
+
*/
|
|
12
|
+
actor?: ActorFacts;
|
|
13
|
+
}
|
|
4
14
|
| {
|
|
5
15
|
admitted: false;
|
|
6
16
|
/** Why, for the operator's log. It is never sent to the client, and it must not hold a secret. */
|
|
@@ -8,14 +18,22 @@ export type Verdict =
|
|
|
8
18
|
};
|
|
9
19
|
|
|
10
20
|
/**
|
|
11
|
-
* Decides whether a request comes from the
|
|
12
|
-
* its path, before anything else, and refuses with 403
|
|
13
|
-
*
|
|
14
|
-
* owner
|
|
21
|
+
* Decides whether a request comes from someone the operator's proxy authenticated, and who. The
|
|
22
|
+
* console asks it for every request under its path, before anything else, and refuses with 403
|
|
23
|
+
* when it says no, throws, or rejects; then it admits only a person whose principal holds the
|
|
24
|
+
* owner role. A verifier reads the request's headers, which the operator's proxy set after
|
|
25
|
+
* authenticating the person, and must never trust a header a client can forge: see "Threat model"
|
|
26
|
+
* in the README.
|
|
15
27
|
*/
|
|
16
28
|
export type RequestVerifier = (request: Request) => Verdict | Promise<Verdict>;
|
|
17
29
|
|
|
30
|
+
/** Admits the request without saying who it is from, as a verifier before 0.9 did. */
|
|
18
31
|
export const admit = (): Verdict => ({ admitted: true });
|
|
32
|
+
/** Admits the request as coming from the person these facts describe. */
|
|
33
|
+
export const admitAs = (actor: ActorFacts): Verdict => ({
|
|
34
|
+
admitted: true,
|
|
35
|
+
actor,
|
|
36
|
+
});
|
|
19
37
|
export const refuse = (reason: string): Verdict => ({
|
|
20
38
|
admitted: false,
|
|
21
39
|
reason,
|
package/src/visitors.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActorFacts,
|
|
3
|
+
IdentityService,
|
|
4
|
+
Logger,
|
|
5
|
+
Principal,
|
|
6
|
+
} from "pi-roundtable";
|
|
7
|
+
|
|
8
|
+
/** Who a request the console admitted comes from: an owner's principal. */
|
|
9
|
+
export interface Visitor {
|
|
10
|
+
principal: Principal;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The visitor of a request, or why it is refused, for the operator's log. */
|
|
14
|
+
export type Identified = { visitor: Visitor } | { refusal: string };
|
|
15
|
+
|
|
16
|
+
/** The reads of `IDENTITY` the console uses; none of them makes or changes a principal. */
|
|
17
|
+
export type ConsoleIdentity = Pick<
|
|
18
|
+
IdentityService,
|
|
19
|
+
"principalOf" | "principal" | "tierOf" | "owners"
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export interface VisitorOptions {
|
|
23
|
+
/** Whom a request from a verifier that reports no actor stands for; default the primary owner. */
|
|
24
|
+
ownerId?: string;
|
|
25
|
+
logger: Logger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The provider of the aliases 0.9 keeps for 0.8 ids; `IDENTITY.resolve` refuses it as no surface's. */
|
|
29
|
+
const LEGACY_PROVIDER = "legacy";
|
|
30
|
+
|
|
31
|
+
/** How many unlinked identities are remembered, so each is warned about once. */
|
|
32
|
+
const WARNED_MAX = 100;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Identifies the person behind each request the verifier admitted, by reading `IDENTITY` only, so
|
|
36
|
+
* a visitor never makes a principal: the principal their identity is linked to, who must hold the
|
|
37
|
+
* owner role. An identity linked to no one is refused on every host, however many owners it has:
|
|
38
|
+
* the verifier may vouch for members too, so only a link says whose an identity is. A verifier
|
|
39
|
+
* that reports no actor speaks for `ownerId`, default the primary owner, as in 0.8.
|
|
40
|
+
*/
|
|
41
|
+
export function consoleVisitors(
|
|
42
|
+
identity: ConsoleIdentity,
|
|
43
|
+
options: VisitorOptions,
|
|
44
|
+
): (actor: ActorFacts | undefined) => Promise<Identified> {
|
|
45
|
+
const { logger } = options;
|
|
46
|
+
const warned = new Set<string>();
|
|
47
|
+
const warnOnce = (key: string, message: string) => {
|
|
48
|
+
if (warned.has(key) || warned.size >= WARNED_MAX) return;
|
|
49
|
+
warned.add(key);
|
|
50
|
+
logger.warn(`web-console: ${message}`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const owner = async (id: string): Promise<Identified> => {
|
|
54
|
+
const principal = await identity.principal(id);
|
|
55
|
+
if (!principal) return { refusal: `there is no principal ${id}` };
|
|
56
|
+
if (principal.disabled) return { refusal: `principal ${id} is disabled` };
|
|
57
|
+
if ((await identity.tierOf(id)) !== "owner")
|
|
58
|
+
return { refusal: `principal ${id} holds no owner role` };
|
|
59
|
+
return { visitor: { principal } };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return async (actor) => {
|
|
63
|
+
if (!actor) {
|
|
64
|
+
warnOnce(
|
|
65
|
+
"",
|
|
66
|
+
"the verifier reports no actor, so the console takes every request it admits as the primary owner's. Have it report who signed in (cloudflareAccess does)",
|
|
67
|
+
);
|
|
68
|
+
const id = options.ownerId ?? (await identity.owners())[0]?.id;
|
|
69
|
+
return id === undefined
|
|
70
|
+
? { refusal: "the host has no owner" }
|
|
71
|
+
: owner(id);
|
|
72
|
+
}
|
|
73
|
+
const named = `${actor.provider}:${actor.subject}`;
|
|
74
|
+
if (actor.provider === LEGACY_PROVIDER)
|
|
75
|
+
return { refusal: `${named} is a 0.8 id, not an identity` };
|
|
76
|
+
let id: string | undefined;
|
|
77
|
+
try {
|
|
78
|
+
id = await identity.principalOf(named);
|
|
79
|
+
} catch {
|
|
80
|
+
return { refusal: "the verifier's actor is no identity" };
|
|
81
|
+
}
|
|
82
|
+
if (id !== undefined) return owner(id);
|
|
83
|
+
warnOnce(
|
|
84
|
+
named,
|
|
85
|
+
`${named} is linked to no principal, so the console refuses it. If it is an owner's, add "${named}" to that owner's access.owners[].identities, or run: roundtable principal link <owner's principal id> ${named}`,
|
|
86
|
+
);
|
|
87
|
+
return { refusal: `${named} is linked to no principal` };
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/web-plugin.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AGENTS,
|
|
3
|
+
CONVERSATIONS,
|
|
3
4
|
definePlugin,
|
|
5
|
+
IDENTITY,
|
|
4
6
|
MEMORY,
|
|
5
7
|
type RoundtablePlugin,
|
|
6
8
|
} from "pi-roundtable";
|
|
@@ -13,17 +15,20 @@ import {
|
|
|
13
15
|
resolveOptions,
|
|
14
16
|
type WebConsoleOptions,
|
|
15
17
|
} from "./options.ts";
|
|
18
|
+
import { consoleVisitors } from "./visitors.ts";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* The owners' web console: conversations, transcripts, memory notes, and the agent team, served
|
|
22
|
+
* on a route of the host's listener and updated live. Every request needs the verifier to vouch
|
|
23
|
+
* for the person and `IDENTITY` to name them an owner. The options are checked here, so a missing
|
|
24
|
+
* verifier or a bad setting throws when the config is loaded.
|
|
21
25
|
*/
|
|
22
26
|
export function webConsole(options: WebConsoleOptions): RoundtablePlugin {
|
|
23
27
|
const settings = resolveOptions(options);
|
|
24
28
|
return definePlugin({
|
|
25
29
|
name: "web-console",
|
|
26
30
|
requires: [
|
|
31
|
+
IDENTITY,
|
|
27
32
|
...(settings.panes.includes("overview") ? [AGENTS] : []),
|
|
28
33
|
...(settings.panes.includes("notes") ? [MEMORY] : []),
|
|
29
34
|
],
|
|
@@ -45,6 +50,9 @@ async function build(settings: ResolvedOptions, context: Context) {
|
|
|
45
50
|
}
|
|
46
51
|
const team = services.find(AGENTS)?.team;
|
|
47
52
|
const connection = services.find(DISCORD)?.connection;
|
|
53
|
+
// Absent on a host without the registry: the console then lists conversations found by name only.
|
|
54
|
+
const registry = services.find(CONVERSATIONS);
|
|
55
|
+
const identity = services.get(IDENTITY);
|
|
48
56
|
const listeners: (() => void)[] = [];
|
|
49
57
|
const changed = () => {
|
|
50
58
|
for (const listener of listeners) listener();
|
|
@@ -61,10 +69,15 @@ async function build(settings: ResolvedOptions, context: Context) {
|
|
|
61
69
|
...(connection
|
|
62
70
|
? { channelName: (id: string) => connection.channelInfo(id) }
|
|
63
71
|
: {}),
|
|
64
|
-
...(settings.
|
|
65
|
-
? {
|
|
72
|
+
...(settings.panes.includes("notes")
|
|
73
|
+
? {
|
|
74
|
+
memoryOf: (principalId: string) =>
|
|
75
|
+
services.get(MEMORY).forSpeaker(principalId),
|
|
76
|
+
}
|
|
66
77
|
: {}),
|
|
78
|
+
people: identity,
|
|
67
79
|
...(settings.exclude ? { exclude: settings.exclude } : {}),
|
|
80
|
+
...(registry ? { registry } : {}),
|
|
68
81
|
relayNotes: settings.relayNotes,
|
|
69
82
|
changed,
|
|
70
83
|
logger,
|
|
@@ -76,6 +89,10 @@ async function build(settings: ResolvedOptions, context: Context) {
|
|
|
76
89
|
mount: settings.mount,
|
|
77
90
|
assets,
|
|
78
91
|
verifier: settings.verifier,
|
|
92
|
+
identify: consoleVisitors(identity, {
|
|
93
|
+
logger,
|
|
94
|
+
...(settings.ownerId ? { ownerId: settings.ownerId } : {}),
|
|
95
|
+
}),
|
|
79
96
|
origin: settings.origin,
|
|
80
97
|
pathRouting: settings.routing === "path",
|
|
81
98
|
...(settings.presentation ? { presentation: settings.presentation } : {}),
|