@miadi/connector 0.1.17 → 0.1.19
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 +7 -1
- package/dist/approve.d.ts +49 -7
- package/dist/approve.js +192 -23
- package/dist/config.d.ts +12 -0
- package/dist/config.js +11 -0
- package/dist/decisions.d.ts +4 -2
- package/dist/decisions.js +54 -18
- package/dist/request-state.d.ts +3 -1
- package/dist/request-state.js +5 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +22 -5
- package/dist/tools.js +44 -22
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,9 +70,11 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
|
|
|
70
70
|
|
|
71
71
|
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.
|
|
72
72
|
|
|
73
|
+
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.
|
|
74
|
+
|
|
73
75
|
The model that asked cannot decide for the person:
|
|
74
76
|
- `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
|
|
75
|
-
- 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
|
|
77
|
+
- 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.
|
|
76
78
|
- The sign-in must carry `miadi:approve`. A person grants it by ticking "Let me approve requests on cards inside this client" on the sign-in page. Only a person with Miadi's write tier receives it, only for a client that sends people back to chatgpt.com, and it ends when Miadi no longer gives them the write tier. A person token used directly as Bearer never carries it.
|
|
77
79
|
|
|
78
80
|
The approval page itself (`/approve/<record>`) names only the kind of request until someone signs in. A write-tier person then reads and decides it, and whoever asked can read it.
|
|
@@ -99,6 +101,8 @@ MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/con
|
|
|
99
101
|
| `MIADI_FACTORY_DIR` | `~/.miadi/factory` | the queue approved runs are written to |
|
|
100
102
|
| `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes |
|
|
101
103
|
| `MIADI_INQUIRY_GITHUB_REPO` | `miadisabelle/Etuaptmumk-RSM` | where a saved report's inquiry issue is opened, with the host's `gh` login |
|
|
104
|
+
| `MIADI_CONNECTOR_DECISION_TOKEN_TTL` | `604800` (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
|
|
105
|
+
| `MIADI_CONNECTOR_DECISION_WAIT_MS` | `20000` | how long a decision answer waits for its run before answering it as recorded and running, at most 50000 |
|
|
102
106
|
|
|
103
107
|
On gaia it runs as the user service `miadi-connector.service`, and the Cloudflare tunnel `gaia` routes `mcp.sanctuaireagentique.com` to it.
|
|
104
108
|
|
|
@@ -109,6 +113,8 @@ On gaia it runs as the user service `miadi-connector.service`, and the Cloudflar
|
|
|
109
113
|
- Miadi decides who a token belongs to (`GET /api/identity/me`), and is asked again at most every 5 minutes while a sign-in is used. A token Miadi no longer recognises ends the sign-in, and the tier Miadi returns now is the one applied. When Miadi cannot be reached, sign-in says so (503) and an existing sign-in keeps the person as last known.
|
|
110
114
|
- What a requester wrote is placed in an approved run's brief with every tag-opening `<` neutralised, so the brief holds one block with the connector's own attributes.
|
|
111
115
|
- Only the loopback hop (cloudflared) is trusted for the client address. The OAuth endpoints, the forms that take a person token, and failed `/mcp` authentications are rate limited per address.
|
|
116
|
+
- A card's decision token lasts 7 days. Its lifetime is not what protects a request: using it also needs the same person's bearer, for the same client, carrying `miadi:approve`, and the write tier Miadi gives that person now. That bearer can already ask `show_decisions` for a fresh token, so a longer lifetime gives a stolen token nothing a stolen bearer does not.
|
|
117
|
+
- Each step of a decision is one JSON line on stdout (`"evt":"connector.decision"`, read with `journalctl --user -u miadi-connector`): the request, a correlation id, the door, timings, the outcome and the HTTP status the run met. No token and no instructions are logged.
|
|
112
118
|
- Outbound calls time out: Miadi 30 s (episode doors 120 s), the review service 15 s (approved review operations 290 s), GitHub 15 s.
|
|
113
119
|
|
|
114
120
|
## Test
|
package/dist/approve.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* is appended as a record that supersedes the request (state, approval, receipt).
|
|
11
11
|
*/
|
|
12
12
|
import { type Express } from "express";
|
|
13
|
-
import type
|
|
13
|
+
import { type ConnectorConfig } from "./config.js";
|
|
14
14
|
import type { Ledger } from "./store.js";
|
|
15
15
|
import type { MiadiClient } from "./miadi.js";
|
|
16
16
|
import { type MiadiPerson } from "./people.js";
|
|
@@ -31,7 +31,21 @@ export interface ApprovalDeps {
|
|
|
31
31
|
tmux?: TmuxLike;
|
|
32
32
|
/** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
|
|
33
33
|
steerSettleMs?: number;
|
|
34
|
+
/** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
|
|
35
|
+
log?: (event: Record<string, unknown>) => void;
|
|
34
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* One line per step of a decision, for the journal: the request, a correlation id,
|
|
39
|
+
* the door it came through, timings, the outcome and the HTTP status the run met.
|
|
40
|
+
* Never a token, never instructions (jgwill/Miadi#924).
|
|
41
|
+
*/
|
|
42
|
+
export declare function logDecision(deps: Pick<ApprovalDeps, "log">, event: Record<string, unknown>): void;
|
|
43
|
+
/** How long a decision answer waits for its run before saying it is recorded and still running. */
|
|
44
|
+
export declare function decisionWaitOf(deps: Pick<ApprovalDeps, "config">): number;
|
|
45
|
+
/** What `settleWithin` answers when the promise has not settled in time; the promise goes on. */
|
|
46
|
+
export declare const STILL_RUNNING: unique symbol;
|
|
47
|
+
/** The promise's value if it settles within `ms`, else STILL_RUNNING. A rejection after the bound is caught, never unhandled. */
|
|
48
|
+
export declare function settleWithin<T>(p: Promise<T>, ms: number): Promise<T | typeof STILL_RUNNING>;
|
|
35
49
|
/**
|
|
36
50
|
* What a person may decide on a request. Approving with changes runs it with the
|
|
37
51
|
* approver's instructions carried into what runs; a revision sends it back to
|
|
@@ -54,27 +68,55 @@ export declare function executeApproved(record: ConnectorRecord, approver: Miadi
|
|
|
54
68
|
* caller has already checked who the person is, their write tier, and that the
|
|
55
69
|
* request still awaits approval.
|
|
56
70
|
*/
|
|
57
|
-
/** A request is decided once: a second decision while one runs, or after it, is refused. */
|
|
71
|
+
/** A request is decided once: a second, different decision while one runs, or after it, is refused. */
|
|
58
72
|
export declare class AlreadyDecided extends Error {
|
|
59
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A decision that differs from the one recorded or being recorded (another choice, or other
|
|
76
|
+
* instructions). The same decision sent again is not refused: it answers with the first one's receipt (#924).
|
|
77
|
+
*/
|
|
78
|
+
export declare class DecisionConflict extends AlreadyDecided {
|
|
79
|
+
}
|
|
60
80
|
/** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
|
|
61
81
|
export declare class StaleVersion extends AlreadyDecided {
|
|
62
82
|
readonly currentVersion: number;
|
|
63
83
|
readonly decidedVersion: number;
|
|
64
84
|
constructor(currentVersion: number, decidedVersion: number);
|
|
65
85
|
}
|
|
86
|
+
/** What a decision answers: the state it left, its receipt, and `replayed` when it repeats a decision already taken. */
|
|
87
|
+
export interface Decided {
|
|
88
|
+
decision: NonNullable<ConnectorRecord["approval_state"]>;
|
|
89
|
+
state: ConnectorRecord["state"];
|
|
90
|
+
receipt: Record<string, unknown>;
|
|
91
|
+
/** The same decision was sent again (a retried call, a second click): this is the first one's answer, nothing ran twice. */
|
|
92
|
+
replayed?: boolean;
|
|
93
|
+
}
|
|
66
94
|
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
67
95
|
export declare function isBeingDecided(recordId: string): boolean;
|
|
96
|
+
/** The approval state a choice leaves a request in. */
|
|
97
|
+
export declare function approvalStateOf(choice: Decision): NonNullable<ConnectorRecord["approval_state"]>;
|
|
98
|
+
/**
|
|
99
|
+
* Record a person's decision once. The page and the card (or two cards, or a host
|
|
100
|
+
* that retries a call it gave up waiting on) may send it more than once:
|
|
101
|
+
* - the same decision while the first is being recorded waits for that one and
|
|
102
|
+
* answers with its receipt, marked `replayed`; nothing runs twice;
|
|
103
|
+
* - the same decision after it was recorded answers with the recorded receipt;
|
|
104
|
+
* - a different decision is refused as a DecisionConflict (jgwill/Miadi#924).
|
|
105
|
+
*/
|
|
68
106
|
export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice: Decision, deps: ApprovalDeps, opts: {
|
|
69
107
|
instructions?: string;
|
|
70
108
|
note?: string;
|
|
71
109
|
via: string;
|
|
72
110
|
expectedVersion?: number;
|
|
73
|
-
}): Promise<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
}): Promise<Decided>;
|
|
112
|
+
/** What the receipt of a run the connector lost to a restart says (jgwill/Miadi#924). */
|
|
113
|
+
export declare const INTERRUPTED_MESSAGE = "The connector stopped while this ran, so whether it finished is not known. Read what it was to change before running it again. A review create is safe to run again: the review service answers with the review it already holds for that video.";
|
|
114
|
+
/**
|
|
115
|
+
* A run recorded as running whose process is gone (the connector restarted mid-run)
|
|
116
|
+
* becomes a failed run that a person may run again. Called once when the connector
|
|
117
|
+
* starts; a run this process is still recording is left alone.
|
|
118
|
+
*/
|
|
119
|
+
export declare function reconcileInterrupted(deps: Pick<ApprovalDeps, "ledger" | "log">): string[];
|
|
78
120
|
/** Whether a request was approved and its run failed, so a person may run it again. */
|
|
79
121
|
export declare function isRerunnable(r: ConnectorRecord | undefined): boolean;
|
|
80
122
|
/**
|
package/dist/approve.js
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
import express from "express";
|
|
13
13
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { escapeHtml } from "@miadi/node-service-kit/format";
|
|
18
|
+
import { DEFAULT_DECISION_WAIT_MS } from "./config.js";
|
|
17
19
|
import { MiadiError } from "./miadi.js";
|
|
18
20
|
import { MiadiUnavailable } from "./people.js";
|
|
19
21
|
import { current, newRecordId } from "./records.js";
|
|
@@ -23,6 +25,42 @@ import { factoryLedgerPath, readFactoryReceipts, receiptsForRecord } from "./fac
|
|
|
23
25
|
import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
|
|
24
26
|
import { contentHash, versionOf } from "./revisions.js";
|
|
25
27
|
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
28
|
+
/**
|
|
29
|
+
* One line per step of a decision, for the journal: the request, a correlation id,
|
|
30
|
+
* the door it came through, timings, the outcome and the HTTP status the run met.
|
|
31
|
+
* Never a token, never instructions (jgwill/Miadi#924).
|
|
32
|
+
*/
|
|
33
|
+
export function logDecision(deps, event) {
|
|
34
|
+
const line = { evt: "connector.decision", at: new Date().toISOString(), ...event };
|
|
35
|
+
try {
|
|
36
|
+
(deps.log ?? ((e) => console.log(JSON.stringify(e))))(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* a log that fails never fails a decision */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** How long a decision answer waits for its run before saying it is recorded and still running. */
|
|
43
|
+
export function decisionWaitOf(deps) {
|
|
44
|
+
return deps.config.decisionWaitMs ?? DEFAULT_DECISION_WAIT_MS;
|
|
45
|
+
}
|
|
46
|
+
/** What `settleWithin` answers when the promise has not settled in time; the promise goes on. */
|
|
47
|
+
export const STILL_RUNNING = Symbol("still_running");
|
|
48
|
+
/** The promise's value if it settles within `ms`, else STILL_RUNNING. A rejection after the bound is caught, never unhandled. */
|
|
49
|
+
export async function settleWithin(p, ms) {
|
|
50
|
+
let timer;
|
|
51
|
+
const bound = new Promise((resolve) => {
|
|
52
|
+
timer = setTimeout(() => resolve(STILL_RUNNING), ms);
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const out = await Promise.race([p, bound]);
|
|
56
|
+
if (out === STILL_RUNNING)
|
|
57
|
+
p.catch(() => { });
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
26
64
|
/**
|
|
27
65
|
* What a person may decide on a request. Approving with changes runs it with the
|
|
28
66
|
* approver's instructions carried into what runs; a revision sends it back to
|
|
@@ -300,9 +338,15 @@ ${versionOf(r) > 1 ? `<dt>Version</dt><dd>${versionOf(r)}, the one a decision he
|
|
|
300
338
|
* caller has already checked who the person is, their write tier, and that the
|
|
301
339
|
* request still awaits approval.
|
|
302
340
|
*/
|
|
303
|
-
/** A request is decided once: a second decision while one runs, or after it, is refused. */
|
|
341
|
+
/** A request is decided once: a second, different decision while one runs, or after it, is refused. */
|
|
304
342
|
export class AlreadyDecided extends Error {
|
|
305
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* A decision that differs from the one recorded or being recorded (another choice, or other
|
|
346
|
+
* instructions). The same decision sent again is not refused: it answers with the first one's receipt (#924).
|
|
347
|
+
*/
|
|
348
|
+
export class DecisionConflict extends AlreadyDecided {
|
|
349
|
+
}
|
|
306
350
|
/** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
|
|
307
351
|
export class StaleVersion extends AlreadyDecided {
|
|
308
352
|
currentVersion;
|
|
@@ -313,32 +357,110 @@ export class StaleVersion extends AlreadyDecided {
|
|
|
313
357
|
this.decidedVersion = decidedVersion;
|
|
314
358
|
}
|
|
315
359
|
}
|
|
316
|
-
|
|
360
|
+
/** The decisions being recorded now, by request id: one at a time per request, in this process. */
|
|
361
|
+
const deciding = new Map();
|
|
317
362
|
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
318
363
|
export function isBeingDecided(recordId) {
|
|
319
364
|
return deciding.has(recordId);
|
|
320
365
|
}
|
|
366
|
+
const APPROVAL_OF = {
|
|
367
|
+
approve: "approved",
|
|
368
|
+
approve_with_changes: "approved",
|
|
369
|
+
request_revision: "revision_requested",
|
|
370
|
+
decline: "declined",
|
|
371
|
+
};
|
|
372
|
+
/** The approval state a choice leaves a request in. */
|
|
373
|
+
export function approvalStateOf(choice) {
|
|
374
|
+
return APPROVAL_OF[choice];
|
|
375
|
+
}
|
|
376
|
+
const sameDecision = (a, b) => a.choice === b.choice && String(a.instructions ?? "").trim() === String(b.instructions ?? "").trim();
|
|
377
|
+
/**
|
|
378
|
+
* Record a person's decision once. The page and the card (or two cards, or a host
|
|
379
|
+
* that retries a call it gave up waiting on) may send it more than once:
|
|
380
|
+
* - the same decision while the first is being recorded waits for that one and
|
|
381
|
+
* answers with its receipt, marked `replayed`; nothing runs twice;
|
|
382
|
+
* - the same decision after it was recorded answers with the recorded receipt;
|
|
383
|
+
* - a different decision is refused as a DecisionConflict (jgwill/Miadi#924).
|
|
384
|
+
*/
|
|
321
385
|
export async function decide(r, person, choice, deps, opts) {
|
|
322
386
|
// Checked again here, with no await between the check and the claim: the
|
|
323
387
|
// page and the card (or two cards) may decide the same request at once, and
|
|
324
388
|
// a revision may land between reading a version and deciding it (#919).
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
389
|
+
const asked = { choice, instructions: opts.instructions };
|
|
390
|
+
const inflight = deciding.get(r.id);
|
|
391
|
+
if (inflight) {
|
|
392
|
+
if (inflight.promise && inflight.choice !== "rerun" && sameDecision({ choice: inflight.choice, instructions: inflight.instructions }, asked)) {
|
|
393
|
+
logDecision(deps, { phase: "replay", record: r.id, correlation: inflight.correlation, via: opts.via, choice, while: "recording" });
|
|
394
|
+
return { ...(await inflight.promise), replayed: true };
|
|
395
|
+
}
|
|
396
|
+
logDecision(deps, { phase: "conflict", record: r.id, correlation: inflight.correlation, via: opts.via, choice, while: "recording" });
|
|
397
|
+
throw new DecisionConflict(inflight.choice === "rerun" ? "This request is being run again." : "A different decision on this request is being recorded.");
|
|
328
398
|
}
|
|
399
|
+
const live = current(deps.ledger.all()).find((x) => x.id === r.id);
|
|
400
|
+
if (!live)
|
|
401
|
+
throw new AlreadyDecided("This request is gone.");
|
|
329
402
|
// A decision that names no version (a page opened before versions, a surface that sends none) decides version 1:
|
|
330
403
|
// a request revised since is refused, never decided blind (#919 review F1).
|
|
331
404
|
const decidedVersion = opts.expectedVersion ?? 1;
|
|
405
|
+
if (live.approval_state !== "awaiting_approval") {
|
|
406
|
+
const prior = (live.completion_receipt ?? {});
|
|
407
|
+
const priorVersion = typeof prior.version === "number" ? prior.version : 1;
|
|
408
|
+
if (typeof prior.decision === "string" && sameDecision({ choice: prior.decision, instructions: prior.instructions }, asked) && priorVersion === decidedVersion) {
|
|
409
|
+
logDecision(deps, { phase: "replay", record: r.id, via: opts.via, choice, while: "recorded", state: live.state });
|
|
410
|
+
return { decision: live.approval_state, state: live.state, receipt: prior, replayed: true };
|
|
411
|
+
}
|
|
412
|
+
logDecision(deps, { phase: "conflict", record: r.id, via: opts.via, choice, while: "recorded", recorded: prior.decision });
|
|
413
|
+
throw new DecisionConflict(`This request is already ${live.approval_state ?? "gone"}.`);
|
|
414
|
+
}
|
|
332
415
|
if (versionOf(live) !== decidedVersion)
|
|
333
416
|
throw new StaleVersion(versionOf(live), decidedVersion);
|
|
334
|
-
|
|
417
|
+
const entry = { ...asked, correlation: randomUUID(), started: Date.now() };
|
|
418
|
+
deciding.set(r.id, entry);
|
|
419
|
+
entry.promise = decideOnce(live, person, choice, deps, { ...opts, correlation: entry.correlation, started: entry.started });
|
|
335
420
|
try {
|
|
336
|
-
return await
|
|
421
|
+
return await entry.promise;
|
|
337
422
|
}
|
|
338
423
|
finally {
|
|
339
424
|
deciding.delete(r.id);
|
|
340
425
|
}
|
|
341
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* The kinds whose run is done at once on this host (a brief written to the
|
|
429
|
+
* factory queue, an ending recorded): nothing outside is called, so nothing is
|
|
430
|
+
* written ahead of it. Every other approved run is recorded as running first.
|
|
431
|
+
*/
|
|
432
|
+
const RUNS_AT_ONCE = new Set(["run_request", "work_proposal", "session_completion_proposal"]);
|
|
433
|
+
/** What the receipt of a run the connector lost to a restart says (jgwill/Miadi#924). */
|
|
434
|
+
export const INTERRUPTED_MESSAGE = "The connector stopped while this ran, so whether it finished is not known. Read what it was to change before running it again. A review create is safe to run again: the review service answers with the review it already holds for that video.";
|
|
435
|
+
/**
|
|
436
|
+
* A run recorded as running whose process is gone (the connector restarted mid-run)
|
|
437
|
+
* becomes a failed run that a person may run again. Called once when the connector
|
|
438
|
+
* starts; a run this process is still recording is left alone.
|
|
439
|
+
*/
|
|
440
|
+
export function reconcileInterrupted(deps) {
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const r of current(deps.ledger.all())) {
|
|
443
|
+
const receipt = (r.completion_receipt ?? {});
|
|
444
|
+
if (r.approval_state !== "approved" || r.state !== "in_progress" || receipt.outcome !== "running" || deciding.has(r.id))
|
|
445
|
+
continue;
|
|
446
|
+
deps.ledger.append({
|
|
447
|
+
id: newRecordId(r.kind),
|
|
448
|
+
kind: r.kind,
|
|
449
|
+
created_at: new Date().toISOString(),
|
|
450
|
+
actor: { person_id: "system:miadi-connector", name: "Miadi connector", client_id: "restart" },
|
|
451
|
+
refs: r.refs,
|
|
452
|
+
evidence_refs: [],
|
|
453
|
+
supersedes: r.id,
|
|
454
|
+
approval_state: "approved",
|
|
455
|
+
state: "blocked",
|
|
456
|
+
completion_receipt: { ...receipt, outcome: "failed", interrupted: true, message: INTERRUPTED_MESSAGE, interrupted_at: new Date().toISOString() },
|
|
457
|
+
body: { decision: "interrupted" },
|
|
458
|
+
});
|
|
459
|
+
logDecision(deps, { phase: "interrupted", record: r.id, correlation: receipt.correlation, started_at: receipt.started_at });
|
|
460
|
+
out.push(r.id);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
342
464
|
/** Whether a request was approved and its run failed, so a person may run it again. */
|
|
343
465
|
export function isRerunnable(r) {
|
|
344
466
|
return !!r && r.approval_state === "approved" && r.state === "blocked";
|
|
@@ -355,7 +477,7 @@ export async function rerun(r, person, deps, opts) {
|
|
|
355
477
|
if (!isRerunnable(live) || deciding.has(r.id)) {
|
|
356
478
|
throw new AlreadyDecided(deciding.has(r.id) ? "This request is being run." : `This request is ${live?.approval_state ?? "gone"} and ${live?.state ?? "unknown"}; only an approved request whose run failed is run again.`);
|
|
357
479
|
}
|
|
358
|
-
deciding.
|
|
480
|
+
deciding.set(r.id, { choice: "rerun", correlation: randomUUID(), started: Date.now() });
|
|
359
481
|
try {
|
|
360
482
|
const prior = (live.completion_receipt ?? {});
|
|
361
483
|
const instructions = typeof prior.instructions === "string" ? prior.instructions : undefined;
|
|
@@ -403,7 +525,8 @@ export async function rerun(r, person, deps, opts) {
|
|
|
403
525
|
}
|
|
404
526
|
async function decideOnce(r, person, choice, deps, opts) {
|
|
405
527
|
const { instructions } = opts;
|
|
406
|
-
const decision = choice
|
|
528
|
+
const decision = approvalStateOf(choice);
|
|
529
|
+
logDecision(deps, { phase: "start", record: r.id, kind: r.kind, correlation: opts.correlation, via: opts.via, choice, version: versionOf(r) });
|
|
407
530
|
let receipt = {
|
|
408
531
|
decided_by: { person_id: person.id, name: person.name },
|
|
409
532
|
decided_at: new Date().toISOString(),
|
|
@@ -414,9 +537,28 @@ async function decideOnce(r, person, choice, deps, opts) {
|
|
|
414
537
|
via: opts.via,
|
|
415
538
|
...(instructions ? { instructions } : {}),
|
|
416
539
|
note: opts.note,
|
|
540
|
+
correlation: opts.correlation,
|
|
417
541
|
};
|
|
418
542
|
let state = decision === "approved" ? "completed" : decision === "revision_requested" ? "pending" : "withdrawn";
|
|
419
543
|
if (decision === "approved") {
|
|
544
|
+
if (!RUNS_AT_ONCE.has(r.kind)) {
|
|
545
|
+
// Written ahead of the run: the decision is in the ledger before anything is called, so a restart
|
|
546
|
+
// mid-run can never offer the request for a second approval, and every surface reads it as running (#924).
|
|
547
|
+
deps.ledger.append({
|
|
548
|
+
id: newRecordId(r.kind),
|
|
549
|
+
kind: r.kind,
|
|
550
|
+
created_at: new Date().toISOString(),
|
|
551
|
+
actor: { person_id: person.id, name: person.name, client_id: opts.via },
|
|
552
|
+
refs: r.refs,
|
|
553
|
+
evidence_refs: [],
|
|
554
|
+
supersedes: r.id,
|
|
555
|
+
approval_state: decision,
|
|
556
|
+
state: "in_progress",
|
|
557
|
+
completion_receipt: { ...receipt, outcome: "running", started_at: new Date(opts.started).toISOString() },
|
|
558
|
+
body: { decision: choice, ...(instructions ? { instructions } : {}) },
|
|
559
|
+
});
|
|
560
|
+
logDecision(deps, { phase: "running", record: r.id, correlation: opts.correlation, ms: Date.now() - opts.started });
|
|
561
|
+
}
|
|
420
562
|
try {
|
|
421
563
|
const outcome = await executeApproved(r, person, deps, instructions);
|
|
422
564
|
receipt = { ...receipt, ...outcome };
|
|
@@ -444,6 +586,16 @@ async function decideOnce(r, person, choice, deps, opts) {
|
|
|
444
586
|
completion_receipt: receipt,
|
|
445
587
|
body: { decision: choice, ...(instructions ? { instructions } : {}) },
|
|
446
588
|
});
|
|
589
|
+
logDecision(deps, {
|
|
590
|
+
phase: "done",
|
|
591
|
+
record: r.id,
|
|
592
|
+
correlation: opts.correlation,
|
|
593
|
+
decision,
|
|
594
|
+
state,
|
|
595
|
+
outcome: receipt.outcome,
|
|
596
|
+
...(typeof receipt.status === "number" ? { status: receipt.status } : {}),
|
|
597
|
+
ms: Date.now() - opts.started,
|
|
598
|
+
});
|
|
447
599
|
return { decision, state, receipt };
|
|
448
600
|
}
|
|
449
601
|
export function isApprovable(r) {
|
|
@@ -523,12 +675,11 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
523
675
|
const failedAgain = ran.receipt.outcome === "failed";
|
|
524
676
|
return void res.type("html").send(page("Recorded", `<h1>${failedAgain ? "Run again, and it failed again" : "Run again"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(ran.receipt, null, 2))}</pre>`));
|
|
525
677
|
}
|
|
526
|
-
if (r.approval_state !== "awaiting_approval") {
|
|
527
|
-
return void res.status(409).type("html").send(approvePage(deps, r, { error: `This request is already ${r.approval_state}.` }));
|
|
528
|
-
}
|
|
529
678
|
const choice = DECISIONS.includes(String(body.decision)) ? body.decision : "decline";
|
|
530
679
|
const instructions = String(body.instructions ?? "").trim().slice(0, 20000) || undefined;
|
|
531
|
-
|
|
680
|
+
const via = String(body.via ?? "approval-page").slice(0, 60);
|
|
681
|
+
// Only a decision that would be recorded now needs its instructions; a form sent again after it was recorded is answered below (#924).
|
|
682
|
+
if (r.approval_state === "awaiting_approval" && !isBeingDecided(r.id) && (choice === "approve_with_changes" || choice === "request_revision") && !instructions) {
|
|
532
683
|
return void res
|
|
533
684
|
.status(400)
|
|
534
685
|
.type("html")
|
|
@@ -537,12 +688,21 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
537
688
|
let decided;
|
|
538
689
|
try {
|
|
539
690
|
const shown = Number(body.version);
|
|
540
|
-
|
|
691
|
+
const running = decide(r, person, choice, deps, {
|
|
541
692
|
instructions,
|
|
542
693
|
note: body.note || undefined,
|
|
543
|
-
via
|
|
694
|
+
via,
|
|
544
695
|
...(Number.isInteger(shown) && shown >= 1 ? { expectedVersion: shown } : {}),
|
|
545
696
|
});
|
|
697
|
+
// A run that takes longer than a proxy waits (the review service may take minutes) is answered as
|
|
698
|
+
// recorded and running; the run goes on, and signing in again shows how it ended (#924).
|
|
699
|
+
const settled = await settleWithin(running, decisionWaitOf(deps));
|
|
700
|
+
if (settled === STILL_RUNNING) {
|
|
701
|
+
logDecision(deps, { phase: "answered_running", record: r.id, via, choice });
|
|
702
|
+
const now = find(r.id);
|
|
703
|
+
return void res.status(202).type("html").send(page("Recorded", `<h1>${escapeHtml(projectionOf(now, deps)?.label ?? "Being recorded")}</h1>${summary(now)}${projectionBlock(projectionOf(now, deps), undefined)}${refreshForm(token)}`));
|
|
704
|
+
}
|
|
705
|
+
decided = settled;
|
|
546
706
|
}
|
|
547
707
|
catch (error) {
|
|
548
708
|
if (error instanceof StaleVersion)
|
|
@@ -551,18 +711,21 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
551
711
|
return void res.status(409).type("html").send(approvePage(deps, find(r.id), { error: error.message }));
|
|
552
712
|
throw error;
|
|
553
713
|
}
|
|
554
|
-
const { decision, receipt } = decided;
|
|
714
|
+
const { decision, receipt, replayed } = decided;
|
|
555
715
|
const p = projectionOf(find(r.id), deps);
|
|
556
716
|
const heading = decision === "approved"
|
|
557
717
|
? p?.stage === "failed"
|
|
558
718
|
? "Approved, but running it failed"
|
|
559
|
-
: p?.
|
|
560
|
-
? "Approved,
|
|
561
|
-
:
|
|
719
|
+
: p?.stage === "recording"
|
|
720
|
+
? "Approved, running"
|
|
721
|
+
: p?.outstanding.length
|
|
722
|
+
? "Approved, changes still to do"
|
|
723
|
+
: "Approved"
|
|
562
724
|
: decision === "revision_requested"
|
|
563
725
|
? "Sent back for revision"
|
|
564
726
|
: "Declined";
|
|
565
|
-
|
|
727
|
+
const again = replayed ? "<p>This decision was already recorded. Nothing ran again; here is where it stands.</p>" : "";
|
|
728
|
+
res.type("html").send(page("Recorded", `<h1>${heading}</h1>${again}${summary(find(r.id))}${projectionBlock(p, receipt)}${p?.stage === "recording" ? refreshForm(token) : ""}`));
|
|
566
729
|
});
|
|
567
730
|
// A record's own page, for citations in ChatGPT: its author and anyone with Miadi's read tier may read it.
|
|
568
731
|
app.get("/records/:id", (req, res) => {
|
|
@@ -589,6 +752,10 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
589
752
|
res.type("html").send(page("Miadi record", `<h1>${escapeHtml(r.kind)}</h1>${summary(r)}`));
|
|
590
753
|
});
|
|
591
754
|
}
|
|
755
|
+
/** A button that shows where a running request stands now, signed in with the token the person just used. */
|
|
756
|
+
function refreshForm(token) {
|
|
757
|
+
return `<form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}"><div class="row"><button class="primary" type="submit">Show where it stands now</button></div></form>`;
|
|
758
|
+
}
|
|
592
759
|
function recordForm(id, error) {
|
|
593
760
|
return `<h1>Miadi record</h1><p>${escapeHtml(id)}</p>
|
|
594
761
|
${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
@@ -658,12 +825,14 @@ ${actionable && o.token
|
|
|
658
825
|
</div></form>`
|
|
659
826
|
: actionable
|
|
660
827
|
? "<p>A person with Miadi's write tier decides this request.</p>"
|
|
661
|
-
:
|
|
662
|
-
?
|
|
828
|
+
: o.token && projectionOf(r, deps)?.stage === "recording"
|
|
829
|
+
? refreshForm(o.token)
|
|
830
|
+
: isRerunnable(r) && o.token
|
|
831
|
+
? `<p>It was approved, and running it failed. When what it calls has been fixed, it can be run again with the same approval and instructions; the ledger records who ran it again.</p>
|
|
663
832
|
<form method="post">
|
|
664
833
|
<input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
|
|
665
834
|
<div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
|
|
666
|
-
|
|
835
|
+
: ""}`);
|
|
667
836
|
}
|
|
668
837
|
/** How a request is named to a person. */
|
|
669
838
|
export const KIND_LABELS = {
|
package/dist/config.d.ts
CHANGED
|
@@ -14,5 +14,17 @@ export interface ConnectorConfig {
|
|
|
14
14
|
inquiryRoot?: string;
|
|
15
15
|
/** owner/repo a saved report's inquiry issue is opened in (MIADI_INQUIRY_GITHUB_REPO). */
|
|
16
16
|
inquiryRepo?: string;
|
|
17
|
+
/**
|
|
18
|
+
* How long a card's decision token stays usable, in seconds (MIADI_CONNECTOR_DECISION_TOKEN_TTL).
|
|
19
|
+
* Default 7 days, held between 5 minutes and 30 days. The request itself does not expire (jgwill/Miadi#924).
|
|
20
|
+
*/
|
|
21
|
+
decisionTokenTtl?: number;
|
|
22
|
+
/**
|
|
23
|
+
* How long a decision answer waits for the run it starts, in milliseconds (MIADI_CONNECTOR_DECISION_WAIT_MS).
|
|
24
|
+
* Past it the answer says the decision is recorded and still running, and the run goes on. Default 20 s, at most 50 s.
|
|
25
|
+
*/
|
|
26
|
+
decisionWaitMs?: number;
|
|
17
27
|
}
|
|
28
|
+
export declare const DEFAULT_DECISION_TOKEN_TTL: number;
|
|
29
|
+
export declare const DEFAULT_DECISION_WAIT_MS = 20000;
|
|
18
30
|
export declare function configFromEnv(env?: NodeJS.ProcessEnv): ConnectorConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { resolveEnv } from "@miadi/inquiry-weave";
|
|
2
2
|
import { reviewToken } from "./reviews.js";
|
|
3
|
+
export const DEFAULT_DECISION_TOKEN_TTL = 7 * 24 * 3600;
|
|
4
|
+
export const DEFAULT_DECISION_WAIT_MS = 20_000;
|
|
5
|
+
/** A number from the environment held between two bounds; anything unreadable is the default. */
|
|
6
|
+
function bounded(raw, fallback, min, max) {
|
|
7
|
+
const n = Number(raw);
|
|
8
|
+
if (raw === undefined || raw.trim() === "" || !Number.isFinite(n))
|
|
9
|
+
return fallback;
|
|
10
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
11
|
+
}
|
|
3
12
|
export function configFromEnv(env = process.env) {
|
|
4
13
|
const port = Number(env.MIADI_CONNECTOR_PORT || 3337);
|
|
5
14
|
const shelf = resolveEnv({}, env);
|
|
@@ -14,5 +23,7 @@ export function configFromEnv(env = process.env) {
|
|
|
14
23
|
stateDir: env.MIADI_CONNECTOR_STATE_DIR || "/srv/miadi/connector",
|
|
15
24
|
inquiryRoot: shelf.inquiryRoot,
|
|
16
25
|
inquiryRepo: shelf.repo,
|
|
26
|
+
decisionTokenTtl: bounded(env.MIADI_CONNECTOR_DECISION_TOKEN_TTL, DEFAULT_DECISION_TOKEN_TTL, 300, 30 * 24 * 3600),
|
|
27
|
+
decisionWaitMs: bounded(env.MIADI_CONNECTOR_DECISION_WAIT_MS, DEFAULT_DECISION_WAIT_MS, 0, 50_000),
|
|
17
28
|
};
|
|
18
29
|
}
|
package/dist/decisions.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
|
|
2
|
+
/** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
|
|
2
3
|
export declare const DECISION_TOKEN_TTL: number;
|
|
3
4
|
/** Where the tokens travel in a result's `_meta`, keyed by record id. */
|
|
4
5
|
export declare const DECISION_TOKENS_META = "miadi/decisionTokens";
|
|
5
6
|
export declare class DecisionTokens {
|
|
7
|
+
readonly ttl: number;
|
|
6
8
|
private readonly secret;
|
|
7
|
-
constructor(stateDir: string);
|
|
9
|
+
constructor(stateDir: string, ttl?: number);
|
|
8
10
|
private sign;
|
|
9
11
|
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
10
12
|
issue(recordId: string, personId: string, clientId: string, now?: number, version?: number): string;
|
|
@@ -17,4 +19,4 @@ export declare class DecisionTokens {
|
|
|
17
19
|
* and calls `decide_request` through the host. In a host without that bridge it
|
|
18
20
|
* shows each request's approval page instead.
|
|
19
21
|
*/
|
|
20
|
-
export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){\n var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));\n var link=el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\");p.appendChild(link);a.appendChild(p);return a;\n }\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&out.projection;\n var already=!!(out&&out.error===\"already_decided\");\n var ok=out&&!out.error;\n delete pending[r.id];\n if(proj){decided[r.id]=projectionView(proj,already?\"This request was already decided. Here is where it stands.\":null);}\n else{decided[r.id]={cls:ok?\"done\":\"fail\",label:ok?(out.summary||\"Recorded.\"):((out&&out.message)||\"Not recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){delete pending[r.id];errors[r.id]=\"Not recorded: \"+(err&&err.message?err.message:String(err));render();});\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
|
|
22
|
+
export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
|
package/dist/decisions.js
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
* so the host does not offer it to the model;
|
|
8
8
|
* - it requires a decision token that travels only in the `_meta` of the
|
|
9
9
|
* `show_decisions` result, which hosts deliver to the card and keep from the
|
|
10
|
-
* model. The token is bound to one request, one person and one
|
|
11
|
-
* and expires. A request decided once is no
|
|
12
|
-
* token cannot
|
|
10
|
+
* model. The token is bound to one request, its version, one person and one
|
|
11
|
+
* OAuth client, and expires (7 days by default). A request decided once is no
|
|
12
|
+
* longer awaiting approval, so a token cannot decide twice.
|
|
13
|
+
*
|
|
14
|
+
* The token's lifetime is not what keeps a request safe: using it needs the same
|
|
15
|
+
* person's bearer for the same client with `miadi:approve`, and that bearer can
|
|
16
|
+
* already ask show_decisions for a fresh token. A short lifetime only made cards
|
|
17
|
+
* left open for a few hours refuse their person (jgwill/Miadi#924).
|
|
13
18
|
*
|
|
14
19
|
* The sign-in must also carry `miadi:approve`, which a person grants by ticking
|
|
15
20
|
* it, and Miadi must give them the write tier.
|
|
@@ -18,13 +23,17 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
18
23
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
24
|
import { join } from "node:path";
|
|
20
25
|
import { newSecret } from "./store.js";
|
|
26
|
+
import { DEFAULT_DECISION_TOKEN_TTL } from "./config.js";
|
|
21
27
|
export const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
|
|
22
|
-
|
|
28
|
+
/** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
|
|
29
|
+
export const DECISION_TOKEN_TTL = DEFAULT_DECISION_TOKEN_TTL;
|
|
23
30
|
/** Where the tokens travel in a result's `_meta`, keyed by record id. */
|
|
24
31
|
export const DECISION_TOKENS_META = "miadi/decisionTokens";
|
|
25
32
|
export class DecisionTokens {
|
|
33
|
+
ttl;
|
|
26
34
|
secret;
|
|
27
|
-
constructor(stateDir) {
|
|
35
|
+
constructor(stateDir, ttl = DECISION_TOKEN_TTL) {
|
|
36
|
+
this.ttl = ttl;
|
|
28
37
|
const file = join(stateDir, "decision-secret");
|
|
29
38
|
if (!existsSync(file))
|
|
30
39
|
writeFileSync(file, newSecret(32), { mode: 0o600, flag: "wx" });
|
|
@@ -37,7 +46,7 @@ export class DecisionTokens {
|
|
|
37
46
|
}
|
|
38
47
|
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
39
48
|
issue(recordId, personId, clientId, now = Math.floor(Date.now() / 1000), version = 1) {
|
|
40
|
-
const exp = now +
|
|
49
|
+
const exp = now + this.ttl;
|
|
41
50
|
return `${exp}.v${version}.${this.sign(recordId, personId, clientId, exp, version)}`;
|
|
42
51
|
}
|
|
43
52
|
/** `stale`: the token is genuine but was minted for a version the request no longer holds. */
|
|
@@ -98,6 +107,10 @@ a{color:inherit}
|
|
|
98
107
|
// A redraw while a decision is being recorded must not offer the buttons again.
|
|
99
108
|
var pending={};
|
|
100
109
|
var errors={};
|
|
110
|
+
// A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.
|
|
111
|
+
var POLL_MS=3000,POLL_MAX=100,polls={};
|
|
112
|
+
// An error that leaves the request open offers its approval page under it.
|
|
113
|
+
var relink={};
|
|
101
114
|
function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k==="class")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}
|
|
102
115
|
function host(){return window.openai||null;}
|
|
103
116
|
function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}
|
|
@@ -125,7 +138,23 @@ a{color:inherit}
|
|
|
125
138
|
if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}
|
|
126
139
|
return out;
|
|
127
140
|
}
|
|
128
|
-
function projectionView(p,note){return {cls:p.stage==="failed"?"fail":"done",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}
|
|
141
|
+
function projectionView(p,note){return {cls:p.stage==="failed"?"fail":["recording","dispatching"].indexOf(p.stage)!==-1?"note":"done",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}
|
|
142
|
+
// Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.
|
|
143
|
+
function follow(r,lost){
|
|
144
|
+
if(!host()||!host().callTool)return;
|
|
145
|
+
polls[r.id]=(polls[r.id]||0)+1;
|
|
146
|
+
host().callTool("get_run_result",{record_id:r.id}).then(function(res){
|
|
147
|
+
var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));
|
|
148
|
+
if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}
|
|
149
|
+
if(p.stage==="awaiting_decision"){delete decided[r.id];if(lost)errors[r.id]=lost+" Nothing was decided; decide again.";render();return;}
|
|
150
|
+
var note=decided[r.id]&&decided[r.id].note;
|
|
151
|
+
decided[r.id]=projectionView(p,note);delete errors[r.id];render();
|
|
152
|
+
if(p.stage==="recording"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);
|
|
153
|
+
},function(err){
|
|
154
|
+
if(lost){decided[r.id]={cls:"note",stage:"recording",label:"Your decision may be recorded. Checking again…",lines:[],url:null,note:null};render();}
|
|
155
|
+
if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
129
158
|
function card(r,token){
|
|
130
159
|
var a=el("article",{"aria-label":r.title});
|
|
131
160
|
a.appendChild(el("span",{class:"kind"},r.kind_label||r.kind));
|
|
@@ -138,6 +167,7 @@ a{color:inherit}
|
|
|
138
167
|
if(r.projection&&r.projection.stage!=="awaiting_decision"&&(!decided[r.id]||["recording","dispatching"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}
|
|
139
168
|
if(decided[r.id]){
|
|
140
169
|
var shown=decided[r.id];a.appendChild(decidedBlock(shown));
|
|
170
|
+
if(shown.stage==="recording"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}
|
|
141
171
|
if(["recording","dispatching"].indexOf(shown.stage)!==-1&&host()&&host().callTool){
|
|
142
172
|
var refresh=el("button",{type:"button"},"Refresh status");
|
|
143
173
|
refresh.addEventListener("click",function(){
|
|
@@ -154,11 +184,9 @@ a{color:inherit}
|
|
|
154
184
|
if(errors[r.id])a.appendChild(el("p",{class:"fail",role:"status"},errors[r.id]));
|
|
155
185
|
return a;
|
|
156
186
|
}
|
|
187
|
+
function approvalLink(){var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));p.appendChild(el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page"));return p;}
|
|
157
188
|
if(pending[r.id]){status.className="note";status.textContent="Recording your decision…";a.appendChild(status);return a;}
|
|
158
|
-
if(!token||!host()||!host().callTool){
|
|
159
|
-
var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));
|
|
160
|
-
var link=el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page");p.appendChild(link);a.appendChild(p);return a;
|
|
161
|
-
}
|
|
189
|
+
if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}
|
|
162
190
|
var ta=el("textarea",{"aria-label":"Instructions or changes (needed to approve with changes or send back)",placeholder:"Instructions or changes (needed to approve with changes or send back)"});
|
|
163
191
|
a.appendChild(ta);
|
|
164
192
|
var row=el("div",{class:"row"});
|
|
@@ -166,7 +194,7 @@ a{color:inherit}
|
|
|
166
194
|
var btns=[];
|
|
167
195
|
buttons.forEach(function(b){var x=el("button",{type:"button",class:b[2]},b[1]);x.addEventListener("click",function(){go(b[0]);});btns.push(x);row.appendChild(x);});
|
|
168
196
|
a.appendChild(row);a.appendChild(status);
|
|
169
|
-
if(errors[r.id]){status.className="fail";status.textContent=errors[r.id];}
|
|
197
|
+
if(errors[r.id]){status.className="fail";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}
|
|
170
198
|
function go(choice){
|
|
171
199
|
var text=ta.value.trim();
|
|
172
200
|
if((choice==="approve_with_changes"||choice==="request_revision")&&!text){status.className="fail";status.textContent=choice==="request_revision"?"Say what to revise.":"Write the changes to apply.";ta.focus();return;}
|
|
@@ -174,14 +202,22 @@ a{color:inherit}
|
|
|
174
202
|
host().callTool("decide_request",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){
|
|
175
203
|
var out=toolResult(res);
|
|
176
204
|
// The connector answers with the request's projection, also when a click finds it decided already.
|
|
177
|
-
var proj=out&&out.projection;
|
|
178
|
-
var
|
|
179
|
-
var ok=out&&!out.error;
|
|
205
|
+
var proj=out&&(out.projection||(out.detail&&out.detail.projection));
|
|
206
|
+
var code=out&&out.error;
|
|
180
207
|
delete pending[r.id];
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
// The token expired or the request was revised, and it still awaits a decision: say so and keep it open.
|
|
209
|
+
if(code&&(!proj||proj.stage==="awaiting_decision")){errors[r.id]=(out&&out.message)||"Not recorded.";relink[r.id]=true;render();return;}
|
|
210
|
+
var note=out&&out.replayed?"This decision was already recorded. Nothing ran again.":code==="decision_conflict"?"A different decision was recorded first. Here is where it stands.":code==="already_decided"?"This request was already decided. Here is where it stands.":null;
|
|
211
|
+
if(proj){decided[r.id]=projectionView(proj,note);}
|
|
212
|
+
else{decided[r.id]={cls:code?"fail":"done",label:code?((out&&out.message)||"Not recorded."):((out&&out.summary)||"Recorded."),lines:[],url:null,note:null};}
|
|
183
213
|
render();
|
|
184
|
-
},function(err){
|
|
214
|
+
},function(err){
|
|
215
|
+
// The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).
|
|
216
|
+
delete pending[r.id];
|
|
217
|
+
decided[r.id]={cls:"note",stage:"recording",label:"Checking whether your decision was recorded…",lines:[],url:null,note:null};
|
|
218
|
+
polls[r.id]=0;render();
|
|
219
|
+
follow(r,"Not recorded: "+(err&&err.message?err.message:String(err))+".");
|
|
220
|
+
});
|
|
185
221
|
}
|
|
186
222
|
return a;
|
|
187
223
|
}
|
package/dist/request-state.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Stages, from the ledger's folded record (see `current()`):
|
|
9
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)
|
|
10
12
|
* - declined, sent_back: decided, nothing ran
|
|
11
13
|
* - dispatching: approved, and its brief is queued or running (state in_progress)
|
|
12
14
|
* - completed: approved, and its run finished (state completed)
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
import type { ConnectorRecord } from "./records.js";
|
|
20
22
|
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"];
|
|
21
23
|
export declare const APPROVABLE: ReadonlySet<string>;
|
|
22
|
-
/** `recording`: a decision was taken a moment ago and its run
|
|
24
|
+
/** `recording`: a decision was taken a moment ago and its run is still going (in memory, or written ahead as outcome `running`). */
|
|
23
25
|
export type RequestStage = "awaiting_decision" | "recording" | "declined" | "sent_back" | "dispatching" | "completed" | "failed";
|
|
24
26
|
export type RequestStanding = "waiting" | "open" | "done" | "stopped";
|
|
25
27
|
export interface RequestDecision {
|
package/dist/request-state.js
CHANGED
|
@@ -20,6 +20,7 @@ const CHOICE_WORDS = {
|
|
|
20
20
|
superseded: "Superseded by a later version",
|
|
21
21
|
};
|
|
22
22
|
const OUTCOME_WORDS = {
|
|
23
|
+
running: "running",
|
|
23
24
|
queued: "queued for the factory intake",
|
|
24
25
|
done: "done",
|
|
25
26
|
linked: "filed in the episode",
|
|
@@ -141,6 +142,10 @@ function projectCore(record, ctx = {}) {
|
|
|
141
142
|
outstanding,
|
|
142
143
|
}, lines);
|
|
143
144
|
}
|
|
145
|
+
// Approved and written ahead of its run, which is still going (#924): a transient stage every surface refreshes.
|
|
146
|
+
if (record.state === "in_progress" && outcome === "running") {
|
|
147
|
+
return finish({ ...base, stage: "recording", standing: "open", decision, label: `${changes ? "Approved with changes" : "Approved"}, running`, outcome }, [...decisionLines(decision), "Running it now. The outcome shows here when it finishes."]);
|
|
148
|
+
}
|
|
144
149
|
if (record.state === "in_progress") {
|
|
145
150
|
const delivered = ctx.queue?.state === "delivered";
|
|
146
151
|
const outstanding = [];
|
package/dist/server.d.ts
CHANGED
|
@@ -23,10 +23,12 @@ export interface ConnectorDeps {
|
|
|
23
23
|
steerSettleMs?: number;
|
|
24
24
|
/** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
|
|
25
25
|
tokenAttemptsPerWindow?: number;
|
|
26
|
+
/** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them (jgwill/Miadi#924). */
|
|
27
|
+
log?: (event: Record<string, unknown>) => void;
|
|
26
28
|
}
|
|
27
29
|
export declare function createConnectorApp(config: ConnectorConfig, deps: ConnectorDeps): {
|
|
28
30
|
app: import("express-serve-static-core").Express;
|
|
29
31
|
provider: MiadiOAuthProvider;
|
|
30
32
|
ledger: Ledger<ConnectorRecord>;
|
|
31
33
|
};
|
|
32
|
-
export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.\n- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
|
|
34
|
+
export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nEvery tool is offered on every connection. A client that loads tools on demand shows only those its last tool search matched: a tool you do not see is not missing. Search for it by its exact name, get_capabilities first, before telling the person a tool is absent. The main reads are get_capabilities, get_context, search, fetch, list_records, list_sessions, get_session, review_lookup, read_episode_file and show_decisions.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.\n- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
|
package/dist/server.js
CHANGED
|
@@ -15,8 +15,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
15
15
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
16
|
import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
17
17
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
18
|
-
import { MiadiOAuthProvider, SCOPES, emptyOAuthState } from "./oauth.js";
|
|
19
|
-
import { registerApprovalRoutes } from "./approve.js";
|
|
18
|
+
import { MiadiOAuthProvider, SCOPES, SCOPE_CONTRIBUTE, SCOPE_READ, emptyOAuthState } from "./oauth.js";
|
|
19
|
+
import { reconcileInterrupted, registerApprovalRoutes } from "./approve.js";
|
|
20
20
|
import { DecisionTokens } from "./decisions.js";
|
|
21
21
|
import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
|
|
22
22
|
import { registerTools } from "./tools.js";
|
|
@@ -87,14 +87,29 @@ export function createConnectorApp(config, deps) {
|
|
|
87
87
|
tmuxSessions: deps.tmuxSessions,
|
|
88
88
|
tmux: deps.tmux,
|
|
89
89
|
steerSettleMs: deps.steerSettleMs,
|
|
90
|
+
log: deps.log,
|
|
90
91
|
};
|
|
92
|
+
// A run the connector was recording when it last stopped is failed now, so a person may run it again (#924).
|
|
93
|
+
reconcileInterrupted(approvalDeps);
|
|
91
94
|
registerApprovalRoutes(app, approvalDeps);
|
|
92
|
-
const decisionTokens = new DecisionTokens(config.stateDir);
|
|
93
|
-
const
|
|
95
|
+
const decisionTokens = new DecisionTokens(config.stateDir, config.decisionTokenTtl);
|
|
96
|
+
const bearer = requireBearerAuth({
|
|
94
97
|
verifier: provider,
|
|
95
|
-
requiredScopes: [
|
|
98
|
+
requiredScopes: [SCOPE_READ],
|
|
96
99
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
|
97
100
|
});
|
|
101
|
+
// A 401's scope is the list a client asks for at sign-in (MCP scope selection,
|
|
102
|
+
// step 1). Claude asks for exactly that list, so naming only miadi:read gave
|
|
103
|
+
// every Claude sign-in read alone. /mcp still requires only miadi:read; the
|
|
104
|
+
// sign-in page lets the person leave contributions off.
|
|
105
|
+
const asked = `scope="${[SCOPE_READ, SCOPE_CONTRIBUTE].join(" ")}"`;
|
|
106
|
+
const auth = (req, res, next) => {
|
|
107
|
+
const set = res.set.bind(res);
|
|
108
|
+
res.set = ((field, value) => field === "WWW-Authenticate" && typeof value === "string"
|
|
109
|
+
? set(field, value.replace(/scope="[^"]*"/, asked))
|
|
110
|
+
: set(field, value));
|
|
111
|
+
bearer(req, res, next);
|
|
112
|
+
};
|
|
98
113
|
const handle = async (req, res) => {
|
|
99
114
|
const ctx = {
|
|
100
115
|
config,
|
|
@@ -143,6 +158,8 @@ export const INSTRUCTIONS = `Miadi is a relational software factory: agent sessi
|
|
|
143
158
|
|
|
144
159
|
Start with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.
|
|
145
160
|
|
|
161
|
+
Every tool is offered on every connection. A client that loads tools on demand shows only those its last tool search matched: a tool you do not see is not missing. Search for it by its exact name, get_capabilities first, before telling the person a tool is absent. The main reads are get_capabilities, get_context, search, fetch, list_records, list_sessions, get_session, review_lookup, read_episode_file and show_decisions.
|
|
162
|
+
|
|
146
163
|
Rules every tool keeps:
|
|
147
164
|
- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.
|
|
148
165
|
- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.
|
package/dist/tools.js
CHANGED
|
@@ -16,7 +16,7 @@ import { reviewIdOf, videoIdOf } from "./reviews.js";
|
|
|
16
16
|
import { SCOPE_APPROVE, SCOPE_CONTRIBUTE } from "./oauth.js";
|
|
17
17
|
import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current, newRecordId, } from "./records.js";
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
19
|
-
import { AlreadyDecided, DECISIONS, KIND_LABELS, StaleVersion, consequenceOf, decide, isApprovable, isBeingDecided, readQueueState, requestProjection } from "./approve.js";
|
|
19
|
+
import { AlreadyDecided, DECISIONS, DecisionConflict, KIND_LABELS, 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
21
|
import { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
|
|
22
22
|
import { discoverProviders, jevConfigOf, resolveProvider, routerPreflight, PROVIDERS } from "./providers.js";
|
|
@@ -231,7 +231,7 @@ export function registerTools(server, ctx) {
|
|
|
231
231
|
// ── reads ──────────────────────────────────────────────────────────────
|
|
232
232
|
server.registerTool("get_capabilities", {
|
|
233
233
|
title: "What this connector can do for you",
|
|
234
|
-
description: "The capability handshake: who you are signed in as, your Miadi tier and scopes,
|
|
234
|
+
description: "Start here with the Miadi connector. The capability handshake: who you are signed in as (who am I), your Miadi tier and scopes, the full list of Miadi tools with each one's status (live, needs_approval, write_tier), the services reached and the five circles. Call this first.",
|
|
235
235
|
inputSchema: {},
|
|
236
236
|
annotations: READ,
|
|
237
237
|
}, wrap(async (_args, extra) => {
|
|
@@ -1932,42 +1932,64 @@ export function registerTools(server, ctx) {
|
|
|
1932
1932
|
const projectionNow = () => requestProjection(all().find((x) => x.id === r.id) ?? r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir });
|
|
1933
1933
|
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId, undefined, versionOf(r));
|
|
1934
1934
|
if (check === "stale") {
|
|
1935
|
-
throw new ToolRefusal("stale_version", `This card showed an earlier version; the request is now at version ${versionOf(r)}. Show the requests again and decide version ${versionOf(r)}.`, { projection: projectionNow() });
|
|
1935
|
+
throw new ToolRefusal("stale_version", `This card showed an earlier version; the request is now at version ${versionOf(r)}. Show the requests again and decide version ${versionOf(r)}.`, { projection: projectionNow(), approval_url: approvalUrl(r.id) });
|
|
1936
1936
|
}
|
|
1937
|
-
if (check
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1937
|
+
if (check === "expired") {
|
|
1938
|
+
// The request does not expire: the card's token did. Its approval page still decides it, and a fresh card does too (#924).
|
|
1939
|
+
throw new ToolRefusal("token_expired", "This card's decision token has expired; the request has not. Show the requests again for a fresh card, or decide on its approval page.", {
|
|
1940
|
+
projection: projectionNow(),
|
|
1941
|
+
approval_url: approvalUrl(r.id),
|
|
1942
|
+
});
|
|
1941
1943
|
}
|
|
1944
|
+
if (check !== "ok")
|
|
1945
|
+
throw new ToolRefusal("invalid_token", "This decision did not come from the person's card.");
|
|
1942
1946
|
const instructions = a.instructions?.trim() || undefined;
|
|
1943
|
-
if ((a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
|
|
1947
|
+
if (r.approval_state === "awaiting_approval" && !isBeingDecided(r.id) && (a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
|
|
1944
1948
|
throw new ToolRefusal("instructions_required", a.decision === "request_revision" ? "Say what to revise." : "Write the changes to apply.");
|
|
1945
1949
|
}
|
|
1946
1950
|
const client = ctx.oauthState.read().clients[auth.clientId];
|
|
1951
|
+
const via = `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`;
|
|
1947
1952
|
let decided;
|
|
1948
1953
|
try {
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
+
const running = decide(r, person, a.decision, ctx.approval, { instructions, via, expectedVersion: versionOf(r) });
|
|
1955
|
+
// A host gives up on a call after about a minute and may send it again; a review create can take longer.
|
|
1956
|
+
// Past the bound the answer is the decision, recorded and running, and the card follows it to its outcome (#924).
|
|
1957
|
+
const settled = await settleWithin(running, decisionWaitOf(ctx.approval));
|
|
1958
|
+
if (settled === STILL_RUNNING) {
|
|
1959
|
+
logDecision(ctx.approval, { phase: "answered_running", record: r.id, via, choice: a.decision });
|
|
1960
|
+
const decision = approvalStateOf(a.decision);
|
|
1961
|
+
const out = {
|
|
1962
|
+
record_id: r.id,
|
|
1963
|
+
version: versionOf(r),
|
|
1964
|
+
decision,
|
|
1965
|
+
state: "in_progress",
|
|
1966
|
+
recording: true,
|
|
1967
|
+
summary: `${decision === "approved" ? "Approved" : "Decided"} by ${person.name}, and still running. The card shows the outcome when it finishes; get_run_result reads it.`,
|
|
1968
|
+
projection: projectionNow(),
|
|
1969
|
+
};
|
|
1970
|
+
return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1971
|
+
}
|
|
1972
|
+
decided = settled;
|
|
1954
1973
|
}
|
|
1955
1974
|
catch (error) {
|
|
1956
1975
|
if (error instanceof StaleVersion)
|
|
1957
|
-
throw new ToolRefusal("stale_version", error.message, { projection: projectionNow() });
|
|
1958
|
-
// A decision recorded a moment earlier, by another client, is reported with where it stands.
|
|
1976
|
+
throw new ToolRefusal("stale_version", error.message, { projection: projectionNow(), approval_url: approvalUrl(r.id) });
|
|
1977
|
+
// A different decision recorded a moment earlier, by another click or client, is reported with where it stands.
|
|
1978
|
+
if (error instanceof DecisionConflict)
|
|
1979
|
+
throw new ToolRefusal("decision_conflict", error.message, { projection: projectionNow() });
|
|
1959
1980
|
if (error instanceof AlreadyDecided)
|
|
1960
1981
|
throw new ToolRefusal("already_decided", error.message, { projection: projectionNow() });
|
|
1961
1982
|
throw error;
|
|
1962
1983
|
}
|
|
1963
|
-
const { decision, state, receipt } = decided;
|
|
1984
|
+
const { decision, state, receipt, replayed } = decided;
|
|
1964
1985
|
const projection = projectionNow();
|
|
1965
|
-
const summary =
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1986
|
+
const summary = (replayed ? "Already recorded; nothing ran again. " : "") +
|
|
1987
|
+
(decision === "approved"
|
|
1988
|
+
? `Approved by ${receipt.decided_by?.name ?? person.name}. ${receipt.outcome === "failed" ? "Running it failed; see the receipt." : `Outcome: ${String(receipt.outcome ?? "recorded").replace(/_/g, " ")}.`}`
|
|
1989
|
+
: decision === "revision_requested"
|
|
1990
|
+
? `Sent back for revision by ${receipt.decided_by?.name ?? person.name}.`
|
|
1991
|
+
: `Declined by ${receipt.decided_by?.name ?? person.name}.`);
|
|
1992
|
+
const out = { record_id: r.id, version: versionOf(r), decision, state, summary, receipt, projection, ...(replayed ? { replayed: true } : {}) };
|
|
1971
1993
|
return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1972
1994
|
}
|
|
1973
1995
|
catch (error) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.19";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.19";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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",
|