@miadi/connector 0.1.22 → 0.1.24

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
@@ -128,6 +128,19 @@ In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws o
128
128
 
129
129
  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.
130
130
 
131
+ **A created review gets its transcript (jgwill/Miadi#925).** An approved `create` calls the review service, then reads the video's original-language YouTube captions with a local `uvx yt-dlp` and stores them on the review as `[mm:ss] text`, so an agent reads them through `fetch` and the factory's kind briefs find them with `miadi_review.py transcript`. No model is called for the transcript. The service answers an empty `POST /api/reviews/:id/transcript` by asking Gemini, so an empty one is never sent. `completion_receipt.transcript` says where it stands, and the card and the approval page say the same:
132
+
133
+ | outcome | meaning |
134
+ |---|---|
135
+ | `stored` | stored, with its source, word count, sha256 and the range the captions cover |
136
+ | `already_present` | the review already held a transcript, which stays |
137
+ | `waiting_for_captions` | YouTube has no captions yet or refused the read. The connector tries again every 15 minutes for three days, from the ledger, so a restart loses nothing |
138
+ | `held_source_withheld` | the video's `source_intent` is `withheld`, so the captions are neither read nor stored. The transcript lands within 15 minutes of the source being released with `register_source_intent`, because a stored transcript is public at `/review/<id>/json` |
139
+ | `no_captions` | three days passed with no captions |
140
+ | `no_review` | the create left no review. Running the request again retries it |
141
+
142
+ A transcript supplied with the request is stored instead of the captions. The captions hold what YouTube heard: they may stop before the video does, and they may hold a synthetic voice and not the person's. Their first line says what they are and the range they cover. The host needs `uvx` on the service's `PATH`.
143
+
131
144
  The model that asked cannot decide for the person:
132
145
  - `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
133
146
  - It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request at the version its card shows, one person and one OAuth client, and expires after 7 days (`MIADI_CONNECTOR_DECISION_TOKEN_TTL`). A decided request no longer awaits a decision, so a token works once. The request itself does not expire: a card whose token expired names the request's approval page, and `show_decisions` gives a fresh card for the same request.
package/dist/approve.d.ts CHANGED
@@ -19,6 +19,7 @@ import { type RequestProjection } from "./request-state.js";
19
19
  import type { DraftReviewService } from "./draft-review.js";
20
20
  import type { RuntimeDeps } from "./runtime-steer.js";
21
21
  import { type TmuxLike } from "./steer.js";
22
+ import { type FetchCaptions } from "./captions.js";
22
23
  import { type ProviderAuthorization } from "./provider-policy.js";
23
24
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
24
25
  export interface ApprovalDeps {
@@ -38,6 +39,8 @@ export interface ApprovalDeps {
38
39
  steerSettleMs?: number;
39
40
  /** Where sessions and their steering inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
40
41
  steeringRuntime?: RuntimeDeps;
42
+ /** Reads a video's captions for an approved create; without it a create stores no transcript (tests, other embedders). */
43
+ captions?: FetchCaptions;
41
44
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
42
45
  log?: (event: Record<string, unknown>) => void;
43
46
  }
@@ -66,12 +69,15 @@ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson,
66
69
  aspect: string;
67
70
  name: string;
68
71
  ask: string;
72
+ settled_by?: string;
69
73
  }[]): {
70
74
  to: string;
71
75
  brief: string;
72
76
  };
73
77
  /** A request's refs as episode references: an `owner/repo#n` becomes its issue link, anything else stays as written. */
74
78
  export declare function episodeReferences(record: ConnectorRecord): string[];
79
+ /** Whether the owner of this video's source registered it as withheld. */
80
+ export declare function sourceWithheld(records: ConnectorRecord[], videoId: unknown): boolean;
75
81
  export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps, instructions?: string): Promise<Record<string, unknown>>;
76
82
  /**
77
83
  * Record a person's decision on a request and, when approved, run it once as
@@ -136,6 +142,14 @@ export declare const INTERRUPTED_MESSAGE = "The connector stopped while this ran
136
142
  * starts; a run this process is still recording is left alone.
137
143
  */
138
144
  export declare function reconcileInterrupted(deps: Pick<ApprovalDeps, "ledger" | "log">): string[];
145
+ /**
146
+ * Try again the transcripts an approved create could not store: captions YouTube had not made yet, a
147
+ * source that was withheld and may be released since. The ledger holds what waits, so a restart loses
148
+ * nothing. A new record is written only when the answer changes.
149
+ */
150
+ export declare function settleTranscripts(deps: ApprovalDeps): Promise<string[]>;
151
+ /** Runs settleTranscripts while the connector runs, one pass at a time. */
152
+ export declare function startTranscriptSettling(deps: ApprovalDeps): void;
139
153
  /** Whether a request was approved and its run failed, so a person may run it again. */
140
154
  export declare function isRerunnable(r: ConnectorRecord | undefined): boolean;
141
155
  /**
@@ -154,7 +168,7 @@ export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: App
154
168
  }>;
155
169
  export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
156
170
  /** What approving a request does, in one sentence, for the card a person decides on. */
