@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.
Files changed (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1 -1
  3. package/dist/__tests__/carrier.test.d.ts +2 -0
  4. package/dist/__tests__/carrier.test.d.ts.map +1 -0
  5. package/dist/__tests__/carrier.test.js +243 -0
  6. package/dist/__tests__/carrier.test.js.map +1 -0
  7. package/dist/__tests__/encoding.test.js +1 -1
  8. package/dist/__tests__/encoding.test.js.map +1 -1
  9. package/dist/__tests__/format.test.js +6 -6
  10. package/dist/__tests__/format.test.js.map +1 -1
  11. package/dist/__tests__/scan.test.js +1 -1
  12. package/dist/__tests__/scan.test.js.map +1 -1
  13. package/dist/__tests__/tools.test.js +8 -7
  14. package/dist/__tests__/tools.test.js.map +1 -1
  15. package/dist/api.js +1 -1
  16. package/dist/api.js.map +1 -1
  17. package/dist/carrier-io.d.ts +52 -0
  18. package/dist/carrier-io.d.ts.map +1 -0
  19. package/dist/carrier-io.js +91 -0
  20. package/dist/carrier-io.js.map +1 -0
  21. package/dist/encoding.js +1 -1
  22. package/dist/encoding.js.map +1 -1
  23. package/dist/format.d.ts +17 -3
  24. package/dist/format.d.ts.map +1 -1
  25. package/dist/format.js +36 -15
  26. package/dist/format.js.map +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/scan.d.ts +4 -0
  30. package/dist/scan.d.ts.map +1 -1
  31. package/dist/scan.js +44 -1
  32. package/dist/scan.js.map +1 -1
  33. package/dist/server.d.ts +1 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +151 -29
  36. package/dist/server.js.map +1 -1
  37. package/dist/task.d.ts.map +1 -1
  38. package/dist/task.js +2 -2
  39. package/dist/task.js.map +1 -1
  40. package/dist/types.js +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +13 -5
  43. package/src/__tests__/carrier.test.ts +271 -0
  44. package/src/__tests__/encoding.test.ts +1 -1
  45. package/src/__tests__/format.test.ts +6 -6
  46. package/src/__tests__/scan.test.ts +1 -1
  47. package/src/__tests__/tools.test.ts +8 -7
  48. package/src/api.ts +1 -1
  49. package/src/carrier-io.ts +120 -0
  50. package/src/encoding.ts +1 -1
  51. package/src/format.ts +56 -19
  52. package/src/index.ts +1 -1
  53. package/src/scan.ts +48 -1
  54. package/src/server.ts +169 -34
  55. package/src/task.ts +2 -2
  56. package/src/types.ts +1 -1
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
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 (indexed before 2026-09-08)."), text);
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("does not index new proofs by hash"), textOf(first).slice(0, 400));
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 between 2026-07-01T00:00:00.000Z"));
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
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
1
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
2
 
3
3
  /**
4
4
  * @mikeargento/bitgraph-mcp: HTTP client for the hosted BitGraph API.
@@ -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
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
1
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
2
2
 
3
3
  /**
4
4
  * @mikeargento/bitgraph-mcp: digest encoding and file hashing.
package/src/format.ts CHANGED
@@ -1,14 +1,18 @@
1
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
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 ("BitGraphed between
8
- * X and Y"), never from advisory clock fields.
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: string; // URL-safe
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 (indexed before 2026-09-08)`);
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. BitGraph does not index new proofs by hash; the set proof beside the originals is the record.`);
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; the ledger's copy of this proof carries no member list. Keep the set's proof page.`);
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
- return `- on record · #${o.counter ?? "?"}${row} · ${o.path}${note}\n ${o.proof_url}`;
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 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."
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 between ${lower} (Ethereum block ${lowerBlock ?? "?"}) and ${upper} (block ${upperBlock ?? "?"}).`;
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 ${upper} (Ethereum block ${upperBlock ?? "?"}).`;
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 ? ` · between ${p.lowerTime} and ${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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+ // Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
3
3
 
4
4
  /**
5
5
  * @mikeargento/bitgraph-mcp: stdio entry point.
package/src/scan.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
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