@miadi/connector 0.1.2 → 0.1.4

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
@@ -25,9 +25,10 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
25
25
  | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
26
26
 
27
27
  - **Reads** stay within the person's Miadi tier. Sessions need the write tier, because a session's name and pane are operator data.
28
- - **Contributions** need the `miadi:contribute` scope, chosen at sign-in. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository.
29
- - **Requests** append a record that waits for approval. The answer gives an `approval_url`. A person with Miadi's write tier opens it in a browser, signs in, and approves or declines. Only that page runs the request, once, as that person:
28
+ - **Contributions** need the `miadi:contribute` scope, chosen at sign-in. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository. A long report is sent in parts (`part`, `parts`, `upload_id`) and saved when the last part arrives; the answer returns its bytes, sha256 and first and last lines.
29
+ - **Requests** append a record that waits for a decision. The answer gives an `approval_url`; Miadi's `/research` page offers the same decisions to a signed-in person with the write tier. The person approves, approves with changes (their instructions are carried into what runs), sends it back for revision with instructions (`revision_requested`, nothing runs, `get_run_result` returns the instructions), or declines. Only the approval door runs a request, once, as that person:
30
30
  - a run becomes a brief in the factory queue, which the intake delivers to a session;
31
+ - a work proposal starts its work the same way, as a brief written from the proposal;
31
32
  - a review operation calls the review service;
32
33
  - Deep Diver marks the review version ready;
33
34
  - an episode link goes through Miadi's episode door;
package/dist/approve.d.ts CHANGED
@@ -25,6 +25,13 @@ export interface ApprovalDeps {
25
25
  factoryDir?: string;
26
26
  tmuxSessions?: () => string[];
27
27
  }
28
+ /**
29
+ * What a person may decide on a request. Approving with changes runs it with the
30
+ * approver's instructions carried into what runs; a revision sends it back to
31
+ * whoever asked, with instructions, and runs nothing.
32
+ */
33
+ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "request_revision", "decline"];
34
+ export type Decision = (typeof DECISIONS)[number];
28
35
  export declare function factoryDir(env?: NodeJS.ProcessEnv): string;
29
36
  export declare function runBriefFile(record: {
30
37
  id: string;
@@ -39,9 +46,11 @@ export declare function readQueueState(record: ConnectorRecord, dir?: string): {
39
46
  file: string;
40
47
  note: string;
41
48
  };
42
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[]): {
49
+ /** The task a work proposal asks for, written from the proposal's own fields. */
50
+ export declare function proposalTask(record: ConnectorRecord): string;
51
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
43
52
  to: string;
44
53
  brief: string;
45
54
  };
46
- export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps): Promise<Record<string, unknown>>;
55
+ export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps, instructions?: string): Promise<Record<string, unknown>>;
47
56
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
package/dist/approve.js CHANGED
@@ -26,6 +26,12 @@ const APPROVABLE = new Set([
26
26
  "session_completion_proposal",
27
27
  "inquiry_episode_request",
28
28
  ]);
29
+ /**
30
+ * What a person may decide on a request. Approving with changes runs it with the
31
+ * approver's instructions carried into what runs; a revision sends it back to
32
+ * whoever asked, with instructions, and runs nothing.
33
+ */
34
+ export const DECISIONS = ["approve", "approve_with_changes", "request_revision", "decline"];
29
35
  export function factoryDir(env = process.env) {
30
36
  return env.MIADI_FACTORY_DIR || join(homedir(), ".miadi", "factory");
31
37
  }
