@miadi/connector 0.1.3 → 0.1.5

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 CHANGED
@@ -23,11 +23,13 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
23
23
  | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `search` `fetch` |
24
24
  | contributions | `append_observation` `open_inquiry` `link_records` `propose_narrative_beat` `record_evaluation` `register_source_intent` `record_handoff` `update_handoff` `prepare_calendar_event` `save_research` |
25
25
  | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
26
+ | decisions in the conversation | `show_decisions` (cards), `decide_request` (called by the card only) |
26
27
 
27
28
  - **Reads** stay within the person's Miadi tier. Sessions need the write tier, because a session's name and pane are operator data.
28
- - **Contributions** need the `miadi:contribute` scope, chosen at sign-in. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository.
29
- - **Requests** append a record that waits for approval. The answer gives an `approval_url`. A person with Miadi's write tier opens it in a browser, signs in, and approves or declines. Only that page runs the request, once, as that person:
29
+ - **Contributions** need the `miadi:contribute` scope, chosen at sign-in. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository. A long report is sent in parts (`part`, `parts`, `upload_id`) and saved when the last part arrives; the answer returns its bytes, sha256 and first and last lines.
30
+ - **Requests** append a record that waits for a decision. The answer gives an `approval_url`; Miadi's `/research` page offers the same decisions to a signed-in person with the write tier, and `show_decisions` shows them as cards inside the conversation (below). The person approves, approves with changes (their instructions are carried into what runs), sends it back for revision with instructions (`revision_requested`, nothing runs, `get_run_result` returns the instructions), or declines. Only the approval door runs a request, once, as that person:
30
31
  - a run becomes a brief in the factory queue, which the intake delivers to a session;
32
+ - a work proposal starts its work the same way, as a brief written from the proposal;
31
33
  - a review operation calls the review service;
32
34
  - Deep Diver marks the review version ready;
33
35
  - an episode link goes through Miadi's episode door;
@@ -36,6 +38,17 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
36
38
 
37
39
  The model that asked cannot approve its own request.
38
40
 
