@miadi/connector 0.1.23 → 0.1.25

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,8 +126,23 @@ 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.
130
+
129
131
  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
132
 
133
+ **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:
134
+
135
+ | outcome | meaning |
136
+ |---|---|
137
+ | `stored` | stored, with its source, word count, sha256 and the range the captions cover |
138
+ | `already_present` | the review already held a transcript, which stays |
139
+ | `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 |
140
+ | `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` |
141
+ | `no_captions` | three days passed with no captions |
142
+ | `no_review` | the create left no review. Running the request again retries it |
143
+
144
+ 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`.
145
+
131
146
  The model that asked cannot decide for the person:
132
147
  - `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
133
148
  - 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
  }
@@ -73,6 +76,8 @@ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson,
73
76
  };
74
77
  /** A request's refs as episode references: an `owner/repo#n` becomes its issue link, anything else stays as written. */
75
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;
76
81
  export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps, instructions?: string): Promise<Record<string, unknown>>;
77
82
  /**
78
83
  * Record a person's decision on a request and, when approved, run it once as
@@ -137,6 +142,14 @@ export declare const INTERRUPTED_MESSAGE = "The connector stopped while this ran
137
142
  * starts; a run this process is still recording is left alone.
138
143
  */
139
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;
140
153
  /** Whether a request was approved and its run failed, so a person may run it again. */
141
154
  export declare function isRerunnable(r: ConnectorRecord | undefined): boolean;
142
155
  /**
@@ -155,7 +168,7 @@ export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: App
155
168
  }>;
156
169
  export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
157
170
  /** What approving a request does, in one sentence, for the card a person decides on. */
