@mikeargento/bitgraph-mcp 0.4.1 → 0.5.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/__tests__/format.test.js +3 -3
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/proof-beside.test.d.ts +2 -0
- package/dist/__tests__/proof-beside.test.d.ts.map +1 -0
- package/dist/__tests__/proof-beside.test.js +25 -0
- package/dist/__tests__/proof-beside.test.js.map +1 -0
- package/dist/__tests__/tools.test.js +4 -4
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/format.js +4 -4
- package/dist/format.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +87 -6
- package/dist/server.js.map +1 -1
- package/dist/task.d.ts +55 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +182 -0
- package/dist/task.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/format.test.ts +3 -3
- package/src/__tests__/proof-beside.test.ts +25 -0
- package/src/__tests__/tools.test.ts +4 -4
- package/src/format.ts +4 -4
- package/src/server.ts +97 -6
- package/src/task.ts +187 -0
package/src/server.ts
CHANGED
|
@@ -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, writeProofBeside } 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.1";
|
|
55
56
|
|
|
56
57
|
const SCAN_CONCURRENCY = 4;
|
|
57
58
|
/** Paths per call; a directory counts once and expands to its files. */
|
|
@@ -332,7 +333,8 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
332
333
|
{
|
|
333
334
|
instructions:
|
|
334
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. " +
|
|
335
|
-
"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."
|
|
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.",
|
|
336
338
|
}
|
|
337
339
|
);
|
|
338
340
|
|
|
@@ -345,9 +347,9 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
345
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. " +
|
|
346
348
|
"Files are never modified and never uploaded: only digests, the committed artifact and slot records leave the machine. " +
|
|
347
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. " +
|
|
348
|
-
"Files
|
|
349
|
-
"
|
|
350
|
-
"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).
|
|
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. " +
|
|
351
353
|
"Use bitgraph_check instead when the user only wants to know whether files are on record.",
|
|
352
354
|
inputSchema: {
|
|
353
355
|
paths: z
|
|
@@ -359,7 +361,7 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
359
361
|
.boolean()
|
|
360
362
|
.default(false)
|
|
361
363
|
.describe(
|
|
362
|
-
"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."
|
|
363
365
|
),
|
|
364
366
|
response_format: responseFormatSchema,
|
|
365
367
|
},
|
|
@@ -591,6 +593,95 @@ export function buildServer(deps: ServerDeps = {}): McpServer {
|
|
|
591
593
|
}
|
|
592
594
|
);
|
|
593
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
|
+
/* The proof goes beside the sealed file, written here, whole. Given
|
|
655
|
+
* only a digest there is no file to put it beside, so it is handed
|
|
656
|
+
* back with the one instruction that matters. */
|
|
657
|
+
let proofPath: string | null = null;
|
|
658
|
+
let proofNote = "";
|
|
659
|
+
if (path !== undefined) {
|
|
660
|
+
try {
|
|
661
|
+
proofPath = await writeProofBeside(path, sealed.proof);
|
|
662
|
+
proofNote = `The proof is written at ${proofPath}, whole; leave it as written. `;
|
|
663
|
+
} catch (err) {
|
|
664
|
+
proofNote = `The proof could not be written beside the file (${errorText(err)}); save the JSON below whole and unedited, every field. `;
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
proofNote = "Save the JSON below as a file beside the task bytes, whole and unedited, every field: a proof missing slotAllocation or environment cannot be verified. ";
|
|
668
|
+
}
|
|
669
|
+
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_path: proofPath, instructions: proofNote.trim(), proof: sealed.proof };
|
|
670
|
+
if (response_format === "json") return ok(JSON.stringify(structured, null, 2), structured);
|
|
671
|
+
return ok(
|
|
672
|
+
`Sealed the task at position ${counter ?? "?"} (slot ${state.slot.counter}).${floor !== null ? ` Not before block ${floor}.` : ""} ` +
|
|
673
|
+
(sealed.offsets !== null ? `The commitment string was found in the sealed bytes at offset ${sealed.offsets[0]}. ` : "The bytes were not read here; a verifier looks for the commitment string in them. ") +
|
|
674
|
+
proofNote +
|
|
675
|
+
"Record the outputs with bitgraph_record when they exist." +
|
|
676
|
+
(proofPath === null ? "\n\n```json\n" + JSON.stringify(sealed.proof, null, 2) + "\n```" : ""),
|
|
677
|
+
structured
|
|
678
|
+
);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
return fail(errorText(err));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
|
|
594
685
|
server.registerTool(
|
|
595
686
|
"bitgraph_check",
|
|
596
687
|
{
|
package/src/task.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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 INSIDE THE OUTPUT you are about to make whenever its format can hold text (SVG, HTML, code, JSON, Markdown, CSV, a document): as a comment, a field, a line that stays in the file. " +
|
|
161
|
+
`When the output exists, call bitgraph_commit with this fuse_token and the output file's path, within ${SLOT_TTL_SECONDS} seconds of opening: the output itself is sealed under the position, and the proof is written beside it. ` +
|
|
162
|
+
"If the output cannot hold text (a PNG, audio, video) or will take longer than that to make, put the commitment into a task file instead (the exact prompt or request you send), seal that file the same way, and when the output exists record it with bitgraph_record: the task is sealed before the output existed, and the output is recorded after. " +
|
|
163
|
+
"Keep the exact bytes that were sealed unchanged: a verifier recomputes the commitment from the proof and looks for the string inside them. " +
|
|
164
|
+
"Leave the proof file as written, whole, every field: a proof missing slotAllocation or environment cannot be verified.";
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Write a sealed task's proof beside the file it is about, whole, and say
|
|
168
|
+
* where. `make_task.txt` gets `make_task.proof.json`; a name already taken
|
|
169
|
+
* gets the position's counter in it rather than being overwritten.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ THE SERVER WRITES IT, NOT THE AGENT. Handed the JSON to save, an agent
|
|
172
|
+
* tidied it and dropped slotAllocation and environment, and the signature
|
|
173
|
+
* had no body left to be checked against (Grok, 2026-09-13). A file this
|
|
174
|
+
* process writes is the proof exactly as the boundary returned it.
|
|
175
|
+
*/
|
|
176
|
+
export async function writeProofBeside(sealedPath: string, proof: BitGraphProof): Promise<string> {
|
|
177
|
+
const { writeFile, access } = await import("node:fs/promises");
|
|
178
|
+
const { basename, dirname, extname, join } = await import("node:path");
|
|
179
|
+
const dir = dirname(sealedPath);
|
|
180
|
+
const base = basename(sealedPath);
|
|
181
|
+
const stem = extname(base) === "" ? base : base.slice(0, -extname(base).length);
|
|
182
|
+
const counter = proof.commit?.counter ?? "0";
|
|
183
|
+
let target = join(dir, `${stem}.proof.json`);
|
|
184
|
+
if (await access(target).then(() => true).catch(() => false)) target = join(dir, `${stem}.proof.${counter}.json`);
|
|
185
|
+
await writeFile(target, JSON.stringify(proof, null, 2) + "\n", { flag: "wx" });
|
|
186
|
+
return target;
|
|
187
|
+
}
|