@miadi/connector 0.1.4 → 0.1.6
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 +13 -1
- package/dist/approve.d.ts +18 -0
- package/dist/approve.js +76 -35
- package/dist/decisions.d.ts +18 -0
- package/dist/decisions.js +139 -0
- package/dist/oauth.d.ts +8 -0
- package/dist/oauth.js +19 -4
- package/dist/server.d.ts +1 -1
- package/dist/server.js +7 -4
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +146 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,10 +23,11 @@ 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
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.
|
|
29
|
-
- **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. 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
|
+
- **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;
|
|
31
32
|
- a work proposal starts its work the same way, as a brief written from the proposal;
|
|
32
33
|
- a review operation calls the review service;
|
|
@@ -37,6 +38,17 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
|
|
|
37
38
|
|
|
38
39
|
The model that asked cannot approve its own request.
|
|
39
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
|
+
|
|
40
52
|
Every record has a claim category: `observed`, `hypothesis`, `desired_future`, `fiction` or `verified_implementation`. Witnessed fact, envisioned features and fiction stay apart.
|
|
41
53
|
|
|
42
54
|
## Run it
|
package/dist/approve.d.ts
CHANGED
|
@@ -53,4 +53,22 @@ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson,
|
|
|
53
53
|
brief: string;
|
|
54
54
|
};
|
|
55
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;
|
|
56
74
|
export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
|
package/dist/approve.js
CHANGED
|
@@ -261,6 +261,79 @@ ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}
|
|
|
261
261
|
</dl>
|
|
262
262
|
<pre>${escapeHtml(JSON.stringify(shown, null, 2))}</pre>`;
|
|
263
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
|
+
}
|
|
264
337
|
export function registerApprovalRoutes(app, deps) {
|
|
265
338
|
const find = (id) => current(deps.ledger.all()).find((r) => r.id === id);
|
|
266
339
|
const form = express.urlencoded({ extended: false, limit: "128kb" });
|
|
@@ -298,42 +371,10 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
298
371
|
res.status(400).type("html").send(approvePage(r, choice === "request_revision" ? "Say what to revise." : "Write the changes to apply."));
|
|
299
372
|
return;
|
|
300
373
|
}
|
|
301
|
-
const decision
|
|
302
|
-
|
|
303
|
-
decided_by: { person_id: person.id, name: person.name },
|
|
304
|
-
decided_at: new Date().toISOString(),
|
|
305
|
-
decision: choice,
|
|
306
|
-
...(instructions ? { instructions } : {}),
|
|
374
|
+
const { decision, receipt } = await decide(r, person, choice, deps, {
|
|
375
|
+
instructions,
|
|
307
376
|
note: body.note || undefined,
|
|
308
|
-
|
|
309
|
-
let state = decision === "approved" ? "completed" : decision === "revision_requested" ? "pending" : "withdrawn";
|
|
310
|
-
if (decision === "approved") {
|
|
311
|
-
try {
|
|
312
|
-
const outcome = await executeApproved(r, person, deps, instructions);
|
|
313
|
-
receipt = { ...receipt, ...outcome };
|
|
314
|
-
if (outcome.outcome === "failed")
|
|
315
|
-
state = "blocked";
|
|
316
|
-
if (outcome.outcome === "queued")
|
|
317
|
-
state = "in_progress";
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
receipt = { ...receipt, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
321
|
-
state = "blocked";
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
deps.ledger.append({
|
|
325
|
-
id: newRecordId(r.kind),
|
|
326
|
-
kind: r.kind,
|
|
327
|
-
created_at: new Date().toISOString(),
|
|
328
|
-
actor: { person_id: person.id, name: person.name, client_id: String(body.via ?? "approval-page").slice(0, 60) },
|
|
329
|
-
refs: r.refs,
|
|
330
|
-
evidence_refs: [],
|
|
331
|
-
supersedes: r.id,
|
|
332
|
-
approval_state: decision,
|
|
333
|
-
state,
|
|
334
|
-
...(decision === "revision_requested" ? { next_action: `Revise and submit a new request that cites ${r.id} in refs.` } : {}),
|
|
335
|
-
completion_receipt: receipt,
|
|
336
|
-
body: { decision: choice, ...(instructions ? { instructions } : {}) },
|
|
377
|
+
via: String(body.via ?? "approval-page").slice(0, 60),
|
|
337
378
|
});
|
|
338
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>`));
|
|
339
380
|
});
|
|
@@ -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
|
-
|
|
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",
|
|
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",
|
|
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/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, 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 in
|
|
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
|
-
|
|
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,7 +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, 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 in
|
|
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.
|
|
120
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.
|
|
121
124
|
- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.
|
|
122
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,10 +13,11 @@
|
|
|
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";
|
|
22
23
|
import { ResearchUploads, UploadRefusal } from "./uploads.js";
|
|
@@ -1001,6 +1002,147 @@ export function registerTools(server, ctx) {
|
|
|
1001
1002
|
});
|
|
1002
1003
|
return { ...r, approval_url: approvalUrl(r.id) };
|
|
1003
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
|
+
// A card-only tool renders nothing itself: ChatGPT refuses a widget on a private tool.
|
|
1104
|
+
_meta: { ui: { visibility: ["app"] }, "openai/widgetAccessible": true },
|
|
1105
|
+
}, async (a, extra) => {
|
|
1106
|
+
try {
|
|
1107
|
+
const { person, auth } = personOf(extra);
|
|
1108
|
+
if (!auth.scopes.includes(SCOPE_APPROVE))
|
|
1109
|
+
throw new ToolRefusal("insufficient_scope", "This sign-in does not allow approving inside this client.");
|
|
1110
|
+
requireTier(person, "write");
|
|
1111
|
+
if (!ctx.decisionTokens || !ctx.approval)
|
|
1112
|
+
throw new ToolRefusal("unavailable", "In-chat decisions are not configured on this connector.");
|
|
1113
|
+
const r = records().find((x) => x.id === a.record_id);
|
|
1114
|
+
if (!isApprovable(r))
|
|
1115
|
+
throw new ToolRefusal("not_found", "No request with that id.");
|
|
1116
|
+
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId);
|
|
1117
|
+
if (check !== "ok")
|
|
1118
|
+
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.");
|
|
1119
|
+
if (r.approval_state !== "awaiting_approval")
|
|
1120
|
+
throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`);
|
|
1121
|
+
const instructions = a.instructions?.trim() || undefined;
|
|
1122
|
+
if ((a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
|
|
1123
|
+
throw new ToolRefusal("instructions_required", a.decision === "request_revision" ? "Say what to revise." : "Write the changes to apply.");
|
|
1124
|
+
}
|
|
1125
|
+
const client = ctx.oauthState.read().clients[auth.clientId];
|
|
1126
|
+
const { decision, state, receipt } = await decide(r, person, a.decision, ctx.approval, {
|
|
1127
|
+
instructions,
|
|
1128
|
+
via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
|
|
1129
|
+
});
|
|
1130
|
+
const summary = decision === "approved"
|
|
1131
|
+
? `Approved by ${person.name}. ${receipt.outcome === "failed" ? "Running it failed; see the receipt." : `Outcome: ${String(receipt.outcome ?? "recorded").replace(/_/g, " ")}.`}`
|
|
1132
|
+
: decision === "revision_requested"
|
|
1133
|
+
? `Sent back for revision by ${person.name}.`
|
|
1134
|
+
: `Declined by ${person.name}.`;
|
|
1135
|
+
const out = { record_id: r.id, decision, state, summary, receipt };
|
|
1136
|
+
return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1137
|
+
}
|
|
1138
|
+
catch (error) {
|
|
1139
|
+
if (error instanceof ToolRefusal) {
|
|
1140
|
+
const out = { error: error.code, message: error.message };
|
|
1141
|
+
return { isError: true, structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1142
|
+
}
|
|
1143
|
+
return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1004
1146
|
}
|
|
1005
1147
|
function asState(e) {
|
|
1006
1148
|
if (e instanceof MiadiError)
|
|
@@ -1082,4 +1224,6 @@ export const TOOL_STATUS = {
|
|
|
1082
1224
|
link_review_to_episode: "live, contribute, needs_approval",
|
|
1083
1225
|
save_research: "live, contribute (the whole report, in parts when long; saved on the inquiry shelf with its issue, pushed; private by default)",
|
|
1084
1226
|
request_inquiry_episode: "live, contribute, needs_approval (weaves a shelf folder into an episode or starts one)",
|
|
1227
|
+
show_decisions: "live (cards in the conversation; deciding on them needs the write tier and the miadi:approve sign-in)",
|
|
1228
|
+
decide_request: "card only: the person's click on a decisions card; not offered to the assistant",
|
|
1085
1229
|
};
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.6";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.6";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|