pi-roundtable-web 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +225 -2
- package/dist/chunk-e8z9q554.css +1 -0
- package/dist/chunk-gkh6x5ag.js +9 -0
- package/dist/index.html +13 -0
- package/package.json +54 -5
- package/src/api-types.ts +128 -0
- package/src/assets.ts +47 -0
- package/src/cloudflare-access.ts +59 -0
- package/src/console-api.ts +354 -0
- package/src/console-server.ts +214 -0
- package/src/conversations.ts +383 -0
- package/src/index.ts +29 -0
- package/src/options.ts +140 -0
- package/src/verifier.ts +22 -0
- package/src/web-plugin.ts +88 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Console</title>
|
|
7
|
+
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./chunk-e8z9q554.css"><script type="module" crossorigin src="./chunk-gkh6x5ag.js"></script></head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "pi-roundtable-web",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "An owner-only web console for pi-roundtable: conversations, transcripts, and memory notes, with live updates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"bun": ">=1.3.0"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"!src/testing",
|
|
17
|
+
"dist",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/wayne930242/pi-roundtable.git",
|
|
25
|
+
"directory": "packages/web"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun scripts/build.ts",
|
|
33
|
+
"prepack": "bun scripts/build.ts",
|
|
34
|
+
"fixture": "bun scripts/fixture-host.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p web",
|
|
36
|
+
"lint": "biome check .",
|
|
37
|
+
"test": "bun test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"jose": "6.2.12"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"pi-roundtable": ">=0.7.0 <0.8.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@biomejs/biome": "2.5.15",
|
|
47
|
+
"@types/bun": "1.4.2",
|
|
48
|
+
"@types/react": "19.3.0",
|
|
49
|
+
"@types/react-dom": "19.3.0",
|
|
50
|
+
"pi-roundtable": "0.7.0",
|
|
51
|
+
"react": "19.3.0",
|
|
52
|
+
"react-dom": "19.3.0",
|
|
53
|
+
"typescript": "7.0.2"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/api-types.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The console's API shapes, shared by the server (`src/`) and the page (`web/`). Times are ISO strings.
|
|
2
|
+
|
|
3
|
+
/** The panes the console can show; the options choose which ones a host serves. */
|
|
4
|
+
export const PANES = ["overview", "conversations", "notes"] as const;
|
|
5
|
+
export type PaneName = (typeof PANES)[number];
|
|
6
|
+
|
|
7
|
+
/** What the page needs before it can draw anything. */
|
|
8
|
+
export interface ConfigView {
|
|
9
|
+
title: string;
|
|
10
|
+
panes: PaneName[];
|
|
11
|
+
/** The IANA zone the page formats times and event dates in: the host's `env.timeZone`. */
|
|
12
|
+
timeZone: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ChannelName =
|
|
16
|
+
| { kind: "dm"; name: string }
|
|
17
|
+
| { kind: "guild"; name: string; guild: string; guildId: string }
|
|
18
|
+
/** Discord no longer knows the channel. */
|
|
19
|
+
| { kind: "gone" }
|
|
20
|
+
/** Discord could not be asked, or the host has no Discord connection. */
|
|
21
|
+
| { kind: "unknown" };
|
|
22
|
+
|
|
23
|
+
export interface ContextView {
|
|
24
|
+
tokens: number | null;
|
|
25
|
+
contextWindow: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AgentView {
|
|
29
|
+
name: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
channelId?: string;
|
|
32
|
+
model: string;
|
|
33
|
+
thinking: string;
|
|
34
|
+
/** Channel id of its running turn. */
|
|
35
|
+
workingIn?: string;
|
|
36
|
+
waiting: number;
|
|
37
|
+
context?: ContextView;
|
|
38
|
+
lastActive?: string;
|
|
39
|
+
schedules: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GroupView {
|
|
43
|
+
name: string;
|
|
44
|
+
displayName: string;
|
|
45
|
+
channelId: string;
|
|
46
|
+
members: string[];
|
|
47
|
+
host: string;
|
|
48
|
+
busy: number;
|
|
49
|
+
lastActive?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OverviewView {
|
|
53
|
+
guildId: string;
|
|
54
|
+
agents: AgentView[];
|
|
55
|
+
groups: GroupView[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Who a stored conversation belongs to. */
|
|
59
|
+
export type ConversationKind = "agent" | "group" | "owner" | "outside";
|
|
60
|
+
|
|
61
|
+
export interface ConversationView {
|
|
62
|
+
/** The channel key: `discord:<id>`, `agentgroup:<channel>.<agent>`, or `mcp:<session>`. */
|
|
63
|
+
key: string;
|
|
64
|
+
kind: ConversationKind;
|
|
65
|
+
/** The Discord channel id, or the outside agent's session id. */
|
|
66
|
+
id: string;
|
|
67
|
+
/** Group conversations only: the agent whose conversation inside the group this is. */
|
|
68
|
+
member?: string;
|
|
69
|
+
/** Discord conversations, group ones included. */
|
|
70
|
+
channel?: ChannelName;
|
|
71
|
+
/** Bytes of the live conversation; 0 when only archives are left. */
|
|
72
|
+
liveBytes: number;
|
|
73
|
+
archives: number;
|
|
74
|
+
lastActive?: string;
|
|
75
|
+
/** Outside-agent conversations only: up to 80 characters of the first message. */
|
|
76
|
+
firstMessage?: string;
|
|
77
|
+
startedAt?: string;
|
|
78
|
+
/** Turns running or waiting in the channel. */
|
|
79
|
+
busy: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ConversationsView {
|
|
83
|
+
conversations: ConversationView[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type TranscriptRole = "user" | "assistant" | "tool" | "compaction";
|
|
87
|
+
|
|
88
|
+
export interface TranscriptEntry {
|
|
89
|
+
role: TranscriptRole;
|
|
90
|
+
at?: string;
|
|
91
|
+
text: string;
|
|
92
|
+
/** What an assistant message called, by name with a short preview of the arguments. */
|
|
93
|
+
calls?: { name: string; preview: string }[];
|
|
94
|
+
/** The tool a `tool` entry answers. */
|
|
95
|
+
tool?: string;
|
|
96
|
+
failed?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TranscriptView {
|
|
100
|
+
conversation: ConversationView;
|
|
101
|
+
/** The names of the conversation's archives, newest first. */
|
|
102
|
+
archives: string[];
|
|
103
|
+
/** The archive being read; absent for the live conversation. */
|
|
104
|
+
archive?: string;
|
|
105
|
+
entries: TranscriptEntry[];
|
|
106
|
+
/** True when the file was longer than the console reads, so the oldest entries are missing. */
|
|
107
|
+
truncated: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type NoteKind = "core" | "note" | "event";
|
|
111
|
+
|
|
112
|
+
export interface NoteView {
|
|
113
|
+
id: number;
|
|
114
|
+
kind: NoteKind;
|
|
115
|
+
fact: string;
|
|
116
|
+
eventDate: string | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface NoteInput {
|
|
120
|
+
fact: string;
|
|
121
|
+
kind: NoteKind;
|
|
122
|
+
eventDate?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every error body the API returns. */
|
|
126
|
+
export interface ApiError {
|
|
127
|
+
error: string;
|
|
128
|
+
}
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/** One built file of the page. */
|
|
6
|
+
export interface Asset {
|
|
7
|
+
body: Uint8Array;
|
|
8
|
+
type: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The page's files by their path under the mount, `index.html` included. */
|
|
12
|
+
export type AssetBundle = ReadonlyMap<string, Asset>;
|
|
13
|
+
|
|
14
|
+
/** Where `bun run build` leaves the page; the published package ships it ready-made. */
|
|
15
|
+
export const BUILT_PAGE_DIR = fileURLToPath(
|
|
16
|
+
new URL("../dist/", import.meta.url),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads the built page into memory. A missing page throws, so a checkout that was never built
|
|
21
|
+
* stops the host at startup instead of serving a blank page.
|
|
22
|
+
*/
|
|
23
|
+
export function loadAssets(dir: string = BUILT_PAGE_DIR): AssetBundle {
|
|
24
|
+
let entries: ReturnType<typeof listFiles>;
|
|
25
|
+
try {
|
|
26
|
+
entries = listFiles(dir);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`the console page is not built: ${dir} does not exist. Run \`bun run build\` in the package, or install the published package, which ships it built.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const assets = new Map<string, Asset>();
|
|
33
|
+
for (const path of entries)
|
|
34
|
+
assets.set(relative(dir, path).split(sep).join("/"), {
|
|
35
|
+
body: readFileSync(path),
|
|
36
|
+
type: Bun.file(path).type,
|
|
37
|
+
});
|
|
38
|
+
if (!assets.has("index.html"))
|
|
39
|
+
throw new Error(`the console page is not built: ${dir} has no index.html`);
|
|
40
|
+
return assets;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listFiles(dir: string): string[] {
|
|
44
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
45
|
+
.filter((entry) => entry.isFile())
|
|
46
|
+
.map((entry) => join(entry.parentPath, entry.name));
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose";
|
|
2
|
+
import { admit, type RequestVerifier, refuse } from "./verifier.ts";
|
|
3
|
+
|
|
4
|
+
export interface CloudflareAccessOptions {
|
|
5
|
+
/** The Access team domain, such as `example.cloudflareaccess.com`. */
|
|
6
|
+
teamDomain: string;
|
|
7
|
+
/** The AUD tag of the Access application in front of the console. */
|
|
8
|
+
audience: string;
|
|
9
|
+
/** The email, or emails, Access may vouch for; compared without regard to case. */
|
|
10
|
+
email: string | readonly string[];
|
|
11
|
+
/** Replaceable in tests; the team's published signing keys otherwise. */
|
|
12
|
+
keys?: JWTVerifyGetKey;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HEADER = "cf-access-jwt-assertion";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Admits a request only when Cloudflare Access vouches for an allowed email: its
|
|
19
|
+
* `Cf-Access-Jwt-Assertion` header holds a token signed by the team's keys, issued by the team,
|
|
20
|
+
* for this application, naming that email. The token is checked on every request, so the
|
|
21
|
+
* console stays closed to a client that reaches it without passing through Access.
|
|
22
|
+
*/
|
|
23
|
+
export function cloudflareAccess(
|
|
24
|
+
options: CloudflareAccessOptions,
|
|
25
|
+
): RequestVerifier {
|
|
26
|
+
const teamDomain = options.teamDomain.trim();
|
|
27
|
+
const audience = options.audience.trim();
|
|
28
|
+
const emails = (
|
|
29
|
+
typeof options.email === "string" ? [options.email] : options.email
|
|
30
|
+
).map((email) => email.trim().toLowerCase());
|
|
31
|
+
if (!teamDomain || teamDomain.includes("/"))
|
|
32
|
+
throw new Error(
|
|
33
|
+
"cloudflareAccess: teamDomain must be a host name such as example.cloudflareaccess.com",
|
|
34
|
+
);
|
|
35
|
+
if (!audience) throw new Error("cloudflareAccess: audience is empty");
|
|
36
|
+
if (emails.length === 0 || emails.some((email) => !email))
|
|
37
|
+
throw new Error("cloudflareAccess: email names no one");
|
|
38
|
+
const keys =
|
|
39
|
+
options.keys ??
|
|
40
|
+
createRemoteJWKSet(new URL(`https://${teamDomain}/cdn-cgi/access/certs`));
|
|
41
|
+
return async (request) => {
|
|
42
|
+
const token = request.headers.get(HEADER);
|
|
43
|
+
if (!token) return refuse("no Access token");
|
|
44
|
+
try {
|
|
45
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
46
|
+
issuer: `https://${teamDomain}`,
|
|
47
|
+
audience,
|
|
48
|
+
});
|
|
49
|
+
if (
|
|
50
|
+
typeof payload.email !== "string" ||
|
|
51
|
+
!emails.includes(payload.email.toLowerCase())
|
|
52
|
+
)
|
|
53
|
+
return refuse("email not allowed");
|
|
54
|
+
} catch {
|
|
55
|
+
return refuse("invalid Access token");
|
|
56
|
+
}
|
|
57
|
+
return admit();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
AgentTeam,
|
|
4
|
+
ChannelKey,
|
|
5
|
+
Logger,
|
|
6
|
+
MemoryKind,
|
|
7
|
+
QueuePort,
|
|
8
|
+
SpeakerMemory,
|
|
9
|
+
} from "pi-roundtable";
|
|
10
|
+
import { MemoryError } from "pi-roundtable";
|
|
11
|
+
import { thinkingLabel } from "pi-roundtable/kit";
|
|
12
|
+
import type {
|
|
13
|
+
ApiError,
|
|
14
|
+
ChannelName,
|
|
15
|
+
ConfigView,
|
|
16
|
+
ConversationKind,
|
|
17
|
+
ConversationsView,
|
|
18
|
+
ConversationView,
|
|
19
|
+
NoteInput,
|
|
20
|
+
NoteView,
|
|
21
|
+
OverviewView,
|
|
22
|
+
PaneName,
|
|
23
|
+
TranscriptView,
|
|
24
|
+
} from "./api-types.ts";
|
|
25
|
+
import {
|
|
26
|
+
archiveNames,
|
|
27
|
+
conversationFiles,
|
|
28
|
+
parseKey,
|
|
29
|
+
readTranscript,
|
|
30
|
+
type StoredConversation,
|
|
31
|
+
storedConversations,
|
|
32
|
+
} from "./conversations.ts";
|
|
33
|
+
|
|
34
|
+
const NOTE_SEARCH_LIMIT = 500;
|
|
35
|
+
/** The largest note body the console reads. */
|
|
36
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
37
|
+
|
|
38
|
+
export interface ConsolePorts {
|
|
39
|
+
title: string;
|
|
40
|
+
timeZone: string;
|
|
41
|
+
panes: readonly PaneName[];
|
|
42
|
+
/** The host's `sessions/` directory. */
|
|
43
|
+
sessionsDir: string;
|
|
44
|
+
/** Present when the overview pane is served, or the agent server is there to classify channels. */
|
|
45
|
+
team?: Pick<AgentTeam, "status" | "owns" | "guildId">;
|
|
46
|
+
queue: Pick<QueuePort, "size">;
|
|
47
|
+
/** Undefined when Discord no longer knows the channel; throws when it cannot be asked. */
|
|
48
|
+
channelName?: (channelId: string) => Promise<ChannelName | undefined>;
|
|
49
|
+
/** The owner's memory; present when the notes pane is served. */
|
|
50
|
+
memory?: Pick<
|
|
51
|
+
SpeakerMemory,
|
|
52
|
+
"list" | "search" | "add" | "update" | "removeById"
|
|
53
|
+
>;
|
|
54
|
+
/** Conversations the console must not list or read. */
|
|
55
|
+
exclude?: (key: ChannelKey) => boolean;
|
|
56
|
+
/** Text a relayed message begins with, which is not the owner's words. */
|
|
57
|
+
relayNotes: readonly string[];
|
|
58
|
+
/** Called after a note is written, so every open page refreshes. */
|
|
59
|
+
changed(): void;
|
|
60
|
+
logger: Logger;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class HttpError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
readonly status: number,
|
|
66
|
+
message: string,
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const json = (body: unknown, status = 200) =>
|
|
73
|
+
Response.json(body, {
|
|
74
|
+
status,
|
|
75
|
+
headers: { "cache-control": "no-store" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Memory refusals as the Notes pane shows them. */
|
|
79
|
+
const MEMORY_REASONS: Record<string, string> = {
|
|
80
|
+
"a memory fact cannot be empty": "The note cannot be empty.",
|
|
81
|
+
"an event needs its date as YYYY-MM-DD":
|
|
82
|
+
"An event needs its date as YYYY-MM-DD.",
|
|
83
|
+
"only an event has a date": "Only an event has a date.",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const memoryReason = (error: MemoryError): string =>
|
|
87
|
+
MEMORY_REASONS[error.message] ?? "The kind is not one of core, note, event.";
|
|
88
|
+
|
|
89
|
+
const iso = (date: Date | undefined) => date?.toISOString();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The console's JSON API under `<mount>/api/`. Every handler answers JSON; errors never repeat
|
|
93
|
+
* the request URL or headers.
|
|
94
|
+
*/
|
|
95
|
+
export class ConsoleApi {
|
|
96
|
+
readonly #ports: ConsolePorts;
|
|
97
|
+
|
|
98
|
+
constructor(ports: ConsolePorts) {
|
|
99
|
+
this.#ports = ports;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `path` is what follows `<mount>/api/`. */
|
|
103
|
+
async handle(request: Request, path: string): Promise<Response> {
|
|
104
|
+
try {
|
|
105
|
+
return await this.#route(request, path);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof HttpError)
|
|
108
|
+
return json({ error: error.message } satisfies ApiError, error.status);
|
|
109
|
+
if (error instanceof MemoryError)
|
|
110
|
+
return json({ error: memoryReason(error) } satisfies ApiError, 400);
|
|
111
|
+
// The URL stays out of the log: a path may hold something private.
|
|
112
|
+
this.#ports.logger.error({ err: error }, "console api failed");
|
|
113
|
+
return json(
|
|
114
|
+
{
|
|
115
|
+
error: "The console could not complete the request.",
|
|
116
|
+
} satisfies ApiError,
|
|
117
|
+
500,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#pane(name: PaneName): void {
|
|
123
|
+
if (!this.#ports.panes.includes(name))
|
|
124
|
+
throw new HttpError(404, "This pane is not served.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async #route(request: Request, path: string): Promise<Response> {
|
|
128
|
+
const method = request.method;
|
|
129
|
+
let parts: string[];
|
|
130
|
+
try {
|
|
131
|
+
parts = path.split("/").filter(Boolean).map(decodeURIComponent);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new HttpError(400, "The path is not valid.");
|
|
134
|
+
}
|
|
135
|
+
const [head, id] = parts;
|
|
136
|
+
if (method === "GET" && head === "config" && !id)
|
|
137
|
+
return json(this.config());
|
|
138
|
+
if (method === "GET" && head === "overview" && !id) {
|
|
139
|
+
this.#pane("overview");
|
|
140
|
+
return json(await this.overview());
|
|
141
|
+
}
|
|
142
|
+
if (method === "GET" && head === "conversations" && parts.length <= 2) {
|
|
143
|
+
this.#pane("conversations");
|
|
144
|
+
if (!id) return json(await this.conversations());
|
|
145
|
+
const archive = new URL(request.url).searchParams.get("archive");
|
|
146
|
+
return json(await this.transcript(id, archive ?? undefined));
|
|
147
|
+
}
|
|
148
|
+
if (head === "notes" && parts.length <= 2) {
|
|
149
|
+
this.#pane("notes");
|
|
150
|
+
return this.#notes(request, id);
|
|
151
|
+
}
|
|
152
|
+
throw new HttpError(404, "There is no such API.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
config(): ConfigView {
|
|
156
|
+
const { title, panes, timeZone } = this.#ports;
|
|
157
|
+
return { title, panes: [...panes], timeZone };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Overview ───────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async overview(): Promise<OverviewView> {
|
|
163
|
+
const { team } = this.#ports;
|
|
164
|
+
if (!team) throw new HttpError(404, "This pane is not served.");
|
|
165
|
+
const status = await team.status();
|
|
166
|
+
return {
|
|
167
|
+
guildId: team.guildId,
|
|
168
|
+
agents: status.agents.map((agent) => ({
|
|
169
|
+
name: agent.name,
|
|
170
|
+
displayName: agent.displayName,
|
|
171
|
+
...(agent.channelId ? { channelId: agent.channelId } : {}),
|
|
172
|
+
model: agent.model,
|
|
173
|
+
thinking: thinkingLabel(agent.thinking),
|
|
174
|
+
...(agent.workingIn ? { workingIn: agent.workingIn } : {}),
|
|
175
|
+
waiting: agent.waiting,
|
|
176
|
+
...(agent.context ? { context: agent.context } : {}),
|
|
177
|
+
...(agent.lastActive ? { lastActive: iso(agent.lastActive) } : {}),
|
|
178
|
+
schedules: agent.schedules,
|
|
179
|
+
})),
|
|
180
|
+
groups: status.groups.map((group) => ({
|
|
181
|
+
name: group.name,
|
|
182
|
+
displayName: group.displayName,
|
|
183
|
+
channelId: group.channelId,
|
|
184
|
+
members: group.members,
|
|
185
|
+
host: group.host,
|
|
186
|
+
busy: group.busy,
|
|
187
|
+
...(group.lastActive ? { lastActive: iso(group.lastActive) } : {}),
|
|
188
|
+
})),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Conversations ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async conversations(): Promise<ConversationsView> {
|
|
195
|
+
const { sessionsDir, relayNotes, exclude } = this.#ports;
|
|
196
|
+
const stored = storedConversations(sessionsDir, {
|
|
197
|
+
excluded: (key) => exclude?.(key) ?? false,
|
|
198
|
+
relayNotes,
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
conversations: await Promise.all(stored.map((c) => this.#view(c))),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async transcript(
|
|
206
|
+
key: string,
|
|
207
|
+
archive: string | undefined,
|
|
208
|
+
): Promise<TranscriptView> {
|
|
209
|
+
const { sessionsDir, relayNotes, exclude } = this.#ports;
|
|
210
|
+
const parsed = parseKey(key);
|
|
211
|
+
if (!parsed || exclude?.(key as ChannelKey))
|
|
212
|
+
throw new HttpError(404, "There is no such conversation.");
|
|
213
|
+
const dir = join(sessionsDir, parsed.dir);
|
|
214
|
+
const files = conversationFiles(dir);
|
|
215
|
+
if (!files) throw new HttpError(404, "There is no such conversation.");
|
|
216
|
+
const archives = archiveNames(dir);
|
|
217
|
+
// The archive is chosen from the folder's listing, never joined from the request.
|
|
218
|
+
if (archive !== undefined && !archives.includes(archive))
|
|
219
|
+
throw new HttpError(404, "There is no such archive.");
|
|
220
|
+
const { entries, truncated } = readTranscript(dir, archive, relayNotes);
|
|
221
|
+
return {
|
|
222
|
+
conversation: await this.#view({
|
|
223
|
+
key: key as ChannelKey,
|
|
224
|
+
kind: parsed.kind,
|
|
225
|
+
id: parsed.id,
|
|
226
|
+
...(parsed.member ? { member: parsed.member } : {}),
|
|
227
|
+
...files,
|
|
228
|
+
}),
|
|
229
|
+
archives,
|
|
230
|
+
...(archive !== undefined ? { archive } : {}),
|
|
231
|
+
entries,
|
|
232
|
+
truncated,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async #view(stored: StoredConversation): Promise<ConversationView> {
|
|
237
|
+
const { team, queue } = this.#ports;
|
|
238
|
+
const kind: ConversationKind =
|
|
239
|
+
stored.kind === "mcp"
|
|
240
|
+
? "outside"
|
|
241
|
+
: stored.kind === "group"
|
|
242
|
+
? "group"
|
|
243
|
+
: (team?.owns(stored.key) ?? "owner");
|
|
244
|
+
return {
|
|
245
|
+
key: stored.key,
|
|
246
|
+
kind,
|
|
247
|
+
id: stored.id,
|
|
248
|
+
...(stored.member ? { member: stored.member } : {}),
|
|
249
|
+
...(stored.kind === "mcp"
|
|
250
|
+
? {}
|
|
251
|
+
: { channel: await this.#name(stored.id) }),
|
|
252
|
+
liveBytes: stored.liveBytes,
|
|
253
|
+
archives: stored.archives,
|
|
254
|
+
...(stored.lastActive ? { lastActive: stored.lastActive } : {}),
|
|
255
|
+
...(stored.firstMessage ? { firstMessage: stored.firstMessage } : {}),
|
|
256
|
+
...(stored.startedAt ? { startedAt: stored.startedAt } : {}),
|
|
257
|
+
// A group's turns queue under the group channel, not under each member's conversation.
|
|
258
|
+
busy: queue.size(
|
|
259
|
+
stored.kind === "group" ? `discord:${stored.id}` : stored.key,
|
|
260
|
+
),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async #name(channelId: string): Promise<ChannelName> {
|
|
265
|
+
const lookup = this.#ports.channelName;
|
|
266
|
+
if (!lookup) return { kind: "unknown" };
|
|
267
|
+
try {
|
|
268
|
+
return (await lookup(channelId)) ?? { kind: "gone" };
|
|
269
|
+
} catch {
|
|
270
|
+
// Discord is reconnecting; the id is all the page can show until the next refresh.
|
|
271
|
+
return { kind: "unknown" };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Notes ──────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
async #notes(request: Request, id: string | undefined): Promise<Response> {
|
|
278
|
+
const { memory } = this.#ports;
|
|
279
|
+
if (!memory) throw new HttpError(404, "This pane is not served.");
|
|
280
|
+
const method = request.method;
|
|
281
|
+
if (!id) {
|
|
282
|
+
if (method === "GET") {
|
|
283
|
+
const query = new URL(request.url).searchParams.get("q")?.trim();
|
|
284
|
+
const notes: NoteView[] = query
|
|
285
|
+
? await memory.search(query, NOTE_SEARCH_LIMIT)
|
|
286
|
+
: await memory.list();
|
|
287
|
+
return json(notes);
|
|
288
|
+
}
|
|
289
|
+
if (method === "POST") {
|
|
290
|
+
const input = await noteInput(request);
|
|
291
|
+
const added = await memory.add(input.fact, input.kind, input.eventDate);
|
|
292
|
+
this.#ports.changed();
|
|
293
|
+
return json(added, 201);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
const noteId = Number(id);
|
|
297
|
+
if (!Number.isSafeInteger(noteId))
|
|
298
|
+
throw new HttpError(404, "There is no such note.");
|
|
299
|
+
if (method === "PATCH") {
|
|
300
|
+
const updated = await memory.update(noteId, await noteInput(request));
|
|
301
|
+
if (!updated) throw new HttpError(404, "There is no such note.");
|
|
302
|
+
this.#ports.changed();
|
|
303
|
+
return json(updated);
|
|
304
|
+
}
|
|
305
|
+
if (method === "DELETE") {
|
|
306
|
+
if (!(await memory.removeById(noteId)))
|
|
307
|
+
throw new HttpError(404, "There is no such note.");
|
|
308
|
+
this.#ports.changed();
|
|
309
|
+
return json({ deleted: noteId });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw new HttpError(405, "This method is not supported.");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The JSON body, read in chunks so a body past the limit is refused without being held whole. */
|
|
317
|
+
async function readBody(request: Request): Promise<unknown> {
|
|
318
|
+
const tooLarge = () => new HttpError(413, "The request is too large.");
|
|
319
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES)
|
|
320
|
+
throw tooLarge();
|
|
321
|
+
const chunks: Uint8Array[] = [];
|
|
322
|
+
let size = 0;
|
|
323
|
+
if (request.body)
|
|
324
|
+
for await (const chunk of request.body) {
|
|
325
|
+
size += chunk.byteLength;
|
|
326
|
+
if (size > MAX_BODY_BYTES) throw tooLarge();
|
|
327
|
+
chunks.push(chunk);
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
331
|
+
} catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function noteInput(request: Request): Promise<{
|
|
337
|
+
fact: string;
|
|
338
|
+
kind: MemoryKind;
|
|
339
|
+
eventDate?: string;
|
|
340
|
+
}> {
|
|
341
|
+
const body = await readBody(request);
|
|
342
|
+
if (typeof body !== "object" || body === null)
|
|
343
|
+
throw new HttpError(400, "The body is not valid.");
|
|
344
|
+
const { fact, kind, eventDate } = body as Partial<NoteInput>;
|
|
345
|
+
if (typeof fact !== "string" || typeof kind !== "string")
|
|
346
|
+
throw new HttpError(400, "The body is not valid.");
|
|
347
|
+
if (eventDate !== undefined && typeof eventDate !== "string")
|
|
348
|
+
throw new HttpError(400, "The date is not valid.");
|
|
349
|
+
return {
|
|
350
|
+
fact,
|
|
351
|
+
kind: kind as MemoryKind,
|
|
352
|
+
...(eventDate ? { eventDate } : {}),
|
|
353
|
+
};
|
|
354
|
+
}
|