@mikeargento/bitgraph-mcp 0.3.0 → 0.4.1

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/dist/server.js CHANGED
@@ -2,33 +2,51 @@
2
2
  /**
3
3
  * @mikeargento/bitgraph-mcp: tool definitions.
4
4
  *
5
- * Three gestures, the same three the website has: make a BitGraph of a file,
6
- * check whether bytes are on record, fetch a proof. Making a BitGraph builds a
7
- * fused artifact from the file in memory, on this machine, and commits its
8
- * digest under a slot allocated for it; only SHA-256 digests and slot records
9
- * ever leave the machine. File contents are never uploaded.
5
+ * Three gestures, the same three the website has: make a BitGraph, check
6
+ * whether bytes are on record, fetch a proof. Making a BitGraph is one
7
+ * gesture for any number of files, the way a drop on the site is: a single
8
+ * file is fused on its own slot, and two or more become members of ONE set
9
+ * under ONE slot. For a set each file is read once, on this machine, for its
10
+ * digest and a hasher state; the new fused bytes are never written and never
11
+ * held, their digest is finished from that state once the slot exists; and
12
+ * the set's committed artifact is hashed and committed under the same slot.
13
+ * Only digests, that artifact and slot records leave the machine. File
14
+ * contents are never uploaded and files are never modified.
10
15
  */
11
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
17
  import { z } from "zod";
13
18
  import { readFile } from "node:fs/promises";
14
- import { fuse, builderFor, placementForBytes, fusedNamesFor } from "@mikeargento/bitgraph";
15
- import { ApiError, batchCheck, configFromEnv, getProofDetail, search, } from "./api.js";
19
+ import { FuseError, MAX_SET_MEMBERS, builderFor, fuse, fuseSet, fusedNamesFor } from "@mikeargento/bitgraph";
20
+ import { ApiError, batchCheck, configFromEnv, getProofDetail, indexSetMembers, search, } from "./api.js";
16
21
  import { fromUrlSafeB64, looksLikeDigest, mapConcurrent, sha256FileB64, toUrlSafeB64, } from "./encoding.js";
17
22
  import { capJson, positionOf, proofUrl, renderCheckMarkdown, renderProofMarkdown, renderRecordMarkdown, } from "./format.js";
18
- export const SERVER_VERSION = "0.2.0";
19
- const HASH_CONCURRENCY = 4;
20
- const MAX_FILES = 500;
21
- /** Files above this are refused: the fused artifact is built in memory. */
22
- const MAX_FUSE_BYTES = 256 * 1024 * 1024;
23
+ import { expandPaths, fusedDigestFor, scanFile } from "./scan.js";
24
+ export const SERVER_VERSION = "0.4.1";
25
+ const SCAN_CONCURRENCY = 4;
26
+ /** Paths per call; a directory counts once and expands to its files. */
27
+ const MAX_PATHS = 2000;
28
+ /** Files one call may BitGraph after directories expand: one set. The site's own ceiling for a set/2. */
29
+ export const MAX_MEMBERS = 100_000;
30
+ /** Files one check may cover after directories expand. */
31
+ const MAX_CHECK_FILES = 10_000;
32
+ /** A file whose length changed while it was read is fused from its bytes instead; above this it is left out rather than held in memory. */
33
+ const MAX_LOADED_BYTES = 256 * 1024 * 1024;
34
+ /** A single file up to this size is fused on its own, in memory, with its Frame; a larger one is a set of one, never held. */
35
+ const MAX_SOLO_BYTES = 256 * 1024 * 1024;
36
+ /** Rows the structured result lists in full; every fused row shares the set's position. */
37
+ export const ROW_CAP = 500;
38
+ /** Members' evidence per set-index request: the site's own chunk. */
39
+ export const SET_INDEX_CHUNK = 2500;
23
40
  /**
24
- * The default pipeline, the same one the site's drop and the bitgraph-fuse
25
- * command run: choose the placement from the bytes, allocate a slot, derive
26
- * the commitment, build the fused bytes, hash them, commit under that exact
27
- * slot, verify the returned proof against the bytes.
41
+ * A single file, the way a single drop on the site goes: the bytes in hand,
42
+ * the placement chosen from them, one slot, the fused bytes built in memory
43
+ * and hashed, committed under that exact slot, verified against the bytes,
44
+ * and a Frame returned. The fused bytes are not kept.
28
45
  */
