@mikeargento/bitgraph-mcp 0.5.1 → 0.6.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/LICENSE +1 -1
- package/README.md +1 -1
- package/dist/__tests__/carrier.test.d.ts +2 -0
- package/dist/__tests__/carrier.test.d.ts.map +1 -0
- package/dist/__tests__/carrier.test.js +243 -0
- package/dist/__tests__/carrier.test.js.map +1 -0
- package/dist/__tests__/encoding.test.js +1 -1
- package/dist/__tests__/encoding.test.js.map +1 -1
- package/dist/__tests__/format.test.js +6 -6
- package/dist/__tests__/format.test.js.map +1 -1
- package/dist/__tests__/scan.test.js +1 -1
- package/dist/__tests__/scan.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +8 -7
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/api.js +1 -1
- package/dist/api.js.map +1 -1
- package/dist/carrier-io.d.ts +52 -0
- package/dist/carrier-io.d.ts.map +1 -0
- package/dist/carrier-io.js +91 -0
- package/dist/carrier-io.js.map +1 -0
- package/dist/encoding.js +1 -1
- package/dist/encoding.js.map +1 -1
- package/dist/format.d.ts +17 -3
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +36 -15
- package/dist/format.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/scan.d.ts +4 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +44 -1
- package/dist/scan.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +151 -29
- package/dist/server.js.map +1 -1
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +2 -2
- package/dist/task.js.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +13 -5
- package/src/__tests__/carrier.test.ts +271 -0
- package/src/__tests__/encoding.test.ts +1 -1
- package/src/__tests__/format.test.ts +6 -6
- package/src/__tests__/scan.test.ts +1 -1
- package/src/__tests__/tools.test.ts +8 -7
- package/src/api.ts +1 -1
- package/src/carrier-io.ts +120 -0
- package/src/encoding.ts +1 -1
- package/src/format.ts +56 -19
- package/src/index.ts +1 -1
- package/src/scan.ts +48 -1
- package/src/server.ts +169 -34
- package/src/task.ts +2 -2
- package/src/types.ts +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2024-2026
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* End-to-end tool tests: a real MCP client drives the real server over an
|
|
@@ -253,7 +253,7 @@ test("record makes ONE set of the fresh files and leaves on-record ones alone",
|
|
|
253
253
|
const batchBody = batch.body as { digests: string[] };
|
|
254
254
|
assert.ok(batchBody.digests.every((d) => !d.includes("+") && !d.includes("=")), "check uses url-safe digests");
|
|
255
255
|
const structured = result.structuredContent as RecordStructured;
|
|
256
|
-
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, not_fused: 0 });
|
|
256
|
+
assert.deepEqual(structured.summary, { files: 3, directories: 0, fused: 2, on_record: 1, carried: 0, not_fused: 0 });
|
|
257
257
|
assert.ok(structured.set, "the set is reported");
|
|
258
258
|
assert.equal(structured.set?.count, 2);
|
|
259
259
|
assert.ok(structured.set?.proof_url.includes("/proof/") && structured.set?.proof_url.includes("counter="), "the set's proof page is pinned to its position");
|
|
@@ -297,7 +297,7 @@ test("a single file is fused on its own, with its Frame; again=true fuses an on-
|
|
|
297
297
|
assert.ok(row?.artifact_digest && structured.frames[row.artifact_digest], "the Frame rides in the structured result under the fused digest");
|
|
298
298
|
assert.ok(row?.proof_url?.includes(encodeURIComponent(row.artifact_digest ?? "")), "a single file's proof page is its own");
|
|
299
299
|
const text = textOf(result);
|
|
300
|
-
assert.ok(text.startsWith("1 fused, 0 already on record
|
|
300
|
+
assert.ok(text.startsWith("1 fused, 0 already on record."), text);
|
|
301
301
|
assert.ok(text.includes("its Frame is in the structured result"), text);
|
|
302
302
|
});
|
|
303
303
|
|
|
@@ -311,7 +311,7 @@ test("a directory is its regular files, hidden entries and links left out", asyn
|
|
|
311
311
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
312
312
|
assert.deepEqual(setCalls[0]?.names, ["a1.txt", "a2.txt"]);
|
|
313
313
|
const structured = result.structuredContent as RecordStructured;
|
|
314
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, not_fused: 0 });
|
|
314
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 1, fused: 2, on_record: 0, carried: 0, not_fused: 0 });
|
|
315
315
|
assert.equal(structured.set?.count, 2);
|
|
316
316
|
});
|
|
317
317
|
|
|
@@ -374,7 +374,7 @@ test("evidence the site cannot index waits, blocks a new set, and is sent first
|
|
|
374
374
|
const s1 = first.structuredContent as RecordStructured;
|
|
375
375
|
assert.deepEqual(s1.set?.index, { written: 0, pending: BIG });
|
|
376
376
|
assert.equal(pendingIndexCount(), BIG);
|
|
377
|
-
assert.ok(textOf(first).includes("
|
|
377
|
+
assert.ok(textOf(first).includes("The set proof beside the originals is the record either way."), textOf(first).slice(0, 400));
|
|
378
378
|
|
|
379
379
|
// Still down: nothing new is made, so the members cannot be made again by mistake.
|
|
380
380
|
requests.length = 0;
|
|
@@ -414,7 +414,7 @@ test("a set failure labels every attempted file 'not fused', never 'on record'",
|
|
|
414
414
|
const structured = result.structuredContent as RecordStructured;
|
|
415
415
|
assert.equal(structured.set, null);
|
|
416
416
|
assert.ok(structured.results.every((r) => r.outcome === "not fused" && r.proof_url === null));
|
|
417
|
-
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, not_fused: 2 });
|
|
417
|
+
assert.deepEqual(structured.summary, { files: 2, directories: 0, fused: 0, on_record: 0, carried: 0, not_fused: 2 });
|
|
418
418
|
} finally {
|
|
419
419
|
fuseMode = "ok";
|
|
420
420
|
}
|
|
@@ -485,7 +485,8 @@ test("get_proof by number resolves through search and renders the window", async
|
|
|
485
485
|
assert.ok(!result.isError, JSON.stringify(result.content));
|
|
486
486
|
const text = textOf(result);
|
|
487
487
|
assert.ok(text.includes("# BitGraph #10"));
|
|
488
|
-
assert.ok(text.includes("BitGraphed
|
|
488
|
+
assert.ok(text.includes("BitGraphed after 2026-07-01T00:00:00.000Z"));
|
|
489
|
+
assert.ok(text.includes("before the anchoring of block 2"), "the ceiling is stated in position, never as the block's mine time");
|
|
489
490
|
assert.ok(text.includes("Causal positions (2)"));
|
|
490
491
|
assert.ok(text.includes("/proof/"), "includes the proof page url");
|
|
491
492
|
});
|
package/src/api.ts
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @mikeargento/bitgraph-mcp: BitGraphed files (bitgraph-carrier/1).
|
|
5
|
+
*
|
|
6
|
+
* A BitGraphed file is committed bytes followed by one structural block that
|
|
7
|
+
* carries the proof and the anchor evidence. The block is found from the END
|
|
8
|
+
* of the file, by structure, never by scanning. Everything here is local:
|
|
9
|
+
* an 8-byte tail read decides whether a file is a carrier at all, and the
|
|
10
|
+
* full judgment runs offline through @mikeargento/bitgraph-verify.
|
|
11
|
+
*
|
|
12
|
+
* The one rule that cannot bend: the envelope is never the recorded thing,
|
|
13
|
+
* the bytes inside it are. So the tools look up the INNER digest, and
|
|
14
|
+
* bitgraph_record never mints carrier bytes, the same structural guarantee
|
|
15
|
+
* the site's drop box gives.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { open } from "node:fs/promises";
|
|
19
|
+
import {
|
|
20
|
+
CARRIER_MAGIC, CARRIER_OVERHEAD, parseCarrier, verifyCarrier,
|
|
21
|
+
type CarrierVerifyResult,
|
|
22
|
+
} from "@mikeargento/bitgraph-verify";
|
|
23
|
+
|
|
24
|
+
/** ISO string for a witness timestamp (seconds), or null when the header was unreadable. */
|
|
25
|
+
const iso = (ts: number | null): string | null => (ts === null ? null : new Date(ts * 1000).toISOString());
|
|
26
|
+
|
|
27
|
+
/** The window as the tools state it. not_after null = NOT FETCHED, in those words. */
|
|
28
|
+
export interface CarrierWindowView {
|
|
29
|
+
verdict: "TRUE" | "FALSE" | "UNDETERMINED";
|
|
30
|
+
not_before: { block: number; hash: string; time: string | null } | null;
|
|
31
|
+
/** null when the ceiling has not been fetched into the file yet. */
|
|
32
|
+
not_after: { block: number; hash: string; time: string | null } | null;
|
|
33
|
+
ceiling: "present" | "unfetched" | null;
|
|
34
|
+
reasons: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function carrierWindowView(r: CarrierVerifyResult): CarrierWindowView {
|
|
38
|
+
return {
|
|
39
|
+
verdict: r.verdict,
|
|
40
|
+
not_before: r.bounds ? { block: r.bounds.notBefore.blockNumber, hash: r.bounds.notBefore.blockHash, time: iso(r.bounds.notBefore.timestamp) } : null,
|
|
41
|
+
not_after: r.bounds?.notAfter ? { block: r.bounds.notAfter.blockNumber, hash: r.bounds.notAfter.blockHash, time: iso(r.bounds.notAfter.timestamp) } : null,
|
|
42
|
+
ceiling: r.ceiling,
|
|
43
|
+
reasons: r.reasons,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One line for a markdown row: the verdict and the window, in the stated
|
|
49
|
+
* words. The floor is temporal (after the block's own time, cryptographically);
|
|
50
|
+
* the ceiling is POSITIONAL: committed before the ANCHORING of the later
|
|
51
|
+
* block, never before that block's mine time, because an anchor is built
|
|
52
|
+
* after the block it carries. Canon §3.5/§3.6: floor in time, ceiling in
|
|
53
|
+
* position; converting the positional ceiling into a clock reading is the
|
|
54
|
+
* one error.
|
|
55
|
+
*/
|
|
56
|
+
export function carrierLine(view: CarrierWindowView): string {
|
|
57
|
+
const parts: string[] = [`carried proof ${view.verdict}`];
|
|
58
|
+
if (view.not_before) parts.push(`after block ${view.not_before.block}${view.not_before.time ? ` (mined ${view.not_before.time})` : ""}`);
|
|
59
|
+
parts.push(
|
|
60
|
+
view.not_after
|
|
61
|
+
? `before the anchoring of block ${view.not_after.block}${view.not_after.time ? ` (block mined ${view.not_after.time})` : ""}`
|
|
62
|
+
: "closing anchor NOT FETCHED (drop the file on bitgraph.ing to complete it)"
|
|
63
|
+
);
|
|
64
|
+
return parts.join(" · ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when the file's last 8 bytes are the carrier magic. One positioned
|
|
69
|
+
* read; no file that is not a carrier pays more than that.
|
|
70
|
+
*/
|
|
71
|
+
export async function sniffCarrierTail(path: string, size: number): Promise<boolean> {
|
|
72
|
+
if (size < CARRIER_OVERHEAD) return false;
|
|
73
|
+
const fd = await open(path, "r");
|
|
74
|
+
try {
|
|
75
|
+
const tail = Buffer.alloc(CARRIER_MAGIC.length);
|
|
76
|
+
await fd.read(tail, 0, tail.length, size - tail.length);
|
|
77
|
+
return tail.equals(Buffer.from(CARRIER_MAGIC));
|
|
78
|
+
} finally {
|
|
79
|
+
await fd.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CarrierFileResult {
|
|
84
|
+
/** "ok": readable block, judged. "corrupt": block found but unreadable. */
|
|
85
|
+
status: "ok" | "corrupt";
|
|
86
|
+
/** Standard base64 SHA-256 of the COMMITTED bytes inside; the digest every lookup uses. */
|
|
87
|
+
innerDigestB64: string | null;
|
|
88
|
+
/** The committed bytes, for callers that go on to sniff or serve them. */
|
|
89
|
+
inner: Uint8Array | null;
|
|
90
|
+
result: CarrierVerifyResult | null;
|
|
91
|
+
view: CarrierWindowView | null;
|
|
92
|
+
corruptReason: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read a tail-confirmed carrier whole and judge it offline: the carried
|
|
97
|
+
* proof, the floor by identity, the witnesses, the ceiling when present.
|
|
98
|
+
* Proof-level judgment (attestation depth stays the audit CLI's job).
|
|
99
|
+
*/
|
|
100
|
+
export async function readCarrierFile(path: string): Promise<CarrierFileResult> {
|
|
101
|
+
const fd = await open(path, "r");
|
|
102
|
+
let bytes: Uint8Array;
|
|
103
|
+
try {
|
|
104
|
+
const info = await fd.stat();
|
|
105
|
+
const buf = Buffer.alloc(info.size);
|
|
106
|
+
await fd.read(buf, 0, info.size, 0);
|
|
107
|
+
bytes = new Uint8Array(buf);
|
|
108
|
+
} finally {
|
|
109
|
+
await fd.close();
|
|
110
|
+
}
|
|
111
|
+
const parsed = parseCarrier(bytes);
|
|
112
|
+
if (parsed.kind !== "carrier") {
|
|
113
|
+
const reason = parsed.kind === "corrupt" ? parsed.reason : "the tail magic matched but no block parsed";
|
|
114
|
+
return { status: "corrupt", innerDigestB64: null, inner: null, result: null, view: null, corruptReason: reason };
|
|
115
|
+
}
|
|
116
|
+
const result = await verifyCarrier(bytes);
|
|
117
|
+
const { createHash } = await import("node:crypto");
|
|
118
|
+
const innerDigestB64 = createHash("sha256").update(parsed.inner).digest("base64");
|
|
119
|
+
return { status: "ok", innerDigestB64, inner: parsed.inner, result, view: carrierWindowView(result), corruptReason: null };
|
|
120
|
+
}
|
package/src/encoding.ts
CHANGED
package/src/format.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
// Copyright (c) 2024-2026
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @mikeargento/bitgraph-mcp: output shaping.
|
|
5
5
|
*
|
|
6
6
|
* Markdown for human-facing summaries, JSON for complete structured data.
|
|
7
|
-
* Time statements come from the Ethereum anchor bracket
|
|
8
|
-
*
|
|
7
|
+
* Time statements come from the Ethereum anchor bracket, never from advisory
|
|
8
|
+
* clock fields, and they keep the canon's units: the floor is temporal
|
|
9
|
+
* ("after T", cryptographic), the ceiling is POSITIONAL ("before the
|
|
10
|
+
* anchoring of block M"), because an anchor is built after the block it
|
|
11
|
+
* carries, so a block's mine time is never an upper clock bound.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { toUrlSafeB64 } from "./encoding.js";
|
|
15
|
+
import { carrierLine, type CarrierWindowView } from "./carrier-io.js";
|
|
12
16
|
import type { BitGraphProof, PositionView, ProofDetailResponse, SetMemberView } from "./types.js";
|
|
13
17
|
|
|
14
18
|
export const CHARACTER_LIMIT = 25_000;
|
|
@@ -38,13 +42,19 @@ export function proofUrl(
|
|
|
38
42
|
* committed artifact. "on record": the bytes already had a recording or a
|
|
39
43
|
* fused artifact naming them as origin, and nothing was made. "not fused":
|
|
40
44
|
* the attempt failed or the file was left out; never claim "on record" for
|
|
41
|
-
* bytes that have no proof.
|
|
45
|
+
* bytes that have no proof. "carried": the file is a BitGraphed file, its
|
|
46
|
+
* proof travels inside it; the committed bytes it holds were judged offline
|
|
47
|
+
* and NOTHING was minted, because the envelope is never the recorded thing.
|
|
42
48
|
*/
|
|
43
49
|
export interface RecordOutcome {
|
|
44
50
|
path: string;
|
|
45
|
-
/** The file's own digest (URL-safe): the origin of its fused bytes. */
|
|
51
|
+
/** The file's own digest (URL-safe): the origin of its fused bytes. For a "carried" row, the COMMITTED bytes' digest. */
|
|
46
52
|
digest: string;
|
|
47
|
-
outcome: "fused" | "on record" | "not fused";
|
|
53
|
+
outcome: "fused" | "on record" | "not fused" | "carried";
|
|
54
|
+
/** A BitGraphed file's offline judgment: the carried proof's verdict and window. */
|
|
55
|
+
carrier?: CarrierWindowView;
|
|
56
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest); the proof page displays them. */
|
|
57
|
+
c2pa?: boolean;
|
|
48
58
|
/** The member's fused digest (URL-safe), present on a "fused" outcome. */
|
|
49
59
|
artifact_digest: string | null;
|
|
50
60
|
placement: string | null;
|
|
@@ -78,10 +88,17 @@ export interface SetOutcome {
|
|
|
78
88
|
|
|
79
89
|
export interface CheckOutcome {
|
|
80
90
|
input: string;
|
|
81
|
-
digest
|
|
91
|
+
/** URL-safe. For a BitGraphed file, the digest of the COMMITTED bytes inside, never the envelope's. */
|
|
92
|
+
digest: string;
|
|
82
93
|
on_record: boolean;
|
|
83
94
|
positions: Array<{ counter: string | null; epoch: string | null; member?: SetMemberView }>;
|
|
84
95
|
proof_url: string | null;
|
|
96
|
+
/** Present when the input is a BitGraphed file: the carried proof's offline judgment. */
|
|
97
|
+
carrier?: CarrierWindowView;
|
|
98
|
+
/** True when the bytes carry Content Credentials (a C2PA manifest). */
|
|
99
|
+
c2pa?: boolean;
|
|
100
|
+
/** A stated condition (an unreadable carrier block, an oversized file), never a verdict. */
|
|
101
|
+
note?: string;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
export function positionOf(proof: BitGraphProof): { counter: string | null; epoch: string | null } {
|
|
@@ -96,9 +113,14 @@ function memberNote(m: SetMemberView | undefined): string {
|
|
|
96
113
|
return m ? ` (member ${fmt(m.index + 1)} of ${fmt(m.count)})` : "";
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
/** The C2PA note a row earns, or an empty string. */
|
|
117
|
+
const c2paNote = (o: { c2pa?: boolean }): string =>
|
|
118
|
+
o.c2pa === true ? `\n Content Credentials (C2PA) detected in the file; the proof page displays them.` : "";
|
|
119
|
+
|
|
99
120
|
export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: SetOutcome | null = null, omitted = 0): string {
|
|
100
121
|
const fused = outcomes.filter((o) => o.outcome === "fused");
|
|
101
122
|
const onRecord = outcomes.filter((o) => o.outcome === "on record");
|
|
123
|
+
const carried = outcomes.filter((o) => o.outcome === "carried");
|
|
102
124
|
const notFused = outcomes.filter((o) => o.outcome === "not fused");
|
|
103
125
|
const lines: string[] = [];
|
|
104
126
|
const parts: string[] = [];
|
|
@@ -107,16 +129,17 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
107
129
|
} else {
|
|
108
130
|
parts.push(`${fmt(fused.length)} fused`);
|
|
109
131
|
}
|
|
110
|
-
parts.push(`${fmt(onRecord.length)} already on record
|
|
132
|
+
parts.push(`${fmt(onRecord.length)} already on record`);
|
|
133
|
+
if (carried.length > 0) parts.push(`${fmt(carried.length)} BitGraphed file${carried.length === 1 ? "" : "s"} (proof inside, nothing minted)`);
|
|
111
134
|
if (notFused.length > 0) parts.push(`${fmt(notFused.length)} NOT fused`);
|
|
112
135
|
lines.push(`${parts.join(", ")}.`);
|
|
113
136
|
if (set !== null && fused.length > 0) {
|
|
114
137
|
lines.push(`- #${set.counter ?? "?"} · set of ${fmt(set.count)} · ${set.proof_url}`);
|
|
115
138
|
if (set.index !== null && set.index.pending > 0) {
|
|
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.
|
|
139
|
+
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. The set proof beside the originals is the record either way.`);
|
|
117
140
|
}
|
|
118
141
|
if (!set.manifest_echoed) {
|
|
119
|
-
lines.push(` The boundary did not echo the committed artifact;
|
|
142
|
+
lines.push(` The boundary did not echo the committed artifact; BitGraph's copy of this proof carries no member list. Keep the set's proof page.`);
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
145
|
const group = (rows: readonly RecordOutcome[], render: (o: RecordOutcome) => string, more: (n: number) => string) => {
|
|
@@ -133,16 +156,25 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
133
156
|
(o) => {
|
|
134
157
|
const note = o.total_positions > 1 ? ` (${fmt(o.total_positions)} positions, earliest shown)` : "";
|
|
135
158
|
const row = o.member !== null && o.member_count !== null ? ` (member ${fmt(o.member)} of ${fmt(o.member_count)})` : "";
|
|
136
|
-
|
|
159
|
+
const carrier = o.carrier ? `\n ${carrierLine(o.carrier)}` : "";
|
|
160
|
+
return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}${carrier}${c2paNote(o)}\n ${o.proof_url}`;
|
|
137
161
|
},
|
|
138
162
|
(n) => `and ${fmt(n)} more already on record`
|
|
139
163
|
);
|
|
164
|
+
group(
|
|
165
|
+
carried,
|
|
166
|
+
(o) => {
|
|
167
|
+
const where = o.proof_url !== null ? `\n ${o.proof_url}` : "\n Not in this ledger; the proof travels in the file itself.";
|
|
168
|
+
return `- BitGraphed file · ${o.path}\n ${o.carrier ? carrierLine(o.carrier) : "carried proof"} · nothing minted: the envelope is never the recorded thing, the bytes inside are${c2paNote(o)}${where}`;
|
|
169
|
+
},
|
|
170
|
+
(n) => `and ${fmt(n)} more BitGraphed files`
|
|
171
|
+
);
|
|
140
172
|
group(
|
|
141
173
|
fused,
|
|
142
174
|
(o) =>
|
|
143
175
|
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 ?? "?"})`,
|
|
176
|
+
? `- fused · #${o.counter ?? "?"} · ${o.path} (${o.placement ?? "?"})${c2paNote(o)}\n ${o.proof_url}`
|
|
177
|
+
: `- fused · ${o.path} (${fmt(o.member)} of ${fmt(o.member_count ?? 0)}, ${o.placement ?? "?"})${c2paNote(o)}`,
|
|
146
178
|
(n) => `and ${fmt(n)} more files in the same set`
|
|
147
179
|
);
|
|
148
180
|
if (set !== null && fused.length > 0) {
|
|
@@ -156,7 +188,7 @@ export function renderRecordMarkdown(outcomes: readonly RecordOutcome[], set: Se
|
|
|
156
188
|
}
|
|
157
189
|
if (onRecord.length > 0) {
|
|
158
190
|
lines.push(
|
|
159
|
-
"\nFiles
|
|
191
|
+
"\nFiles already on record were left alone. A file can also hold a BitGraph in its holder's folder, which no lookup here can see. To make a new one regardless, call bitgraph_record with again=true."
|
|
160
192
|
);
|
|
161
193
|
}
|
|
162
194
|
if (omitted > 0) {
|
|
@@ -169,12 +201,17 @@ export function renderCheckMarkdown(outcomes: readonly CheckOutcome[]): string {
|
|
|
169
201
|
const found = outcomes.filter((o) => o.on_record).length;
|
|
170
202
|
const lines: string[] = [`${fmt(found)} of ${fmt(outcomes.length)} on record.`];
|
|
171
203
|
for (const o of outcomes.slice(0, MARKDOWN_ROWS)) {
|
|
204
|
+
const carrier = o.carrier ? `\n BitGraphed file: ${carrierLine(o.carrier)} (judged offline from the proof inside)` : "";
|
|
172
205
|
if (o.on_record) {
|
|
173
206
|
const first = o.positions[0];
|
|
174
207
|
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}`);
|
|
208
|
+
lines.push(`- on record · #${first?.counter ?? "?"}${memberNote(first?.member)}${extra} · ${o.input}${carrier}${c2paNote(o)}\n ${o.proof_url}`);
|
|
209
|
+
} else if (o.carrier) {
|
|
210
|
+
lines.push(`- not in this ledger · ${o.input}${carrier}${c2paNote(o)}`);
|
|
211
|
+
} else if (o.note !== undefined) {
|
|
212
|
+
lines.push(`- not judged · ${o.input}\n ${o.note}`);
|
|
176
213
|
} else {
|
|
177
|
-
lines.push(`- not on record · ${o.input}`);
|
|
214
|
+
lines.push(`- not on record · ${o.input}${c2paNote(o)}`);
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
if (outcomes.length > MARKDOWN_ROWS) lines.push(`- and ${fmt(outcomes.length - MARKDOWN_ROWS)} more; the structured result lists every one`);
|
|
@@ -189,9 +226,9 @@ function renderWindow(detail: ProofDetailResponse): string | null {
|
|
|
189
226
|
const lowerBlock = w.anchorBefore?.blockNumber ?? null;
|
|
190
227
|
const upperBlock = w.anchorAfter?.blockNumber ?? null;
|
|
191
228
|
if (lower && upper) {
|
|
192
|
-
return `BitGraphed
|
|
229
|
+
return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}), and before the anchoring of block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
193
230
|
}
|
|
194
|
-
if (upper) return `BitGraphed before
|
|
231
|
+
if (upper) return `BitGraphed before the anchoring of Ethereum block ${upperBlock ?? "?"} (that block mined ${upper}).`;
|
|
195
232
|
if (lower) return `BitGraphed after ${lower} (Ethereum block ${lowerBlock ?? "?"}).`;
|
|
196
233
|
return null;
|
|
197
234
|
}
|
|
@@ -236,7 +273,7 @@ export function renderProofMarkdown(
|
|
|
236
273
|
positions.forEach((p, i) => {
|
|
237
274
|
const label = i === 0 ? " · original" : "";
|
|
238
275
|
const bracket =
|
|
239
|
-
p.lowerTime && p.upperTime ? ` ·
|
|
276
|
+
p.lowerTime && p.upperTime ? ` · after ${p.lowerTime}, before the closing anchor (its block mined ${p.upperTime})` : "";
|
|
240
277
|
lines.push(`- #${p.counter ?? "?"}${label}${p.member ? ` · set of ${fmt(p.member.count)}` : ""}${bracket}`);
|
|
241
278
|
});
|
|
242
279
|
}
|
package/src/index.ts
CHANGED
package/src/scan.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright (c) 2024-2026
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @mikeargento/bitgraph-mcp: the scan.
|
|
@@ -39,6 +39,50 @@ export interface ScannedFile {
|
|
|
39
39
|
* then needs the bytes again.
|
|
40
40
|
*/
|
|
41
41
|
state: Hash | null;
|
|
42
|
+
/** True when the bytes carry what looks like a C2PA manifest (Content Credentials). */
|
|
43
|
+
c2pa: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Content Credentials detection, structural and cheap: a C2PA manifest lives
|
|
48
|
+
* in a JUMBF box whose description ("jumd") is followed within a few bytes by
|
|
49
|
+
* the store label "c2pa", in every container (JPEG APP11, PNG caBX, ISO BMFF).
|
|
50
|
+
* Requiring the pair in order and near each other keeps a stray "c2pa" in
|
|
51
|
+
* prose from counting. Detection, not judgment: the proof page parses and
|
|
52
|
+
* displays the manifest itself.
|
|
53
|
+
*/
|
|
54
|
+
const JUMD = Buffer.from("jumd", "ascii");
|
|
55
|
+
const C2PA_LABEL = Buffer.from("c2pa", "ascii");
|
|
56
|
+
const C2PA_NEAR = 96;
|
|
57
|
+
|
|
58
|
+
function windowHasC2pa(buf: Buffer): boolean {
|
|
59
|
+
for (let i = buf.indexOf(JUMD); i !== -1; i = buf.indexOf(JUMD, i + 1)) {
|
|
60
|
+
const from = i + JUMD.length;
|
|
61
|
+
const to = Math.min(buf.length, i + C2PA_NEAR);
|
|
62
|
+
if (buf.subarray(from, to).indexOf(C2PA_LABEL) !== -1) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class C2paSniffer {
|
|
68
|
+
found = false;
|
|
69
|
+
/** The last C2PA_NEAR + 4 bytes of the previous chunk, so a pair split across chunks is seen whole. */
|
|
70
|
+
private carry: Buffer = Buffer.alloc(0);
|
|
71
|
+
update(chunk: Buffer): void {
|
|
72
|
+
if (this.found) return;
|
|
73
|
+
const buf = this.carry.length > 0 ? Buffer.concat([this.carry, chunk]) : chunk;
|
|
74
|
+
if (windowHasC2pa(buf)) {
|
|
75
|
+
this.found = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const keep = Math.min(buf.length, C2PA_NEAR + JUMD.length);
|
|
79
|
+
this.carry = Buffer.from(buf.subarray(buf.length - keep));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One-shot form for bytes already in memory (a carrier's committed bytes). */
|
|
84
|
+
export function sniffC2paBytes(bytes: Uint8Array): boolean {
|
|
85
|
+
return windowHasC2pa(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
42
86
|
}
|
|
43
87
|
|
|
44
88
|
/** Bytes the placement decision reads: every magic number sits in the first 16. */
|
|
@@ -61,9 +105,11 @@ export async function scanFile(path: string): Promise<ScannedFile> {
|
|
|
61
105
|
const prefix = getPlacement(p)?.scanPrefix?.(size) ?? null;
|
|
62
106
|
if (prefix !== null && prefix.length > 0) run.fused = createHash("sha256").update(prefix);
|
|
63
107
|
};
|
|
108
|
+
const sniffer = new C2paSniffer();
|
|
64
109
|
const feed = (chunk: Buffer) => {
|
|
65
110
|
origin.update(chunk);
|
|
66
111
|
if (run.fused !== null) run.fused.update(chunk);
|
|
112
|
+
sniffer.update(chunk);
|
|
67
113
|
};
|
|
68
114
|
for await (const raw of createReadStream(path)) {
|
|
69
115
|
const chunk = raw as Buffer;
|
|
@@ -96,6 +142,7 @@ export async function scanFile(path: string): Promise<ScannedFile> {
|
|
|
96
142
|
originDigest: Uint8Array.from(digest),
|
|
97
143
|
placement,
|
|
98
144
|
state,
|
|
145
|
+
c2pa: sniffer.found,
|
|
99
146
|
};
|
|
100
147
|
}
|
|
101
148
|
|