@miadi/connector 0.1.11 → 0.1.12

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
@@ -27,6 +27,7 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
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.
29
29
  - **Episode files.** `get_evidence` lists an episode's files; each text file Miadi can preview carries a `read_ref` (`miadi-chronicle://<n>/<path>`). `read_episode_file` (or `fetch` with that ref) returns the file's whole UTF-8 text, read as the person through Miadi's file door, so Miadi's audience and room rules apply. The answer names the episode, the path, the content type, the byte count and the sha256, and `verified` says whether the text received hashes to what Miadi read. Only md, txt, yaml, yml and json files up to 512 KiB are read. A traversal, absolute, hidden or encoded path is refused before any URL is built. A longer text comes in pages cut on character boundaries: pass `page.next` back, and the joined pages hash to `sha256`. Other kinds answer `unsupported`, a file over the limit `too_large`, a missing one `not_found`, a ceremony note below the write gate `forbidden`.
30
+ - **One narrow grant.** A person with no Miadi API tier who holds `chronicle_foundations_read` (ticked by an admin under /admin → People → Additional permissions; admins hold it through their role) reaches `read_episode_file` for foundation documents only: `foundations/<topic>/*.md`, read through Miadi's `GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md`, which checks the permission on every read. Naming the episode alone lists them. Every other file and every other tool stays refused, and the permission gives no API tier. Unticking it refuses the next read. `get_capabilities` reports it under `foundation_read`, from Miadi's answer at that moment.
30
31
  - **Contributions** need Miadi's read tier and the `miadi:contribute` scope, chosen at sign-in. A record is moved on (`update_handoff`) only by its author or the write tier, and a request's receipt comes only from its decision. 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.
31
32
  - **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, and `show_decisions` shows them as cards inside the conversation (below). 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; a second decision on the same request, at the same time or later, is refused:
32
33
  - a run becomes a brief in the factory queue, which the intake delivers to a session;
@@ -1,5 +1,14 @@
1
1
  /** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
2
2
  export declare const TEXT_TYPES: Record<string, string>;
3
+ /**
4
+ * The one Miadi permission that reads foundation documents without an API
5
+ * tier (jgwill/Miadi#905). Miadi checks it on every read at
6
+ * GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md.
7
+ */
8
+ export declare const FOUNDATIONS_PERMISSION = "chronicle_foundations_read";
9
+ /** `foundations/<topic>/…/<file>.md`: what that permission reads, and nothing else. */
10
+ export declare function isFoundationDoc(segments: string[]): boolean;
11
+ export declare function holdsFoundationRead(permissions: readonly string[] | undefined): boolean;
3
12
  /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
4
13
  export declare const MAX_TEXT_BYTES: number;
5
14
  export declare const DEFAULT_PAGE_BYTES = 100000;
@@ -21,6 +21,19 @@ export const TEXT_TYPES = {
21
21
  ".yml": "application/yaml; charset=utf-8",
22
22
  ".json": "application/json; charset=utf-8",
23
23
  };
24
+ /**
25
+ * The one Miadi permission that reads foundation documents without an API
26
+ * tier (jgwill/Miadi#905). Miadi checks it on every read at
27
+ * GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md.
28
+ */
29
+ export const FOUNDATIONS_PERMISSION = "chronicle_foundations_read";
30
+ /** `foundations/<topic>/…/<file>.md`: what that permission reads, and nothing else. */
31
+ export function isFoundationDoc(segments) {
32
+ return segments.length >= 3 && segments[0] === "foundations" && extensionOf(segments.join("/")) === ".md";
33
+ }
34
+ export function holdsFoundationRead(permissions) {
35
+ return Boolean(permissions?.includes("*") || permissions?.includes(FOUNDATIONS_PERMISSION));
36
+ }
24
37
  /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
25
38
  export const MAX_TEXT_BYTES = 512 * 1024;
26
39
  export const DEFAULT_PAGE_BYTES = 100_000;