29
- async function fuseFileDefault(bytes, name, config) {
30
- const placement = placementForBytes(bytes);
31
- const { fusedName } = fusedNamesFor(name, placement);
46
+ async function fuseFileDefault(file, config) {
47
+ const bytes = new Uint8Array(await readFile(file.path));
48
+ const placement = file.placement;
49
+ const { fusedName } = fusedNamesFor(file.name, placement);
32
50
  const r = await fuse(builderFor(placement, bytes), {
33
51
  placement,
34
52
  original: bytes,
@@ -41,30 +59,62 @@ async function fuseFileDefault(bytes, name, config) {
41
59
  frame: r.frame,
42
60
  placement,
43
61
  artifactDigestB64: r.artifactDigestB64,
44
- originDigestB64: r.originDigestB64 ?? "",
62
+ originDigestB64: r.originDigestB64 ?? file.digestB64,
45
63
  };
46
64
  }
47
- /** Read the given paths whole (bounded concurrency). Throws before any network call. */
48
- async function readPaths(paths) {
65
+ /**
66
+ * The default pipeline, the one the site's drop runs: one slot for the set,
67
+ * every member a hashed member whose fused digest is finished from the
68
+ * scan's open hasher with its placement's suffix for that slot, the set's
69
+ * manifest (or, for a set/2, its root document) committed under the same
70
+ * slot, and the returned proof verified against the committed artifact with
71
+ * every member bound to it by digest. A file whose length changed during the
72
+ * scan is a loaded member: read again when it is its turn, checked against
73
+ * the scan's digest, fused in memory, hashed and released.
74
+ */
75
+ async function fuseSetDefault(files, config, opts) {
76
+ const members = files.map((f) => f.state !== null
77
+ ? { originDigest: f.originDigest, placement: f.placement, name: f.name, fusedDigest: ({ commitment }) => fusedDigestFor(f, commitment) }
78
+ : { load: async () => new Uint8Array(await readFile(f.path)), originDigest: f.originDigest, placement: f.placement, name: f.name });
79
+ const r = await fuseSet(members, {
80
+ set: opts.set,
81
+ keepFused: false,
82
+ ...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
83
+ transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
84
+ });
85
+ return {
86
+ set: r.set,
87
+ proof: r.proof,
88
+ artifactDigestB64: r.artifactDigestB64,
89
+ count: r.members.length,
90
+ manifestEchoed: r.manifestEchoed,
91
+ recovered: r.recovered,
92
+ members: r.members.map((m) => ({
93
+ index: m.index,
94
+ manifestIndex: m.manifestIndex,
95
+ placement: m.placement,
96
+ originDigestB64: m.originDigestB64,
97
+ artifactDigestB64: m.artifactDigestB64,
98
+ ...(m.memberProof !== undefined ? { memberProof: m.memberProof } : {}),
99
+ })),
100
+ };
101
+ }
102
+ /** Hash the given paths (bounded concurrency). Throws before any network call. */
103
+ async function hashPaths(paths) {
49
104
  const failures = [];
50
- const out = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
105
+ const digests = await mapConcurrent(paths, SCAN_CONCURRENCY, async (p) => {
51
106
  try {
52
- const buf = await readFile(p);
53
- if (buf.length > MAX_FUSE_BYTES)
54
- throw new Error(`larger than ${MAX_FUSE_BYTES / (1024 * 1024)} MB; the fused artifact is built in memory`);
55
- const bytes = new Uint8Array(buf);
56
- const digestB64 = await sha256FileB64(p);
57
- return { bytes, digestB64 };
107
+ return await sha256FileB64(p);
58
108
  }
59
109
  catch (err) {
60
110
  failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
61
- return { bytes: new Uint8Array(0), digestB64: "" };
111
+ return "";
62
112
  }
63
113
  });
64
114
  if (failures.length > 0) {
65
- throw new Error(`Could not read ${failures.length} file(s); nothing was BitGraphed.\n${failures.join("\n")}\nUse absolute paths to existing regular files under ${MAX_FUSE_BYTES / (1024 * 1024)} MB.`);
115
+ throw new Error(`Could not read ${failures.length} file(s); nothing was checked.\n${failures.join("\n")}\nUse absolute paths to existing regular files.`);
66
116
  }
67
- return out;
117
+ return digests;
68
118
  }
69
119
  const responseFormatSchema = z
70
120
  .enum(["markdown", "json"])
@@ -86,48 +136,107 @@ function errorText(err) {
86
136
  }
87
137
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
88
138
  }
89
- /** Hash the given paths (bounded concurrency). Throws before any network call. */
90
- async function hashPaths(paths) {
91
- const failures = [];
92
- const digests = await mapConcurrent(paths, HASH_CONCURRENCY, async (p) => {
93
- try {
94
- return await sha256FileB64(p);
139
+ /** Why the set was not made, and what to do next. Never a success-looking line. */
140
+ function setFailureText(err) {
141
+ if (err instanceof FuseError) {
142
+ const where = err.member !== null ? ` (member ${err.member})` : "";
143
+ switch (err.code) {
144
+ case "tee-restarting":
145
+ return `Nothing was BitGraphed: ${err.message}. The boundary restarts once a day at 23:59 UTC; run bitgraph_record again with the same paths in a minute.`;
146
+ case "network":
147
+ case "transport":
148
+ return `Nothing is known to be BitGraphed: ${err.message}. Run bitgraph_record again with the same paths: files a set did land come back as on record and are not made again.`;
149
+ default:
150
+ return `Nothing was BitGraphed${where}: ${err.message} (${err.code}). Run bitgraph_record again with the same paths.`;
95
151
  }
96
- catch (err) {
97
- failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
98
- return "";
152
+ }
153
+ return `Nothing was BitGraphed: ${err instanceof Error ? err.message : String(err)}. Run bitgraph_record again with the same paths.`;
154
+ }
155
+ /** Progress notifications, when the client asked for them with a progress token; a no-op otherwise. */
156
+ function progressReporter(extra) {
157
+ const token = extra._meta?.progressToken;
158
+ if (token === undefined)
159
+ return () => { };
160
+ return (progress, total, message) => {
161
+ void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress, total, message } }).catch(() => { });
162
+ };
163
+ }
164
+ const PHASES = {
165
+ hash: "checking members",
166
+ fuse: "fusing",
167
+ tree: "building the tree",
168
+ commit: "committing",
169
+ verify: "verifying",
170
+ };
171
+ const pendingIndex = [];
172
+ /** How many members' evidence is waiting to be indexed (tests read it). */
173
+ export function pendingIndexCount() {
174
+ return pendingIndex.reduce((n, p) => n + p.members.length, 0);
175
+ }
176
+ /** Send pending evidence in chunks; what fails stays pending. */
177
+ async function flushIndex(config, report) {
178
+ const total = pendingIndexCount();
179
+ let written = 0;
180
+ let stopped = false;
181
+ for (const set of pendingIndex) {
182
+ while (set.members.length > 0 && !stopped) {
183
+ const chunk = set.members.slice(0, SET_INDEX_CHUNK);
184
+ let ok = false;
185
+ for (let attempt = 0; attempt < 2 && !ok; attempt++) {
186
+ try {
187
+ await indexSetMembers(config, { setDigest: set.setDigest, epoch: set.epoch, counter: set.counter, members: chunk });
188
+ ok = true;
189
+ }
190
+ catch {
191
+ ok = false;
192
+ }
193
+ }
194
+ if (!ok) {
195
+ stopped = true;
196
+ break;
197
+ }
198
+ set.members.splice(0, chunk.length);
199
+ written += chunk.length;
200
+ report(written, total, `indexing ${written} of ${total}`);
99
201
  }
100
- });
101
- if (failures.length > 0) {
102
- throw new Error(`Could not read ${failures.length} file(s); nothing was recorded.\n${failures.join("\n")}\nUse absolute paths to existing regular files.`);
202
+ if (stopped)
203
+ break;
103
204
  }
104
- return digests;
205
+ const left = pendingIndex.filter((s) => s.members.length > 0);
206
+ pendingIndex.length = 0;
207
+ pendingIndex.push(...left);
208
+ return { written, pending: total - written };
105
209
  }
106
210
  export function buildServer(deps = {}) {
107
- const fuseFile = deps.fuseFile ?? fuseFileDefault;
211
+ const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
212
+ const fuseFilePipeline = deps.fuseFile ?? fuseFileDefault;
108
213
  const server = new McpServer({
109
214
  name: "bitgraph-mcp-server",
110
215
  version: SERVER_VERSION,
216
+ }, {
217
+ instructions: "BitGraph gives a file's bytes a causal position in a public ledger bracketed by Ethereum anchors. bitgraph_record makes ONE BitGraph of everything in a call, files and folders alike: a single file is fused on its own; two or more become one set under one slot, one position, every file's new fused bytes listed by digest in the committed artifact. " +
218
+ "Files are read on this machine and never uploaded or modified; the new bytes are virtual and never written. Recordings are permanent: only make BitGraphs of files the user asked for, and never generate content just to record it. bitgraph_check and bitgraph_get_proof are read-only.",
111
219
  });
112
220
  server.registerTool("bitgraph_record", {
113
221
  title: "Make a BitGraph",
114
- description: "Make a BitGraph of one or more files. For each file, on this machine: hash it (the origin), allocate an unused slot in the BitGraph ledger (bitgraph.ing) before any artifact exists, build a new fused artifact from the file in memory with a registered placement (a 48-byte trailer for formats that ignore trailing bytes such as JPEG, PNG, TIFF, WebP; a small tar container otherwise), hash it, and commit that digest under the same slot. " +
115
- "The file is never modified and never uploaded; only digests and slot records leave the machine. The fused bytes are not kept: the original plus the proof rebuilds them, and the Frame for each file is returned in the structured result. " +
116
- "Files whose bytes are already on record, as a recording or as the origin of a fused artifact, are NOT BitGraphed again by default; they come back as 'on record' with their earliest position. " +
117
- "Pass again=true to deliberately make a new fused artifact from a file already on record. " +
118
- "BitGraphs are permanent: the ledger has 10-year retention and no deletes, so only BitGraph files the user asked to. " +
119
- "Returns one outcome per file: 'fused' (a new fused artifact, with its placement, position and proof page URL), 'on record', or 'not fused' (with the error). " +
120
- "Use bitgraph_check instead when the user only wants to know whether a file is on record.",
222
+ description: "Make a BitGraph of files or folders. Everything in one call becomes ONE BitGraph, the way a drop on the site works: a single file is fused on its own slot; two or more files become a set under a single slot in the BitGraph ledger (bitgraph.ing), one position for all of them. " +
223
+ "On this machine each file is read once for its SHA-256 (the origin) and a hasher state; an unused slot is allocated before any new file exists; every file's new fused bytes (the original plus a registered placement carrying the slot's commitment: a 48-byte trailer for JPEG, PNG, GIF, TIFF and TIFF-based raws, BMP, WebP, WAV and AVI, a small tar container with the original first for everything else) are hashed from that state without being written or held; and for a set the canonical list of those digests (above 2,000 files, a Merkle root over it) is committed under the same slot. " +
224
+ "Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
225
+ "Give file paths, directory paths, or both (absolute paths preferred): a directory is every regular file under it, recursively, with hidden entries and symbolic links left out. " +
226
+ "Files already on record (recorded, or the origin of a fused file) are NOT made again by default; they come back as 'on record' with their earliest position. Pass again=true to make a new BitGraph of them deliberately. " +
227
+ "BitGraphs are permanent: the ledger has 10-year retention and no deletes, so only BitGraph files the user asked to, and never generate content just to record it. " +
228
+ "Returns one outcome per file: 'fused' (for a set, its row, one of N, and the set's position and proof page; for a single file, its own position and Frame), 'on record', or 'not fused' (with the reason). A lookup by any file's own digest finds its BitGraph. " +
229
+ "Use bitgraph_check instead when the user only wants to know whether files are on record.",
121
230
  inputSchema: {
122
231
  paths: z
123
232
  .array(z.string().min(1))
124
233
  .min(1)
125
- .max(MAX_FILES)
126
- .describe(`File paths to BitGraph (absolute paths preferred), up to ${MAX_FILES}.`),
234
+ .max(MAX_PATHS)
235
+ .describe(`File or directory paths to BitGraph, up to ${MAX_PATHS}; a directory expands to its files, up to ${MAX_MEMBERS} in all.`),
127
236
  again: z
128
237
  .boolean()
129
238
  .default(false)
130
- .describe("false (default): files already on record are returned as-is, nothing minted. true: make a new fused artifact from every file even if its bytes are already on record. Outcomes are per unique file content: two paths with identical bytes yield one artifact."),
239
+ .describe("false (default): files already on record are returned as-is, nothing made. true: put every file in the set even if its bytes are already on record. Outcomes are per unique file content: two paths with identical bytes are one member."),
131
240
  response_format: responseFormatSchema,
132
241
  },
133
242
  annotations: {
@@ -136,18 +245,42 @@ export function buildServer(deps = {}) {
136
245
  idempotentHint: false,
137
246
  openWorldHint: true,
138
247
  },
139
- }, async ({ paths, again, response_format }) => {
248
+ }, async ({ paths, again, response_format }, extra) => {
140
249
  const config = configFromEnv();
250
+ const report = progressReporter(extra);
141
251
  try {
142
- const read = await readPaths(paths);
143
- // Unique by content, first path wins for the artifact's name; extra paths listed too.
144
- const byDigest = new Map();
145
- read.forEach((r, i) => {
146
- const entry = byDigest.get(r.digestB64) ?? { bytes: r.bytes, paths: [] };
147
- entry.paths.push(paths[i]);
148
- byDigest.set(r.digestB64, entry);
252
+ // 0. Paths to files, before any network call.
253
+ const expanded = await expandPaths(paths, MAX_MEMBERS);
254
+ const files = expanded.files;
255
+ if (files.length === 0) {
256
+ return fail("Error: nothing to BitGraph: the given directories hold no regular files (hidden entries and symbolic links are left out).");
257
+ }
258
+ // 1. Evidence from an earlier set that the site has not indexed yet goes first.
259
+ if (pendingIndexCount() > 0) {
260
+ const flushed = await flushIndex(config, report);
261
+ if (flushed.pending > 0) {
262
+ return fail(`Error: ${flushed.pending} members of an earlier set are still waiting to be indexed and the site could not take them; nothing was BitGraphed. Run bitgraph_record again in a moment: the waiting evidence is sent first.`);
263
+ }
264
+ }
265
+ // 2. The scan: one pass per file.
266
+ let scanned = 0;
267
+ report(0, files.length, `hashing ${files.length} files`);
268
+ const scans = await mapConcurrent(files, SCAN_CONCURRENCY, async (p) => {
269
+ const s = await scanFile(p);
270
+ scanned += 1;
271
+ report(scanned, files.length, `hashed ${scanned} of ${files.length}`);
272
+ return s;
149
273
  });
274
+ // 3. Unique by content; the first path names the member, every path is reported.
275
+ const byDigest = new Map();
276
+ for (const s of scans) {
277
+ const entry = byDigest.get(s.digestB64) ?? { file: s, paths: [] };
278
+ entry.paths.push(s.path);
279
+ byDigest.set(s.digestB64, entry);
280
+ }
150
281
  const unique = [...byDigest.keys()];
282
+ // 4. What is on record already.
283
+ report(0, 1, "checking the ledger");
151
284
  const checked = await batchCheck(config, unique.map(toUrlSafeB64));
152
285
  const existing = new Map();
153
286
  for (const d of unique) {
@@ -155,92 +288,178 @@ export function buildServer(deps = {}) {
155
288
  if (entry && entry.proofs.length > 0)
156
289
  existing.set(d, entry.proofs);
157
290
  }
158
- const toMint = again ? unique : unique.filter((d) => !existing.has(d));
159
- const fused = new Map();
160
- const failed = new Map();
161
- for (const d of toMint) {
162
- const entry = byDigest.get(d);
163
- const name = entry.paths[0].split(/[\\/]/).pop() ?? "file";
291
+ // 5. The set: every fresh file (every file, with again), one call.
292
+ const excluded = new Map();
293
+ const toMint = [];
294
+ for (const d of again ? unique : unique.filter((x) => !existing.has(x))) {
295
+ const f = byDigest.get(d).file;
296
+ if (f.state === null && f.size > MAX_LOADED_BYTES) {
297
+ excluded.set(d, "the file changed while it was read and is too large to read again in memory; run bitgraph_record again for it");
298
+ continue;
299
+ }
300
+ toMint.push(f);
301
+ }
302
+ const attempted = new Set(toMint.map((f) => f.digestB64));
303
+ let made = null;
304
+ let solo = null;
305
+ let failure = null;
306
+ const one = toMint.length === 1 ? toMint[0] : null;
307
+ if (one !== null && one.size <= MAX_SOLO_BYTES) {
308
+ // One file, as a single drop on the site goes: its own slot, its own Frame.
309
+ report(0, 1, "fusing");
164
310
  try {
165
- fused.set(d, await fuseFile(entry.bytes, name, config));
311
+ solo = { ...(await fuseFilePipeline(one, config)), file: one };
166
312
  }
167
313
  catch (err) {
168
- failed.set(d, err instanceof Error ? err.message : String(err));
314
+ failure = setFailureText(err);
169
315
  }
170
316
  }
171
- const outcomes = [];
317
+ else if (toMint.length > 0) {
318
+ const kind = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
319
+ try {
320
+ made = await fuseSetPipeline(toMint, config, {
321
+ set: kind,
322
+ onProgress: (p) => report(p.done, p.total, `${PHASES[p.phase]} ${p.done} of ${p.total}`),
323
+ });
324
+ }
325
+ catch (err) {
326
+ failure = setFailureText(err);
327
+ }
328
+ }
329
+ // 6. A set/2 lands with only its root on the ledger; its members are indexed afterwards.
330
+ let index = null;
331
+ if (made !== null && made.set === "set/2") {
332
+ const counter = made.proof.commit?.counter;
333
+ const epochId = made.proof.commit?.epochId;
334
+ const members = made.members.map((m) => m.memberProof).filter((e) => e !== undefined);
335
+ if (counter !== undefined && epochId !== undefined && members.length > 0) {
336
+ pendingIndex.push({ setDigest: toUrlSafeB64(made.artifactDigestB64), epoch: toUrlSafeB64(epochId), counter, members });
337
+ index = await flushIndex(config, report);
338
+ }
339
+ }
340
+ // 7. Outcomes.
341
+ let setOutcome = null;
342
+ const memberOf = new Map();
343
+ if (made !== null) {
344
+ for (const m of made.members)
345
+ memberOf.set(m.originDigestB64, m);
346
+ const { counter, epoch } = positionOf(made.proof);
347
+ setOutcome = {
348
+ set: made.set,
349
+ count: made.count,
350
+ counter,
351
+ epoch,
352
+ artifact_digest: toUrlSafeB64(made.artifactDigestB64),
353
+ proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
354
+ manifest_echoed: made.manifestEchoed,
355
+ recovered: made.recovered,
356
+ index,
357
+ };
358
+ }
172
359
  const frames = {};
360
+ if (solo !== null)
361
+ frames[toUrlSafeB64(solo.artifactDigestB64)] = solo.frame;
362
+ const outcomes = [];
173
363
  for (const [digest, entry] of byDigest) {
174
- const made = fused.get(digest);
364
+ const m = memberOf.get(digest);
175
365
  const prior = existing.get(digest);
176
366
  for (const path of entry.paths) {
177
- if (made) {
178
- const { counter, epoch } = positionOf(made.proof);
179
- frames[toUrlSafeB64(made.artifactDigestB64)] = made.frame;
367
+ const base = { path, digest: toUrlSafeB64(digest) };
368
+ if (solo !== null && solo.file.digestB64 === digest) {
369
+ const { counter, epoch } = positionOf(solo.proof);
180
370
  outcomes.push({
181
- path,
182
- digest: toUrlSafeB64(digest),
371
+ ...base,
183
372
  outcome: "fused",
184
- artifact_digest: toUrlSafeB64(made.artifactDigestB64),
185
- placement: made.placement,
373
+ artifact_digest: toUrlSafeB64(solo.artifactDigestB64),
374
+ placement: solo.placement,
186
375
  counter,
187
376
  epoch,
377
+ member: null,
378
+ member_count: null,
379
+ total_positions: (prior?.length ?? 0) + 1,
380
+ proof_url: proofUrl(config.baseUrl, solo.artifactDigestB64, counter ?? undefined, solo.proof.commit?.epochId),
381
+ });
382
+ }
383
+ else if (m !== undefined && made !== null && setOutcome !== null) {
384
+ outcomes.push({
385
+ ...base,
386
+ outcome: "fused",
387
+ artifact_digest: toUrlSafeB64(m.artifactDigestB64),
388
+ placement: m.placement,
389
+ counter: setOutcome.counter,
390
+ epoch: setOutcome.epoch,
391
+ member: m.manifestIndex + 1,
392
+ member_count: made.count,
188
393
  total_positions: (prior?.length ?? 0) + 1,
189
- proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
394
+ proof_url: proofUrl(config.baseUrl, digest, setOutcome.counter ?? undefined, made.proof.commit?.epochId),
190
395
  });
191
396
  }
192
- else if (prior && !failed.has(digest)) {
193
- const first = prior[0]?.proof;
194
- const { counter, epoch } = first ? positionOf(first) : { counter: null, epoch: null };
397
+ else if (prior && !attempted.has(digest) && !excluded.has(digest)) {
398
+ const first = prior[0];
399
+ const { counter, epoch } = first ? positionOf(first.proof) : { counter: null, epoch: null };
400
+ const row = first?.member;
195
401
  outcomes.push({
196
- path,
197
- digest: toUrlSafeB64(digest),
402
+ ...base,
198
403
  outcome: "on record",
199
404
  artifact_digest: null,
200
405
  placement: null,
201
406
  counter,
202
407
  epoch,
408
+ member: row ? row.index + 1 : null,
409
+ member_count: row ? row.count : null,
203
410
  total_positions: prior.length,
204
411
  proof_url: proofUrl(config.baseUrl, digest),
205
412
  });
206
413
  }
207
414
  else {
208
415
  outcomes.push({
209
- path,
210
- digest: toUrlSafeB64(digest),
416
+ ...base,
211
417
  outcome: "not fused",
212
418
  artifact_digest: null,
213
419
  placement: null,
214
420
  counter: null,
215
421
  epoch: null,
422
+ member: null,
423
+ member_count: null,
216
424
  total_positions: prior?.length ?? 0,
217
425
  proof_url: null,
218
- error: failed.get(digest) ?? "not attempted",
426
+ error: excluded.get(digest) ?? failure ?? "not attempted",
219
427
  });
220
428
  }
221
429
  }
222
430
  }
431
+ // Rows that need reading come first, so a cap drops fused rows, which all share one position.
432
+ const order = { "not fused": 0, "on record": 1, fused: 2 };
433
+ outcomes.sort((a, b) => order[a.outcome] - order[b.outcome]);
434
+ const listed = outcomes.slice(0, ROW_CAP);
435
+ const omitted = outcomes.length - listed.length;
436
+ const summary = {
437
+ files: files.length,
438
+ directories: expanded.directories,
439
+ fused: outcomes.filter((o) => o.outcome === "fused").length,
440
+ on_record: outcomes.filter((o) => o.outcome === "on record").length,
441
+ not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
442
+ };
223
443
  const structured = {
224
- results: outcomes,
444
+ set: setOutcome,
445
+ results: listed,
225
446
  frames,
226
- summary: {
227
- fused: outcomes.filter((o) => o.outcome === "fused").length,
228
- on_record: outcomes.filter((o) => o.outcome === "on record").length,
229
- not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
230
- },
447
+ omitted,
448
+ summary,
231
449
  };
232
- if (failed.size > 0) {
233
- const guidance = again
234
- ? "A file marked 'not fused' MAY still have been BitGraphed if the failure was a timeout: run bitgraph_check on it first, then re-run bitgraph_record with again=true for only the files still missing."
235
- : "Re-run bitgraph_record with the same paths: files already on record come back as 'on record' and only the missing ones are BitGraphed.";
450
+ const markdown = renderRecordMarkdown(outcomes, setOutcome, omitted);
451
+ if (summary.not_fused > 0) {
236
452
  return {
237
453
  isError: true,
238
- content: [{ type: "text", text: `${fused.size} of ${toMint.length} files were BitGraphed; ${failed.size} failed. ${guidance}\n\n${renderRecordMarkdown(outcomes)}` }],
454
+ content: [{ type: "text", text: `${failure ?? `${summary.not_fused} file(s) were left out.`}\n\n${markdown}` }],
239
455
  structuredContent: structured,
240
456
  };
241
457
  }
242
- const text = response_format === "json" ? capJson(structured).text : renderRecordMarkdown(outcomes);
243
- return ok(text, structured);
458
+ if (response_format === "json") {
459
+ const full = { ...structured, set: setOutcome !== null && made !== null ? { ...setOutcome, proof: made.proof } : null };
460
+ return ok(capJson(full).text, structured);
461
+ }
462
+ return ok(markdown, structured);
244
463
  }
245
464
  catch (err) {
246
465
  return fail(errorText(err));
@@ -249,18 +468,18 @@ export function buildServer(deps = {}) {
249
468
  server.registerTool("bitgraph_check", {
250
469
  title: "Check for BitGraphs",
251
470
  description: "Check whether files or digests are on record in the BitGraph ledger, without recording anything. " +
252
- "Accepts file paths (hashed locally; only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. " +
253
- "Returns, per item: on_record (the bytes are on record, as an exact recording or as the original a new file was made from), every position by counter, and the proof page URL. " +
471
+ `Accepts file paths and directory paths (every regular file under them, up to ${MAX_CHECK_FILES} in all; hashed locally, only digests are sent) and/or raw SHA-256 digests in standard or URL-safe base64. ` +
472
+ "Returns, per item: on_record (the bytes are on record, as an exact recording, as the original a new file was made from, or as a member of a set), every position by counter, and the proof page URL. " +
254
473
  "Read-only. Use bitgraph_record to BitGraph files that turn out not to be on record.",
255
474
  inputSchema: {
256
475
  paths: z
257
476
  .array(z.string().min(1))
258
- .max(MAX_FILES)
477
+ .max(MAX_PATHS)
259
478
  .optional()
260
- .describe("File paths to check."),
479
+ .describe("File or directory paths to check."),
261
480
  digests: z
262
481
  .array(z.string().min(1).max(100))
263
- .max(MAX_FILES)
482
+ .max(MAX_CHECK_FILES)
264
483
  .optional()
265
484
  .describe("SHA-256 digests, base64 (standard or URL-safe form)."),
266
485
  response_format: responseFormatSchema,
@@ -276,8 +495,9 @@ export function buildServer(deps = {}) {
276
495
  try {
277
496
  const inputs = [];
278
497
  if (paths && paths.length > 0) {
279
- const hashed = await hashPaths(paths);
280
- hashed.forEach((d, i) => inputs.push({ label: paths[i], standardDigest: d }));
498
+ const { files } = await expandPaths(paths, MAX_CHECK_FILES);
499
+ const hashed = await hashPaths(files);
500
+ hashed.forEach((d, i) => inputs.push({ label: files[i], standardDigest: d }));
281
501
  }
282
502
  for (const d of digests ?? []) {
283
503
  const trimmed = d.trim();
@@ -289,11 +509,14 @@ export function buildServer(deps = {}) {
289
509
  if (inputs.length === 0) {
290
510
  return fail("Error: provide at least one of paths or digests.");
291
511
  }
512
+ if (inputs.length > MAX_CHECK_FILES) {
513
+ return fail(`Error: ${inputs.length} items; check at most ${MAX_CHECK_FILES} at a time.`);
514
+ }
292
515
  const checked = await batchCheck(config, inputs.map((i) => toUrlSafeB64(i.standardDigest)));
293
516
  const outcomes = inputs.map((input) => {
294
517
  const entry = checked.results[toUrlSafeB64(input.standardDigest)];
295
518
  const proofs = entry?.proofs ?? [];
296
- const positions = proofs.map((p) => positionOf(p.proof));
519
+ const positions = proofs.map((p) => ({ ...positionOf(p.proof), ...(p.member ? { member: p.member } : {}) }));
297
520
  return {
298
521
  input: input.label,
299
522
  digest: toUrlSafeB64(input.standardDigest),
@@ -320,7 +543,7 @@ export function buildServer(deps = {}) {
320
543
  });
321
544
  server.registerTool("bitgraph_get_proof", {
322
545
  title: "Get a BitGraph proof",
323
- description: "Fetch a BitGraph proof and its context: causal position, every position the same bytes occupy, and the two-sided Ethereum anchor window " +
546
+ description: "Fetch a BitGraph proof and its context: causal position, every position the same bytes occupy, the row a set member holds (one of N), and the two-sided Ethereum anchor window " +
324
547
  "('BitGraphed between X and Y'). Look up by digest (base64, either form), by BitGraph number (e.g. '4523' or '#4,523', current epoch), or by file path (hashed locally). " +
325
548
  "Exactly one of digest, number, or path is required. Read-only. " +
326
549
  "markdown returns a summary; json returns the full proof object with positions and anchor window.",
@@ -387,7 +610,7 @@ export function buildServer(deps = {}) {
387
610
  const selEpoch = epoch !== undefined ? toUrlSafeB64(fromUrlSafeB64(epoch)) : undefined;
388
611
  const detail = await getProofDetail(config, urlSafeDigest, selCounter, selEpoch);
389
612
  if (detail.proofs.length === 0) {
390
- return fail(`Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to record the file.`);
613
+ return fail(`Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to make a BitGraph of the file.`);
391
614
  }
392
615
  if (response_format === "json") {
393
616
  const capped = capJson(detail);