@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 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
- A new card address needs a new URI, and the earlier ones stay readable (`DECISIONS_CARD_EARLIER_URIS`). A host keeps the tool metadata it read until its connection is refreshed by hand, and draws an empty card for a template it cannot read. The journal holds one line for each `show_decisions` call and each template read (`card_template_read`), so a blank card can be told from a card nobody asked for.
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 canDecideHere = Boolean(actionable && o.token);
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
- ${actionable && o.token
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",
@@ -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(),
@@ -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
@@ -54,6 +54,7 @@ export const RECORD_KINDS = [
54
54
  "handoff",
55
55
  "source_intent",
56
56
  "review_operation_request",
57
+ "participant_consent_request",
57
58
  "deep_diver_request",
58
59
  "review_episode_link_request",
59
60
  "calendar_event_draft",
@@ -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";
@@ -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.25";
1
+ export declare const VERSION = "0.1.27";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.25";
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.25",
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",