package/dist/tools.js CHANGED
@@ -24,7 +24,7 @@ import { checkGrant, isBusy, steerAttempts, steeringGrantOf, steerSession } from
24
24
  import { join } from "node:path";
25
25
  import { artefactVisibility, readArtefactWeave, saveResearchArtefact } from "@miadi/inquiry-weave";
26
26
  import { ResearchUploads, UploadRefusal } from "./uploads.js";
27
- import { DEFAULT_PAGE_BYTES, FileRefusal, MAX_TEXT_BYTES, MIN_PAGE_BYTES, TEXT_TYPES, checkRelativePath, episodeNumberOf, extensionOf, fileRef, pageOf, parseFileRef, } from "./episode-files.js";
27
+ import { DEFAULT_PAGE_BYTES, FOUNDATIONS_PERMISSION, FileRefusal, MAX_TEXT_BYTES, MIN_PAGE_BYTES, TEXT_TYPES, checkRelativePath, episodeNumberOf, extensionOf, fileRef, holdsFoundationRead, isFoundationDoc, pageOf, parseFileRef, } from "./episode-files.js";
28
28
  class ToolRefusal extends Error {
29
29
  code;
30
30
  detail;
@@ -161,7 +161,14 @@ export function registerTools(server, ctx) {
161
161
  annotations: READ,
162
162
  }, wrap(async (_args, extra) => {
163
163
  const { person, auth } = who(extra, "none");
164
- const miadiUp = await miadi.get(person.token, "/api/identity/me").then(() => "reachable", (e) => (e instanceof MiadiError && e.status === 0 ? "unavailable" : "reachable"));
164
+ let fresh;
165
+ const miadiUp = await miadi.get(person.token, "/api/identity/me").then((me) => {
166
+ if (Array.isArray(me?.permissions))
167
+ fresh = me.permissions.map(String);
168
+ return "reachable";
169
+ }, (e) => (e instanceof MiadiError && e.status === 0 ? "unavailable" : "reachable"));
170
+ // Miadi's answer now, not the sign-in's last one (cached up to five minutes).
171
+ const foundations = holdsFoundationRead(fresh ?? person.permissions);
165
172
  return {
166
173
  service: "@miadi/connector",
167
174
  version: VERSION,
@@ -175,6 +182,15 @@ export function registerTools(server, ctx) {
175
182
  ledger: "connector ledger (append-only, /srv/miadi/connector)",
176
183
  },
177
184
  tools: TOOL_STATUS,
185
+ foundation_read: {
186
+ permission: FOUNDATIONS_PERMISSION,
187
+ held: foundations,
188
+ reads: foundations
189
+ ? "read_episode_file reads foundations/<topic>/*.md in episodes you can see; Miadi checks the permission on every read."
190
+ : person.api_tier
191
+ ? "Not held. read_episode_file reads an episode's text files through your Miadi read tier."
192
+ : "Not held: nothing to read. An admin gives it under /admin → People → Additional permissions.",
193
+ },
178
194
  domains: DOMAINS,
179
195
  circles: CIRCLES.map((c) => ({ id: c.id, glyph: c.glyph, name: c.name })),
180
196
  not_built: [
@@ -341,6 +357,26 @@ export function registerTools(server, ctx) {
341
357
  * door; Miadi applies their audience, the room rule and the vessel's path
342
358
  * guard, and the path is checked here before a URL is built (jgwill/Miadi#815).
343
359
  */
360
+ /** A number, epNNN or a folder name; `..` would be folded by the URL parser into another route. */
361
+ const checkedEpisodeRef = (episode) => {
362
+ const ref = episodeRef(episode);
363
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(decodeURIComponent(ref))) {
364
+ throw new FileRefusal("invalid_path", "Name the episode as miadi-chronicle://<n>, its number or its folder.");
365
+ }
366
+ return ref;
367
+ };
368
+ /** An episode's foundation documents, by path and size: what read_episode_file reads with the permission alone. */
369
+ const foundationListing = async (person, episode) => {
370
+ const listing = await miadi.get(person.token, `/api/chronicle/episodes/${checkedEpisodeRef(episode)}/files`);
371
+ const folder = listing?.episode?.path;
372
+ const number = listing?.episode?.number ?? (folder ? episodeNumberOf(folder) : null);
373
+ const docs = (Array.isArray(listing?.files) ? listing.files : []).filter((f) => f?.previewable && isFoundationDoc(String(f.relativePath).split("/")));
374
+ return {
375
+ episode: { ref: number === null ? `chronicle:${folder}` : `miadi-chronicle://${number}`, number, folder },
376
+ foundations: docs.map((f) => ({ path: f.relativePath, bytes: f.size, modified_at: f.modifiedAt, read_ref: fileRef(number ?? folder, f.relativePath) })),
377
+ count: docs.length,
378
+ };
379
+ };
344
380
  const readEpisodeFile = async (person, a) => {
345
381
  const path = a.path.trim();
346
382
  const segments = checkRelativePath(path);
@@ -349,12 +385,18 @@ export function registerTools(server, ctx) {
349
385
  if (!contentType) {
350
386
  throw new FileRefusal("unsupported", `${path} is not read as text here: only ${Object.keys(TEXT_TYPES).join(", ")} files are. Miadi serves documents (html, pdf, csv, svg) as downloads and images and media by their own routes; get_evidence lists them.`);
351
387
  }
352
- const ref = episodeRef(a.episode);
353
- // A number, epNNN or a folder name; `..` would be folded by the URL parser into another route.
354
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(decodeURIComponent(ref))) {
355
- throw new FileRefusal("invalid_path", "Name the episode as miadi-chronicle://<n>, its number or its folder.");
388
+ const ref = checkedEpisodeRef(a.episode);
389
+ // Without a tier, a foundation document is all this tool reads, through
390
+ // Miadi's foundation door, which decides on every call whether the person
391
+ // holds the permission: a grant or a revocation counts from the next call,
392
+ // whatever the sign-in cached. A tier holder reads the room as before.
393
+ const foundation = person.api_tier === null && isFoundationDoc(segments);
394
+ if (!foundation && person.api_tier === null) {
395
+ throw new ToolRefusal("forbidden", `Without Miadi's read tier, this tool reads only foundation documents (foundations/<topic>/<file>.md), with the ${FOUNDATIONS_PERMISSION} permission.`);
356
396
  }
357
- const url = `/api/chronicle/episodes/${ref}/files/${segments.map(encodeURIComponent).join("/")}`;
397
+ const url = foundation
398
+ ? `/api/chronicle/episodes/${ref}/foundations/${segments.slice(1).map(encodeURIComponent).join("/")}`
399
+ : `/api/chronicle/episodes/${ref}/files/${segments.map(encodeURIComponent).join("/")}`;
358
400
  let text;
359
401
  try {
360
402
  text = await miadi.get(person.token, url);
@@ -368,7 +410,12 @@ export function registerTools(server, ctx) {
368
410
  throw new ToolRefusal("unavailable", "Miadi could not be reached.", detail);
369
411
  if (body.error === "not_in_the_room")
370
412
  throw new ToolRefusal("forbidden", body.message ?? "This file is not part of the episode room.", detail);
371
- if (error.status === 401 || error.status === 403)
413
+ if (error.status === 401)
414
+ throw new ToolRefusal("unauthenticated", "Miadi does not recognise this sign-in's token.", detail);
415
+ if (error.status === 403 && foundation) {
416
+ throw new ToolRefusal("forbidden", `Reading foundation documents needs the ${FOUNDATIONS_PERMISSION} permission; an admin gives it under /admin → People → Additional permissions.`, detail);
417
+ }
418
+ if (error.status === 403)
372
419
  throw new ToolRefusal("forbidden", body.message ?? "Miadi does not let this person read that file.", detail);
373
420
  if (error.status === 404)
374
421
  throw new ToolRefusal("not_found", body.message ?? `No file ${path} in ${decodeURIComponent(ref)}.`, detail);
@@ -408,7 +455,7 @@ export function registerTools(server, ctx) {
408
455
  };
409
456
  server.registerTool("read_episode_file", {
410
457
  title: "Read one file an episode holds",
411
- description: "The full UTF-8 text of one text file (md, txt, yaml, yml, json) in a Chronicle episode, read as you through Miadi, with its content type, byte count and sha256. Name the episode and the path get_evidence lists (or pass its read_ref as episode alone). A file longer than max_bytes comes in pages: pass page.next back to read the next one; joined, the pages hash to sha256. Read only: nothing is changed.",
458
+ description: "The full UTF-8 text of one text file (md, txt, yaml, yml, json) in a Chronicle episode, read as you through Miadi, with its content type, byte count and sha256. Name the episode and the path get_evidence lists (or pass its read_ref as episode alone); name the episode alone to list its foundation documents. With Miadi's read tier it reads the episode's text files; with the chronicle_foundations_read permission alone, foundations/<topic>/*.md only. A file longer than max_bytes comes in pages: pass page.next back to read the next one; joined, the pages hash to sha256. Read only: nothing is changed.",
412
459
  inputSchema: {
413
460
  episode: z.string().min(1).describe("miadi-chronicle://<n>, an episode number or its folder; or a read_ref (miadi-chronicle://<n>/<path>) with path left out."),
414
461
  path: z.string().optional().describe("The file's path inside the episode, as get_evidence lists it, e.g. foundations/broker-adapters/README.md."),
@@ -418,13 +465,22 @@ export function registerTools(server, ctx) {
418
465
  },
419
466
  annotations: READ,
420
467
  }, wrap(async (a, extra) => {
421
- const { person } = who(extra);
468
+ // No API tier needed: the permission alone admits foundation documents (jgwill/Miadi#905).
469
+ const { person } = who(extra, "none");
422
470
  let episode = a.episode;
423
471
  let path = a.path;
424
472
  if (!path) {
425
473
  const parsed = parseFileRef(a.episode);
426
- if (!parsed)
427
- throw new FileRefusal("invalid_path", "Name the file: give path, or pass a read_ref (miadi-chronicle://<n>/<path>) as episode.");
474
+ if (!parsed) {
475
+ // Miadi's answer now: the sign-in's copy may be five minutes old, and a
476
+ // person just given the permission would be refused the listing.
477
+ const now = person.api_tier === null ? await miadi.get(person.token, "/api/identity/me").catch(() => null) : null;
478
+ const permissions = Array.isArray(now?.permissions) ? now.permissions.map(String) : person.permissions;
479
+ if (person.api_tier === null && !holdsFoundationRead(permissions)) {
480
+ throw new ToolRefusal("forbidden", `Listing an episode's foundation documents needs Miadi's read tier or the ${FOUNDATIONS_PERMISSION} permission.`);
481
+ }
482
+ return foundationListing(person, a.episode);
483
+ }
428
484
  ({ episode, path } = parsed);
429
485
  }
430
486
  return readEpisodeFile(person, { ...a, episode, path });
@@ -1548,7 +1604,7 @@ export const TOOL_STATUS = {
1548
1604
  list_sessions: "live, Miadi write tier",
1549
1605
  get_session: "live, Miadi write tier",
1550
1606
  get_evidence: "live",
1551
- read_episode_file: "live (text files an episode holds: md, txt, yaml, yml, json, up to 512 KiB, paged; read as you, nothing changed)",
1607
+ read_episode_file: "live (Miadi read tier: an episode's text files; chronicle_foundations_read alone: foundations/<topic>/*.md; up to 512 KiB, paged; read as you, nothing changed)",
1552
1608
  get_evaluations: "live (tracing records and recorded assessments; no provider scores)",
1553
1609
  get_narrative_beats: "live (NCP project beats and candidate beats)",
1554
1610
  review_lookup: "live (video search needs the review token on the connector host)",
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.11";
1
+ export declare const VERSION = "0.1.12";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.11";
1
+ export const VERSION = "0.1.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miadi/connector",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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",