@miadi/connector 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,9 +20,9 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
20
20
 
21
21
  | kind | tools |
22
22
  |---|---|
23
- | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `search` `fetch` |
23
+ | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `search` `fetch` `list_research_artifacts` `read_research_artifact` |
24
24
  | contributions | `append_observation` `open_inquiry` `link_records` `propose_narrative_beat` `record_evaluation` `register_source_intent` `record_handoff` `update_handoff` `prepare_calendar_event` `save_research` |
25
- | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
25
+ | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `revoke_session_steering` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
26
26
  | decisions in the conversation | `show_decisions` (cards), `decide_request` (called by the card only) |
27
27
 
28
28
  - **Reads** need Miadi's read tier and stay within it. A person Miadi gives no API tier reaches only `get_capabilities`. Sessions need the write tier, because a session's name and pane are operator data. For the same reason, below the write tier a record someone else made shows no session, working folder or seat, and no long bodies.
@@ -60,7 +60,40 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
60
60
 
61
61
  **The agent's result (jgwill/Miadi#921).** An agent returns its work with `propose_session_completion` naming the mission in `mission`, from its bound session, with its evidence refs and, optionally, a structured `result`: `summary`, `outputs` (`ref`, `label`, `git_sha`), `tests` (`command`, `passed`, `failed`, `skipped`) and `blockers`. `mission_status` and `get_run_result` read it back by the mission id as `result`: the standing submission (the newest `verified` one: it names the mission, ends a bound session and was submitted with Miadi's write tier or the factory's writer token, which the connector records on the ending as `submitted_with_tier`; anything else naming the mission is `unverified_association` with an `association_code`, a mere citation `cited_only` and never a result), its summary, tests and blockers as the agent's claim, and each output typed and checked when read. A commit or file on GitHub is read through GitHub (`GITHUB_TOKEN` when the host has one): `verified` with its sha, `changed_since_submission` when its declared `git_sha` no longer matches or the file changed on its branch after the submission, `unverified` with the reason when GitHub cannot be read, never `not_found`. A connector record id the ledger does not hold is `not_found`. Bound sessions are taken from the factory's receipts for every reader, so the verdict is the same at every tier; below the write tier the session ids are left out. The completion decision stays the person's, and only a verified submission sets it: `stage_truth.completion` is `pending_approval`, `accepted`, `declined` or `revision_requested` (`none` for an unverified one, whose stage is `result_submitted_unverified`; an approved exit submitted below the write tier closes nothing), `pending_human_gates` lists what waits on a person with its approval page, and `mission_stage` names the furthest stage with evidence (`result_submitted_awaiting_completion_approval`, `completion_accepted`, `merged`, `released`, `closed`…). A duplicate submission (same session, ending and mission, still pending) is handed back with `replayed: true`, and `not_recorded` names what the second call carried that was not kept.
62
62
 