157
- export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization): string;
171
+ export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization, records?: ConnectorRecord[]): string;
158
172
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
159
173
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
160
174
  /**
package/dist/approve.js CHANGED
@@ -24,6 +24,8 @@ import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
24
24
  import { factoryLedgerPath, readFactoryReceipts, receiptsForRecord } from "./factory-receipt.js";
25
25
  import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
26
26
  import { contentHash, versionOf } from "./revisions.js";
27
+ import { TRANSCRIPT_RETRY_MS, transcriptStep } from "./captions.js";
28
+ import { ReviewClient } from "./reviews.js";
27
29
  import { canReadReading } from "./reading/privacy.js";
28
30
  import { authorizationReceipt, authorizeProvider, briefAttributes, CHOICE_TTL_MS, CODEX_POLICIES, isPersonId, needsPersonChoice, providerPolicyOf, } from "./provider-policy.js";
29
31
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
@@ -147,7 +149,7 @@ export function runBrief(record, approver, sessions, instructions, providerAttrs
147
149
  : []),
148
150
  // A person may approve past an open question: the worker reads it, and says in its result what it could not settle (jgwill/Miadi#939).
149
151
  ...(openAspects.length
150
- ? ["", "These questions were open against the mission standard when it was approved. Settle what reading can settle, and say in your result what you could not:", "", "<open_questions>", ...openAspects.map((f) => text(`${f.aspect} ${f.name}: ${f.ask}`)), "</open_questions>"]
152
+ ? ["", "These questions were open against the mission standard when it was approved. Settle what reading can settle, and say in your result what you could not:", "", "<open_questions>", ...openAspects.map((f) => text(`${f.aspect} ${f.name}: ${f.ask}${f.settled_by === "person" ? " Only the person can answer this: ask them, and do not assume it." : ""}`)), "</open_questions>"]
151
153
  : []),
152
154
  ...(instructions
153
155
  ? [
@@ -180,28 +182,55 @@ export function episodeReferences(record) {
180
182
  return m ? `https://github.com/${m[1]}/${m[2]}/issues/${m[3]}` : ref;
181
183
  });
182
184
  }
185
+ async function callReview(deps, method, path, payload) {
186
+ const f = deps.fetchImpl ?? fetch;
187
+ if (!deps.config.reviewToken)
188
+ throw new Error("The connector host has no review service token");
189
+ const res = await f(`${deps.config.reviewUrl}${path}`, {
190
+ method,
191
+ headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${deps.config.reviewToken}` },
192
+ body: JSON.stringify(payload),
193
+ signal: AbortSignal.timeout(290_000),
194
+ });
195
+ const text = await res.text();
196
+ let parsed = text;
197
+ try {
198
+ parsed = JSON.parse(text);
199
+ }
200
+ catch {
201
+ /* text */
202
+ }
203
+ return { status: res.status, ok: res.ok, response: parsed };
204
+ }
205
+ /** Whether the owner of this video's source registered it as withheld. */
206
+ export function sourceWithheld(records, videoId) {
207
+ return records.some((r) => r.kind === "source_intent" && r.body.video_id === videoId && r.body.readiness === "withheld");
208
+ }
209
+ function transcriptIo(deps) {
210
+ return {
211
+ withheld: (video) => sourceWithheld(current(deps.ledger.all()), video),
212
+ captions: deps.captions,
213
+ store: (reviewId, transcript) => callReview(deps, "POST", `/api/reviews/${reviewId}/transcript`, { transcript }),
214
+ };
215
+ }
216
+ /** The review a create left: its answer names it, also when generation failed and a stub stands; else search by video. */
217
+ async function reviewLeftBy(deps, response, videoId) {
218
+ const id = response?.id;
219
+ if (typeof id === "string")
220
+ return id;
221
+ try {
222
+ const rows = await new ReviewClient(deps.config.reviewUrl, deps.config.reviewToken, deps.fetchImpl ?? fetch).search(videoId, 100);
223
+ const match = rows.find((row) => row.video_id === videoId);
224
+ return typeof match?.id === "string" ? match.id : null;
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
183
230
  export async function executeApproved(record, approver, deps, instructions) {
184
231
  const f = deps.fetchImpl ?? fetch;
185
232
  const b = record.body;
186
- const reviewCall = async (method, path, payload) => {
187
- if (!deps.config.reviewToken)
188
- throw new Error("The connector host has no review service token");
189
- const res = await f(`${deps.config.reviewUrl}${path}`, {
190
- method,
191
- headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${deps.config.reviewToken}` },
192
- body: JSON.stringify(payload),
193
- signal: AbortSignal.timeout(290_000),
194
- });
195
- const text = await res.text();
196
- let parsed = text;
197
- try {
198
- parsed = JSON.parse(text);
199
- }
200
- catch {
201
- /* text */
202
- }
203
- return { status: res.status, ok: res.ok, response: parsed };
204
- };
233
+ const reviewCall = (method, path, payload) => callReview(deps, method, path, payload);
205
234
  const queueBrief = () => {
206
235
  // Checked again where the brief is written: an approval, and every rerun of it (jgwill/Miadi#926).
207
236
  const authorization = record.kind === "run_request" ? authorizeProvider(record, deps.ledger.all()) : undefined;
@@ -241,7 +270,12 @@ export async function executeApproved(record, approver, deps, instructions) {
241
270
  : b.operation === "new_version"
242
271
  ? await reviewCall("POST", `/api/reviews/${b.review_id}/versions`, b.transcript ? { transcript: b.transcript } : {})
243
272
  : await reviewCall("POST", `/api/reviews/${b.review_id}/transcript`, { transcript: b.transcript });
244
- return { outcome: r.ok ? "done" : "failed", operation: b.operation, status: r.status, response: r.response };
273
+ const result = { outcome: r.ok ? "done" : "failed", operation: b.operation, status: r.status, response: r.response };
274
+ if (b.operation !== "create" || !deps.captions)
275
+ return result;
276
+ const transcript = await transcriptStep({ video_id: b.video_id, review_id: await reviewLeftBy(deps, r.response, b.video_id), since: new Date().toISOString(), ...(b.transcript ? { supplied: b.transcript } : {}) }, transcriptIo(deps));
277
+ logDecision(deps, { phase: "transcript", record: record.id, video: b.video_id, outcome: transcript.outcome });
278
+ return { ...result, transcript };
245
279
  }
246
280
  case "deep_diver_request": {
247
281
  const r = await reviewCall("PUT", `/api/reviews/${b.review_id}/diver`, { ready: true, version: b.version, by: plainName(approver.name) });
@@ -535,6 +569,68 @@ export function reconcileInterrupted(deps) {
535
569
  }
536
570
  return out;
537
571
  }
572
+ /**
573
+ * Try again the transcripts an approved create could not store: captions YouTube had not made yet, a
574
+ * source that was withheld and may be released since. The ledger holds what waits, so a restart loses
575
+ * nothing. A new record is written only when the answer changes.
576
+ */
577
+ export async function settleTranscripts(deps) {
578
+ const out = [];
579
+ if (!deps.captions)
580
+ return out;
581
+ for (const r of current(deps.ledger.all())) {
582
+ const receipt = (r.completion_receipt ?? {});
583
+ const waiting = receipt.transcript;
584
+ if (r.kind !== "review_operation_request" || deciding.has(r.id))
585
+ continue;
586
+ if (waiting?.outcome !== "waiting_for_captions" && waiting?.outcome !== "held_source_withheld")
587
+ continue;
588
+ const b = r.body;
589
+ const next = await transcriptStep({
590
+ video_id: waiting.video_id,
591
+ review_id: waiting.review_id ?? null,
592
+ // The three days start when the source is released, not when it was held.
593
+ since: waiting.outcome === "held_source_withheld" ? new Date().toISOString() : waiting.since,
594
+ ...(b.transcript ? { supplied: b.transcript } : {}),
595
+ }, transcriptIo(deps));
596
+ if (next.outcome === waiting.outcome)
597
+ continue;
598
+ deps.ledger.append({
599
+ id: newRecordId(r.kind),
600
+ kind: r.kind,
601
+ created_at: new Date().toISOString(),
602
+ actor: { person_id: "system:miadi-connector", name: "Miadi connector", client_id: "captions" },
603
+ refs: r.refs,
604
+ evidence_refs: [],
605
+ supersedes: r.id,
606
+ approval_state: r.approval_state,
607
+ state: r.state,
608
+ completion_receipt: { ...receipt, transcript: next },
609
+ body: { decision: "transcript" },
610
+ });
611
+ logDecision(deps, { phase: "transcript", record: r.id, video: waiting.video_id, outcome: next.outcome });
612
+ out.push(r.id);
613
+ }
614
+ return out;
615
+ }
616
+ /** Runs settleTranscripts while the connector runs, one pass at a time. */
617
+ export function startTranscriptSettling(deps) {
618
+ if (!deps.captions)
619
+ return;
620
+ let busy = false;
621
+ const pass = () => {
622
+ if (busy)
623
+ return;
624
+ busy = true;
625
+ settleTranscripts(deps)
626
+ .catch((error) => logDecision(deps, { phase: "transcript", error: error instanceof Error ? error.message : String(error) }))
627
+ .finally(() => {
628
+ busy = false;
629
+ });
630
+ };
631
+ setTimeout(pass, 30_000).unref();
632
+ setInterval(pass, TRANSCRIPT_RETRY_MS).unref();
633
+ }
538
634
  /** Whether a request was approved and its run failed, so a person may run it again. */
539
635
  export function isRerunnable(r) {
540
636
  return !!r && r.approval_state === "approved" && r.state === "blocked";
@@ -676,7 +772,7 @@ export function isApprovable(r) {
676
772
  return Boolean(r && APPROVABLE.has(r.kind));
677
773
  }
678
774
  /** What approving a request does, in one sentence, for the card a person decides on. */
679
- export function consequenceOf(r, authorization) {
775
+ export function consequenceOf(r, authorization, records = []) {
680
776
  const b = r.body;
681
777
  switch (r.kind) {
682
778
  case "run_request":
@@ -686,6 +782,11 @@ export function consequenceOf(r, authorization) {
686
782
  case "work_proposal":
687
783
  return "Approving starts the work: a brief written from the proposal and your instructions is queued for an agent session.";
688
784
  case "review_operation_request":
785
+ if (b.operation === "create") {
786
+ return `Approving calls the review service once to create the review, then stores the video's YouTube captions as its transcript, with no model call for the transcript. ${sourceWithheld(records, b.video_id)
787
+ ? "This source is registered as withheld: the review is still created, and the transcript waits until the source is released."
788
+ : "If YouTube has no captions yet, it tries again every 15 minutes for three days."}`;
789
+ }
689
790
  return `Approving calls the review service once: ${String(b.operation ?? "operation").replace(/_/g, " ")}.`;
690
791
  case "deep_diver_request":
691
792
  return `Approving marks review version ${b.version ?? "?"} ready for Deep Diver, which queues its pass.`;
@@ -956,7 +1057,12 @@ function draftReviewSection(r, review, canAnswer = false) {
956
1057
  .join("");
957
1058
  const openCodes = new Set(open.map((f) => f.aspect));
958
1059
  const unchecked = review.not_checked.filter((n) => !openCodes.has(n.aspect));
959
- const uncheckedLine = unchecked.length ? stateLine("Read it yourself", EYE_PATHS, `No check covers these, so read the mission for them: ${unchecked.map((n) => `<span class="code">${escapeHtml(n.aspect)}</span> ${escapeHtml(n.name)}`).join(" · ")}`) : "";
1060
+ const partly = unchecked.filter((n) => n.in_part);
1061
+ const uncovered = unchecked.filter((n) => !n.in_part);
1062
+ const uncheckedLine = [
1063
+ ...partly.map((n) => stateLine("Checked in part", EYE_PATHS, `Checked in part: <span class="code">${escapeHtml(n.aspect)}</span> ${escapeHtml(n.name)} (${escapeHtml(n.in_part)})`)),
1064
+ ...(uncovered.length ? [stateLine("Read it yourself", EYE_PATHS, `No check covers these, so read the mission for them: ${uncovered.map((n) => `<span class="code">${escapeHtml(n.aspect)}</span> ${escapeHtml(n.name)}`).join(" · ")}`)] : []),
1065
+ ].join("\n");
960
1066
  const heading = open.length ? `${open.length} open on the mission standard v${review.standard.version}` : `The mission standard v${review.standard.version}`;
961
1067
  return `<section class="standard" aria-label="The mission standard">
962
1068
  <h2>${escapeHtml(heading)}</h2>
@@ -976,7 +1082,7 @@ function approvePage(deps, r, o = {}) {
976
1082
  const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
977
1083
  const blocked = Boolean(authorization && !authorization.authorized);
978
1084
  return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
979
- ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
1085
+ ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization, current(deps.ledger.all())))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
980
1086
  ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
981
1087
  ${summary(r)}
982
1088
  ${open ? draftReviewSection(r, deps.draftReview?.stored(r), Boolean(actionable && o.token && !blocked)) : ""}
package/dist/bin.js CHANGED
@@ -5,6 +5,7 @@ import { identifyThroughMiadi } from "./people.js";
5
5
  import { steeringTransport } from "./tools.js";
6
6
  import { tmuxAdapter } from "./steer.js";
7
7
  import { defaultRuntime } from "./runtime-steer.js";
8
+ import { youtubeCaptions } from "./captions.js";
8
9
  import { VERSION } from "./version.js";
9
10
  const config = configFromEnv();
10
11
  // The host's steering surface (jgwill/Miadi#909): the tmux server of the user the service runs as,
@@ -16,6 +17,8 @@ const { app } = createConnectorApp(config, {
16
17
  tmuxSessions: () => tmux.listSessions(),
17
18
  // Each session's own steering inbox and Claude Code's registry of running sessions (jgwill/Miadi#930).
18
19
  steeringRuntime: defaultRuntime(),
20
+ // An approved create stores the video's YouTube captions as the review's transcript, with no model call.
21
+ captions: youtubeCaptions,
19
22
  });
20
23
  app.listen(config.port, config.host, () => {
21
24
  console.log(`@miadi/connector ${VERSION} on http://${config.host}:${config.port}`);
@@ -0,0 +1,68 @@
1
+ export declare const TRANSCRIPT_RETRY_MS: number;
2
+ export declare const TRANSCRIPT_WINDOW_MS: number;
3
+ export type CaptionsAnswer = {
4
+ state: "found";
5
+ text: string;
6
+ language: string;
7
+ track: string;
8
+ words: number;
9
+ first_ms: number;
10
+ last_ms: number;
11
+ duration_s?: number;
12
+ fetched_at: string;
13
+ } | {
14
+ state: "none_yet";
15
+ message?: string;
16
+ };
17
+ export type FetchCaptions = (videoId: string) => Promise<CaptionsAnswer>;
18
+ export interface TranscriptReceipt {
19
+ outcome: "stored" | "already_present" | "waiting_for_captions" | "held_source_withheld" | "no_captions" | "no_review";
20
+ video_id: string;
21
+ review_id?: string;
22
+ since: string;
23
+ source?: string;
24
+ language?: string;
25
+ chars?: number;
26
+ words?: number;
27
+ sha256?: string;
28
+ covers?: string;
29
+ stored_at?: string;
30
+ message?: string;
31
+ }
32
+ export interface TranscriptIo {
33
+ withheld: (videoId: string) => boolean;
34
+ captions: FetchCaptions;
35
+ store: (reviewId: string, transcript: string) => Promise<{
36
+ status: number;
37
+ ok: boolean;
38
+ }>;
39
+ now?: () => number;
40
+ }
41
+ /**
42
+ * YouTube's json3 captions as the plain `[mm:ss] text` paragraphs the review service parses into turns.
43
+ * The words stay as YouTube wrote them. The first line says what they are. Null when they hold no words.
44
+ */
45
+ export declare function transcriptFromJson3(raw: string, o: {
46
+ language: string;
47
+ duration_s?: number;
48
+ fetched_at: string;
49
+ }): {
50
+ text: string;
51
+ words: number;
52
+ first_ms: number;
53
+ last_ms: number;
54
+ } | null;
55
+ /** The video's original-language automatic captions, through a local yt-dlp in a directory of its own. */
56
+ export declare const youtubeCaptions: FetchCaptions;
57
+ /**
58
+ * One attempt at the transcript of a created review. A source its owner registered as withheld is
59
+ * neither read nor stored until it is released: the transcript the service holds is public.
60
+ */
61
+ export declare function transcriptStep(input: {
62
+ video_id: string;
63
+ review_id: string | null;
64
+ since: string;
65
+ supplied?: string;
66
+ }, io: TranscriptIo): Promise<TranscriptReceipt>;
67
+ /** What a transcript receipt says on a request's card and approval page. */
68
+ export declare function transcriptLines(transcript: unknown): string[];
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The transcript of a reviewed video, taken from the video's own YouTube captions.
3
+ *
4
+ * Guillaume, 2026-11-06: "when this gets triggered and I approve it, the actual transcript of the
5
+ * video will be available ... And I don't want us to call the API for Gemini to do it because it
6
+ * costs too much." So an approved `create` reads the captions with a local yt-dlp and stores them.
7
+ * Nothing here calls a model, and `store` is never sent an empty transcript, because the review
8
+ * service answers an empty one by asking Gemini (`POST /api/reviews/:id/transcript`).
9
+ *
10
+ * YouTube gives a video its address before its captions exist (Episode 550, thread beat 01f2e53e),
11
+ * so a transcript that cannot be stored yet stays in the request's receipt and is tried again.
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { promisify } from "node:util";
19
+ const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
20
+ /** A silence this long, or a paragraph this long, starts the next timestamped paragraph. */
21
+ const PAUSE_MS = 3000;
22
+ const PARAGRAPH_CHARS = 900;
23
+ export const TRANSCRIPT_RETRY_MS = 15 * 60_000;
24
+ export const TRANSCRIPT_WINDOW_MS = 72 * 3_600_000;
25
+ function clock(ms) {
26
+ const total = Math.floor(ms / 1000);
27
+ const h = Math.floor(total / 3600);
28
+ const m = Math.floor((total % 3600) / 60);
29
+ const s = String(total % 60).padStart(2, "0");
30
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${String(m).padStart(2, "0")}:${s}`;
31
+ }
32
+ /** The covered range in words a person reads: "00:02 to 33:03 of 62:38". */
33
+ function coverage(a) {
34
+ return `${clock(a.first_ms)} to ${clock(a.last_ms)}${a.duration_s ? ` of ${clock(a.duration_s * 1000)}` : ""}`;
35
+ }
36
+ /**
37
+ * YouTube's json3 captions as the plain `[mm:ss] text` paragraphs the review service parses into turns.
38
+ * The words stay as YouTube wrote them. The first line says what they are. Null when they hold no words.
39
+ */
40
+ export function transcriptFromJson3(raw, o) {
41
+ const events = JSON.parse(raw).events ?? [];
42
+ const paragraphs = [];
43
+ let end = 0;
44
+ let first = -1;
45
+ let last = 0;
46
+ for (const e of events) {
47
+ const words = (e.segs ?? []).map((s) => s.utf8 ?? "").join("").replace(/\s+/g, " ").trim();
48
+ if (!words)
49
+ continue;
50
+ const start = e.tStartMs ?? 0;
51
+ const open = paragraphs[paragraphs.length - 1];
52
+ if (!open || start - end >= PAUSE_MS || words.startsWith(">>") || open.text.length >= PARAGRAPH_CHARS)
53
+ paragraphs.push({ at: start, text: words });
54
+ else
55
+ open.text += ` ${words}`;
56
+ end = Math.max(end, start + (e.dDurationMs ?? 0));
57
+ if (first < 0)
58
+ first = start;
59
+ last = end;
60
+ }
61
+ if (!paragraphs.length)
62
+ return null;
63
+ const covered = { first_ms: first, last_ms: last, duration_s: o.duration_s };
64
+ const header = `(YouTube automatic captions, language ${o.language}, uncorrected, fetched ${o.fetched_at.slice(0, 10)}. They run from ${coverage(covered)}. Names may be misheard, and a voice the captions did not catch is absent.)`;
65
+ const body = paragraphs.map((p) => `[${clock(p.at)}] ${p.text}`).join("\n\n");
66
+ return { text: `${header}\n\n${body}\n`, words: paragraphs.reduce((n, p) => n + p.text.split(" ").length, 0), first_ms: first, last_ms: last };
67
+ }
68
+ const run = promisify(execFile);
69
+ /** The video's original-language automatic captions, through a local yt-dlp in a directory of its own. */
70
+ export const youtubeCaptions = async (videoId) => {
71
+ if (!VIDEO_ID.test(videoId))
72
+ return { state: "none_yet", message: "Not a YouTube video id." };
73
+ const dir = mkdtempSync(join(tmpdir(), "miadi-captions-"));
74
+ try {
75
+ await run("uvx", [
76
+ "--quiet",
77
+ "yt-dlp@latest",
78
+ "--ignore-config",
79
+ "--js-runtimes",
80
+ `node:${process.execPath}`,
81
+ "--skip-download",
82
+ "--no-playlist",
83
+ "--no-warnings",
84
+ "--write-auto-subs",
85
+ "--sub-langs",
86
+ ".*-orig",
87
+ "--sub-format",
88
+ "json3",
89
+ "--write-info-json",
90
+ "--output",
91
+ "video.%(ext)s",
92
+ `https://youtu.be/${videoId}`,
93
+ ], { cwd: dir, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
94
+ const file = readdirSync(dir).find((f) => /^video\.[\w-]+-orig\.json3$/.test(f));
95
+ if (!file)
96
+ return { state: "none_yet" };
97
+ const track = file.slice("video.".length, -".json3".length);
98
+ const language = track.slice(0, -"-orig".length);
99
+ let duration_s;
100
+ try {
101
+ const d = JSON.parse(readFileSync(join(dir, "video.info.json"), "utf8")).duration;
102
+ if (typeof d === "number" && d > 0)
103
+ duration_s = d;
104
+ }
105
+ catch {
106
+ /* the length is only said when it is known */
107
+ }
108
+ const fetched_at = new Date().toISOString();
109
+ const rendered = transcriptFromJson3(readFileSync(join(dir, file), "utf8"), { language, fetched_at, ...(duration_s ? { duration_s } : {}) });
110
+ if (!rendered)
111
+ return { state: "none_yet", message: "The caption track holds no words." };
112
+ return { state: "found", ...rendered, language, track, ...(duration_s ? { duration_s } : {}), fetched_at };
113
+ }
114
+ catch (error) {
115
+ // A refusal or a timeout today may answer tomorrow, so it waits with the captions that are not there yet.
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ return { state: "none_yet", message: message.split("\n").filter(Boolean).pop()?.slice(0, 300) ?? "yt-dlp failed" };
118
+ }
119
+ finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ };
123
+ /**
124
+ * One attempt at the transcript of a created review. A source its owner registered as withheld is
125
+ * neither read nor stored until it is released: the transcript the service holds is public.
126
+ */
127
+ export async function transcriptStep(input, io) {
128
+ const { video_id, since } = input;
129
+ const review_id = input.review_id ?? undefined;
130
+ const base = { video_id, since, ...(review_id ? { review_id } : {}) };
131
+ if (!review_id)
132
+ return { ...base, outcome: "no_review", message: "The review service left no review for this video, so nothing holds a transcript. Running the request again retries it." };
133
+ if (io.withheld(video_id)) {
134
+ return { ...base, outcome: "held_source_withheld", message: "The source is registered as withheld. The transcript is stored once the source is released." };
135
+ }
136
+ const now = (io.now ?? Date.now)();
137
+ const expired = now - Date.parse(since) >= TRANSCRIPT_WINDOW_MS;
138
+ const waiting = (message) => expired ? { ...base, outcome: "no_captions", message: `No transcript after three days of trying, and no model was called. Last answer: ${message}` } : { ...base, outcome: "waiting_for_captions", message };
139
+ const supplied = input.supplied?.trim();
140
+ const answer = supplied ? undefined : await io.captions(video_id);
141
+ if (answer && answer.state !== "found")
142
+ return waiting(answer.message ?? "YouTube has no captions for this video yet.");
143
+ const text = (supplied ?? answer.text).trim();
144
+ if (!text)
145
+ return waiting("The captions hold no words.");
146
+ let stored;
147
+ try {
148
+ stored = await io.store(review_id, text);
149
+ }
150
+ catch (error) {
151
+ return waiting(`The review service could not be reached: ${error instanceof Error ? error.message : String(error)}`);
152
+ }
153
+ if (stored.status === 409)
154
+ return { ...base, outcome: "already_present", message: "The review already holds a transcript, which stays." };
155
+ if (!stored.ok)
156
+ return waiting(`The review service answered ${stored.status}.`);
157
+ return {
158
+ ...base,
159
+ outcome: "stored",
160
+ source: supplied ? "supplied with the request" : `YouTube captions (${answer.track})`,
161
+ chars: text.length,
162
+ sha256: createHash("sha256").update(text).digest("hex"),
163
+ stored_at: new Date(now).toISOString(),
164
+ ...(answer?.state === "found" ? { language: answer.language, words: answer.words, covers: coverage(answer) } : {}),
165
+ };
166
+ }
167
+ /** What a transcript receipt says on a request's card and approval page. */
168
+ export function transcriptLines(transcript) {
169
+ const t = transcript;
170
+ switch (t?.outcome) {
171
+ case "stored":
172
+ return [`Transcript: stored from ${t.source}${t.words ? `, ${t.words} words, ${t.covers}` : ""}. No model was called for it.`];
173
+ case "already_present":
174
+ return ["Transcript: the review already held one, which stays."];
175
+ case "waiting_for_captions":
176
+ return [`Transcript: not stored yet. ${t.message ?? ""} It is tried again every 15 minutes for three days, with no model call.`.trim()];
177
+ case "held_source_withheld":
178
+ return ["Transcript: held. The source is registered as withheld, and the transcript is stored once the source is released."];
179
+ case "no_captions":
180
+ case "no_review":
181
+ return [`Transcript: not stored. ${t.message ?? ""}`.trim()];
182
+ default:
183
+ return [];
184
+ }
185
+ }
@@ -19,4 +19,4 @@ export declare class DecisionTokens {
19
19
  * and calls `decide_request` through the host. In a host without that bridge it
20
20
  * shows each request's approval page instead.
21
21
  */
22
- 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 rest=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});\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 if(canAnswer&&list.length)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>";
22
+ 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>";
package/dist/decisions.js CHANGED
@@ -234,13 +234,18 @@ summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
234
234
  s.appendChild(ul);
235
235
  }
236
236
  var open={};list.forEach(function(f){open[f.aspect]=true;});
237
- var rest=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});
237
+ var unseen=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});
238
+ var rest=unseen.filter(function(n){return !n.in_part;});
239
+ unseen.filter(function(n){return n.in_part;}).forEach(function(n){
240
+ 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+")"));}));
241
+ });
238
242
  if(rest.length)s.appendChild(stateLine("Read it yourself",EYE,function(t){
239
243
  t.appendChild(el("span",null,"No check covers these, so read the mission for them: "));
240
244
  rest.forEach(function(n,i){if(i)t.appendChild(el("span",null," · "));t.appendChild(el("span",{class:"code"},n.aspect));t.appendChild(el("span",null," "+n.name));});
241
245
  }));