158
- export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization): string;
171
+ export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization, records?: ConnectorRecord[]): string;
159
172
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
160
173
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
161
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";
@@ -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) });
@@ -369,7 +403,7 @@ blockquote{margin:.2rem 0 .6rem;padding-left:.8rem;border-left:2px solid var(--l
369
403
  @media (prefers-color-scheme:dark){.err{color:#ff8a80}}
370
404
  </style></head><body><main>${body}</main></body></html>`;
371
405
  }
372
- function summary(r) {
406
+ function summary(r, o = {}) {
373
407
  const shown = { ...r.body };
374
408
  for (const k of ["markdown", "transcript"])
375
409
  if (typeof shown[k] === "string")
@@ -380,7 +414,7 @@ function summary(r) {
380
414
  <dt>By</dt><dd>${escapeHtml(r.actor.name)}${r.actor.client_name ? ` through ${escapeHtml(r.actor.client_name)}` : ""}</dd>
381
415
  <dt>When</dt><dd>${escapeHtml(r.created_at)}</dd>
382
416
  ${r.circle ? `<dt>Circle</dt><dd>${escapeHtml(r.circle)}</dd>` : ""}
383
- ${r.intention ? `<dt>Intention</dt><dd>${escapeHtml(r.intention)}</dd>` : ""}
417
+ ${r.intention && o.intention !== false ? `<dt>Intention</dt><dd>${escapeHtml(r.intention)}</dd>` : ""}
384
418
  ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}</dd>` : ""}
385
419
  <dt>State</dt><dd>${escapeHtml(r.approval_state ?? "—")}</dd>
386
420
  ${versionOf(r) > 1 ? `<dt>Version</dt><dd>${versionOf(r)}, the one a decision here decides. Earlier versions stay in the ledger.</dd>` : ""}
@@ -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.`;
@@ -883,14 +984,14 @@ function signInToRead(r, error) {
883
984
  // The label names no one and no instruction: this page is shown before sign-in.
884
985
  const open = r.approval_state === "awaiting_approval";
885
986
  const label = projectRequest(r, { recording: isBeingDecided(r.id) })?.label ?? r.approval_state ?? "";
886
- return page(open ? "A Miadi request awaits a decision" : "A Miadi request was decided", `<h1>${open ? "A request awaits a decision" : "A request was decided"}</h1>
987
+ return page(open ? "A Miadi request awaits a decision" : "A Miadi request was decided", `<h1>${open ? "Sign in to read this request" : "A request was decided"}</h1>
887
988
  <p>${escapeHtml(KIND_LABELS[r.kind] ?? r.kind)}, asked ${escapeHtml(r.created_at.slice(0, 16).replace("T", " "))} UTC. ${escapeHtml(label)}.</p>
888
- <p>${open ? "Sign in to read it. A person with Miadi's write tier then decides; whoever asked can read it." : "Sign in to read what was decided and what came of it. Whoever asked, and anyone with Miadi's write tier, can read it."}</p>
989
+ <p>${open ? "Signing in approves nothing. The next page shows the request, and a person with Miadi's write tier decides there. Whoever asked can read it." : "Sign in to read what was decided and what came of it. Whoever asked, and anyone with Miadi's write tier, can read it."}</p>
889
990
  ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
890
991
  <form method="post">
891
992
  <label for="t">Your Miadi person token</label>
892
993
  <input id="t" name="person_token" type="password" autocomplete="off" required>
893
- <div class="row"><button class="primary" type="submit">Read the request</button></div>
994
+ <div class="row"><button class="primary" type="submit">Sign in and read the request</button></div>
894
995
  </form>`);
895
996
  }
896
997
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
@@ -980,12 +1081,20 @@ function approvePage(deps, r, o = {}) {
980
1081
  // Freshness binds a decision still to come; a decided run shows the authorization it holds (#926 review R1).
981
1082
  const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
982
1083
  const blocked = Boolean(authorization && !authorization.authorized);
983
- return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
984
- ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
1084
+ // 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);
1086
+ const heading = canDecideHere ? "Not approved yet: decide here" : open ? "A request awaiting a decision" : (projectionOf(r, deps)?.label ?? "A decided request");
1087
+ const details = canDecideHere
1088
+ ? `<details><summary>What exactly is asked</summary>${summary(r, { intention: false })}</details>`
1089
+ : `${summary(r)}
1090
+ ${open ? draftReviewSection(r, deps.draftReview?.stored(r), false) : ""}
1091
+ ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}`;
1092
+ return page("Approve a Miadi request", `<h1>${escapeHtml(heading)}</h1>
1093
+ ${canDecideHere ? `<p>You are signed in. Nothing is approved until you press a button below.</p>` : ""}
1094
+ ${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
+ ${canDecideHere && r.intention ? `<p><strong>Intention:</strong> ${escapeHtml(r.intention)}</p>` : ""}
985
1096
  ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
986
- ${summary(r)}
987
- ${open ? draftReviewSection(r, deps.draftReview?.stored(r), Boolean(actionable && o.token && !blocked)) : ""}
988
- ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
1097
+ ${canDecideHere ? draftReviewSection(r, deps.draftReview?.stored(r), !blocked) : ""}
989
1098
  ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
990
1099
  ${actionable && o.token
991
1100
  ? `<form method="post">
@@ -1010,7 +1119,8 @@ ${blocked
1010
1119
  <form method="post">
1011
1120
  <input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
1012
1121
  <div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
1013
- : ""}`);
1122
+ : ""}
1123
+ ${details}`);
1014
1124
  }
1015
1125
  /**
1016
1126
  * Which provider a run launches with, who chose it, and, when the requester's own choice is
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
+ }
@@ -1,4 +1,10 @@
1
1
  export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v2.html";
2
+ /**
3
+ * Addresses the card had before. Every one stays readable, because a host keeps the tool metadata it read
4
+ * until the connection is refreshed by hand, and it renders nothing for a template it cannot read: removing
5
+ * v1 in 0.1.22 left ChatGPT drawing an empty card (jgwill/Miadi#939).
6
+ */
7
+ export declare const DECISIONS_CARD_EARLIER_URIS: string[];
2
8
  /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
3
9
  export declare const DECISION_TOKEN_TTL: number;
4
10
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
package/dist/decisions.js CHANGED
@@ -25,6 +25,12 @@ import { join } from "node:path";
25
25
  import { newSecret } from "./store.js";
26
26
  import { DEFAULT_DECISION_TOKEN_TTL } from "./config.js";
27
27
  export const DECISIONS_CARD_URI = "ui://miadi/decisions-v2.html";
28
+ /**
29
+ * Addresses the card had before. Every one stays readable, because a host keeps the tool metadata it read
30
+ * until the connection is refreshed by hand, and it renders nothing for a template it cannot read: removing
31
+ * v1 in 0.1.22 left ChatGPT drawing an empty card (jgwill/Miadi#939).
32
+ */
33
+ export const DECISIONS_CARD_EARLIER_URIS = ["ui://miadi/decisions-v1.html"];
28
34
  /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
29
35
  export const DECISION_TOKEN_TTL = DEFAULT_DECISION_TOKEN_TTL;
30
36
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
@@ -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 { logDecision, 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);
@@ -105,6 +107,12 @@ export function createConnectorApp(config, deps) {
105
107
  bearer(req, res, next);
106
108
  };
107
109
  const handle = async (req, res) => {
110
+ // What a host asks for to draw the card, for the journal: a blank card is otherwise invisible from here (jgwill/Miadi#939).
111
+ const rpc = (req.body ?? {});
112
+ if (rpc.method === "resources/read")
113
+ logDecision(approvalDeps, { phase: "card_template_read", uri: String(rpc.params?.uri ?? "").slice(0, 120) });
114
+ if (rpc.method === "tools/call" && rpc.params?.name === "show_decisions")
115
+ logDecision(approvalDeps, { phase: "show_decisions" });
108
116
  const ctx = {
109
117
  config,
110
118
  miadi,
package/dist/tools.js CHANGED
@@ -18,7 +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 { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
21
+ import { DECISIONS_CARD_EARLIER_URIS, DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
22
22
  import { discoverProviders, jevConfigOf, resolveProvider, routerPreflight, PROVIDERS } from "./providers.js";
23
23
  import { authorizesActions, missionStatus, primaryIssueOf, resolveMissionSubject } from "./mission-status.js";
24
24
  import { authorizationReceipt, authorizeProvider, CHOICE_PROVIDERS, isPersonId, needsPersonChoice, providerPolicyOf } from "./provider-policy.js";
@@ -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(),
@@ -2247,16 +2256,18 @@ export function registerTools(server, ctx) {
2247
2256
  shown[k] = `${shown[k].length} chars`;
2248
2257
  return JSON.stringify({ ...shown, refs: r.refs.length ? r.refs : undefined }, null, 2);
2249
2258
  };
2250
- server.registerResource("miadi-decisions-card", DECISIONS_CARD_URI, { title: "Miadi decisions", description: "Requests awaiting a person's decision, decided by the person's own click.", mimeType: "text/html;profile=mcp-app" }, async () => ({
2251
- contents: [
2252
- {
2253
- uri: DECISIONS_CARD_URI,
2254
- mimeType: "text/html;profile=mcp-app",
2255
- text: DECISIONS_CARD_HTML,
2256
- _meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } }, "openai/widgetDescription": "Cards for the requests awaiting the person's decision." },
2257
- },
2258
- ],
2259
- }));
2259
+ for (const uri of [DECISIONS_CARD_URI, ...DECISIONS_CARD_EARLIER_URIS]) {
2260
+ server.registerResource(uri === DECISIONS_CARD_URI ? "miadi-decisions-card" : `miadi-decisions-card-${uri.replace(/\W+/g, "-")}`, uri, { title: "Miadi decisions", description: "Requests awaiting a person's decision, decided by the person's own click.", mimeType: "text/html;profile=mcp-app" }, async () => ({
2261
+ contents: [
2262
+ {
2263
+ uri,
2264
+ mimeType: "text/html;profile=mcp-app",
2265
+ text: DECISIONS_CARD_HTML,
2266
+ _meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } }, "openai/widgetDescription": "Cards for the requests awaiting the person's decision." },
2267
+ },
2268
+ ],
2269
+ }));
2270
+ }
2260
2271
  server.registerTool("show_decisions", {
2261
2272
  title: "Show the requests awaiting a decision",
2262
2273
  description: "Show the requests that await a person's decision (runs, work proposals, review operations, Deep Diver, episode links, session endings) as cards in the conversation. The person approves, approves with changes, sends back or declines each one with their own click; you cannot decide for them. Call it after making a request, or when the person asks what waits for them. Optionally pass record_id to show one request.",
@@ -2290,7 +2301,7 @@ export function registerTools(server, ctx) {
2290
2301
  created_at: r.created_at,
2291
2302
  circle: r.circle,
2292
2303
  intention: r.intention,
2293
- consequence: consequenceOf(r, providerAuthorizationOf(r)),
2304
+ consequence: consequenceOf(r, providerAuthorizationOf(r), records()),
2294
2305
  detail: detailOf(r),
2295
2306
  ...(r.kind === "run_request" || r.kind === "work_proposal"
2296
2307
  ? {
@@ -2513,7 +2524,7 @@ export const TOOL_STATUS = {
2513
2524
  supersede_pending_requests: "live, contribute (retires named earlier requests still waiting and joins them as earlier versions of the one that stands)",
2514
2525
  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
2526
  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",
2527
+ request_review_operation: "live, contribute, needs_approval (create also stores the video's YouTube captions as the review's transcript, no model call)",
2517
2528
  request_screenwalk_reading: "live, contribute, needs_approval (host provider adapter required; stops at editorial decision)",
2518
2529
  request_reading_import: "live, contribute, needs_approval (exact public import; atomic service contract required)",
2519
2530
  request_deep_diver: "live, contribute, needs_approval",
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.23";
1
+ export declare const VERSION = "0.1.25";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.23";
1
+ export const VERSION = "0.1.25";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/connector",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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",