@mikeargento/bitgraph-mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +287 -84
- 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 +64 -29
- package/dist/format.js.map +1 -1
- 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 +51 -9
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +336 -113
- 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 +302 -97
- package/src/api.ts +36 -77
- package/src/format.ts +104 -37
- package/src/scan.ts +173 -0
- package/src/server.ts +410 -120
- package/src/types.ts +23 -1
package/src/api.ts
CHANGED
|
@@ -3,17 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @mikeargento/bitgraph-mcp: HTTP client for the hosted BitGraph API.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The reads: the batch lookup, the proof detail, the number search. Making a
|
|
7
|
+
* BitGraph goes through the core package's own transport against the same
|
|
8
|
+
* site (its /api/fuse routes), which is what maintains the per-position
|
|
9
|
+
* by-digest index that makes every member of a set discoverable afterward;
|
|
10
|
+
* the one write here is a set/2's member evidence, indexed after the commit.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
BitGraphProof,
|
|
14
|
-
ProofDetailResponse,
|
|
15
|
-
SearchResponse,
|
|
16
|
-
} from "./types.js";
|
|
13
|
+
import { mapConcurrent } from "./encoding.js";
|
|
14
|
+
import type { BatchCheckResponse, ProofDetailResponse, SearchResponse, SetIndexResponse } from "./types.js";
|
|
17
15
|
|
|
18
16
|
export interface ApiConfig {
|
|
19
17
|
baseUrl: string;
|
|
@@ -27,13 +25,13 @@ export function configFromEnv(): ApiConfig {
|
|
|
27
25
|
return apiKey ? { baseUrl, apiKey } : { baseUrl };
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
/** Matches the website client: 50 digests per commit request (~1s of TEE work each). */
|
|
31
|
-
export const COMMIT_CHUNK_SIZE = 50;
|
|
32
28
|
/** Matches the batch endpoint's MAX_DIGESTS. */
|
|
33
29
|
export const BATCH_CHECK_LIMIT = 500;
|
|
30
|
+
/** Batch lookups in flight at once: a large folder is many pages. */
|
|
31
|
+
const BATCH_CHECK_CONCURRENCY = 4;
|
|
34
32
|
|
|
35
33
|
const CHECK_TIMEOUT_MS = 30_000;
|
|
36
|
-
const
|
|
34
|
+
const SET_INDEX_TIMEOUT_MS = 60_000;
|
|
37
35
|
|
|
38
36
|
export class ApiError extends Error {
|
|
39
37
|
readonly status: number;
|
|
@@ -104,83 +102,44 @@ async function postJson<T>(
|
|
|
104
102
|
|
|
105
103
|
/**
|
|
106
104
|
* Look up which digests are on record. Input digests must be URL-safe base64;
|
|
107
|
-
* the response is keyed by the exact strings sent.
|
|
105
|
+
* the response is keyed by the exact strings sent. Pages of up to 500, a few
|
|
106
|
+
* in flight at once.
|
|
108
107
|
*/
|
|
109
108
|
export async function batchCheck(
|
|
110
109
|
config: ApiConfig,
|
|
111
110
|
urlSafeDigests: readonly string[]
|
|
112
111
|
): Promise<BatchCheckResponse> {
|
|
113
|
-
const
|
|
112
|
+
const chunks: string[][] = [];
|
|
114
113
|
for (let offset = 0; offset < urlSafeDigests.length; offset += BATCH_CHECK_LIMIT) {
|
|
115
|
-
|
|
116
|
-
const page = await postJson<BatchCheckResponse>(
|
|
117
|
-
config,
|
|
118
|
-
"/api/proofs/batch",
|
|
119
|
-
{ digests: chunk },
|
|
120
|
-
CHECK_TIMEOUT_MS,
|
|
121
|
-
false
|
|
122
|
-
);
|
|
123
|
-
Object.assign(merged.results, page.results);
|
|
114
|
+
chunks.push(urlSafeDigests.slice(offset, offset + BATCH_CHECK_LIMIT));
|
|
124
115
|
}
|
|
116
|
+
const pages = await mapConcurrent(chunks, BATCH_CHECK_CONCURRENCY, (chunk) =>
|
|
117
|
+
postJson<BatchCheckResponse>(config, "/api/proofs/batch", { digests: chunk }, CHECK_TIMEOUT_MS, false)
|
|
118
|
+
);
|
|
119
|
+
const merged: BatchCheckResponse = { results: {} };
|
|
120
|
+
for (const page of pages) Object.assign(merged.results, page.results);
|
|
125
121
|
return merged;
|
|
126
122
|
}
|
|
127
123
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
export async function commitDigests(
|
|
138
|
-
config: ApiConfig,
|
|
139
|
-
standardDigests: readonly string[],
|
|
140
|
-
attribution?: { name?: string | undefined; title?: string | undefined; message?: string | undefined }
|
|
141
|
-
): Promise<BitGraphProof[]> {
|
|
142
|
-
const proofs: BitGraphProof[] = [];
|
|
143
|
-
for (let offset = 0; offset < standardDigests.length; offset += COMMIT_CHUNK_SIZE) {
|
|
144
|
-
const chunk = standardDigests.slice(offset, offset + COMMIT_CHUNK_SIZE);
|
|
145
|
-
const body: Record<string, unknown> = {
|
|
146
|
-
digests: chunk.map((digestB64) => ({ digestB64, hashAlg: "sha256" })),
|
|
147
|
-
chainId: "bitgraph:main",
|
|
148
|
-
};
|
|
149
|
-
if (attribution) body["attribution"] = attribution;
|
|
150
|
-
try {
|
|
151
|
-
const raw = await postJson<BitGraphProof[] | BitGraphProof>(
|
|
152
|
-
config,
|
|
153
|
-
"/api/commit",
|
|
154
|
-
body,
|
|
155
|
-
COMMIT_TIMEOUT_MS,
|
|
156
|
-
true
|
|
157
|
-
);
|
|
158
|
-
proofs.push(...(Array.isArray(raw) ? raw : [raw]));
|
|
159
|
-
} catch (err) {
|
|
160
|
-
throw new PartialCommitError(proofs, standardDigests.length, err);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return proofs;
|
|
124
|
+
/** One request's worth of a set/2's member evidence, for the site to index. */
|
|
125
|
+
export interface SetIndexRequest {
|
|
126
|
+
/** The set proof's artifact digest, URL-safe base64. */
|
|
127
|
+
setDigest: string;
|
|
128
|
+
/** The set proof's epoch id, URL-safe base64. */
|
|
129
|
+
epoch: string;
|
|
130
|
+
/** The set proof's commit counter. */
|
|
131
|
+
counter: string;
|
|
132
|
+
members: unknown[];
|
|
164
133
|
}
|
|
165
134
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
super(
|
|
175
|
-
`Recording stopped after ${minted.length} of ${requested} digests: ${reason}` +
|
|
176
|
-
(cause instanceof ApiError && cause.retryAfterSec !== null
|
|
177
|
-
? ` (retry after ${cause.retryAfterSec}s)`
|
|
178
|
-
: "")
|
|
179
|
-
);
|
|
180
|
-
this.minted = minted;
|
|
181
|
-
this.requested = requested;
|
|
182
|
-
this.cause2 = cause;
|
|
183
|
-
}
|
|
135
|
+
/**
|
|
136
|
+
* Index members of a set/2 from their evidence. The site reads the set proof
|
|
137
|
+
* from its own position, binds the root document, and checks every member's
|
|
138
|
+
* path before writing a key; a row that does not bind is rejected, never
|
|
139
|
+
* written.
|
|
140
|
+
*/
|
|
141
|
+
export async function indexSetMembers(config: ApiConfig, body: SetIndexRequest): Promise<SetIndexResponse> {
|
|
142
|
+
return postJson<SetIndexResponse>(config, "/api/fuse/set-index", body, SET_INDEX_TIMEOUT_MS, false);
|
|
184
143
|
}
|
|
185
144
|
|
|
186
145
|
/** Full detail for one digest: proof, all causal positions, anchor window. */
|
package/src/format.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { toUrlSafeB64 } from "./encoding.js";
|
|
12
|
-
import type { BitGraphProof, PositionView, ProofDetailResponse } from "./types.js";
|
|
12
|
+
import type { BitGraphProof, PositionView, ProofDetailResponse, SetMemberView } from "./types.js";
|
|
13
13
|
|
|
14
14
|
export const CHARACTER_LIMIT = 25_000;
|
|
15
|
+
/** Rows per group a markdown summary lists before "and N more". */
|
|
16
|
+
export const MARKDOWN_ROWS = 50;
|
|
15
17
|
|
|
16
18
|
/** Public proof page URL for a digest, optionally pinned to one causal position. */
|
|
17
19
|
export function proofUrl(
|
|
@@ -31,32 +33,54 @@ export function proofUrl(
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* One outcome per path, in the product's own vocabulary. "fused":
|
|
35
|
-
*
|
|
36
|
-
* record": the bytes already had a recording or a
|
|
37
|
-
* as origin, and nothing was
|
|
38
|
-
* claim "on record" for
|
|
36
|
+
* One outcome per path, in the product's own vocabulary. "fused": the file is
|
|
37
|
+
* a member of the set just made, its new fused bytes listed by digest in the
|
|
38
|
+
* committed artifact. "on record": the bytes already had a recording or a
|
|
39
|
+
* fused artifact naming them as origin, and nothing was made. "not fused":
|
|
40
|
+
* the attempt failed or the file was left out; never claim "on record" for
|
|
41
|
+
* bytes that have no proof.
|
|
39
42
|
*/
|
|
40
43
|
export interface RecordOutcome {
|
|
41
44
|
path: string;
|
|
42
|
-
/** The file's own digest (URL-safe): the origin of
|
|
45
|
+
/** The file's own digest (URL-safe): the origin of its fused bytes. */
|
|
43
46
|
digest: string;
|
|
44
47
|
outcome: "fused" | "on record" | "not fused";
|
|
45
|
-
/** The
|
|
48
|
+
/** The member's fused digest (URL-safe), present on a "fused" outcome. */
|
|
46
49
|
artifact_digest: string | null;
|
|
47
50
|
placement: string | null;
|
|
48
51
|
counter: string | null;
|
|
49
52
|
epoch: string | null; // URL-safe
|
|
53
|
+
/** The file's row in the set just made, 1-based, of member_count. */
|
|
54
|
+
member: number | null;
|
|
55
|
+
member_count: number | null;
|
|
50
56
|
total_positions: number;
|
|
51
57
|
proof_url: string | null;
|
|
52
58
|
error?: string;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** The one BitGraph a record call makes: a set, one position for every fused row. */
|
|
62
|
+
export interface SetOutcome {
|
|
63
|
+
/** "set/1": the committed artifact lists every member. "set/2": it is a Merkle root over the rows, and each member's evidence is indexed on the site. */
|
|
64
|
+
set: "set/1" | "set/2";
|
|
65
|
+
count: number;
|
|
66
|
+
counter: string | null;
|
|
67
|
+
epoch: string | null; // URL-safe
|
|
68
|
+
/** The committed artifact's digest (URL-safe): the manifest or the root document. */
|
|
69
|
+
artifact_digest: string;
|
|
70
|
+
proof_url: string;
|
|
71
|
+
/** True when the boundary echoed the committed artifact in the proof's metadata, so the ledger's copy carries it. */
|
|
72
|
+
manifest_echoed: boolean;
|
|
73
|
+
/** True when the commit response was lost and the proof was read back by digest. */
|
|
74
|
+
recovered: boolean;
|
|
75
|
+
/** set/2 only: members whose evidence the site has indexed, and members still waiting. */
|
|
76
|
+
index: { written: number; pending: number } | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
export interface CheckOutcome {
|
|
56
80
|
input: string;
|
|
57
81
|
digest: string; // URL-safe
|
|
58
82
|
on_record: boolean;
|
|
59
|
-
positions: Array<{ counter: string | null; epoch: string | null }>;
|
|
83
|
+
positions: Array<{ counter: string | null; epoch: string | null; member?: SetMemberView }>;
|
|
60
84
|
proof_url: string | null;
|
|
61
85
|
}
|
|
62
86
|
|
|
@@ -66,56 +90,94 @@ export function positionOf(proof: BitGraphProof): { counter: string | null; epoc
|
|
|
66
90
|
return { counter, epoch: epochId !== undefined ? toUrlSafeB64(epochId) : null };
|
|
67
91
|
}
|
|
68
92
|
|
|
69
|
-
|
|
93
|
+
const fmt = (n: number): string => n.toLocaleString("en-US");
|
|
94
|
+
|
|
95
|
+
function memberNote(m: SetMemberView | undefined): string {
|
|
96
|
+
return m ? ` (member ${fmt(m.index + 1)} of ${fmt(m.count)})` : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: SetOutcome | null = null, omitted = 0): string {
|
|
70
100
|
const fused = outcomes.filter((o) => o.outcome === "fused");
|
|
71
101
|
const onRecord = outcomes.filter((o) => o.outcome === "on record");
|
|
72
102
|
const notFused = outcomes.filter((o) => o.outcome === "not fused");
|
|
73
103
|
const lines: string[] = [];
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
if (set !== null && fused.length > 0) {
|
|
106
|
+
parts.push(`${fmt(fused.length)} file${fused.length === 1 ? "" : "s"} BitGraphed as one set at #${set.counter ?? "?"} (set of ${fmt(set.count)})`);
|
|
107
|
+
} else {
|
|
108
|
+
parts.push(`${fmt(fused.length)} fused`);
|
|
77
109
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
parts.push(`${fmt(onRecord.length)} already on record`);
|
|
111
|
+
if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
|
|
112
|
+
lines.push(`${parts.join(", ")}.`);
|
|
113
|
+
if (set !== null && fused.length > 0) {
|
|
114
|
+
lines.push(`- #${set.counter ?? "?"} · set of ${fmt(set.count)} · ${set.proof_url}`);
|
|
115
|
+
if (set.index !== null && set.index.pending > 0) {
|
|
116
|
+
lines.push(` The set is on the ledger, but the evidence for ${fmt(set.index.pending)} of its ${fmt(set.count)} members is not indexed yet, so those files are not findable by hash until it is. It is sent again at the start of the next bitgraph_record call.`);
|
|
117
|
+
}
|
|
118
|
+
if (!set.manifest_echoed) {
|
|
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.`);
|
|
83
120
|
}
|
|
84
|
-
const note =
|
|
85
|
-
o.outcome === "on record"
|
|
86
|
-
? o.total_positions > 1
|
|
87
|
-
? ` (${o.total_positions} positions, earliest shown)`
|
|
88
|
-
: ""
|
|
89
|
-
: o.placement
|
|
90
|
-
? ` (${o.placement})`
|
|
91
|
-
: "";
|
|
92
|
-
lines.push(`- ${o.outcome} · #${o.counter ?? "?"} · ${o.path}${note}\n ${o.proof_url}`);
|
|
93
121
|
}
|
|
94
|
-
|
|
122
|
+
const group = (rows: readonly RecordOutcome[], render: (o: RecordOutcome) => string, more: (n: number) => string) => {
|
|
123
|
+
for (const o of rows.slice(0, MARKDOWN_ROWS)) lines.push(render(o));
|
|
124
|
+
if (rows.length > MARKDOWN_ROWS) lines.push(`- ${more(rows.length - MARKDOWN_ROWS)}`);
|
|
125
|
+
};
|
|
126
|
+
group(
|
|
127
|
+
notFused,
|
|
128
|
+
(o) => `- not fused · ${o.path}${o.error ? `: ${o.error}` : ""}`,
|
|
129
|
+
(n) => `and ${fmt(n)} more not fused`
|
|
130
|
+
);
|
|
131
|
+
group(
|
|
132
|
+
onRecord,
|
|
133
|
+
(o) => {
|
|
134
|
+
const note = o.total_positions > 1 ? ` (${fmt(o.total_positions)} positions, earliest shown)` : "";
|
|
135
|
+
const row = o.member !== null && o.member_count !== null ? ` (member ${fmt(o.member)} of ${fmt(o.member_count)})` : "";
|
|
136
|
+
return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}\n ${o.proof_url}`;
|
|
137
|
+
},
|
|
138
|
+
(n) => `and ${fmt(n)} more already on record`
|
|
139
|
+
);
|
|
140
|
+
group(
|
|
141
|
+
fused,
|
|
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 ?? "?"})`,
|
|
146
|
+
(n) => `and ${fmt(n)} more files in the same set`
|
|
147
|
+
);
|
|
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. A lookup by any file's own digest finds the set."
|
|
151
|
+
);
|
|
152
|
+
} else if (fused.length > 0) {
|
|
95
153
|
lines.push(
|
|
96
|
-
"\
|
|
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."
|
|
97
155
|
);
|
|
98
156
|
}
|
|
99
157
|
if (onRecord.length > 0) {
|
|
100
158
|
lines.push(
|
|
101
|
-
"\nFiles already on record were left alone. To make a new
|
|
159
|
+
"\nFiles already on record were left alone. To make a new BitGraph of them deliberately, call bitgraph_record with again=true."
|
|
102
160
|
);
|
|
103
161
|
}
|
|
162
|
+
if (omitted > 0) {
|
|
163
|
+
lines.push(`\nThe structured result lists the first rows only (${fmt(omitted)} omitted); every fused file shares the set's position above.`);
|
|
164
|
+
}
|
|
104
165
|
return lines.join("\n");
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
|
|
108
169
|
const found = outcomes.filter((o) => o.on_record).length;
|
|
109
|
-
const lines: string[] = [`${found} of ${outcomes.length} on record.`];
|
|
110
|
-
for (const o of outcomes) {
|
|
170
|
+
const lines: string[] = [`${fmt(found)} of ${fmt(outcomes.length)} on record.`];
|
|
171
|
+
for (const o of outcomes.slice(0, MARKDOWN_ROWS)) {
|
|
111
172
|
if (o.on_record) {
|
|
112
173
|
const first = o.positions[0];
|
|
113
|
-
const extra = o.positions.length > 1 ? ` and ${o.positions.length - 1} more position(s)` : "";
|
|
114
|
-
lines.push(`- on record · #${first?.counter ?? "?"}${extra} · ${o.input}\n ${o.proof_url}`);
|
|
174
|
+
const extra = o.positions.length > 1 ? ` and ${fmt(o.positions.length - 1)} more position(s)` : "";
|
|
175
|
+
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}\n ${o.proof_url}`);
|
|
115
176
|
} else {
|
|
116
177
|
lines.push(`- not on record · ${o.input}`);
|
|
117
178
|
}
|
|
118
179
|
}
|
|
180
|
+
if (outcomes.length > MARKDOWN_ROWS) lines.push(`- and ${fmt(outcomes.length - MARKDOWN_ROWS)} more; the structured result lists every one`);
|
|
119
181
|
return lines.join("\n");
|
|
120
182
|
}
|
|
121
183
|
|
|
@@ -142,11 +204,17 @@ export function renderProofMarkdown(
|
|
|
142
204
|
if (!proof) return "No proof found for that digest.";
|
|
143
205
|
const digest = proof.artifact?.digestB64 ?? "";
|
|
144
206
|
const { counter, epoch } = positionOf(proof);
|
|
207
|
+
const positions: PositionView[] = detail.positions ?? [];
|
|
208
|
+
const here = positions.find((p) => p.counter === counter) ?? positions[0];
|
|
145
209
|
const lines: string[] = [];
|
|
146
210
|
lines.push(`# BitGraph #${counter ?? "?"}`);
|
|
147
211
|
lines.push("");
|
|
148
212
|
lines.push(`- Digest (SHA-256): ${toUrlSafeB64(digest)}`);
|
|
149
213
|
if (epoch) lines.push(`- Position: counter ${counter ?? "?"} in epoch ${epoch.slice(0, 12)}…`);
|
|
214
|
+
if (here?.member) {
|
|
215
|
+
const role = here.member.role === "fused" ? "its new fused bytes" : "the original";
|
|
216
|
+
lines.push(`- Set: member ${fmt(here.member.index + 1)} of ${fmt(here.member.count)}, as ${role}${here.placement ? ` (${here.placement})` : ""}`);
|
|
217
|
+
}
|
|
150
218
|
const window = renderWindow(detail);
|
|
151
219
|
if (window) lines.push(`- ${window}`);
|
|
152
220
|
const etherscan =
|
|
@@ -162,15 +230,14 @@ export function renderProofMarkdown(
|
|
|
162
230
|
.join(": ");
|
|
163
231
|
lines.push(`- Submitter's note (self-attributed, not verified): ${note}`);
|
|
164
232
|
}
|
|
165
|
-
const positions: PositionView[] = detail.positions ?? [];
|
|
166
233
|
if (positions.length > 1) {
|
|
167
234
|
lines.push("");
|
|
168
|
-
lines.push(`## Causal positions (${positions.length})`);
|
|
235
|
+
lines.push(`## Causal positions (${fmt(positions.length)})`);
|
|
169
236
|
positions.forEach((p, i) => {
|
|
170
237
|
const label = i === 0 ? " · original" : "";
|
|
171
238
|
const bracket =
|
|
172
239
|
p.lowerTime && p.upperTime ? ` · between ${p.lowerTime} and ${p.upperTime}` : "";
|
|
173
|
-
lines.push(`- #${p.counter ?? "?"}${label}${bracket}`);
|
|
240
|
+
lines.push(`- #${p.counter ?? "?"}${label}${p.member ? ` · set of ${fmt(p.member.count)}` : ""}${bracket}`);
|
|
174
241
|
});
|
|
175
242
|
}
|
|
176
243
|
lines.push("");
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @mikeargento/bitgraph-mcp: the scan.
|
|
5
|
+
*
|
|
6
|
+
* One pass over each file yields its SHA-256 (the member's origin) and a
|
|
7
|
+
* hasher left open after the placement's prefix and the file's last byte, so
|
|
8
|
+
* the member's fused digest can be finished later, for whatever slot the set
|
|
9
|
+
* is made under, without reading the file again. A placement is prefix,
|
|
10
|
+
* original, suffix (its frame): trailer/1 has no prefix and container/2's is
|
|
11
|
+
* the original's tar header, which depends on the size alone; the suffix
|
|
12
|
+
* carries the commitment and is hashed at BitGraph time. Node's SHA-256 is
|
|
13
|
+
* the platform's own, and a Hash can be copied mid-stream, which is the whole
|
|
14
|
+
* trick: the copy takes the suffix, so no file is read twice or held in
|
|
15
|
+
* memory, and the original is never touched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash, type Hash } from "node:crypto";
|
|
19
|
+
import { createReadStream } from "node:fs";
|
|
20
|
+
import { readdir, stat } from "node:fs/promises";
|
|
21
|
+
import { basename, join } from "node:path";
|
|
22
|
+
import { placementForBytes } from "@mikeargento/bitgraph";
|
|
23
|
+
import { getPlacement } from "@mikeargento/bitgraph-verify";
|
|
24
|
+
|
|
25
|
+
/** The placements the scan makes: chosen from the bytes by the core, never from the name. */
|
|
26
|
+
export type ScanPlacement = "trailer/1" | "container/2";
|
|
27
|
+
|
|
28
|
+
export interface ScannedFile {
|
|
29
|
+
path: string;
|
|
30
|
+
name: string;
|
|
31
|
+
size: number;
|
|
32
|
+
/** SHA-256 of the file, standard base64: the member's origin. */
|
|
33
|
+
digestB64: string;
|
|
34
|
+
originDigest: Uint8Array;
|
|
35
|
+
placement: ScanPlacement;
|
|
36
|
+
/**
|
|
37
|
+
* The hasher after the placement's prefix and the file's bytes, still open.
|
|
38
|
+
* Null when the file's length changed while it was read: its fused digest
|
|
39
|
+
* then needs the bytes again.
|
|
40
|
+
*/
|
|
41
|
+
state: Hash | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bytes the placement decision reads: every magic number sits in the first 16. */
|
|
45
|
+
const SNIFF = 64;
|
|
46
|
+
|
|
47
|
+
/** Hash one file in a single pass. Throws, before any network call, when the path is not a regular file. */
|
|
48
|
+
export async function scanFile(path: string): Promise<ScannedFile> {
|
|
49
|
+
const info = await stat(path);
|
|
50
|
+
if (!info.isFile()) throw new Error(`Not a regular file: ${path}`);
|
|
51
|
+
const size = info.size;
|
|
52
|
+
const origin = createHash("sha256");
|
|
53
|
+
// Held in one object: the closures below assign to it.
|
|
54
|
+
const run: { fused: Hash | null; placement: ScanPlacement | null } = { fused: null, placement: null };
|
|
55
|
+
let head: Buffer | null = null;
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
const start = (p: ScanPlacement) => {
|
|
58
|
+
run.placement = p;
|
|
59
|
+
// A second hasher runs over prefix and bytes when the prefix is not
|
|
60
|
+
// empty; with an empty prefix the origin hasher's own state is the fused state.
|
|
61
|
+
const prefix = getPlacement(p)?.scanPrefix?.(size) ?? null;
|
|
62
|
+
if (prefix !== null && prefix.length > 0) run.fused = createHash("sha256").update(prefix);
|
|
63
|
+
};
|
|
64
|
+
const feed = (chunk: Buffer) => {
|
|
65
|
+
origin.update(chunk);
|
|
66
|
+
if (run.fused !== null) run.fused.update(chunk);
|
|
67
|
+
};
|
|
68
|
+
for await (const raw of createReadStream(path)) {
|
|
69
|
+
const chunk = raw as Buffer;
|
|
70
|
+
if (run.placement === null) {
|
|
71
|
+
head = head === null ? chunk : Buffer.concat([head, chunk]);
|
|
72
|
+
if (head.length >= SNIFF) {
|
|
73
|
+
start(placementForBytes(new Uint8Array(head.subarray(0, SNIFF))));
|
|
74
|
+
feed(head);
|
|
75
|
+
head = null;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
feed(chunk);
|
|
79
|
+
}
|
|
80
|
+
bytes += chunk.length;
|
|
81
|
+
}
|
|
82
|
+
if (run.placement === null) {
|
|
83
|
+
// A short file: decide from what there is, then hash it.
|
|
84
|
+
start(placementForBytes(new Uint8Array(head ?? Buffer.alloc(0))));
|
|
85
|
+
if (head !== null) feed(head);
|
|
86
|
+
}
|
|
87
|
+
const placement = run.placement as ScanPlacement;
|
|
88
|
+
const state = bytes !== size ? null : (run.fused ?? origin);
|
|
89
|
+
// When the origin hasher is the state its digest is taken from a copy, so it stays open.
|
|
90
|
+
const digest = state === origin ? origin.copy().digest() : origin.digest();
|
|
91
|
+
return {
|
|
92
|
+
path,
|
|
93
|
+
name: basename(path),
|
|
94
|
+
size,
|
|
95
|
+
digestB64: digest.toString("base64"),
|
|
96
|
+
originDigest: Uint8Array.from(digest),
|
|
97
|
+
placement,
|
|
98
|
+
state,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A member's fused digest for a slot: the open hasher, copied, finished with
|
|
104
|
+
* the placement's suffix for that slot's commitment. The hash of prefix,
|
|
105
|
+
* original, suffix is exactly the hash of the placement's own build, which
|
|
106
|
+
* a test pins.
|
|
107
|
+
*/
|
|
108
|
+
export function fusedDigestFor(file: ScannedFile, commitment: Uint8Array): Uint8Array {
|
|
109
|
+
if (file.state === null) throw new Error(`${file.path}: the file changed while it was read`);
|
|
110
|
+
const placement = getPlacement(file.placement);
|
|
111
|
+
if (placement?.frame === undefined) throw new Error(`placement ${file.placement} has no frame`);
|
|
112
|
+
const { suffix } = placement.frame({ originalSize: file.size, originDigest: file.originDigest, commitment });
|
|
113
|
+
return Uint8Array.from(file.state.copy().update(suffix).digest());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface Expansion {
|
|
117
|
+
/** Regular files in the order given; a directory contributes its files sorted by name, depth first. */
|
|
118
|
+
files: string[];
|
|
119
|
+
/** How many of the inputs were directories. */
|
|
120
|
+
directories: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Paths to files: a regular file is itself; a directory is every regular
|
|
125
|
+
* file under it, recursively, with hidden entries (names starting with a
|
|
126
|
+
* dot) and symbolic links left out. The same file twice is one file. A path
|
|
127
|
+
* that is missing or neither kind, or more than `limit` files in all, throws
|
|
128
|
+
* before any network call; the message names every path that failed.
|
|
129
|
+
*/
|
|
130
|
+
export async function expandPaths(paths: readonly string[], limit: number): Promise<Expansion> {
|
|
131
|
+
const files: string[] = [];
|
|
132
|
+
const seen = new Set<string>();
|
|
133
|
+
const failures: string[] = [];
|
|
134
|
+
let directories = 0;
|
|
135
|
+
const push = (p: string) => {
|
|
136
|
+
if (seen.has(p)) return;
|
|
137
|
+
seen.add(p);
|
|
138
|
+
files.push(p);
|
|
139
|
+
if (files.length > limit) throw new Error(`more than ${limit} files; BitGraph fewer at a time`);
|
|
140
|
+
};
|
|
141
|
+
const walk = async (dir: string): Promise<void> => {
|
|
142
|
+
const entries = (await readdir(dir, { withFileTypes: true }))
|
|
143
|
+
.filter((e) => !e.name.startsWith("."))
|
|
144
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
145
|
+
for (const e of entries) {
|
|
146
|
+
const full = join(dir, e.name);
|
|
147
|
+
if (e.isSymbolicLink()) continue;
|
|
148
|
+
if (e.isDirectory()) await walk(full);
|
|
149
|
+
else if (e.isFile()) push(full);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
for (const p of paths) {
|
|
153
|
+
try {
|
|
154
|
+
const info = await stat(p);
|
|
155
|
+
if (info.isDirectory()) {
|
|
156
|
+
directories += 1;
|
|
157
|
+
await walk(p);
|
|
158
|
+
} else if (info.isFile()) {
|
|
159
|
+
push(p);
|
|
160
|
+
} else {
|
|
161
|
+
throw new Error("not a regular file or a directory");
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
165
|
+
if (message.startsWith("more than ")) throw err;
|
|
166
|
+
failures.push(`${p}: ${message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (failures.length > 0) {
|
|
170
|
+
throw new Error(`Could not read ${failures.length} path(s); nothing was BitGraphed.\n${failures.join("\n")}\nUse absolute paths to existing regular files or directories.`);
|
|
171
|
+
}
|
|
172
|
+
return { files, directories };
|
|
173
|
+
}
|