@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.
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/dist/approve.d.ts +47 -0
- package/dist/approve.js +316 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +14 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/miadi.d.ts +19 -0
- package/dist/miadi.js +58 -0
- package/dist/oauth.d.ts +89 -0
- package/dist/oauth.js +258 -0
- package/dist/people.d.ts +15 -0
- package/dist/people.js +28 -0
- package/dist/records.d.ts +86 -0
- package/dist/records.js +91 -0
- package/dist/reviews.d.ts +26 -0
- package/dist/reviews.js +97 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +121 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +89 -0
- package/dist/tools.d.ts +19 -0
- package/dist/tools.js +954 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +58 -3
package/dist/tools.js
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connector's tools: the verbs of jgwill/Miadi#815 ("Connector v2") and
|
|
3
|
+
* #816 (Review Bridge), each a thin adapter over a Miadi route, the review
|
|
4
|
+
* service, or the connector's own ledger.
|
|
5
|
+
*
|
|
6
|
+
* Three kinds:
|
|
7
|
+
* - reads (readOnlyHint), for any signed-in person, within their Miadi tier;
|
|
8
|
+
* - contributions, which append a record in the person's name (scope
|
|
9
|
+
* miadi:contribute) and change nothing else;
|
|
10
|
+
* - requests, which append a record awaiting approval and return the page
|
|
11
|
+
* where a person with Miadi's write tier approves it. Only that page runs it.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { MiadiError } from "./miadi.js";
|
|
15
|
+
import { reviewIdOf, videoIdOf } from "./reviews.js";
|
|
16
|
+
import { SCOPE_CONTRIBUTE } from "./oauth.js";
|
|
17
|
+
import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current, newRecordId, } from "./records.js";
|
|
18
|
+
import { VERSION } from "./version.js";
|
|
19
|
+
import { readQueueState } from "./approve.js";
|
|
20
|
+
class ToolRefusal extends Error {
|
|
21
|
+
code;
|
|
22
|
+
detail;
|
|
23
|
+
constructor(code, message, detail) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.detail = detail;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function personOf(extra) {
|
|
30
|
+
const auth = extra.authInfo;
|
|
31
|
+
const person = auth?.extra?.person;
|
|
32
|
+
if (!auth || !person)
|
|
33
|
+
throw new ToolRefusal("unauthenticated", "No signed-in person on this request.");
|
|
34
|
+
return { person, auth };
|
|
35
|
+
}
|
|
36
|
+
function requireContribute(auth) {
|
|
37
|
+
if (!auth.scopes.includes(SCOPE_CONTRIBUTE)) {
|
|
38
|
+
throw new ToolRefusal("insufficient_scope", "This sign-in did not allow contributions. Sign in again and allow contributions in your name.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function requireTier(person, tier) {
|
|
42
|
+
const ok = person.api_tier === "write" || (tier === "read" && person.api_tier === "read");
|
|
43
|
+
if (!ok) {
|
|
44
|
+
throw new ToolRefusal("forbidden", `Miadi gives the ${person.role ?? "current"} role ${person.api_tier ? `the ${person.api_tier} tier` : "no API tier"}; this tool needs the ${tier} tier.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const ok = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
48
|
+
const fail = (value) => ({ isError: true, content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
49
|
+
function wrap(fn) {
|
|
50
|
+
return async (args, extra) => {
|
|
51
|
+
try {
|
|
52
|
+
return ok(await fn(args, extra));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof ToolRefusal)
|
|
56
|
+
return fail({ error: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) });
|
|
57
|
+
if (error instanceof MiadiError) {
|
|
58
|
+
const unavailable = error.status === 0;
|
|
59
|
+
return fail({
|
|
60
|
+
error: unavailable ? "unavailable" : error.status === 404 ? "not_found" : "miadi_error",
|
|
61
|
+
status: error.status,
|
|
62
|
+
path: error.path,
|
|
63
|
+
body: error.body,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const READ = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
71
|
+
const WRITE = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
72
|
+
const refs = z.array(z.string()).max(50).default([]).describe("References: miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n, session:<uuid>, a record id, or a URL.");
|
|
73
|
+
const circle = z.enum(CIRCLE_IDS);
|
|
74
|
+
const claim = z.enum(CLAIM_CATEGORIES).describe("observed, hypothesis, desired_future, fiction or verified_implementation.");
|
|
75
|
+
function episodeRef(input) {
|
|
76
|
+
// miadi-chronicle://316, miadi-chronicle:316, chronicle:<folder>, a folder, or a number
|
|
77
|
+
return encodeURIComponent(input.replace(/^miadi-chronicle:(\/\/)?/, "").replace(/^chronicle:/, "").trim());
|
|
78
|
+
}
|
|
79
|
+
export function registerTools(server, ctx) {
|
|
80
|
+
const { config, miadi, reviews, ledger } = ctx;
|
|
81
|
+
const actorOf = (person, auth) => {
|
|
82
|
+
const client = ctx.oauthState.read().clients[auth.clientId];
|
|
83
|
+
return { person_id: person.id, name: person.name, client_id: auth.clientId, ...(client?.client_name ? { client_name: client.client_name } : {}) };
|
|
84
|
+
};
|
|
85
|
+
const approvalUrl = (id) => `${config.publicUrl}/approve/${encodeURIComponent(id)}`;
|
|
86
|
+
const append = (kind, person, auth, fields) => {
|
|
87
|
+
if (fields.idempotency_key) {
|
|
88
|
+
const prior = ledger.all().find((r) => r.kind === kind && r.idempotency_key === fields.idempotency_key && r.actor.person_id === person.id);
|
|
89
|
+
if (prior)
|
|
90
|
+
return prior;
|
|
91
|
+
}
|
|
92
|
+
return ledger.append({
|
|
93
|
+
id: newRecordId(kind),
|
|
94
|
+
kind,
|
|
95
|
+
created_at: new Date().toISOString(),
|
|
96
|
+
actor: actorOf(person, auth),
|
|
97
|
+
refs: [],
|
|
98
|
+
evidence_refs: [],
|
|
99
|
+
...fields,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const records = () => current(ledger.all());
|
|
103
|
+
const touching = (ref) => records().filter((r) => r.refs.includes(ref) || r.evidence_refs.includes(ref) || r.id === ref);
|
|
104
|
+
// ── reads ──────────────────────────────────────────────────────────────
|
|
105
|
+
server.registerTool("get_capabilities", {
|
|
106
|
+
title: "What this connector can do for you",
|
|
107
|
+
description: "The capability handshake: who you are signed in as, your Miadi tier and scopes, every tool with its status (live, needs_approval, write_tier), the services reached and the five circles. Call this first.",
|
|
108
|
+
inputSchema: {},
|
|
109
|
+
annotations: READ,
|
|
110
|
+
}, wrap(async (_args, extra) => {
|
|
111
|
+
const { person, auth } = personOf(extra);
|
|
112
|
+
const miadiUp = await miadi.get(person.token, "/api/identity/me").then(() => "reachable", (e) => (e instanceof MiadiError && e.status === 0 ? "unavailable" : "reachable"));
|
|
113
|
+
return {
|
|
114
|
+
service: "@miadi/connector",
|
|
115
|
+
version: VERSION,
|
|
116
|
+
contract: "jgwill/Miadi#815 Connector v2, jgwill/Miadi#816 Review Bridge",
|
|
117
|
+
signed_in_as: { person_id: person.id, name: person.name, role: person.role ?? null, miadi_api_tier: person.api_tier, via: auth.clientId },
|
|
118
|
+
scopes: auth.scopes,
|
|
119
|
+
can_contribute: auth.scopes.includes(SCOPE_CONTRIBUTE),
|
|
120
|
+
services: {
|
|
121
|
+
miadi: { url: config.miadiPublicUrl, state: miadiUp },
|
|
122
|
+
review_service: { url: config.reviewUrl, search: reviews.canSearch ? "enabled" : "disabled: no token on the connector host" },
|
|
123
|
+
ledger: "connector ledger (append-only, /srv/miadi/connector)",
|
|
124
|
+
},
|
|
125
|
+
tools: TOOL_STATUS,
|
|
126
|
+
domains: DOMAINS,
|
|
127
|
+
circles: CIRCLES.map((c) => ({ id: c.id, glyph: c.glyph, name: c.name })),
|
|
128
|
+
not_built: [
|
|
129
|
+
"Calendar events are drafts with an .ics text; nothing is put on a calendar.",
|
|
130
|
+
"A decided session ending is recorded, never executed: no pane is closed through the connector.",
|
|
131
|
+
"Narrative clustering (#314) has no code; beats come from NCP projects and candidate beats recorded here.",
|
|
132
|
+
"Langfuse scores are not read; evaluations are the tracing records of an episode and the assessments recorded here.",
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}));
|
|
136
|
+
server.registerTool("get_context", {
|
|
137
|
+
title: "Resolve a reference",
|
|
138
|
+
description: "Resolve one reference to what it points at: miadi-review:<uuid> (with the episodes that hold it), miadi-chronicle://<n> or chronicle:<folder>, miadi-ceremony:, miadi-circle:, miadi-foundation:, miadi-tracing:, session:<uuid>, owner/repo#n (GitHub issue), a YouTube URL or video id, or a connector record id. Also lists connector records that cite it.",
|
|
139
|
+
inputSchema: { ref: z.string().min(1) },
|
|
140
|
+
annotations: READ,
|
|
141
|
+
}, wrap(async ({ ref }, extra) => {
|
|
142
|
+
const { person } = personOf(extra);
|
|
143
|
+
const r = ref.trim();
|
|
144
|
+
const cited = touching(r);
|
|
145
|
+
if (/^session:/.test(r)) {
|
|
146
|
+
requireTier(person, "write");
|
|
147
|
+
const id = r.replace(/^session:/, "");
|
|
148
|
+
return { ref: r, kind: "session", session: await miadi.get(person.token, `/api/session/${encodeURIComponent(id)}`), cited_by: cited };
|
|
149
|
+
}
|
|
150
|
+
if (/^[a-z_]+:[0-9a-f-]{36}$/.test(r) && r.split(":")[0] !== "miadi-review" && r.split(":")[0] !== "review" && r.split(":")[0] !== "session") {
|
|
151
|
+
const rec = records().find((x) => x.id === r);
|
|
152
|
+
if (rec)
|
|
153
|
+
return { ref: r, kind: "connector_record", record: rec, cited_by: cited.filter((x) => x.id !== r) };
|
|
154
|
+
}
|
|
155
|
+
const issue = r.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/);
|
|
156
|
+
if (issue) {
|
|
157
|
+
const res = await fetch(`https://api.github.com/repos/${issue[1]}/${issue[2]}/issues/${issue[3]}`, {
|
|
158
|
+
headers: { accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) },
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
return { ref: r, kind: "github_issue", state: res.status === 404 ? "not_found_or_private" : `github_${res.status}`, cited_by: cited };
|
|
162
|
+
const i = await res.json();
|
|
163
|
+
return {
|
|
164
|
+
ref: r,
|
|
165
|
+
kind: i.pull_request ? "github_pull_request" : "github_issue",
|
|
166
|
+
title: i.title,
|
|
167
|
+
state: i.state,
|
|
168
|
+
url: i.html_url,
|
|
169
|
+
labels: (i.labels ?? []).map((l) => l.name),
|
|
170
|
+
updated_at: i.updated_at,
|
|
171
|
+
body: String(i.body ?? "").slice(0, 6000),
|
|
172
|
+
cited_by: cited,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const video = videoIdOf(r);
|
|
176
|
+
if (video && !reviewIdOf(r)) {
|
|
177
|
+
return { ref: r, kind: "video", video_id: video, reviews: await lookupByVideo(video), cited_by: cited };
|
|
178
|
+
}
|
|
179
|
+
const reviewId = reviewIdOf(r);
|
|
180
|
+
const uri = reviewId ? `miadi-review:${reviewId}` : r.replace(/^chronicle:/, "miadi-chronicle:");
|
|
181
|
+
const resolved = await miadi.get(person.token, `/api/chronicle/resolve?uri=${encodeURIComponent(uri)}`);
|
|
182
|
+
return { ref: r, kind: "miadi_reference", resolved, cited_by: cited };
|
|
183
|
+
}));
|
|
184
|
+
async function lookupByVideo(videoId) {
|
|
185
|
+
if (!reviews.canSearch)
|
|
186
|
+
return { state: "search_unavailable", message: "The connector host has no review service token; give the review id instead." };
|
|
187
|
+
const found = (await reviews.search(videoId, 10)).filter((x) => x.video_id === videoId);
|
|
188
|
+
return found.map((x) => reviews.summary(x));
|
|
189
|
+
}
|
|
190
|
+
server.registerTool("list_circles", {
|
|
191
|
+
title: "The five circles, and the talking circles you belong to",
|
|
192
|
+
description: "The five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) with what each is accountable for and how many handoffs each holds open, and the Miadi talking circles your person token reaches.",
|
|
193
|
+
inputSchema: {},
|
|
194
|
+
annotations: READ,
|
|
195
|
+
}, wrap(async (_args, extra) => {
|
|
196
|
+
const { person } = personOf(extra);
|
|
197
|
+
const open = records().filter((r) => r.kind === "handoff" && r.state !== "completed" && r.state !== "withdrawn");
|
|
198
|
+
const talking = await miadi.get(person.token, "/api/circles").then((b) => (b.circles ?? []).map((c) => ({ id: c.id, name: c.name, intention: c.intention, member: c.member ?? undefined })), (e) => ({ state: e instanceof MiadiError ? `miadi_${e.status}` : "unavailable" }));
|
|
199
|
+
return {
|
|
200
|
+
accountability_circles: CIRCLES.map((c) => ({ ...c, open_handoffs: open.filter((r) => r.to_circle === c.id || r.circle === c.id).length })),
|
|
201
|
+
note: "The legacy teams T1–T7 (miadi-orchestration-kit teams/teams.json) are migration evidence; no team is mapped to a circle until that mapping is decided.",
|
|
202
|
+
talking_circles: talking,
|
|
203
|
+
};
|
|
204
|
+
}));
|
|
205
|
+
server.registerTool("get_circle", {
|
|
206
|
+
title: "One circle",
|
|
207
|
+
description: "One of the five circles by id (story, stewardship, engineering, evaluation, continuity) with its open handoffs, proposals and requests, or a Miadi talking circle by its id with its members and ceremonies.",
|
|
208
|
+
inputSchema: { circle_id: z.string().min(1) },
|
|
209
|
+
annotations: READ,
|
|
210
|
+
}, wrap(async ({ circle_id }, extra) => {
|
|
211
|
+
const { person } = personOf(extra);
|
|
212
|
+
const five = CIRCLES.find((c) => c.id === circle_id.toLowerCase());
|
|
213
|
+
if (five) {
|
|
214
|
+
const mine = records().filter((r) => r.circle === five.id || r.to_circle === five.id || r.from_circle === five.id);
|
|
215
|
+
return {
|
|
216
|
+
circle: five,
|
|
217
|
+
handoffs: mine.filter((r) => r.kind === "handoff"),
|
|
218
|
+
proposals: mine.filter((r) => r.kind === "work_proposal" || r.kind === "run_request"),
|
|
219
|
+
observations: mine.filter((r) => r.kind === "observation" || r.kind === "inquiry").slice(-25),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { talking_circle: await miadi.get(person.token, `/api/circles/${encodeURIComponent(circle_id.replace(/^miadi-circle:/, ""))}`) };
|
|
223
|
+
}));
|
|
224
|
+
server.registerTool("list_sessions", {
|
|
225
|
+
title: "Agent sessions",
|
|
226
|
+
description: "Agent sessions as Miadi joins them (binding line, live registry, witness inventory): intention, team, accountable circle, episode, review refs, and work_status, runtime_status and closure_status kept apart. Newest first. Needs Miadi's write tier: session names and panes are operator data.",
|
|
227
|
+
inputSchema: {
|
|
228
|
+
episode: z.string().optional().describe("An episode folder, to list the sessions that worked in it."),
|
|
229
|
+
live_only: z.boolean().optional(),
|
|
230
|
+
team: z.string().optional().describe("Legacy team id, e.g. T3."),
|
|
231
|
+
status: z.string().optional().describe("A work_status (complete, in_progress, held…) or a runtime_status (running, ended, not_running)."),
|
|
232
|
+
q: z.string().optional().describe("Text in the name, intention, repo or review refs."),
|
|
233
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
234
|
+
},
|
|
235
|
+
annotations: READ,
|
|
236
|
+
}, wrap(async (args, extra) => {
|
|
237
|
+
const { person } = personOf(extra);
|
|
238
|
+
requireTier(person, "write");
|
|
239
|
+
const qs = new URLSearchParams();
|
|
240
|
+
if (args.episode)
|
|
241
|
+
qs.set("episode", args.episode);
|
|
242
|
+
if (args.live_only)
|
|
243
|
+
qs.set("live", "1");
|
|
244
|
+
if (args.team)
|
|
245
|
+
qs.set("team", args.team);
|
|
246
|
+
if (args.status)
|
|
247
|
+
qs.set("status", args.status);
|
|
248
|
+
if (args.q)
|
|
249
|
+
qs.set("q", args.q);
|
|
250
|
+
qs.set("limit", String(args.limit ?? 30));
|
|
251
|
+
return miadi.get(person.token, `/api/session?${qs}`);
|
|
252
|
+
}));
|
|
253
|
+
server.registerTool("get_session", {
|
|
254
|
+
title: "One agent session",
|
|
255
|
+
description: "One session by id: what it was asked to do, what its record says it completed, what it holds, its ending (proposed or decided), the live pane state, and the connector records that cite it (handoffs, completion proposals).",
|
|
256
|
+
inputSchema: { session_id: z.string().min(8) },
|
|
257
|
+
annotations: READ,
|
|
258
|
+
}, wrap(async ({ session_id }, extra) => {
|
|
259
|
+
const { person } = personOf(extra);
|
|
260
|
+
requireTier(person, "write");
|
|
261
|
+
const id = session_id.replace(/^session:/, "");
|
|
262
|
+
const session = await miadi.get(person.token, `/api/session/${encodeURIComponent(id)}`);
|
|
263
|
+
return { ...session, connector_records: touching(`session:${id}`) };
|
|
264
|
+
}));
|
|
265
|
+
server.registerTool("get_evidence", {
|
|
266
|
+
title: "What an episode holds as evidence",
|
|
267
|
+
description: "For one Chronicle episode: its files by name, its screenwalks, the reviews it holds with their versions, and the connector records that cite it. Captures other than screenwalks are not listed by Miadi yet.",
|
|
268
|
+
inputSchema: { episode: z.string().min(1).describe("miadi-chronicle://<n>, an episode number or its folder.") },
|
|
269
|
+
annotations: READ,
|
|
270
|
+
}, wrap(async ({ episode }, extra) => {
|
|
271
|
+
const { person } = personOf(extra);
|
|
272
|
+
const ref = episodeRef(episode);
|
|
273
|
+
const [files, screenwalks, epReviews] = await Promise.all([
|
|
274
|
+
miadi.get(person.token, `/api/chronicle/episodes/${ref}/files`).catch(asState),
|
|
275
|
+
miadi.get(person.token, `/api/chronicle/episodes/${ref}/screenwalks`).catch(asState),
|
|
276
|
+
miadi.get(person.token, `/api/chronicle/episodes/${ref}/reviews`).catch(asState),
|
|
277
|
+
]);
|
|
278
|
+
return {
|
|
279
|
+
episode: decodeURIComponent(ref),
|
|
280
|
+
files,
|
|
281
|
+
screenwalks,
|
|
282
|
+
reviews: epReviews,
|
|
283
|
+
connector_records: touching(`miadi-chronicle://${decodeURIComponent(ref)}`),
|
|
284
|
+
};
|
|
285
|
+
}));
|
|
286
|
+
server.registerTool("get_evaluations", {
|
|
287
|
+
title: "Evaluations: provider traces and recorded assessments",
|
|
288
|
+
description: "The tracing records (LLMOps provider traces and prompts) an episode holds, and the assessments people recorded with record_evaluation, operational and creator-experience kept apart.",
|
|
289
|
+
inputSchema: { episode: z.string().optional(), subject_ref: z.string().optional() },
|
|
290
|
+
annotations: READ,
|
|
291
|
+
}, wrap(async ({ episode, subject_ref }, extra) => {
|
|
292
|
+
const { person } = personOf(extra);
|
|
293
|
+
const evals = records().filter((r) => r.kind === "observation" && typeof r.body?.assessment === "string");
|
|
294
|
+
const scoped = subject_ref ? evals.filter((r) => r.refs.includes(subject_ref)) : evals;
|
|
295
|
+
return {
|
|
296
|
+
tracing: episode ? await miadi.get(person.token, `/api/chronicle/episodes/${episodeRef(episode)}/tracing`).catch(asState) : "give an episode to read its tracing records",
|
|
297
|
+
operational: scoped.filter((r) => r.body.assessment === "operational"),
|
|
298
|
+
creator_experience: scoped.filter((r) => r.body.assessment === "creator_experience"),
|
|
299
|
+
};
|
|
300
|
+
}));
|
|
301
|
+
server.registerTool("get_narrative_beats", {
|
|
302
|
+
title: "Narrative beats",
|
|
303
|
+
description: "Story beats of an NCP story project in Miadi's studio (e.g. chimera), and the candidate narrative beats people recorded with propose_narrative_beat, each with its claim category so a witnessed moment and an imagined one stay apart.",
|
|
304
|
+
inputSchema: { project: z.string().optional().describe("NCP studio project id, e.g. chimera."), episode: z.string().optional() },
|
|
305
|
+
annotations: READ,
|
|
306
|
+
}, wrap(async ({ project, episode }, extra) => {
|
|
307
|
+
const { person } = personOf(extra);
|
|
308
|
+
let ncp = "give a project id to read its story beats";
|
|
309
|
+
if (project) {
|
|
310
|
+
ncp = await miadi.get(person.token, `/api/ncp/projects/${encodeURIComponent(project)}`).then((b) => ({ project: project, beats: collectBeats(b?.project), validation: b?.validation ?? null }), asState);
|
|
311
|
+
}
|
|
312
|
+
const candidates = records().filter((r) => r.body?.narrative_beat === true && (!episode || r.refs.some((x) => x.includes(String(episode)))));
|
|
313
|
+
return { ncp, candidate_beats: candidates };
|
|
314
|
+
}));
|
|
315
|
+
server.registerTool("review_lookup", {
|
|
316
|
+
title: "Find a Miadi review",
|
|
317
|
+
description: "Find the review of a video before anything is created: by review id, review URL, YouTube URL or video id, or keywords. Returns review id, latest version, video, Deep Diver readiness and the episodes that hold it. Never creates a review.",
|
|
318
|
+
inputSchema: { query: z.string().min(1) },
|
|
319
|
+
annotations: READ,
|
|
320
|
+
}, wrap(async ({ query }, extra) => {
|
|
321
|
+
const { person } = personOf(extra);
|
|
322
|
+
const id = reviewIdOf(query);
|
|
323
|
+
if (id) {
|
|
324
|
+
const r = await reviews.get(id);
|
|
325
|
+
if (!r)
|
|
326
|
+
return { query, state: "not_found", review_id: id };
|
|
327
|
+
const holders = await miadi.get(person.token, `/api/chronicle/resolve?uri=${encodeURIComponent(`miadi-review:${id}`)}`).then((b) => b?.holders ?? b?.resolution?.holders ?? null, () => null);
|
|
328
|
+
return { query, state: "found", review: reviews.summary(r), holders, cited_by: touching(`miadi-review:${id}`) };
|
|
329
|
+
}
|
|
330
|
+
const video = videoIdOf(query);
|
|
331
|
+
if (video) {
|
|
332
|
+
const found = await lookupByVideo(video);
|
|
333
|
+
const intents = records().filter((r) => r.kind === "source_intent" && r.body.video_id === video);
|
|
334
|
+
return { query, video_id: video, state: Array.isArray(found) && found.length ? "found" : "no_review", reviews: found, source_intents: intents };
|
|
335
|
+
}
|
|
336
|
+
if (!reviews.canSearch)
|
|
337
|
+
return { query, state: "search_unavailable" };
|
|
338
|
+
return { query, state: "searched", reviews: (await reviews.search(query, 10)).map((x) => reviews.summary(x)) };
|
|
339
|
+
}));
|
|
340
|
+
server.registerTool("review_versions", {
|
|
341
|
+
title: "A review's versions",
|
|
342
|
+
description: "Every version of a review (immutable history: number, model, date), and the text of one version (latest by default) with its aspects and results.",
|
|
343
|
+
inputSchema: { review_id: z.string().min(8), version: z.number().int().min(1).optional() },
|
|
344
|
+
annotations: READ,
|
|
345
|
+
}, wrap(async ({ review_id, version }) => {
|
|
346
|
+
const id = reviewIdOf(review_id);
|
|
347
|
+
if (!id)
|
|
348
|
+
throw new ToolRefusal("invalid_ref", "Give a review UUID, miadi-review:<uuid> or the review's URL.");
|
|
349
|
+
const r = await reviews.get(id, version);
|
|
350
|
+
if (!r)
|
|
351
|
+
return { state: "not_found", review_id: id };
|
|
352
|
+
return {
|
|
353
|
+
review: reviews.summary(r),
|
|
354
|
+
versions: r.versions ?? [],
|
|
355
|
+
selected: r.version
|
|
356
|
+
? { version: r.version.version, model: r.version.model, created_at: r.version.created_at, markdown: r.version.markdown, data: r.version.data }
|
|
357
|
+
: null,
|
|
358
|
+
raw_url: `${config.reviewUrl}/review/${id}/raw${version ? `?v=${version}` : ""}`,
|
|
359
|
+
};
|
|
360
|
+
}));
|
|
361
|
+
server.registerTool("handoff_status", {
|
|
362
|
+
title: "Handoffs between circles",
|
|
363
|
+
description: "Handoffs and their state (pending, blocked, ready, in_progress, completed, withdrawn), responsible circle, next action, receipts and human gates. Filter by a reference, a circle or a state.",
|
|
364
|
+
inputSchema: { ref: z.string().optional(), circle: circle.optional(), state: z.enum(HANDOFF_STATES).optional() },
|
|
365
|
+
annotations: READ,
|
|
366
|
+
}, wrap(async ({ ref, circle: c, state }, extra) => {
|
|
367
|
+
personOf(extra);
|
|
368
|
+
let list = records().filter((r) => r.kind === "handoff" || r.kind === "source_intent");
|
|
369
|
+
if (ref)
|
|
370
|
+
list = list.filter((r) => r.id === ref || r.refs.includes(ref) || r.evidence_refs.includes(ref));
|
|
371
|
+
if (c)
|
|
372
|
+
list = list.filter((r) => r.to_circle === c || r.circle === c);
|
|
373
|
+
if (state)
|
|
374
|
+
list = list.filter((r) => r.state === state);
|
|
375
|
+
const gates = records().filter((r) => r.approval_state === "awaiting_approval");
|
|
376
|
+
return { handoffs: list, awaiting_approval: gates.map((r) => ({ id: r.id, kind: r.kind, created_at: r.created_at, by: r.actor.name, approval_url: approvalUrl(r.id) })) };
|
|
377
|
+
}));
|
|
378
|
+
server.registerTool("list_records", {
|
|
379
|
+
title: "Records contributed through the connector",
|
|
380
|
+
description: "The ledger, newest first: observations, inquiries, links, beats, evaluations, handoffs, proposals and requests, each with who made it and its claim category.",
|
|
381
|
+
inputSchema: {
|
|
382
|
+
kind: z.string().optional(),
|
|
383
|
+
circle: circle.optional(),
|
|
384
|
+
ref: z.string().optional(),
|
|
385
|
+
person_id: z.string().optional(),
|
|
386
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
387
|
+
},
|
|
388
|
+
annotations: READ,
|
|
389
|
+
}, wrap(async (a, extra) => {
|
|
390
|
+
personOf(extra);
|
|
391
|
+
let list = records();
|
|
392
|
+
if (a.kind)
|
|
393
|
+
list = list.filter((r) => r.kind === a.kind);
|
|
394
|
+
if (a.circle)
|
|
395
|
+
list = list.filter((r) => r.circle === a.circle || r.to_circle === a.circle);
|
|
396
|
+
if (a.ref)
|
|
397
|
+
list = list.filter((r) => r.refs.includes(a.ref) || r.evidence_refs.includes(a.ref));
|
|
398
|
+
if (a.person_id)
|
|
399
|
+
list = list.filter((r) => r.actor.person_id === a.person_id);
|
|
400
|
+
const out = list.reverse().slice(0, a.limit ?? 50);
|
|
401
|
+
return { records: out, count: out.length, total: list.length };
|
|
402
|
+
}));
|
|
403
|
+
server.registerTool("get_run_result", {
|
|
404
|
+
title: "Where a requested run stands",
|
|
405
|
+
description: "A run request's approval, the brief queued for the factory intake once approved, whether the intake delivered it, and its receipt.",
|
|
406
|
+
inputSchema: { record_id: z.string().min(1) },
|
|
407
|
+
annotations: READ,
|
|
408
|
+
}, wrap(async ({ record_id }, extra) => {
|
|
409
|
+
personOf(extra);
|
|
410
|
+
const r = records().find((x) => x.id === record_id);
|
|
411
|
+
if (!r)
|
|
412
|
+
return { state: "not_found", record_id };
|
|
413
|
+
return {
|
|
414
|
+
record: r,
|
|
415
|
+
approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
|
|
416
|
+
queue: r.kind === "run_request" ? readQueueState(r, ctx.factoryDir) : undefined,
|
|
417
|
+
};
|
|
418
|
+
}));
|
|
419
|
+
// search / fetch: the pair ChatGPT uses for connectors in search and deep research.
|
|
420
|
+
server.registerTool("search", {
|
|
421
|
+
title: "Search Miadi",
|
|
422
|
+
description: "Search reviews (title, channel, video, text) and the connector's records. Returns ids to pass to fetch.",
|
|
423
|
+
inputSchema: { query: z.string().min(1) },
|
|
424
|
+
annotations: READ,
|
|
425
|
+
}, wrap(async ({ query }, extra) => {
|
|
426
|
+
personOf(extra);
|
|
427
|
+
const needle = query.toLowerCase();
|
|
428
|
+
const results = [];
|
|
429
|
+
if (reviews.canSearch) {
|
|
430
|
+
for (const r of await reviews.search(query, 10).catch(() => [])) {
|
|
431
|
+
results.push({ id: `miadi-review:${r.id}`, title: r.title ?? r.id, url: `${config.reviewUrl}/review/${r.id}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const r of records().reverse()) {
|
|
435
|
+
if (results.length >= 25)
|
|
436
|
+
break;
|
|
437
|
+
if (JSON.stringify(r.body).toLowerCase().includes(needle) || (r.intention ?? "").toLowerCase().includes(needle)) {
|
|
438
|
+
results.push({ id: r.id, title: `${r.kind} by ${r.actor.name}: ${String(r.body.title ?? r.body.text ?? r.intention ?? "").slice(0, 80)}`, url: `${config.publicUrl}/records/${encodeURIComponent(r.id)}` });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { results };
|
|
442
|
+
}));
|
|
443
|
+
server.registerTool("fetch", {
|
|
444
|
+
title: "Fetch one item found by search",
|
|
445
|
+
description: "The full text of an item returned by search: a review (miadi-review:<uuid>) or a connector record id.",
|
|
446
|
+
inputSchema: { id: z.string().min(1) },
|
|
447
|
+
annotations: READ,
|
|
448
|
+
}, wrap(async ({ id }, extra) => {
|
|
449
|
+
personOf(extra);
|
|
450
|
+
const reviewId = reviewIdOf(id);
|
|
451
|
+
if (reviewId) {
|
|
452
|
+
const r = await reviews.get(reviewId);
|
|
453
|
+
if (!r)
|
|
454
|
+
return { id, state: "not_found" };
|
|
455
|
+
return { id, title: r.title, text: r.version?.markdown ?? "", url: `${config.reviewUrl}/review/${reviewId}`, metadata: { latest_version: r.latest_version, video_id: r.video_id } };
|
|
456
|
+
}
|
|
457
|
+
const rec = records().find((x) => x.id === id);
|
|
458
|
+
if (!rec)
|
|
459
|
+
return { id, state: "not_found" };
|
|
460
|
+
return { id, title: `${rec.kind} by ${rec.actor.name}`, text: JSON.stringify(rec, null, 2), url: `${config.publicUrl}/records/${encodeURIComponent(rec.id)}`, metadata: { kind: rec.kind } };
|
|
461
|
+
}));
|
|
462
|
+
// ── contributions ──────────────────────────────────────────────────────
|
|
463
|
+
const base = {
|
|
464
|
+
intention: z.string().max(2000).optional().describe("Why this is recorded: the human intention it serves."),
|
|
465
|
+
role: z.string().max(80).optional().describe("The role you act in: author, steward, agent, reviewer…"),
|
|
466
|
+
idempotency_key: z.string().max(200).optional().describe("Repeat the same key to get the first record back instead of a duplicate."),
|
|
467
|
+
};
|
|
468
|
+
server.registerTool("append_observation", {
|
|
469
|
+
title: "Record an observation",
|
|
470
|
+
description: "Record what was observed (or hypothesised, desired, imagined) about a source, in your name, with its claim category and the circle it concerns. Fiction and desired futures stay labelled as such.",
|
|
471
|
+
inputSchema: {
|
|
472
|
+
text: z.string().min(1).max(20000),
|
|
473
|
+
claim_category: claim,
|
|
474
|
+
refs,
|
|
475
|
+
evidence_refs: refs,
|
|
476
|
+
circle: circle.optional(),
|
|
477
|
+
domain: z.enum(DOMAINS).optional(),
|
|
478
|
+
consent_boundary: z.string().max(1000).optional().describe("Who may see or use this, when it matters."),
|
|
479
|
+
...base,
|
|
480
|
+
},
|
|
481
|
+
annotations: WRITE,
|
|
482
|
+
}, wrap(async (a, extra) => {
|
|
483
|
+
const { person, auth } = personOf(extra);
|
|
484
|
+
requireContribute(auth);
|
|
485
|
+
return append("observation", person, auth, {
|
|
486
|
+
claim_category: a.claim_category,
|
|
487
|
+
circle: a.circle,
|
|
488
|
+
domain: a.domain,
|
|
489
|
+
consent_boundary: a.consent_boundary,
|
|
490
|
+
refs: a.refs,
|
|
491
|
+
evidence_refs: a.evidence_refs,
|
|
492
|
+
intention: a.intention,
|
|
493
|
+
role: a.role,
|
|
494
|
+
idempotency_key: a.idempotency_key,
|
|
495
|
+
body: { text: a.text },
|
|
496
|
+
});
|
|
497
|
+
}));
|
|
498
|
+
server.registerTool("open_inquiry", {
|
|
499
|
+
title: "Open an inquiry",
|
|
500
|
+
description: "Record an open question for a circle to carry, with the sources it rests on. It stays open until a later record answers it.",
|
|
501
|
+
inputSchema: { question: z.string().min(1).max(5000), refs, circle: circle.optional(), episode: z.string().optional(), ...base },
|
|
502
|
+
annotations: WRITE,
|
|
503
|
+
}, wrap(async (a, extra) => {
|
|
504
|
+
const { person, auth } = personOf(extra);
|
|
505
|
+
requireContribute(auth);
|
|
506
|
+
return append("inquiry", person, auth, {
|
|
507
|
+
circle: a.circle,
|
|
508
|
+
refs: a.episode ? [...a.refs, `miadi-chronicle://${String(a.episode).replace(/^miadi-chronicle:(\/\/)?/, "")}`] : a.refs,
|
|
509
|
+
intention: a.intention,
|
|
510
|
+
role: a.role,
|
|
511
|
+
idempotency_key: a.idempotency_key,
|
|
512
|
+
state: "pending",
|
|
513
|
+
body: { question: a.question },
|
|
514
|
+
});
|
|
515
|
+
}));
|
|
516
|
+
server.registerTool("link_records", {
|
|
517
|
+
title: "Relate two references",
|
|
518
|
+
description: "Record a relation between two references (a review and an episode, a session and an issue, a beat and a source), with the relation's name and why.",
|
|
519
|
+
inputSchema: { from: z.string().min(1), to: z.string().min(1), relation: z.string().min(1).max(80), note: z.string().max(2000).optional(), ...base },
|
|
520
|
+
annotations: WRITE,
|
|
521
|
+
}, wrap(async (a, extra) => {
|
|
522
|
+
const { person, auth } = personOf(extra);
|
|
523
|
+
requireContribute(auth);
|
|
524
|
+
return append("link", person, auth, {
|
|
525
|
+
refs: [a.from, a.to],
|
|
526
|
+
intention: a.intention,
|
|
527
|
+
role: a.role,
|
|
528
|
+
idempotency_key: a.idempotency_key,
|
|
529
|
+
body: { from: a.from, to: a.to, relation: a.relation, note: a.note },
|
|
530
|
+
});
|
|
531
|
+
}));
|
|
532
|
+
server.registerTool("propose_narrative_beat", {
|
|
533
|
+
title: "Propose a candidate narrative beat",
|
|
534
|
+
description: "A candidate beat for the author to consider: a witnessed moment (observed) or an imagined one (desired_future, fiction), with the source it comes from. The author chooses whether it enters a chapter or song.",
|
|
535
|
+
inputSchema: {
|
|
536
|
+
title: z.string().min(1).max(200),
|
|
537
|
+
text: z.string().min(1).max(10000),
|
|
538
|
+
claim_category: z.enum(["observed", "desired_future", "fiction"]),
|
|
539
|
+
source_refs: refs,
|
|
540
|
+
episode: z.string().optional(),
|
|
541
|
+
...base,
|
|
542
|
+
},
|
|
543
|
+
annotations: WRITE,
|
|
544
|
+
}, wrap(async (a, extra) => {
|
|
545
|
+
const { person, auth } = personOf(extra);
|
|
546
|
+
requireContribute(auth);
|
|
547
|
+
return append("observation", person, auth, {
|
|
548
|
+
circle: "story",
|
|
549
|
+
claim_category: a.claim_category,
|
|
550
|
+
refs: a.episode ? [...a.source_refs, `miadi-chronicle://${String(a.episode).replace(/^miadi-chronicle:(\/\/)?/, "")}`] : a.source_refs,
|
|
551
|
+
evidence_refs: a.source_refs,
|
|
552
|
+
intention: a.intention,
|
|
553
|
+
role: a.role,
|
|
554
|
+
idempotency_key: a.idempotency_key,
|
|
555
|
+
state: "pending",
|
|
556
|
+
body: { narrative_beat: true, title: a.title, text: a.text, author_choice: "pending" },
|
|
557
|
+
});
|
|
558
|
+
}));
|
|
559
|
+
server.registerTool("record_evaluation", {
|
|
560
|
+
title: "Record an assessment",
|
|
561
|
+
description: "An assessment of a subject (a review version, a session, a contribution): operational (did it work, with measured evidence) or creator_experience (did it help the author create, understand, relate, continue). The two are never merged.",
|
|
562
|
+
inputSchema: {
|
|
563
|
+
subject_ref: z.string().min(1),
|
|
564
|
+
assessment: z.enum(["operational", "creator_experience"]),
|
|
565
|
+
verdict: z.string().min(1).max(200),
|
|
566
|
+
notes: z.string().max(10000).optional(),
|
|
567
|
+
evidence_refs: refs,
|
|
568
|
+
...base,
|
|
569
|
+
},
|
|
570
|
+
annotations: WRITE,
|
|
571
|
+
}, wrap(async (a, extra) => {
|
|
572
|
+
const { person, auth } = personOf(extra);
|
|
573
|
+
requireContribute(auth);
|
|
574
|
+
return append("observation", person, auth, {
|
|
575
|
+
circle: "evaluation",
|
|
576
|
+
claim_category: a.assessment === "operational" && a.evidence_refs.length ? "verified_implementation" : "observed",
|
|
577
|
+
refs: [a.subject_ref],
|
|
578
|
+
evidence_refs: a.evidence_refs,
|
|
579
|
+
intention: a.intention,
|
|
580
|
+
role: a.role,
|
|
581
|
+
idempotency_key: a.idempotency_key,
|
|
582
|
+
body: { assessment: a.assessment, verdict: a.verdict, notes: a.notes },
|
|
583
|
+
});
|
|
584
|
+
}));
|
|
585
|
+
server.registerTool("register_source_intent", {
|
|
586
|
+
title: "Register a pending source",
|
|
587
|
+
description: "Register a screenwalk or video as a pending source with its intention and readiness, without generating any review. One per video and purpose: registering again returns the first.",
|
|
588
|
+
inputSchema: {
|
|
589
|
+
video: z.string().min(1).describe("YouTube URL or video id."),
|
|
590
|
+
intention: z.string().min(1).max(4000),
|
|
591
|
+
readiness: z.enum(["uploading", "released_for_review", "withheld"]),
|
|
592
|
+
purpose: z.string().max(120).default("screenwalk_review"),
|
|
593
|
+
refs,
|
|
594
|
+
},
|
|
595
|
+
annotations: WRITE,
|
|
596
|
+
}, wrap(async (a, extra) => {
|
|
597
|
+
const { person, auth } = personOf(extra);
|
|
598
|
+
requireContribute(auth);
|
|
599
|
+
const video = videoIdOf(a.video);
|
|
600
|
+
if (!video)
|
|
601
|
+
throw new ToolRefusal("invalid_ref", "Give a YouTube URL or an 11-character video id.");
|
|
602
|
+
return append("source_intent", person, auth, {
|
|
603
|
+
circle: "continuity",
|
|
604
|
+
refs: [...a.refs, `https://youtu.be/${video}`],
|
|
605
|
+
intention: a.intention,
|
|
606
|
+
idempotency_key: `${video}:${a.purpose}`,
|
|
607
|
+
state: a.readiness === "released_for_review" ? "ready" : a.readiness === "withheld" ? "blocked" : "pending",
|
|
608
|
+
body: { video_id: video, supplied: a.video, readiness: a.readiness, purpose: a.purpose },
|
|
609
|
+
});
|
|
610
|
+
}));
|
|
611
|
+
server.registerTool("record_handoff", {
|
|
612
|
+
title: "Hand work to a circle",
|
|
613
|
+
description: "Record a durable handoff: which circle hands what to which circle, the contribution wanted, the evidence, and the next action. One accountable circle per handoff; no terminal is opened by it.",
|
|
614
|
+
inputSchema: {
|
|
615
|
+
source_ref: z.string().min(1),
|
|
616
|
+
from_circle: circle,
|
|
617
|
+
to_circle: circle,
|
|
618
|
+
desired_contribution: z.string().min(1).max(4000),
|
|
619
|
+
next_action: z.string().min(1).max(1000),
|
|
620
|
+
evidence_refs: refs,
|
|
621
|
+
refs,
|
|
622
|
+
state: z.enum(HANDOFF_STATES).default("pending"),
|
|
623
|
+
...base,
|
|
624
|
+
},
|
|
625
|
+
annotations: WRITE,
|
|
626
|
+
}, wrap(async (a, extra) => {
|
|
627
|
+
const { person, auth } = personOf(extra);
|
|
628
|
+
requireContribute(auth);
|
|
629
|
+
return append("handoff", person, auth, {
|
|
630
|
+
from_circle: a.from_circle,
|
|
631
|
+
to_circle: a.to_circle,
|
|
632
|
+
circle: a.to_circle,
|
|
633
|
+
refs: [a.source_ref, ...a.refs],
|
|
634
|
+
evidence_refs: a.evidence_refs,
|
|
635
|
+
intention: a.intention,
|
|
636
|
+
role: a.role,
|
|
637
|
+
idempotency_key: a.idempotency_key,
|
|
638
|
+
state: a.state,
|
|
639
|
+
next_action: a.next_action,
|
|
640
|
+
body: { desired_contribution: a.desired_contribution },
|
|
641
|
+
});
|
|
642
|
+
}));
|
|
643
|
+
server.registerTool("update_handoff", {
|
|
644
|
+
title: "Move a handoff or inquiry on",
|
|
645
|
+
description: "Change the state of a handoff, inquiry, source intent or beat (accepted → in_progress → completed, or blocked, withdrawn), with a note and, on completion, the receipt: what was delivered and where.",
|
|
646
|
+
inputSchema: {
|
|
647
|
+
record_id: z.string().min(1),
|
|
648
|
+
state: z.enum(HANDOFF_STATES),
|
|
649
|
+
note: z.string().max(4000).optional(),
|
|
650
|
+
next_action: z.string().max(1000).optional(),
|
|
651
|
+
completion_receipt: z.record(z.string(), z.unknown()).optional(),
|
|
652
|
+
},
|
|
653
|
+
annotations: WRITE,
|
|
654
|
+
}, wrap(async (a, extra) => {
|
|
655
|
+
const { person, auth } = personOf(extra);
|
|
656
|
+
requireContribute(auth);
|
|
657
|
+
const target = records().find((r) => r.id === a.record_id);
|
|
658
|
+
if (!target)
|
|
659
|
+
return { state: "not_found", record_id: a.record_id };
|
|
660
|
+
if (!["handoff", "inquiry", "source_intent", "observation", "work_proposal"].includes(target.kind)) {
|
|
661
|
+
throw new ToolRefusal("not_updatable", `A ${target.kind} changes through its approval, not here.`);
|
|
662
|
+
}
|
|
663
|
+
append(target.kind, person, auth, {
|
|
664
|
+
supersedes: target.id,
|
|
665
|
+
refs: target.refs,
|
|
666
|
+
state: a.state,
|
|
667
|
+
next_action: a.next_action,
|
|
668
|
+
completion_receipt: a.completion_receipt,
|
|
669
|
+
body: { update: true, note: a.note },
|
|
670
|
+
});
|
|
671
|
+
return records().find((r) => r.id === target.id);
|
|
672
|
+
}));
|
|
673
|
+
server.registerTool("propose_work", {
|
|
674
|
+
title: "Propose work to a circle",
|
|
675
|
+
description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. A proposal; nothing starts. A person approves it before it becomes a run.",
|
|
676
|
+
inputSchema: {
|
|
677
|
+
title: z.string().min(1).max(200),
|
|
678
|
+
desired_outcome: z.string().min(1).max(6000),
|
|
679
|
+
circle,
|
|
680
|
+
refs,
|
|
681
|
+
repo: z.string().max(120).optional(),
|
|
682
|
+
...base,
|
|
683
|
+
},
|
|
684
|
+
annotations: WRITE,
|
|
685
|
+
}, wrap(async (a, extra) => {
|
|
686
|
+
const { person, auth } = personOf(extra);
|
|
687
|
+
requireContribute(auth);
|
|
688
|
+
const r = append("work_proposal", person, auth, {
|
|
689
|
+
circle: a.circle,
|
|
690
|
+
refs: a.refs,
|
|
691
|
+
intention: a.intention,
|
|
692
|
+
role: a.role,
|
|
693
|
+
idempotency_key: a.idempotency_key,
|
|
694
|
+
state: "pending",
|
|
695
|
+
approval_state: "awaiting_approval",
|
|
696
|
+
body: { title: a.title, desired_outcome: a.desired_outcome, repo: a.repo },
|
|
697
|
+
});
|
|
698
|
+
return { ...r, approval_url: approvalUrl(r.id) };
|
|
699
|
+
}));
|
|
700
|
+
server.registerTool("prepare_calendar_event", {
|
|
701
|
+
title: "Draft a calendar event",
|
|
702
|
+
description: "Draft a meeting (title, time, attendees, agenda) and return it with an .ics text. Nothing is put on anyone's calendar: a person adds it.",
|
|
703
|
+
inputSchema: {
|
|
704
|
+
title: z.string().min(1).max(200),
|
|
705
|
+
start: z.string().describe("ISO 8601 date-time with offset."),
|
|
706
|
+
end: z.string().optional(),
|
|
707
|
+
attendees: z.array(z.string()).max(30).default([]),
|
|
708
|
+
agenda: z.string().max(6000).optional(),
|
|
709
|
+
refs,
|
|
710
|
+
},
|
|
711
|
+
annotations: WRITE,
|
|
712
|
+
}, wrap(async (a, extra) => {
|
|
713
|
+
const { person, auth } = personOf(extra);
|
|
714
|
+
requireContribute(auth);
|
|
715
|
+
const start = new Date(a.start);
|
|
716
|
+
if (Number.isNaN(start.getTime()))
|
|
717
|
+
throw new ToolRefusal("invalid_time", "start must be an ISO 8601 date-time.");
|
|
718
|
+
const end = a.end ? new Date(a.end) : new Date(start.getTime() + 3600_000);
|
|
719
|
+
const r = append("calendar_event_draft", person, auth, {
|
|
720
|
+
refs: a.refs,
|
|
721
|
+
approval_state: "not_required",
|
|
722
|
+
body: { title: a.title, start: start.toISOString(), end: end.toISOString(), attendees: a.attendees, agenda: a.agenda },
|
|
723
|
+
});
|
|
724
|
+
return { ...r, ics: ics(r.id, a.title, start, end, a.agenda ?? "", a.attendees) };
|
|
725
|
+
}));
|
|
726
|
+
server.registerTool("propose_session_completion", {
|
|
727
|
+
title: "Propose how a session ends",
|
|
728
|
+
description: "Propose an ending for an agent session (exit, complete_then_exit, record_only) with the reason and evidence. A person decides it on the approval page; the decision is recorded, and no pane is closed by the connector.",
|
|
729
|
+
inputSchema: {
|
|
730
|
+
session_id: z.string().min(8),
|
|
731
|
+
ending: z.enum(["exit", "complete_then_exit", "record_only"]),
|
|
732
|
+
reason: z.string().min(1).max(4000),
|
|
733
|
+
evidence_refs: refs,
|
|
734
|
+
},
|
|
735
|
+
annotations: WRITE,
|
|
736
|
+
}, wrap(async (a, extra) => {
|
|
737
|
+
const { person, auth } = personOf(extra);
|
|
738
|
+
requireContribute(auth);
|
|
739
|
+
const id = a.session_id.replace(/^session:/, "");
|
|
740
|
+
const r = append("session_completion_proposal", person, auth, {
|
|
741
|
+
circle: "continuity",
|
|
742
|
+
refs: [`session:${id}`],
|
|
743
|
+
evidence_refs: a.evidence_refs,
|
|
744
|
+
approval_state: "awaiting_approval",
|
|
745
|
+
idempotency_key: `${id}:${a.ending}`,
|
|
746
|
+
body: { session_id: id, ending: a.ending, reason: a.reason },
|
|
747
|
+
});
|
|
748
|
+
return { ...r, approval_url: approvalUrl(r.id) };
|
|
749
|
+
}));
|
|
750
|
+
// ── requests: recorded, approved by a person on the approval page, then run ──
|
|
751
|
+
server.registerTool("request_authorized_run", {
|
|
752
|
+
title: "Request a bounded agent run",
|
|
753
|
+
description: "Ask for an agent session to do one bounded task. The request waits for a person with Miadi's write tier to approve it on the returned approval_url; once approved, a brief is queued for the factory intake, which opens or reaches the session within minutes. Read it back with get_run_result.",
|
|
754
|
+
inputSchema: {
|
|
755
|
+
task: z.string().min(1).max(12000).describe("What the session does and what it returns."),
|
|
756
|
+
circle,
|
|
757
|
+
refs,
|
|
758
|
+
session: z.string().regex(/^[A-Za-z0-9._-]{3,80}$/).optional().describe("An existing tmux session to hand it to, or a name for a new one."),
|
|
759
|
+
cwd: z.string().max(300).optional().describe("Working folder for a new session (default the Miadi checkout)."),
|
|
760
|
+
...base,
|
|
761
|
+
},
|
|
762
|
+
annotations: WRITE,
|
|
763
|
+
}, wrap(async (a, extra) => {
|
|
764
|
+
const { person, auth } = personOf(extra);
|
|
765
|
+
requireContribute(auth);
|
|
766
|
+
const r = append("run_request", person, auth, {
|
|
767
|
+
circle: a.circle,
|
|
768
|
+
refs: a.refs,
|
|
769
|
+
intention: a.intention,
|
|
770
|
+
role: a.role,
|
|
771
|
+
idempotency_key: a.idempotency_key,
|
|
772
|
+
approval_state: "awaiting_approval",
|
|
773
|
+
state: "pending",
|
|
774
|
+
body: { task: a.task, session: a.session, cwd: a.cwd },
|
|
775
|
+
});
|
|
776
|
+
return { ...r, approval_url: approvalUrl(r.id) };
|
|
777
|
+
}));
|
|
778
|
+
server.registerTool("request_review_operation", {
|
|
779
|
+
title: "Request a review operation",
|
|
780
|
+
description: "Ask for one review-service operation: create (a review from a YouTube URL), import_markdown (a hand-written version), new_version (regenerate), store_transcript. It waits for a person's approval; check review_lookup first so nothing is duplicated.",
|
|
781
|
+
inputSchema: {
|
|
782
|
+
operation: z.enum(["create", "import_markdown", "new_version", "store_transcript"]),
|
|
783
|
+
review_id: z.string().optional(),
|
|
784
|
+
video: z.string().optional().describe("YouTube URL, for create and import_markdown."),
|
|
785
|
+
markdown: z.string().max(400000).optional(),
|
|
786
|
+
transcript: z.string().max(800000).optional(),
|
|
787
|
+
refs,
|
|
788
|
+
...base,
|
|
789
|
+
},
|
|
790
|
+
annotations: WRITE,
|
|
791
|
+
}, wrap(async (a, extra) => {
|
|
792
|
+
const { person, auth } = personOf(extra);
|
|
793
|
+
requireContribute(auth);
|
|
794
|
+
const reviewId = a.review_id ? reviewIdOf(a.review_id) : null;
|
|
795
|
+
const video = a.video ? videoIdOf(a.video) : null;
|
|
796
|
+
if ((a.operation === "new_version" || a.operation === "store_transcript") && !reviewId)
|
|
797
|
+
throw new ToolRefusal("invalid_request", `${a.operation} needs review_id.`);
|
|
798
|
+
if ((a.operation === "create" || a.operation === "import_markdown") && !video)
|
|
799
|
+
throw new ToolRefusal("invalid_request", `${a.operation} needs a YouTube video.`);
|
|
800
|
+
if (a.operation === "import_markdown" && !a.markdown)
|
|
801
|
+
throw new ToolRefusal("invalid_request", "import_markdown needs markdown.");
|
|
802
|
+
if (a.operation === "store_transcript" && !a.transcript)
|
|
803
|
+
throw new ToolRefusal("invalid_request", "store_transcript needs transcript.");
|
|
804
|
+
if (a.operation === "create" && video && reviews.canSearch) {
|
|
805
|
+
const existing = await lookupByVideo(video);
|
|
806
|
+
if (Array.isArray(existing) && existing.length) {
|
|
807
|
+
return { state: "review_exists", message: "A review of this video exists; use new_version or import_markdown on it.", reviews: existing };
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const r = append("review_operation_request", person, auth, {
|
|
811
|
+
circle: "continuity",
|
|
812
|
+
refs: [...a.refs, ...(reviewId ? [`miadi-review:${reviewId}`] : []), ...(video ? [`https://youtu.be/${video}`] : [])],
|
|
813
|
+
intention: a.intention,
|
|
814
|
+
role: a.role,
|
|
815
|
+
idempotency_key: a.idempotency_key,
|
|
816
|
+
approval_state: "awaiting_approval",
|
|
817
|
+
body: { operation: a.operation, review_id: reviewId, video_id: video, markdown: a.markdown, transcript: a.transcript },
|
|
818
|
+
});
|
|
819
|
+
return { ...r, body: { ...r.body, markdown: a.markdown ? `${a.markdown.length} chars` : undefined, transcript: a.transcript ? `${a.transcript.length} chars` : undefined }, approval_url: approvalUrl(r.id) };
|
|
820
|
+
}));
|
|
821
|
+
server.registerTool("request_deep_diver", {
|
|
822
|
+
title: "Request Deep Diver for a review version",
|
|
823
|
+
description: "Ask that one accepted review version be marked ready for Deep Diver. It waits for a person's approval; then the review service records it and its signed webhook queues /deepdive for the Deep Diver owner.",
|
|
824
|
+
inputSchema: { review_id: z.string().min(8), version: z.number().int().min(1), episode: z.string().optional(), ...base },
|
|
825
|
+
annotations: WRITE,
|
|
826
|
+
}, wrap(async (a, extra) => {
|
|
827
|
+
const { person, auth } = personOf(extra);
|
|
828
|
+
requireContribute(auth);
|
|
829
|
+
const id = reviewIdOf(a.review_id);
|
|
830
|
+
if (!id)
|
|
831
|
+
throw new ToolRefusal("invalid_ref", "Give a review UUID.");
|
|
832
|
+
const r0 = await reviews.get(id, a.version);
|
|
833
|
+
if (!r0)
|
|
834
|
+
return { state: "not_found", review_id: id, version: a.version };
|
|
835
|
+
const r = append("deep_diver_request", person, auth, {
|
|
836
|
+
circle: "continuity",
|
|
837
|
+
refs: [`miadi-review:${id}`, ...(a.episode ? [`miadi-chronicle://${String(a.episode).replace(/^miadi-chronicle:(\/\/)?/, "")}`] : [])],
|
|
838
|
+
intention: a.intention,
|
|
839
|
+
role: a.role,
|
|
840
|
+
idempotency_key: a.idempotency_key ?? `${id}:v${a.version}`,
|
|
841
|
+
approval_state: "awaiting_approval",
|
|
842
|
+
body: { review_id: id, version: a.version, episode: a.episode },
|
|
843
|
+
});
|
|
844
|
+
return { ...r, approval_url: approvalUrl(r.id) };
|
|
845
|
+
}));
|
|
846
|
+
server.registerTool("link_review_to_episode", {
|
|
847
|
+
title: "Request that an episode hold a review",
|
|
848
|
+
description: "Ask that a Chronicle episode hold a review version, through Miadi's episode door (which also relates them on the wheel). An unknown episode stays unassigned: name it. Waits for a person's approval.",
|
|
849
|
+
inputSchema: {
|
|
850
|
+
review_id: z.string().min(8),
|
|
851
|
+
version: z.number().int().min(1),
|
|
852
|
+
episode: z.string().min(1),
|
|
853
|
+
relation: z.string().max(80).optional(),
|
|
854
|
+
...base,
|
|
855
|
+
},
|
|
856
|
+
annotations: WRITE,
|
|
857
|
+
}, wrap(async (a, extra) => {
|
|
858
|
+
const { person, auth } = personOf(extra);
|
|
859
|
+
requireContribute(auth);
|
|
860
|
+
const id = reviewIdOf(a.review_id);
|
|
861
|
+
if (!id)
|
|
862
|
+
throw new ToolRefusal("invalid_ref", "Give a review UUID.");
|
|
863
|
+
const episode = String(a.episode).replace(/^miadi-chronicle:(\/\/)?/, "");
|
|
864
|
+
const r = append("review_episode_link_request", person, auth, {
|
|
865
|
+
circle: "continuity",
|
|
866
|
+
refs: [`miadi-review:${id}`, `miadi-chronicle://${episode}`],
|
|
867
|
+
intention: a.intention,
|
|
868
|
+
role: a.role,
|
|
869
|
+
idempotency_key: a.idempotency_key ?? `${id}:v${a.version}:${episode}`,
|
|
870
|
+
approval_state: "awaiting_approval",
|
|
871
|
+
body: { review_id: id, version: a.version, episode, relation: a.relation },
|
|
872
|
+
});
|
|
873
|
+
return { ...r, approval_url: approvalUrl(r.id) };
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
function asState(e) {
|
|
877
|
+
if (e instanceof MiadiError)
|
|
878
|
+
return { state: e.status === 0 ? "unavailable" : e.status === 404 ? "not_found" : `miadi_${e.status}`, body: e.body };
|
|
879
|
+
return { state: "failed", message: e instanceof Error ? e.message : String(e) };
|
|
880
|
+
}
|
|
881
|
+
function collectBeats(project) {
|
|
882
|
+
if (!project || typeof project !== "object")
|
|
883
|
+
return [];
|
|
884
|
+
const out = [];
|
|
885
|
+
const walk = (v, depth) => {
|
|
886
|
+
if (!v || typeof v !== "object" || depth > 8)
|
|
887
|
+
return;
|
|
888
|
+
for (const [k, val] of Object.entries(v)) {
|
|
889
|
+
if (/^story_?beats$/i.test(k) && Array.isArray(val))
|
|
890
|
+
out.push(...val);
|
|
891
|
+
else
|
|
892
|
+
walk(val, depth + 1);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
walk(project, 0);
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
function icsDate(d) {
|
|
899
|
+
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
900
|
+
}
|
|
901
|
+
function icsText(s) {
|
|
902
|
+
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
|
903
|
+
}
|
|
904
|
+
function ics(uid, title, start, end, agenda, attendees) {
|
|
905
|
+
return [
|
|
906
|
+
"BEGIN:VCALENDAR",
|
|
907
|
+
"VERSION:2.0",
|
|
908
|
+
"PRODID:-//Miadi//connector//EN",
|
|
909
|
+
"BEGIN:VEVENT",
|
|
910
|
+
`UID:${uid.replace(/[^\w-]/g, "")}@miadi`,
|
|
911
|
+
`DTSTAMP:${icsDate(new Date())}`,
|
|
912
|
+
`DTSTART:${icsDate(start)}`,
|
|
913
|
+
`DTEND:${icsDate(end)}`,
|
|
914
|
+
`SUMMARY:${icsText(title)}`,
|
|
915
|
+
...(agenda ? [`DESCRIPTION:${icsText(agenda)}`] : []),
|
|
916
|
+
...attendees.filter((x) => x.includes("@")).map((x) => `ATTENDEE:mailto:${x}`),
|
|
917
|
+
"END:VEVENT",
|
|
918
|
+
"END:VCALENDAR",
|
|
919
|
+
].join("\r\n");
|
|
920
|
+
}
|
|
921
|
+
/** What get_capabilities reports for each tool. */
|
|
922
|
+
export const TOOL_STATUS = {
|
|
923
|
+
get_capabilities: "live",
|
|
924
|
+
get_context: "live",
|
|
925
|
+
list_circles: "live",
|
|
926
|
+
get_circle: "live",
|
|
927
|
+
list_sessions: "live, Miadi write tier",
|
|
928
|
+
get_session: "live, Miadi write tier",
|
|
929
|
+
get_evidence: "live",
|
|
930
|
+
get_evaluations: "live (tracing records and recorded assessments; no provider scores)",
|
|
931
|
+
get_narrative_beats: "live (NCP project beats and candidate beats)",
|
|
932
|
+
review_lookup: "live (video search needs the review token on the connector host)",
|
|
933
|
+
review_versions: "live",
|
|
934
|
+
handoff_status: "live",
|
|
935
|
+
list_records: "live",
|
|
936
|
+
get_run_result: "live",
|
|
937
|
+
search: "live",
|
|
938
|
+
fetch: "live",
|
|
939
|
+
append_observation: "live, contribute",
|
|
940
|
+
open_inquiry: "live, contribute",
|
|
941
|
+
link_records: "live, contribute",
|
|
942
|
+
propose_narrative_beat: "live, contribute",
|
|
943
|
+
record_evaluation: "live, contribute",
|
|
944
|
+
register_source_intent: "live, contribute",
|
|
945
|
+
record_handoff: "live, contribute",
|
|
946
|
+
update_handoff: "live, contribute",
|
|
947
|
+
propose_work: "live, contribute, needs_approval",
|
|
948
|
+
prepare_calendar_event: "live, contribute (draft and .ics only)",
|
|
949
|
+
propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
|
|
950
|
+
request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved)",
|
|
951
|
+
request_review_operation: "live, contribute, needs_approval",
|
|
952
|
+
request_deep_diver: "live, contribute, needs_approval",
|
|
953
|
+
link_review_to_episode: "live, contribute, needs_approval",
|
|
954
|
+
};
|