@miadi/connector 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * OAuth 2.1 for the connector, so ChatGPT (and any MCP client that speaks
3
+ * the MCP authorization spec) can sign a person in.
4
+ *
5
+ * There is no password: a person proves who they are with the Miadi person
6
+ * token they already hold. The sign-in page asks for it once, Miadi says
7
+ * whose it is, and from then on the client holds connector tokens only. A
8
+ * Miadi person token sent directly as a Bearer token is also accepted, so a
9
+ * terminal client (Claude Code, curl) needs no OAuth dance.
10
+ */
11
+ import type { Request, Response } from "express";
12
+ import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
13
+ import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
14
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
15
+ import type { OAuthClientInformationFull, OAuthTokens, OAuthTokenRevocationRequest } from "@modelcontextprotocol/sdk/shared/auth.js";
16
+ import { JsonFile } from "./store.js";
17
+ export declare const SCOPE_READ = "miadi:read";
18
+ export declare const SCOPE_CONTRIBUTE = "miadi:contribute";
19
+ export declare const SCOPES: string[];
20
+ /** Who a Miadi person token belongs to, as Miadi itself answers. */
21
+ export interface Person {
22
+ id: string;
23
+ name: string;
24
+ /** The Miadi person token, so calls to Miadi are made as this person. */
25
+ token: string;
26
+ }
27
+ export type Identify = (token: string) => Promise<Person | null>;
28
+ interface Pending {
29
+ client_id: string;
30
+ params: {
31
+ state?: string;
32
+ scopes?: string[];
33
+ codeChallenge: string;
34
+ redirectUri: string;
35
+ resource?: string;
36
+ };
37
+ created_at: number;
38
+ }
39
+ interface Grant {
40
+ client_id: string;
41
+ person: Person;
42
+ scopes: string[];
43
+ expires_at: number;
44
+ resource?: string;
45
+ }
46
+ interface CodeGrant extends Grant {
47
+ codeChallenge: string;
48
+ redirectUri: string;
49
+ }
50
+ interface TokenGrant extends Grant {
51
+ kind: "access" | "refresh";
52
+ }
53
+ export interface OAuthState {
54
+ clients: Record<string, OAuthClientInformationFull>;
55
+ pending: Record<string, Pending>;
56
+ codes: Record<string, CodeGrant>;
57
+ tokens: Record<string, TokenGrant>;
58
+ }
59
+ export declare const emptyOAuthState: () => OAuthState;
60
+ export declare class MiadiOAuthProvider implements OAuthServerProvider {
61
+ private readonly state;
62
+ private readonly identify;
63
+ /**
64
+ * The issuer as the metadata states it. It goes back on every authorization
65
+ * response as `iss` (RFC 9207), which ChatGPT requires to use its stable
66
+ * callback URL.
67
+ */
68
+ private readonly issuer?;
69
+ private readonly confirmPath;
70
+ private readonly direct;
71
+ constructor(state: JsonFile<OAuthState>, identify: Identify,
72
+ /**
73
+ * The issuer as the metadata states it. It goes back on every authorization
74
+ * response as `iss` (RFC 9207), which ChatGPT requires to use its stable
75
+ * callback URL.
76
+ */
77
+ issuer?: string | undefined, confirmPath?: string);
78
+ get clientsStore(): OAuthRegisteredClientsStore;
79
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
80
+ /** POST from the sign-in page: the person's token, or a refusal. */
81
+ confirm(req: Request, res: Response): Promise<void>;
82
+ challengeForAuthorizationCode(_client: OAuthClientInformationFull, code: string): Promise<string>;
83
+ exchangeAuthorizationCode(client: OAuthClientInformationFull, code: string): Promise<OAuthTokens>;
84
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
85
+ verifyAccessToken(token: string): Promise<AuthInfo>;
86
+ revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
87
+ private issue;
88
+ }
89
+ export {};
package/dist/oauth.js ADDED
@@ -0,0 +1,258 @@
1
+ import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
2
+ import { escapeHtml } from "@miadi/node-service-kit/format";
3
+ import { digest, newSecret } from "./store.js";
4
+ export const SCOPE_READ = "miadi:read";
5
+ export const SCOPE_CONTRIBUTE = "miadi:contribute";
6
+ export const SCOPES = [SCOPE_READ, SCOPE_CONTRIBUTE];
7
+ export const emptyOAuthState = () => ({ clients: {}, pending: {}, codes: {}, tokens: {} });
8
+ const ACCESS_TTL = 8 * 3600;
9
+ const REFRESH_TTL = 90 * 24 * 3600;
10
+ const CODE_TTL = 600;
11
+ const PENDING_TTL = 1800;
12
+ const DIRECT_TTL = 300;
13
+ const now = () => Math.floor(Date.now() / 1000);
14
+ export class MiadiOAuthProvider {
15
+ state;
16
+ identify;
17
+ issuer;
18
+ confirmPath;
19
+ direct = new Map();
20
+ constructor(state, identify,
21
+ /**
22
+ * The issuer as the metadata states it. It goes back on every authorization
23
+ * response as `iss` (RFC 9207), which ChatGPT requires to use its stable
24
+ * callback URL.
25
+ */
26
+ issuer, confirmPath = "/authorize/confirm") {
27
+ this.state = state;
28
+ this.identify = identify;
29
+ this.issuer = issuer;
30
+ this.confirmPath = confirmPath;
31
+ }
32
+ get clientsStore() {
33
+ return {
34
+ getClient: (id) => this.state.read().clients[id],
35
+ registerClient: (client) => {
36
+ const full = {
37
+ ...client,
38
+ client_id: `miadi-${newSecret(12)}`,
39
+ client_id_issued_at: now(),
40
+ };
41
+ this.state.update((s) => {
42
+ s.clients[full.client_id] = full;
43
+ });
44
+ return full;
45
+ },
46
+ };
47
+ }
48
+ async authorize(client, params, res) {
49
+ const requestId = newSecret(18);
50
+ this.state.update((s) => {
51
+ prune(s);
52
+ s.pending[requestId] = {
53
+ client_id: client.client_id,
54
+ params: {
55
+ state: params.state,
56
+ scopes: params.scopes,
57
+ codeChallenge: params.codeChallenge,
58
+ redirectUri: params.redirectUri,
59
+ resource: params.resource?.href,
60
+ },
61
+ created_at: now(),
62
+ };
63
+ });
64
+ res.status(200).type("html").send(signInPage({ requestId, client, scopes: wanted(params.scopes), action: this.confirmPath }));
65
+ }
66
+ /** POST from the sign-in page: the person's token, or a refusal. */
67
+ async confirm(req, res) {
68
+ const body = (req.body ?? {});
69
+ const s = this.state.read();
70
+ const pending = s.pending[body.request_id ?? ""];
71
+ if (!pending || now() - pending.created_at > PENDING_TTL) {
72
+ res.status(400).type("html").send(page("Sign-in expired", "<p>This sign-in request has expired. Start again from your client.</p>"));
73
+ return;
74
+ }
75
+ const client = s.clients[pending.client_id];
76
+ const redirect = new URL(pending.params.redirectUri);
77
+ if (pending.params.state)
78
+ redirect.searchParams.set("state", pending.params.state);
79
+ if (this.issuer)
80
+ redirect.searchParams.set("iss", this.issuer);
81
+ if (body.decision === "deny") {
82
+ this.state.update((st) => delete st.pending[body.request_id]);
83
+ redirect.searchParams.set("error", "access_denied");
84
+ res.redirect(302, redirect.href);
85
+ return;
86
+ }
87
+ const person = await this.identify(String(body.person_token ?? "").trim());
88
+ if (!person) {
89
+ res
90
+ .status(401)
91
+ .type("html")
92
+ .send(signInPage({
93
+ requestId: body.request_id,
94
+ client,
95
+ scopes: wanted(pending.params.scopes),
96
+ action: this.confirmPath,
97
+ error: "Miadi did not recognise that person token.",
98
+ }));
99
+ return;
100
+ }
101
+ let scopes = wanted(pending.params.scopes);
102
+ if (body.contribute !== "on")
103
+ scopes = scopes.filter((x) => x !== SCOPE_CONTRIBUTE);
104
+ const code = newSecret(24);
105
+ this.state.update((st) => {
106
+ delete st.pending[body.request_id];
107
+ st.codes[digest(code)] = {
108
+ client_id: pending.client_id,
109
+ person,
110
+ scopes,
111
+ expires_at: now() + CODE_TTL,
112
+ resource: pending.params.resource,
113
+ codeChallenge: pending.params.codeChallenge,
114
+ redirectUri: pending.params.redirectUri,
115
+ };
116
+ });
117
+ redirect.searchParams.set("code", code);
118
+ res.redirect(302, redirect.href);
119
+ }
120
+ async challengeForAuthorizationCode(_client, code) {
121
+ const grant = this.state.read().codes[digest(code)];
122
+ if (!grant)
123
+ throw new InvalidGrantError("Unknown authorization code");
124
+ return grant.codeChallenge;
125
+ }
126
+ async exchangeAuthorizationCode(client, code) {
127
+ let grant;
128
+ this.state.update((s) => {
129
+ grant = s.codes[digest(code)];
130
+ delete s.codes[digest(code)];
131
+ });
132
+ if (!grant || grant.client_id !== client.client_id || grant.expires_at < now()) {
133
+ throw new InvalidGrantError("Authorization code is invalid or expired");
134
+ }
135
+ return this.issue(grant);
136
+ }
137
+ async exchangeRefreshToken(client, refreshToken, scopes) {
138
+ let grant;
139
+ this.state.update((s) => {
140
+ const key = digest(refreshToken);
141
+ grant = s.tokens[key];
142
+ if (grant?.kind === "refresh")
143
+ delete s.tokens[key];
144
+ });
145
+ if (!grant || grant.kind !== "refresh" || grant.client_id !== client.client_id || grant.expires_at < now()) {
146
+ throw new InvalidGrantError("Refresh token is invalid or expired");
147
+ }
148
+ const narrowed = scopes?.length ? grant.scopes.filter((x) => scopes.includes(x)) : grant.scopes;
149
+ return this.issue({ ...grant, scopes: narrowed });
150
+ }
151
+ async verifyAccessToken(token) {
152
+ const grant = this.state.read().tokens[digest(token)];
153
+ if (grant && grant.kind === "access") {
154
+ if (grant.expires_at < now())
155
+ throw new InvalidTokenError("Access token expired");
156
+ return info(token, grant.client_id, grant.scopes, grant.expires_at, grant.person, grant.resource);
157
+ }
158
+ // A Miadi person token used directly (terminal clients).
159
+ const cached = this.direct.get(digest(token));
160
+ if (cached && cached.until > now()) {
161
+ return info(token, "direct", SCOPES, cached.until, cached.person);
162
+ }
163
+ const person = await this.identify(token);
164
+ if (!person)
165
+ throw new InvalidTokenError("Unknown token");
166
+ const until = now() + DIRECT_TTL;
167
+ this.direct.set(digest(token), { person, until });
168
+ return info(token, "direct", SCOPES, until, person);
169
+ }
170
+ async revokeToken(_client, request) {
171
+ this.state.update((s) => {
172
+ delete s.tokens[digest(request.token)];
173
+ });
174
+ }
175
+ issue(grant) {
176
+ const access = newSecret();
177
+ const refresh = newSecret();
178
+ this.state.update((s) => {
179
+ prune(s);
180
+ const base = { client_id: grant.client_id, person: grant.person, scopes: grant.scopes, resource: grant.resource };
181
+ s.tokens[digest(access)] = { ...base, kind: "access", expires_at: now() + ACCESS_TTL };
182
+ s.tokens[digest(refresh)] = { ...base, kind: "refresh", expires_at: now() + REFRESH_TTL };
183
+ });
184
+ return {
185
+ access_token: access,
186
+ token_type: "bearer",
187
+ expires_in: ACCESS_TTL,
188
+ refresh_token: refresh,
189
+ scope: grant.scopes.join(" "),
190
+ };
191
+ }
192
+ }
193
+ function info(token, clientId, scopes, expiresAt, person, resource) {
194
+ return {
195
+ token,
196
+ clientId,
197
+ scopes,
198
+ expiresAt,
199
+ resource: resource ? new URL(resource) : undefined,
200
+ extra: { person },
201
+ };
202
+ }
203
+ function wanted(scopes) {
204
+ const asked = (scopes ?? []).filter((x) => SCOPES.includes(x));
205
+ return asked.length ? asked : [...SCOPES];
206
+ }
207
+ function prune(s) {
208
+ const t = now();
209
+ for (const [k, v] of Object.entries(s.pending))
210
+ if (t - v.created_at > PENDING_TTL)
211
+ delete s.pending[k];
212
+ for (const [k, v] of Object.entries(s.codes))
213
+ if (v.expires_at < t)
214
+ delete s.codes[k];
215
+ for (const [k, v] of Object.entries(s.tokens))
216
+ if (v.expires_at < t)
217
+ delete s.tokens[k];
218
+ }
219
+ function page(title, body) {
220
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
221
+ <title>${escapeHtml(title)}</title>
222
+ <style>
223
+ :root{color-scheme:light dark;--bg:#fbfaf7;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00}
224
+ @media (prefers-color-scheme:dark){:root{--bg:#141311;--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700}}
225
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
226
+ main{max-width:34rem;margin:0 auto;padding:2rem 1rem}
227
+ h1{font-size:1.4rem;margin:0 0 .5rem}
228
+ p,li{color:var(--muted)}
229
+ label{display:block;margin:1rem 0 .3rem;font-weight:600}
230
+ input[type=password]{width:100%;box-sizing:border-box;padding:.6rem;border:1px solid var(--line);border-radius:.4rem;background:transparent;color:var(--fg);font:inherit}
231
+ .row{display:flex;gap:.6rem;margin-top:1.2rem;flex-wrap:wrap}
232
+ button{padding:.6rem 1.1rem;border-radius:.4rem;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer}
233
+ button.primary{border-color:var(--accent);outline:1px solid var(--accent)}
234
+ .err{color:#b00020}
235
+ @media (prefers-color-scheme:dark){.err{color:#ff8a80}}
236
+ </style></head><body><main>${body}</main></body></html>`;
237
+ }
238
+ function signInPage(o) {
239
+ const name = o.client?.client_name || o.client?.client_id || "An MCP client";
240
+ const contribute = o.scopes.includes(SCOPE_CONTRIBUTE);
241
+ return page("Sign in to Miadi", `<h1>Sign in to Miadi</h1>
242
+ <p><strong>${escapeHtml(name)}</strong> asks to use Miadi as you.</p>
243
+ <ul>
244
+ <li>Read sessions, reviews, episodes, circles and the records people contributed.</li>
245
+ ${contribute ? "<li>Contribute in your name: observations, inquiries, links, handoffs and proposals. Nothing it proposes runs, publishes or closes without a person's approval.</li>" : ""}
246
+ </ul>
247
+ ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
248
+ <form method="post" action="${escapeHtml(o.action)}">
249
+ <input type="hidden" name="request_id" value="${escapeHtml(o.requestId)}">
250
+ <label for="person_token">Your Miadi person token</label>
251
+ <input id="person_token" name="person_token" type="password" autocomplete="off" required>
252
+ ${contribute ? '<label><input type="checkbox" name="contribute" checked> Allow contributions in my name</label>' : ""}
253
+ <div class="row">
254
+ <button class="primary" type="submit" name="decision" value="allow">Sign in</button>
255
+ <button type="submit" name="decision" value="deny" formnovalidate>Refuse</button>
256
+ </div>
257
+ </form>`);
258
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Who a Miadi person token belongs to, answered by Miadi itself:
3
+ * GET /api/identity/me with the token as Bearer. The answer carries the
4
+ * person, their role, their API tier (`write`, `read` or none, lib/api-access.ts
5
+ * in Miadi) and their permissions. The connector applies that same tier to
6
+ * every tool, so a person reaches through it exactly what they reach in Miadi.
7
+ */
8
+ import type { Person } from "./oauth.js";
9
+ export type ApiTier = "write" | "read" | null;
10
+ export interface MiadiPerson extends Person {
11
+ role?: string;
12
+ api_tier: ApiTier;
13
+ permissions: string[];
14
+ }
15
+ export declare function identifyThroughMiadi(miadiUrl: string, fetchImpl?: typeof fetch): (token: string) => Promise<MiadiPerson | null>;
package/dist/people.js ADDED
@@ -0,0 +1,28 @@
1
+ export function identifyThroughMiadi(miadiUrl, fetchImpl = fetch) {
2
+ return async (token) => {
3
+ if (!token || token.length < 16)
4
+ return null;
5
+ let res;
6
+ try {
7
+ res = await fetchImpl(`${miadiUrl}/api/identity/me`, {
8
+ headers: { accept: "application/json", authorization: `Bearer ${token}` },
9
+ });
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ if (!res.ok)
15
+ return null;
16
+ const me = await res.json().catch(() => null);
17
+ if (!me?.person?.id)
18
+ return null;
19
+ return {
20
+ id: String(me.person.id),
21
+ name: String(me.person.name ?? me.person.id),
22
+ token,
23
+ role: me.person.role,
24
+ api_tier: me.api_tier === "write" || me.api_tier === "read" ? me.api_tier : null,
25
+ permissions: Array.isArray(me.permissions) ? me.permissions.map(String) : [],
26
+ };
27
+ };
28
+ }
@@ -0,0 +1,86 @@
1
+ export declare const CIRCLES: readonly [{
2
+ readonly id: "story";
3
+ readonly glyph: "📖";
4
+ readonly name: "Story";
5
+ readonly accountable_for: "An author can build chapters, music, prompts and narrative beats from lived observations and imagined futures, retaining authorship.";
6
+ readonly returns: "A human-selected artistic direction grounded in identifiable source material.";
7
+ }, {
8
+ readonly id: "stewardship";
9
+ readonly glyph: "ðŸŒŋ";
10
+ readonly name: "Stewardship";
11
+ readonly accountable_for: "Participants have clear consent, cultural boundaries, relationships and genuine agency.";
12
+ readonly returns: "A stewardship record identifying applicable responsibilities, relationships, and human-held decisions.";
13
+ }, {
14
+ readonly id: "engineering";
15
+ readonly glyph: "🧠";
16
+ readonly name: "Engineering";
17
+ readonly accountable_for: "Useful capabilities work reliably; agents return deliverables that support creation.";
18
+ readonly returns: "Demonstrable capabilities, tested implementations, and inspectable engineering contributions.";
19
+ }, {
20
+ readonly id: "evaluation";
21
+ readonly glyph: "🔎";
22
+ readonly name: "Evaluation";
23
+ readonly accountable_for: "Operational evidence and creator experience are visible as distinct assessments; genuine provider traces accompany measured claims.";
24
+ readonly returns: "A reproducible evaluation report distinguishing source evidence, model interpretation, technical reliability, and artistic usefulness.";
25
+ }, {
26
+ readonly id: "continuity";
27
+ readonly glyph: "ðŸŠķ";
28
+ readonly name: "Continuity";
29
+ readonly accountable_for: "The author or a new agent can find what matters, how it was learned, and how to continue without reconstructing the session history.";
30
+ readonly returns: "A navigable creative history that human participants and computational agents can continue developing.";
31
+ }];
32
+ export type CircleId = (typeof CIRCLES)[number]["id"];
33
+ export declare const CIRCLE_IDS: [CircleId, ...CircleId[]];
34
+ export declare const DOMAINS: readonly ["creative", "ceremonial", "engineering"];
35
+ /** #815 "claim category": what kind of statement a record makes. */
36
+ export declare const CLAIM_CATEGORIES: readonly ["observed", "hypothesis", "desired_future", "fiction", "verified_implementation"];
37
+ export type ClaimCategory = (typeof CLAIM_CATEGORIES)[number];
38
+ export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal"];
39
+ export type RecordKind = (typeof RECORD_KINDS)[number];
40
+ /** #816 handoff states. */
41
+ export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
42
+ export type HandoffState = (typeof HANDOFF_STATES)[number];
43
+ /** Approval of a request that a person must grant before anything runs. */
44
+ export declare const APPROVAL_STATES: readonly ["awaiting_approval", "approved", "declined", "not_required"];
45
+ export type ApprovalState = (typeof APPROVAL_STATES)[number];
46
+ export interface Actor {
47
+ person_id: string;
48
+ name: string;
49
+ /** The OAuth client the person used, e.g. ChatGPT's registration id, or "direct". */
50
+ client_id: string;
51
+ client_name?: string;
52
+ }
53
+ export interface ConnectorRecord {
54
+ /** Typed stable id, `<kind>:<uuid>`. */
55
+ id: string;
56
+ kind: RecordKind;
57
+ created_at: string;
58
+ actor: Actor;
59
+ /** The role the person said they act in (free text: author, steward, agent, reviewerâ€Ķ). */
60
+ role?: string;
61
+ intention?: string;
62
+ claim_category?: ClaimCategory;
63
+ circle?: CircleId;
64
+ from_circle?: CircleId;
65
+ to_circle?: CircleId;
66
+ domain?: (typeof DOMAINS)[number];
67
+ consent_boundary?: string;
68
+ /** References this record is about: miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n, session:<uuid>, https URLs. */
69
+ refs: string[];
70
+ evidence_refs: string[];
71
+ /** The record's content, by kind. */
72
+ body: Record<string, unknown>;
73
+ state?: HandoffState;
74
+ approval_state?: ApprovalState;
75
+ next_action?: string;
76
+ /** A later record that changes this one points back with `supersedes`. */
77
+ supersedes?: string;
78
+ idempotency_key?: string;
79
+ completion_receipt?: Record<string, unknown>;
80
+ }
81
+ export declare function newRecordId(kind: RecordKind): string;
82
+ /**
83
+ * Fold the ledger: a record superseded by a later one shows the later one's
84
+ * state, approval and receipt, keeping its own id and history.
85
+ */
86
+ export declare function current(records: ConnectorRecord[]): ConnectorRecord[];
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The shared record and the five circles, as jgwill/Miadi#815 and #816 state
3
+ * them. Every contribution made through the connector is one ConnectorRecord
4
+ * appended to the ledger, with the person who made it.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ export const CIRCLES = [
8
+ {
9
+ id: "story",
10
+ glyph: "📖",
11
+ name: "Story",
12
+ accountable_for: "An author can build chapters, music, prompts and narrative beats from lived observations and imagined futures, retaining authorship.",
13
+ returns: "A human-selected artistic direction grounded in identifiable source material.",
14
+ },
15
+ {
16
+ id: "stewardship",
17
+ glyph: "ðŸŒŋ",
18
+ name: "Stewardship",
19
+ accountable_for: "Participants have clear consent, cultural boundaries, relationships and genuine agency.",
20
+ returns: "A stewardship record identifying applicable responsibilities, relationships, and human-held decisions.",
21
+ },
22
+ {
23
+ id: "engineering",
24
+ glyph: "🧠",
25
+ name: "Engineering",
26
+ accountable_for: "Useful capabilities work reliably; agents return deliverables that support creation.",
27
+ returns: "Demonstrable capabilities, tested implementations, and inspectable engineering contributions.",
28
+ },
29
+ {
30
+ id: "evaluation",
31
+ glyph: "🔎",
32
+ name: "Evaluation",
33
+ accountable_for: "Operational evidence and creator experience are visible as distinct assessments; genuine provider traces accompany measured claims.",
34
+ returns: "A reproducible evaluation report distinguishing source evidence, model interpretation, technical reliability, and artistic usefulness.",
35
+ },
36
+ {
37
+ id: "continuity",
38
+ glyph: "ðŸŠķ",
39
+ name: "Continuity",
40
+ accountable_for: "The author or a new agent can find what matters, how it was learned, and how to continue without reconstructing the session history.",
41
+ returns: "A navigable creative history that human participants and computational agents can continue developing.",
42
+ },
43
+ ];
44
+ export const CIRCLE_IDS = CIRCLES.map((c) => c.id);
45
+ export const DOMAINS = ["creative", "ceremonial", "engineering"];
46
+ /** #815 "claim category": what kind of statement a record makes. */
47
+ export const CLAIM_CATEGORIES = ["observed", "hypothesis", "desired_future", "fiction", "verified_implementation"];
48
+ export const RECORD_KINDS = [
49
+ "observation",
50
+ "inquiry",
51
+ "link",
52
+ "work_proposal",
53
+ "run_request",
54
+ "handoff",
55
+ "source_intent",
56
+ "review_operation_request",
57
+ "deep_diver_request",
58
+ "review_episode_link_request",
59
+ "calendar_event_draft",
60
+ "session_completion_proposal",
61
+ ];
62
+ /** #816 handoff states. */
63
+ export const HANDOFF_STATES = ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
64
+ /** Approval of a request that a person must grant before anything runs. */
65
+ export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "not_required"];
66
+ export function newRecordId(kind) {
67
+ return `${kind}:${randomUUID()}`;
68
+ }
69
+ /**
70
+ * Fold the ledger: a record superseded by a later one shows the later one's
71
+ * state, approval and receipt, keeping its own id and history.
72
+ */
73
+ export function current(records) {
74
+ const byId = new Map();
75
+ for (const r of records) {
76
+ if (r.supersedes && byId.has(r.supersedes)) {
77
+ const base = byId.get(r.supersedes);
78
+ byId.set(r.supersedes, {
79
+ ...base,
80
+ state: r.state ?? base.state,
81
+ approval_state: r.approval_state ?? base.approval_state,
82
+ next_action: r.next_action ?? base.next_action,
83
+ completion_receipt: r.completion_receipt ?? base.completion_receipt,
84
+ history: [...(base.history ?? []), r.id],
85
+ });
86
+ continue;
87
+ }
88
+ byId.set(r.id, { ...r });
89
+ }
90
+ return [...byId.values()];
91
+ }
@@ -0,0 +1,26 @@
1
+ export declare function reviewToken(env?: NodeJS.ProcessEnv): string | undefined;
2
+ export declare function videoIdOf(input: string): string | null;
3
+ export declare function reviewIdOf(input: string): string | null;
4
+ export interface ReviewSummary {
5
+ review_id: string;
6
+ ref: string;
7
+ url: string;
8
+ title: string | null;
9
+ video_id: string | null;
10
+ video_url: string | null;
11
+ latest_version: number | null;
12
+ diver_ready: unknown;
13
+ created_at?: string;
14
+ updated_at?: string;
15
+ }
16
+ export declare class ReviewClient {
17
+ readonly baseUrl: string;
18
+ private readonly token;
19
+ private readonly fetchImpl;
20
+ constructor(baseUrl: string, token: string | undefined, fetchImpl?: typeof fetch);
21
+ get canSearch(): boolean;
22
+ /** `/review/<id>/json`, optionally pinned to a version. Null on 404. */
23
+ get(reviewId: string, version?: number): Promise<any | null>;
24
+ search(q: string, limit?: number): Promise<any[]>;
25
+ summary(r: any): ReviewSummary;
26
+ }