63
- `request_session_steer` sends one authorized follow-up to one existing live agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). It peeks at the bound pane — the grant's pane or the session's first pane, never whichever pane is active — refuses a busy agent or a pane that is not an agent, types the exact instruction literally, and re-peeks to verify the agent accepted it (`accepted`) or only holds it at the prompt (`submitted`). Every attempt leaves a durable artifact with the instruction's sha256, its grant, its target and both captures. When the factory recorded the mission's pane, the steer goes to that pane id and nowhere else: another session is `stale_binding`, another pane `wrong_pane`, a replaced pane `stale_binding`, and text already at the prompt `composing` (nothing typed, Enter not pressed). A pane whose prompt box cannot be read (any TUI but Claude Code's today) is `blocked`. Every tmux address is exact (`=name:w.p`), because tmux otherwise matches a session by the start of its name. Only a steer that typed its instruction uses one of the grant's attempts (`typed`, and `attempts` in the answer). The connector's service steers through the tmux server of the user it runs as; `get_capabilities.steering` says whether that transport answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
63
+ `request_session_steer` sends one person's correction to a running agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). Busy is the normal case (jgwill/Miadi#930).
64
+
65
+ | who | owns | never does |
66
+ |---|---|---|
67
+ | The person, through ChatGPT or any client | one correction, in scope | watch tools, wait for idle, send it again |
68
+ | The connector | who may send it, the grant, mission → session → runtime, idempotency, handing it to that session's inbox, reporting what happened | decide when the model reads it, type into a working agent's pane, call a forward a delivery |
69
+ | The runtime (Claude Code hooks: `miadi-session-observability` 0.1.34, `hooks/steering_inbox.mjs`) | the inbox, its order, the checks again at consumption, when its model reads it | deliver anything to a subagent or to another session |
70
+ | The model | reading it and changing what it does next | |
71
+
72
+ The route comes from what each part signed: the factory's receipt names the session, Claude Code's own registry says it runs and in which pane, and the adapter's `adapter.json` says this session loaded its hooks. A wrong session is `stale_binding`, a wrong pane `wrong_pane`. A provider with no adapter, or a Claude session started before the adapter (hooks load at session start), answers `unsupported` with the reason and uses no attempt. No Claude Code behaviour is assumed of Codex, Copilot or Pi, and `get_capabilities.steering.contract` names the adapters that exist.
73
+
74
+ The inbox is `$MIADI_STEERING_DIR` (default `<factory>/steering`), `<session_id>/pending/` and its siblings, one Unix account's, `0700`. The connector writes one envelope with fsync and rename. The adapter takes it at the next `PostToolUse` or `PostToolUseFailure` as `additionalContext` in the same turn, or at `Stop` with `decision: block` when no further tool follows. A correction landing while the turn ends, after `Stop` has looked, is caught by a background watcher that runs for 30 seconds after each turn (`asyncRewake`): exit 2 wakes the model with it, so a working agent never waits for idle or a timer. A seat already idle is typed at once (below). The factory runner's next pass is the last net for anything left (`wakeIdleSteers`, once per envelope, only if the prompt box is clear), for example a session that resumed with a correction waiting. `delivery.continuation` says which of these is coming: `next_tool_or_turn_end`, `runner_wakes_idle_seat` or `session_not_running`. One rename into `claimed/` decides who delivers, so it is delivered once and never replayed. The adapter checks the session, the hash, the expiry (the grant's, or one day) and revocation again when it consumes. The hook does not poll: it runs in the agent's own lifecycle, and nothing outside watches it. A hook on an interrupted tool does not consume, because Claude Code drops hook messages there, and a subagent's tool does not consume either.
75
+
76
+ Why hooks: Claude Code 2.1.318 has a native mid-turn queue, and `claude --help` offers no outside door to it. Typing reaches it through the keyboard, which is the terminal heuristic this replaces. The cross-session socket (`messagingSocketPath`) belongs to Claude sessions speaking to each other: its protocol is not published, and a connector speaking it would pose as a session. `PostToolUse.additionalContext` and `Stop` are its documented interface, so that is the adapter. Pi, Codex and Copilot have their own in-turn mechanisms, and none has an adapter here yet.
77
+
78
+ `delivery` in the answer, in `get_run_result` on the steer's id, and in `mission_status.steering.steers` (write tier) says where it stands, each state with its source and time:
79
+
80
+ | state | means | source |
81
+ |---|---|---|
82
+ | `authorized` | the grant or a person's decision covered it | connector |
83
+ | `forwarded_to_runtime`, `queued_at_runtime` | it is in the session's inbox; the model has not necessarily read it | connector, runtime inbox |
84
+ | `delivered_to_agent_context` | Claude Code recorded it entering the context | the transcript's attachment record |
85
+ | `acknowledged` | the agent's own next text names `steer <id> received` | the transcript |
86
+ | `behavior_adjusted` | a later tool input holds what the steer named in `expect_next_action`; without that, `behavior: not_judged` and the next actions are shown for a person to judge | the transcript |
87
+ | `unsupported`, `expired`, `blocked` | it never reached the model, with the reason | connector or runtime hook |
88
+ | `unknown` | claimed with no transcript record: ambiguous, and it is not sent again | |
89
+
90
+ The record itself moves with its evidence: `completed` once the model has it, `blocked` once it never will, on the next read by anyone. So the ledger, `mission_status` and the decision surfaces say the same thing.
91
+
92
+ `revoke_session_steering` stops a run's steering or one steer. What is still pending moves out at once, a marker stops anything later, and the answer says exactly what was revoked and what the agent had already consumed. The same idempotency key gets the first answer back, and the same words under a new key answer `duplicate`. Only a typed or forwarded steer uses one of the grant's attempts.
93
+
94
+ An idle seat has no continuation coming, so it is the one case still typed: into the mission's bound pane, never whichever pane is active, with an exact tmux address (`=name:w.p`). It peeks first and re-peeks after. Text already at the prompt, or a prompt box that cannot be read, goes to the inbox when the seat has one and otherwise is `composing` or `blocked`, with nothing typed. A pane echo is not delivery: only a user record in the transcript moves it to `delivered_to_agent_context`. Every attempt leaves a durable artifact with the instruction's sha256, its grant and its target. The connector's service reaches the tmux server of the user it runs as; `get_capabilities.steering` says whether that answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
95
+
96
+ Rollback: run the connector version before this one (`git checkout` of its `dist`, then `systemctl --user restart miadi-connector`). A working agent's steer then answers `busy` again and nothing else changes. To stop the runtime half, remove the plugin's `steering_inbox.sh` entries from `hooks/hooks.json`; a new session then has no `adapter.json` and answers `unsupported`. Envelopes already queued stay on disk, unread.
64
97
 
65
98
  `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order that may be picked by default (Pi, then Copilot) is resolved and named in the brief; Codex and Claude Code are never chosen silently, each runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
66
99
 
@@ -101,7 +134,7 @@ MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/con
101
134
  | `MIADI_REVIEW_BASE_URL` | `https://miadi-review-service.vercel.app` | the review service |
102
135
  | `MIADI_REVIEW_TOKEN` | read from `~/.env` | review search and approved review operations |
103
136
  | `MIADI_FACTORY_DIR` | `~/.miadi/factory` | the queue approved runs are written to |
104
- | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes |
137
+ | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes, and `list_research_artifacts` and `read_research_artifact` read back |
105
138
  | `MIADI_INQUIRY_GITHUB_REPO` | `miadisabelle/Etuaptmumk-RSM` | where a saved report's inquiry issue is opened, with the host's `gh` login |
106
139
  | `MIADI_CONNECTOR_DECISION_TOKEN_TTL` | `604800` (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
107
140
  | `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 |
package/dist/approve.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { MiadiClient } from "./miadi.js";
16
16
  import { type MiadiPerson } from "./people.js";
17
17
  import { type ConnectorRecord } from "./records.js";
18
18
  import { type RequestProjection } from "./request-state.js";
19
+ import type { RuntimeDeps } from "./runtime-steer.js";
19
20
  import { type TmuxLike } from "./steer.js";
20
21
  import { type ProviderAuthorization } from "./provider-policy.js";
21
22
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
@@ -32,6 +33,8 @@ export interface ApprovalDeps {
32
33
  tmux?: TmuxLike;
33
34
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
34
35
  steerSettleMs?: number;
36
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
37
+ steeringRuntime?: RuntimeDeps;
35
38
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
36
39
  log?: (event: Record<string, unknown>) => void;
37
40
  }
package/dist/approve.js CHANGED
@@ -289,18 +289,21 @@ export async function executeApproved(record, approver, deps, instructions) {
289
289
  return { outcome: "failed", message: "The run whose grant this steer cites is gone." };
290
290
  // The pane the factory delivered the mission to, when it recorded one (jgwill/Miadi#909).
291
291
  const bound = receiptsForRecord(readFactoryReceipts(factoryLedgerPath(deps.factoryDir ?? factoryDir())), grantRecord.id)[0];
292
- const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
292
+ const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steeringRuntime ? { runtime: deps.steeringRuntime } : {}), ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
293
293
  record,
294
294
  grantRecord,
295
295
  priorAttempts: steerAttempts(current(deps.ledger.all()), grantRecord.id),
296
296
  authorized: true,
297
- ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}) } } : {}),
297
+ ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}), ...(bound.session_id ? { session_id: bound.session_id } : {}), ...(bound.provider ? { provider: bound.provider } : {}) } } : {}),
298
298
  });
299
- // A typed steer is never "failed": failed is re-runnable, and that would type it twice (#922 R8).
299
+ // A typed or forwarded steer is never "failed": failed is re-runnable, and that would send it twice (#922 R8).
300
300
  return {
301
- outcome: result.typed ? result.status : "failed",
301
+ outcome: result.typed || result.forwarded ? result.status : "failed",
302
302
  steer_status: result.status,
303
303
  typed: result.typed,
304
+ ...(result.forwarded ? { forwarded: true } : {}),
305
+ ...(result.route ? { route: result.route } : {}),
306
+ ...(result.transcript_offset !== undefined ? { transcript_offset: result.transcript_offset } : {}),
304
307
  message: result.message,
305
308
  ...(result.target ? { target: result.target } : {}),
306
309
  ...(result.artifact ? { artifact: result.artifact } : {}),
@@ -533,7 +536,7 @@ export async function rerun(r, person, deps, opts) {
533
536
  receipt = { ...receipt, ...outcome };
534
537
  if (outcome.outcome === "failed")
535
538
  state = "blocked";
536
- if (outcome.outcome === "queued")
539
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
537
540
  state = "in_progress";
538
541
  }
539
542
  catch (error) {
@@ -600,7 +603,7 @@ async function decideOnce(r, person, choice, deps, opts) {
600
603
  receipt = { ...receipt, ...outcome };
601
604
  if (outcome.outcome === "failed")
602
605
  state = "blocked";
603
- if (outcome.outcome === "queued")
606
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
604
607
  state = "in_progress";
605
608
  }
606
609
  catch (error) {
package/dist/bin.js CHANGED
@@ -4,6 +4,7 @@ import { createConnectorApp } from "./server.js";
4
4
  import { identifyThroughMiadi } from "./people.js";
5
5
  import { steeringTransport } from "./tools.js";
6
6
  import { tmuxAdapter } from "./steer.js";
7
+ import { defaultRuntime } from "./runtime-steer.js";
7
8
  import { VERSION } from "./version.js";
8
9
  const config = configFromEnv();
9
10
  // The host's steering surface (jgwill/Miadi#909): the tmux server of the user the service runs as,
@@ -13,6 +14,8 @@ const { app } = createConnectorApp(config, {
13
14
  identify: identifyThroughMiadi(config.miadiUrl),
14
15
  tmux,
15
16
  tmuxSessions: () => tmux.listSessions(),
17
+ // Each session's own steering inbox and Claude Code's registry of running sessions (jgwill/Miadi#930).
18
+ steeringRuntime: defaultRuntime(),
16
19
  });
17
20
  app.listen(config.port, config.host, () => {
18
21
  console.log(`@miadi/connector ${VERSION} on http://${config.host}:${config.port}`);
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { ConnectorRecord } from "./records.js";
23
23
  import type { RequestProjection } from "./request-state.js";
24
+ import type { SteerDelivery } from "./runtime-steer.js";
24
25
  import type { FactoryReceipt } from "./factory-receipt.js";
25
26
  import { type GithubGet, type MissionResult } from "./mission-result.js";
26
27
  export interface MissionSession {
@@ -184,9 +185,18 @@ export interface MissionStatus {
184
185
  access?: string;
185
186
  }[];
186
187
  steering?: {
187
- grant: "none" | "active" | "expired_grant" | "exhausted";
188
+ grant: "none" | "active" | "expired_grant" | "exhausted" | "revoked";
188
189
  attempts_used: number;
189
190
  max_attempts?: number;
191
+ /** Each correction sent under this mission and where it stands, by its own evidence (jgwill/Miadi#930; write tier). */
192
+ steers?: {
193
+ id: string;
194
+ at: string;
195
+ by: string;
196
+ state: string;
197
+ route: string;
198
+ delivery: SteerDelivery;
199
+ }[];
190
200
  };
191
201
  stage_truth?: MissionStageTruth;
192
202
  /**
@@ -216,6 +226,8 @@ export interface MissionStatusDeps {
216
226
  tmuxSessions?: () => string[];
217
227
  /** Read-only peek: is the named session's pane mid-turn? The pane id when the binding names one, else the first pane. (write tier only.) */
218
228
  paneBusy?: (session: string, paneId?: string) => "busy" | "idle" | "unknown";
229
+ /** Where one steer stands, read from the runtime's inbox and the session's transcript (write tier only). */
230
+ steerDeliveryOf?: (steer: ConnectorRecord) => SteerDelivery | null;
219
231
  /** Whether the tmux session still holds the pane id (write tier only); undefined when it cannot be listed. */
220
232
  panePresent?: (session: string, paneId: string) => boolean | undefined;
221
233
  /** The factory runner's delivery receipts for one record, newest first (write tier only). */
@@ -1,4 +1,4 @@
1
- import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
1
+ import { steerAttempts, steeringGrantOf, steeringRevoked, checkGrant } from "./steer.js";
2
2
  import { missionResult, redactResult, submittedWithWriteTier } from "./mission-result.js";
3
3
  const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
4
4
  const SESSION_REF = /^session:([0-9a-f-]{36})$/;
@@ -389,8 +389,14 @@ export async function missionStatus(ref, records, deps) {
389
389
  }
390
390
  const grant = steeringGrantOf(record);
391
391
  const attempts = steerAttempts(records, record.id);
392
- const grantCheck = grant ? checkGrant(grant, attempts, now) : null;
393
- const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : "exhausted";
392
+ const grantCheck = grant ? checkGrant(grant, attempts, now, steeringRevoked(records, record.id)) : null;
393
+ const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : grantCheck === "revoked" ? "revoked" : "exhausted";
394
+ const steers = deps.steerDeliveryOf
395
+ ? records.filter((r) => r.kind === "session_steer_request" && r.refs.includes(record.id)).flatMap((r) => {
396
+ const delivery = deps.steerDeliveryOf(r);
397
+ return delivery ? [{ id: r.id, at: r.created_at, by: r.actor.name, state: delivery.state, route: delivery.route, delivery }] : [];
398
+ })
399
+ : [];
394
400
  const receipt = (record.completion_receipt ?? {});
395
401
  const anyLive = sessions.some((s) => s.live === "running");
396
402
  const prs = issues.filter((i) => i.merged !== undefined);
@@ -464,7 +470,7 @@ export async function missionStatus(ref, records, deps) {
464
470
  ...(queue ? { queue } : {}),
465
471
  sessions,
466
472
  issues,
467
- steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
473
+ steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}), ...(steers.length ? { steers } : {}) },
468
474
  ...(provider ? { provider } : {}),
469
475
  stage_truth: stageTruth,
470
476
  mission_stage: missionStage(standing ?? null, stageTruth),
package/dist/records.d.ts CHANGED
@@ -36,7 +36,7 @@ export declare const DOMAINS: readonly ["creative", "ceremonial", "engineering"]
36
36
  /** #815 "claim category": what kind of statement a record makes. */
37
37
  export declare const CLAIM_CATEGORIES: readonly ["observed", "hypothesis", "desired_future", "fiction", "verified_implementation"];
38
38
  export type ClaimCategory = (typeof CLAIM_CATEGORIES)[number];
39
- export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request", "session_steer_request", "provider_choice", "provider_policy"];
39
+ export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request", "session_steer_request", "steering_revocation", "provider_choice", "provider_policy"];
40
40
  export type RecordKind = (typeof RECORD_KINDS)[number];
41
41
  /** #816 handoff states. */
42
42
  export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
package/dist/records.js CHANGED
@@ -61,6 +61,8 @@ export const RECORD_KINDS = [
61
61
  "research_artifact",
62
62
  "inquiry_episode_request",
63
63
  "session_steer_request",
64
+ // A person stops a run's steering, or one steer still waiting at the runtime (jgwill/Miadi#930).
65
+ "steering_revocation",
64
66
  // A requester's own per-mission provider choice and provider policy, made on the connector's pages (jgwill/Miadi#926).
65
67
  "provider_choice",
66
68
  "provider_policy",
@@ -29,7 +29,12 @@ const OUTCOME_WORDS = {
29
29
  marked_ready: "marked ready for Deep Diver",
30
30
  decided: "recorded",
31
31
  nothing_to_run: "nothing to run",
32
- accepted: "accepted by the session",
32
+ accepted: "typed into an idle session, which turned busy; its delivery says whether the model read it",
33
+ queued_at_runtime: "handed to the session's inbox, on its way to the model",
34
+ delivered_to_agent_context: "delivered: the model has it",
35
+ acknowledged: "delivered, and the agent said so",
36
+ behavior_adjusted: "delivered, and the agent's next action shows it",
37
+ expired: "expired before the model read it",
33
38
  submitted: "typed into the session, acceptance unconfirmed",
34
39
  failed: "failed",
35
40
  };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What a shelf report is, read from its own Markdown, for a shelf a person can look through: its
3
+ * title, its opening, how long it is, what is inside, how many sources it cites. Everything here is
4
+ * computed from the file; nothing is summarised or invented. jgwill/Miadi#879.
5
+ */
6
+ export interface ReportDigest {
7
+ title: string;
8
+ /** The document names itself; otherwise the title is its folder's name. */
9
+ titled: boolean;
10
+ /** The report's first paragraph of prose, without Markdown marks. */
11
+ excerpt?: string;
12
+ words: number;
13
+ minutes: number;
14
+ /** Its `##` and `###` headings, in order. */
15
+ headings: string[];
16
+ sections: number;
17
+ /** Distinct web addresses it cites. */
18
+ sources: number;
19
+ }
20
+ /** `jacqueline-m-quinless-…-261029-0e1f…` → `jacqueline m quinless …`: the date and uuid a folder name ends in are not its name. */
21
+ export declare function folderTitle(name: string): string;
22
+ export declare function digestReport(markdown: string, folder: string): ReportDigest;
23
+ /**
24
+ * The text as the page shows it. A stray fence would turn the rest of a report into one code box, so
25
+ * a fence with no close in reach is left out of the display. The file itself is never changed.
26
+ */
27
+ export declare function readableMarkdown(markdown: string): {
28
+ markdown: string;
29
+ repaired: boolean;
30
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What a shelf report is, read from its own Markdown, for a shelf a person can look through: its
3
+ * title, its opening, how long it is, what is inside, how many sources it cites. Everything here is
4
+ * computed from the file; nothing is summarised or invented. jgwill/Miadi#879.
5
+ */
6
+ const WORDS_PER_MINUTE = 230;
7
+ const MAX_HEADINGS = 6;
8
+ const MAX_EXCERPT = 280;
9
+ /** A stray fence must not swallow a report: a block longer than this is not treated as code. */
10
+ const MAX_FENCE_LINES = 60;
11
+ const MAX_TITLE = 100;
12
+ /** `jacqueline-m-quinless-…-261029-0e1f…` → `jacqueline m quinless …`: the date and uuid a folder name ends in are not its name. */
13
+ export function folderTitle(name) {
14
+ const bare = name
15
+ .replace(/^ep\d+[-_]/i, "")
16
+ .replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "")
17
+ .replace(/-\d{6,10}(-[0-9a-f]{6,})?$/i, "")
18
+ .replace(/[-_]+/g, " ")
19
+ .trim();
20
+ return bare ? bare[0].toUpperCase() + bare.slice(1) : name;
21
+ }
22
+ function plain(text) {
23
+ return text
24
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
25
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
26
+ .replace(/[`*_~]+/g, "")
27
+ .replace(/<[^>]+>/g, "")
28
+ .replace(/\s+/g, " ")
29
+ .trim();
30
+ }
31
+ /** The lines outside fenced code, with a fence left alone when no close comes within reach. */
32
+ function outsideFences(lines) {
33
+ const out = [];
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const open = /^(```|~~~)/.exec(lines[i]);
36
+ if (!open) {
37
+ out.push(lines[i]);
38
+ continue;
39
+ }
40
+ const close = lines.slice(i + 1, i + 1 + MAX_FENCE_LINES).findIndex((l) => l.trimEnd() === open[1]);
41
+ if (close < 0)
42
+ continue;
43
+ i += close + 1;
44
+ }
45
+ return out;
46
+ }
47
+ /** A heading that is a prompt, a command or a chat fragment is not the document's name. */
48
+ const nameable = (t) => t.length > 0 && t.length <= MAX_TITLE && !/^@/.test(t) && !/\s--[a-z]/.test(t) && !/^(hey|hi|ok|okay|so|please|can you|could you)\b/i.test(t);
49
+ /** How a document was made is not what it is about. */
50
+ const PROVENANCE = /^(parent vessel|inquiry vessel|author role|mission|seed for|for the author's review|rendered from|status:|date:|woven to|generated)\b|\/(srv|workspace|a\/src|home)\/|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
51
+ export function digestReport(markdown, folder) {
52
+ const clean = outsideFences(markdown.replace(/^---\n[\s\S]*?\n---\n/, "").split("\n"));
53
+ const body = clean.join("\n");
54
+ const h1 = clean.find((l) => /^#\s+\S/.test(l));
55
+ const h1Text = h1 ? plain(h1.replace(/^#\s+/, "")) : "";
56
+ const headings = clean.filter((l) => /^#{2,3}\s+\S/.test(l)).map((l) => plain(l.replace(/^#+\s+/, "")));
57
+ let excerpt;
58
+ for (const block of body.split(/\n\s*\n/)) {
59
+ const t = block.trim();
60
+ if (!t || /^(#|\||>|[-*+]\s|\d+\.\s|!\[|<|---|===)/.test(t))
61
+ continue;
62
+ const p = plain(t);
63
+ if (p.split(" ").length < 8 || PROVENANCE.test(p))
64
+ continue;
65
+ excerpt = p.length > MAX_EXCERPT ? `${p.slice(0, MAX_EXCERPT).replace(/\s+\S*$/, "")}…` : p;
66
+ break;
67
+ }
68
+ const words = plain(body.replace(/^#+\s+/gm, "")).split(" ").filter(Boolean).length;
69
+ const titled = nameable(h1Text);
70
+ return {
71
+ title: titled ? h1Text : folderTitle(folder),
72
+ titled,
73
+ ...(excerpt ? { excerpt } : {}),
74
+ words,
75
+ minutes: Math.max(1, Math.round(words / WORDS_PER_MINUTE)),
76
+ headings: headings.slice(0, MAX_HEADINGS),
77
+ sections: headings.length,
78
+ sources: new Set(markdown.match(/https?:\/\/[^\s)\]>"']+/g) ?? []).size,
79
+ };
80
+ }
81
+ /**
82
+ * The text as the page shows it. A stray fence would turn the rest of a report into one code box, so
83
+ * a fence with no close in reach is left out of the display. The file itself is never changed.
84
+ */
85
+ export function readableMarkdown(markdown) {
86
+ const lines = markdown.split("\n");
87
+ const out = [];
88
+ let repaired = false;
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const open = /^(```|~~~)/.exec(lines[i]);
91
+ if (!open) {
92
+ out.push(lines[i]);
93
+ continue;
94
+ }
95
+ const close = lines.slice(i + 1, i + 1 + MAX_FENCE_LINES).findIndex((l) => l.trimEnd() === open[1]);
96
+ if (close < 0) {
97
+ repaired = true;
98
+ continue;
99
+ }
100
+ out.push(...lines.slice(i, i + close + 2));
101
+ i += close + 1;
102
+ }
103
+ return { markdown: out.join("\n"), repaired };
104
+ }
@@ -0,0 +1,173 @@
1
+ import { type ArtefactWeave } from "@miadi/inquiry-weave";
2
+ import { type ReportDigest } from "./research-digest.js";
3
+ /** A saved report may be 1 MB of characters; several times that is not a document anyone reads through a tool. */
4
+ export declare const MAX_SHELF_FILE_BYTES: number;
5
+ export declare class ShelfRefusal extends Error {
6
+ readonly code: "invalid_ref" | "not_found" | "forbidden";
7
+ constructor(code: "invalid_ref" | "not_found" | "forbidden", message: string);
8
+ }
9
+ /** Who may read a folder. `weave` is undefined for a folder no one saved through a door. */
10
+ export declare function canReadShelf(weave: ArtefactWeave | undefined, person: {
11
+ id: string;
12
+ api_tier: string | null;
13
+ }): boolean;
14
+ export interface ShelfReadOptions {
15
+ file?: string;
16
+ offset?: number;
17
+ revision?: string;
18
+ maxBytes?: number;
19
+ }
20
+ /** The folder's manifest, and one file's page when a file is named or the folder holds its report. */
21
+ export declare function readShelfArtefact(root: string, name: string, person: {
22
+ id: string;
23
+ api_tier: string | null;
24
+ }, opts?: ShelfReadOptions): Promise<{
25
+ note: string;
26
+ files_truncated?: string | undefined;
27
+ episodes: string[];
28
+ git: {
29
+ commit?: string;
30
+ committed_at?: string;
31
+ uncommitted_changes?: boolean;
32
+ state: "known" | "unknown";
33
+ };
34
+ files: {
35
+ path: string;
36
+ bytes: number;
37
+ sha256?: string;
38
+ readable: boolean;
39
+ }[];
40
+ saved?: {
41
+ file: string;
42
+ sha256: string;
43
+ bytes: number;
44
+ saved_at: string;
45
+ via: string | undefined;
46
+ refs: string[];
47
+ } | undefined;
48
+ issue?: string | undefined;
49
+ issue_url?: string | undefined;
50
+ circle?: string | undefined;
51
+ visibility: "circle" | "published" | "private" | "proposed";
52
+ owner?: {
53
+ person_id: string;
54
+ name: string | undefined;
55
+ } | undefined;
56
+ artefact: string;
57
+ } | {
58
+ page: {
59
+ offset: number;
60
+ end: number;
61
+ bytes: number;
62
+ sha256: string;
63
+ complete: boolean;
64
+ next: {
65
+ offset: number;
66
+ revision: string;
67
+ } | null;
68
+ };
69
+ text: string;
70
+ modified_at?: string | undefined;
71
+ verification?: string | undefined;
72
+ bytes: number;
73
+ sha256: string;
74
+ verified: boolean;
75
+ file: string;
76
+ content_type: string;
77
+ integrity: string;
78
+ file_git: {
79
+ commit?: string;
80
+ committed_at?: string;
81
+ uncommitted_changes?: boolean;
82
+ state: "known" | "unknown";
83
+ };
84
+ files_truncated?: string | undefined;
85
+ episodes: string[];
86
+ git: {
87
+ commit?: string;
88
+ committed_at?: string;
89
+ uncommitted_changes?: boolean;
90
+ state: "known" | "unknown";
91
+ };
92
+ files: {
93
+ path: string;
94
+ bytes: number;
95
+ sha256?: string;
96
+ readable: boolean;
97
+ }[];
98
+ saved?: {
99
+ file: string;
100
+ sha256: string;
101
+ bytes: number;
102
+ saved_at: string;
103
+ via: string | undefined;
104
+ refs: string[];
105
+ } | undefined;
106
+ issue?: string | undefined;
107
+ issue_url?: string | undefined;
108
+ circle?: string | undefined;
109
+ visibility: "circle" | "published" | "private" | "proposed";
110
+ owner?: {
111
+ person_id: string;
112
+ name: string | undefined;
113
+ } | undefined;
114
+ artefact: string;
115
+ }>;
116
+ /** One thing on the shelf, as its own files say it is: computed from them, never summarised. */
117
+ export interface ShelfListing extends ReportDigest {
118
+ ref: string;
119
+ artefact: string;
120
+ /** `report`: saved through a door, with its hash. `folder`: anything else with a document. `notes`: agent instructions or unreadable files only. */
121
+ kind: "report" | "folder" | "notes";
122
+ /** The document its title and opening come from, the one a reader opens on. */
123
+ main?: string;
124
+ files: number;
125
+ /** Saved, or last woven, or its main document's last change. */
126
+ at?: string;
127
+ owner?: {
128
+ person_id: string;
129
+ name?: string;
130
+ };
131
+ visibility: string;
132
+ circle?: string;
133
+ issue?: string;
134
+ issue_url?: string;
135
+ episodes: {
136
+ number?: number;
137
+ title: string;
138
+ }[];
139
+ saved?: {
140
+ sha256: string;
141
+ bytes: number;
142
+ saved_at: string;
143
+ via?: string;
144
+ refs: string[];
145
+ };
146
+ }
147
+ /** One folder's listing. A folder that cannot be read is listed by its name. */
148
+ export declare function shelfListing(root: string, name: string, weave: ArtefactWeave | undefined): ShelfListing;
149
+ export type ShelfSort = "newest" | "oldest" | "longest" | "title";
150
+ export declare function sortListings(items: ShelfListing[], sort?: ShelfSort): ShelfListing[];
151
+ /**
152
+ * The shelf as this person may read it. `mine` is what they own. `all` is every folder, the write
153
+ * tier's alone, because a folder another person owns or no one owns is theirs to read and no one else's.
154
+ */
155
+ export declare function listShelfArtefacts(root: string, person: {
156
+ id: string;
157
+ api_tier: string | null;
158
+ }, opts?: {
159
+ scope?: "mine" | "all";
160
+ query?: string;
161
+ kind?: ShelfListing["kind"];
162
+ sort?: ShelfSort;
163
+ limit?: number;
164
+ offset?: number;
165
+ }): Promise<{
166
+ scope: "mine" | "all";
167
+ total: number;
168
+ offset: number;
169
+ limit: number;
170
+ items: ShelfListing[];
171
+ next_offset: number | null;
172
+ read_with: string;
173
+ }>;