@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 +15 -0
- package/dist/approve.d.ts +14 -1
- package/dist/approve.js +142 -32
- package/dist/bin.js +3 -0
- package/dist/captions.d.ts +68 -0
- package/dist/captions.js +185 -0
- package/dist/decisions.d.ts +6 -0
- package/dist/decisions.js +6 -0
- package/dist/reading/contract.d.ts +1 -1
- package/dist/request-state.d.ts +0 -20
- package/dist/request-state.js +23 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +9 -1
- package/dist/tools.js +26 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
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 ? "
|
|
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 ? "
|
|
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">
|
|
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
|
-
|
|
984
|
-
|
|
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
|
-
${
|
|
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[];
|
package/dist/captions.js
ADDED
|
@@ -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
|
+
}
|
package/dist/decisions.d.ts
CHANGED
|
@@ -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;
|
package/dist/request-state.d.ts
CHANGED
|
@@ -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>;
|
package/dist/request-state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.1.25";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
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.
|
|
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",
|