@unifedev/thread-pages 0.3.2 → 1.0.3
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/README.md +77 -129
- package/dist/server.js +11426 -11802
- package/dist/server.meta.json +2 -2
- package/package.json +25 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +355 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +69 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +4 -11
- package/src/agent/starter-hub.ts +217 -0
- package/src/bb/activity.ts +59 -0
- package/src/bb/bb-host.ts +280 -0
- package/src/bb/public-origin.ts +45 -0
- package/src/config/settings.ts +82 -0
- package/src/domain/capabilities/contract.ts +48 -0
- package/src/domain/capabilities/index.ts +10 -0
- package/src/domain/capabilities/protocol.ts +112 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/schema.ts +198 -0
- package/src/domain/capabilities/specs.ts +479 -0
- package/src/domain/eligibility.ts +43 -0
- package/src/domain/errors.ts +116 -0
- package/src/domain/html/document.ts +109 -0
- package/src/domain/html/escape.ts +16 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/json/canonical.ts +19 -0
- package/src/domain/json/strict-json.ts +139 -0
- package/src/domain/limits.ts +88 -0
- package/src/domain/rate-limit.ts +64 -0
- package/src/domain/revision.ts +27 -0
- package/src/domain/submissions/idempotency.ts +59 -0
- package/src/domain/submissions/message.ts +42 -0
- package/src/domain/submissions/parse.ts +105 -0
- package/src/domain/tokens/action-token.ts +52 -0
- package/src/domain/tokens/confirmation.ts +99 -0
- package/src/domain/tokens/mac.ts +50 -0
- package/src/generated/kernel-runtime.ts +3 -0
- package/src/generated/shell-runtime.ts +3 -0
- package/src/host/contract.ts +65 -0
- package/src/host/types.ts +89 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +136 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +71 -0
- package/src/runtime/kernel/anchors.ts +45 -0
- package/src/runtime/kernel/api.ts +15 -0
- package/src/runtime/kernel/bridge-client.ts +148 -0
- package/src/runtime/kernel/dirty.ts +51 -0
- package/src/runtime/kernel/forms.ts +114 -0
- package/src/runtime/kernel/install.ts +156 -0
- package/src/runtime/kernel/labels.ts +98 -0
- package/src/runtime/kernel/main.ts +6 -0
- package/src/runtime/kernel/readonly.ts +75 -0
- package/src/runtime/shared/protocol.ts +125 -0
- package/src/runtime/shell/confirm.ts +70 -0
- package/src/runtime/shell/install.ts +79 -0
- package/src/runtime/shell/main.ts +12 -0
- package/src/runtime/shell/navigate.ts +64 -0
- package/src/runtime/shell/poll.ts +125 -0
- package/src/runtime/shell/relay.ts +185 -0
- package/src/serving/action-request.ts +32 -0
- package/src/serving/bridge/dispatcher.ts +112 -0
- package/src/serving/bridge/handler.ts +37 -0
- package/src/serving/bridge/handlers/index.ts +26 -0
- package/src/serving/bridge/handlers/navigation.ts +43 -0
- package/src/serving/bridge/handlers/reads.ts +186 -0
- package/src/serving/bridge/handlers/writes.ts +175 -0
- package/src/serving/bridge/selection-store.ts +58 -0
- package/src/serving/bridge-route.ts +23 -0
- package/src/serving/context.ts +34 -0
- package/src/serving/document-route.ts +37 -0
- package/src/serving/home-route.ts +23 -0
- package/src/serving/responses.ts +81 -0
- package/src/serving/routes.ts +26 -0
- package/src/serving/session-access.ts +22 -0
- package/src/serving/shell-html.ts +77 -0
- package/src/serving/shell-route.ts +51 -0
- package/src/serving/signing-key.ts +25 -0
- package/src/serving/submit-route.ts +47 -0
- package/src/serving/upload-route.ts +46 -0
- package/tsconfig.json +10 -6
- package/ARCHITECTURE.md +0 -230
- package/PLUGIN_OVERVIEW.md +0 -83
- package/authoring.ts +0 -368
- package/bridge.ts +0 -1721
- package/docs/MODEL.md +0 -211
- package/docs/ROADMAP.md +0 -96
- package/home.ts +0 -419
- package/page.ts +0 -782
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { SessionSummary, SnapshotParams } from "../../../domain/capabilities/specs.ts";
|
|
2
|
+
import { PageError } from "../../../domain/errors.ts";
|
|
3
|
+
import type { JsonValue } from "../../../domain/json/strict-json.ts";
|
|
4
|
+
import { LIMITS } from "../../../domain/limits.ts";
|
|
5
|
+
import type { SessionRecord } from "../../../host/types.ts";
|
|
6
|
+
import { ENTRY_FILE, joinPath } from "../../../pages/layout.ts";
|
|
7
|
+
import { handler, type HandlerContext } from "../handler.ts";
|
|
8
|
+
|
|
9
|
+
/** Read capabilities. spec 05 §Reads */
|
|
10
|
+
|
|
11
|
+
export const contextGet = handler<null, unknown>({
|
|
12
|
+
method: "context.get",
|
|
13
|
+
async execute(_params, { serving, session, page }) {
|
|
14
|
+
return {
|
|
15
|
+
result: {
|
|
16
|
+
protocolVersion: 1,
|
|
17
|
+
session: { id: session.id, title: session.title.slice(0, LIMITS.titleChars), projectId: session.projectId },
|
|
18
|
+
page: { revision: page.revision, readOnly: page.stale },
|
|
19
|
+
capabilities: serving.registry.descriptors(),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const sessionActivity = handler<{ limit: number }, unknown>({
|
|
26
|
+
method: "session.activity",
|
|
27
|
+
async execute(params, { serving, session }) {
|
|
28
|
+
const items = await serving.host.sessions.activity(session.id, params.limit);
|
|
29
|
+
return { result: { state: session.state, updatedAtMs: session.updatedAtMs, items } };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// --- sessions.snapshot -------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface Cursor {
|
|
36
|
+
readonly phase: "live" | "archived";
|
|
37
|
+
readonly offset: number;
|
|
38
|
+
readonly query: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function queryKey(params: SnapshotParams): string {
|
|
42
|
+
return `${params.projectId ?? ""}|${params.includeArchived ? 1 : 0}|${params.includeChildren ? 1 : 0}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function encodeCursor(cursor: Cursor): string {
|
|
46
|
+
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function decodeCursor(value: string, params: SnapshotParams): Cursor {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Partial<Cursor>;
|
|
52
|
+
if ((parsed.phase !== "live" && parsed.phase !== "archived") || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || parsed.query !== queryKey(params)) {
|
|
53
|
+
throw new Error("mismatch");
|
|
54
|
+
}
|
|
55
|
+
return { phase: parsed.phase, offset: parsed.offset as number, query: parsed.query };
|
|
56
|
+
} catch {
|
|
57
|
+
throw new PageError("invalid_params", "The cursor does not belong to this query");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function pageAvailability(context: HandlerContext, sessions: readonly SessionRecord[]): Promise<Map<string, boolean>> {
|
|
62
|
+
const { serving } = context;
|
|
63
|
+
const byHost = new Map<string, { session: string; path: string }[]>();
|
|
64
|
+
await Promise.all(
|
|
65
|
+
sessions.map(async (session) => {
|
|
66
|
+
try {
|
|
67
|
+
const location = await serving.host.sessions.storage(session.id);
|
|
68
|
+
const entries = byHost.get(location.hostId) ?? [];
|
|
69
|
+
entries.push({ session: session.id, path: joinPath(location.rootPath, ENTRY_FILE) });
|
|
70
|
+
byHost.set(location.hostId, entries);
|
|
71
|
+
} catch {
|
|
72
|
+
// No storage yet: no page.
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
const availability = new Map<string, boolean>();
|
|
77
|
+
await Promise.all(
|
|
78
|
+
[...byHost.entries()].map(async ([hostId, entries]) => {
|
|
79
|
+
const existence = await serving.host.files.exist(hostId, entries.map((entry) => entry.path));
|
|
80
|
+
for (const entry of entries) availability.set(entry.session, existence[entry.path] === true);
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
return availability;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const sessionsSnapshot = handler<SnapshotParams, unknown>({
|
|
87
|
+
method: "sessions.snapshot",
|
|
88
|
+
async execute(params, context) {
|
|
89
|
+
const { serving } = context;
|
|
90
|
+
const start: Cursor = params.cursor ? decodeCursor(params.cursor, params) : { phase: "live", offset: 0, query: queryKey(params) };
|
|
91
|
+
const collected: SessionRecord[] = [];
|
|
92
|
+
let phase = start.phase;
|
|
93
|
+
let offset = start.offset;
|
|
94
|
+
let next: Cursor | null = null;
|
|
95
|
+
while (collected.length < params.limit) {
|
|
96
|
+
const want = params.limit - collected.length;
|
|
97
|
+
const rows = await serving.host.sessions.list({
|
|
98
|
+
...(params.projectId ? { projectId: params.projectId } : {}),
|
|
99
|
+
archived: phase === "archived",
|
|
100
|
+
rootsOnly: !params.includeChildren,
|
|
101
|
+
offset,
|
|
102
|
+
limit: want + 1,
|
|
103
|
+
});
|
|
104
|
+
const visible = rows.filter(
|
|
105
|
+
(row) => row.visibility === "visible" && !row.deleted && row.archived === (phase === "archived") && (params.includeChildren || row.parentId === null),
|
|
106
|
+
);
|
|
107
|
+
const more = rows.length > want;
|
|
108
|
+
collected.push(...visible.slice(0, want));
|
|
109
|
+
offset += Math.min(rows.length, want);
|
|
110
|
+
if (more) {
|
|
111
|
+
next = { phase, offset, query: start.query };
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
if (phase === "live" && params.includeArchived) {
|
|
115
|
+
phase = "archived";
|
|
116
|
+
offset = 0;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const availability = await pageAvailability(context, collected);
|
|
122
|
+
const sessions: SessionSummary[] = collected.map((record) => ({
|
|
123
|
+
id: record.id,
|
|
124
|
+
title: record.title.slice(0, LIMITS.titleChars),
|
|
125
|
+
projectId: record.projectId,
|
|
126
|
+
parentSessionId: record.parentId,
|
|
127
|
+
status: record.state,
|
|
128
|
+
archived: record.archived,
|
|
129
|
+
page: { available: availability.get(record.id) === true, revision: availability.get(record.id) ? serving.pages.knownRevision(record.id) : null },
|
|
130
|
+
updatedAtMs: record.updatedAtMs,
|
|
131
|
+
attentionAtMs: record.attentionAtMs,
|
|
132
|
+
unread: record.unread,
|
|
133
|
+
}));
|
|
134
|
+
return { result: { sessions, nextCursor: next ? encodeCursor(next) : null, generatedAtMs: serving.now() } };
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const projectsList = handler<null, unknown>({
|
|
139
|
+
method: "projects.list",
|
|
140
|
+
async execute(_params, { serving }) {
|
|
141
|
+
const projects = await serving.host.projects.list();
|
|
142
|
+
return { result: { projects: projects.slice(0, LIMITS.projectsMax).map((project) => ({ id: project.id, name: project.name.slice(0, LIMITS.titleChars), kind: project.kind })) } };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export const providersList = handler<null, unknown>({
|
|
147
|
+
method: "providers.list",
|
|
148
|
+
async execute(_params, { serving }) {
|
|
149
|
+
const providers = await serving.host.providers.list();
|
|
150
|
+
return {
|
|
151
|
+
result: {
|
|
152
|
+
providers: providers.slice(0, LIMITS.providersMax).map((provider) => ({
|
|
153
|
+
id: provider.id,
|
|
154
|
+
displayName: provider.displayName.slice(0, LIMITS.titleChars),
|
|
155
|
+
available: provider.available,
|
|
156
|
+
models: provider.models.slice(0, LIMITS.modelsPerProvider).map((model) => ({
|
|
157
|
+
id: model.id,
|
|
158
|
+
displayName: model.displayName.slice(0, LIMITS.titleChars),
|
|
159
|
+
isDefault: model.isDefault,
|
|
160
|
+
reasoningLevels: model.reasoningLevels.slice(0, 16),
|
|
161
|
+
})),
|
|
162
|
+
})),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export function storageKey(session: string, key: string): string {
|
|
169
|
+
return `state:${session}:${key}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const storageGet = handler<{ key: string }, unknown>({
|
|
173
|
+
method: "storage.get",
|
|
174
|
+
async execute(params, { serving, session }) {
|
|
175
|
+
const stored = await serving.host.kv.get(storageKey(session.id, params.key));
|
|
176
|
+
return { result: stored === undefined ? { found: false } : { found: true, value: stored } };
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
export const storageSet = handler<{ key: string; value: JsonValue }, unknown>({
|
|
181
|
+
method: "storage.set",
|
|
182
|
+
async execute(params, { serving, session }) {
|
|
183
|
+
await serving.host.kv.set(storageKey(session.id, params.key), params.value);
|
|
184
|
+
return { result: { stored: true } };
|
|
185
|
+
},
|
|
186
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { SessionsSendParams, SessionsStartParams } from "../../../domain/capabilities/specs.ts";
|
|
2
|
+
import { PageError } from "../../../domain/errors.ts";
|
|
3
|
+
import { fingerprint } from "../../../domain/json/canonical.ts";
|
|
4
|
+
import type { JsonValue } from "../../../domain/json/strict-json.ts";
|
|
5
|
+
import { formatReplyMessage } from "../../../domain/submissions/message.ts";
|
|
6
|
+
import type { SessionRecord, StartSessionArgs } from "../../../host/types.ts";
|
|
7
|
+
import { excerpt, handler, type HandlerContext } from "../handler.ts";
|
|
8
|
+
|
|
9
|
+
/** Writes to the page's own session, and the confirmed cross-session effects. spec 05 */
|
|
10
|
+
|
|
11
|
+
interface ReplyParams {
|
|
12
|
+
title?: string;
|
|
13
|
+
mode: "queue" | "steer";
|
|
14
|
+
result: JsonValue;
|
|
15
|
+
idempotencyKey?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const sessionReply = handler<ReplyParams, unknown>({
|
|
19
|
+
method: "session.reply",
|
|
20
|
+
async execute(params, { serving, session, page, requestId }) {
|
|
21
|
+
const key = `${session.id}:${params.idempotencyKey ?? requestId}`;
|
|
22
|
+
const print = fingerprint({ revision: page.revision, result: params.result, mode: params.mode, title: params.title ?? null });
|
|
23
|
+
const remembered = serving.replies.remember(key, print, () => serving.host.sessions.send(session.id, formatReplyMessage(params.title, params.result), params.mode), serving.now());
|
|
24
|
+
if (remembered.kind === "conflict") throw new PageError("conflict", "This idempotency key was already used with a different reply");
|
|
25
|
+
const outcome = await remembered.outcome;
|
|
26
|
+
return { result: { delivery: outcome.delivery, duplicate: remembered.kind === "replay" } };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function targetSession(context: HandlerContext, id: string): Promise<SessionRecord> {
|
|
31
|
+
const target = await context.serving.host.sessions.get(id);
|
|
32
|
+
if (!target || target.deleted) throw new PageError("not_found", "That session is not available");
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const sessionsSend = handler<SessionsSendParams, unknown>({
|
|
37
|
+
method: "sessions.send",
|
|
38
|
+
async refuse(params, context) {
|
|
39
|
+
if (params.sessionId === context.session.id) throw new PageError("invalid_params", "Use session.reply to answer this page's own session");
|
|
40
|
+
await targetSession(context, params.sessionId);
|
|
41
|
+
},
|
|
42
|
+
async summarize(params, context) {
|
|
43
|
+
const target = await targetSession(context, params.sessionId);
|
|
44
|
+
return `Send to “${excerpt(target.title, 60)}”: “${excerpt(params.prompt)}”${params.mode === "steer" ? " (interrupting its current turn)" : ""}`;
|
|
45
|
+
},
|
|
46
|
+
async execute(params, { serving }) {
|
|
47
|
+
const sent = await serving.host.sessions.send(params.sessionId, params.prompt, params.mode);
|
|
48
|
+
return { result: { sessionId: params.sessionId, delivery: sent.delivery, duplicate: false } };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function resolveStart(params: SessionsStartParams, context: HandlerContext): Promise<{ args: StartSessionArgs; projectName: string; environmentLabel: string }> {
|
|
53
|
+
const projects = await context.serving.host.projects.list();
|
|
54
|
+
const project = projects.find((candidate) => candidate.id === params.projectId);
|
|
55
|
+
if (!project) throw new PageError("not_found", "That project is not available");
|
|
56
|
+
let environment: StartSessionArgs["environment"] = { kind: "project-default" };
|
|
57
|
+
let environmentLabel = "the project's default environment";
|
|
58
|
+
if (typeof params.environment === "object") {
|
|
59
|
+
const other = await targetSession(context, params.environment.sameAs);
|
|
60
|
+
if (!other.environmentId) throw new PageError("invalid_params", "That session has no environment to share");
|
|
61
|
+
environment = { kind: "reuse", environmentId: other.environmentId };
|
|
62
|
+
environmentLabel = `the environment of “${excerpt(other.title, 40)}”`;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
args: {
|
|
66
|
+
projectId: params.projectId,
|
|
67
|
+
prompt: params.prompt,
|
|
68
|
+
...(params.title ? { title: params.title } : {}),
|
|
69
|
+
...(params.providerId ? { providerId: params.providerId } : {}),
|
|
70
|
+
...(params.model ? { model: params.model } : {}),
|
|
71
|
+
...(params.reasoningLevel ? { reasoningLevel: params.reasoningLevel } : {}),
|
|
72
|
+
environment,
|
|
73
|
+
},
|
|
74
|
+
projectName: project.name,
|
|
75
|
+
environmentLabel,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const sessionsStart = handler<SessionsStartParams, unknown>({
|
|
80
|
+
method: "sessions.start",
|
|
81
|
+
async refuse(params, context) {
|
|
82
|
+
await resolveStart(params, context);
|
|
83
|
+
},
|
|
84
|
+
async summarize(params, context) {
|
|
85
|
+
const { projectName, environmentLabel } = await resolveStart(params, context);
|
|
86
|
+
const runtime = [params.providerId, params.model, params.reasoningLevel].filter(Boolean).join(" · ") || "the project's default provider and model";
|
|
87
|
+
return `Start a session in ${projectName}: “${excerpt(params.prompt)}” — using ${runtime}, in ${environmentLabel}`;
|
|
88
|
+
},
|
|
89
|
+
async execute(params, context) {
|
|
90
|
+
const { args } = await resolveStart(params, context);
|
|
91
|
+
const started = await context.serving.host.sessions.start(args);
|
|
92
|
+
return { result: { sessionId: started.id } };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const sessionsStop = handler<{ sessionId: string }, unknown>({
|
|
97
|
+
method: "sessions.stop",
|
|
98
|
+
async refuse(params, context) {
|
|
99
|
+
if (params.sessionId === context.session.id) throw new PageError("invalid_params", "A page cannot stop its own session");
|
|
100
|
+
await targetSession(context, params.sessionId);
|
|
101
|
+
},
|
|
102
|
+
async summarize(params, context) {
|
|
103
|
+
const target = await targetSession(context, params.sessionId);
|
|
104
|
+
return `Stop “${excerpt(target.title, 60)}”`;
|
|
105
|
+
},
|
|
106
|
+
async execute(params, { serving }) {
|
|
107
|
+
await serving.host.sessions.stop(params.sessionId);
|
|
108
|
+
return { result: { stopped: true } };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export const sessionsArchive = handler<{ sessionId: string }, unknown>({
|
|
113
|
+
method: "sessions.archive",
|
|
114
|
+
async refuse(params, context) {
|
|
115
|
+
await targetSession(context, params.sessionId);
|
|
116
|
+
},
|
|
117
|
+
async summarize(params, context) {
|
|
118
|
+
const target = await targetSession(context, params.sessionId);
|
|
119
|
+
return `Archive “${excerpt(target.title, 60)}”${params.sessionId === context.session.id ? " (this page's own session; its page will stop being served)" : ""}`;
|
|
120
|
+
},
|
|
121
|
+
async execute(params, { serving }) {
|
|
122
|
+
await serving.host.sessions.archive(params.sessionId);
|
|
123
|
+
return { result: { archived: true } };
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const sessionsMarkRead = handler<{ sessionId: string; read: boolean }, unknown>({
|
|
128
|
+
method: "sessions.markRead",
|
|
129
|
+
async refuse(params, context) {
|
|
130
|
+
await targetSession(context, params.sessionId);
|
|
131
|
+
},
|
|
132
|
+
async execute(params, { serving }) {
|
|
133
|
+
const after = await serving.host.sessions.markRead(params.sessionId, params.read);
|
|
134
|
+
return { result: { sessionId: params.sessionId, unread: after.unread } };
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const projectsBrowse = handler<null, unknown>({
|
|
139
|
+
method: "projects.browse",
|
|
140
|
+
async summarize() {
|
|
141
|
+
return "Choose a project folder on this device";
|
|
142
|
+
},
|
|
143
|
+
async execute(_params, { serving, session }) {
|
|
144
|
+
const location = await serving.host.sessions.storage(session.id);
|
|
145
|
+
const picked = await serving.host.projects.browse(location.hostId);
|
|
146
|
+
if (!picked) return { result: { selection: null } };
|
|
147
|
+
const token = serving.selections.issue({ session: session.id, hostId: location.hostId, path: picked.path }, serving.now());
|
|
148
|
+
return { result: { selection: { token, displayPath: displayPath(picked.path), hostName: picked.hostName } } };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function displayPath(path: string): string {
|
|
153
|
+
return path.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~").replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const projectsCreate = handler<{ selectionToken: string; name?: string }, unknown>({
|
|
157
|
+
method: "projects.create",
|
|
158
|
+
async refuse(params, { serving, session }) {
|
|
159
|
+
if (!serving.selections.peek(params.selectionToken, session.id, serving.now())) {
|
|
160
|
+
throw new PageError("not_found", "That folder selection has expired; choose the folder again");
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
async summarize(params, { serving, session }) {
|
|
164
|
+
const selection = serving.selections.peek(params.selectionToken, session.id, serving.now());
|
|
165
|
+
const name = params.name ?? selection?.path.split(/[\\/]/).pop() ?? "the selected folder";
|
|
166
|
+
return `Create project “${excerpt(name, 60)}” from ${selection ? displayPath(selection.path) : "the selected folder"}`;
|
|
167
|
+
},
|
|
168
|
+
async execute(params, { serving, session }) {
|
|
169
|
+
const selection = serving.selections.redeem(params.selectionToken, session.id, serving.now());
|
|
170
|
+
if (!selection) throw new PageError("not_found", "That folder selection has expired; choose the folder again");
|
|
171
|
+
const name = params.name ?? selection.path.split(/[\\/]/).pop() ?? "New project";
|
|
172
|
+
const created = await serving.host.projects.create({ name, hostId: selection.hostId, path: selection.path });
|
|
173
|
+
return { result: { project: { id: created.id, name: created.name, kind: created.kind } } };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { LIMITS } from "../../domain/limits.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Folder-picker selections, held server-side so a page only ever sees an
|
|
6
|
+
* opaque token: single use, short-lived, bound to the requesting session.
|
|
7
|
+
* spec R5.35–R5.37
|
|
8
|
+
*/
|
|
9
|
+
export interface Selection {
|
|
10
|
+
readonly session: string;
|
|
11
|
+
readonly hostId: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SelectionStore {
|
|
17
|
+
issue(selection: Omit<Selection, "expiresAt">, now: number): string;
|
|
18
|
+
/** Looks a selection up without consuming it. */
|
|
19
|
+
peek(token: string, session: string, now: number): Selection | null;
|
|
20
|
+
/** Returns and forgets the selection when the token is valid for the session. */
|
|
21
|
+
redeem(token: string, session: string, now: number): Selection | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createSelectionStore(): SelectionStore {
|
|
25
|
+
const selections = new Map<string, Selection>();
|
|
26
|
+
|
|
27
|
+
function prune(now: number): void {
|
|
28
|
+
for (const [token, selection] of selections) {
|
|
29
|
+
if (selection.expiresAt <= now) selections.delete(token);
|
|
30
|
+
}
|
|
31
|
+
while (selections.size > LIMITS.selectionTokens) {
|
|
32
|
+
const oldest = selections.keys().next().value;
|
|
33
|
+
if (oldest === undefined) break;
|
|
34
|
+
selections.delete(oldest);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
issue(selection, now) {
|
|
40
|
+
prune(now);
|
|
41
|
+
const token = `sel.${randomBytes(18).toString("base64url")}`;
|
|
42
|
+
selections.set(token, { ...selection, expiresAt: now + LIMITS.selectionTokenMs });
|
|
43
|
+
return token;
|
|
44
|
+
},
|
|
45
|
+
peek(token, session, now) {
|
|
46
|
+
prune(now);
|
|
47
|
+
const selection = selections.get(token);
|
|
48
|
+
return selection && selection.session === session ? selection : null;
|
|
49
|
+
},
|
|
50
|
+
redeem(token, session, now) {
|
|
51
|
+
prune(now);
|
|
52
|
+
const selection = selections.get(token);
|
|
53
|
+
if (!selection || selection.session !== session) return null;
|
|
54
|
+
selections.delete(token);
|
|
55
|
+
return selection;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { failure } from "../domain/capabilities/protocol.ts";
|
|
3
|
+
import { PageError } from "../domain/errors.ts";
|
|
4
|
+
import { LIMITS } from "../domain/limits.ts";
|
|
5
|
+
import { readJsonBody } from "./action-request.ts";
|
|
6
|
+
import type { createDispatcher } from "./bridge/dispatcher.ts";
|
|
7
|
+
import { jsonResponse } from "./responses.ts";
|
|
8
|
+
|
|
9
|
+
/** `POST /bridge` — one capability invocation. */
|
|
10
|
+
export function bridgeRoute(dispatch: ReturnType<typeof createDispatcher>) {
|
|
11
|
+
return async (context: Context): Promise<Response> => {
|
|
12
|
+
let body: unknown;
|
|
13
|
+
try {
|
|
14
|
+
body = await readJsonBody(context, LIMITS.capabilityPayloadBytes + 8_192);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const failed = PageError.is(error) ? error : new PageError("invalid_json", "Invalid bridge body");
|
|
17
|
+
const code = failed.code === "request_too_large" ? "request_too_large" : "invalid_json";
|
|
18
|
+
return jsonResponse({ response: failure(undefined, code, failed.message) }, failed.status);
|
|
19
|
+
}
|
|
20
|
+
const outcome = await dispatch(body);
|
|
21
|
+
return jsonResponse(outcome.body, outcome.status);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CapabilityRegistry } from "../domain/capabilities/registry.ts";
|
|
2
|
+
import type { RateLimiter } from "../domain/rate-limit.ts";
|
|
3
|
+
import type { OutcomeMemory } from "../domain/submissions/idempotency.ts";
|
|
4
|
+
import type { LiveSettings } from "../config/settings.ts";
|
|
5
|
+
import type { SessionHost } from "../host/contract.ts";
|
|
6
|
+
import type { PageStore } from "../pages/page-store.ts";
|
|
7
|
+
import type { SiteStrategy } from "../pages/site.ts";
|
|
8
|
+
import type { SelectionStore } from "./bridge/selection-store.ts";
|
|
9
|
+
|
|
10
|
+
/** Everything a route or handler may need, assembled once by the composition root. */
|
|
11
|
+
export interface ServingContext {
|
|
12
|
+
readonly host: SessionHost;
|
|
13
|
+
readonly pages: PageStore;
|
|
14
|
+
readonly settings: LiveSettings;
|
|
15
|
+
readonly signingKey: Uint8Array;
|
|
16
|
+
readonly site: SiteStrategy;
|
|
17
|
+
readonly routeBase: string;
|
|
18
|
+
readonly registry: CapabilityRegistry;
|
|
19
|
+
readonly rate: RateLimiter;
|
|
20
|
+
readonly submissions: OutcomeMemory<{ status: number; body: Record<string, unknown> }>;
|
|
21
|
+
readonly replies: OutcomeMemory<{ delivery: "started" | "queued" | "steered" }>;
|
|
22
|
+
readonly selections: SelectionStore;
|
|
23
|
+
/** The host application's URL for a session's conversation, origin-relative. */
|
|
24
|
+
readonly hostSessionUrl: (session: string) => string;
|
|
25
|
+
readonly now: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pageUrl(routeBase: string, session: string): string {
|
|
29
|
+
return `${routeBase}/page?session=${encodeURIComponent(session)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function homeUrl(routeBase: string): string {
|
|
33
|
+
return `${routeBase}/home`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { injectKernel } from "../domain/html/document.ts";
|
|
3
|
+
import { etagFor, ifNoneMatchMatches } from "../domain/revision.ts";
|
|
4
|
+
import { KERNEL_RUNTIME } from "../generated/kernel-runtime.ts";
|
|
5
|
+
import type { KernelConfig } from "../runtime/shared/protocol.ts";
|
|
6
|
+
import type { ServingContext } from "./context.ts";
|
|
7
|
+
import { baseHeaders, documentCsp, failureResponse } from "./responses.ts";
|
|
8
|
+
import { eligibleSession, sessionIdFrom } from "./session-access.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `GET /document?session=<id>` — the entry document with the kernel injected,
|
|
12
|
+
* and the shell's revision poll: the ETag is the revision, the activity and
|
|
13
|
+
* source state ride along as headers. spec R2.11–R2.13, R2.17, R2.25
|
|
14
|
+
*/
|
|
15
|
+
export function documentRoute(serving: ServingContext) {
|
|
16
|
+
return async (context: Context): Promise<Response> => {
|
|
17
|
+
try {
|
|
18
|
+
const id = sessionIdFrom(context);
|
|
19
|
+
const session = await eligibleSession(serving, id);
|
|
20
|
+
const page = await serving.pages.load(id);
|
|
21
|
+
const headers = baseHeaders("text/html; charset=utf-8");
|
|
22
|
+
headers.set("content-security-policy", documentCsp());
|
|
23
|
+
headers.set("etag", etagFor(page.revision));
|
|
24
|
+
headers.set("x-thread-page-stale", String(page.stale));
|
|
25
|
+
headers.set("x-thread-page-activity", session.state);
|
|
26
|
+
headers.set("x-thread-page-updated-at", String(page.updatedAtMs));
|
|
27
|
+
if (ifNoneMatchMatches(context.req.header("if-none-match"), etagFor(page.revision))) {
|
|
28
|
+
return new Response(null, { status: 304, headers });
|
|
29
|
+
}
|
|
30
|
+
const config: KernelConfig = { pageRevision: page.revision, stale: page.stale };
|
|
31
|
+
const html = injectKernel(page.html, { kernel: KERNEL_RUNTIME, config, baseHref: serving.site.baseHref(id) });
|
|
32
|
+
return new Response(html, { status: 200, headers });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return failureResponse(error, serving.host.log, "GET /document", true);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { isSessionId } from "../domain/ids.ts";
|
|
3
|
+
import type { ServingContext } from "./context.ts";
|
|
4
|
+
import { pageUrl } from "./context.ts";
|
|
5
|
+
import { errorPage } from "./responses.ts";
|
|
6
|
+
|
|
7
|
+
/** `GET /home` — a redirect to the designated home page, never a listing. spec R7.9, R7.4 */
|
|
8
|
+
export function homeRoute(serving: ServingContext) {
|
|
9
|
+
return async (_context: Context): Promise<Response> => {
|
|
10
|
+
const home = serving.settings.current().homeSessionId;
|
|
11
|
+
if (!isSessionId(home)) {
|
|
12
|
+
return errorPage(
|
|
13
|
+
"No home page is set yet. A home page is an ordinary page some agent built and designated. To get one, ask any agent: “Set up my Thread Pages home page” — it runs `bb thread-page home` in a session dedicated to it and builds a hub of your sessions there. Or run `bb thread-page home` yourself in the session whose page should be home.",
|
|
14
|
+
404,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const session = await serving.host.sessions.get(home).catch(() => null);
|
|
18
|
+
if (!session || session.deleted || session.archived) {
|
|
19
|
+
return errorPage("The home page points at a session that no longer exists. Run `bb thread-page home` in another session, or `bb thread-page home --clear`.", 404);
|
|
20
|
+
}
|
|
21
|
+
return new Response(null, { status: 302, headers: { location: pageUrl(serving.routeBase, home), "cache-control": "no-store, max-age=0" } });
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { PageError, errorText } from "../domain/errors.ts";
|
|
2
|
+
import { escapeHtml } from "../domain/html/escape.ts";
|
|
3
|
+
import type { HostLogger } from "../host/types.ts";
|
|
4
|
+
|
|
5
|
+
/** Response helpers and the two content security policies. spec R2.41–R2.43, 03 */
|
|
6
|
+
|
|
7
|
+
export function baseHeaders(contentType: string): Headers {
|
|
8
|
+
return new Headers({
|
|
9
|
+
"cache-control": "no-store, max-age=0",
|
|
10
|
+
"content-type": contentType,
|
|
11
|
+
"referrer-policy": "no-referrer",
|
|
12
|
+
"x-content-type-options": "nosniff",
|
|
13
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The trusted shell: nothing runs but our nonced script and style. */
|
|
18
|
+
export function shellCsp(nonce: string): string {
|
|
19
|
+
return [
|
|
20
|
+
"default-src 'none'",
|
|
21
|
+
"base-uri 'none'",
|
|
22
|
+
"connect-src 'self'",
|
|
23
|
+
"form-action 'none'",
|
|
24
|
+
"frame-ancestors 'self'",
|
|
25
|
+
"frame-src 'self'",
|
|
26
|
+
`script-src 'nonce-${nonce}'`,
|
|
27
|
+
`style-src 'nonce-${nonce}'`,
|
|
28
|
+
"img-src 'self' data:",
|
|
29
|
+
].join("; ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The document: open network and arbitrary code (spec R3.4, R3.11), no
|
|
34
|
+
* frames (embedding is deferred), no plugins, same-origin bases only, and
|
|
35
|
+
* the sandbox restated so it holds even if the frame attribute did not.
|
|
36
|
+
*/
|
|
37
|
+
export function documentCsp(): string {
|
|
38
|
+
return [
|
|
39
|
+
"default-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
|
|
40
|
+
"script-src * data: blob: 'unsafe-inline' 'unsafe-eval'",
|
|
41
|
+
"style-src * data: blob: 'unsafe-inline'",
|
|
42
|
+
"img-src * data: blob:",
|
|
43
|
+
"font-src * data: blob:",
|
|
44
|
+
"media-src * data: blob:",
|
|
45
|
+
"connect-src * data: blob:",
|
|
46
|
+
"worker-src * blob: data:",
|
|
47
|
+
"frame-src 'none'",
|
|
48
|
+
"child-src blob:",
|
|
49
|
+
"object-src 'none'",
|
|
50
|
+
"base-uri 'self'",
|
|
51
|
+
"sandbox allow-scripts allow-forms",
|
|
52
|
+
].join("; ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function jsonResponse(value: unknown, status = 200, extra?: Record<string, string>): Response {
|
|
56
|
+
const headers = baseHeaders("application/json; charset=utf-8");
|
|
57
|
+
for (const [key, entry] of Object.entries(extra ?? {})) headers.set(key, entry);
|
|
58
|
+
return new Response(JSON.stringify(value), { status, headers });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function errorJson(error: PageError): Response {
|
|
62
|
+
return jsonResponse({ ok: false, code: error.code, message: error.message }, error.status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function errorPage(message: string, status: number): Response {
|
|
66
|
+
const headers = baseHeaders("text/html; charset=utf-8");
|
|
67
|
+
headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
68
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Thread Page</title><style>body{max-width:42rem;margin:4rem auto;padding:0 1rem;font:16px/1.5 system-ui,sans-serif;color:CanvasText;background:Canvas}h1{font-size:1.4rem}</style></head><body><main><h1>Thread Page</h1><p>${escapeHtml(message)}</p></main></body></html>`;
|
|
69
|
+
return new Response(html, { status, headers });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Turns any failure into a response, logging the cause when it is not a page error. */
|
|
73
|
+
export function failureResponse(error: unknown, log: HostLogger, where: string, asPage: boolean): Response {
|
|
74
|
+
if (PageError.is(error)) {
|
|
75
|
+
if (error.cause !== undefined) log.warn(`${where}: ${error.code}: ${errorText(error.cause)}`);
|
|
76
|
+
return asPage ? errorPage(error.message, error.status) : errorJson(error);
|
|
77
|
+
}
|
|
78
|
+
log.warn(`${where}: ${errorText(error)}`);
|
|
79
|
+
const generic = new PageError("handler_error", "Something went wrong serving this page.");
|
|
80
|
+
return asPage ? errorPage(generic.message, 500) : errorJson(generic);
|
|
81
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
2
|
+
import { createDispatcher } from "./bridge/dispatcher.ts";
|
|
3
|
+
import { ALL_HANDLERS } from "./bridge/handlers/index.ts";
|
|
4
|
+
import { bridgeRoute } from "./bridge-route.ts";
|
|
5
|
+
import type { ServingContext } from "./context.ts";
|
|
6
|
+
import { documentRoute } from "./document-route.ts";
|
|
7
|
+
import { homeRoute } from "./home-route.ts";
|
|
8
|
+
import { shellRoute } from "./shell-route.ts";
|
|
9
|
+
import { submitRoute } from "./submit-route.ts";
|
|
10
|
+
import { uploadRoute } from "./upload-route.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The route table. spec 02 §Routes
|
|
14
|
+
*
|
|
15
|
+
* Every route uses the host's "local" auth: the reader's existing session
|
|
16
|
+
* with bb, plus the action token where an effect is possible.
|
|
17
|
+
*/
|
|
18
|
+
export function registerRoutes(bb: BbPluginApi, serving: ServingContext): void {
|
|
19
|
+
const dispatch = createDispatcher(serving, ALL_HANDLERS);
|
|
20
|
+
bb.http.route("GET", "/page", shellRoute(serving), { auth: "local" });
|
|
21
|
+
bb.http.route("GET", "/document", documentRoute(serving), { auth: "local" });
|
|
22
|
+
bb.http.route("GET", "/home", homeRoute(serving), { auth: "local" });
|
|
23
|
+
bb.http.route("POST", "/submit", submitRoute(serving), { auth: "local" });
|
|
24
|
+
bb.http.route("POST", "/upload", uploadRoute(serving), { auth: "local" });
|
|
25
|
+
bb.http.route("POST", "/bridge", bridgeRoute(dispatch), { auth: "local" });
|
|
26
|
+
}
|