@miadi/connector 0.1.25 → 0.1.27
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 +10 -1
- package/dist/approve.d.ts +13 -0
- package/dist/approve.js +159 -3
- package/dist/captions.d.ts +7 -0
- package/dist/captions.js +2 -2
- package/dist/decisions.d.ts +4 -1
- package/dist/decisions.js +10 -1
- package/dist/records.d.ts +1 -1
- package/dist/records.js +1 -0
- package/dist/request-state.d.ts +1 -1
- package/dist/request-state.js +3 -1
- package/dist/server.js +3 -1
- package/dist/tools.js +66 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,7 +126,16 @@ The drafter gets two rounds: a report and a revision, twice. The third report's
|
|
|
126
126
|
|
|
127
127
|
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.
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
**An empty card in ChatGPT.** ChatGPT keeps the tool metadata it read when the connection was made or refreshed, and draws an empty box for a template it cannot read. Keep these true:
|
|
130
|
+
|
|
131
|
+
- A card change that breaks the template gets a new URI, and every earlier URI stays readable (`DECISIONS_CARD_EARLIER_URIS`, pinned by a test).
|
|
132
|
+
- After a change to a card or a tool, reconnect the connector in ChatGPT or select **Refresh** at chatgpt.com/plugins, then start a new conversation. Other clients do the same in their own settings.
|
|
133
|
+
- `journalctl --user -u miadi-connector | grep -E 'card_template_read|"show_decisions"'` says what a host asked for. A `show_decisions` line with no template read means the host drew from what it holds.
|
|
134
|
+
- While a card is empty, the request's `approval_url` decides the same request.
|
|
135
|
+
|
|
136
|
+
Earned 2026-11-06: 0.1.22 moved the card to `decisions-v2.html` and stopped serving v1, and the card stayed empty in the iOS app. After the person reconnected the connector, ChatGPT read v2 and a card approval went through.
|
|
137
|
+
|
|
138
|
+
**The approval page has two steps.** The first signs in and approves nothing. The second reads "Not approved yet: decide here" and holds the buttons. `page_post` in the journal records what each signed-in post carried, with `decision: null` for a sign-in.
|
|
130
139
|
|
|
131
140
|
A decision is recorded once. An approved run that calls another service (a review operation, Deep Diver, an episode door, a steer) is written to the ledger as approved and running before the call, so a restart can never offer the request for a second approval; a run the connector was recording when it stopped reads as failed and may be run again. A decision whose run takes longer than `MIADI_CONNECTOR_DECISION_WAIT_MS` is answered as recorded and running, and the card reads `get_run_result` every 3 seconds until the outcome is in. The same decision sent again (a host retrying a call, a second click, the page form sent twice) answers with the first one's receipt, marked `replayed`, and runs nothing. A different decision is refused as `decision_conflict`, with where the request stands. When the host gives up on a call, the card reads where the request stands before it offers the buttons again.
|
|
132
141
|
|
package/dist/approve.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface ApprovalDeps {
|
|
|
41
41
|
steeringRuntime?: RuntimeDeps;
|
|
42
42
|
/** Reads a video's captions for an approved create; without it a create stores no transcript (tests, other embedders). */
|
|
43
43
|
captions?: FetchCaptions;
|
|
44
|
+
/** The link a named person decides a consent request on; set where the decision secret is held. */
|
|
45
|
+
consentLink?: (recordId: string) => string;
|
|
44
46
|
/** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
|
|
45
47
|
log?: (event: Record<string, unknown>) => void;
|
|
46
48
|
}
|
|
@@ -170,6 +172,17 @@ export declare function isApprovable(r: ConnectorRecord | undefined): r is Conne
|
|
|
170
172
|
/** What approving a request does, in one sentence, for the card a person decides on. */
|
|
171
173
|
export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization, records?: ConnectorRecord[]): string;
|
|
172
174
|
export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
|
|
175
|
+
/**
|
|
176
|
+
* The page a person named in a consent request decides on. Their link is the door: a person who never
|
|
177
|
+
* signed in to Miadi can answer, and it can release only what the source's owner could already release.
|
|
178
|
+
* The ledger says the answer came through the link, which is what is known (jgwill/Miadi#925).
|
|
179
|
+
*/
|
|
180
|
+
export declare function registerConsentRoutes(app: Express, deps: ApprovalDeps, keyOk: (recordId: string, key: string) => boolean): void;
|
|
181
|
+
/** The email that carries a consent link: short, one question, the same words the page asks. */
|
|
182
|
+
export declare function consentMail(r: ConnectorRecord, link: string): {
|
|
183
|
+
subject: string;
|
|
184
|
+
text: string;
|
|
185
|
+
};
|
|
173
186
|
/** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
|
|
174
187
|
/**
|
|
175
188
|
* Where a request stands, as every surface of the connector reads it (request-state.ts):
|
package/dist/approve.js
CHANGED
|
@@ -277,6 +277,28 @@ export async function executeApproved(record, approver, deps, instructions) {
|
|
|
277
277
|
logDecision(deps, { phase: "transcript", record: record.id, video: b.video_id, outcome: transcript.outcome });
|
|
278
278
|
return { ...result, transcript };
|
|
279
279
|
}
|
|
280
|
+
case "participant_consent_request": {
|
|
281
|
+
// The named person said yes: their source is released, and a transcript held for it lands now, as the words they read.
|
|
282
|
+
const source = current(deps.ledger.all()).find((x) => x.kind === "source_intent" && x.body.video_id === b.video_id);
|
|
283
|
+
if (!source)
|
|
284
|
+
return { outcome: "failed", message: "The source this consent names is gone." };
|
|
285
|
+
if (source.body.readiness !== "released_for_review") {
|
|
286
|
+
deps.ledger.append({
|
|
287
|
+
id: newRecordId("source_intent"),
|
|
288
|
+
kind: "source_intent",
|
|
289
|
+
created_at: new Date().toISOString(),
|
|
290
|
+
actor: { person_id: approver.id, name: approver.name, client_id: "consent-link" },
|
|
291
|
+
refs: source.refs,
|
|
292
|
+
evidence_refs: [record.id],
|
|
293
|
+
supersedes: source.id,
|
|
294
|
+
state: "ready",
|
|
295
|
+
body: { ...source.body, readiness: "released_for_review", source_readiness_transition: true },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
await settleTranscripts(deps);
|
|
299
|
+
const created = current(deps.ledger.all()).find((x) => x.kind === "review_operation_request" && x.body.video_id === b.video_id && x.completion_receipt?.transcript);
|
|
300
|
+
return { outcome: "released", source_intent: source.id, ...(created ? { transcript: created.completion_receipt.transcript } : {}) };
|
|
301
|
+
}
|
|
280
302
|
case "deep_diver_request": {
|
|
281
303
|
const r = await reviewCall("PUT", `/api/reviews/${b.review_id}/diver`, { ready: true, version: b.version, by: plainName(approver.name) });
|
|
282
304
|
return { outcome: r.ok ? "marked_ready" : "failed", status: r.status, response: r.response };
|
|
@@ -586,12 +608,14 @@ export async function settleTranscripts(deps) {
|
|
|
586
608
|
if (waiting?.outcome !== "waiting_for_captions" && waiting?.outcome !== "held_source_withheld")
|
|
587
609
|
continue;
|
|
588
610
|
const b = r.body;
|
|
611
|
+
const consented = current(deps.ledger.all()).find((x) => x.kind === "participant_consent_request" && x.approval_state === "approved" && x.body.video_id === waiting.video_id);
|
|
612
|
+
const consentWords = consented && typeof consented.body.transcript === "string" ? consented.body.transcript : undefined;
|
|
589
613
|
const next = await transcriptStep({
|
|
590
614
|
video_id: waiting.video_id,
|
|
591
615
|
review_id: waiting.review_id ?? null,
|
|
592
616
|
// The three days start when the source is released, not when it was held.
|
|
593
617
|
since: waiting.outcome === "held_source_withheld" ? new Date().toISOString() : waiting.since,
|
|
594
|
-
...(b.transcript ? { supplied: b.transcript } : {}),
|
|
618
|
+
...(b.transcript ? { supplied: b.transcript } : consentWords ? { supplied: consentWords, source: `the YouTube captions ${String(consented.body.decider_name)} read and said yes to` } : {}),
|
|
595
619
|
}, transcriptIo(deps));
|
|
596
620
|
if (next.outcome === waiting.outcome)
|
|
597
621
|
continue;
|
|
@@ -788,6 +812,8 @@ export function consequenceOf(r, authorization, records = []) {
|
|
|
788
812
|
: "If YouTube has no captions yet, it tries again every 15 minutes for three days."}`;
|
|
789
813
|
}
|
|
790
814
|
return `Approving calls the review service once: ${String(b.operation ?? "operation").replace(/_/g, " ")}.`;
|
|
815
|
+
case "participant_consent_request":
|
|
816
|
+
return `Only ${b.decider_name ?? "the person it names"} decides this, on the link made for them. Nobody else can approve it. A yes releases the source and stores the words on the review, and a no keeps them private. Its page shows the email that carries the link, sent only when you press Send.`;
|
|
791
817
|
case "deep_diver_request":
|
|
792
818
|
return `Approving marks review version ${b.version ?? "?"} ready for Deep Diver, which queues its pass.`;
|
|
793
819
|
case "review_episode_link_request":
|
|
@@ -831,6 +857,8 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
831
857
|
}
|
|
832
858
|
if (!person)
|
|
833
859
|
return void res.status(401).type("html").send(signInToRead(r, "Miadi did not recognise that person token."));
|
|
860
|
+
// What the page was sent, for the journal: signing in and deciding look alike from the person's side (jgwill/Miadi#939).
|
|
861
|
+
logDecision(deps, { phase: "page_post", record: r.id, decision: String(body.decision ?? "") || null, approval_state: r.approval_state });
|
|
834
862
|
const writer = person.api_tier === "write";
|
|
835
863
|
if (!writer && r.actor.person_id !== person.id) {
|
|
836
864
|
return void res.status(403).type("html").send(signInToRead(r, `Reading and deciding this request needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
|
|
@@ -874,6 +902,32 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
874
902
|
}
|
|
875
903
|
if (!writer)
|
|
876
904
|
return void res.status(403).type("html").send(approvePage(deps, r, { error: `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.` }));
|
|
905
|
+
if (r.kind === "participant_consent_request" && body.decision === "send_consent_mail") {
|
|
906
|
+
// The admin read this email on the page and pressed Send: it goes once, to the address Miadi holds for the person.
|
|
907
|
+
const b = r.body;
|
|
908
|
+
const again = (status, error) => void res.status(status).type("html").send(approvePage(deps, find(r.id), { token, ...(error ? { error } : {}) }));
|
|
909
|
+
if (r.approval_state !== "awaiting_approval")
|
|
910
|
+
return again(409, "This request was already answered.");
|
|
911
|
+
if (!b.decider_person_id || !deps.consentLink)
|
|
912
|
+
return again(400, "This request names no Miadi person to mail.");
|
|
913
|
+
if (consentMailSent(deps, r.id))
|
|
914
|
+
return again(409, "This email was already sent.");
|
|
915
|
+
const mail = consentMail(r, deps.consentLink(r.id));
|
|
916
|
+
try {
|
|
917
|
+
await deps.miadi.request(token, "POST", `/api/identity/people/${encodeURIComponent(String(b.decider_person_id))}/mail`, mail, 30_000);
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
const answer = error instanceof MiadiError ? JSON.stringify(error.body).slice(0, 300) : error instanceof Error ? error.message : String(error);
|
|
921
|
+
logDecision(deps, { phase: "consent_mail", record: r.id, sent: false });
|
|
922
|
+
return again(502, `The email was not sent: ${answer}`);
|
|
923
|
+
}
|
|
924
|
+
deps.ledger.append({ id: newRecordId("observation"), kind: "observation", created_at: new Date().toISOString(), actor: { person_id: person.id, name: person.name, client_id: "approval-page" }, refs: [r.id], evidence_refs: [], circle: "continuity", claim_category: "observed", body: { consent_mail: { request: r.id, subject: mail.subject, sent_at: new Date().toISOString() } } });
|
|
925
|
+
logDecision(deps, { phase: "consent_mail", record: r.id, sent: true });
|
|
926
|
+
return again(200);
|
|
927
|
+
}
|
|
928
|
+
if (r.kind === "participant_consent_request") {
|
|
929
|
+
return void res.status(403).type("html").send(approvePage(deps, r, { token, error: `Only ${String(r.body.decider_name)} decides this, on the link made for them.` }));
|
|
930
|
+
}
|
|
877
931
|
if (body.decision === "rerun") {
|
|
878
932
|
let ran;
|
|
879
933
|
try {
|
|
@@ -970,6 +1024,101 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
970
1024
|
res.type("html").send(page("Miadi record", `<h1>${escapeHtml(r.kind)}</h1>${summary(r)}`));
|
|
971
1025
|
});
|
|
972
1026
|
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The page a person named in a consent request decides on. Their link is the door: a person who never
|
|
1029
|
+
* signed in to Miadi can answer, and it can release only what the source's owner could already release.
|
|
1030
|
+
* The ledger says the answer came through the link, which is what is known (jgwill/Miadi#925).
|
|
1031
|
+
*/
|
|
1032
|
+
export function registerConsentRoutes(app, deps, keyOk) {
|
|
1033
|
+
const form = express.urlencoded({ extended: false, limit: "64kb" });
|
|
1034
|
+
const opened = (req, res, key) => {
|
|
1035
|
+
const r = current(deps.ledger.all()).find((x) => x.id === String(req.params.id));
|
|
1036
|
+
res.set("X-Robots-Tag", "noindex");
|
|
1037
|
+
res.set("Referrer-Policy", "no-referrer");
|
|
1038
|
+
if (!r || r.kind !== "participant_consent_request" || !keyOk(r.id, key)) {
|
|
1039
|
+
res.status(404).type("html").send(page("Not found", "<h1>This link opens nothing</h1>"));
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
return r;
|
|
1043
|
+
};
|
|
1044
|
+
app.get("/consent/:id", (req, res) => {
|
|
1045
|
+
const key = String(req.query.k ?? "");
|
|
1046
|
+
const r = opened(req, res, key);
|
|
1047
|
+
if (r)
|
|
1048
|
+
res.type("html").send(consentPage(r, key));
|
|
1049
|
+
});
|
|
1050
|
+
app.post("/consent/:id", form, async (req, res) => {
|
|
1051
|
+
const body = (req.body ?? {});
|
|
1052
|
+
const key = String(body.k ?? "");
|
|
1053
|
+
const r = opened(req, res, key);
|
|
1054
|
+
if (!r)
|
|
1055
|
+
return;
|
|
1056
|
+
const choice = body.answer === "yes" ? "approve" : body.answer === "not_yet" ? "decline" : null;
|
|
1057
|
+
if (!choice)
|
|
1058
|
+
return void res.status(400).type("html").send(consentPage(r, key, "Choose one of the two buttons."));
|
|
1059
|
+
const b = r.body;
|
|
1060
|
+
const person = { id: String(b.decider_person_id ?? `named:${b.decider_name}`), name: String(b.decider_name), token: "", api_tier: null, permissions: [] };
|
|
1061
|
+
const words = String(body.words ?? "").trim().slice(0, 4000) || undefined;
|
|
1062
|
+
try {
|
|
1063
|
+
await settleWithin(decide(r, person, choice, deps, { instructions: words, via: "consent-link", expectedVersion: versionOf(r) }), decisionWaitOf(deps));
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
if (!(error instanceof AlreadyDecided))
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
res.type("html").send(consentPage(current(deps.ledger.all()).find((x) => x.id === r.id), key));
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/** The email that carries a consent link: short, one question, the same words the page asks. */
|
|
1073
|
+
export function consentMail(r, link) {
|
|
1074
|
+
const b = r.body;
|
|
1075
|
+
const asker = String(b.asked_by ?? r.actor.name);
|
|
1076
|
+
return {
|
|
1077
|
+
subject: `${asker} has one question for you`,
|
|
1078
|
+
text: [`${String(b.decider_name)},`, "", String(b.question), "", `Answer here, no sign-in needed: ${link}`, "", "The page shows the words first. Nothing is published unless you say yes."].join("\n"),
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
function consentMailSent(deps, recordId) {
|
|
1082
|
+
const sent = deps.ledger.all().find((x) => x.kind === "observation" && x.body.consent_mail?.request === recordId);
|
|
1083
|
+
return sent ? sent.created_at : null;
|
|
1084
|
+
}
|
|
1085
|
+
function consentMailSection(r, deps) {
|
|
1086
|
+
const b = r.body;
|
|
1087
|
+
const sentAt = consentMailSent(deps, r.id);
|
|
1088
|
+
if (sentAt)
|
|
1089
|
+
return `<p><strong>The email to ${escapeHtml(String(b.decider_name))} was sent</strong> ${escapeHtml(sentAt.slice(0, 16).replace("T", " "))} UTC.</p>`;
|
|
1090
|
+
if (!b.decider_person_id)
|
|
1091
|
+
return `<p>This request names no Miadi person, so there is no address to mail. Send the link yourself.</p>`;
|
|
1092
|
+
const mail = consentMail(r, deps.consentLink(r.id));
|
|
1093
|
+
return `<section aria-label="The email"><p><strong>The email to ${escapeHtml(String(b.decider_name))}, not sent yet.</strong> It goes to the address on their Miadi record.</p>
|
|
1094
|
+
<pre>Subject: ${escapeHtml(mail.subject)}\n\n${escapeHtml(mail.text)}</pre></section>`;
|
|
1095
|
+
}
|
|
1096
|
+
function consentPage(r, key, error) {
|
|
1097
|
+
const b = r.body;
|
|
1098
|
+
const name = String(b.decider_name);
|
|
1099
|
+
const link = (url) => `<a href="${escapeHtml(String(url))}" rel="noreferrer">${escapeHtml(String(url))}</a>`;
|
|
1100
|
+
const where = `<p>The video: ${link(`https://youtu.be/${b.video_id}`)}</p>${b.review_url ? `<p>Its review, already public: ${link(b.review_url)}</p>` : ""}`;
|
|
1101
|
+
const said = String(r.completion_receipt?.instructions ?? "");
|
|
1102
|
+
if (r.approval_state !== "awaiting_approval") {
|
|
1103
|
+
const yes = r.approval_state === "approved";
|
|
1104
|
+
return page("Your answer", `<h1>${yes ? "You said yes" : "You said not yet"}</h1>
|
|
1105
|
+
<p>${yes ? "The words are published beside the review." : "The words stay off the review."} Your answer is kept with your name. To change it, tell ${escapeHtml(r.actor.name)}.</p>
|
|
1106
|
+
${said ? `<blockquote>${escapeHtml(said)}</blockquote>` : ""}${where}`);
|
|
1107
|
+
}
|
|
1108
|
+
return page(`${name}, one question`, `<h1>${escapeHtml(name)}, one question</h1>
|
|
1109
|
+
<p><strong>${escapeHtml(String(b.question))}</strong></p>
|
|
1110
|
+
${where}
|
|
1111
|
+
<details><summary>Read the words${b.transcript_words ? ` (${escapeHtml(String(b.transcript_words))} words)` : ""}</summary><pre>${escapeHtml(String(b.transcript ?? ""))}</pre></details>
|
|
1112
|
+
${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
1113
|
+
<form method="post">
|
|
1114
|
+
<input type="hidden" name="k" value="${escapeHtml(key)}">
|
|
1115
|
+
<label for="w">Anything you want to say (optional)</label>
|
|
1116
|
+
<textarea id="w" name="words" rows="3"></textarea>
|
|
1117
|
+
<div class="row"><button class="primary" type="submit" name="answer" value="yes">Yes, publish the words</button>
|
|
1118
|
+
<button type="submit" name="answer" value="not_yet">Not yet</button></div>
|
|
1119
|
+
</form>
|
|
1120
|
+
<p class="muted">This asks only about the words. If the video or the review should change, say so in the box. Your answer is kept with your name, and not yet keeps the words off the review.</p>`);
|
|
1121
|
+
}
|
|
973
1122
|
/** A button that shows where a running request stands now, signed in with the token the person just used. */
|
|
974
1123
|
function refreshForm(token) {
|
|
975
1124
|
return `<form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}"><div class="row"><button class="primary" type="submit">Show where it stands now</button></div></form>`;
|
|
@@ -1082,7 +1231,8 @@ function approvePage(deps, r, o = {}) {
|
|
|
1082
1231
|
const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
|
|
1083
1232
|
const blocked = Boolean(authorization && !authorization.authorized);
|
|
1084
1233
|
// Signed in and able to decide: the decision comes before the record's details, and no line says it waits for someone else.
|
|
1085
|
-
const
|
|
1234
|
+
const asksSomeone = r.kind === "participant_consent_request";
|
|
1235
|
+
const canDecideHere = Boolean(actionable && o.token && !asksSomeone);
|
|
1086
1236
|
const heading = canDecideHere ? "Not approved yet: decide here" : open ? "A request awaiting a decision" : (projectionOf(r, deps)?.label ?? "A decided request");
|
|
1087
1237
|
const details = canDecideHere
|
|
1088
1238
|
? `<details><summary>What exactly is asked</summary>${summary(r, { intention: false })}</details>`
|
|
@@ -1091,12 +1241,17 @@ ${open ? draftReviewSection(r, deps.draftReview?.stored(r), false) : ""}
|
|
|
1091
1241
|
${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}`;
|
|
1092
1242
|
return page("Approve a Miadi request", `<h1>${escapeHtml(heading)}</h1>
|
|
1093
1243
|
${canDecideHere ? `<p>You are signed in. Nothing is approved until you press a button below.</p>` : ""}
|
|
1244
|
+
${asksSomeone && open && o.token && deps.consentLink ? consentMailSection(r, deps) : ""}
|
|
1245
|
+
${asksSomeone && open && (o.token || o.chooserToken) && deps.consentLink ? `<p><strong>The link to send ${escapeHtml(String(r.body.decider_name))}:</strong><br><a href="${escapeHtml(deps.consentLink(r.id))}" rel="noreferrer">${escapeHtml(deps.consentLink(r.id))}</a></p>` : ""}
|
|
1094
1246
|
${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization, current(deps.ledger.all())))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
|
|
1095
1247
|
${canDecideHere && r.intention ? `<p><strong>Intention:</strong> ${escapeHtml(r.intention)}</p>` : ""}
|
|
1096
1248
|
${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
|
|
1097
1249
|
${canDecideHere ? draftReviewSection(r, deps.draftReview?.stored(r), !blocked) : ""}
|
|
1098
1250
|
${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
|
|
1099
|
-
${
|
|
1251
|
+
${asksSomeone && open && o.token && deps.consentLink && r.body.decider_person_id && !consentMailSent(deps, r.id)
|
|
1252
|
+
? `<form method="post"><input type="hidden" name="person_token" value="${escapeHtml(o.token)}"><div class="row"><button class="primary" type="submit" name="decision" value="send_consent_mail">Send this email</button></div></form>`
|
|
1253
|
+
: ""}
|
|
1254
|
+
${canDecideHere
|
|
1100
1255
|
? `<form method="post">
|
|
1101
1256
|
<input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
|
|
1102
1257
|
<input type="hidden" name="version" value="${versionOf(r)}">
|
|
@@ -1208,6 +1363,7 @@ export const KIND_LABELS = {
|
|
|
1208
1363
|
run_request: "Agent run",
|
|
1209
1364
|
work_proposal: "Work proposal",
|
|
1210
1365
|
review_operation_request: "Review operation",
|
|
1366
|
+
participant_consent_request: "A person's consent",
|
|
1211
1367
|
deep_diver_request: "Deep Diver",
|
|
1212
1368
|
review_episode_link_request: "Review into an episode",
|
|
1213
1369
|
inquiry_episode_request: "Inquiry into an episode",
|
package/dist/captions.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface TranscriptIo {
|
|
|
38
38
|
}>;
|
|
39
39
|
now?: () => number;
|
|
40
40
|
}
|
|
41
|
+
/** The covered range in words a person reads: "00:02 to 33:03 of 62:38". */
|
|
42
|
+
export declare function coverage(a: {
|
|
43
|
+
first_ms: number;
|
|
44
|
+
last_ms: number;
|
|
45
|
+
duration_s?: number;
|
|
46
|
+
}): string;
|
|
41
47
|
/**
|
|
42
48
|
* YouTube's json3 captions as the plain `[mm:ss] text` paragraphs the review service parses into turns.
|
|
43
49
|
* The words stay as YouTube wrote them. The first line says what they are. Null when they hold no words.
|
|
@@ -63,6 +69,7 @@ export declare function transcriptStep(input: {
|
|
|
63
69
|
review_id: string | null;
|
|
64
70
|
since: string;
|
|
65
71
|
supplied?: string;
|
|
72
|
+
source?: string;
|
|
66
73
|
}, io: TranscriptIo): Promise<TranscriptReceipt>;
|
|
67
74
|
/** What a transcript receipt says on a request's card and approval page. */
|
|
68
75
|
export declare function transcriptLines(transcript: unknown): string[];
|
package/dist/captions.js
CHANGED
|
@@ -30,7 +30,7 @@ function clock(ms) {
|
|
|
30
30
|
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${String(m).padStart(2, "0")}:${s}`;
|
|
31
31
|
}
|
|
32
32
|
/** The covered range in words a person reads: "00:02 to 33:03 of 62:38". */
|
|
33
|
-
function coverage(a) {
|
|
33
|
+
export function coverage(a) {
|
|
34
34
|
return `${clock(a.first_ms)} to ${clock(a.last_ms)}${a.duration_s ? ` of ${clock(a.duration_s * 1000)}` : ""}`;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
@@ -157,7 +157,7 @@ export async function transcriptStep(input, io) {
|
|
|
157
157
|
return {
|
|
158
158
|
...base,
|
|
159
159
|
outcome: "stored",
|
|
160
|
-
source: supplied ? "supplied with the request" : `YouTube captions (${answer.track})
|
|
160
|
+
source: input.source ?? (supplied ? "supplied with the request" : `YouTube captions (${answer.track})`),
|
|
161
161
|
chars: text.length,
|
|
162
162
|
sha256: createHash("sha256").update(text).digest("hex"),
|
|
163
163
|
stored_at: new Date(now).toISOString(),
|
package/dist/decisions.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export declare class DecisionTokens {
|
|
|
14
14
|
private readonly secret;
|
|
15
15
|
constructor(stateDir: string, ttl?: number);
|
|
16
16
|
private sign;
|
|
17
|
+
/** The key in a consent link: it opens one request, and only for the person that request names (jgwill/Miadi#925). */
|
|
18
|
+
consentKey(recordId: string): string;
|
|
19
|
+
consentKeyOk(recordId: string, key: string): boolean;
|
|
17
20
|
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
18
21
|
issue(recordId: string, personId: string, clientId: string, now?: number, version?: number): string;
|
|
19
22
|
/** `stale`: the token is genuine but was minted for a version the request no longer holds. */
|
|
@@ -25,4 +28,4 @@ export declare class DecisionTokens {
|
|
|
25
28
|
* and calls `decide_request` through the host. In a host without that bridge it
|
|
26
29
|
* shows each request's approval page instead.
|
|
27
30
|
*/
|
|
28
|
-
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.std{border-top:1px solid var(--line);margin:8px 0;padding-top:8px;display:grid;gap:8px}\nh3{font-size:14px;margin:0;font-weight:600}\n.aspects{list-style:none;margin:4px 0;padding:0;display:grid;gap:14px}\n.asp{display:grid;grid-template-columns:16px 1fr;gap:6px;font-size:14px;overflow-wrap:anywhere}\n.asp .gl{margin-top:3px;color:var(--muted);display:block}\n.asp p{margin:1px 0}\n.asp .nm{color:var(--muted)}\n.asp .ask{font-weight:600}\n.asp details{margin:0}\n.asp details[open]{border-left:2px solid var(--accent);padding-left:8px;margin-top:2px}\n.asp summary{padding:8px 0}\n.who{display:flex;gap:8px;align-items:center}\n.who .settler{flex:1}\n.yours .settler{color:var(--fg);font-weight:600}\n.yours .gl{color:var(--accent)}\n.iconbtn{min-height:34px;width:34px;padding:0;display:inline-grid;place-items:center;flex:none;border-color:var(--accent);color:var(--accent)}\n.openL{display:none}\ndetails[open]>summary .openL{display:inline}\ndetails[open]>summary .closedL{display:none}\n.fact{margin:0;font-size:14px;overflow-wrap:anywhere}\n.code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}\n.small{font-size:13px;color:var(--muted);margin:0;overflow-wrap:anywhere}\nblockquote{margin:4px 0 0;padding-left:8px;border-left:2px solid var(--line);font-size:14px;overflow-wrap:anywhere}\nsummary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\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 // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\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 // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(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 return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n var SETTLED={drafter:\"The drafting agent can answer this.\",person:\"Only you can answer this. Write it under Instructions.\",bystander:\"Only the person deciding can answer this, in their instructions.\",local_agent:\"A local agent can settle this from the running system.\"};\n var ARROW='<svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 17V3\"/><path d=\"m6 11 6 6 6-6\"/><path d=\"M19 21H5\"/></svg>';\n var DASHED='<path d=\"M10.1 2.182a10 10 0 0 1 3.8 0\"/><path d=\"M13.9 21.818a10 10 0 0 1-3.8 0\"/><path d=\"M17.609 3.721a10 10 0 0 1 2.69 2.7\"/><path d=\"M2.182 13.9a10 10 0 0 1 0-3.8\"/><path d=\"M20.279 17.609a10 10 0 0 1-2.7 2.69\"/><path d=\"M21.818 10.1a10 10 0 0 1 0 3.8\"/><path d=\"M3.721 6.391a10 10 0 0 1 2.7-2.69\"/><path d=\"M6.391 20.279a10 10 0 0 1-2.69-2.7\"/>';var EYE='<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>';\n function glyph(label,paths){return '<svg role=\"img\" aria-label=\"'+label+'\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\">'+paths+'</svg>';}\n // A redraw rebuilds the card: what the person typed and which reasons they opened stay.\n var typed={};var whyOpen={};var boxes={};\n function stateLine(label,paths,build){var row=el(\"div\",{class:\"asp state\"});var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(label,paths);row.appendChild(g);var t=el(\"p\",{class:\"small\"});build(t);row.appendChild(t);return row;}\n // The mission standard's open aspects, for the person deciding: what only they can answer first, and no verdict (jgwill/Miadi#939).\n function standardBlock(r,token){\n var rv=r.draft_review;\n var canAnswer=Boolean(token&&host()&&host().callTool);\n var s=el(\"section\",{class:\"std\",\"aria-label\":\"The mission standard\"});\n var list=rv?(rv.needs_revision||[]).slice().sort(function(a,b){var p=(b.settled_by===\"person\"?1:0)-(a.settled_by===\"person\"?1:0);return p||String(a.aspect).localeCompare(String(b.aspect));}):[];\n var version=rv&&rv.standard?\" v\"+rv.standard.version:\"\";\n s.appendChild(el(\"h3\",null,list.length?list.length+\" open on the mission standard\"+version:\"The mission standard\"+version));\n if(r.delivery){\n var place=el(\"p\",{class:\"fact\"});place.appendChild(el(\"span\",{class:\"small\"},\"The result lands at: \"));var disputed=list.some(function(f){return f.aspect===\"S1\";});\n place.appendChild(el(\"span\",disputed?{class:\"small\"}:null,r.delivery));\n if(disputed)place.appendChild(el(\"span\",{class:\"small\"},\" (S1 below asks about this)\"));\n s.appendChild(place);\n }\n if(r.person_words){var w=el(\"div\");w.appendChild(el(\"p\",{class:\"small\"},\"The person's words, as the drafting agent quoted them:\"));w.appendChild(el(\"blockquote\",null,r.person_words));s.appendChild(w);}\n if(!rv){s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){t.textContent=\"No check ran on this version of the mission. Read it yourself.\";}));return s;}\n if(!list.length)s.appendChild(stateLine(\"Nothing flagged\",DASHED,function(t){t.textContent=\"Nothing flagged on the aspects checked.\";}));\n else{\n var ul=el(\"ul\",{class:\"aspects\"});\n list.forEach(function(f){\n var mine=f.settled_by===\"person\"&&canAnswer;\n var row=el(\"li\",{class:\"asp\"+(mine?\" yours\":\"\")});\n var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(\"Open aspect\",DASHED);row.appendChild(g);\n var t=el(\"div\");\n var name=el(\"p\",{class:\"nm\"});name.appendChild(el(\"span\",{class:\"code\"},f.aspect));name.appendChild(el(\"span\",null,\" \"+f.name));t.appendChild(name);\n t.appendChild(el(\"p\",{class:\"ask\"},f.ask));\n if(f.span&&f.span!==r.delivery)t.appendChild(el(\"p\",{class:\"small\"},\"The mission says: \"+f.span));\n if(f.note)t.appendChild(el(\"p\",{class:\"small\"},f.note));\n var who=el(\"div\",{class:\"who\"});\n who.appendChild(el(\"p\",{class:\"small settler\"},(f.settled_by===\"person\"&&!canAnswer?SETTLED.bystander:SETTLED[f.settled_by])||f.settled_by));\n if(mine){\n var go=el(\"button\",{type:\"button\",class:\"iconbtn\",\"aria-label\":\"Go to Instructions\",title:\"Go to Instructions\"});go.innerHTML=ARROW;\n go.addEventListener(\"click\",function(){var box=boxes[r.id];if(box){if(box.scrollIntoView)box.scrollIntoView({block:\"center\"});box.focus();}});\n who.appendChild(go);\n }\n t.appendChild(who);\n var key=r.id+\"/\"+f.aspect;\n var why=el(\"details\");if(whyOpen[key])why.setAttribute(\"open\",\"\");\n why.addEventListener(\"toggle\",function(){whyOpen[key]=why.open;});\n var sum=el(\"summary\");sum.appendChild(el(\"span\",{class:\"closedL\"},\"Why\"));sum.appendChild(el(\"span\",{class:\"openL\"},\"Hide why\"));why.appendChild(sum);\n why.appendChild(el(\"p\",{class:\"small\"},f.why));\n var by=String(f.checked_by||\"\").split(\" \")[0]===\"jev\"?\"Jev\":f.checked_by===\"code\"?\"a check in code\":\"the \"+f.checked_by;\n why.appendChild(el(\"p\",{class:\"small\"},\"Flagged by \"+by+\".\"));\n t.appendChild(why);\n row.appendChild(t);ul.appendChild(row);\n });\n s.appendChild(ul);\n }\n var open={};list.forEach(function(f){open[f.aspect]=true;});\n var unseen=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});\n var rest=unseen.filter(function(n){return !n.in_part;});\n unseen.filter(function(n){return n.in_part;}).forEach(function(n){\n s.appendChild(stateLine(\"Checked in part\",EYE,function(t){t.appendChild(el(\"span\",null,\"Checked in part: \"));t.appendChild(el(\"span\",{class:\"code\"},n.aspect));t.appendChild(el(\"span\",null,\" \"+n.name+\" (\"+n.in_part+\")\"));}));\n });\n if(rest.length)s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){\n t.appendChild(el(\"span\",null,\"No check covers these, so read the mission for them: \"));\n rest.forEach(function(n,i){if(i)t.appendChild(el(\"span\",null,\" \u00B7 \"));t.appendChild(el(\"span\",{class:\"code\"},n.aspect));t.appendChild(el(\"span\",null,\" \"+n.name));});\n }));\n if(canAnswer&&list.some(function(f){return f.settled_by===\"drafter\";}))s.appendChild(el(\"p\",{class:\"small\"},\"To put the drafting agent's questions to it, say what to revise under Instructions and send it back.\"));\n var refused=r.provider_authorization&&r.provider_authorization.authorized===false;\n if(canAnswer&&list.length&&!refused)s.appendChild(el(\"p\",{class:\"small\"},\"Approving as it stands starts the session, and the worker reads these questions with the task.\"));\n return s;\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 if(!decided[r.id]&&!pending[r.id]&&r.projection&&r.projection.stage===\"awaiting_decision\"&&(r.kind===\"run_request\"||r.kind===\"work_proposal\"||r.draft_review||r.delivery||r.person_words))a.appendChild(standardBlock(r,token));\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(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\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 ta.value=typed[r.id]||\"\";boxes[r.id]=ta;\n ta.addEventListener(\"input\",function(){typed[r.id]=ta.value;});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n // The ring on a mission would move with its report and read as a verdict, so a mission's buttons carry none (jgwill/Miadi#939).\n var mission=r.kind===\"run_request\"||r.kind===\"work_proposal\";\n var buttons=[[\"approve\",\"Approve\",mission?\"\":\"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 if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\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\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
|
|
31
|
+
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.std{border-top:1px solid var(--line);margin:8px 0;padding-top:8px;display:grid;gap:8px}\nh3{font-size:14px;margin:0;font-weight:600}\n.aspects{list-style:none;margin:4px 0;padding:0;display:grid;gap:14px}\n.asp{display:grid;grid-template-columns:16px 1fr;gap:6px;font-size:14px;overflow-wrap:anywhere}\n.asp .gl{margin-top:3px;color:var(--muted);display:block}\n.asp p{margin:1px 0}\n.asp .nm{color:var(--muted)}\n.asp .ask{font-weight:600}\n.asp details{margin:0}\n.asp details[open]{border-left:2px solid var(--accent);padding-left:8px;margin-top:2px}\n.asp summary{padding:8px 0}\n.who{display:flex;gap:8px;align-items:center}\n.who .settler{flex:1}\n.yours .settler{color:var(--fg);font-weight:600}\n.yours .gl{color:var(--accent)}\n.iconbtn{min-height:34px;width:34px;padding:0;display:inline-grid;place-items:center;flex:none;border-color:var(--accent);color:var(--accent)}\n.openL{display:none}\ndetails[open]>summary .openL{display:inline}\ndetails[open]>summary .closedL{display:none}\n.fact{margin:0;font-size:14px;overflow-wrap:anywhere}\n.code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}\n.small{font-size:13px;color:var(--muted);margin:0;overflow-wrap:anywhere}\nblockquote{margin:4px 0 0;padding-left:8px;border-left:2px solid var(--line);font-size:14px;overflow-wrap:anywhere}\nsummary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\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 // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\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 // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(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 return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n var SETTLED={drafter:\"The drafting agent can answer this.\",person:\"Only you can answer this. Write it under Instructions.\",bystander:\"Only the person deciding can answer this, in their instructions.\",local_agent:\"A local agent can settle this from the running system.\"};\n var ARROW='<svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 17V3\"/><path d=\"m6 11 6 6 6-6\"/><path d=\"M19 21H5\"/></svg>';\n var DASHED='<path d=\"M10.1 2.182a10 10 0 0 1 3.8 0\"/><path d=\"M13.9 21.818a10 10 0 0 1-3.8 0\"/><path d=\"M17.609 3.721a10 10 0 0 1 2.69 2.7\"/><path d=\"M2.182 13.9a10 10 0 0 1 0-3.8\"/><path d=\"M20.279 17.609a10 10 0 0 1-2.7 2.69\"/><path d=\"M21.818 10.1a10 10 0 0 1 0 3.8\"/><path d=\"M3.721 6.391a10 10 0 0 1 2.7-2.69\"/><path d=\"M6.391 20.279a10 10 0 0 1-2.69-2.7\"/>';var EYE='<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>';\n function glyph(label,paths){return '<svg role=\"img\" aria-label=\"'+label+'\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\">'+paths+'</svg>';}\n // A redraw rebuilds the card: what the person typed and which reasons they opened stay.\n var typed={};var whyOpen={};var boxes={};\n function stateLine(label,paths,build){var row=el(\"div\",{class:\"asp state\"});var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(label,paths);row.appendChild(g);var t=el(\"p\",{class:\"small\"});build(t);row.appendChild(t);return row;}\n // The mission standard's open aspects, for the person deciding: what only they can answer first, and no verdict (jgwill/Miadi#939).\n function standardBlock(r,token){\n var rv=r.draft_review;\n var canAnswer=Boolean(token&&host()&&host().callTool);\n var s=el(\"section\",{class:\"std\",\"aria-label\":\"The mission standard\"});\n var list=rv?(rv.needs_revision||[]).slice().sort(function(a,b){var p=(b.settled_by===\"person\"?1:0)-(a.settled_by===\"person\"?1:0);return p||String(a.aspect).localeCompare(String(b.aspect));}):[];\n var version=rv&&rv.standard?\" v\"+rv.standard.version:\"\";\n s.appendChild(el(\"h3\",null,list.length?list.length+\" open on the mission standard\"+version:\"The mission standard\"+version));\n if(r.delivery){\n var place=el(\"p\",{class:\"fact\"});place.appendChild(el(\"span\",{class:\"small\"},\"The result lands at: \"));var disputed=list.some(function(f){return f.aspect===\"S1\";});\n place.appendChild(el(\"span\",disputed?{class:\"small\"}:null,r.delivery));\n if(disputed)place.appendChild(el(\"span\",{class:\"small\"},\" (S1 below asks about this)\"));\n s.appendChild(place);\n }\n if(r.person_words){var w=el(\"div\");w.appendChild(el(\"p\",{class:\"small\"},\"The person's words, as the drafting agent quoted them:\"));w.appendChild(el(\"blockquote\",null,r.person_words));s.appendChild(w);}\n if(!rv){s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){t.textContent=\"No check ran on this version of the mission. Read it yourself.\";}));return s;}\n if(!list.length)s.appendChild(stateLine(\"Nothing flagged\",DASHED,function(t){t.textContent=\"Nothing flagged on the aspects checked.\";}));\n else{\n var ul=el(\"ul\",{class:\"aspects\"});\n list.forEach(function(f){\n var mine=f.settled_by===\"person\"&&canAnswer;\n var row=el(\"li\",{class:\"asp\"+(mine?\" yours\":\"\")});\n var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(\"Open aspect\",DASHED);row.appendChild(g);\n var t=el(\"div\");\n var name=el(\"p\",{class:\"nm\"});name.appendChild(el(\"span\",{class:\"code\"},f.aspect));name.appendChild(el(\"span\",null,\" \"+f.name));t.appendChild(name);\n t.appendChild(el(\"p\",{class:\"ask\"},f.ask));\n if(f.span&&f.span!==r.delivery)t.appendChild(el(\"p\",{class:\"small\"},\"The mission says: \"+f.span));\n if(f.note)t.appendChild(el(\"p\",{class:\"small\"},f.note));\n var who=el(\"div\",{class:\"who\"});\n who.appendChild(el(\"p\",{class:\"small settler\"},(f.settled_by===\"person\"&&!canAnswer?SETTLED.bystander:SETTLED[f.settled_by])||f.settled_by));\n if(mine){\n var go=el(\"button\",{type:\"button\",class:\"iconbtn\",\"aria-label\":\"Go to Instructions\",title:\"Go to Instructions\"});go.innerHTML=ARROW;\n go.addEventListener(\"click\",function(){var box=boxes[r.id];if(box){if(box.scrollIntoView)box.scrollIntoView({block:\"center\"});box.focus();}});\n who.appendChild(go);\n }\n t.appendChild(who);\n var key=r.id+\"/\"+f.aspect;\n var why=el(\"details\");if(whyOpen[key])why.setAttribute(\"open\",\"\");\n why.addEventListener(\"toggle\",function(){whyOpen[key]=why.open;});\n var sum=el(\"summary\");sum.appendChild(el(\"span\",{class:\"closedL\"},\"Why\"));sum.appendChild(el(\"span\",{class:\"openL\"},\"Hide why\"));why.appendChild(sum);\n why.appendChild(el(\"p\",{class:\"small\"},f.why));\n var by=String(f.checked_by||\"\").split(\" \")[0]===\"jev\"?\"Jev\":f.checked_by===\"code\"?\"a check in code\":\"the \"+f.checked_by;\n why.appendChild(el(\"p\",{class:\"small\"},\"Flagged by \"+by+\".\"));\n t.appendChild(why);\n row.appendChild(t);ul.appendChild(row);\n });\n s.appendChild(ul);\n }\n var open={};list.forEach(function(f){open[f.aspect]=true;});\n var unseen=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});\n var rest=unseen.filter(function(n){return !n.in_part;});\n unseen.filter(function(n){return n.in_part;}).forEach(function(n){\n s.appendChild(stateLine(\"Checked in part\",EYE,function(t){t.appendChild(el(\"span\",null,\"Checked in part: \"));t.appendChild(el(\"span\",{class:\"code\"},n.aspect));t.appendChild(el(\"span\",null,\" \"+n.name+\" (\"+n.in_part+\")\"));}));\n });\n if(rest.length)s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){\n t.appendChild(el(\"span\",null,\"No check covers these, so read the mission for them: \"));\n rest.forEach(function(n,i){if(i)t.appendChild(el(\"span\",null,\" \u00B7 \"));t.appendChild(el(\"span\",{class:\"code\"},n.aspect));t.appendChild(el(\"span\",null,\" \"+n.name));});\n }));\n if(canAnswer&&list.some(function(f){return f.settled_by===\"drafter\";}))s.appendChild(el(\"p\",{class:\"small\"},\"To put the drafting agent's questions to it, say what to revise under Instructions and send it back.\"));\n var refused=r.provider_authorization&&r.provider_authorization.authorized===false;\n if(canAnswer&&list.length&&!refused)s.appendChild(el(\"p\",{class:\"small\"},\"Approving as it stands starts the session, and the worker reads these questions with the task.\"));\n return s;\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 if(!decided[r.id]&&!pending[r.id]&&r.projection&&r.projection.stage===\"awaiting_decision\"&&(r.kind===\"run_request\"||r.kind===\"work_proposal\"||r.draft_review||r.delivery||r.person_words))a.appendChild(standardBlock(r,token));\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(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(r.kind===\"participant_consent_request\"?\"Only the person it asks decides this. Its page: \":\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\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 ta.value=typed[r.id]||\"\";boxes[r.id]=ta;\n ta.addEventListener(\"input\",function(){typed[r.id]=ta.value;});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n // The ring on a mission would move with its report and read as a verdict, so a mission's buttons carry none (jgwill/Miadi#939).\n var mission=r.kind===\"run_request\"||r.kind===\"work_proposal\";\n var buttons=[[\"approve\",\"Approve\",mission?\"\":\"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 if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\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\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
|
package/dist/decisions.js
CHANGED
|
@@ -50,6 +50,15 @@ export class DecisionTokens {
|
|
|
50
50
|
const v = version === undefined ? "" : `\nv${version}`;
|
|
51
51
|
return createHmac("sha256", this.secret).update(`${recordId}\n${personId}\n${clientId}\n${exp}${v}`).digest("base64url");
|
|
52
52
|
}
|
|
53
|
+
/** The key in a consent link: it opens one request, and only for the person that request names (jgwill/Miadi#925). */
|
|
54
|
+
consentKey(recordId) {
|
|
55
|
+
return createHmac("sha256", this.secret).update(`consent\n${recordId}`).digest("base64url");
|
|
56
|
+
}
|
|
57
|
+
consentKeyOk(recordId, key) {
|
|
58
|
+
const expected = Buffer.from(this.consentKey(recordId));
|
|
59
|
+
const given = Buffer.from(String(key ?? ""));
|
|
60
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
61
|
+
}
|
|
53
62
|
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
54
63
|
issue(recordId, personId, clientId, now = Math.floor(Date.now() / 1000), version = 1) {
|
|
55
64
|
const exp = now + this.ttl;
|
|
@@ -284,7 +293,7 @@ summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
|
284
293
|
if(errors[r.id])a.appendChild(el("p",{class:"fail",role:"status"},errors[r.id]));
|
|
285
294
|
return a;
|
|
286
295
|
}
|
|
287
|
-
function approvalLink(){var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));p.appendChild(el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page"));return p;}
|
|
296
|
+
function approvalLink(){var p=el("p",{class:"note"});p.appendChild(document.createTextNode(r.kind==="participant_consent_request"?"Only the person it asks decides this. Its page: ":"Decide on its approval page: "));p.appendChild(el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page"));return p;}
|
|
288
297
|
if(pending[r.id]){status.className="note";status.textContent="Recording your decision…";a.appendChild(status);return a;}
|
|
289
298
|
if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}
|
|
290
299
|
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)"});
|
package/dist/records.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare const DOMAINS: readonly ["creative", "ceremonial", "engineering"]
|
|
|
36
36
|
/** #815 "claim category": what kind of statement a record makes. */
|
|
37
37
|
export declare const CLAIM_CATEGORIES: readonly ["observed", "hypothesis", "desired_future", "fiction", "verified_implementation"];
|
|
38
38
|
export type ClaimCategory = (typeof CLAIM_CATEGORIES)[number];
|
|
39
|
-
export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request", "session_steer_request", "steering_revocation", "provider_choice", "provider_policy"];
|
|
39
|
+
export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "participant_consent_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request", "session_steer_request", "steering_revocation", "provider_choice", "provider_policy"];
|
|
40
40
|
export type RecordKind = (typeof RECORD_KINDS)[number];
|
|
41
41
|
/** #816 handoff states. */
|
|
42
42
|
export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
|
package/dist/records.js
CHANGED
package/dist/request-state.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConnectorRecord } from "./records.js";
|
|
2
|
-
export declare const APPROVABLE_KINDS: readonly ["run_request", "review_operation_request", "deep_diver_request", "review_episode_link_request", "work_proposal", "session_completion_proposal", "inquiry_episode_request", "session_steer_request"];
|
|
2
|
+
export declare const APPROVABLE_KINDS: readonly ["run_request", "review_operation_request", "participant_consent_request", "deep_diver_request", "review_episode_link_request", "work_proposal", "session_completion_proposal", "inquiry_episode_request", "session_steer_request"];
|
|
3
3
|
export declare const APPROVABLE: ReadonlySet<string>;
|
|
4
4
|
/** `recording`: a decision was taken a moment ago and its run is still going (in memory, or written ahead as outcome `running`). */
|
|
5
5
|
export type RequestStage = "awaiting_decision" | "recording" | "declined" | "sent_back" | "dispatching" | "completed" | "failed";
|
package/dist/request-state.js
CHANGED
|
@@ -22,6 +22,7 @@ import { transcriptLines } from "./captions.js";
|
|
|
22
22
|
export const APPROVABLE_KINDS = [
|
|
23
23
|
"run_request",
|
|
24
24
|
"review_operation_request",
|
|
25
|
+
"participant_consent_request",
|
|
25
26
|
"deep_diver_request",
|
|
26
27
|
"review_episode_link_request",
|
|
27
28
|
"work_proposal",
|
|
@@ -49,6 +50,7 @@ const OUTCOME_WORDS = {
|
|
|
49
50
|
minted_and_woven: "a new episode minted with it",
|
|
50
51
|
marked_ready: "marked ready for Deep Diver",
|
|
51
52
|
decided: "recorded",
|
|
53
|
+
released: "the source is released",
|
|
52
54
|
nothing_to_run: "nothing to run",
|
|
53
55
|
accepted: "typed into an idle session, which turned busy; its delivery says whether the model read it",
|
|
54
56
|
queued_at_runtime: "handed to the session's inbox, on its way to the model",
|
|
@@ -126,7 +128,7 @@ function projectCore(record, ctx = {}) {
|
|
|
126
128
|
}
|
|
127
129
|
if (record.approval_state === "awaiting_approval") {
|
|
128
130
|
return finish({ ...base, stage: "awaiting_decision", standing: "waiting", label: "Awaiting a decision" }, [
|
|
129
|
-
"Waiting for a person with Miadi's write tier to decide.",
|
|
131
|
+
record.kind === "participant_consent_request" ? `Waiting for ${text(record.body.decider_name)} to decide.` : "Waiting for a person with Miadi's write tier to decide.",
|
|
130
132
|
]);
|
|
131
133
|
}
|
|
132
134
|
if (record.approval_state === "declined") {
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
5
5
|
import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
6
6
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
7
|
import { MiadiOAuthProvider, SCOPES, SCOPE_CONTRIBUTE, SCOPE_READ, emptyOAuthState } from "./oauth.js";
|
|
8
|
-
import { logDecision, reconcileInterrupted, registerApprovalRoutes, registerProviderPolicyRoutes, startTranscriptSettling } from "./approve.js";
|
|
8
|
+
import { logDecision, reconcileInterrupted, registerApprovalRoutes, registerConsentRoutes, registerProviderPolicyRoutes, startTranscriptSettling } from "./approve.js";
|
|
9
9
|
import { DecisionTokens } from "./decisions.js";
|
|
10
10
|
import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
|
|
11
11
|
import { registerTools } from "./tools.js";
|
|
@@ -89,6 +89,8 @@ export function createConnectorApp(config, deps) {
|
|
|
89
89
|
registerApprovalRoutes(app, approvalDeps);
|
|
90
90
|
registerProviderPolicyRoutes(app, approvalDeps);
|
|
91
91
|
const decisionTokens = new DecisionTokens(config.stateDir, config.decisionTokenTtl);
|
|
92
|
+
approvalDeps.consentLink = (id) => `${config.publicUrl}/consent/${encodeURIComponent(id)}?k=${decisionTokens.consentKey(id)}`;
|
|
93
|
+
registerConsentRoutes(app, approvalDeps, (id, key) => decisionTokens.consentKeyOk(id, key));
|
|
92
94
|
const bearer = requireBearerAuth({
|
|
93
95
|
verifier: provider,
|
|
94
96
|
requiredScopes: [SCOPE_READ],
|
package/dist/tools.js
CHANGED
|
@@ -18,6 +18,7 @@ import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current
|
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
19
19
|
import { AlreadyDecided, DECISIONS, DecisionConflict, KIND_LABELS, ProviderChoiceRequired, STILL_RUNNING, StaleVersion, approvalStateOf, consequenceOf, decide, decisionWaitOf, isApprovable, isBeingDecided, logDecision, readQueueState, requestProjection, settleWithin, } from "./approve.js";
|
|
20
20
|
import { RevisionRefused, requestHistory, revisionRecord, supersedeRecords, versionOf } from "./revisions.js";
|
|
21
|
+
import { coverage } from "./captions.js";
|
|
21
22
|
import { DECISIONS_CARD_EARLIER_URIS, DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
|
|
22
23
|
import { discoverProviders, jevConfigOf, resolveProvider, routerPreflight, PROVIDERS } from "./providers.js";
|
|
23
24
|
import { authorizesActions, missionStatus, primaryIssueOf, resolveMissionSubject } from "./mission-status.js";
|
|
@@ -1222,6 +1223,12 @@ export function registerTools(server, ctx) {
|
|
|
1222
1223
|
const video = videoIdOf(a.video);
|
|
1223
1224
|
if (!video)
|
|
1224
1225
|
throw new ToolRefusal("invalid_ref", "Give a YouTube URL or an 11-character video id.");
|
|
1226
|
+
if (a.readiness === "released_for_review") {
|
|
1227
|
+
// A person named in this video decides its release, so a request to them is not stepped around (jgwill/Miadi#925).
|
|
1228
|
+
const asked = all().find(r => r.kind === "participant_consent_request" && r.body.video_id === video && (r.approval_state === "awaiting_approval" || r.approval_state === "declined"));
|
|
1229
|
+
if (asked)
|
|
1230
|
+
throw new ToolRefusal("consent_required", `${String(asked.body.decider_name)} decides this release (${asked.id}): ${asked.approval_state === "declined" ? "they said not yet" : "they have not answered"}.`);
|
|
1231
|
+
}
|
|
1225
1232
|
const prior = all().find(r => r.kind === "source_intent" && r.actor.person_id === person.id && r.idempotency_key === `${video}:${a.purpose}`);
|
|
1226
1233
|
if (prior && prior.body.readiness !== a.readiness) {
|
|
1227
1234
|
// Release/withdrawal is an explicit source-owner contribution, not processing authority.
|
|
@@ -1996,6 +2003,61 @@ export function registerTools(server, ctx) {
|
|
|
1996
2003
|
});
|
|
1997
2004
|
return { ...r, body: { ...r.body, markdown: a.markdown ? `${a.markdown.length} chars` : undefined, transcript: a.transcript ? `${a.transcript.length} chars` : undefined }, approval_url: approvalUrl(r.id) };
|
|
1998
2005
|
}));
|
|
2006
|
+
server.registerTool("request_participant_consent", {
|
|
2007
|
+
title: "Ask a person in a video whether its words may be published",
|
|
2008
|
+
description: "A source withheld because another person is in the video waits for that person. This records one question to them, with the video's YouTube captions for them to read, and answers with a link only they decide on, no Miadi sign-in needed. Their yes releases the source and stores the words on the review; their not yet keeps them private, and the source's owner cannot release it around them. It sends nothing: pass consent_url to whoever speaks with that person, or an admin reads the email on approval_url and presses Send.",
|
|
2009
|
+
inputSchema: {
|
|
2010
|
+
video: z.string().min(1).describe("YouTube URL or video id of a source you registered."),
|
|
2011
|
+
person_name: z.string().min(1).max(80).describe("The name they know themselves by; the page greets them with it."),
|
|
2012
|
+
person_id: z.string().max(120).optional().describe("Their Miadi person id, when they have one."),
|
|
2013
|
+
question: z.string().min(10).max(600).optional().describe("One plain question, in words they follow. It says who recorded the video and what publishing means."),
|
|
2014
|
+
asked_by: z.string().min(1).max(80).optional().describe("The name this person knows the asker by, when it differs from your Miadi name."),
|
|
2015
|
+
refs,
|
|
2016
|
+
...base,
|
|
2017
|
+
},
|
|
2018
|
+
annotations: WRITE,
|
|
2019
|
+
}, wrap(async (a, extra) => {
|
|
2020
|
+
const { person, auth } = who(extra);
|
|
2021
|
+
requireContribute(auth);
|
|
2022
|
+
const video = videoIdOf(a.video);
|
|
2023
|
+
if (!video)
|
|
2024
|
+
throw new ToolRefusal("invalid_ref", "Give a YouTube URL or an 11-character video id.");
|
|
2025
|
+
const source = all().find(r => r.kind === "source_intent" && r.body.video_id === video && r.actor.person_id === person.id);
|
|
2026
|
+
if (!source)
|
|
2027
|
+
throw new ToolRefusal("not_found", "Register this video with register_source_intent first, as withheld.");
|
|
2028
|
+
if (source.body.readiness === "released_for_review")
|
|
2029
|
+
throw new ToolRefusal("already_released", "This source is already released, so there is nothing to ask.");
|
|
2030
|
+
if (!ctx.approval?.captions || !ctx.approval.consentLink)
|
|
2031
|
+
throw new ToolRefusal("unavailable", "Consent requests are not configured on this connector.");
|
|
2032
|
+
// The words are read here so the person decides on exact text, and that text is what a yes publishes.
|
|
2033
|
+
const answer = await ctx.approval.captions(video);
|
|
2034
|
+
if (answer.state !== "found")
|
|
2035
|
+
throw new ToolRefusal("unavailable", `There are no words to show yet. ${answer.message ?? "YouTube has no captions for this video yet."}`);
|
|
2036
|
+
const found = reviews.canSearch ? await lookupByVideo(video) : [];
|
|
2037
|
+
const review = Array.isArray(found) ? found[0] : undefined;
|
|
2038
|
+
const r = append("participant_consent_request", person, auth, {
|
|
2039
|
+
circle: "continuity",
|
|
2040
|
+
refs: [...a.refs, source.id, `https://youtu.be/${video}`, ...(review?.review_id ? [`miadi-review:${review.review_id}`] : [])],
|
|
2041
|
+
intention: a.intention,
|
|
2042
|
+
role: a.role,
|
|
2043
|
+
idempotency_key: a.idempotency_key ?? `consent:${video}:${a.person_name}`,
|
|
2044
|
+
approval_state: "awaiting_approval",
|
|
2045
|
+
body: {
|
|
2046
|
+
title: `Ask ${a.person_name} whether the words of ${video} may be published`,
|
|
2047
|
+
video_id: video,
|
|
2048
|
+
source_intent: source.id,
|
|
2049
|
+
decider_name: a.person_name,
|
|
2050
|
+
...(a.person_id ? { decider_person_id: a.person_id } : {}),
|
|
2051
|
+
...(a.asked_by ? { asked_by: a.asked_by } : {}),
|
|
2052
|
+
question: a.question ?? `${a.asked_by ?? person.name} recorded this video, and you are in it. May its words be published beside its review?`,
|
|
2053
|
+
...(review?.review_id ? { review_id: review.review_id, review_url: review.url } : {}),
|
|
2054
|
+
transcript: answer.text,
|
|
2055
|
+
transcript_words: answer.words,
|
|
2056
|
+
transcript_covers: coverage(answer),
|
|
2057
|
+
},
|
|
2058
|
+
});
|
|
2059
|
+
return { ...r, body: { ...r.body, transcript: `${answer.text.length} chars` }, consent_url: ctx.approval.consentLink(r.id), approval_url: approvalUrl(r.id), next_action: "Nothing has been sent. Pass consent_url to whoever speaks with this person, or send the email from approval_url." };
|
|
2060
|
+
}));
|
|
1999
2061
|
server.registerTool("request_deep_diver", {
|
|
2000
2062
|
title: "Request Deep Diver for a review version",
|
|
2001
2063
|
description: "Ask that one accepted review version be marked ready for Deep Diver. It waits for a person's approval; then the review service records it and its signed webhook queues /deepdive for the Deep Diver owner.",
|
|
@@ -2316,7 +2378,7 @@ export function registerTools(server, ctx) {
|
|
|
2316
2378
|
}));
|
|
2317
2379
|
const actionable = decisions.filter((d) => d.projection?.stage === "awaiting_decision");
|
|
2318
2380
|
// Each token is bound to the version its card shows: a card left open while the request is revised cannot approve it (#919).
|
|
2319
|
-
const tokens = canDecide ? Object.fromEntries(actionable.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId, undefined, r.version)])) : {};
|
|
2381
|
+
const tokens = canDecide ? Object.fromEntries(actionable.filter((r) => r.kind !== "participant_consent_request").map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId, undefined, r.version)])) : {};
|
|
2320
2382
|
const structured = {
|
|
2321
2383
|
decisions,
|
|
2322
2384
|
count: decisions.length,
|
|
@@ -2359,6 +2421,8 @@ export function registerTools(server, ctx) {
|
|
|
2359
2421
|
const r = all().find((x) => x.id === a.record_id);
|
|
2360
2422
|
if (!isApprovable(r))
|
|
2361
2423
|
throw new ToolRefusal("not_found", "No request with that id.");
|
|
2424
|
+
if (r.kind === "participant_consent_request")
|
|
2425
|
+
throw new ToolRefusal("forbidden", `Only ${String(r.body.decider_name)} decides this, on the link made for them.`);
|
|
2362
2426
|
// A stale card or a second client gets where the request stands, not a bare refusal (jgwill/Miadi#889).
|
|
2363
2427
|
const projectionNow = () => requestProjection(all().find((x) => x.id === r.id) ?? r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir });
|
|
2364
2428
|
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId, undefined, versionOf(r));
|
|
@@ -2525,6 +2589,7 @@ export const TOOL_STATUS = {
|
|
|
2525
2589
|
request_session_steer: "live, contribute, write tier (one correction to a working agent, forwarded to its runtime; runs inside an approved run's steering grant, else one scoped human decision; get_capabilities.steering says whether the transport works now and which providers have an adapter)",
|
|
2526
2590
|
revoke_session_steering: "live, contribute, write tier (stops a run's steering, or one steer not yet consumed)",
|
|
2527
2591
|
request_review_operation: "live, contribute, needs_approval (create also stores the video's YouTube captions as the review's transcript, no model call)",
|
|
2592
|
+
request_participant_consent: "live, contribute (one question to a person in a withheld video, answered on a link only they decide on; sends nothing)",
|
|
2528
2593
|
request_screenwalk_reading: "live, contribute, needs_approval (host provider adapter required; stops at editorial decision)",
|
|
2529
2594
|
request_reading_import: "live, contribute, needs_approval (exact public import; atomic service contract required)",
|
|
2530
2595
|
request_deep_diver: "live, contribute, needs_approval",
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.27";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.27";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
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",
|