@unifedev/thread-pages 0.3.2 → 1.1.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/README.md +77 -129
- package/dist/server.js +11779 -11801
- package/dist/server.meta.json +2 -2
- package/docs/B1-OWN-FILES.md +133 -0
- package/docs/FOR-PAGE-AUTHORS-1.1.md +167 -0
- package/docs/UPGRADING.md +53 -0
- package/package.json +26 -18
- package/server.ts +3 -2175
- package/src/agent/cli.ts +193 -0
- package/src/agent/guide.ts +450 -0
- package/src/agent/instruction.ts +59 -0
- package/src/agent/seed/seed.ts +73 -0
- package/{theme.ts → src/agent/seed/theme-css.ts} +9 -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 +113 -0
- package/src/domain/capabilities/registry.ts +48 -0
- package/src/domain/capabilities/renamed.ts +34 -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 +98 -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/inline.ts +277 -0
- package/src/pages/layout.ts +65 -0
- package/src/pages/page-store.ts +170 -0
- package/src/pages/site.ts +36 -0
- package/src/plugin.ts +81 -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,198 @@
|
|
|
1
|
+
import { invalid, isJsonObject, pathForKey, valid, validateJson, type Issue, type JsonLimits, type JsonValue, type Validation } from "../json/strict-json.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A tiny typed schema language for capability parameters and results.
|
|
5
|
+
*
|
|
6
|
+
* Every object is exact: unknown keys are rejected, required keys must be
|
|
7
|
+
* present, optional keys may be absent (never `undefined`). Strings are
|
|
8
|
+
* bounded; numbers are integers in a range; JSON blobs carry their own
|
|
9
|
+
* limits. spec R5.2, R5.3
|
|
10
|
+
*/
|
|
11
|
+
export interface Schema<T> {
|
|
12
|
+
parse(value: JsonValue | undefined, path: string): Validation<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OptionalSchema<T> extends Schema<T> {
|
|
16
|
+
readonly isOptional: true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Optional on the wire, always present after parsing. */
|
|
20
|
+
export interface DefaultedSchema<T> extends OptionalSchema<T> {
|
|
21
|
+
readonly hasDefault: true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Infer<S> = S extends Schema<infer T> ? T : never;
|
|
25
|
+
|
|
26
|
+
type Shape = Record<string, Schema<unknown>>;
|
|
27
|
+
type OptionalKeys<S extends Shape> = {
|
|
28
|
+
[K in keyof S]: S[K] extends DefaultedSchema<unknown> ? never : S[K] extends OptionalSchema<unknown> ? K : never;
|
|
29
|
+
}[keyof S];
|
|
30
|
+
type RequiredKeys<S extends Shape> = Exclude<keyof S, OptionalKeys<S>>;
|
|
31
|
+
export type InferShape<S extends Shape> = { [K in RequiredKeys<S>]: Infer<S[K]> } & { [K in OptionalKeys<S>]?: Infer<S[K]> };
|
|
32
|
+
|
|
33
|
+
function issues<T>(list: readonly Issue[]): Validation<T> {
|
|
34
|
+
return { ok: false, issues: list };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function string(options: { min?: number; max: number; pattern?: RegExp; label?: string }): Schema<string> {
|
|
38
|
+
const label = options.label ?? "String";
|
|
39
|
+
return {
|
|
40
|
+
parse(value, path) {
|
|
41
|
+
if (typeof value !== "string") return invalid(path, `${label}: expected a string`, "invalid_type");
|
|
42
|
+
const min = options.min ?? 0;
|
|
43
|
+
if (value.length < min || value.length > options.max) {
|
|
44
|
+
return invalid(path, `${label}: length must be ${min}–${options.max}`, "too_large");
|
|
45
|
+
}
|
|
46
|
+
if (options.pattern && !options.pattern.test(value)) return invalid(path, `${label}: invalid format`);
|
|
47
|
+
return valid(value);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function integer(min: number, max: number, label = "Integer"): Schema<number> {
|
|
53
|
+
return {
|
|
54
|
+
parse(value, path) {
|
|
55
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
56
|
+
return invalid(path, `${label}: expected an integer from ${min} to ${max}`);
|
|
57
|
+
}
|
|
58
|
+
return valid(value);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function boolean(label = "Boolean"): Schema<boolean> {
|
|
64
|
+
return {
|
|
65
|
+
parse(value, path) {
|
|
66
|
+
return typeof value === "boolean" ? valid(value) : invalid(path, `${label}: expected a boolean`, "invalid_type");
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function literal<const T extends readonly (string | number | boolean | null)[]>(values: T, label = "Value"): Schema<T[number]> {
|
|
72
|
+
return {
|
|
73
|
+
parse(value, path) {
|
|
74
|
+
return (values as readonly unknown[]).includes(value)
|
|
75
|
+
? valid(value as T[number])
|
|
76
|
+
: invalid(path, `${label}: expected one of ${values.map((item) => JSON.stringify(item)).join(", ")}`);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function nullable<T>(schema: Schema<T>): Schema<T | null> {
|
|
82
|
+
return {
|
|
83
|
+
parse(value, path) {
|
|
84
|
+
return value === null ? valid(null) : schema.parse(value, path);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function optional<T>(schema: Schema<T>): OptionalSchema<T> {
|
|
90
|
+
return { isOptional: true, parse: (value, path) => schema.parse(value, path) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Optional with a default applied when the key is absent. */
|
|
94
|
+
export function withDefault<T>(schema: Schema<T>, fallback: T): DefaultedSchema<T> {
|
|
95
|
+
return {
|
|
96
|
+
isOptional: true,
|
|
97
|
+
hasDefault: true,
|
|
98
|
+
parse(value, path) {
|
|
99
|
+
return value === undefined ? valid(fallback) : schema.parse(value, path);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function array<T>(item: Schema<T>, max: number, label = "List"): Schema<T[]> {
|
|
105
|
+
return {
|
|
106
|
+
parse(value, path) {
|
|
107
|
+
if (!Array.isArray(value)) return invalid(path, `${label}: expected a list`, "invalid_type");
|
|
108
|
+
if (value.length > max) return invalid(path, `${label}: at most ${max} items`, "too_large");
|
|
109
|
+
const out: T[] = [];
|
|
110
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
111
|
+
const parsed = item.parse(value[index], `${path}[${index}]`);
|
|
112
|
+
if (!parsed.ok) return issues(parsed.issues);
|
|
113
|
+
out.push(parsed.value);
|
|
114
|
+
}
|
|
115
|
+
return valid(out);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function object<S extends Shape>(shape: S, label = "Object"): Schema<InferShape<S>> {
|
|
121
|
+
const keys = Object.keys(shape);
|
|
122
|
+
const known = new Set(keys);
|
|
123
|
+
return {
|
|
124
|
+
parse(value, path) {
|
|
125
|
+
if (!isJsonObject(value)) return invalid(path, `${label}: expected an object`, "invalid_type");
|
|
126
|
+
for (const key of Object.keys(value)) {
|
|
127
|
+
if (!known.has(key)) return invalid(pathForKey(path, key), "Unknown key", "unknown_key");
|
|
128
|
+
}
|
|
129
|
+
const out: Record<string, unknown> = {};
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
const schema = shape[key] as Schema<unknown> & { isOptional?: boolean };
|
|
132
|
+
const present = Object.prototype.hasOwnProperty.call(value, key);
|
|
133
|
+
if (!present) {
|
|
134
|
+
if (schema.isOptional) {
|
|
135
|
+
// withDefault schemas yield a value for an absent key; plain optional ones do not.
|
|
136
|
+
const parsed = schema.parse(undefined, pathForKey(path, key));
|
|
137
|
+
if (parsed.ok && parsed.value !== undefined) out[key] = parsed.value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
return invalid(pathForKey(path, key), "Missing required key", "missing_key");
|
|
141
|
+
}
|
|
142
|
+
const parsed = schema.parse(value[key], pathForKey(path, key));
|
|
143
|
+
if (!parsed.ok) return issues(parsed.issues);
|
|
144
|
+
out[key] = parsed.value;
|
|
145
|
+
}
|
|
146
|
+
return valid(out as InferShape<S>);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `null`, absent, or `{}` all mean "no parameters". */
|
|
152
|
+
export function noParams(): Schema<null> {
|
|
153
|
+
return {
|
|
154
|
+
parse(value, path) {
|
|
155
|
+
if (value === undefined || value === null) return valid(null);
|
|
156
|
+
if (isJsonObject(value) && Object.keys(value).length === 0) return valid(null);
|
|
157
|
+
return invalid(path, "This capability takes no parameters", "unknown_key");
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Any strict JSON value within the given limits. */
|
|
163
|
+
export function json(limits: JsonLimits = {}, label = "Value"): Schema<JsonValue> {
|
|
164
|
+
return {
|
|
165
|
+
parse(value, path) {
|
|
166
|
+
if (value === undefined) return invalid(path, `${label}: missing`, "missing_key");
|
|
167
|
+
const checked = validateJson(value, limits);
|
|
168
|
+
if (!checked.ok) {
|
|
169
|
+
const first = checked.issues[0];
|
|
170
|
+
return first ? invalid(path === "$" ? first.path : `${path}${first.path.slice(1)}`, first.message, first.code) : checked;
|
|
171
|
+
}
|
|
172
|
+
return checked;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function union<A, B>(first: Schema<A>, second: Schema<B>, label = "Value"): Schema<A | B> {
|
|
178
|
+
return {
|
|
179
|
+
parse(value, path) {
|
|
180
|
+
const a = first.parse(value, path);
|
|
181
|
+
if (a.ok) return a;
|
|
182
|
+
const b = second.parse(value, path);
|
|
183
|
+
if (b.ok) return b;
|
|
184
|
+
return invalid(path, `${label}: did not match any accepted shape`);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function refine<T>(schema: Schema<T>, check: (value: T) => string | null): Schema<T> {
|
|
190
|
+
return {
|
|
191
|
+
parse(value, path) {
|
|
192
|
+
const parsed = schema.parse(value, path);
|
|
193
|
+
if (!parsed.ok) return parsed;
|
|
194
|
+
const problem = check(parsed.value);
|
|
195
|
+
return problem ? invalid(path, problem) : parsed;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { isEntityId, isOpaqueToken, isStorageKey } from "../ids.ts";
|
|
2
|
+
import type { JsonValue } from "../json/strict-json.ts";
|
|
3
|
+
import { LIMITS } from "../limits.ts";
|
|
4
|
+
import type { CapabilitySpec } from "./contract.ts";
|
|
5
|
+
import * as s from "./schema.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every capability, exactly as spec 05 defines it. One object each; the
|
|
9
|
+
* server-side handler for each lives in src/serving/bridge/handlers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// --- shared pieces ---------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
15
|
+
const entityId = (label: string) => s.string({ min: 1, max: 128, pattern: ENTITY_ID, label });
|
|
16
|
+
const title = (label = "Title") => s.string({ max: LIMITS.titleChars, label });
|
|
17
|
+
const prompt = s.string({ min: 1, max: LIMITS.promptChars, label: "Prompt" });
|
|
18
|
+
const safeName = (label: string, max = 160) => s.string({ min: 1, max, pattern: /^[^\u0000-\u001f\u007f]+$/, label });
|
|
19
|
+
const timestamp = s.integer(0, Number.MAX_SAFE_INTEGER, "Timestamp");
|
|
20
|
+
|
|
21
|
+
export const SESSION_STATES = ["working", "idle", "waiting", "failed", "stopped"] as const;
|
|
22
|
+
export type SessionState = (typeof SESSION_STATES)[number];
|
|
23
|
+
const sessionState = s.literal(SESSION_STATES, "State");
|
|
24
|
+
|
|
25
|
+
const deliveryResult = s.object({
|
|
26
|
+
delivery: s.literal(["started", "queued", "steered"], "Delivery"),
|
|
27
|
+
duplicate: s.boolean(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const projectChoice = s.object({
|
|
31
|
+
id: entityId("Project id"),
|
|
32
|
+
name: title("Project name"),
|
|
33
|
+
kind: s.literal(["standard", "personal"], "Project kind"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function spec<P, R>(definition: CapabilitySpec<P, R>): CapabilitySpec<P, R> {
|
|
37
|
+
return Object.freeze(definition);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function params<P>(schema: s.Schema<P>) {
|
|
41
|
+
return (value: JsonValue | undefined) => schema.parse(value, "$");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function result<R>(schema: s.Schema<R>) {
|
|
45
|
+
return (value: unknown) => schema.parse(value as JsonValue, "$");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- reads -----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export const contextGet = spec({
|
|
51
|
+
method: "context.get",
|
|
52
|
+
description: "Read this page's identity and the capability roster.",
|
|
53
|
+
effect: "read",
|
|
54
|
+
confirmed: false,
|
|
55
|
+
implemented: true,
|
|
56
|
+
validateParams: params(s.noParams()),
|
|
57
|
+
validateResult: result(
|
|
58
|
+
s.object({
|
|
59
|
+
protocolVersion: s.literal([1]),
|
|
60
|
+
session: s.object({ id: entityId("Session id"), title: title(), projectId: s.nullable(entityId("Project id")) }),
|
|
61
|
+
page: s.object({ revision: s.string({ min: 64, max: 64, pattern: /^[a-f0-9]{64}$/, label: "Revision" }), readOnly: s.boolean() }),
|
|
62
|
+
capabilities: s.array(
|
|
63
|
+
s.object({
|
|
64
|
+
method: s.string({ min: 3, max: LIMITS.methodNameChars, label: "Method" }),
|
|
65
|
+
effect: s.literal(["read", "own-session-write", "cross-session-write", "destructive", "navigation", "device", "reader-state"]),
|
|
66
|
+
confirmation: s.literal(["none", "required"]),
|
|
67
|
+
}),
|
|
68
|
+
64,
|
|
69
|
+
),
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
doc: {
|
|
73
|
+
params: "None.",
|
|
74
|
+
result: "`{ protocolVersion: 1, session: { id, title, projectId }, page: { revision, readOnly }, capabilities: [{ method, effect, confirmation }] }`.",
|
|
75
|
+
notes: "The roster lists what is actually enabled; check it rather than assume.",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const sessionActivity = spec({
|
|
80
|
+
method: "session.activity",
|
|
81
|
+
description: "Read this session's state and recent activity.",
|
|
82
|
+
effect: "read",
|
|
83
|
+
confirmed: false,
|
|
84
|
+
implemented: true,
|
|
85
|
+
validateParams: params(s.object({ limit: s.withDefault(s.integer(1, LIMITS.activityMax, "Limit"), LIMITS.activityDefault) })),
|
|
86
|
+
validateResult: result(
|
|
87
|
+
s.object({
|
|
88
|
+
state: sessionState,
|
|
89
|
+
updatedAtMs: timestamp,
|
|
90
|
+
items: s.array(
|
|
91
|
+
s.object({
|
|
92
|
+
kind: s.string({ min: 1, max: 80, label: "Kind" }),
|
|
93
|
+
done: s.boolean(),
|
|
94
|
+
atMs: timestamp,
|
|
95
|
+
label: s.string({ min: 1, max: 80, label: "Label" }),
|
|
96
|
+
text: s.string({ max: 200, label: "Text" }),
|
|
97
|
+
}),
|
|
98
|
+
LIMITS.activityMax,
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
doc: {
|
|
103
|
+
params: `\`{ limit? }\` — 1 to ${LIMITS.activityMax}, default ${LIMITS.activityDefault}. It never takes a session id: it is always this page's own session.`,
|
|
104
|
+
result: "`{ state, updatedAtMs, items: [{ kind, done, atMs, label, text }] }` where `state` is one of `working`, `idle`, `waiting`, `failed`, `stopped`.",
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export type SnapshotParams = s.Infer<typeof snapshotParams>;
|
|
109
|
+
const snapshotParams = s.object({
|
|
110
|
+
projectId: s.optional(s.nullable(entityId("Project id"))),
|
|
111
|
+
includeArchived: s.withDefault(s.boolean(), false),
|
|
112
|
+
includeChildren: s.withDefault(s.boolean(), false),
|
|
113
|
+
limit: s.withDefault(s.integer(1, LIMITS.snapshotMax, "Limit"), LIMITS.snapshotDefault),
|
|
114
|
+
cursor: s.optional(s.nullable(s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9._~:-]+$/, label: "Cursor" }))),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const sessionSummary = s.object({
|
|
118
|
+
id: entityId("Session id"),
|
|
119
|
+
title: title(),
|
|
120
|
+
projectId: s.nullable(entityId("Project id")),
|
|
121
|
+
parentSessionId: s.nullable(entityId("Session id")),
|
|
122
|
+
status: sessionState,
|
|
123
|
+
archived: s.boolean(),
|
|
124
|
+
page: s.object({ available: s.boolean(), revision: s.nullable(s.string({ min: 64, max: 64, pattern: /^[a-f0-9]{64}$/ })) }),
|
|
125
|
+
updatedAtMs: timestamp,
|
|
126
|
+
attentionAtMs: timestamp,
|
|
127
|
+
unread: s.boolean(),
|
|
128
|
+
});
|
|
129
|
+
export type SessionSummary = s.Infer<typeof sessionSummary>;
|
|
130
|
+
|
|
131
|
+
export const sessionsSnapshot = spec({
|
|
132
|
+
method: "sessions.snapshot",
|
|
133
|
+
description: "Read a bounded, projected list of sessions.",
|
|
134
|
+
effect: "read",
|
|
135
|
+
confirmed: false,
|
|
136
|
+
implemented: true,
|
|
137
|
+
validateParams: params(snapshotParams),
|
|
138
|
+
validateResult: result(
|
|
139
|
+
s.object({
|
|
140
|
+
sessions: s.array(sessionSummary, LIMITS.snapshotMax),
|
|
141
|
+
nextCursor: s.nullable(s.string({ min: 1, max: 512 })),
|
|
142
|
+
generatedAtMs: timestamp,
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
doc: {
|
|
146
|
+
params: `\`{ projectId?, includeArchived?, includeChildren?, limit?, cursor? }\` — \`limit\` 1 to ${LIMITS.snapshotMax}, default ${LIMITS.snapshotDefault}; pass the previous result's \`nextCursor\` to continue. By default only root sessions are listed, the way the host's own sidebar shows them; \`includeChildren: true\` adds sub-agent sessions (with \`parentSessionId\` set).`,
|
|
147
|
+
result: "`{ sessions: [{ id, title, projectId, parentSessionId, status, archived, unread, attentionAtMs, updatedAtMs, page: { available, revision } }], nextCursor, generatedAtMs }`. `unread` means the session asked for the reader's attention (a turn ended, a question) after they last looked at it — the same mark the host's sidebar shows; `attentionAtMs` is when. `page.revision` is known for pages this host has served recently and `null` otherwise.",
|
|
148
|
+
notes: "No message bodies or agent output are included.",
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export const projectsList = spec({
|
|
153
|
+
method: "projects.list",
|
|
154
|
+
description: "Read project choices without paths or host details.",
|
|
155
|
+
effect: "read",
|
|
156
|
+
confirmed: false,
|
|
157
|
+
implemented: true,
|
|
158
|
+
validateParams: params(s.noParams()),
|
|
159
|
+
validateResult: result(s.object({ projects: s.array(projectChoice, LIMITS.projectsMax) })),
|
|
160
|
+
doc: { params: "None.", result: "`{ projects: [{ id, name, kind }] }` where `kind` is `standard` or `personal`." },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const providersList = spec({
|
|
164
|
+
method: "providers.list",
|
|
165
|
+
description: "Read the provider and model choices a page may pass to sessions.start.",
|
|
166
|
+
effect: "read",
|
|
167
|
+
confirmed: false,
|
|
168
|
+
implemented: true,
|
|
169
|
+
validateParams: params(s.noParams()),
|
|
170
|
+
validateResult: result(
|
|
171
|
+
s.object({
|
|
172
|
+
providers: s.array(
|
|
173
|
+
s.object({
|
|
174
|
+
id: entityId("Provider id"),
|
|
175
|
+
displayName: title("Provider name"),
|
|
176
|
+
available: s.boolean(),
|
|
177
|
+
models: s.array(
|
|
178
|
+
s.object({
|
|
179
|
+
id: safeName("Model id"),
|
|
180
|
+
displayName: title("Model name"),
|
|
181
|
+
isDefault: s.boolean(),
|
|
182
|
+
reasoningLevels: s.array(safeName("Reasoning level", 32), 16),
|
|
183
|
+
}),
|
|
184
|
+
LIMITS.modelsPerProvider,
|
|
185
|
+
),
|
|
186
|
+
}),
|
|
187
|
+
LIMITS.providersMax,
|
|
188
|
+
),
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
doc: {
|
|
192
|
+
params: "None.",
|
|
193
|
+
result: "`{ providers: [{ id, displayName, available, models: [{ id, displayName, isDefault, reasoningLevels }] }] }`.",
|
|
194
|
+
notes: "Fails with `unavailable` when the host cannot enumerate providers; it never returns an empty list to mean that.",
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const storageKey = s.string({ min: 1, max: LIMITS.storageKeyChars, pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, label: "Storage key" });
|
|
199
|
+
const storageValue = s.json({ maxBytes: LIMITS.storageValueBytes, maxDepth: 12 }, "Stored value");
|
|
200
|
+
|
|
201
|
+
export const storageGet = spec({
|
|
202
|
+
method: "storage.get",
|
|
203
|
+
description: "Read a small JSON value stored for this page.",
|
|
204
|
+
effect: "read",
|
|
205
|
+
confirmed: false,
|
|
206
|
+
implemented: true,
|
|
207
|
+
validateParams: params(s.object({ key: storageKey })),
|
|
208
|
+
validateResult: result(
|
|
209
|
+
s.union(
|
|
210
|
+
s.object({ found: s.literal([false]) }),
|
|
211
|
+
s.object({ found: s.literal([true]), value: storageValue }),
|
|
212
|
+
"Storage result",
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
doc: { params: "`{ key }`.", result: "`{ found: false }` or `{ found: true, value }`." },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// --- writes to the page's own session --------------------------------------
|
|
219
|
+
|
|
220
|
+
export const sessionReply = spec({
|
|
221
|
+
method: "session.reply",
|
|
222
|
+
description: "Send a structured result to this page's owning session.",
|
|
223
|
+
effect: "own-session-write",
|
|
224
|
+
confirmed: false,
|
|
225
|
+
implemented: true,
|
|
226
|
+
validateParams: params(
|
|
227
|
+
s.object({
|
|
228
|
+
title: s.optional(title()),
|
|
229
|
+
mode: s.withDefault(s.literal(["queue", "steer"], "Mode"), "queue"),
|
|
230
|
+
result: s.json({ maxBytes: LIMITS.resultTextBytes }, "Result"),
|
|
231
|
+
idempotencyKey: s.optional(s.string({ min: 1, max: LIMITS.requestIdChars, pattern: /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, label: "Idempotency key" })),
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
validateResult: result(deliveryResult),
|
|
235
|
+
doc: {
|
|
236
|
+
params: "`{ result, title?, mode?, idempotencyKey? }` — `mode` is `queue` (default: waits for the current turn) or `steer` (interrupts it).",
|
|
237
|
+
result: "`{ delivery: 'started' | 'queued' | 'steered', duplicate }`.",
|
|
238
|
+
notes: "With an `idempotencyKey`, a repeat with the same content delivers once and reports `duplicate: true`; a repeat with different content is a `conflict`.",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
export const storageSet = spec({
|
|
243
|
+
method: "storage.set",
|
|
244
|
+
description: "Store a small JSON value for this page.",
|
|
245
|
+
effect: "own-session-write",
|
|
246
|
+
confirmed: false,
|
|
247
|
+
implemented: true,
|
|
248
|
+
validateParams: params(s.object({ key: storageKey, value: storageValue })),
|
|
249
|
+
validateResult: result(s.object({ stored: s.literal([true]) })),
|
|
250
|
+
doc: { params: `\`{ key, value }\` — the value serialised must be at most ${LIMITS.storageValueBytes / 1024} KiB.`, result: "`{ stored: true }`.", notes: "Namespaced per session: no page can read another page's keys." },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// --- starting and steering work --------------------------------------------
|
|
254
|
+
|
|
255
|
+
const sessionTarget = s.object({ sessionId: entityId("Session id") });
|
|
256
|
+
|
|
257
|
+
export type SessionsSendParams = s.Infer<typeof sessionsSendParams>;
|
|
258
|
+
const sessionsSendParams = s.object({
|
|
259
|
+
sessionId: entityId("Session id"),
|
|
260
|
+
prompt,
|
|
261
|
+
mode: s.withDefault(s.literal(["queue", "steer"], "Mode"), "queue"),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export const sessionsSend = spec({
|
|
265
|
+
method: "sessions.send",
|
|
266
|
+
description: "Send a prompt to another existing session.",
|
|
267
|
+
effect: "cross-session-write",
|
|
268
|
+
confirmed: true,
|
|
269
|
+
implemented: true,
|
|
270
|
+
validateParams: params(sessionsSendParams),
|
|
271
|
+
validateResult: result(s.object({ sessionId: entityId("Session id"), delivery: s.literal(["started", "queued", "steered"]), duplicate: s.boolean() })),
|
|
272
|
+
doc: {
|
|
273
|
+
params: "`{ sessionId, prompt, mode? }` — `mode` `queue` (default) or `steer`.",
|
|
274
|
+
result: "`{ sessionId, delivery, duplicate }`.",
|
|
275
|
+
notes: "Refuses this page's own session with `invalid_params`; use `session.reply` for that.",
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
export type SessionsStartParams = s.Infer<typeof sessionsStartParams>;
|
|
280
|
+
const sessionsStartParams = s.object({
|
|
281
|
+
projectId: entityId("Project id"),
|
|
282
|
+
prompt,
|
|
283
|
+
title: s.optional(title()),
|
|
284
|
+
providerId: s.optional(entityId("Provider id")),
|
|
285
|
+
model: s.optional(safeName("Model id")),
|
|
286
|
+
reasoningLevel: s.optional(s.literal(["none", "low", "medium", "high", "xhigh", "max", "ultra", "ultracode"], "Reasoning level")),
|
|
287
|
+
environment: s.withDefault(
|
|
288
|
+
s.union(s.literal(["project-default"]), s.object({ sameAs: entityId("Session id") }), "Environment"),
|
|
289
|
+
"project-default" as const,
|
|
290
|
+
),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
export const sessionsStart = spec({
|
|
294
|
+
method: "sessions.start",
|
|
295
|
+
description: "Start a new visible root session in a project.",
|
|
296
|
+
effect: "cross-session-write",
|
|
297
|
+
confirmed: true,
|
|
298
|
+
implemented: true,
|
|
299
|
+
validateParams: params(sessionsStartParams),
|
|
300
|
+
validateResult: result(s.object({ sessionId: entityId("Session id") })),
|
|
301
|
+
doc: {
|
|
302
|
+
params: "`{ projectId, prompt, title?, providerId?, model?, reasoningLevel?, environment? }`.",
|
|
303
|
+
result: "`{ sessionId }`.",
|
|
304
|
+
notes:
|
|
305
|
+
"Defaults when you say nothing: the project's default environment, the project's default provider, model and reasoning level. Say otherwise with `providerId`/`model`/`reasoningLevel` from `providers.list`, or `environment: { sameAs: sessionId }` to run in the same environment as another session. The started session is a visible root owned by the reader, never a child of this page's session.",
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export const sessionsStop = spec({
|
|
310
|
+
method: "sessions.stop",
|
|
311
|
+
description: "Stop a session's running turn.",
|
|
312
|
+
effect: "destructive",
|
|
313
|
+
confirmed: true,
|
|
314
|
+
implemented: true,
|
|
315
|
+
validateParams: params(sessionTarget),
|
|
316
|
+
validateResult: result(s.object({ stopped: s.boolean() })),
|
|
317
|
+
doc: { params: "`{ sessionId }`.", result: "`{ stopped }`.", notes: "Refuses this page's own session outright, before any dialog." },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
export const sessionsMarkRead = spec({
|
|
321
|
+
method: "sessions.markRead",
|
|
322
|
+
description: "Mark a session read or unread for the reader.",
|
|
323
|
+
effect: "reader-state",
|
|
324
|
+
confirmed: false,
|
|
325
|
+
implemented: true,
|
|
326
|
+
validateParams: params(s.object({ sessionId: entityId("Session id"), read: s.withDefault(s.boolean(), true) })),
|
|
327
|
+
validateResult: result(s.object({ sessionId: entityId("Session id"), unread: s.boolean() })),
|
|
328
|
+
doc: {
|
|
329
|
+
params: "`{ sessionId, read? }` — `read` defaults to true; `false` marks it unread again.",
|
|
330
|
+
result: "`{ sessionId, unread }`, the mark after the change.",
|
|
331
|
+
notes: "Changes only the reader's own attention mark, the one the host's sidebar shows; it never touches the session's work, so it is not confirmed.",
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
export const sessionsArchive = spec({
|
|
336
|
+
method: "sessions.archive",
|
|
337
|
+
description: "Archive a session.",
|
|
338
|
+
effect: "destructive",
|
|
339
|
+
confirmed: true,
|
|
340
|
+
implemented: true,
|
|
341
|
+
validateParams: params(sessionTarget),
|
|
342
|
+
validateResult: result(s.object({ archived: s.boolean() })),
|
|
343
|
+
doc: { params: "`{ sessionId }`.", result: "`{ archived }`." },
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// --- navigation --------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
const openedResult = result(s.object({ opened: s.boolean() }));
|
|
349
|
+
|
|
350
|
+
export const pagesOpen = spec({
|
|
351
|
+
method: "pages.open",
|
|
352
|
+
description: "Open another session's page in place.",
|
|
353
|
+
effect: "navigation",
|
|
354
|
+
confirmed: false,
|
|
355
|
+
implemented: true,
|
|
356
|
+
validateParams: params(sessionTarget),
|
|
357
|
+
validateResult: openedResult,
|
|
358
|
+
doc: { params: "`{ sessionId }`.", result: "`{ opened: true }`, after which the reader's view navigates in place; the back button returns here." },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
export const sessionsOpenHost = spec({
|
|
362
|
+
method: "sessions.openHost",
|
|
363
|
+
description: "Open a session in the host application.",
|
|
364
|
+
effect: "navigation",
|
|
365
|
+
confirmed: false,
|
|
366
|
+
implemented: true,
|
|
367
|
+
validateParams: params(sessionTarget),
|
|
368
|
+
validateResult: openedResult,
|
|
369
|
+
doc: { params: "`{ sessionId }`.", result: "`{ opened: true }`; navigates the reader's view in place to the session's conversation." },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
export type OpenExternalParams = s.Infer<typeof openExternalParams>;
|
|
373
|
+
const openExternalParams = s.object({
|
|
374
|
+
url: s.refine(s.string({ min: 1, max: 2048, pattern: /^[^\u0000-\u0020\u007f]+$/, label: "URL" }), (value) => {
|
|
375
|
+
let parsed: URL;
|
|
376
|
+
try {
|
|
377
|
+
parsed = new URL(value);
|
|
378
|
+
} catch {
|
|
379
|
+
return "Expected an absolute http or https URL";
|
|
380
|
+
}
|
|
381
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname || parsed.username || parsed.password) {
|
|
382
|
+
return "Expected an absolute http or https URL without credentials";
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}),
|
|
386
|
+
label: s.optional(s.string({ min: 1, max: 160, label: "Label" })),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
export const navigationOpenExternal = spec({
|
|
390
|
+
method: "navigation.openExternal",
|
|
391
|
+
description: "Open an external http(s) URL through trusted chrome.",
|
|
392
|
+
effect: "navigation",
|
|
393
|
+
confirmed: true,
|
|
394
|
+
implemented: true,
|
|
395
|
+
validateParams: params(openExternalParams),
|
|
396
|
+
validateResult: openedResult,
|
|
397
|
+
doc: {
|
|
398
|
+
params: "`{ url, label? }` — http or https only.",
|
|
399
|
+
result: "`{ opened: true }`. The confirmation names the destination origin. An ordinary `<a href=\"https://…\">` in your page goes through this automatically.",
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// --- device --------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
export const projectsBrowse = spec({
|
|
406
|
+
method: "projects.browse",
|
|
407
|
+
description: "Open the host's folder picker and return an opaque selection token.",
|
|
408
|
+
effect: "device",
|
|
409
|
+
confirmed: true,
|
|
410
|
+
implemented: true,
|
|
411
|
+
validateParams: params(s.noParams()),
|
|
412
|
+
validateResult: result(
|
|
413
|
+
s.object({
|
|
414
|
+
selection: s.nullable(
|
|
415
|
+
s.object({
|
|
416
|
+
token: s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9][A-Za-z0-9._~:-]*$/, label: "Selection token" }),
|
|
417
|
+
displayPath: s.string({ min: 1, max: 1024, label: "Display path" }),
|
|
418
|
+
hostName: title("Host name"),
|
|
419
|
+
}),
|
|
420
|
+
),
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
doc: {
|
|
424
|
+
params: "None.",
|
|
425
|
+
result: `\`{ selection: null }\` when the reader cancels, else \`{ selection: { token, displayPath, hostName } }\`. The token is single use, valid for ${LIMITS.selectionTokenMs / 60_000} minutes and only for this page; the page never sees a filesystem path.`,
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
export const projectsCreate = spec({
|
|
430
|
+
method: "projects.create",
|
|
431
|
+
description: "Create a project from a folder-picker selection.",
|
|
432
|
+
effect: "cross-session-write",
|
|
433
|
+
confirmed: true,
|
|
434
|
+
implemented: true,
|
|
435
|
+
validateParams: params(s.object({ selectionToken: s.string({ min: 1, max: 512, pattern: /^[A-Za-z0-9][A-Za-z0-9._~:-]*$/, label: "Selection token" }), name: s.optional(title("Name")) })),
|
|
436
|
+
validateResult: result(s.object({ project: projectChoice })),
|
|
437
|
+
doc: { params: "`{ selectionToken, name? }`.", result: "`{ project: { id, name, kind } }`.", notes: "An expired, reused or foreign token fails with `not_found`." },
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
export const voiceCaptureAndTranscribe = spec({
|
|
441
|
+
method: "voice.captureAndTranscribe",
|
|
442
|
+
description: "Record and transcribe the reader's voice through trusted chrome.",
|
|
443
|
+
effect: "device",
|
|
444
|
+
confirmed: true,
|
|
445
|
+
implemented: false,
|
|
446
|
+
validateParams: params(
|
|
447
|
+
s.object({
|
|
448
|
+
language: s.optional(s.string({ min: 2, max: 64, pattern: /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, label: "Language" })),
|
|
449
|
+
prompt: s.optional(s.string({ max: 1000, label: "Prompt" })),
|
|
450
|
+
maxDurationSeconds: s.withDefault(s.integer(1, 120, "Duration"), 120),
|
|
451
|
+
}),
|
|
452
|
+
),
|
|
453
|
+
validateResult: result(s.object({ text: s.string({ max: LIMITS.resultTextBytes, label: "Transcript" }) })),
|
|
454
|
+
doc: { params: "`{ language?, prompt?, maxDurationSeconds? }`.", result: "`{ text }`.", notes: "Deferred: the contract exists, the host reports `unknown_method`." },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
export const ALL_CAPABILITIES = Object.freeze([
|
|
458
|
+
contextGet,
|
|
459
|
+
sessionActivity,
|
|
460
|
+
sessionsSnapshot,
|
|
461
|
+
projectsList,
|
|
462
|
+
providersList,
|
|
463
|
+
storageGet,
|
|
464
|
+
sessionReply,
|
|
465
|
+
storageSet,
|
|
466
|
+
pagesOpen,
|
|
467
|
+
sessionsOpenHost,
|
|
468
|
+
sessionsSend,
|
|
469
|
+
sessionsStart,
|
|
470
|
+
projectsCreate,
|
|
471
|
+
sessionsStop,
|
|
472
|
+
sessionsArchive,
|
|
473
|
+
sessionsMarkRead,
|
|
474
|
+
navigationOpenExternal,
|
|
475
|
+
projectsBrowse,
|
|
476
|
+
voiceCaptureAndTranscribe,
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
export { isEntityId, isOpaqueToken, isStorageKey };
|