@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 +3 -1
- package/dist/episode-files.d.ts +71 -0
- package/dist/episode-files.js +128 -0
- package/dist/tools.js +175 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.1.12";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
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.
|
|
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.
|
|
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",
|