@mikeargento/bitgraph-mcp 0.2.0 → 0.4.0
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 +8 -8
- package/dist/__tests__/format.test.js +24 -3
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/scan.test.d.ts +2 -0
- package/dist/__tests__/scan.test.d.ts.map +1 -0
- package/dist/__tests__/scan.test.js +73 -0
- package/dist/__tests__/scan.test.js.map +1 -0
- package/dist/__tests__/tools.test.js +265 -83
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/api.d.ts +18 -30
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +27 -50
- package/dist/api.js.map +1 -1
- package/dist/format.d.ts +36 -9
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +56 -26
- package/dist/format.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/scan.d.ts +56 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +158 -0
- package/dist/scan.js.map +1 -0
- package/dist/server.d.ts +45 -14
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +289 -125
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/format.test.ts +25 -3
- package/src/__tests__/scan.test.ts +80 -0
- package/src/__tests__/tools.test.ts +280 -96
- package/src/api.ts +36 -77
- package/src/format.ts +96 -36
- package/src/scan.ts +173 -0
- package/src/server.ts +358 -137
- package/src/types.ts +23 -1
package/dist/server.js
CHANGED
|
@@ -2,69 +2,92 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @mikeargento/bitgraph-mcp: tool definitions.
|
|
4
4
|
*
|
|
5
|
-
* Three gestures, the same three the website has:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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: everything in a call becomes a member of
|
|
8
|
+
* ONE set under ONE slot, the way a drop on the site does. Each file is read
|
|
9
|
+
* once, on this machine, for its digest and a hasher state; the new fused
|
|
10
|
+
* bytes are never written and never held, their digest is finished from that
|
|
11
|
+
* state once the slot exists; and the set's committed artifact is hashed
|
|
12
|
+
* and committed under the same slot. Only digests, that artifact and slot
|
|
13
|
+
* records leave the machine. File contents are never uploaded and files are
|
|
14
|
+
* 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 {
|
|
15
|
-
import { ApiError, batchCheck, configFromEnv, getProofDetail, search, } from "./api.js";
|
|
19
|
+
import { FuseError, MAX_SET_MEMBERS, fuseSet } 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
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
/**
|
|
22
|
-
const
|
|
23
|
+
import { expandPaths, fusedDigestFor, scanFile } from "./scan.js";
|
|
24
|
+
export const SERVER_VERSION = "0.4.0";
|
|
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
|
+
/** Rows the structured result lists in full; every fused row shares the set's position. */
|
|
35
|
+
export const ROW_CAP = 500;
|
|
36
|
+
/** Members' evidence per set-index request: the site's own chunk. */
|
|
37
|
+
export const SET_INDEX_CHUNK = 2500;
|
|
23
38
|
/**
|
|
24
|
-
* The default pipeline, the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
39
|
+
* The default pipeline, the one the site's drop runs: one slot for the set,
|
|
40
|
+
* every member a hashed member whose fused digest is finished from the
|
|
41
|
+
* scan's open hasher with its placement's suffix for that slot, the set's
|
|
42
|
+
* manifest (or, for a set/2, its root document) committed under the same
|
|
43
|
+
* slot, and the returned proof verified against the committed artifact with
|
|
44
|
+
* every member bound to it by digest. A file whose length changed during the
|
|
45
|
+
* scan is a loaded member: read again when it is its turn, checked against
|
|
46
|
+
* the scan's digest, fused in memory, hashed and released.
|
|
28
47
|
*/
|
|
29
|
-
async function
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
fusedFile: fusedName,
|
|
48
|
+
async function fuseSetDefault(files, config, opts) {
|
|
49
|
+
const members = files.map((f) => f.state !== null
|
|
50
|
+
? { originDigest: f.originDigest, placement: f.placement, name: f.name, fusedDigest: ({ commitment }) => fusedDigestFor(f, commitment) }
|
|
51
|
+
: { load: async () => new Uint8Array(await readFile(f.path)), originDigest: f.originDigest, placement: f.placement, name: f.name });
|
|
52
|
+
const r = await fuseSet(members, {
|
|
53
|
+
set: opts.set,
|
|
36
54
|
keepFused: false,
|
|
55
|
+
...(opts.onProgress !== undefined ? { onProgress: opts.onProgress } : {}),
|
|
37
56
|
transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
|
|
38
57
|
});
|
|
39
58
|
return {
|
|
59
|
+
set: r.set,
|
|
40
60
|
proof: r.proof,
|
|
41
|
-
frame: r.frame,
|
|
42
|
-
placement,
|
|
43
61
|
artifactDigestB64: r.artifactDigestB64,
|
|
44
|
-
|
|
62
|
+
count: r.members.length,
|
|
63
|
+
manifestEchoed: r.manifestEchoed,
|
|
64
|
+
recovered: r.recovered,
|
|
65
|
+
members: r.members.map((m) => ({
|
|
66
|
+
index: m.index,
|
|
67
|
+
manifestIndex: m.manifestIndex,
|
|
68
|
+
placement: m.placement,
|
|
69
|
+
originDigestB64: m.originDigestB64,
|
|
70
|
+
artifactDigestB64: m.artifactDigestB64,
|
|
71
|
+
...(m.memberProof !== undefined ? { memberProof: m.memberProof } : {}),
|
|
72
|
+
})),
|
|
45
73
|
};
|
|
46
74
|
}
|
|
47
|
-
/**
|
|
48
|
-
async function
|
|
75
|
+
/** Hash the given paths (bounded concurrency). Throws before any network call. */
|
|
76
|
+
async function hashPaths(paths) {
|
|
49
77
|
const failures = [];
|
|
50
|
-
const
|
|
78
|
+
const digests = await mapConcurrent(paths, SCAN_CONCURRENCY, async (p) => {
|
|
51
79
|
try {
|
|
52
|
-
|
|
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 };
|
|
80
|
+
return await sha256FileB64(p);
|
|
58
81
|
}
|
|
59
82
|
catch (err) {
|
|
60
83
|
failures.push(`${p}: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
-
return
|
|
84
|
+
return "";
|
|
62
85
|
}
|
|
63
86
|
});
|
|
64
87
|
if (failures.length > 0) {
|
|
65
|
-
throw new Error(`Could not read ${failures.length} file(s); nothing was
|
|
88
|
+
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
89
|
}
|
|
67
|
-
return
|
|
90
|
+
return digests;
|
|
68
91
|
}
|
|
69
92
|
const responseFormatSchema = z
|
|
70
93
|
.enum(["markdown", "json"])
|
|
@@ -86,48 +109,106 @@ function errorText(err) {
|
|
|
86
109
|
}
|
|
87
110
|
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
88
111
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
/** Why the set was not made, and what to do next. Never a success-looking line. */
|
|
113
|
+
function setFailureText(err) {
|
|
114
|
+
if (err instanceof FuseError) {
|
|
115
|
+
const where = err.member !== null ? ` (member ${err.member})` : "";
|
|
116
|
+
switch (err.code) {
|
|
117
|
+
case "tee-restarting":
|
|
118
|
+
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.`;
|
|
119
|
+
case "network":
|
|
120
|
+
case "transport":
|
|
121
|
+
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.`;
|
|
122
|
+
default:
|
|
123
|
+
return `Nothing was BitGraphed${where}: ${err.message} (${err.code}). Run bitgraph_record again with the same paths.`;
|
|
95
124
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
}
|
|
126
|
+
return `Nothing was BitGraphed: ${err instanceof Error ? err.message : String(err)}. Run bitgraph_record again with the same paths.`;
|
|
127
|
+
}
|
|
128
|
+
/** Progress notifications, when the client asked for them with a progress token; a no-op otherwise. */
|
|
129
|
+
function progressReporter(extra) {
|
|
130
|
+
const token = extra._meta?.progressToken;
|
|
131
|
+
if (token === undefined)
|
|
132
|
+
return () => { };
|
|
133
|
+
return (progress, total, message) => {
|
|
134
|
+
void extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress, total, message } }).catch(() => { });
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const PHASES = {
|
|
138
|
+
hash: "checking members",
|
|
139
|
+
fuse: "fusing",
|
|
140
|
+
tree: "building the tree",
|
|
141
|
+
commit: "committing",
|
|
142
|
+
verify: "verifying",
|
|
143
|
+
};
|
|
144
|
+
const pendingIndex = [];
|
|
145
|
+
/** How many members' evidence is waiting to be indexed (tests read it). */
|
|
146
|
+
export function pendingIndexCount() {
|
|
147
|
+
return pendingIndex.reduce((n, p) => n + p.members.length, 0);
|
|
148
|
+
}
|
|
149
|
+
/** Send pending evidence in chunks; what fails stays pending. */
|
|
150
|
+
async function flushIndex(config, report) {
|
|
151
|
+
const total = pendingIndexCount();
|
|
152
|
+
let written = 0;
|
|
153
|
+
let stopped = false;
|
|
154
|
+
for (const set of pendingIndex) {
|
|
155
|
+
while (set.members.length > 0 && !stopped) {
|
|
156
|
+
const chunk = set.members.slice(0, SET_INDEX_CHUNK);
|
|
157
|
+
let ok = false;
|
|
158
|
+
for (let attempt = 0; attempt < 2 && !ok; attempt++) {
|
|
159
|
+
try {
|
|
160
|
+
await indexSetMembers(config, { setDigest: set.setDigest, epoch: set.epoch, counter: set.counter, members: chunk });
|
|
161
|
+
ok = true;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
ok = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!ok) {
|
|
168
|
+
stopped = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
set.members.splice(0, chunk.length);
|
|
172
|
+
written += chunk.length;
|
|
173
|
+
report(written, total, `indexing ${written} of ${total}`);
|
|
99
174
|
}
|
|
100
|
-
|
|
101
|
-
|
|
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.`);
|
|
175
|
+
if (stopped)
|
|
176
|
+
break;
|
|
103
177
|
}
|
|
104
|
-
|
|
178
|
+
const left = pendingIndex.filter((s) => s.members.length > 0);
|
|
179
|
+
pendingIndex.length = 0;
|
|
180
|
+
pendingIndex.push(...left);
|
|
181
|
+
return { written, pending: total - written };
|
|
105
182
|
}
|
|
106
183
|
export function buildServer(deps = {}) {
|
|
107
|
-
const
|
|
184
|
+
const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
|
|
108
185
|
const server = new McpServer({
|
|
109
186
|
name: "bitgraph-mcp-server",
|
|
110
187
|
version: SERVER_VERSION,
|
|
188
|
+
}, {
|
|
189
|
+
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: one set under one slot, one position, every file's new fused bytes listed by digest in the committed artifact. " +
|
|
190
|
+
"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
191
|
});
|
|
112
192
|
server.registerTool("bitgraph_record", {
|
|
113
|
-
title: "
|
|
114
|
-
description: "
|
|
115
|
-
"
|
|
116
|
-
"Files
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
"
|
|
193
|
+
title: "Make a BitGraph",
|
|
194
|
+
description: "Make a BitGraph of files or folders. Everything in one call becomes ONE BitGraph: a set under a single slot in the BitGraph ledger (bitgraph.ing), one position for all of it, the way a drop on the site works. " +
|
|
195
|
+
"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 the canonical list of those digests (above 2,000 files, a Merkle root over it) is committed under the same slot. " +
|
|
196
|
+
"Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
|
|
197
|
+
"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. " +
|
|
198
|
+
"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. " +
|
|
199
|
+
"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. " +
|
|
200
|
+
"Returns the set's position and proof page, and one outcome per file: 'fused' (its row in the set, one of N), 'on record', or 'not fused' (with the reason). A lookup by any file's own digest finds the set. " +
|
|
201
|
+
"Use bitgraph_check instead when the user only wants to know whether files are on record.",
|
|
121
202
|
inputSchema: {
|
|
122
203
|
paths: z
|
|
123
204
|
.array(z.string().min(1))
|
|
124
205
|
.min(1)
|
|
125
|
-
.max(
|
|
126
|
-
.describe(`File paths to BitGraph
|
|
206
|
+
.max(MAX_PATHS)
|
|
207
|
+
.describe(`File or directory paths to BitGraph, up to ${MAX_PATHS}; a directory expands to its files, up to ${MAX_MEMBERS} in all.`),
|
|
127
208
|
again: z
|
|
128
209
|
.boolean()
|
|
129
210
|
.default(false)
|
|
130
|
-
.describe("false (default): files already on record are returned as-is, nothing
|
|
211
|
+
.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
212
|
response_format: responseFormatSchema,
|
|
132
213
|
},
|
|
133
214
|
annotations: {
|
|
@@ -136,18 +217,42 @@ export function buildServer(deps = {}) {
|
|
|
136
217
|
idempotentHint: false,
|
|
137
218
|
openWorldHint: true,
|
|
138
219
|
},
|
|
139
|
-
}, async ({ paths, again, response_format }) => {
|
|
220
|
+
}, async ({ paths, again, response_format }, extra) => {
|
|
140
221
|
const config = configFromEnv();
|
|
222
|
+
const report = progressReporter(extra);
|
|
141
223
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
224
|
+
// 0. Paths to files, before any network call.
|
|
225
|
+
const expanded = await expandPaths(paths, MAX_MEMBERS);
|
|
226
|
+
const files = expanded.files;
|
|
227
|
+
if (files.length === 0) {
|
|
228
|
+
return fail("Error: nothing to BitGraph: the given directories hold no regular files (hidden entries and symbolic links are left out).");
|
|
229
|
+
}
|
|
230
|
+
// 1. Evidence from an earlier set that the site has not indexed yet goes first.
|
|
231
|
+
if (pendingIndexCount() > 0) {
|
|
232
|
+
const flushed = await flushIndex(config, report);
|
|
233
|
+
if (flushed.pending > 0) {
|
|
234
|
+
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.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// 2. The scan: one pass per file.
|
|
238
|
+
let scanned = 0;
|
|
239
|
+
report(0, files.length, `hashing ${files.length} files`);
|
|
240
|
+
const scans = await mapConcurrent(files, SCAN_CONCURRENCY, async (p) => {
|
|
241
|
+
const s = await scanFile(p);
|
|
242
|
+
scanned += 1;
|
|
243
|
+
report(scanned, files.length, `hashed ${scanned} of ${files.length}`);
|
|
244
|
+
return s;
|
|
149
245
|
});
|
|
246
|
+
// 3. Unique by content; the first path names the member, every path is reported.
|
|
247
|
+
const byDigest = new Map();
|
|
248
|
+
for (const s of scans) {
|
|
249
|
+
const entry = byDigest.get(s.digestB64) ?? { file: s, paths: [] };
|
|
250
|
+
entry.paths.push(s.path);
|
|
251
|
+
byDigest.set(s.digestB64, entry);
|
|
252
|
+
}
|
|
150
253
|
const unique = [...byDigest.keys()];
|
|
254
|
+
// 4. What is on record already.
|
|
255
|
+
report(0, 1, "checking the ledger");
|
|
151
256
|
const checked = await batchCheck(config, unique.map(toUrlSafeB64));
|
|
152
257
|
const existing = new Map();
|
|
153
258
|
for (const d of unique) {
|
|
@@ -155,92 +260,147 @@ export function buildServer(deps = {}) {
|
|
|
155
260
|
if (entry && entry.proofs.length > 0)
|
|
156
261
|
existing.set(d, entry.proofs);
|
|
157
262
|
}
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
for (const d of
|
|
162
|
-
const
|
|
163
|
-
|
|
263
|
+
// 5. The set: every fresh file (every file, with again), one call.
|
|
264
|
+
const excluded = new Map();
|
|
265
|
+
const toMint = [];
|
|
266
|
+
for (const d of again ? unique : unique.filter((x) => !existing.has(x))) {
|
|
267
|
+
const f = byDigest.get(d).file;
|
|
268
|
+
if (f.state === null && f.size > MAX_LOADED_BYTES) {
|
|
269
|
+
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");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
toMint.push(f);
|
|
273
|
+
}
|
|
274
|
+
const attempted = new Set(toMint.map((f) => f.digestB64));
|
|
275
|
+
let made = null;
|
|
276
|
+
let failure = null;
|
|
277
|
+
if (toMint.length > 0) {
|
|
278
|
+
const kind = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
|
|
164
279
|
try {
|
|
165
|
-
|
|
280
|
+
made = await fuseSetPipeline(toMint, config, {
|
|
281
|
+
set: kind,
|
|
282
|
+
onProgress: (p) => report(p.done, p.total, `${PHASES[p.phase]} ${p.done} of ${p.total}`),
|
|
283
|
+
});
|
|
166
284
|
}
|
|
167
285
|
catch (err) {
|
|
168
|
-
|
|
286
|
+
failure = setFailureText(err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 6. A set/2 lands with only its root on the ledger; its members are indexed afterwards.
|
|
290
|
+
let index = null;
|
|
291
|
+
if (made !== null && made.set === "set/2") {
|
|
292
|
+
const counter = made.proof.commit?.counter;
|
|
293
|
+
const epochId = made.proof.commit?.epochId;
|
|
294
|
+
const members = made.members.map((m) => m.memberProof).filter((e) => e !== undefined);
|
|
295
|
+
if (counter !== undefined && epochId !== undefined && members.length > 0) {
|
|
296
|
+
pendingIndex.push({ setDigest: toUrlSafeB64(made.artifactDigestB64), epoch: toUrlSafeB64(epochId), counter, members });
|
|
297
|
+
index = await flushIndex(config, report);
|
|
169
298
|
}
|
|
170
299
|
}
|
|
300
|
+
// 7. Outcomes.
|
|
301
|
+
let setOutcome = null;
|
|
302
|
+
const memberOf = new Map();
|
|
303
|
+
if (made !== null) {
|
|
304
|
+
for (const m of made.members)
|
|
305
|
+
memberOf.set(m.originDigestB64, m);
|
|
306
|
+
const { counter, epoch } = positionOf(made.proof);
|
|
307
|
+
setOutcome = {
|
|
308
|
+
set: made.set,
|
|
309
|
+
count: made.count,
|
|
310
|
+
counter,
|
|
311
|
+
epoch,
|
|
312
|
+
artifact_digest: toUrlSafeB64(made.artifactDigestB64),
|
|
313
|
+
proof_url: proofUrl(config.baseUrl, made.artifactDigestB64, counter ?? undefined, made.proof.commit?.epochId),
|
|
314
|
+
manifest_echoed: made.manifestEchoed,
|
|
315
|
+
recovered: made.recovered,
|
|
316
|
+
index,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
171
319
|
const outcomes = [];
|
|
172
|
-
const frames = {};
|
|
173
320
|
for (const [digest, entry] of byDigest) {
|
|
174
|
-
const
|
|
321
|
+
const m = memberOf.get(digest);
|
|
175
322
|
const prior = existing.get(digest);
|
|
176
323
|
for (const path of entry.paths) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
frames[toUrlSafeB64(made.artifactDigestB64)] = made.frame;
|
|
324
|
+
const base = { path, digest: toUrlSafeB64(digest) };
|
|
325
|
+
if (m !== undefined && made !== null && setOutcome !== null) {
|
|
180
326
|
outcomes.push({
|
|
181
|
-
|
|
182
|
-
digest: toUrlSafeB64(digest),
|
|
327
|
+
...base,
|
|
183
328
|
outcome: "fused",
|
|
184
|
-
artifact_digest: toUrlSafeB64(
|
|
185
|
-
placement:
|
|
186
|
-
counter,
|
|
187
|
-
epoch,
|
|
329
|
+
artifact_digest: toUrlSafeB64(m.artifactDigestB64),
|
|
330
|
+
placement: m.placement,
|
|
331
|
+
counter: setOutcome.counter,
|
|
332
|
+
epoch: setOutcome.epoch,
|
|
333
|
+
member: m.manifestIndex + 1,
|
|
334
|
+
member_count: made.count,
|
|
188
335
|
total_positions: (prior?.length ?? 0) + 1,
|
|
189
|
-
proof_url: proofUrl(config.baseUrl,
|
|
336
|
+
proof_url: proofUrl(config.baseUrl, digest, setOutcome.counter ?? undefined, made.proof.commit?.epochId),
|
|
190
337
|
});
|
|
191
338
|
}
|
|
192
|
-
else if (prior && !
|
|
193
|
-
const first = prior[0]
|
|
194
|
-
const { counter, epoch } = first ? positionOf(first) : { counter: null, epoch: null };
|
|
339
|
+
else if (prior && !attempted.has(digest) && !excluded.has(digest)) {
|
|
340
|
+
const first = prior[0];
|
|
341
|
+
const { counter, epoch } = first ? positionOf(first.proof) : { counter: null, epoch: null };
|
|
342
|
+
const row = first?.member;
|
|
195
343
|
outcomes.push({
|
|
196
|
-
|
|
197
|
-
digest: toUrlSafeB64(digest),
|
|
344
|
+
...base,
|
|
198
345
|
outcome: "on record",
|
|
199
346
|
artifact_digest: null,
|
|
200
347
|
placement: null,
|
|
201
348
|
counter,
|
|
202
349
|
epoch,
|
|
350
|
+
member: row ? row.index + 1 : null,
|
|
351
|
+
member_count: row ? row.count : null,
|
|
203
352
|
total_positions: prior.length,
|
|
204
353
|
proof_url: proofUrl(config.baseUrl, digest),
|
|
205
354
|
});
|
|
206
355
|
}
|
|
207
356
|
else {
|
|
208
357
|
outcomes.push({
|
|
209
|
-
|
|
210
|
-
digest: toUrlSafeB64(digest),
|
|
358
|
+
...base,
|
|
211
359
|
outcome: "not fused",
|
|
212
360
|
artifact_digest: null,
|
|
213
361
|
placement: null,
|
|
214
362
|
counter: null,
|
|
215
363
|
epoch: null,
|
|
364
|
+
member: null,
|
|
365
|
+
member_count: null,
|
|
216
366
|
total_positions: prior?.length ?? 0,
|
|
217
367
|
proof_url: null,
|
|
218
|
-
error:
|
|
368
|
+
error: excluded.get(digest) ?? failure ?? "not attempted",
|
|
219
369
|
});
|
|
220
370
|
}
|
|
221
371
|
}
|
|
222
372
|
}
|
|
373
|
+
// Rows that need reading come first, so a cap drops fused rows, which all share one position.
|
|
374
|
+
const order = { "not fused": 0, "on record": 1, fused: 2 };
|
|
375
|
+
outcomes.sort((a, b) => order[a.outcome] - order[b.outcome]);
|
|
376
|
+
const listed = outcomes.slice(0, ROW_CAP);
|
|
377
|
+
const omitted = outcomes.length - listed.length;
|
|
378
|
+
const summary = {
|
|
379
|
+
files: files.length,
|
|
380
|
+
directories: expanded.directories,
|
|
381
|
+
fused: outcomes.filter((o) => o.outcome === "fused").length,
|
|
382
|
+
on_record: outcomes.filter((o) => o.outcome === "on record").length,
|
|
383
|
+
not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
|
|
384
|
+
};
|
|
223
385
|
const structured = {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
on_record: outcomes.filter((o) => o.outcome === "on record").length,
|
|
229
|
-
not_fused: outcomes.filter((o) => o.outcome === "not fused").length,
|
|
230
|
-
},
|
|
386
|
+
set: setOutcome,
|
|
387
|
+
results: listed,
|
|
388
|
+
omitted,
|
|
389
|
+
summary,
|
|
231
390
|
};
|
|
232
|
-
|
|
233
|
-
|
|
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.";
|
|
391
|
+
const markdown = renderRecordMarkdown(outcomes, setOutcome, omitted);
|
|
392
|
+
if (summary.not_fused > 0) {
|
|
236
393
|
return {
|
|
237
394
|
isError: true,
|
|
238
|
-
content: [{ type: "text", text: `${
|
|
395
|
+
content: [{ type: "text", text: `${failure ?? `${summary.not_fused} file(s) were left out.`}\n\n${markdown}` }],
|
|
239
396
|
structuredContent: structured,
|
|
240
397
|
};
|
|
241
398
|
}
|
|
242
|
-
|
|
243
|
-
|
|
399
|
+
if (response_format === "json") {
|
|
400
|
+
const full = { ...structured, set: setOutcome !== null && made !== null ? { ...setOutcome, proof: made.proof } : null };
|
|
401
|
+
return ok(capJson(full).text, structured);
|
|
402
|
+
}
|
|
403
|
+
return ok(markdown, structured);
|
|
244
404
|
}
|
|
245
405
|
catch (err) {
|
|
246
406
|
return fail(errorText(err));
|
|
@@ -249,18 +409,18 @@ export function buildServer(deps = {}) {
|
|
|
249
409
|
server.registerTool("bitgraph_check", {
|
|
250
410
|
title: "Check for BitGraphs",
|
|
251
411
|
description: "Check whether files or digests are on record in the BitGraph ledger, without recording anything. " +
|
|
252
|
-
|
|
253
|
-
"Returns, per item: on_record (
|
|
412
|
+
`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. ` +
|
|
413
|
+
"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
414
|
"Read-only. Use bitgraph_record to BitGraph files that turn out not to be on record.",
|
|
255
415
|
inputSchema: {
|
|
256
416
|
paths: z
|
|
257
417
|
.array(z.string().min(1))
|
|
258
|
-
.max(
|
|
418
|
+
.max(MAX_PATHS)
|
|
259
419
|
.optional()
|
|
260
|
-
.describe("File paths to check."),
|
|
420
|
+
.describe("File or directory paths to check."),
|
|
261
421
|
digests: z
|
|
262
422
|
.array(z.string().min(1).max(100))
|
|
263
|
-
.max(
|
|
423
|
+
.max(MAX_CHECK_FILES)
|
|
264
424
|
.optional()
|
|
265
425
|
.describe("SHA-256 digests, base64 (standard or URL-safe form)."),
|
|
266
426
|
response_format: responseFormatSchema,
|
|
@@ -276,8 +436,9 @@ export function buildServer(deps = {}) {
|
|
|
276
436
|
try {
|
|
277
437
|
const inputs = [];
|
|
278
438
|
if (paths && paths.length > 0) {
|
|
279
|
-
const
|
|
280
|
-
hashed
|
|
439
|
+
const { files } = await expandPaths(paths, MAX_CHECK_FILES);
|
|
440
|
+
const hashed = await hashPaths(files);
|
|
441
|
+
hashed.forEach((d, i) => inputs.push({ label: files[i], standardDigest: d }));
|
|
281
442
|
}
|
|
282
443
|
for (const d of digests ?? []) {
|
|
283
444
|
const trimmed = d.trim();
|
|
@@ -289,17 +450,20 @@ export function buildServer(deps = {}) {
|
|
|
289
450
|
if (inputs.length === 0) {
|
|
290
451
|
return fail("Error: provide at least one of paths or digests.");
|
|
291
452
|
}
|
|
453
|
+
if (inputs.length > MAX_CHECK_FILES) {
|
|
454
|
+
return fail(`Error: ${inputs.length} items; check at most ${MAX_CHECK_FILES} at a time.`);
|
|
455
|
+
}
|
|
292
456
|
const checked = await batchCheck(config, inputs.map((i) => toUrlSafeB64(i.standardDigest)));
|
|
293
457
|
const outcomes = inputs.map((input) => {
|
|
294
458
|
const entry = checked.results[toUrlSafeB64(input.standardDigest)];
|
|
295
459
|
const proofs = entry?.proofs ?? [];
|
|
296
|
-
const positions = proofs.map((p) => positionOf(p.proof));
|
|
460
|
+
const positions = proofs.map((p) => ({ ...positionOf(p.proof), ...(p.member ? { member: p.member } : {}) }));
|
|
297
461
|
return {
|
|
298
462
|
input: input.label,
|
|
299
463
|
digest: toUrlSafeB64(input.standardDigest),
|
|
300
464
|
// A fused descendant that names these bytes as origin is not a recording of them.
|
|
301
|
-
|
|
302
|
-
|
|
465
|
+
// The original and the new file made from it find the same proof.
|
|
466
|
+
on_record: proofs.length > 0,
|
|
303
467
|
positions,
|
|
304
468
|
proof_url: proofs.length > 0 ? proofUrl(config.baseUrl, input.standardDigest) : null,
|
|
305
469
|
};
|
|
@@ -320,7 +484,7 @@ export function buildServer(deps = {}) {
|
|
|
320
484
|
});
|
|
321
485
|
server.registerTool("bitgraph_get_proof", {
|
|
322
486
|
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 " +
|
|
487
|
+
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
488
|
"('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
489
|
"Exactly one of digest, number, or path is required. Read-only. " +
|
|
326
490
|
"markdown returns a summary; json returns the full proof object with positions and anchor window.",
|
|
@@ -387,7 +551,7 @@ export function buildServer(deps = {}) {
|
|
|
387
551
|
const selEpoch = epoch !== undefined ? toUrlSafeB64(fromUrlSafeB64(epoch)) : undefined;
|
|
388
552
|
const detail = await getProofDetail(config, urlSafeDigest, selCounter, selEpoch);
|
|
389
553
|
if (detail.proofs.length === 0) {
|
|
390
|
-
return fail(`Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to
|
|
554
|
+
return fail(`Not on record: no proof exists for digest ${urlSafeDigest}. Use bitgraph_record to make a BitGraph of the file.`);
|
|
391
555
|
}
|
|
392
556
|
if (response_format === "json") {
|
|
393
557
|
const capped = capJson(detail);
|