41
+ ### Deciding inside the conversation
42
+
43
+ In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws one card per request awaiting a decision: what is asked, by whom, and what approving does. The person decides on the card with their own click: Approve, Approve with my changes, Send back for revision, or Decline. The card then shows the receipt.
44
+
45
+ The model that asked cannot decide for the person:
46
+ - `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
47
+ - It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request, one person and one OAuth client, and expires after 30 minutes. A decided request no longer awaits a decision, so a token works once.
48
+ - The sign-in must carry `miadi:approve`. A person grants it by ticking "Let me approve requests on cards inside this client" on the sign-in page, and only a person with Miadi's write tier receives it. A person token used directly as Bearer never carries it.
49
+
50
+ Without that sign-in, or for a person without the write tier, the cards link to each request's approval page instead.
51
+
39
52
  Every record has a claim category: `observed`, `hypothesis`, `desired_future`, `fiction` or `verified_implementation`. Witnessed fact, envisioned features and fiction stay apart.
40
53
 
41
54
  ## Run it
package/dist/approve.d.ts CHANGED
@@ -25,6 +25,13 @@ export interface ApprovalDeps {
25
25
  factoryDir?: string;
26
26
  tmuxSessions?: () => string[];
27
27
  }
28
+ /**
29
+ * What a person may decide on a request. Approving with changes runs it with the
30
+ * approver's instructions carried into what runs; a revision sends it back to
31
+ * whoever asked, with instructions, and runs nothing.
32
+ */
33
+ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "request_revision", "decline"];
34
+ export type Decision = (typeof DECISIONS)[number];
28
35
  export declare function factoryDir(env?: NodeJS.ProcessEnv): string;
29
36
  export declare function runBriefFile(record: {
30
37
  id: string;
@@ -39,9 +46,29 @@ export declare function readQueueState(record: ConnectorRecord, dir?: string): {
39
46
  file: string;
40
47
  note: string;
41
48
  };
42
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[]): {
49
+ /** The task a work proposal asks for, written from the proposal's own fields. */
50
+ export declare function proposalTask(record: ConnectorRecord): string;
51
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
43
52
  to: string;
44
53
  brief: string;
45
54
  };
46
- export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps): Promise<Record<string, unknown>>;
55
+ export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps, instructions?: string): Promise<Record<string, unknown>>;
56
+ /**
57
+ * Record a person's decision on a request and, when approved, run it once as
58
+ * them. The approval page and the in-chat approval card both decide here; the
59
+ * caller has already checked who the person is, their write tier, and that the
60
+ * request still awaits approval.
61
+ */
62
+ export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice: Decision, deps: ApprovalDeps, opts: {
63
+ instructions?: string;
64
+ note?: string;
65
+ via: string;
66
+ }): Promise<{
67
+ decision: NonNullable<ConnectorRecord["approval_state"]>;
68
+ state: ConnectorRecord["state"];
69
+ receipt: Record<string, unknown>;
70
+ }>;
71
+ export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
72
+ /** What approving a request does, in one sentence, for the card a person decides on. */
73
+ export declare function consequenceOf(r: ConnectorRecord): string;
47
74
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
package/dist/approve.js CHANGED
@@ -26,6 +26,12 @@ const APPROVABLE = new Set([
26
26
  "session_completion_proposal",
27
27
  "inquiry_episode_request",
28
28
  ]);
29
+ /**
30
+ * What a person may decide on a request. Approving with changes runs it with the
31
+ * approver's instructions carried into what runs; a revision sends it back to
32
+ * whoever asked, with instructions, and runs nothing.
33
+ */
34
+ export const DECISIONS = ["approve", "approve_with_changes", "request_revision", "decline"];
29
35
  export function factoryDir(env = process.env) {
30
36
  return env.MIADI_FACTORY_DIR || join(homedir(), ".miadi", "factory");
31
37
  }
@@ -63,8 +69,19 @@ function yymmdd(d = new Date()) {
63
69
  function attr(v) {
64
70
  return v.replace(/["<>\n]/g, " ");
65
71
  }
66
- export function runBrief(record, approver, sessions) {
67
- const body = record.body;
72
+ /** The task a work proposal asks for, written from the proposal's own fields. */
73
+ export function proposalTask(record) {
74
+ const b = record.body;
75
+ const lines = ["Carry out this approved work proposal, within its own scope and constraints.", ""];
76
+ for (const [k, v] of Object.entries(b)) {
77
+ if (v === undefined || v === null || v === "")
78
+ continue;
79
+ lines.push(`${k.replace(/_/g, " ")}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ export function runBrief(record, approver, sessions, instructions) {
84
+ const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
68
85
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
69
86
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
70
87
  const cwd = body.cwd || "/a/src/Miadi-18";
@@ -86,6 +103,16 @@ export function runBrief(record, approver, sessions) {
86
103
  // The intake reads one <input> block per file: a closing tag inside the task would end it early.
87
104
  body.task.replace(/<\/?(input|task)\b/gi, (m) => m.replace("<", "‹")),
88
105
  "</task>",
106
+ ...(instructions
107
+ ? [
108
+ "",
109
+ `${approver.name} approved it with these instructions. Where they differ from the task, they win:`,
110
+ "",
111
+ "<instructions>",
112
+ instructions.replace(/<\/?(input|task|instructions)\b/gi, (m) => m.replace("<", "‹")),
113
+ "</instructions>",
114
+ ]
115
+ : []),
89
116
  "",
90
117
  "When it is done, reply with what was achieved, the evidence (commits, paths, URLs), what is still open, and whether this session should end. Then record the receipt through the connector's update_handoff if a handoff names this record, and end the session per its lifecycle.",
91
118
  "",
@@ -98,7 +125,7 @@ function plainName(name) {
98
125
  const n = name.replace(/[^\w .'-]/g, "").trim().slice(0, 40);
99
126
  return /^[^\W_]/.test(n) ? n : "Miadi connector";
100
127
  }
101
- export async function executeApproved(record, approver, deps) {
128
+ export async function executeApproved(record, approver, deps, instructions) {
102
129
  const f = deps.fetchImpl ?? fetch;
103
130
  const b = record.body;
104
131
  const reviewCall = async (method, path, payload) => {
@@ -120,15 +147,17 @@ export async function executeApproved(record, approver, deps) {
120
147
  }
121
148
  return { status: res.status, ok: res.ok, response: parsed };
122
149
  };
150
+ const queueBrief = () => {
151
+ const queue = join(deps.factoryDir ?? factoryDir(), "queue");
152
+ mkdirSync(queue, { recursive: true });
153
+ const { to, brief } = runBrief(record, approver, (deps.tmuxSessions ?? liveTmux)(), instructions);
154
+ const file = runBriefFile(record);
155
+ writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
156
+ return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
157
+ };
123
158
  switch (record.kind) {
124
- case "run_request": {
125
- const queue = join(deps.factoryDir ?? factoryDir(), "queue");
126
- mkdirSync(queue, { recursive: true });
127
- const { to, brief } = runBrief(record, approver, (deps.tmuxSessions ?? liveTmux)());
128
- const file = runBriefFile(record);
129
- writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
130
- return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
131
- }
159
+ case "run_request":
160
+ return queueBrief();
132
161
  case "review_operation_request": {
133
162
  const url = b.video_id ? `https://youtu.be/${b.video_id}` : undefined;
134
163
  const r = b.operation === "create"
@@ -181,7 +210,8 @@ export async function executeApproved(record, approver, deps) {
181
210
  }
182
211
  }
183
212
  case "work_proposal":
184
- return { outcome: "approved", note: "The proposal is approved; a run starts only through request_authorized_run." };
213
+ // Approving a proposal starts its work: a factory brief written from the proposal and the approver's instructions.
214
+ return queueBrief();
185
215
  case "session_completion_proposal":
186
216
  return {
187
217
  outcome: "decided",
@@ -231,9 +261,82 @@ ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}
231
261
  </dl>
232
262
  <pre>${escapeHtml(JSON.stringify(shown, null, 2))}</pre>`;
233
263
  }
264
+ /**
265
+ * Record a person's decision on a request and, when approved, run it once as
266
+ * them. The approval page and the in-chat approval card both decide here; the
267
+ * caller has already checked who the person is, their write tier, and that the
268
+ * request still awaits approval.
269
+ */
270
+ export async function decide(r, person, choice, deps, opts) {
271
+ const { instructions } = opts;
272
+ const decision = choice === "approve" || choice === "approve_with_changes" ? "approved" : choice === "request_revision" ? "revision_requested" : "declined";
273
+ let receipt = {
274
+ decided_by: { person_id: person.id, name: person.name },
275
+ decided_at: new Date().toISOString(),
276
+ decision: choice,
277
+ via: opts.via,
278
+ ...(instructions ? { instructions } : {}),
279
+ note: opts.note,
280
+ };
281
+ let state = decision === "approved" ? "completed" : decision === "revision_requested" ? "pending" : "withdrawn";
282
+ if (decision === "approved") {
283
+ try {
284
+ const outcome = await executeApproved(r, person, deps, instructions);
285
+ receipt = { ...receipt, ...outcome };
286
+ if (outcome.outcome === "failed")
287
+ state = "blocked";
288
+ if (outcome.outcome === "queued")
289
+ state = "in_progress";
290
+ }
291
+ catch (error) {
292
+ receipt = { ...receipt, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
293
+ state = "blocked";
294
+ }
295
+ }
296
+ deps.ledger.append({
297
+ id: newRecordId(r.kind),
298
+ kind: r.kind,
299
+ created_at: new Date().toISOString(),
300
+ actor: { person_id: person.id, name: person.name, client_id: opts.via },
301
+ refs: r.refs,
302
+ evidence_refs: [],
303
+ supersedes: r.id,
304
+ approval_state: decision,
305
+ state,
306
+ ...(decision === "revision_requested" ? { next_action: `Revise and submit a new request that cites ${r.id} in refs.` } : {}),
307
+ completion_receipt: receipt,
308
+ body: { decision: choice, ...(instructions ? { instructions } : {}) },
309
+ });
310
+ return { decision, state, receipt };
311
+ }
312
+ export function isApprovable(r) {
313
+ return Boolean(r && APPROVABLE.has(r.kind));
314
+ }
315
+ /** What approving a request does, in one sentence, for the card a person decides on. */
316
+ export function consequenceOf(r) {
317
+ const b = r.body;
318
+ switch (r.kind) {
319
+ case "run_request":
320
+ return "Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes.";
321
+ case "work_proposal":
322
+ return "Approving starts the work: a brief written from the proposal and your instructions is queued for an agent session.";
323
+ case "review_operation_request":
324
+ return `Approving calls the review service once: ${String(b.operation ?? "operation").replace(/_/g, " ")}.`;
325
+ case "deep_diver_request":
326
+ return `Approving marks review version ${b.version ?? "?"} ready for Deep Diver, which queues its pass.`;
327
+ case "review_episode_link_request":
328
+ return `Approving has episode ${b.episode ?? "?"} hold the review through the episode door.`;
329
+ case "inquiry_episode_request":
330
+ return b.new_episode ? "Approving mints a new episode with the inquiry woven in." : `Approving weaves the inquiry into episode ${b.episode ?? "?"}.`;
331
+ case "session_completion_proposal":
332
+ return `Approving records the ending (${b.ending ?? "?"}) for the session. No pane is closed.`;
333
+ default:
334
+ return "Approving runs the request once, as you.";
335
+ }
336
+ }
234
337
  export function registerApprovalRoutes(app, deps) {
235
338
  const find = (id) => current(deps.ledger.all()).find((r) => r.id === id);
236
- const form = express.urlencoded({ extended: false, limit: "16kb" });
339
+ const form = express.urlencoded({ extended: false, limit: "128kb" });
237
340
  app.get("/approve/:id", (req, res) => {
238
341
  const r = find(String(req.params.id));
239
342
  if (!r || !APPROVABLE.has(r.kind)) {
@@ -262,37 +365,18 @@ export function registerApprovalRoutes(app, deps) {
262
365
  res.status(403).type("html").send(approvePage(r, `Approving needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
263
366
  return;
264
367
  }
265
- const decision = body.decision === "approve" ? "approved" : "declined";
266
- let receipt = { decided_by: { person_id: person.id, name: person.name }, decided_at: new Date().toISOString(), note: body.note || undefined };
267
- let state = decision === "approved" ? "completed" : "withdrawn";
268
- if (decision === "approved") {
269
- try {
270
- const outcome = await executeApproved(r, person, deps);
271
- receipt = { ...receipt, ...outcome };
272
- if (outcome.outcome === "failed")
273
- state = "blocked";
274
- if (outcome.outcome === "queued")
275
- state = "in_progress";
276
- }
277
- catch (error) {
278
- receipt = { ...receipt, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
279
- state = "blocked";
280
- }
368
+ const choice = DECISIONS.includes(String(body.decision)) ? body.decision : "decline";
369
+ const instructions = String(body.instructions ?? "").trim().slice(0, 20000) || undefined;
370
+ if ((choice === "approve_with_changes" || choice === "request_revision") && !instructions) {
371
+ res.status(400).type("html").send(approvePage(r, choice === "request_revision" ? "Say what to revise." : "Write the changes to apply."));
372
+ return;
281
373
  }
282
- deps.ledger.append({
283
- id: newRecordId(r.kind),
284
- kind: r.kind,
285
- created_at: new Date().toISOString(),
286
- actor: { person_id: person.id, name: person.name, client_id: "approval-page" },
287
- refs: r.refs,
288
- evidence_refs: [],
289
- supersedes: r.id,
290
- approval_state: decision,
291
- state,
292
- completion_receipt: receipt,
293
- body: { decision },
374
+ const { decision, receipt } = await decide(r, person, choice, deps, {
375
+ instructions,
376
+ note: body.note || undefined,
377
+ via: String(body.via ?? "approval-page").slice(0, 60),
294
378
  });
295
- res.type("html").send(page("Recorded", `<h1>${decision === "approved" ? "Approved" : "Declined"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre>`));
379
+ res.type("html").send(page("Recorded", `<h1>${decision === "approved" ? "Approved" : decision === "revision_requested" ? "Sent back for revision" : "Declined"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre>`));
296
380
  });
297
381
  // A record's own page, for citations in ChatGPT: any person Miadi recognises may read it.
298
382
  app.get("/records/:id", (req, res) => {
@@ -328,10 +412,12 @@ ${r.approval_state === "awaiting_approval"
328
412
  ? `<form method="post">
329
413
  <label for="t">Your Miadi person token (write tier)</label>
330
414
  <input id="t" name="person_token" type="password" autocomplete="off" required>
331
- <label for="n">Note (optional)</label>
332
- <textarea id="n" name="note" rows="2"></textarea>
415
+ <label for="i">Instructions (needed to approve with changes or to send back)</label>
416
+ <textarea id="i" name="instructions" rows="4"></textarea>
333
417
  <div class="row">
334
418
  <button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
419
+ <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>
420
+ <button type="submit" name="decision" value="request_revision">Send back for revision</button>
335
421
  <button type="submit" name="decision" value="decline">Decline</button>
336
422
  </div></form>`
337
423
  : ""}`);
@@ -0,0 +1,18 @@
1
+ export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
2
+ export declare const DECISION_TOKEN_TTL: number;
3
+ /** Where the tokens travel in a result's `_meta`, keyed by record id. */
4
+ export declare const DECISION_TOKENS_META = "miadi/decisionTokens";
5
+ export declare class DecisionTokens {
6
+ private readonly secret;
7
+ constructor(stateDir: string);
8
+ private sign;
9
+ issue(recordId: string, personId: string, clientId: string, now?: number): string;
10
+ verify(token: string, recordId: string, personId: string, clientId: string, now?: number): "ok" | "expired" | "invalid";
11
+ }
12
+ /**
13
+ * The card. Self-contained: no network, no external script. It reads the
14
+ * request list from the tool output and the tokens from the tool's metadata,
15
+ * and calls `decide_request` through the host. In a host without that bridge it
16
+ * shows each request's approval page instead.
17
+ */
18
+ export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(decided[r.id]){status.className=decided[r.id].ok?\"done\":\"fail\";status.textContent=decided[r.id].text;a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){\n var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));\n var link=el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\");p.appendChild(link);a.appendChild(p);return a;\n }\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n var ok=out&&!out.error;\n decided[r.id]={ok:ok,text:ok?(out.summary||\"Recorded.\"):((out&&out.message)||\"Not recorded.\")};render();\n },function(err){btns.forEach(function(x){x.disabled=false;});status.className=\"fail\";status.textContent=\"Not recorded: \"+(err&&err.message?err.message:String(err));});\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * In-chat approval: a request is decided on a card rendered inside the client
3
+ * (ChatGPT and other MCP Apps hosts), by the person's own click.
4
+ *
5
+ * Two locks keep the model that asked from approving:
6
+ * - `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`),
7
+ * so the host does not offer it to the model;
8
+ * - it requires a decision token that travels only in the `_meta` of the
9
+ * `show_decisions` result, which hosts deliver to the card and keep from the
10
+ * model. The token is bound to one request, one person and one OAuth client,
11
+ * and expires. A request decided once is no longer awaiting approval, so a
12
+ * token cannot be used twice.
13
+ *
14
+ * The sign-in must also carry `miadi:approve`, which a person grants by ticking
15
+ * it, and Miadi must give them the write tier.
16
+ */
17
+ import { createHmac, timingSafeEqual } from "node:crypto";
18
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { newSecret } from "./store.js";
21
+ export const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
22
+ export const DECISION_TOKEN_TTL = 30 * 60;
23
+ /** Where the tokens travel in a result's `_meta`, keyed by record id. */
24
+ export const DECISION_TOKENS_META = "miadi/decisionTokens";
25
+ export class DecisionTokens {
26
+ secret;
27
+ constructor(stateDir) {
28
+ const file = join(stateDir, "decision-secret");
29
+ if (!existsSync(file))
30
+ writeFileSync(file, newSecret(32), { mode: 0o600, flag: "wx" });
31
+ this.secret = readFileSync(file, "utf8").trim();
32
+ }
33
+ sign(recordId, personId, clientId, exp) {
34
+ return createHmac("sha256", this.secret).update(`${recordId}\n${personId}\n${clientId}\n${exp}`).digest("base64url");
35
+ }
36
+ issue(recordId, personId, clientId, now = Math.floor(Date.now() / 1000)) {
37
+ const exp = now + DECISION_TOKEN_TTL;
38
+ return `${exp}.${this.sign(recordId, personId, clientId, exp)}`;
39
+ }
40
+ verify(token, recordId, personId, clientId, now = Math.floor(Date.now() / 1000)) {
41
+ const [expRaw, mac] = String(token ?? "").split(".");
42
+ const exp = Number(expRaw);
43
+ if (!Number.isInteger(exp) || !mac)
44
+ return "invalid";
45
+ const expected = Buffer.from(this.sign(recordId, personId, clientId, exp));
46
+ const given = Buffer.from(mac);
47
+ if (expected.length !== given.length || !timingSafeEqual(expected, given))
48
+ return "invalid";
49
+ return exp < now ? "expired" : "ok";
50
+ }
51
+ }
52
+ /**
53
+ * The card. Self-contained: no network, no external script. It reads the
54
+ * request list from the tool output and the tokens from the tool's metadata,
55
+ * and calls `decide_request` through the host. In a host without that bridge it
56
+ * shows each request's approval page instead.
57
+ */
58
+ export const DECISIONS_CARD_HTML = `<!doctype html>
59
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
60
+ <style>
61
+ :root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}
62
+ @media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}
63
+ *{box-sizing:border-box}
64
+ body{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}
65
+ main{padding:12px;display:grid;gap:12px}
66
+ .empty,.note{color:var(--muted)}
67
+ article{border:1px solid var(--line);border-radius:10px;padding:12px}
68
+ h2{font-size:15px;margin:0 0 4px}
69
+ .kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}
70
+ .meta{font-size:13px;color:var(--muted);margin:0 0 6px}
71
+ .does{margin:6px 0;font-weight:600}
72
+ details{margin:6px 0}
73
+ summary{cursor:pointer;color:var(--muted);font-size:13px}
74
+ pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}
75
+ textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}
76
+ .row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
77
+ button{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}
78
+ button.primary{border-color:var(--accent);outline:1px solid var(--accent)}
79
+ button:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
80
+ button[disabled]{opacity:.55;cursor:default}
81
+ a{color:inherit}
82
+ .done{color:var(--ok);font-weight:600}
83
+ .fail{color:var(--err);font-weight:600}
84
+ </style></head><body><main id="root" aria-live="polite"><p class="note">Loading requests…</p></main>
85
+ <script>
86
+ (function(){
87
+ var root=document.getElementById("root");
88
+ var decided={};
89
+ function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k==="class")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}
90
+ function host(){return window.openai||null;}
91
+ function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}
92
+ function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m["${DECISION_TOKENS_META}"])||{};}
93
+ function render(){
94
+ var d=data();root.textContent="";
95
+ if(!d){root.appendChild(el("p",{class:"note"},"Waiting for the request list."));return;}
96
+ var list=d.decisions||[];
97
+ if(!list.length){root.appendChild(el("p",{class:"empty"},d.message||"No request is waiting for a decision."));return;}
98
+ if(!d.can_decide_here&&d.reason){root.appendChild(el("p",{class:"note"},d.reason));}
99
+ var t=tokens();
100
+ list.forEach(function(r){root.appendChild(card(r,t[r.id]));});
101
+ }
102
+ function card(r,token){
103
+ var a=el("article",{"aria-label":r.title});
104
+ a.appendChild(el("span",{class:"kind"},r.kind_label||r.kind));
105
+ a.appendChild(el("h2",null,r.title));
106
+ a.appendChild(el("p",{class:"meta"},"Asked by "+r.by+(r.via?" through "+r.via:"")+" · "+new Date(r.created_at).toLocaleString()+(r.circle?" · "+r.circle+" circle":"")));
107
+ a.appendChild(el("p",{class:"does"},r.consequence));
108
+ if(r.intention)a.appendChild(el("p",null,r.intention));
109
+ var det=el("details");det.appendChild(el("summary",null,"What exactly is asked"));det.appendChild(el("pre",null,r.detail||""));a.appendChild(det);
110
+ var status=el("p",{role:"status"});
111
+ if(decided[r.id]){status.className=decided[r.id].ok?"done":"fail";status.textContent=decided[r.id].text;a.appendChild(status);return a;}
112
+ if(!token||!host()||!host().callTool){
113
+ var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));
114
+ var link=el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page");p.appendChild(link);a.appendChild(p);return a;
115
+ }
116
+ var ta=el("textarea",{"aria-label":"Instructions or changes (needed to approve with changes or send back)",placeholder:"Instructions or changes (needed to approve with changes or send back)"});
117
+ a.appendChild(ta);
118
+ var row=el("div",{class:"row"});
119
+ var buttons=[["approve","Approve","primary"],["approve_with_changes","Approve with my changes",""],["request_revision","Send back for revision",""],["decline","Decline",""]];
120
+ var btns=[];
121
+ buttons.forEach(function(b){var x=el("button",{type:"button",class:b[2]},b[1]);x.addEventListener("click",function(){go(b[0]);});btns.push(x);row.appendChild(x);});
122
+ a.appendChild(row);a.appendChild(status);
123
+ function go(choice){
124
+ var text=ta.value.trim();
125
+ if((choice==="approve_with_changes"||choice==="request_revision")&&!text){status.className="fail";status.textContent=choice==="request_revision"?"Say what to revise.":"Write the changes to apply.";ta.focus();return;}
126
+ btns.forEach(function(x){x.disabled=true;});status.className="note";status.textContent="Recording your decision…";
127
+ host().callTool("decide_request",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){
128
+ var out=(res&&(res.structuredContent||res.structured_content))||null;
129
+ if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}
130
+ var ok=out&&!out.error;
131
+ decided[r.id]={ok:ok,text:ok?(out.summary||"Recorded."):((out&&out.message)||"Not recorded.")};render();
132
+ },function(err){btns.forEach(function(x){x.disabled=false;});status.className="fail";status.textContent="Not recorded: "+(err&&err.message?err.message:String(err));});
133
+ }
134
+ return a;
135
+ }
136
+ window.addEventListener("openai:set_globals",render);
137
+ render();
138
+ })();
139
+ </script></body></html>`;
package/dist/oauth.d.ts CHANGED
@@ -16,6 +16,12 @@ import type { OAuthClientInformationFull, OAuthTokens, OAuthTokenRevocationReque
16
16
  import { JsonFile } from "./store.js";
17
17
  export declare const SCOPE_READ = "miadi:read";
18
18
  export declare const SCOPE_CONTRIBUTE = "miadi:contribute";
19
+ /**
20
+ * Decide requests on in-chat approval cards. Granted only when the person ticks
21
+ * it at sign-in and Miadi gives them the write tier; never to a person token
22
+ * used directly as Bearer.
23
+ */
24
+ export declare const SCOPE_APPROVE = "miadi:approve";
19
25
  export declare const SCOPES: string[];
20
26
  /** Who a Miadi person token belongs to, as Miadi itself answers. */
21
27
  export interface Person {
@@ -23,6 +29,8 @@ export interface Person {
23
29
  name: string;
24
30
  /** The Miadi person token, so calls to Miadi are made as this person. */
25
31
  token: string;
32
+ /** Miadi's API tier for this person, when the identify function knows it. */
33
+ api_tier?: string | null;
26
34
  }
27
35
  export type Identify = (token: string) => Promise<Person | null>;
28
36
  interface Pending {
package/dist/oauth.js CHANGED
@@ -3,7 +3,15 @@ import { escapeHtml } from "@miadi/node-service-kit/format";
3
3
  import { digest, newSecret } from "./store.js";
4
4
  export const SCOPE_READ = "miadi:read";
5
5
  export const SCOPE_CONTRIBUTE = "miadi:contribute";
6
- export const SCOPES = [SCOPE_READ, SCOPE_CONTRIBUTE];
6
+ /**
7
+ * Decide requests on in-chat approval cards. Granted only when the person ticks
8
+ * it at sign-in and Miadi gives them the write tier; never to a person token
9
+ * used directly as Bearer.
10
+ */
11
+ export const SCOPE_APPROVE = "miadi:approve";
12
+ export const SCOPES = [SCOPE_READ, SCOPE_CONTRIBUTE, SCOPE_APPROVE];
13
+ /** What a person token used directly as Bearer carries: no in-chat approval. */
14
+ const DIRECT_SCOPES = [SCOPE_READ, SCOPE_CONTRIBUTE];
7
15
  export const emptyOAuthState = () => ({ clients: {}, pending: {}, codes: {}, tokens: {} });
8
16
  const ACCESS_TTL = 8 * 3600;
9
17
  const REFRESH_TTL = 90 * 24 * 3600;
@@ -98,9 +106,14 @@ export class MiadiOAuthProvider {
98
106
  }));
99
107
  return;
100
108
  }
101
- let scopes = wanted(pending.params.scopes);
109
+ let scopes = wanted(pending.params.scopes).filter((x) => x !== SCOPE_APPROVE);
102
110
  if (body.contribute !== "on")
103
111
  scopes = scopes.filter((x) => x !== SCOPE_CONTRIBUTE);
112
+ // In-chat approval is opt-in, whatever the client asked for, and only for Miadi's write tier.
113
+ if (body.approve === "on" && person.api_tier === "write")
114
+ scopes.push(SCOPE_APPROVE);
115
+ if (!scopes.includes(SCOPE_READ))
116
+ scopes.unshift(SCOPE_READ);
104
117
  const code = newSecret(24);
105
118
  this.state.update((st) => {
106
119
  delete st.pending[body.request_id];
@@ -158,14 +171,14 @@ export class MiadiOAuthProvider {
158
171
  // A Miadi person token used directly (terminal clients).
159
172
  const cached = this.direct.get(digest(token));
160
173
  if (cached && cached.until > now()) {
161
- return info(token, "direct", SCOPES, cached.until, cached.person);
174
+ return info(token, "direct", DIRECT_SCOPES, cached.until, cached.person);
162
175
  }
163
176
  const person = await this.identify(token);
164
177
  if (!person)
165
178
  throw new InvalidTokenError("Unknown token");
166
179
  const until = now() + DIRECT_TTL;
167
180
  this.direct.set(digest(token), { person, until });
168
- return info(token, "direct", SCOPES, until, person);
181
+ return info(token, "direct", DIRECT_SCOPES, until, person);
169
182
  }
170
183
  async revokeToken(_client, request) {
171
184
  this.state.update((s) => {
@@ -250,6 +263,8 @@ ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
250
263
  <label for="person_token">Your Miadi person token</label>
251
264
  <input id="person_token" name="person_token" type="password" autocomplete="off" required>
252
265
  ${contribute ? '<label><input type="checkbox" name="contribute" checked> Allow contributions in my name</label>' : ""}
266
+ <label><input type="checkbox" name="approve"> Let me approve requests on cards inside this client</label>
267
+ <p>Only for people with Miadi's write tier. Each approval is your own click on a card; the assistant cannot approve for you.</p>
253
268
  <div class="row">
254
269
  <button class="primary" type="submit" name="decision" value="allow">Sign in</button>
255
270
  <button type="submit" name="decision" value="deny" formnovalidate>Refuse</button>
package/dist/records.d.ts CHANGED
@@ -40,8 +40,12 @@ export type RecordKind = (typeof RECORD_KINDS)[number];
40
40
  /** #816 handoff states. */
41
41
  export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
42
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"];
43
+ /**
44
+ * Approval of a request that a person must grant before anything runs.
45
+ * `revision_requested`: sent back to whoever asked, with the approver's
46
+ * instructions in the receipt; nothing of it ran.
47
+ */
48
+ export declare const APPROVAL_STATES: readonly ["awaiting_approval", "approved", "declined", "revision_requested", "not_required"];
45
49
  export type ApprovalState = (typeof APPROVAL_STATES)[number];
46
50
  export interface Actor {
47
51
  person_id: string;
package/dist/records.js CHANGED
@@ -63,8 +63,12 @@ export const RECORD_KINDS = [
63
63
  ];
64
64
  /** #816 handoff states. */
65
65
  export const HANDOFF_STATES = ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
66
- /** Approval of a request that a person must grant before anything runs. */
67
- export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "not_required"];
66
+ /**
67
+ * Approval of a request that a person must grant before anything runs.
68
+ * `revision_requested`: sent back to whoever asked, with the approver's
69
+ * instructions in the receipt; nothing of it ran.
70
+ */
71
+ export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "revision_requested", "not_required"];
68
72
  export function newRecordId(kind) {
69
73
  return `${kind}:${randomUUID()}`;
70
74
  }
package/dist/server.d.ts CHANGED
@@ -15,4 +15,4 @@ export declare function createConnectorApp(config: ConnectorConfig, deps: Connec
15
15
  provider: MiadiOAuthProvider;
16
16
  ledger: Ledger<ConnectorRecord>;
17
17
  };
18
- export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
18
+ export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and submit a revised request citing the old id in refs), or decline.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
package/dist/server.js CHANGED
@@ -16,6 +16,7 @@ import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUr
16
16
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
17
17
  import { MiadiOAuthProvider, SCOPES, emptyOAuthState } from "./oauth.js";
18
18
  import { registerApprovalRoutes } from "./approve.js";
19
+ import { DecisionTokens } from "./decisions.js";
19
20
  import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
20
21
  import { registerTools } from "./tools.js";
21
22
  import { MiadiClient } from "./miadi.js";
@@ -67,7 +68,7 @@ export function createConnectorApp(config, deps) {
67
68
  app.post("/authorize/confirm", express.urlencoded({ extended: false, limit: "16kb" }), (req, res, next) => {
68
69
  provider.confirm(req, res).catch(next);
69
70
  });
70
- registerApprovalRoutes(app, {
71
+ const approvalDeps = {
71
72
  config,
72
73
  ledger,
73
74
  miadi,
@@ -75,14 +76,16 @@ export function createConnectorApp(config, deps) {
75
76
  fetchImpl: deps.fetchImpl,
76
77
  factoryDir: deps.factoryDir,
77
78
  tmuxSessions: deps.tmuxSessions,
78
- });
79
+ };
80
+ registerApprovalRoutes(app, approvalDeps);
81
+ const decisionTokens = new DecisionTokens(config.stateDir);
79
82
  const auth = requireBearerAuth({
80
83
  verifier: provider,
81
84
  requiredScopes: ["miadi:read"],
82
85
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
83
86
  });
84
87
  const handle = async (req, res) => {
85
- const ctx = { config, miadi, reviews, ledger, oauthState, factoryDir: deps.factoryDir };
88
+ const ctx = { config, miadi, reviews, ledger, oauthState, factoryDir: deps.factoryDir, approval: approvalDeps, decisionTokens };
86
89
  const server = new McpServer({ name: "miadi", version: VERSION }, { instructions: INSTRUCTIONS });
87
90
  registerTools(server, ctx);
88
91
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
@@ -116,6 +119,7 @@ Start with get_capabilities: it says who you are signed in as, which tools are l
116
119
 
117
120
  Rules every tool keeps:
118
121
  - Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.
119
- - Requests (runs, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.
122
+ - Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and submit a revised request citing the old id in refs), or decline.
123
+ - A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.
120
124
  - An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.
121
125
  - Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.`;