@@ -63,8 +69,19 @@ function yymmdd(d = new Date()) {
63
69
  function attr(v) {
64
70
  return v.replace(/["<>\n]/g, " ");
65
71
  }
66
- export function runBrief(record, approver, sessions) {
67
- const body = record.body;
72
+ /** The task a work proposal asks for, written from the proposal's own fields. */
73
+ export function proposalTask(record) {
74
+ const b = record.body;
75
+ const lines = ["Carry out this approved work proposal, within its own scope and constraints.", ""];
76
+ for (const [k, v] of Object.entries(b)) {
77
+ if (v === undefined || v === null || v === "")
78
+ continue;
79
+ lines.push(`${k.replace(/_/g, " ")}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ export function runBrief(record, approver, sessions, instructions) {
84
+ const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
68
85
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
69
86
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
70
87
  const cwd = body.cwd || "/a/src/Miadi-18";
@@ -86,6 +103,16 @@ export function runBrief(record, approver, sessions) {
86
103
  // The intake reads one <input> block per file: a closing tag inside the task would end it early.
87
104
  body.task.replace(/<\/?(input|task)\b/gi, (m) => m.replace("<", "‹")),
88
105
  "</task>",
106
+ ...(instructions
107
+ ? [
108
+ "",
109
+ `${approver.name} approved it with these instructions. Where they differ from the task, they win:`,
110
+ "",
111
+ "<instructions>",
112
+ instructions.replace(/<\/?(input|task|instructions)\b/gi, (m) => m.replace("<", "‹")),
113
+ "</instructions>",
114
+ ]
115
+ : []),
89
116
  "",
90
117
  "When it is done, reply with what was achieved, the evidence (commits, paths, URLs), what is still open, and whether this session should end. Then record the receipt through the connector's update_handoff if a handoff names this record, and end the session per its lifecycle.",
91
118
  "",
@@ -98,7 +125,7 @@ function plainName(name) {
98
125
  const n = name.replace(/[^\w .'-]/g, "").trim().slice(0, 40);
99
126
  return /^[^\W_]/.test(n) ? n : "Miadi connector";
100
127
  }
101
- export async function executeApproved(record, approver, deps) {
128
+ export async function executeApproved(record, approver, deps, instructions) {
102
129
  const f = deps.fetchImpl ?? fetch;
103
130
  const b = record.body;
104
131
  const reviewCall = async (method, path, payload) => {
@@ -120,15 +147,17 @@ export async function executeApproved(record, approver, deps) {
120
147
  }
121
148
  return { status: res.status, ok: res.ok, response: parsed };
122
149
  };
150
+ const queueBrief = () => {
151
+ const queue = join(deps.factoryDir ?? factoryDir(), "queue");
152
+ mkdirSync(queue, { recursive: true });
153
+ const { to, brief } = runBrief(record, approver, (deps.tmuxSessions ?? liveTmux)(), instructions);
154
+ const file = runBriefFile(record);
155
+ writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
156
+ return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
157
+ };
123
158
  switch (record.kind) {
124
- case "run_request": {
125
- const queue = join(deps.factoryDir ?? factoryDir(), "queue");
126
- mkdirSync(queue, { recursive: true });
127
- const { to, brief } = runBrief(record, approver, (deps.tmuxSessions ?? liveTmux)());
128
- const file = runBriefFile(record);
129
- writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
130
- return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
131
- }
159
+ case "run_request":
160
+ return queueBrief();
132
161
  case "review_operation_request": {
133
162
  const url = b.video_id ? `https://youtu.be/${b.video_id}` : undefined;
134
163
  const r = b.operation === "create"
@@ -181,7 +210,8 @@ export async function executeApproved(record, approver, deps) {
181
210
  }
182
211
  }
183
212
  case "work_proposal":
184
- return { outcome: "approved", note: "The proposal is approved; a run starts only through request_authorized_run." };
213
+ // Approving a proposal starts its work: a factory brief written from the proposal and the approver's instructions.
214
+ return queueBrief();
185
215
  case "session_completion_proposal":
186
216
  return {
187
217
  outcome: "decided",
@@ -233,7 +263,7 @@ ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}
233
263
  }
234
264
  export function registerApprovalRoutes(app, deps) {
235
265
  const find = (id) => current(deps.ledger.all()).find((r) => r.id === id);
236
- const form = express.urlencoded({ extended: false, limit: "16kb" });
266
+ const form = express.urlencoded({ extended: false, limit: "128kb" });
237
267
  app.get("/approve/:id", (req, res) => {
238
268
  const r = find(String(req.params.id));
239
269
  if (!r || !APPROVABLE.has(r.kind)) {
@@ -262,12 +292,24 @@ export function registerApprovalRoutes(app, deps) {
262
292
  res.status(403).type("html").send(approvePage(r, `Approving needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
263
293
  return;
264
294
  }
265
- const decision = body.decision === "approve" ? "approved" : "declined";
266
- let receipt = { decided_by: { person_id: person.id, name: person.name }, decided_at: new Date().toISOString(), note: body.note || undefined };
267
- let state = decision === "approved" ? "completed" : "withdrawn";
295
+ const choice = DECISIONS.includes(String(body.decision)) ? body.decision : "decline";
296
+ const instructions = String(body.instructions ?? "").trim().slice(0, 20000) || undefined;
297
+ if ((choice === "approve_with_changes" || choice === "request_revision") && !instructions) {
298
+ res.status(400).type("html").send(approvePage(r, choice === "request_revision" ? "Say what to revise." : "Write the changes to apply."));
299
+ return;
300
+ }
301
+ const decision = choice === "approve" || choice === "approve_with_changes" ? "approved" : choice === "request_revision" ? "revision_requested" : "declined";
302
+ let receipt = {
303
+ decided_by: { person_id: person.id, name: person.name },
304
+ decided_at: new Date().toISOString(),
305
+ decision: choice,
306
+ ...(instructions ? { instructions } : {}),
307
+ note: body.note || undefined,
308
+ };
309
+ let state = decision === "approved" ? "completed" : decision === "revision_requested" ? "pending" : "withdrawn";
268
310
  if (decision === "approved") {
269
311
  try {
270
- const outcome = await executeApproved(r, person, deps);
312
+ const outcome = await executeApproved(r, person, deps, instructions);
271
313
  receipt = { ...receipt, ...outcome };
272
314
  if (outcome.outcome === "failed")
273
315
  state = "blocked";
@@ -283,16 +325,17 @@ export function registerApprovalRoutes(app, deps) {
283
325
  id: newRecordId(r.kind),
284
326
  kind: r.kind,
285
327
  created_at: new Date().toISOString(),
286
- actor: { person_id: person.id, name: person.name, client_id: "approval-page" },
328
+ actor: { person_id: person.id, name: person.name, client_id: String(body.via ?? "approval-page").slice(0, 60) },
287
329
  refs: r.refs,
288
330
  evidence_refs: [],
289
331
  supersedes: r.id,
290
332
  approval_state: decision,
291
333
  state,
334
+ ...(decision === "revision_requested" ? { next_action: `Revise and submit a new request that cites ${r.id} in refs.` } : {}),
292
335
  completion_receipt: receipt,
293
- body: { decision },
336
+ body: { decision: choice, ...(instructions ? { instructions } : {}) },
294
337
  });
295
- res.type("html").send(page("Recorded", `<h1>${decision === "approved" ? "Approved" : "Declined"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre>`));
338
+ res.type("html").send(page("Recorded", `<h1>${decision === "approved" ? "Approved" : decision === "revision_requested" ? "Sent back for revision" : "Declined"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre>`));
296
339
  });
297
340
  // A record's own page, for citations in ChatGPT: any person Miadi recognises may read it.
298
341
  app.get("/records/:id", (req, res) => {
@@ -328,10 +371,12 @@ ${r.approval_state === "awaiting_approval"
328
371
  ? `<form method="post">
329
372
  <label for="t">Your Miadi person token (write tier)</label>
330
373
  <input id="t" name="person_token" type="password" autocomplete="off" required>
331
- <label for="n">Note (optional)</label>
332
- <textarea id="n" name="note" rows="2"></textarea>
374
+ <label for="i">Instructions (needed to approve with changes or to send back)</label>
375
+ <textarea id="i" name="instructions" rows="4"></textarea>
333
376
  <div class="row">
334
377
  <button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
378
+ <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>
379
+ <button type="submit" name="decision" value="request_revision">Send back for revision</button>
335
380
  <button type="submit" name="decision" value="decline">Decline</button>
336
381
  </div></form>`
337
382
  : ""}`);
package/dist/records.d.ts CHANGED
@@ -40,8 +40,12 @@ export type RecordKind = (typeof RECORD_KINDS)[number];
40
40
  /** #816 handoff states. */
41
41
  export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
42
42
  export type HandoffState = (typeof HANDOFF_STATES)[number];
43
- /** Approval of a request that a person must grant before anything runs. */
44
- export declare const APPROVAL_STATES: readonly ["awaiting_approval", "approved", "declined", "not_required"];
43
+ /**
44
+ * Approval of a request that a person must grant before anything runs.
45
+ * `revision_requested`: sent back to whoever asked, with the approver's
46
+ * instructions in the receipt; nothing of it ran.
47
+ */
48
+ export declare const APPROVAL_STATES: readonly ["awaiting_approval", "approved", "declined", "revision_requested", "not_required"];
45
49
  export type ApprovalState = (typeof APPROVAL_STATES)[number];
46
50
  export interface Actor {
47
51
  person_id: string;
package/dist/records.js CHANGED
@@ -63,8 +63,12 @@ export const RECORD_KINDS = [
63
63
  ];
64
64
  /** #816 handoff states. */
65
65
  export const HANDOFF_STATES = ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
66
- /** Approval of a request that a person must grant before anything runs. */
67
- export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "not_required"];
66
+ /**
67
+ * Approval of a request that a person must grant before anything runs.
68
+ * `revision_requested`: sent back to whoever asked, with the approver's
69
+ * instructions in the receipt; nothing of it ran.
70
+ */
71
+ export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "revision_requested", "not_required"];
68
72
  export function newRecordId(kind) {
69
73
  return `${kind}:${randomUUID()}`;
70
74
  }
package/dist/server.d.ts CHANGED
@@ -15,4 +15,4 @@ export declare function createConnectorApp(config: ConnectorConfig, deps: Connec
15
15
  provider: MiadiOAuthProvider;
16
16
  ledger: Ledger<ConnectorRecord>;
17
17
  };
18
- 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, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.\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.";
18
+ 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 in Miadi. 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 submit a revised request citing the old id in refs), or decline.\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
@@ -116,6 +116,7 @@ Start with get_capabilities: it says who you are signed in as, which tools are l
116
116
 
117
117
  Rules every tool keeps:
118
118
  - 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.
119
- - Requests (runs, review operations, Deep Diver, review-to-episode links, session completion, calendar events) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person approves it in Miadi.
119
+ - 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 in Miadi. 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 submit a revised request citing the old id in refs), or decline.
120
+ - 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.
120
121
  - An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.
121
122
  - Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.`;
package/dist/tools.js CHANGED
@@ -19,6 +19,7 @@ import { VERSION } from "./version.js";
19
19
  import { readQueueState } from "./approve.js";
20
20
  import { join } from "node:path";
21
21
  import { artefactVisibility, readArtefactWeave, saveResearchArtefact } from "@miadi/inquiry-weave";
22
+ import { ResearchUploads, UploadRefusal } from "./uploads.js";
22
23
  class ToolRefusal extends Error {
23
24
  code;
24
25
  detail;
@@ -56,6 +57,8 @@ function wrap(fn) {
56
57
  catch (error) {
57
58
  if (error instanceof ToolRefusal)
58
59
  return fail({ error: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) });
60
+ if (error instanceof UploadRefusal)
61
+ return fail({ error: "invalid_upload", message: error.message });
59
62
  if (error instanceof MiadiError) {
60
63
  const unavailable = error.status === 0;
61
64
  return fail({
@@ -102,6 +105,7 @@ export function registerTools(server, ctx) {
102
105
  });
103
106
  };
104
107
  const records = () => current(ledger.all());
108
+ const uploads = new ResearchUploads(join(config.stateDir, "uploads"));
105
109
  const touching = (ref) => records().filter((r) => r.refs.includes(ref) || r.evidence_refs.includes(ref) || r.id === ref);
106
110
  // ── reads ──────────────────────────────────────────────────────────────
107
111
  server.registerTool("get_capabilities", {
@@ -404,7 +408,7 @@ export function registerTools(server, ctx) {
404
408
  }));
405
409
  server.registerTool("get_run_result", {
406
410
  title: "Where a requested run stands",
407
- description: "A run request's approval, the brief queued for the factory intake once approved, whether the intake delivered it, and its receipt.",
411
+ description: "A request's decision (approved, approved with changes, sent back for revision, declined), the approver's instructions, the brief queued for the factory intake once approved, whether the intake delivered it, and its receipt.",
408
412
  inputSchema: { record_id: z.string().min(1) },
409
413
  annotations: READ,
410
414
  }, wrap(async ({ record_id }, extra) => {
@@ -415,7 +419,10 @@ export function registerTools(server, ctx) {
415
419
  return {
416
420
  record: r,
417
421
  approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
418
- queue: r.kind === "run_request" ? readQueueState(r, ctx.factoryDir) : undefined,
422
+ queue: r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined,
423
+ ...(r.approval_state === "revision_requested"
424
+ ? { revision: { instructions: r.completion_receipt?.instructions, next: `Submit a revised request that cites ${r.id} in refs.` } }
425
+ : {}),
419
426
  };
420
427
  }));
421
428
  // search / fetch: the pair ChatGPT uses for connectors in search and deep research.
@@ -674,7 +681,7 @@ export function registerTools(server, ctx) {
674
681
  }));
675
682
  server.registerTool("propose_work", {
676
683
  title: "Propose work to a circle",
677
- description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. A proposal; nothing starts. A person approves it before it becomes a run.",
684
+ description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. Nothing starts until a person decides. Approving it starts the work as a factory run, with any changes the person writes; the person may instead send it back for revision with instructions (approval_state revision_requested, instructions in completion_receipt): then submit a revised proposal citing the old id in refs.",
678
685
  inputSchema: {
679
686
  title: z.string().min(1).max(200),
680
687
  desired_outcome: z.string().min(1).max(6000),
@@ -877,10 +884,13 @@ export function registerTools(server, ctx) {
877
884
  // ── research on the inquiry shelf (jgwill/Miadi#879) ───────────────────
878
885
  server.registerTool("save_research", {
879
886
  title: "Save a research report on the inquiry shelf",
880
- description: "Save a finished research report (for example a Deep Research export) in your name: a new folder on Miadi's inquiry shelf holding the Markdown exactly as given, its inquiry issue, and the folder committed and pushed in the shelf's repository. Private to you unless shared with a circle; publishing goes through the public-inquiry route, never here. The same report saved again returns the folder already saved. Weave it into an episode afterwards with request_inquiry_episode.",
887
+ description: "Deliver a finished research report (a Deep Research report) to Miadi in your name, exactly as written: never summarised, shortened or rewritten, with its sources and citations. It is saved byte-for-byte in a new folder on Miadi's inquiry shelf, gets its inquiry issue, and is committed and pushed. A report longer than about 60,000 characters is sent in parts: call with part 1 and parts N, then with the upload_id returned and parts 2..N, each part the next slice of the text cut at a line end; the report is saved when the last part arrives. The answer gives bytes, sha256 and the report's first and last lines so you can confirm all of it arrived. Private to you unless shared with a circle; never published from here. The same report saved again returns the folder already saved. Weave it into an episode afterwards with request_inquiry_episode.",
881
888
  inputSchema: {
882
889
  title: z.string().min(1).max(200),
883
- markdown: z.string().min(1).max(1_000_000).describe("The whole report, as Markdown. Saved byte-for-byte."),
890
+ markdown: z.string().min(1).max(1_000_000).describe("The whole report, or this part of it, exactly as written."),
891
+ part: z.number().int().min(1).max(200).optional().describe("Which part this is, from 1, when the report is sent in parts."),
892
+ parts: z.number().int().min(1).max(200).optional().describe("How many parts the report is sent in."),
893
+ upload_id: z.string().max(80).optional().describe("Returned by part 1; give it with every later part."),
884
894
  refs: refs.describe("Records and sources the report answers: a connector inquiry id, a DOI or URL, owner/repo#n."),
885
895
  visibility: z.enum(["private", "circle"]).default("private"),
886
896
  circle: circle.optional().describe("The circle it is shared with, when visibility is circle."),
@@ -894,20 +904,32 @@ export function registerTools(server, ctx) {
894
904
  throw new ToolRefusal("unavailable", "This connector host has no inquiry shelf configured.");
895
905
  if (a.visibility === "circle" && !a.circle)
896
906
  throw new ToolRefusal("invalid_input", "Name the circle the report is shared with.");
907
+ let markdown = a.markdown;
908
+ let meta = { title: a.title, refs: a.refs, visibility: a.visibility, circle: a.circle };
909
+ const parts = a.parts ?? 1;
910
+ if (parts > 1 || a.upload_id) {
911
+ const got = uploads.receive(person.id, { upload_id: a.upload_id, part: a.part ?? 1, parts, text: a.markdown, meta });
912
+ if (!got.complete) {
913
+ return { upload_id: got.upload_id, received: got.received, missing: got.missing, bytes_so_far: got.bytes, next: `Send part ${got.missing[0]} of ${got.parts} with upload_id ${got.upload_id}.` };
914
+ }
915
+ markdown = got.text;
916
+ meta = got.meta;
917
+ }
897
918
  const saved = await saveResearchArtefact({
898
919
  inquiryRoot: config.inquiryRoot,
899
920
  repo: config.inquiryRepo,
900
- title: a.title,
901
- markdown: a.markdown,
921
+ title: meta.title,
922
+ markdown,
902
923
  owner: { person_id: person.id, name: person.name },
903
- visibility: a.visibility,
904
- circle: a.circle,
924
+ visibility: meta.visibility,
925
+ circle: meta.circle,
905
926
  via: "miadi-connector",
906
- refs: a.refs,
927
+ refs: meta.refs,
907
928
  });
929
+ const lines = markdown.split("\n").filter((l) => l.trim());
908
930
  const r = append("research_artifact", person, auth, {
909
- circle: a.circle ?? "stewardship",
910
- refs: [`inquiry-artefact:${saved.artefact}`, ...(saved.issue ? [saved.issue] : []), ...a.refs],
931
+ circle: meta.circle ?? "stewardship",
932
+ refs: [`inquiry-artefact:${saved.artefact}`, ...(saved.issue ? [saved.issue] : []), ...meta.refs],
911
933
  intention: a.intention,
912
934
  role: a.role,
913
935
  idempotency_key: a.idempotency_key ?? `research:${saved.sha256}`,
@@ -915,18 +937,23 @@ export function registerTools(server, ctx) {
915
937
  state: saved.shelf.state === "landed" ? "completed" : "blocked",
916
938
  next_action: "request_inquiry_episode to weave it into an episode or start one",
917
939
  body: {
918
- title: a.title,
940
+ title: meta.title,
919
941
  artefact: saved.artefact,
920
942
  issue: saved.issue,
921
943
  issue_url: saved.issue_url,
922
944
  sha256: saved.sha256,
923
945
  bytes: saved.bytes,
924
946
  visibility: saved.visibility,
925
- circle: a.circle,
947
+ circle: meta.circle,
926
948
  shelf: { state: saved.shelf.state, observed: saved.shelf.observed },
927
949
  },
928
950
  });
929
- return { ...r, saved: saved.status, research_url: `${config.miadiPublicUrl}/research` };
951
+ return {
952
+ ...r,
953
+ saved: saved.status,
954
+ received: { bytes: saved.bytes, sha256: saved.sha256, parts, first_line: lines[0]?.slice(0, 200), last_line: lines.at(-1)?.slice(0, 200) },
955
+ research_url: `${config.miadiPublicUrl}/research`,
956
+ };
930
957
  }));
931
958
  server.registerTool("request_inquiry_episode", {
932
959
  title: "Request that an inquiry be woven into an episode, or start one",
@@ -1046,13 +1073,13 @@ export const TOOL_STATUS = {
1046
1073
  register_source_intent: "live, contribute",
1047
1074
  record_handoff: "live, contribute",
1048
1075
  update_handoff: "live, contribute",
1049
- propose_work: "live, contribute, needs_approval",
1076
+ propose_work: "live, contribute, needs_approval (approving starts the work as a factory run)",
1050
1077
  prepare_calendar_event: "live, contribute (draft and .ics only)",
1051
1078
  propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
1052
1079
  request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved)",
1053
1080
  request_review_operation: "live, contribute, needs_approval",
1054
1081
  request_deep_diver: "live, contribute, needs_approval",
1055
1082
  link_review_to_episode: "live, contribute, needs_approval",
1056
- save_research: "live, contribute (saves on the inquiry shelf, opens its issue, pushes; private by default)",
1083
+ save_research: "live, contribute (the whole report, in parts when long; saved on the inquiry shelf with its issue, pushed; private by default)",
1057
1084
  request_inquiry_episode: "live, contribute, needs_approval (weaves a shelf folder into an episode or starts one)",
1058
1085
  };
@@ -0,0 +1,27 @@
1
+ export declare const MAX_UPLOAD_CHARS = 5000000;
2
+ export declare class UploadRefusal extends Error {
3
+ }
4
+ export declare class ResearchUploads {
5
+ private readonly dir;
6
+ constructor(dir: string);
7
+ private path;
8
+ receive(personId: string, input: {
9
+ upload_id?: string;
10
+ part: number;
11
+ parts: number;
12
+ text: string;
13
+ meta: Record<string, unknown>;
14
+ }): {
15
+ upload_id: string;
16
+ parts: number;
17
+ received: number[];
18
+ missing: number[];
19
+ bytes: number;
20
+ complete: false;
21
+ } | {
22
+ complete: true;
23
+ upload_id: string;
24
+ text: string;
25
+ meta: Record<string, unknown>;
26
+ };
27
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A research report sent in parts (save_research with part/parts): each part is
3
+ * kept under <state>/uploads/<id>.json until the last arrives, then the parts are
4
+ * joined in order, exactly, and the upload is removed. One person's upload is
5
+ * never readable by another.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ export const MAX_UPLOAD_CHARS = 5_000_000;
11
+ export class UploadRefusal extends Error {
12
+ }
13
+ export class ResearchUploads {
14
+ dir;
15
+ constructor(dir) {
16
+ this.dir = dir;
17
+ }
18
+ path(id) {
19
+ if (!/^upload-[0-9a-f-]{36}$/.test(id))
20
+ throw new UploadRefusal(`Unknown upload_id ${id}.`);
21
+ return join(this.dir, `${id}.json`);
22
+ }
23
+ receive(personId, input) {
24
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
25
+ let up;
26
+ if (input.upload_id) {
27
+ const p = this.path(input.upload_id);
28
+ if (!existsSync(p))
29
+ throw new UploadRefusal(`Unknown upload_id ${input.upload_id}: start again with part 1.`);
30
+ up = JSON.parse(readFileSync(p, "utf8"));
31
+ if (up.person_id !== personId)
32
+ throw new UploadRefusal(`Unknown upload_id ${input.upload_id}.`);
33
+ }
34
+ else {
35
+ if (input.part !== 1)
36
+ throw new UploadRefusal("Start with part 1; it returns the upload_id the later parts carry.");
37
+ up = { id: `upload-${randomUUID()}`, person_id: personId, parts: input.parts, received: {}, meta: input.meta, created_at: new Date().toISOString() };
38
+ }
39
+ if (input.part > up.parts)
40
+ throw new UploadRefusal(`This upload has ${up.parts} parts; part ${input.part} does not exist.`);
41
+ up.received[String(input.part)] = input.text;
42
+ const total = Object.values(up.received).reduce((n, t) => n + t.length, 0);
43
+ if (total > MAX_UPLOAD_CHARS)
44
+ throw new UploadRefusal(`The report passes ${MAX_UPLOAD_CHARS} characters.`);
45
+ const missing = Array.from({ length: up.parts }, (_, i) => i + 1).filter((n) => !(String(n) in up.received));
46
+ if (missing.length) {
47
+ writeFileSync(this.path(up.id), JSON.stringify(up), { mode: 0o600 });
48
+ return { upload_id: up.id, parts: up.parts, received: Object.keys(up.received).map(Number).sort((a, b) => a - b), missing, bytes: total, complete: false };
49
+ }
50
+ const text = Array.from({ length: up.parts }, (_, i) => up.received[String(i + 1)]).join("");
51
+ rmSync(this.path(up.id), { force: true });
52
+ return { complete: true, upload_id: up.id, text, meta: up.meta };
53
+ }
54
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.2";
1
+ export declare const VERSION = "0.1.4";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.2";
1
+ export const VERSION = "0.1.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/connector",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -12,6 +12,10 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js"
14
14
  },
15
+ "./records": {
16
+ "types": "./dist/records.d.ts",
17
+ "import": "./dist/records.js"
18
+ },
15
19
  "./package.json": "./package.json"
16
20
  },
17
21
  "bin": {