242
246
  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."));
243
- if(canAnswer&&list.length)s.appendChild(el("p",{class:"small"},"Approving as it stands starts the session, and the worker reads these questions with the task."));
247
+ var refused=r.provider_authorization&&r.provider_authorization.authorized===false;
248
+ 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."));
244
249
  return s;
245
250
  }
246
251
  function card(r,token){
@@ -43,6 +43,7 @@ export interface DraftReview {
43
43
  aspect: string;
44
44
  name: string;
45
45
  reason: string;
46
+ in_part?: string;
46
47
  }[];
47
48
  running_missions: RunningMission[];
48
49
  preferences: string[];
@@ -194,7 +194,7 @@ export async function reviewDraft({ record, records, standard, adapter, now = Da
194
194
  notChecked.push({ aspect: a.code, name: a.name, reason: "This version of the connector runs no check for it." });
195
195
  }
196
196
  if (a.not_checked)
197
- notChecked.push({ aspect: a.code, name: a.name, reason: a.not_checked });
197
+ notChecked.push({ aspect: a.code, name: a.name, reason: a.not_checked, ...(a.checked_in_part ? { in_part: a.checked_in_part } : {}) });
198
198
  }
199
199
  // For a finding Jev raised on the mission, the sentence it is about. A failure leaves the finding without one.
200
200
  const raised = needs.flatMap((f) => {
@@ -39,6 +39,8 @@ export interface StandardAspect {
39
39
  jev?: JevCheck;
40
40
  /** Why this version of the connector does not check it, or checks only part of it. */
41
41
  not_checked?: string;
42
+ /** What is checked when only part of the aspect is, in words for the person deciding. */
43
+ checked_in_part?: string;
42
44
  }
43
45
  export interface MissionStandard {
44
46
  name: string;
@@ -57,6 +57,7 @@ export const BUILT_IN_STANDARD = {
57
57
  fix: "Say which in the mission.",
58
58
  settled_by: "drafter",
59
59
  not_checked: "Only shared issues are compared. The local reviewer judges other overlap, and it does not run yet (jgwill/Miadi#939 A3).",
60
+ checked_in_part: "only shared issues are compared",
60
61
  },
61
62
  {
62
63
  code: "S4",
@@ -127,7 +128,7 @@ export function parseStandard(text, version) {
127
128
  if (!["drafter", "person", "local_agent"].includes(a.settled_by))
128
129
  throw new Error(`aspect ${a.code}: settled_by is drafter, person or local_agent`);
129
130
  const jev = checkOf(a.jev);
130
- return { code: a.code, name: a.name, requires: a.requires, why: a.why, ask: a.ask, ...(isText(a.fix) ? { fix: a.fix } : {}), settled_by: a.settled_by, ...(jev ? { jev } : {}), ...(isText(a.not_checked) ? { not_checked: a.not_checked } : {}) };
131
+ return { code: a.code, name: a.name, requires: a.requires, why: a.why, ask: a.ask, ...(isText(a.fix) ? { fix: a.fix } : {}), settled_by: a.settled_by, ...(jev ? { jev } : {}), ...(isText(a.not_checked) ? { not_checked: a.not_checked } : {}), ...(isText(a.checked_in_part) ? { checked_in_part: a.checked_in_part } : {}) };
131
132
  });
132
133
  const preferences = {};
133
134
  for (const [person, list] of Object.entries(raw.preferences ?? {})) {
@@ -35,9 +35,9 @@ export declare const sourcePart: z.ZodObject<{
35
35
  locator: z.ZodString;
36
36
  provider: z.ZodString;
37
37
  caption_kind: z.ZodEnum<{
38
+ supplied: "supplied";
38
39
  manual: "manual";
39
40
  automatic: "automatic";
40
- supplied: "supplied";
41
41
  local_stt: "local_stt";
42
42
  }>;
43
43
  fetched_at: z.ZodString;
@@ -1,23 +1,3 @@
1
- /**
2
- * Where a request stands, read once for every surface that shows it: the
3
- * decision card in the conversation, the approval page, get_run_result and
4
- * Miadi's /research (jgwill/Miadi#889, Mission A). A surface shows the
5
- * projection's `label` and `lines`. It does not read the record's state,
6
- * approval state or receipt by itself.
7
- *
8
- * Stages, from the ledger's folded record (see `current()`):
9
- * - awaiting_decision: awaiting_approval, nothing decided
10
- * - recording: a decision is being recorded, or it was approved and its run is
11
- * going (written ahead as outcome `running`, jgwill/Miadi#924)
12
- * - declined, sent_back: decided, nothing ran
13
- * - dispatching: approved, and its brief is queued or running (state in_progress)
14
- * - completed: approved, and its run finished (state completed)
15
- * - failed: approved, and its run failed (state blocked); the same approval may run it again
16
- *
17
- * Approving with changes keeps the changes with the decision. A kind whose run
18
- * does not carry instructions (approve.ts runBrief carries them only for runs and
19
- * work proposals) lists them as outstanding. The projection never says they ran.
20
- */
21
1
  import type { ConnectorRecord } from "./records.js";
22
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"];
23
3
  export declare const APPROVABLE: ReadonlySet<string>;
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Where a request stands, read once for every surface that shows it: the
3
+ * decision card in the conversation, the approval page, get_run_result and
4
+ * Miadi's /research (jgwill/Miadi#889, Mission A). A surface shows the
5
+ * projection's `label` and `lines`. It does not read the record's state,
6
+ * approval state or receipt by itself.
7
+ *
8
+ * Stages, from the ledger's folded record (see `current()`):
9
+ * - awaiting_decision: awaiting_approval, nothing decided
10
+ * - recording: a decision is being recorded, or it was approved and its run is
11
+ * going (written ahead as outcome `running`, jgwill/Miadi#924)
12
+ * - declined, sent_back: decided, nothing ran
13
+ * - dispatching: approved, and its brief is queued or running (state in_progress)
14
+ * - completed: approved, and its run finished (state completed)
15
+ * - failed: approved, and its run failed (state blocked); the same approval may run it again
16
+ *
17
+ * Approving with changes keeps the changes with the decision. A kind whose run
18
+ * does not carry instructions (approve.ts runBrief carries them only for runs and
19
+ * work proposals) lists them as outstanding. The projection never says they ran.
20
+ */
21
+ import { transcriptLines } from "./captions.js";
1
22
  export const APPROVABLE_KINDS = [
2
23
  "run_request",
3
24
  "review_operation_request",
@@ -134,6 +155,7 @@ function projectCore(record, ctx = {}) {
134
155
  const lines = [...decisionLines(decision), `Failure: ${failure}`];
135
156
  if (receipt.outcome)
136
157
  lines.push(`Outcome: ${outcomeWord}.`);
158
+ lines.push(...transcriptLines(receipt.transcript));
137
159
  lines.push(...outstanding);
138
160
  return finish({
139
161
  ...base,
@@ -183,6 +205,7 @@ function projectCore(record, ctx = {}) {
183
205
  lines.push(`Outcome: ${outcomeWord}.`);
184
206
  if (receiptUrl)
185
207
  lines.push(`Review: ${receiptUrl}`);
208
+ lines.push(...transcriptLines(receipt.transcript));
186
209
  lines.push(...outstanding);
187
210
  return finish({
188
211
  ...base,
package/dist/server.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { RuntimeDeps } from "./runtime-steer.js";
13
13
  import { MiadiOAuthProvider } from "./oauth.js";
14
14
  import type { MiadiPerson } from "./people.js";
15
+ import type { FetchCaptions } from "./captions.js";
15
16
  import { Ledger } from "./store.js";
16
17
  import type { ConnectorRecord } from "./records.js";
17
18
  import type { ConnectorConfig } from "./config.js";
@@ -40,6 +41,8 @@ export interface ConnectorDeps {
40
41
  steerSettleMs?: number;
41
42
  /** Where sessions and their steering inboxes live (jgwill/Miadi#930). */
42
43
  steeringRuntime?: RuntimeDeps;
44
+ /** Reads a video's captions for an approved create; the service passes youtubeCaptions, tests their own. */
45
+ captions?: FetchCaptions;
43
46
  /** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
44
47
  tokenAttemptsPerWindow?: number;
45
48
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them (jgwill/Miadi#924). */
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 { reconcileInterrupted, registerApprovalRoutes, registerProviderPolicyRoutes } from "./approve.js";
8
+ import { reconcileInterrupted, registerApprovalRoutes, 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";
@@ -80,10 +80,12 @@ export function createConnectorApp(config, deps) {
80
80
  tmux: deps.tmux,
81
81
  steerSettleMs: deps.steerSettleMs,
82
82
  ...(deps.steeringRuntime ? { steeringRuntime: deps.steeringRuntime } : {}),
83
+ ...(deps.captions ? { captions: deps.captions } : {}),
83
84
  log: deps.log,
84
85
  };
85
86
  // A run the connector was recording when it last stopped is failed now, so a person may run it again (#924).
86
87
  reconcileInterrupted(approvalDeps);
88
+ startTranscriptSettling(approvalDeps);
87
89
  registerApprovalRoutes(app, approvalDeps);
88
90
  registerProviderPolicyRoutes(app, approvalDeps);
89
91
  const decisionTokens = new DecisionTokens(config.stateDir, config.decisionTokenTtl);
package/dist/tools.js CHANGED
@@ -1066,7 +1066,16 @@ export function registerTools(server, ctx) {
1066
1066
  const r = await reviews.get(reviewId);
1067
1067
  if (!r)
1068
1068
  return { id, state: "not_found" };
1069
- return { id, title: r.title, text: r.version?.markdown ?? "", url: `${config.reviewUrl}/review/${reviewId}`, metadata: { latest_version: r.latest_version, video_id: r.video_id } };
1069
+ // The review's stored transcript comes with it, so the agent that asked for the review can read what was said.
1070
+ const turns = Array.isArray(r.transcript?.turns) ? r.transcript.turns : [];
1071
+ const said = turns.map((t) => `${t.at ? `[${t.at}] ` : ""}${t.speaker || t.label ? `${t.speaker || t.label}: ` : ""}${t.text}`).join("\n\n");
1072
+ return {
1073
+ id,
1074
+ title: r.title,
1075
+ text: `${r.version?.markdown ?? ""}${said ? `\n\n## Transcript\n\n${said}\n` : ""}`,
1076
+ url: `${config.reviewUrl}/review/${reviewId}`,
1077
+ metadata: { latest_version: r.latest_version, video_id: r.video_id, has_transcript: Boolean(said), ...(r.transcript?.source ? { transcript_source: r.transcript.source } : {}) },
1078
+ };
1070
1079
  }
1071
1080
  const rec = records().find((x) => x.id === id);
1072
1081
  if (!rec)
@@ -1946,7 +1955,7 @@ export function registerTools(server, ctx) {
1946
1955
  }));
1947
1956
  server.registerTool("request_review_operation", {
1948
1957
  title: "Request a review operation",
1949
- description: "Ask for one review-service operation: create (a review from a YouTube URL), import_markdown (a hand-written version), new_version (regenerate), store_transcript. It waits for a person's approval; check review_lookup first so nothing is duplicated.",
1958
+ description: "Ask for one review-service operation: create (a review from a YouTube URL; once approved it also stores the video's YouTube captions as the review's transcript, with no model call), import_markdown (a hand-written version), new_version (regenerate), store_transcript. It waits for a person's approval; check review_lookup first so nothing is duplicated.",
1950
1959
  inputSchema: {
1951
1960
  operation: z.enum(["create", "import_markdown", "new_version", "store_transcript"]),
1952
1961
  review_id: z.string().optional(),
@@ -2290,7 +2299,7 @@ export function registerTools(server, ctx) {
2290
2299
  created_at: r.created_at,
2291
2300
  circle: r.circle,
2292
2301
  intention: r.intention,
2293
- consequence: consequenceOf(r, providerAuthorizationOf(r)),
2302
+ consequence: consequenceOf(r, providerAuthorizationOf(r), records()),
2294
2303
  detail: detailOf(r),
2295
2304
  ...(r.kind === "run_request" || r.kind === "work_proposal"
2296
2305
  ? {
@@ -2513,7 +2522,7 @@ export const TOOL_STATUS = {
2513
2522
  supersede_pending_requests: "live, contribute (retires named earlier requests still waiting and joins them as earlier versions of the one that stands)",
2514
2523
  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)",
2515
2524
  revoke_session_steering: "live, contribute, write tier (stops a run's steering, or one steer not yet consumed)",
2516
- request_review_operation: "live, contribute, needs_approval",
2525
+ request_review_operation: "live, contribute, needs_approval (create also stores the video's YouTube captions as the review's transcript, no model call)",
2517
2526
  request_screenwalk_reading: "live, contribute, needs_approval (host provider adapter required; stops at editorial decision)",
2518
2527
  request_reading_import: "live, contribute, needs_approval (exact public import; atomic service contract required)",
2519
2528
  request_deep_diver: "live, contribute, needs_approval",
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.22";
1
+ export declare const VERSION = "0.1.24";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.22";
1
+ export const VERSION = "0.1.24";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/connector",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",