@mikeargento/bitgraph-mcp 0.4.0 → 0.5.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 +2 -2
- package/dist/__tests__/format.test.js +3 -3
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +32 -11
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +11 -6
- package/dist/format.js.map +1 -1
- package/dist/server.d.ts +20 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +141 -20
- package/dist/server.js.map +1 -1
- package/dist/task.d.ts +44 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +158 -0
- package/dist/task.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/format.test.ts +3 -3
- package/src/__tests__/tools.test.ts +33 -12
- package/src/format.ts +13 -6
- package/src/server.ts +163 -20
- package/src/task.ts +163 -0
|
@@ -37,7 +37,7 @@ test("record markdown mentions again=true only when something was already on rec
|
|
|
37
37
|
{ ...base, path: "/b", outcome: "on record", artifact_digest: null, placement: null, member: null, member_count: null },
|
|
38
38
|
]);
|
|
39
39
|
assert.ok(mixed.includes("again=true"));
|
|
40
|
-
assert.ok(mixed.startsWith("1 fused, 1 already on record."));
|
|
40
|
+
assert.ok(mixed.startsWith("1 fused, 1 already on record (indexed before 2026-09-08)."));
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("record markdown names the set once and its members by row", () => {
|
|
@@ -54,12 +54,12 @@ test("record markdown names the set once and its members by row", () => {
|
|
|
54
54
|
};
|
|
55
55
|
const row = { digest: toUrlSafeB64(DIGEST), counter: "1386", epoch: toUrlSafeB64(EPOCH), total_positions: 1, proof_url: "https://bitgraph.ing/proof/x", artifact_digest: "ZnVzZWQ", outcome: "fused" as const, member_count: 2 };
|
|
56
56
|
const md = renderRecordMarkdown([{ ...row, path: "/a.png", placement: "trailer/1", member: 2 }, { ...row, path: "/b.txt", placement: "container/2", member: 1 }], set);
|
|
57
|
-
assert.ok(md.startsWith("2 files BitGraphed as one set at #1386 (set of 2), 0 already on record."), md);
|
|
57
|
+
assert.ok(md.startsWith("2 files BitGraphed as one set at #1386 (set of 2), 0 already on record (indexed before 2026-09-08)."), md);
|
|
58
58
|
assert.ok(md.includes("- #1386 · set of 2 · https://bitgraph.ing/proof/c2V0?counter=1386"), md);
|
|
59
59
|
assert.ok(md.includes("- fused · /a.png (2 of 2, trailer/1)"), md);
|
|
60
60
|
assert.ok(md.includes("- fused · /b.txt (1 of 2, container/2)"), md);
|
|
61
61
|
const waiting = renderRecordMarkdown([{ ...row, path: "/a.png", placement: "trailer/1", member: 1 }], { ...set, set: "set/2", index: { written: 0, pending: 2 } });
|
|
62
|
-
assert.ok(waiting.includes("not
|
|
62
|
+
assert.ok(waiting.includes("does not index new proofs by hash"), waiting);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("proof markdown states the window as between lower and upper", () => {
|
|
@@ -17,7 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
18
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
19
19
|
import { FuseError } from "@mikeargento/bitgraph";
|
|
20
|
-
import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseSetFn } from "../server.js";
|
|
20
|
+
import { buildServer, pendingIndexCount, ROW_CAP, SET_INDEX_CHUNK, type FuseFileFn, type FuseSetFn } from "../server.js";
|
|
21
21
|
import { toUrlSafeB64 } from "../encoding.js";
|
|
22
22
|
|
|
23
23
|
interface Recorded {
|
|
@@ -202,8 +202,16 @@ const fakeFuseSet: FuseSetFn = async (files, _config, opts) => {
|
|
|
202
202
|
}),
|
|
203
203
|
};
|
|
204
204
|
};
|
|
205
|
+
/** A stand-in for the single-file pipeline: records what it was asked to fuse. */
|
|
206
|
+
const soloCalls: Array<{ name: string; digestB64: string }> = [];
|
|
207
|
+
const fakeFuseFile: FuseFileFn = async (file) => {
|
|
208
|
+
soloCalls.push({ name: file.name, digestB64: file.digestB64 });
|
|
209
|
+
if (fuseMode === "fail") throw new FuseError("tee-restarting", "the boundary is restarting", 503);
|
|
210
|
+
const artifactDigestB64 = createHash("sha256").update("fused:" + file.digestB64).digest("base64");
|
|
211
|
+
return { proof: proofFor(artifactDigestB64), frame: { type: "bitgraph-fuse/1" }, placement: file.placement, artifactDigestB64, originDigestB64: file.digestB64 };
|
|
212
|
+
};
|
|
205
213
|
async function connectedClient(): Promise<Client> {
|
|
206
|
-
const server = buildServer({ fuseSet: fakeFuseSet });
|
|
214
|
+
const server = buildServer({ fuseSet: fakeFuseSet, fuseFile: fakeFuseFile });
|
|
207
215
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
208
216
|
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
209
217
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
|
@@ -212,17 +220,18 @@ async function connectedClient(): Promise<Client> {
|
|
|
212
220
|
|
|
213
221
|
interface RecordStructured {
|
|
214
222
|
set: { set: string; count: number; counter: string | null; proof_url: string; index: { written: number; pending: number } | null } | null;
|
|
223
|
+
frames: Record<string, unknown>;
|
|
215
224
|
results: Array<{ path: string; outcome: string; counter: string | null; placement: string | null; artifact_digest: string | null; member: number | null; member_count: number | null; total_positions: number; proof_url: string | null; error?: string }>;
|
|
216
225
|
omitted: number;
|
|
217
226
|
summary: { files: number; directories: number; fused: number; on_record: number; not_fused: number };
|
|
218
227
|
}
|
|
219
228
|
const textOf = (result: unknown): string => (((result as { content?: unknown }).content ?? []) as Array<{ text: string }>)[0]?.text ?? "";
|
|
220
229
|
|
|
221
|
-
test("lists the
|
|
230
|
+
test("lists the five tools", async () => {
|
|
222
231
|
const client = await connectedClient();
|
|
223
232
|
const tools = await client.listTools();
|
|
224
233
|
const names = tools.tools.map((t) => t.name).sort();
|
|
225
|
-
assert.deepEqual(names, ["bitgraph_check", "bitgraph_get_proof", "bitgraph_record"]);
|
|
234
|
+
assert.deepEqual(names, ["bitgraph_check", "bitgraph_commit", "bitgraph_get_proof", "bitgraph_open", "bitgraph_record"]);
|
|
226
235
|
});
|
|
227
236
|
|
|
228
237
|
test("record makes ONE set of the fresh files and leaves on-record ones alone", async () => {
|
|
@@ -268,18 +277,28 @@ test("record makes ONE set of the fresh files and leaves on-record ones alone",
|
|
|
268
277
|
assert.ok(text.includes("set of 2"), text);
|
|
269
278
|
});
|
|
270
279
|
|
|
271
|
-
test("
|
|
280
|
+
test("a single file is fused on its own, with its Frame; again=true fuses an on-record one", async () => {
|
|
272
281
|
const client = await connectedClient();
|
|
273
282
|
setCalls.length = 0;
|
|
283
|
+
soloCalls.length = 0;
|
|
274
284
|
const result = await client.callTool({
|
|
275
285
|
name: "bitgraph_record",
|
|
276
286
|
arguments: { paths: [fileA], again: true },
|
|
277
287
|
});
|
|
278
288
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
279
|
-
assert.
|
|
289
|
+
assert.equal(setCalls.length, 0, "one file is not a set");
|
|
290
|
+
assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestA]);
|
|
280
291
|
const structured = result.structuredContent as RecordStructured;
|
|
281
|
-
assert.equal(structured.
|
|
282
|
-
|
|
292
|
+
assert.equal(structured.set, null);
|
|
293
|
+
const row = structured.results[0];
|
|
294
|
+
assert.equal(row?.outcome, "fused");
|
|
295
|
+
assert.equal(row?.member, null);
|
|
296
|
+
assert.equal(row?.total_positions, 3, "two prior positions plus the new BitGraph");
|
|
297
|
+
assert.ok(row?.artifact_digest && structured.frames[row.artifact_digest], "the Frame rides in the structured result under the fused digest");
|
|
298
|
+
assert.ok(row?.proof_url?.includes(encodeURIComponent(row.artifact_digest ?? "")), "a single file's proof page is its own");
|
|
299
|
+
const text = textOf(result);
|
|
300
|
+
assert.ok(text.startsWith("1 fused, 0 already on record (indexed before 2026-09-08)."), text);
|
|
301
|
+
assert.ok(text.includes("its Frame is in the structured result"), text);
|
|
283
302
|
});
|
|
284
303
|
|
|
285
304
|
test("a directory is its regular files, hidden entries and links left out", async () => {
|
|
@@ -296,17 +315,19 @@ test("a directory is its regular files, hidden entries and links left out", asyn
|
|
|
296
315
|
assert.equal(structured.set?.count, 2);
|
|
297
316
|
});
|
|
298
317
|
|
|
299
|
-
test("the same bytes under two paths are one
|
|
318
|
+
test("the same bytes under two paths are one file, fused once and reported twice", async () => {
|
|
300
319
|
const client = await connectedClient();
|
|
301
320
|
setCalls.length = 0;
|
|
321
|
+
soloCalls.length = 0;
|
|
302
322
|
const result = await client.callTool({
|
|
303
323
|
name: "bitgraph_record",
|
|
304
324
|
arguments: { paths: [fileB, copyB] },
|
|
305
325
|
});
|
|
306
326
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
307
|
-
assert.
|
|
327
|
+
assert.equal(setCalls.length, 0, "one distinct file is fused on its own");
|
|
328
|
+
assert.deepEqual(soloCalls.map((c) => c.digestB64), [digestB]);
|
|
308
329
|
const structured = result.structuredContent as RecordStructured;
|
|
309
|
-
assert.equal(structured.set
|
|
330
|
+
assert.equal(structured.set, null);
|
|
310
331
|
assert.equal(structured.summary.fused, 2);
|
|
311
332
|
assert.equal(structured.results[0]?.artifact_digest, structured.results[1]?.artifact_digest);
|
|
312
333
|
});
|
|
@@ -353,7 +374,7 @@ test("evidence the site cannot index waits, blocks a new set, and is sent first
|
|
|
353
374
|
const s1 = first.structuredContent as RecordStructured;
|
|
354
375
|
assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
|
|
355
376
|
assert.equal(pendingIndexCount(), BIG);
|
|
356
|
-
assert.ok(textOf(first).includes("not
|
|
377
|
+
assert.ok(textOf(first).includes("does not index new proofs by hash"), textOf(first).slice(0, 400));
|
|
357
378
|
|
|
358
379
|
// Still down: nothing new is made, so the members cannot be made again by mistake.
|
|
359
380
|
requests.length = 0;
|
package/src/format.ts
CHANGED
|
@@ -107,13 +107,13 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
107
107
|
} else {
|
|
108
108
|
parts.push(`${fmt(fused.length)} fused`);
|
|
109
109
|
}
|
|
110
|
-
parts.push(`${fmt(onRecord.length)} already on record`);
|
|
110
|
+
parts.push(`${fmt(onRecord.length)} already on record (indexed before 2026-09-08)`);
|
|
111
111
|
if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
|
|
112
112
|
lines.push(`${parts.join(", ")}.`);
|
|
113
113
|
if (set !== null && fused.length > 0) {
|
|
114
114
|
lines.push(`- #${set.counter ?? "?"} · set of ${fmt(set.count)} · ${set.proof_url}`);
|
|
115
115
|
if (set.index !== null && set.index.pending > 0) {
|
|
116
|
-
lines.push(` The set is
|
|
116
|
+
lines.push(` The set is made. The evidence for ${fmt(set.index.pending)} of its ${fmt(set.count)} members has not reached BitGraph yet and is sent again at the start of the next bitgraph_record call. BitGraph does not index new proofs by hash; the set proof beside the originals is the record.`);
|
|
117
117
|
}
|
|
118
118
|
if (!set.manifest_echoed) {
|
|
119
119
|
lines.push(` The boundary did not echo the committed artifact; the ledger's copy of this proof carries no member list. Keep the set's proof page.`);
|
|
@@ -139,17 +139,24 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
139
139
|
);
|
|
140
140
|
group(
|
|
141
141
|
fused,
|
|
142
|
-
(o) =>
|
|
142
|
+
(o) =>
|
|
143
|
+
o.member === null
|
|
144
|
+
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})\n ${o.proof_url}`
|
|
145
|
+
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})`,
|
|
143
146
|
(n) => `and ${fmt(n)} more files in the same set`
|
|
144
147
|
);
|
|
145
|
-
if (fused.length > 0) {
|
|
148
|
+
if (set !== null && fused.length > 0) {
|
|
149
|
+
lines.push(
|
|
150
|
+
"\nOne BitGraph holds every file made here: one slot, one position, and the committed artifact lists each file's new fused bytes by digest. Those bytes were hashed on this machine and never written or uploaded; the file itself is unchanged, and the original plus the set proof rebuilds them. Keep the set proof beside the originals; BitGraph does not index it."
|
|
151
|
+
);
|
|
152
|
+
} else if (fused.length > 0) {
|
|
146
153
|
lines.push(
|
|
147
|
-
"\
|
|
154
|
+
"\nThe new fused file was built in memory from the file, hashed and committed under its own slot; the file itself is unchanged and was not uploaded. The original plus the proof rebuilds the new file; its Frame is in the structured result."
|
|
148
155
|
);
|
|
149
156
|
}
|
|
150
157
|
if (onRecord.length > 0) {
|
|
151
158
|
lines.push(
|
|
152
|
-
"\nFiles
|
|
159
|
+
"\nFiles BitGraph still indexes (recorded before 2026-09-08) were left alone. BitGraph does not index new proofs, so a file may already have a BitGraph in its holder's folder. To make a new one regardless, call bitgraph_record with again=true."
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
if (omitted > 0) {
|
package/src/server.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Three gestures, the same three the website has: make a BitGraph, check
|
|
7
7
|
* whether bytes are on record, fetch a proof. Making a BitGraph is one
|
|
8
|
-
* gesture for any number of files
|
|
9
|
-
*
|
|
10
|
-
* once, on this machine, for its
|
|
11
|
-
*
|
|
12
|
-
* state once the slot exists; and
|
|
13
|
-
* and committed under the same slot.
|
|
14
|
-
* records leave the machine. File
|
|
15
|
-
* never modified.
|
|
8
|
+
* gesture for any number of files, the way a drop on the site is: a single
|
|
9
|
+
* file is fused on its own slot, and two or more become members of ONE set
|
|
10
|
+
* under ONE slot. For a set each file is read once, on this machine, for its
|
|
11
|
+
* digest and a hasher state; the new fused bytes are never written and never
|
|
12
|
+
* held, their digest is finished from that state once the slot exists; and
|
|
13
|
+
* the set's committed artifact is hashed and committed under the same slot.
|
|
14
|
+
* Only digests, that artifact and slot records leave the machine. File
|
|
15
|
+
* contents are never uploaded and files are never modified.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -20,7 +20,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto
|
|
|
20
20
|
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { readFile } from "node:fs/promises";
|
|
23
|
-
import { FuseError, MAX_SET_MEMBERS, fuseSet, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
|
|
23
|
+
import { FuseError, MAX_SET_MEMBERS, builderFor, fuse, fuseSet, fusedNamesFor, type FuseSetMember, type FuseSetProgress } from "@mikeargento/bitgraph";
|
|
24
24
|
import {
|
|
25
25
|
ApiError,
|
|
26
26
|
batchCheck,
|
|
@@ -49,9 +49,10 @@ import {
|
|
|
49
49
|
type SetOutcome,
|
|
50
50
|
} from "./format.js";
|
|
51
51
|
import { expandPaths, fusedDigestFor, scanFile, type ScannedFile } from "./scan.js";
|
|
52
|
+
import { SLOT_TTL_SECONDS, TASK_INSTRUCTIONS, beginTask, decodeTaskToken, sealTask } from "./task.js";
|
|
52
53
|
import type { BitGraphProof } from "./types.js";
|
|
53
54
|
|
|
54
|
-
export const SERVER_VERSION = "0.
|
|
55
|
+
export const SERVER_VERSION = "0.5.0";
|
|
55
56
|
|
|
56
57
|
const SCAN_CONCURRENCY = 4;
|
|
57
58
|
/** Paths per call; a directory counts once and expands to its files. */
|
|
@@ -62,6 +63,8 @@ export const MAX_MEMBERS = 100_000;
|
|
|
62
63
|
const MAX_CHECK_FILES = 10_000;
|
|
63
64
|
/** 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. */
|
|
64
65
|
const MAX_LOADED_BYTES = 256 * 1024 * 1024;
|
|
66
|
+
/** 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. */
|
|
67
|
+
const MAX_SOLO_BYTES = 256 * 1024 * 1024;
|
|
65
68
|
/** Rows the structured result lists in full; every fused row shares the set's position. */
|
|
66
69
|
export const ROW_CAP = 500;
|
|
67
70
|
/** Members' evidence per set-index request: the site's own chunk. */
|
|
@@ -93,9 +96,48 @@ export type FuseSetFn = (
|
|
|
93
96
|
config: ApiConfig,
|
|
94
97
|
opts: { set: "set/1" | "set/2"; onProgress?: (p: FuseSetProgress) => void }
|
|
95
98
|
) => Promise<SetSummary>;
|
|
99
|
+
|
|
100
|
+
/** What fusing one file on its own yields. */
|
|
101
|
+
export interface FusedSummary {
|
|
102
|
+
proof: BitGraphProof;
|
|
103
|
+
frame: unknown;
|
|
104
|
+
placement: string;
|
|
105
|
+
artifactDigestB64: string;
|
|
106
|
+
originDigestB64: string;
|
|
107
|
+
}
|
|
108
|
+
export type FuseFileFn = (file: ScannedFile, config: ApiConfig) => Promise<FusedSummary>;
|
|
109
|
+
|
|
96
110
|
export interface ServerDeps {
|
|
97
111
|
/** The set pipeline; tests inject a stand-in. Default: the core package's fuseSet() against the configured site. */
|
|
98
112
|
fuseSet?: FuseSetFn;
|
|
113
|
+
/** The single-file pipeline; tests inject a stand-in. Default: the core package's fuse() against the configured site. */
|
|
114
|
+
fuseFile?: FuseFileFn;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A single file, the way a single drop on the site goes: the bytes in hand,
|
|
119
|
+
* the placement chosen from them, one slot, the fused bytes built in memory
|
|
120
|
+
* and hashed, committed under that exact slot, verified against the bytes,
|
|
121
|
+
* and a Frame returned. The fused bytes are not kept.
|
|
122
|
+
*/
|
|
123
|
+
async function fuseFileDefault(file: ScannedFile, config: ApiConfig): Promise<FusedSummary> {
|
|
124
|
+
const bytes = new Uint8Array(await readFile(file.path));
|
|
125
|
+
const placement = file.placement;
|
|
126
|
+
const { fusedName } = fusedNamesFor(file.name, placement);
|
|
127
|
+
const r = await fuse(builderFor(placement, bytes), {
|
|
128
|
+
placement,
|
|
129
|
+
original: bytes,
|
|
130
|
+
fusedFile: fusedName,
|
|
131
|
+
keepFused: false,
|
|
132
|
+
transport: { baseUrl: config.baseUrl, ...(config.apiKey ? { apiKey: config.apiKey } : {}) },
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
proof: r.proof as unknown as BitGraphProof,
|
|
136
|
+
frame: r.frame,
|
|
137
|
+
placement,
|
|
138
|
+
artifactDigestB64: r.artifactDigestB64,
|
|
139
|
+
originDigestB64: r.originDigestB64 ?? file.digestB64,
|
|
140
|
+
};
|
|
99
141
|
}
|
|
100
142
|
|
|
101
143
|
/**
|
|
@@ -282,6 +324,7 @@ async function flushIndex(config: ApiConfig, report: Report): Promise<{ written:
|
|
|
282
324
|
|
|
283
325
|
export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
284
326
|
const fuseSetPipeline = deps.fuseSet ?? fuseSetDefault;
|
|
327
|
+
const fuseFilePipeline = deps.fuseFile ?? fuseFileDefault;
|
|
285
328
|
const server = new McpServer(
|
|
286
329
|
{
|
|
287
330
|
name: "bitgraph-mcp-server",
|
|
@@ -289,8 +332,9 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
289
332
|
},
|
|
290
333
|
{
|
|
291
334
|
instructions:
|
|
292
|
-
"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. " +
|
|
293
|
-
"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."
|
|
335
|
+
"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. " +
|
|
336
|
+
"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. " +
|
|
337
|
+
"To do work INSIDE a BitGraph, call bitgraph_open BEFORE starting: it returns a position and its commitment; put the commitment string into the task, seal the task with bitgraph_commit within 120 seconds, then record the outputs with bitgraph_record. The task then could not have existed before the position's floor block, and the outputs sit after it.",
|
|
294
338
|
}
|
|
295
339
|
);
|
|
296
340
|
|
|
@@ -299,13 +343,13 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
299
343
|
{
|
|
300
344
|
title: "Make a BitGraph",
|
|
301
345
|
description:
|
|
302
|
-
"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
|
|
303
|
-
"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. " +
|
|
346
|
+
"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. " +
|
|
347
|
+
"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. " +
|
|
304
348
|
"Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
|
|
305
349
|
"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. " +
|
|
306
|
-
"Files
|
|
307
|
-
"
|
|
308
|
-
"Returns
|
|
350
|
+
"Files BitGraph still indexes (recorded before 2026-09-08) are NOT made again by default; they come back as 'on record' with their earliest position. BitGraph does not index new proofs, so a file may already have a BitGraph its holder keeps. Pass again=true to make a new BitGraph regardless. " +
|
|
351
|
+
"Positions are permanent and the proof comes back to you to keep, so only BitGraph files the user asked to, and never generate content just to record it. " +
|
|
352
|
+
"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). Keep the proof beside the files; BitGraph does not index it. " +
|
|
309
353
|
"Use bitgraph_check instead when the user only wants to know whether files are on record.",
|
|
310
354
|
inputSchema: {
|
|
311
355
|
paths: z
|
|
@@ -317,7 +361,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
317
361
|
.boolean()
|
|
318
362
|
.default(false)
|
|
319
363
|
.describe(
|
|
320
|
-
"false (default): files
|
|
364
|
+
"false (default): files BitGraph still indexes are returned as-is, nothing made. true: put every file in the set regardless. Outcomes are per unique file content: two paths with identical bytes are one member."
|
|
321
365
|
),
|
|
322
366
|
response_format: responseFormatSchema,
|
|
323
367
|
},
|
|
@@ -390,8 +434,18 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
390
434
|
}
|
|
391
435
|
const attempted = new Set(toMint.map((f) => f.digestB64));
|
|
392
436
|
let made: SetSummary | null = null;
|
|
437
|
+
let solo: (FusedSummary & { file: ScannedFile }) | null = null;
|
|
393
438
|
let failure: string | null = null;
|
|
394
|
-
|
|
439
|
+
const one = toMint.length === 1 ? (toMint[0] as ScannedFile) : null;
|
|
440
|
+
if (one !== null && one.size <= MAX_SOLO_BYTES) {
|
|
441
|
+
// One file, as a single drop on the site goes: its own slot, its own Frame.
|
|
442
|
+
report(0, 1, "fusing");
|
|
443
|
+
try {
|
|
444
|
+
solo = { ...(await fuseFilePipeline(one, config)), file: one };
|
|
445
|
+
} catch (err) {
|
|
446
|
+
failure = setFailureText(err);
|
|
447
|
+
}
|
|
448
|
+
} else if (toMint.length > 0) {
|
|
395
449
|
const kind: "set/1" | "set/2" = toMint.length > MAX_SET_MEMBERS ? "set/2" : "set/1";
|
|
396
450
|
try {
|
|
397
451
|
made = await fuseSetPipeline(toMint, config, {
|
|
@@ -433,13 +487,29 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
433
487
|
index,
|
|
434
488
|
};
|
|
435
489
|
}
|
|
490
|
+
const frames: Record<string, unknown> = {};
|
|
491
|
+
if (solo !== null) frames[toUrlSafeB64(solo.artifactDigestB64)] = solo.frame;
|
|
436
492
|
const outcomes: RecordOutcome[] = [];
|
|
437
493
|
for (const [digest, entry] of byDigest) {
|
|
438
494
|
const m = memberOf.get(digest);
|
|
439
495
|
const prior = existing.get(digest);
|
|
440
496
|
for (const path of entry.paths) {
|
|
441
497
|
const base = { path, digest: toUrlSafeB64(digest) };
|
|
442
|
-
if (
|
|
498
|
+
if (solo !== null && solo.file.digestB64 === digest) {
|
|
499
|
+
const { counter, epoch } = positionOf(solo.proof);
|
|
500
|
+
outcomes.push({
|
|
501
|
+
...base,
|
|
502
|
+
outcome: "fused",
|
|
503
|
+
artifact_digest: toUrlSafeB64(solo.artifactDigestB64),
|
|
504
|
+
placement: solo.placement,
|
|
505
|
+
counter,
|
|
506
|
+
epoch,
|
|
507
|
+
member: null,
|
|
508
|
+
member_count: null,
|
|
509
|
+
total_positions: (prior?.length ?? 0) + 1,
|
|
510
|
+
proof_url: proofUrl(config.baseUrl, solo.artifactDigestB64, counter ?? undefined, solo.proof.commit?.epochId),
|
|
511
|
+
});
|
|
512
|
+
} else if (m !== undefined && made !== null && setOutcome !== null) {
|
|
443
513
|
outcomes.push({
|
|
444
514
|
...base,
|
|
445
515
|
outcome: "fused",
|
|
@@ -500,6 +570,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
500
570
|
const structured = {
|
|
501
571
|
set: setOutcome,
|
|
502
572
|
results: listed as unknown as Record<string, unknown>[],
|
|
573
|
+
frames,
|
|
503
574
|
omitted,
|
|
504
575
|
summary,
|
|
505
576
|
};
|
|
@@ -522,6 +593,78 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
522
593
|
}
|
|
523
594
|
);
|
|
524
595
|
|
|
596
|
+
server.registerTool(
|
|
597
|
+
"bitgraph_open",
|
|
598
|
+
{
|
|
599
|
+
title: "Open a position before the work",
|
|
600
|
+
description:
|
|
601
|
+
"Ask for a BitGraph position BEFORE starting a task, so the task can be done inside the BitGraph. Returns a fuse_token, the position, its floor block, and the slot's commitment as a string. " +
|
|
602
|
+
"Put the commitment string into the task before running it (the prompt or request you send, a seed, a line in the document, text that must appear in the output), then call bitgraph_commit with the fuse_token and the task file's path within " + SLOT_TTL_SECONDS + " seconds. " +
|
|
603
|
+
"What that gives a stranger: the task could not have existed before the floor block (the commitment did not exist before the position did), and the outputs, recorded afterwards with bitgraph_record, sit at later positions. " +
|
|
604
|
+
"Nothing about the task is sent here; only its digest is, at commit. Positions are permanent: open one only when a task is about to run.",
|
|
605
|
+
inputSchema: {
|
|
606
|
+
response_format: responseFormatSchema,
|
|
607
|
+
},
|
|
608
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
609
|
+
},
|
|
610
|
+
async ({ response_format }) => {
|
|
611
|
+
const config = configFromEnv();
|
|
612
|
+
try {
|
|
613
|
+
const b = await beginTask(config);
|
|
614
|
+
const structured = { outcome: "opened", task: true, slot_counter: b.slotCounter, epoch: b.epoch, commitment: b.commitment, commitment_base64: b.commitmentB64, floor: b.floor, fuse_token: b.token, expires_in_seconds: SLOT_TTL_SECONDS, instructions: TASK_INSTRUCTIONS };
|
|
615
|
+
if (response_format === "json") return ok(JSON.stringify(structured, null, 2), structured);
|
|
616
|
+
return ok(
|
|
617
|
+
`Opened position ${b.slotCounter} before any work exists. Floor: ${b.floor ? `not before block ${b.floor.block}` : "the sealed proof will carry the signed floor"}.\n\n` +
|
|
618
|
+
`Commitment (put this string into the task): ${b.commitment}\n\n${TASK_INSTRUCTIONS}\n\n` +
|
|
619
|
+
"```json\n" + JSON.stringify({ fuse_token: b.token, commitment: b.commitment, slot_counter: b.slotCounter, epoch: b.epoch, floor: b.floor }, null, 2) + "\n```",
|
|
620
|
+
structured
|
|
621
|
+
);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return fail(errorText(err));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
server.registerTool(
|
|
629
|
+
"bitgraph_commit",
|
|
630
|
+
{
|
|
631
|
+
title: "Seal the task under its position",
|
|
632
|
+
description:
|
|
633
|
+
"Step two of doing work inside a BitGraph: seal the task bytes that carry the commitment under the position bitgraph_open returned. Give the fuse_token and the path of the task file (the exact request, prompt or document that contains the commitment string), or its SHA-256 (base64) when the bytes are elsewhere. " +
|
|
634
|
+
"With a path, the file is read on this machine, the commitment string is looked for inside it BEFORE anything is sent (a task that does not carry it is refused and the slot stays held), and only the digest travels. " +
|
|
635
|
+
"Returns the proof: keep it beside the task bytes. Then record the outputs with bitgraph_record. Must be called within " + SLOT_TTL_SECONDS + " seconds of bitgraph_open.",
|
|
636
|
+
inputSchema: {
|
|
637
|
+
fuse_token: z.string().min(1).max(8000).describe("The fuse_token bitgraph_open returned."),
|
|
638
|
+
path: z.string().min(1).optional().describe("Path of the task file that contains the commitment string."),
|
|
639
|
+
digest: z.string().min(1).max(100).optional().describe("Instead of a path: SHA-256 of the task bytes, base64 (either form)."),
|
|
640
|
+
response_format: responseFormatSchema,
|
|
641
|
+
},
|
|
642
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
643
|
+
},
|
|
644
|
+
async ({ fuse_token, path, digest, response_format }) => {
|
|
645
|
+
const config = configFromEnv();
|
|
646
|
+
const state = decodeTaskToken(fuse_token);
|
|
647
|
+
if (state === null) return fail("Error: fuse_token is not one issued by bitgraph_open.");
|
|
648
|
+
if (path === undefined && digest === undefined) return fail("Error: give the task file's path, or its SHA-256 digest.");
|
|
649
|
+
try {
|
|
650
|
+
const sealed = await sealTask(config, state, path !== undefined ? { path } : { digestB64: fromUrlSafeB64(digest!.trim()) });
|
|
651
|
+
const counter = sealed.proof.commit?.counter ?? null;
|
|
652
|
+
const epoch = sealed.proof.commit?.epochId ?? null;
|
|
653
|
+
const floor = sealed.proof.commit?.slotAnchor?.blockNumber ?? null;
|
|
654
|
+
const structured = { outcome: "sealed", slot_counter: state.slot.counter, counter, epoch: epoch ? toUrlSafeB64(epoch) : null, floor_block: floor, artifact_digest: toUrlSafeB64(sealed.artifactDigestB64), commitment_offsets: sealed.offsets, proof: sealed.proof };
|
|
655
|
+
if (response_format === "json") return ok(JSON.stringify(structured, null, 2), structured);
|
|
656
|
+
return ok(
|
|
657
|
+
`Sealed the task at position ${counter ?? "?"} (slot ${state.slot.counter}).${floor !== null ? ` Not before block ${floor}.` : ""} ` +
|
|
658
|
+
(sealed.offsets !== null ? `The commitment string was found in the task bytes at offset ${sealed.offsets[0]}. ` : "The task bytes were not read here; a verifier looks for the commitment string in them. ") +
|
|
659
|
+
"Keep the proof beside the task bytes, and record the outputs with bitgraph_record when they exist.\n\n```json\n" + JSON.stringify(sealed.proof, null, 2) + "\n```",
|
|
660
|
+
structured
|
|
661
|
+
);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
return fail(errorText(err));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
|
|
525
668
|
server.registerTool(
|
|
526
669
|
"bitgraph_check",
|
|
527
670
|
{
|
package/src/task.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A position BEFORE the work: the task form of the stdio server.
|
|
5
|
+
*
|
|
6
|
+
* bitgraph_open with no files asks the site's own /api/fuse/allocate for a
|
|
7
|
+
* slot and returns its commitment, and nothing else. The caller puts the
|
|
8
|
+
* commitment into the task it is about to run (the prompt, the request, a
|
|
9
|
+
* seed, a line in the document) and, within the slot's life, commits the
|
|
10
|
+
* digest of those task bytes under the slot with the inline marker
|
|
11
|
+
* (bitgraph-fuse/1, title base64url): a verifier recomputes the commitment
|
|
12
|
+
* from the slot record and finds its base64url text inside the bytes.
|
|
13
|
+
* Outputs are recorded afterwards with bitgraph_record and sit later.
|
|
14
|
+
*
|
|
15
|
+
* The slot allocation and the commit go through the same two site routes
|
|
16
|
+
* the core package's fuse() uses; the recovery rule is the core's (read back
|
|
17
|
+
* by digest, refuse a proof under any other slot).
|
|
18
|
+
*/
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { bytesToBase64, computeSlotCommitment, findCommitment, inlineAttribution, verifyProofIntegrity, ENCODING_BASE64URL } from "@mikeargento/bitgraph-verify";
|
|
22
|
+
import type { BitGraphProof, SlotAllocation } from "@mikeargento/bitgraph-verify";
|
|
23
|
+
import { ApiError, type ApiConfig } from "./api.js";
|
|
24
|
+
import { toUrlSafeB64 } from "./encoding.js";
|
|
25
|
+
|
|
26
|
+
export const SLOT_TTL_SECONDS = 120;
|
|
27
|
+
const CHAIN = "bitgraph:main";
|
|
28
|
+
const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
|
|
29
|
+
const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
|
|
30
|
+
|
|
31
|
+
export interface TaskState {
|
|
32
|
+
v: 1;
|
|
33
|
+
task: true;
|
|
34
|
+
slot: SlotAllocation;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isSlotRecord(x: unknown): x is SlotAllocation {
|
|
38
|
+
if (x === null || typeof x !== "object" || Array.isArray(x)) return false;
|
|
39
|
+
const s = x as Record<string, unknown>;
|
|
40
|
+
return s.version === "bitgraph/slot/1" && typeof s.nonceB64 === "string" && B64_32.test(s.nonceB64) && typeof s.counter === "string" && /^(0|[1-9][0-9]*)$/.test(s.counter)
|
|
41
|
+
&& typeof s.epochId === "string" && typeof s.publicKeyB64 === "string" && typeof s.signatureB64 === "string" && B64_64.test(s.signatureB64) && s.chainId === CHAIN;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeTaskToken(state: TaskState): string {
|
|
45
|
+
return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decodeTaskToken(token: string): TaskState | null {
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try { parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8")); } catch { return null; }
|
|
51
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
52
|
+
const s = parsed as Record<string, unknown>;
|
|
53
|
+
if (s.v !== 1 || s.task !== true || !isSlotRecord(s.slot)) return null;
|
|
54
|
+
return { v: 1, task: true, slot: s.slot };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function post(config: ApiConfig, path: string, body: unknown, timeoutMs: number): Promise<{ status: number; json: unknown; retryAfterSec: number | null }> {
|
|
58
|
+
const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
|
|
59
|
+
if (config.apiKey) headers["authorization"] = `Bearer ${config.apiKey}`;
|
|
60
|
+
const res = await fetch(`${config.baseUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
|
|
61
|
+
let json: unknown = null;
|
|
62
|
+
try { json = await res.json(); } catch { /* non-JSON */ }
|
|
63
|
+
const raw = res.headers.get("retry-after");
|
|
64
|
+
const retry = raw !== null ? Number.parseInt(raw, 10) : NaN;
|
|
65
|
+
return { status: res.status, json, retryAfterSec: Number.isFinite(retry) ? retry : null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const messageOf = (json: unknown, fallback: string): string => {
|
|
69
|
+
const e = (json as { error?: unknown } | null)?.error;
|
|
70
|
+
return typeof e === "string" ? e : fallback;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface Begun {
|
|
74
|
+
token: string;
|
|
75
|
+
/** Unpadded base64url: the string to put into the task. */
|
|
76
|
+
commitment: string;
|
|
77
|
+
commitmentB64: string;
|
|
78
|
+
slotCounter: string;
|
|
79
|
+
epoch: string;
|
|
80
|
+
floor: { block: number } | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Step one of the task form: a held slot and its commitment, before any work exists. */
|
|
84
|
+
export async function beginTask(config: ApiConfig): Promise<Begun> {
|
|
85
|
+
const r = await post(config, "/api/fuse/allocate", {}, 20_000);
|
|
86
|
+
if (r.status !== 200) throw new ApiError(r.status, messageOf(r.json, `allocation refused (${r.status})`), r.retryAfterSec);
|
|
87
|
+
const slot = (r.json as { slot?: unknown } | null)?.slot;
|
|
88
|
+
if (!isSlotRecord(slot)) throw new ApiError(502, "the allocation response is not a slot record on bitgraph:main");
|
|
89
|
+
const commitmentB64 = bytesToBase64(computeSlotCommitment(slot));
|
|
90
|
+
let floor: Begun["floor"] = null;
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${config.baseUrl}/api/proofs/anchors?counter=${encodeURIComponent(slot.counter)}&epoch=${encodeURIComponent(slot.epochId)}&before=1`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
|
|
93
|
+
const data = res.status === 200 ? ((await res.json()) as { anchors?: Array<{ commit?: { anchor?: { blockNumber?: number } } }> }) : null;
|
|
94
|
+
const b = data?.anchors?.[0]?.commit?.anchor?.blockNumber;
|
|
95
|
+
if (typeof b === "number") floor = { block: b };
|
|
96
|
+
} catch { floor = null; }
|
|
97
|
+
return { token: encodeTaskToken({ v: 1, task: true, slot }), commitment: toUrlSafeB64(commitmentB64), commitmentB64, slotCounter: slot.counter, epoch: toUrlSafeB64(slot.epochId), floor };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SealedTask {
|
|
101
|
+
proof: BitGraphProof;
|
|
102
|
+
artifactDigestB64: string;
|
|
103
|
+
/** Byte offsets of the commitment string inside the task bytes, when the bytes were given. */
|
|
104
|
+
offsets: number[] | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Step two: the task bytes (a path, or the bytes themselves) sealed under
|
|
109
|
+
* the held slot with the inline marker. When the bytes are in hand the
|
|
110
|
+
* commitment is looked for BEFORE the commit, so a task that does not carry
|
|
111
|
+
* it is refused without spending the slot.
|
|
112
|
+
*/
|
|
113
|
+
export async function sealTask(config: ApiConfig, state: TaskState, task: { path: string } | { bytes: Uint8Array } | { digestB64: string }): Promise<SealedTask> {
|
|
114
|
+
const { slot } = state;
|
|
115
|
+
const commitment = computeSlotCommitment(slot);
|
|
116
|
+
let bytes: Uint8Array | null = null;
|
|
117
|
+
let artifactDigestB64: string;
|
|
118
|
+
if ("digestB64" in task) {
|
|
119
|
+
if (!B64_32.test(task.digestB64)) throw new ApiError(400, "artifact digest must be a base64 SHA-256");
|
|
120
|
+
artifactDigestB64 = task.digestB64;
|
|
121
|
+
} else {
|
|
122
|
+
bytes = "path" in task ? new Uint8Array(await readFile(task.path)) : task.bytes;
|
|
123
|
+
artifactDigestB64 = createHash("sha256").update(bytes).digest("base64");
|
|
124
|
+
}
|
|
125
|
+
const offsets = bytes === null ? null : findCommitment(bytes, commitment, ENCODING_BASE64URL);
|
|
126
|
+
if (offsets !== null && offsets.length === 0) {
|
|
127
|
+
throw new ApiError(400, `the task bytes do not contain the commitment string ${toUrlSafeB64(bytesToBase64(commitment))}; put it in before sealing. Nothing was committed and the slot is still held.`);
|
|
128
|
+
}
|
|
129
|
+
const attribution = inlineAttribution();
|
|
130
|
+
const r = await post(config, "/api/fuse/commit", { digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }], slotId: slot.nonceB64, slot, chainId: CHAIN, attribution }, 40_000);
|
|
131
|
+
let proof: BitGraphProof | null = null;
|
|
132
|
+
if (r.status === 200) proof = ((r.json as { proof?: BitGraphProof } | null)?.proof ?? null);
|
|
133
|
+
else if (r.status === 409 || r.status === 503) proof = await recover(config, artifactDigestB64, slot);
|
|
134
|
+
if (proof === null) throw new ApiError(r.status, messageOf(r.json, `commit refused (${r.status})`), r.retryAfterSec);
|
|
135
|
+
if (proof.artifact?.digestB64 !== artifactDigestB64 || proof.commit?.nonceB64 !== slot.nonceB64 || proof.slotAllocation?.nonceB64 !== slot.nonceB64) throw new ApiError(502, "the boundary returned a proof under a different slot; nothing is labelled sealed");
|
|
136
|
+
const a = proof.attribution;
|
|
137
|
+
if (a?.name !== attribution.name || a?.title !== attribution.title || a?.message !== undefined) throw new ApiError(502, "the returned proof does not carry the inline marker that was sent; nothing is labelled sealed");
|
|
138
|
+
const integrity = await verifyProofIntegrity({ proof });
|
|
139
|
+
if (!integrity.valid) throw new ApiError(502, `the returned proof does not verify: ${integrity.reason ?? "unknown reason"}`);
|
|
140
|
+
return { proof, artifactDigestB64, offsets };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function recover(config: ApiConfig, artifactDigestB64: string, slot: SlotAllocation): Promise<BitGraphProof | null> {
|
|
144
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
145
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, 1500));
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`${config.baseUrl}/api/proofs/${encodeURIComponent(toUrlSafeB64(artifactDigestB64))}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
|
|
148
|
+
if (res.status !== 200) continue;
|
|
149
|
+
const j = (await res.json()) as { proofs?: Array<{ proof?: BitGraphProof }> } | null;
|
|
150
|
+
for (const e of j?.proofs ?? []) {
|
|
151
|
+
const p = e.proof;
|
|
152
|
+
if (p && p.artifact?.digestB64 === artifactDigestB64 && p.commit?.nonceB64 === slot.nonceB64) return p;
|
|
153
|
+
}
|
|
154
|
+
} catch { /* try again */ }
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const TASK_INSTRUCTIONS =
|
|
160
|
+
"You hold a position and its commitment, and no work exists yet. Put the commitment string into the task before you run it: in the prompt or request you are about to send, as a seed, as a line in the document, as text that must appear in the output. " +
|
|
161
|
+
`Then call bitgraph_commit with this fuse_token and the path of the task file (or the SHA-256 of its bytes), within ${SLOT_TTL_SECONDS} seconds of opening: that seals the task under the position before its output exists. ` +
|
|
162
|
+
"Keep those exact bytes: a verifier recomputes the commitment from the proof and looks for the string inside them. " +
|
|
163
|
+
"When the output exists, record it with bitgraph_record; it will sit at a later position. What a stranger can then check: the task could not have existed before the position's floor block, and the output was recorded after it.";
|