package/dist/tools.d.ts CHANGED
@@ -5,6 +5,8 @@ import type { ReviewClient } from "./reviews.js";
5
5
  import type { JsonFile, Ledger } from "./store.js";
6
6
  import type { OAuthState } from "./oauth.js";
7
7
  import { type ConnectorRecord } from "./records.js";
8
+ import { type ApprovalDeps } from "./approve.js";
9
+ import { type DecisionTokens } from "./decisions.js";
8
10
  export interface ToolContext {
9
11
  config: ConnectorConfig;
10
12
  miadi: MiadiClient;
@@ -13,6 +15,9 @@ export interface ToolContext {
13
15
  oauthState: JsonFile<OAuthState>;
14
16
  /** The factory directory holding queue/ (default ~/.miadi/factory). */
15
17
  factoryDir?: string;
18
+ /** What the in-chat approval card needs to run a decided request. */
19
+ approval?: ApprovalDeps;
20
+ decisionTokens?: DecisionTokens;
16
21
  }
17
22
  export declare function registerTools(server: McpServer, ctx: ToolContext): void;
18
23
  /** What get_capabilities reports for each tool. */
package/dist/tools.js CHANGED
@@ -13,12 +13,14 @@
13
13
  import { z } from "zod";
14
14
  import { MiadiError } from "./miadi.js";
15
15
  import { reviewIdOf, videoIdOf } from "./reviews.js";
16
- import { SCOPE_CONTRIBUTE } from "./oauth.js";
16
+ import { SCOPE_APPROVE, SCOPE_CONTRIBUTE } from "./oauth.js";
17
17
  import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current, newRecordId, } from "./records.js";
