@miadi/connector 0.1.10 → 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
@@ -20,12 +20,14 @@ 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` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `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` `search` `fetch` |
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
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` |
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.
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.
29
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.
30
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:
31
33
  - a run becomes a brief in the factory queue, which the intake delivers to a session;
@@ -0,0 +1,71 @@
1
+ /** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
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;
12
+ /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
13
+ export declare const MAX_TEXT_BYTES: number;
14
+ export declare const DEFAULT_PAGE_BYTES = 100000;
15
+ /** A page must hold the longest UTF-8 character. */
16
+ export declare const MIN_PAGE_BYTES = 4;
17
+ export declare class FileRefusal extends Error {
18
+ readonly code: "invalid_path" | "invalid_offset" | "unsupported" | "changed";
19
+ constructor(code: "invalid_path" | "invalid_offset" | "unsupported" | "changed", message: string);
20
+ }
21
+ /** The same rule as the vessel's assertRelativeFile, applied before any URL exists. */
22
+ export declare function checkRelativePath(value: string): string[];
23
+ export declare function extensionOf(path: string): string;
24
+ /** `miadi-chronicle://548/foundations/x/README.md` → its episode and path; null for any other id. */
25
+ export declare function parseFileRef(id: string): {
26
+ episode: string;
27
+ path: string;
28
+ } | null;
29
+ export declare function fileRef(episodeNumber: number | string, path: string): string;
30
+ /** The episode number a folder carries (`2026-09-27-episode-548-…` → 548). */
31
+ export declare function episodeNumberOf(folder: string): number | null;
32
+ export interface MiadiText {
33
+ episode: string;
34
+ file: string;
35
+ content: string;
36
+ size: number;
37
+ revision?: {
38
+ sha256?: string;
39
+ bytes?: number;
40
+ modifiedAt?: string;
41
+ };
42
+ }
43
+ /**
44
+ * One page of a file Miadi returned whole. `offset` must be 0 or a
45
+ * `next.offset` this function gave, so a page never starts inside a character;
46
+ * `revision`, when given, must still be the file's sha256, so pages of two
47
+ * versions are never joined.
48
+ */
49
+ export declare function pageOf(text: MiadiText, opts?: {
50
+ offset?: number;
51
+ maxBytes?: number;
52
+ revision?: string;
53
+ }): {
54
+ page: {
55
+ offset: number;
56
+ end: number;
57
+ bytes: number;
58
+ sha256: string;
59
+ complete: boolean;
60
+ next: {
61
+ offset: number;
62
+ revision: string;
63
+ } | null;
64
+ };
65
+ text: string;
66
+ modified_at?: string | undefined;
67
+ verification?: string | undefined;
68
+ bytes: number;
69
+ sha256: string;
70
+ verified: boolean;
71
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Reading one text file an episode holds, through Miadi's file door
3
+ * (`GET /api/chronicle/episodes/<ref>/files/<path>`, jgwill/Miadi#653), as the
4
+ * signed-in person. Miadi decides what that person may read: the episode's
5
+ * audience, the room rule (ceremony notes and captures), the vessel's path
6
+ * guard and its 512 KiB text limit. The connector adds two things Miadi cannot
7
+ * see from its side of the URL:
8
+ *
9
+ * - the path is checked before a URL is built, because a URL parser folds
10
+ * `a/../../people` into another route before Miadi ever reads it;
11
+ * - the text is paged by UTF-8 bytes on character boundaries, each page
12
+ * carrying the whole file's sha256, so a reader can join the pages and prove
13
+ * they hold the file exactly (jgwill/Miadi#815).
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ /** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
17
+ export const TEXT_TYPES = {
18
+ ".md": "text/markdown; charset=utf-8",
19
+ ".txt": "text/plain; charset=utf-8",
20
+ ".yaml": "application/yaml; charset=utf-8",
21
+ ".yml": "application/yaml; charset=utf-8",
22
+ ".json": "application/json; charset=utf-8",
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
+ }
37
+ /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
38
+ export const MAX_TEXT_BYTES = 512 * 1024;
39
+ export const DEFAULT_PAGE_BYTES = 100_000;
40
+ /** A page must hold the longest UTF-8 character. */
41
+ export const MIN_PAGE_BYTES = 4;
42
+ export class FileRefusal extends Error {
43
+ code;
44
+ constructor(code, message) {
45
+ super(message);
46
+ this.code = code;
47
+ }
48
+ }
49
+ /** The same rule as the vessel's assertRelativeFile, applied before any URL exists. */
50
+ export function checkRelativePath(value) {
51
+ if (!value || value.includes("\\") || value.includes("\0") || value.startsWith("/") || /^[a-z]+:/i.test(value)) {
52
+ throw new FileRefusal("invalid_path", "Name a file by its path inside the episode, as get_evidence lists it (e.g. foundations/<topic>/README.md).");
53
+ }
54
+ const segments = value.split("/");
55
+ if (segments.some((s) => !s || s === "." || s === ".." || s.startsWith(".") || /%2e|%2f|%5c|%00/i.test(s))) {
56
+ throw new FileRefusal("invalid_path", "The path has an empty, '.', '..', hidden or encoded segment; episode files are read by their listed path only.");
57
+ }
58
+ return segments;
59
+ }
60
+ export function extensionOf(path) {
61
+ const name = path.slice(path.lastIndexOf("/") + 1);
62
+ const dot = name.lastIndexOf(".");
63
+ return dot > 0 ? name.slice(dot).toLowerCase() : "";
64
+ }
65
+ /** `miadi-chronicle://548/foundations/x/README.md` → its episode and path; null for any other id. */
66
+ export function parseFileRef(id) {
67
+ const m = /^miadi-chronicle:(?:\/\/)?([^/\s]+)\/(.+)$/.exec(id.trim());
68
+ return m ? { episode: m[1], path: m[2] } : null;
69
+ }
70
+ export function fileRef(episodeNumber, path) {
71
+ return `miadi-chronicle://${episodeNumber}/${path}`;
72
+ }
73
+ /** The episode number a folder carries (`2026-09-27-episode-548-…` → 548). */
74
+ export function episodeNumberOf(folder) {
75
+ const m = /(?:^|-)episode-0*(\d+)(?:-|$)/.exec(folder);
76
+ return m ? Number.parseInt(m[1], 10) : null;
77
+ }
78
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
79
+ /**
80
+ * One page of a file Miadi returned whole. `offset` must be 0 or a
81
+ * `next.offset` this function gave, so a page never starts inside a character;
82
+ * `revision`, when given, must still be the file's sha256, so pages of two
83
+ * versions are never joined.
84
+ */
85
+ export function pageOf(text, opts = {}) {
86
+ const bytes = Buffer.from(text.content, "utf8");
87
+ const total = bytes.byteLength;
88
+ const whole = sha256(bytes);
89
+ if (opts.revision && opts.revision !== whole) {
90
+ throw new FileRefusal("changed", `The file changed since that page was read (it was ${opts.revision}, it is ${whole} now). Read it again from offset 0.`);
91
+ }
92
+ const offset = opts.offset ?? 0;
93
+ const maxBytes = Math.max(MIN_PAGE_BYTES, opts.maxBytes ?? DEFAULT_PAGE_BYTES);
94
+ if (!Number.isInteger(offset) || offset < 0 || offset > total || (offset < total && (bytes[offset] & 0xc0) === 0x80)) {
95
+ throw new FileRefusal("invalid_offset", `offset must be 0 or a next.offset this tool returned (the file has ${total} bytes).`);
96
+ }
97
+ let end = Math.min(total, offset + maxBytes);
98
+ while (end < total && (bytes[end] & 0xc0) === 0x80)
99
+ end--;
100
+ const page = bytes.subarray(offset, end);
101
+ const complete = end === total;
102
+ // Miadi computed its sha256 over the text it decoded; the bytes on disk are
103
+ // `size`. Both agree only when the file is valid UTF-8 and was read whole.
104
+ const miadiSha = text.revision?.sha256;
105
+ const verified = miadiSha === whole && total === text.size;
106
+ return {
107
+ bytes: total,
108
+ sha256: whole,
109
+ verified,
110
+ ...(verified
111
+ ? {}
112
+ : {
113
+ verification: miadiSha !== whole
114
+ ? `Miadi named sha256 ${miadiSha ?? "none"}; the text received hashes to ${whole}.`
115
+ : `The file is ${text.size} bytes on disk and ${total} bytes as UTF-8 text: it is not valid UTF-8, and the text holds replacement characters.`,
116
+ }),
117
+ ...(text.revision?.modifiedAt ? { modified_at: text.revision.modifiedAt } : {}),
118
+ page: {
119
+ offset,
120
+ end,
121
+ bytes: page.byteLength,
122
+ sha256: sha256(page),
123
+ complete,
124
+ next: complete ? null : { offset: end, revision: whole },
125
+ },
126
+ text: page.toString("utf8"),
127
+ };
128
+ }
package/dist/tools.js CHANGED
@@ -24,6 +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, FOUNDATIONS_PERMISSION, FileRefusal, MAX_TEXT_BYTES, MIN_PAGE_BYTES, TEXT_TYPES, checkRelativePath, episodeNumberOf, extensionOf, fileRef, holdsFoundationRead, isFoundationDoc, pageOf, parseFileRef, } from "./episode-files.js";
27
28
  class ToolRefusal extends Error {
28
29
  code;
29
30
  detail;
@@ -63,6 +64,8 @@ function wrap(fn) {
63
64
  return fail({ error: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) });
64
65
  if (error instanceof UploadRefusal)
65
66
  return fail({ error: "invalid_upload", message: error.message });
67
+ if (error instanceof FileRefusal)
68
+ return fail({ error: error.code, message: error.message });
66
69
  if (error instanceof MiadiError) {
67
70
  const unavailable = error.status === 0;
68
71
  return fail({
@@ -158,7 +161,14 @@ export function registerTools(server, ctx) {
158
161
  annotations: READ,
159
162
  }, wrap(async (_args, extra) => {
160
163
  const { person, auth } = who(extra, "none");
161
- 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);
162
172
  return {
163
173
  service: "@miadi/connector",
164
174
  version: VERSION,
@@ -172,6 +182,15 @@ export function registerTools(server, ctx) {
172
182
  ledger: "connector ledger (append-only, /srv/miadi/connector)",
173
183
  },
174
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
+ },
175
194
  domains: DOMAINS,
176
195
  circles: CIRCLES.map((c) => ({ id: c.id, glyph: c.glyph, name: c.name })),
177
196
  not_built: [
@@ -327,12 +346,145 @@ export function registerTools(server, ctx) {
327
346
  ]);
328
347
  return {
329
348
  episode: decodeURIComponent(ref),
330
- files,
349
+ files: withReadRefs(files),
331
350
  screenwalks,
332
351
  reviews: epReviews,
333
352
  connector_records: touching(`miadi-chronicle://${decodeURIComponent(ref)}`),
334
353
  };
335
354
  }));
355
+ /**
356
+ * One text file of one episode, read as the person through Miadi's file
357
+ * door; Miadi applies their audience, the room rule and the vessel's path
358
+ * guard, and the path is checked here before a URL is built (jgwill/Miadi#815).
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
+ };
380
+ const readEpisodeFile = async (person, a) => {
381
+ const path = a.path.trim();
382
+ const segments = checkRelativePath(path);
383
+ const extension = extensionOf(path);
384
+ const contentType = TEXT_TYPES[extension];
385
+ if (!contentType) {
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.`);
387
+ }
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.`);
396
+ }
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("/")}`;
400
+ let text;
401
+ try {
402
+ text = await miadi.get(person.token, url);
403
+ }
404
+ catch (error) {
405
+ if (!(error instanceof MiadiError))
406
+ throw error;
407
+ const body = (error.body ?? {});
408
+ const detail = { episode: decodeURIComponent(ref), path, miadi: { status: error.status, ...body } };
409
+ if (error.status === 0)
410
+ throw new ToolRefusal("unavailable", "Miadi could not be reached.", detail);
411
+ if (body.error === "not_in_the_room")
412
+ throw new ToolRefusal("forbidden", body.message ?? "This file is not part of the episode room.", detail);
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)
419
+ throw new ToolRefusal("forbidden", body.message ?? "Miadi does not let this person read that file.", detail);
420
+ if (error.status === 404)
421
+ throw new ToolRefusal("not_found", body.message ?? `No file ${path} in ${decodeURIComponent(ref)}.`, detail);
422
+ if (body.error === "TOO_LARGE")
423
+ throw new ToolRefusal("too_large", `${path} is larger than the ${MAX_TEXT_BYTES / 1024} KiB Miadi reads as text whole; nothing of it is returned.`, detail);
424
+ if (error.status === 415)
425
+ throw new ToolRefusal("unsupported", body.message ?? `${path} is not text Miadi reads.`, detail);
426
+ if (error.status === 400)
427
+ throw new ToolRefusal("invalid_path", body.message ?? `${path} is not a readable path in the episode.`, detail);
428
+ throw error;
429
+ }
430
+ if (typeof text?.content !== "string")
431
+ throw new ToolRefusal("unsupported", `Miadi did not return ${path} as text.`);
432
+ const number = episodeNumberOf(text.episode);
433
+ const episodeAddress = number === null ? `chronicle:${text.episode}` : `miadi-chronicle://${number}`;
434
+ return {
435
+ ref: fileRef(number ?? text.episode, text.file ?? path),
436
+ episode: { ref: episodeAddress, number, folder: text.episode },
437
+ path: text.file ?? path,
438
+ content_type: contentType,
439
+ ...pageOf(text, { offset: a.offset, maxBytes: a.max_bytes, revision: a.revision }),
440
+ };
441
+ };
442
+ /** Each text file Miadi lists as previewable carries the ref read_episode_file and fetch take. */
443
+ const withReadRefs = (listing) => {
444
+ const folder = listing?.episode?.path;
445
+ if (!folder || !Array.isArray(listing?.files))
446
+ return listing;
447
+ const number = listing.episode.number ?? episodeNumberOf(folder);
448
+ return {
449
+ ...listing,
450
+ files: listing.files.map((f) => f?.kind === "text" && f.previewable && TEXT_TYPES[extensionOf(String(f.relativePath))]
451
+ ? { ...f, read_ref: fileRef(number ?? folder, f.relativePath) }
452
+ : f),
453
+ connector_read: "A file with read_ref is read whole, in pages if long, by read_episode_file or fetch. Files without one are listed only.",
454
+ };
455
+ };
456
+ server.registerTool("read_episode_file", {
457
+ title: "Read one file an episode holds",
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.",
459
+ inputSchema: {
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."),
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."),
462
+ offset: z.number().int().min(0).optional().describe("0, or page.next.offset from the previous page."),
463
+ revision: z.string().regex(/^[0-9a-f]{64}$/).optional().describe("page.next.revision from the previous page: refuses with changed if the file moved since."),
464
+ max_bytes: z.number().int().min(MIN_PAGE_BYTES).max(MAX_TEXT_BYTES).optional().describe(`Page size in bytes (default ${DEFAULT_PAGE_BYTES}).`),
465
+ },
466
+ annotations: READ,
467
+ }, wrap(async (a, extra) => {
468
+ // No API tier needed: the permission alone admits foundation documents (jgwill/Miadi#905).
469
+ const { person } = who(extra, "none");
470
+ let episode = a.episode;
471
+ let path = a.path;
472
+ if (!path) {
473
+ const parsed = parseFileRef(a.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
+ }
484
+ ({ episode, path } = parsed);
485
+ }
486
+ return readEpisodeFile(person, { ...a, episode, path });
487
+ }));
336
488
  server.registerTool("get_evaluations", {
337
489
  title: "Evaluations: provider traces and recorded assessments",
338
490
  description: "The tracing records (LLMOps provider traces and prompts) an episode holds, and the assessments people recorded with record_evaluation, operational and creator-experience kept apart.",
@@ -563,11 +715,29 @@ export function registerTools(server, ctx) {
563
715
  }));
564
716
  server.registerTool("fetch", {
565
717
  title: "Fetch one item found by search",
566
- description: "The full text of an item returned by search: a review (miadi-review:<uuid>) or a connector record id.",
718
+ description: "The full text of an item returned by search: a review (miadi-review:<uuid>) or a connector record id; or of an episode file by the read_ref get_evidence lists (miadi-chronicle://<n>/<path>), its first page when long.",
567
719
  inputSchema: { id: z.string().min(1) },
568
720
  annotations: READ,
569
721
  }, wrap(async ({ id }, extra) => {
570
- who(extra);
722
+ const { person } = who(extra);
723
+ const file = parseFileRef(id);
724
+ if (file) {
725
+ const read = await readEpisodeFile(person, file);
726
+ return {
727
+ id: read.ref,
728
+ title: `${read.path} (${read.episode.ref})`,
729
+ text: read.text,
730
+ url: `${config.miadiPublicUrl}/chronicle/${encodeURIComponent(read.episode.folder)}`,
731
+ metadata: {
732
+ content_type: read.content_type,
733
+ bytes: read.bytes,
734
+ sha256: read.sha256,
735
+ verified: read.verified,
736
+ complete: read.page.complete,
737
+ ...(read.page.next ? { next: { tool: "read_episode_file", episode: read.ref, ...read.page.next } } : {}),
738
+ },
739
+ };
740
+ }
571
741
  const reviewId = reviewIdOf(id);
572
742
  if (reviewId) {
573
743
  const r = await reviews.get(reviewId);
@@ -1434,6 +1604,7 @@ export const TOOL_STATUS = {
1434
1604
  list_sessions: "live, Miadi write tier",
1435
1605
  get_session: "live, Miadi write tier",
1436
1606
  get_evidence: "live",
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)",
1437
1608
  get_evaluations: "live (tracing records and recorded assessments; no provider scores)",
1438
1609
  get_narrative_beats: "live (NCP project beats and candidate beats)",
1439
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.10";
1
+ export declare const VERSION = "0.1.12";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.10";
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.10",
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",
@@ -26,7 +26,7 @@
26
26
  "README.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@miadi/inquiry-weave": "0.18.5",
29
+ "@miadi/inquiry-weave": "0.18.8",
30
30
  "@miadi/node-service-kit": "0.1.2",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "express": "^5.2.1",