18
18
  import { VERSION } from "./version.js";
19
- import { readQueueState } from "./approve.js";
19
+ import { DECISIONS, consequenceOf, decide, isApprovable, readQueueState } from "./approve.js";
20
+ import { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
20
21
  import { join } from "node:path";
21
22
  import { artefactVisibility, readArtefactWeave, saveResearchArtefact } from "@miadi/inquiry-weave";
23
+ import { ResearchUploads, UploadRefusal } from "./uploads.js";
22
24
  class ToolRefusal extends Error {
23
25
  code;
24
26
  detail;
@@ -56,6 +58,8 @@ function wrap(fn) {
56
58
  catch (error) {
57
59
  if (error instanceof ToolRefusal)
58
60
  return fail({ error: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) });
61
+ if (error instanceof UploadRefusal)
62
+ return fail({ error: "invalid_upload", message: error.message });
59
63
  if (error instanceof MiadiError) {
60
64
  const unavailable = error.status === 0;
61
65
  return fail({
@@ -102,6 +106,7 @@ export function registerTools(server, ctx) {
102
106
  });
103
107
  };
104
108
  const records = () => current(ledger.all());
109
+ const uploads = new ResearchUploads(join(config.stateDir, "uploads"));
105
110
  const touching = (ref) => records().filter((r) => r.refs.includes(ref) || r.evidence_refs.includes(ref) || r.id === ref);
106
111
  // ── reads ──────────────────────────────────────────────────────────────
107
112
  server.registerTool("get_capabilities", {
@@ -404,7 +409,7 @@ export function registerTools(server, ctx) {
404
409
  }));
405
410
  server.registerTool("get_run_result", {
406
411
  title: "Where a requested run stands",
407
- description: "A run request's approval, the brief queued for the factory intake once approved, whether the intake delivered it, and its receipt.",
412
+ description: "A request's decision (approved, approved with changes, sent back for revision, declined), the approver's instructions, the brief queued for the factory intake once approved, whether the intake delivered it, and its receipt.",
408
413
  inputSchema: { record_id: z.string().min(1) },
409
414
  annotations: READ,
410
415
  }, wrap(async ({ record_id }, extra) => {
@@ -415,7 +420,10 @@ export function registerTools(server, ctx) {
415
420
  return {
416
421
  record: r,
417
422
  approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
418
- queue: r.kind === "run_request" ? readQueueState(r, ctx.factoryDir) : undefined,
423
+ queue: r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined,
424
+ ...(r.approval_state === "revision_requested"
425
+ ? { revision: { instructions: r.completion_receipt?.instructions, next: `Submit a revised request that cites ${r.id} in refs.` } }
426
+ : {}),
419
427
  };
420
428
  }));
421
429
  // search / fetch: the pair ChatGPT uses for connectors in search and deep research.
@@ -674,7 +682,7 @@ export function registerTools(server, ctx) {
674
682
  }));
675
683
  server.registerTool("propose_work", {
676
684
  title: "Propose work to a circle",
677
- 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.",
685
+ description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. Nothing starts until a person decides. Approving it starts the work as a factory run, with any changes the person writes; the person may instead send it back for revision with instructions (approval_state revision_requested, instructions in completion_receipt): then submit a revised proposal citing the old id in refs.",
678
686
  inputSchema: {
679
687
  title: z.string().min(1).max(200),
680
688
  desired_outcome: z.string().min(1).max(6000),
@@ -877,10 +885,13 @@ export function registerTools(server, ctx) {
877
885
  // ── research on the inquiry shelf (jgwill/Miadi#879) ───────────────────
878
886
  server.registerTool("save_research", {
879
887
  title: "Save a research report on the inquiry shelf",
880
- description: "Save a finished research report (for example a Deep Research export) in your name: a new folder on Miadi's inquiry shelf holding the Markdown exactly as given, its inquiry issue, and the folder committed and pushed in the shelf's repository. Private to you unless shared with a circle; publishing goes through the public-inquiry route, never here. The same report saved again returns the folder already saved. Weave it into an episode afterwards with request_inquiry_episode.",
888
+ description: "Deliver a finished research report (a Deep Research report) to Miadi in your name, exactly as written: never summarised, shortened or rewritten, with its sources and citations. It is saved byte-for-byte in a new folder on Miadi's inquiry shelf, gets its inquiry issue, and is committed and pushed. A report longer than about 60,000 characters is sent in parts: call with part 1 and parts N, then with the upload_id returned and parts 2..N, each part the next slice of the text cut at a line end; the report is saved when the last part arrives. The answer gives bytes, sha256 and the report's first and last lines so you can confirm all of it arrived. Private to you unless shared with a circle; never published from here. The same report saved again returns the folder already saved. Weave it into an episode afterwards with request_inquiry_episode.",
881
889
  inputSchema: {
882
890
  title: z.string().min(1).max(200),
883
- markdown: z.string().min(1).max(1_000_000).describe("The whole report, as Markdown. Saved byte-for-byte."),
891
+ markdown: z.string().min(1).max(1_000_000).describe("The whole report, or this part of it, exactly as written."),
892
+ part: z.number().int().min(1).max(200).optional().describe("Which part this is, from 1, when the report is sent in parts."),
893
+ parts: z.number().int().min(1).max(200).optional().describe("How many parts the report is sent in."),
894
+ upload_id: z.string().max(80).optional().describe("Returned by part 1; give it with every later part."),
884
895
  refs: refs.describe("Records and sources the report answers: a connector inquiry id, a DOI or URL, owner/repo#n."),
885
896
  visibility: z.enum(["private", "circle"]).default("private"),
886
897
  circle: circle.optional().describe("The circle it is shared with, when visibility is circle."),
@@ -894,20 +905,32 @@ export function registerTools(server, ctx) {
894
905
  throw new ToolRefusal("unavailable", "This connector host has no inquiry shelf configured.");
895
906
  if (a.visibility === "circle" && !a.circle)
896
907
  throw new ToolRefusal("invalid_input", "Name the circle the report is shared with.");
908
+ let markdown = a.markdown;
909
+ let meta = { title: a.title, refs: a.refs, visibility: a.visibility, circle: a.circle };
910
+ const parts = a.parts ?? 1;
911
+ if (parts > 1 || a.upload_id) {
912
+ const got = uploads.receive(person.id, { upload_id: a.upload_id, part: a.part ?? 1, parts, text: a.markdown, meta });
913
+ if (!got.complete) {
914
+ return { upload_id: got.upload_id, received: got.received, missing: got.missing, bytes_so_far: got.bytes, next: `Send part ${got.missing[0]} of ${got.parts} with upload_id ${got.upload_id}.` };
915
+ }
916
+ markdown = got.text;
917
+ meta = got.meta;
918
+ }
897
919
  const saved = await saveResearchArtefact({
898
920
  inquiryRoot: config.inquiryRoot,
899
921
  repo: config.inquiryRepo,
900
- title: a.title,
901
- markdown: a.markdown,
922
+ title: meta.title,
923
+ markdown,
902
924
  owner: { person_id: person.id, name: person.name },
903
- visibility: a.visibility,
904
- circle: a.circle,
925
+ visibility: meta.visibility,
926
+ circle: meta.circle,
905
927
  via: "miadi-connector",
906
- refs: a.refs,
928
+ refs: meta.refs,
907
929
  });
930
+ const lines = markdown.split("\n").filter((l) => l.trim());
908
931
  const r = append("research_artifact", person, auth, {
909
- circle: a.circle ?? "stewardship",
910
- refs: [`inquiry-artefact:${saved.artefact}`, ...(saved.issue ? [saved.issue] : []), ...a.refs],
932
+ circle: meta.circle ?? "stewardship",
933
+ refs: [`inquiry-artefact:${saved.artefact}`, ...(saved.issue ? [saved.issue] : []), ...meta.refs],
911
934
  intention: a.intention,
912
935
  role: a.role,
913
936
  idempotency_key: a.idempotency_key ?? `research:${saved.sha256}`,
@@ -915,18 +938,23 @@ export function registerTools(server, ctx) {
915
938
  state: saved.shelf.state === "landed" ? "completed" : "blocked",
916
939
  next_action: "request_inquiry_episode to weave it into an episode or start one",
917
940
  body: {
918
- title: a.title,
941
+ title: meta.title,
919
942
  artefact: saved.artefact,
920
943
  issue: saved.issue,
921
944
  issue_url: saved.issue_url,
922
945
  sha256: saved.sha256,
923
946
  bytes: saved.bytes,
924
947
  visibility: saved.visibility,
925
- circle: a.circle,
948
+ circle: meta.circle,
926
949
  shelf: { state: saved.shelf.state, observed: saved.shelf.observed },
927
950
  },
928
951
  });
929
- return { ...r, saved: saved.status, research_url: `${config.miadiPublicUrl}/research` };
952
+ return {
953
+ ...r,
954
+ saved: saved.status,
955
+ received: { bytes: saved.bytes, sha256: saved.sha256, parts, first_line: lines[0]?.slice(0, 200), last_line: lines.at(-1)?.slice(0, 200) },
956
+ research_url: `${config.miadiPublicUrl}/research`,
957
+ };
930
958
  }));
931
959
  server.registerTool("request_inquiry_episode", {
932
960
  title: "Request that an inquiry be woven into an episode, or start one",
@@ -974,6 +1002,146 @@ export function registerTools(server, ctx) {
974
1002
  });
975
1003
  return { ...r, approval_url: approvalUrl(r.id) };
976
1004
  }));
1005
+ // ── decisions inside the conversation ─────────────────────────────────
1006
+ const KIND_LABEL = {
1007
+ run_request: "Agent run",
1008
+ work_proposal: "Work proposal",
1009
+ review_operation_request: "Review operation",
1010
+ deep_diver_request: "Deep Diver",
1011
+ review_episode_link_request: "Review into an episode",
1012
+ inquiry_episode_request: "Inquiry into an episode",
1013
+ session_completion_proposal: "Session ending",
1014
+ };
1015
+ const titleOf = (r) => {
1016
+ const b = r.body;
1017
+ const t = b.title ?? b.task ?? b.desired_outcome ?? b.reason ?? b.operation ?? b.artefact ?? r.intention ?? r.kind;
1018
+ const line = String(t).split("\n")[0].trim();
1019
+ return line.length > 120 ? `${line.slice(0, 117)}…` : line;
1020
+ };
1021
+ const detailOf = (r) => {
1022
+ const shown = { ...r.body };
1023
+ for (const k of ["markdown", "transcript"])
1024
+ if (typeof shown[k] === "string")
1025
+ shown[k] = `${shown[k].length} chars`;
1026
+ return JSON.stringify({ ...shown, refs: r.refs.length ? r.refs : undefined }, null, 2);
1027
+ };
1028
+ server.registerResource("miadi-decisions-card", DECISIONS_CARD_URI, { title: "Miadi decisions", description: "Requests awaiting a person's decision, decided by the person's own click.", mimeType: "text/html;profile=mcp-app" }, async () => ({
1029
+ contents: [
1030
+ {
1031
+ uri: DECISIONS_CARD_URI,
1032
+ mimeType: "text/html;profile=mcp-app",
1033
+ text: DECISIONS_CARD_HTML,
1034
+ _meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } }, "openai/widgetDescription": "Cards for the requests awaiting the person's decision." },
1035
+ },
1036
+ ],
1037
+ }));
1038
+ server.registerTool("show_decisions", {
1039
+ title: "Show the requests awaiting a decision",
1040
+ description: "Show the requests that await a person's decision (runs, work proposals, review operations, Deep Diver, episode links, session endings) as cards in the conversation. The person approves, approves with changes, sends back or declines each one with their own click; you cannot decide for them. Call it after making a request, or when the person asks what waits for them. Optionally pass record_id to show one request.",
1041
+ inputSchema: { record_id: z.string().optional() },
1042
+ annotations: READ,
1043
+ _meta: { ui: { resourceUri: DECISIONS_CARD_URI }, "openai/outputTemplate": DECISIONS_CARD_URI, "openai/toolInvocation/invoking": "Gathering requests", "openai/toolInvocation/invoked": "Requests shown" },
1044
+ }, async ({ record_id }, extra) => {
1045
+ try {
1046
+ const { person, auth } = personOf(extra);
1047
+ const writer = person.api_tier === "write";
1048
+ const waiting = records()
1049
+ .filter((r) => r.approval_state === "awaiting_approval" && isApprovable(r))
1050
+ .filter((r) => writer || r.actor.person_id === person.id)
1051
+ .filter((r) => !record_id || r.id === record_id)
1052
+ .slice(-20)
1053
+ .reverse();
1054
+ const canDecide = writer && auth.scopes.includes(SCOPE_APPROVE) && Boolean(ctx.decisionTokens);
1055
+ const reason = canDecide
1056
+ ? undefined
1057
+ : !writer
1058
+ ? `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}. The requests are listed with their approval pages.`
1059
+ : "This sign-in does not allow approving inside this client. Reconnect and tick \"Let me approve requests on cards inside this client\", or use each request's approval page.";
1060
+ const decisions = waiting.map((r) => ({
1061
+ id: r.id,
1062
+ kind: r.kind,
1063
+ kind_label: KIND_LABEL[r.kind] ?? r.kind,
1064
+ title: titleOf(r),
1065
+ by: r.actor.name,
1066
+ via: r.actor.client_name,
1067
+ created_at: r.created_at,
1068
+ circle: r.circle,
1069
+ intention: r.intention,
1070
+ consequence: consequenceOf(r),
1071
+ detail: detailOf(r),
1072
+ approval_url: approvalUrl(r.id),
1073
+ }));
1074
+ const tokens = canDecide ? Object.fromEntries(waiting.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId)])) : {};
1075
+ const structured = {
1076
+ decisions,
1077
+ count: decisions.length,
1078
+ can_decide_here: canDecide,
1079
+ ...(reason ? { reason } : {}),
1080
+ ...(decisions.length ? {} : { message: record_id ? "That request is not awaiting a decision." : "No request is waiting for a decision." }),
1081
+ };
1082
+ const text = decisions.length
1083
+ ? `${decisions.length} request(s) await a decision and are shown as cards. ${canDecide ? "The person decides each one on its card; do not decide or claim a decision for them." : reason}`
1084
+ : structured.message;
1085
+ return { structuredContent: structured, content: [{ type: "text", text }], _meta: { [DECISION_TOKENS_META]: tokens } };
1086
+ }
1087
+ catch (error) {
1088
+ if (error instanceof ToolRefusal)
1089
+ return fail({ error: error.code, message: error.message });
1090
+ return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
1091
+ }
1092
+ });
1093
+ server.registerTool("decide_request", {
1094
+ title: "Record the person's decision from a card",
1095
+ description: "Called by the decisions card when the person clicks Approve, Approve with my changes, Send back or Decline. Requires the card's decision token, which the assistant does not receive.",
1096
+ inputSchema: {
1097
+ record_id: z.string().min(1),
1098
+ decision: z.enum(DECISIONS),
1099
+ instructions: z.string().max(20000).optional(),
1100
+ decision_token: z.string().min(10).max(200),
1101
+ },
1102
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1103
+ _meta: { ui: { resourceUri: DECISIONS_CARD_URI, visibility: ["app"] }, "openai/widgetAccessible": true },
1104
+ }, async (a, extra) => {
1105
+ try {
1106
+ const { person, auth } = personOf(extra);
1107
+ if (!auth.scopes.includes(SCOPE_APPROVE))
1108
+ throw new ToolRefusal("insufficient_scope", "This sign-in does not allow approving inside this client.");
1109
+ requireTier(person, "write");
1110
+ if (!ctx.decisionTokens || !ctx.approval)
1111
+ throw new ToolRefusal("unavailable", "In-chat decisions are not configured on this connector.");
1112
+ const r = records().find((x) => x.id === a.record_id);
1113
+ if (!isApprovable(r))
1114
+ throw new ToolRefusal("not_found", "No request with that id.");
1115
+ const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId);
1116
+ if (check !== "ok")
1117
+ throw new ToolRefusal(check === "expired" ? "token_expired" : "invalid_token", check === "expired" ? "This card is too old; show the requests again." : "This decision did not come from the person's card.");
1118
+ if (r.approval_state !== "awaiting_approval")
1119
+ throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`);
1120
+ const instructions = a.instructions?.trim() || undefined;
1121
+ if ((a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
1122
+ throw new ToolRefusal("instructions_required", a.decision === "request_revision" ? "Say what to revise." : "Write the changes to apply.");
1123
+ }
1124
+ const client = ctx.oauthState.read().clients[auth.clientId];
1125
+ const { decision, state, receipt } = await decide(r, person, a.decision, ctx.approval, {
1126
+ instructions,
1127
+ via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
1128
+ });
1129
+ const summary = decision === "approved"
1130
+ ? `Approved by ${person.name}. ${receipt.outcome === "failed" ? "Running it failed; see the receipt." : `Outcome: ${String(receipt.outcome ?? "recorded").replace(/_/g, " ")}.`}`
1131
+ : decision === "revision_requested"
1132
+ ? `Sent back for revision by ${person.name}.`
1133
+ : `Declined by ${person.name}.`;
1134
+ const out = { record_id: r.id, decision, state, summary, receipt };
1135
+ return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1136
+ }
1137
+ catch (error) {
1138
+ if (error instanceof ToolRefusal) {
1139
+ const out = { error: error.code, message: error.message };
1140
+ return { isError: true, structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1141
+ }
1142
+ return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
1143
+ }
1144
+ });
977
1145
  }
978
1146
  function asState(e) {
979
1147
  if (e instanceof MiadiError)
@@ -1046,13 +1214,15 @@ export const TOOL_STATUS = {
1046
1214
  register_source_intent: "live, contribute",
1047
1215
  record_handoff: "live, contribute",
1048
1216
  update_handoff: "live, contribute",
1049
- propose_work: "live, contribute, needs_approval",
1217
+ propose_work: "live, contribute, needs_approval (approving starts the work as a factory run)",
1050
1218
  prepare_calendar_event: "live, contribute (draft and .ics only)",
1051
1219
  propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
1052
1220
  request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved)",
1053
1221
  request_review_operation: "live, contribute, needs_approval",
1054
1222
  request_deep_diver: "live, contribute, needs_approval",
1055
1223
  link_review_to_episode: "live, contribute, needs_approval",
1056
- save_research: "live, contribute (saves on the inquiry shelf, opens its issue, pushes; private by default)",
1224
+ save_research: "live, contribute (the whole report, in parts when long; saved on the inquiry shelf with its issue, pushed; private by default)",
1057
1225
  request_inquiry_episode: "live, contribute, needs_approval (weaves a shelf folder into an episode or starts one)",
1226
+ show_decisions: "live (cards in the conversation; deciding on them needs the write tier and the miadi:approve sign-in)",
1227
+ decide_request: "card only: the person's click on a decisions card; not offered to the assistant",
1058
1228
  };
@@ -0,0 +1,27 @@
1
+ export declare const MAX_UPLOAD_CHARS = 5000000;
2
+ export declare class UploadRefusal extends Error {
3
+ }
4
+ export declare class ResearchUploads {
5
+ private readonly dir;
6
+ constructor(dir: string);
7
+ private path;
8
+ receive(personId: string, input: {
9
+ upload_id?: string;
10
+ part: number;
11
+ parts: number;
12
+ text: string;
13
+ meta: Record<string, unknown>;
14
+ }): {
15
+ upload_id: string;
16
+ parts: number;
17
+ received: number[];
18
+ missing: number[];
19
+ bytes: number;
20
+ complete: false;
21
+ } | {
22
+ complete: true;
23
+ upload_id: string;
24
+ text: string;
25
+ meta: Record<string, unknown>;
26
+ };
27
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A research report sent in parts (save_research with part/parts): each part is
3
+ * kept under <state>/uploads/<id>.json until the last arrives, then the parts are
4
+ * joined in order, exactly, and the upload is removed. One person's upload is
5
+ * never readable by another.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ export const MAX_UPLOAD_CHARS = 5_000_000;
11
+ export class UploadRefusal extends Error {
12
+ }
13
+ export class ResearchUploads {
14
+ dir;
15
+ constructor(dir) {
16
+ this.dir = dir;
17
+ }
18
+ path(id) {
19
+ if (!/^upload-[0-9a-f-]{36}$/.test(id))
20
+ throw new UploadRefusal(`Unknown upload_id ${id}.`);
21
+ return join(this.dir, `${id}.json`);
22
+ }
23
+ receive(personId, input) {
24
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
25
+ let up;
26
+ if (input.upload_id) {
27
+ const p = this.path(input.upload_id);
28
+ if (!existsSync(p))
29
+ throw new UploadRefusal(`Unknown upload_id ${input.upload_id}: start again with part 1.`);
30
+ up = JSON.parse(readFileSync(p, "utf8"));
31
+ if (up.person_id !== personId)
32
+ throw new UploadRefusal(`Unknown upload_id ${input.upload_id}.`);
33
+ }
34
+ else {
35
+ if (input.part !== 1)
36
+ throw new UploadRefusal("Start with part 1; it returns the upload_id the later parts carry.");
37
+ up = { id: `upload-${randomUUID()}`, person_id: personId, parts: input.parts, received: {}, meta: input.meta, created_at: new Date().toISOString() };
38
+ }
39
+ if (input.part > up.parts)
40
+ throw new UploadRefusal(`This upload has ${up.parts} parts; part ${input.part} does not exist.`);
41
+ up.received[String(input.part)] = input.text;
42
+ const total = Object.values(up.received).reduce((n, t) => n + t.length, 0);
43
+ if (total > MAX_UPLOAD_CHARS)
44
+ throw new UploadRefusal(`The report passes ${MAX_UPLOAD_CHARS} characters.`);
45
+ const missing = Array.from({ length: up.parts }, (_, i) => i + 1).filter((n) => !(String(n) in up.received));
46
+ if (missing.length) {
47
+ writeFileSync(this.path(up.id), JSON.stringify(up), { mode: 0o600 });
48
+ return { upload_id: up.id, parts: up.parts, received: Object.keys(up.received).map(Number).sort((a, b) => a - b), missing, bytes: total, complete: false };
49
+ }
50
+ const text = Array.from({ length: up.parts }, (_, i) => up.received[String(i + 1)]).join("");
51
+ rmSync(this.path(up.id), { force: true });
52
+ return { complete: true, upload_id: up.id, text, meta: up.meta };
53
+ }
54
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.3";
1
+ export declare const VERSION = "0.1.5";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.3";
1
+ export const VERSION = "0.1.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/connector",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The Miadi connector: one remote MCP service through which a person signed in with their Miadi person token, from ChatGPT or any MCP client, reads sessions, reviews, episodes and circles and records observations, inquiries, handoffs and proposals with their name on each record.",
5
5
  "license": "MIT",
6
6
  "author": "Miadi",