dsh-research-report 0.1.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 (71) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +155 -0
  4. package/README.hi.md +155 -0
  5. package/README.md +155 -0
  6. package/README.pt.md +155 -0
  7. package/README.zh.md +155 -0
  8. package/THIRD_PARTY_NOTICES.md +21 -0
  9. package/cordis.patch.yml +24 -0
  10. package/lib/index.js +2143 -0
  11. package/lib/types/assemble.d.ts +123 -0
  12. package/lib/types/assemble.d.ts.map +1 -0
  13. package/lib/types/assemble.js +239 -0
  14. package/lib/types/assemble.js.map +1 -0
  15. package/lib/types/config.d.ts +48 -0
  16. package/lib/types/config.d.ts.map +1 -0
  17. package/lib/types/config.js +60 -0
  18. package/lib/types/config.js.map +1 -0
  19. package/lib/types/gather.d.ts +119 -0
  20. package/lib/types/gather.d.ts.map +1 -0
  21. package/lib/types/gather.js +165 -0
  22. package/lib/types/gather.js.map +1 -0
  23. package/lib/types/index.d.ts +51 -0
  24. package/lib/types/index.d.ts.map +1 -0
  25. package/lib/types/index.js +71 -0
  26. package/lib/types/index.js.map +1 -0
  27. package/lib/types/ledger.d.ts +159 -0
  28. package/lib/types/ledger.d.ts.map +1 -0
  29. package/lib/types/ledger.js +276 -0
  30. package/lib/types/ledger.js.map +1 -0
  31. package/lib/types/provider-local.d.ts +137 -0
  32. package/lib/types/provider-local.d.ts.map +1 -0
  33. package/lib/types/provider-local.js +418 -0
  34. package/lib/types/provider-local.js.map +1 -0
  35. package/lib/types/service.d.ts +302 -0
  36. package/lib/types/service.d.ts.map +1 -0
  37. package/lib/types/service.js +31 -0
  38. package/lib/types/service.js.map +1 -0
  39. package/lib/types/tools/evidence-add.d.ts +36 -0
  40. package/lib/types/tools/evidence-add.d.ts.map +1 -0
  41. package/lib/types/tools/evidence-add.js +107 -0
  42. package/lib/types/tools/evidence-add.js.map +1 -0
  43. package/lib/types/tools/ledger-query.d.ts +42 -0
  44. package/lib/types/tools/ledger-query.d.ts.map +1 -0
  45. package/lib/types/tools/ledger-query.js +154 -0
  46. package/lib/types/tools/ledger-query.js.map +1 -0
  47. package/lib/types/tools/research-report.d.ts +67 -0
  48. package/lib/types/tools/research-report.d.ts.map +1 -0
  49. package/lib/types/tools/research-report.js +345 -0
  50. package/lib/types/tools/research-report.js.map +1 -0
  51. package/lib/types/verify.d.ts +128 -0
  52. package/lib/types/verify.d.ts.map +1 -0
  53. package/lib/types/verify.js +208 -0
  54. package/lib/types/verify.js.map +1 -0
  55. package/lib/types/version.d.ts +7 -0
  56. package/lib/types/version.d.ts.map +1 -0
  57. package/lib/types/version.js +7 -0
  58. package/lib/types/version.js.map +1 -0
  59. package/package.json +147 -0
  60. package/src/assemble.ts +302 -0
  61. package/src/config.ts +97 -0
  62. package/src/gather.ts +239 -0
  63. package/src/index.ts +139 -0
  64. package/src/ledger.ts +344 -0
  65. package/src/provider-local.ts +489 -0
  66. package/src/service.ts +322 -0
  67. package/src/tools/evidence-add.ts +132 -0
  68. package/src/tools/ledger-query.ts +191 -0
  69. package/src/tools/research-report.ts +424 -0
  70. package/src/verify.ts +285 -0
  71. package/src/version.ts +7 -0
package/lib/index.js ADDED
@@ -0,0 +1,2143 @@
1
+ import path from "node:path";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { KNOWN_SESSION_EVENT_TYPES } from "@deepseek-ai/dsh-session";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { Service } from "@deepseek-ai/cordis";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ //#region src/config.ts
9
+ /**
10
+ * Config schema and resolution for `dsh-research-report`. Every tunable is a
11
+ * validated {@link Config} field changeable from cordis.yml; the resolution
12
+ * step validates bounds so misconfiguration fails loud at mount.
13
+ * @module dsh-research-report/config
14
+ */
15
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
16
+ const Config = z.object({
17
+ enabled: z.boolean().default(true),
18
+ ledgerRoot: z.string().default(".research-ledger"),
19
+ reportRoot: z.string().default("research-reports"),
20
+ maxEvidenceBytes: z.number().default(2097152),
21
+ maxEvidencePerReport: z.number().default(200),
22
+ fetchTimeoutMs: z.number().default(2e4)
23
+ });
24
+ /** Throw unless `value` is a positive safe integer. */
25
+ function assertPositiveInt(name, value) {
26
+ if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}`);
27
+ }
28
+ /**
29
+ * Resolve one configured root to an absolute path. Relative roots anchor at
30
+ * the harness working directory (the workspace the deployment runs in), which
31
+ * keeps the ledger and the sealed reports inside the workspace by default.
32
+ * @param value - the configured root.
33
+ * @param name - the config key, for error messages.
34
+ * @returns the absolute root path.
35
+ */
36
+ function resolveRoot(value, name) {
37
+ if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${name} must be a non-empty path`);
38
+ return path.resolve(value);
39
+ }
40
+ /**
41
+ * Validate raw values and fill explicit defaults. Invalid bounds throw here —
42
+ * misconfiguration fails loud at mount even without the Schemastery loader.
43
+ * @param config - raw (possibly partial) plugin config.
44
+ * @returns the fully resolved config.
45
+ */
46
+ function resolveConfig(config = {}) {
47
+ const maxEvidenceBytes = config.maxEvidenceBytes ?? 2097152;
48
+ assertPositiveInt("maxEvidenceBytes", maxEvidenceBytes);
49
+ const maxEvidencePerReport = config.maxEvidencePerReport ?? 200;
50
+ assertPositiveInt("maxEvidencePerReport", maxEvidencePerReport);
51
+ const fetchTimeoutMs = config.fetchTimeoutMs ?? 2e4;
52
+ assertPositiveInt("fetchTimeoutMs", fetchTimeoutMs);
53
+ return {
54
+ enabled: config.enabled ?? true,
55
+ ledgerRoot: resolveRoot(config.ledgerRoot ?? ".research-ledger", "ledgerRoot"),
56
+ reportRoot: resolveRoot(config.reportRoot ?? "research-reports", "reportRoot"),
57
+ maxEvidenceBytes,
58
+ maxEvidencePerReport,
59
+ fetchTimeoutMs
60
+ };
61
+ }
62
+ //#endregion
63
+ //#region src/ledger.ts
64
+ /**
65
+ * The evidence ledger: a content-addressed snapshot store with JSONL journals.
66
+ *
67
+ * Layout under the configured `ledgerRoot`:
68
+ * - `objects/<sha256>` — one immutable snapshot per content hash (same content
69
+ * is stored exactly once; an "update" is a new object, history is never
70
+ * rewritten).
71
+ * - `index.jsonl` — evidence registrations: id → hash, origin, capturedAt,
72
+ * title, bytes. Append-only.
73
+ * - `claims.jsonl` — claim registrations: id → text, evidenceIds, optional
74
+ * dataset bridge fields. Append-only.
75
+ * - `verdicts.jsonl` — verification verdicts; the latest line per claim wins.
76
+ *
77
+ * Tamper detection is the point of the design: every content read recomputes
78
+ * the SHA-256 of the object file and compares it against the indexed hash —
79
+ * a mismatch surfaces as `tampered`, a deleted object as `missing`.
80
+ *
81
+ * This module is pure Node (zero DSH imports) so it stays testable in
82
+ * isolation; policy (size caps, fetch) lives in the provider.
83
+ *
84
+ * @module dsh-research-report/ledger
85
+ */
86
+ /** SHA-256 hex of one UTF-8 string. */
87
+ function sha256Of(content) {
88
+ return createHash("sha256").update(content, "utf8").digest("hex");
89
+ }
90
+ /** A loud ledger failure (audit state must never degrade silently). */
91
+ var LedgerError = class extends Error {
92
+ /** The machine-routable failure code. */
93
+ code;
94
+ constructor(code, message) {
95
+ super(message);
96
+ this.name = "LedgerError";
97
+ this.code = code;
98
+ }
99
+ };
100
+ /** Read one JSONL journal; a corrupt line fails loud with file and line number. */
101
+ async function readJournal(file) {
102
+ let text;
103
+ try {
104
+ text = await readFile(file, "utf8");
105
+ } catch (error) {
106
+ if (error.code === "ENOENT") return [];
107
+ throw new LedgerError("IO", `cannot read journal ${file}: ${error.message}`);
108
+ }
109
+ const lines = [];
110
+ const rows = text.split("\n");
111
+ for (let index = 0; index < rows.length; index++) {
112
+ const row = rows[index];
113
+ if (row.trim() === "") continue;
114
+ try {
115
+ lines.push(JSON.parse(row));
116
+ } catch {
117
+ throw new LedgerError("JOURNAL_CORRUPT", `corrupt JSONL at ${file}:${index + 1}`);
118
+ }
119
+ }
120
+ return lines;
121
+ }
122
+ /**
123
+ * The content-addressed evidence ledger. Writes are serialized through an
124
+ * internal promise queue so concurrent tool calls cannot interleave journals.
125
+ */
126
+ var EvidenceLedger = class {
127
+ /** Absolute ledger root directory. */
128
+ root;
129
+ /** Write serialization chain (never rejects — each link absorbs the previous error). */
130
+ queue = Promise.resolve();
131
+ /**
132
+ * @param root - absolute ledger root directory.
133
+ */
134
+ constructor(root) {
135
+ this.root = root;
136
+ }
137
+ get objectsDir() {
138
+ return path.join(this.root, "objects");
139
+ }
140
+ get indexFile() {
141
+ return path.join(this.root, "index.jsonl");
142
+ }
143
+ get claimsFile() {
144
+ return path.join(this.root, "claims.jsonl");
145
+ }
146
+ get verdictsFile() {
147
+ return path.join(this.root, "verdicts.jsonl");
148
+ }
149
+ /** Run `work` after all previously queued writes settle. */
150
+ enqueue(work) {
151
+ const run = this.queue.then(work);
152
+ this.queue = run.then(() => void 0, () => void 0);
153
+ return run;
154
+ }
155
+ /** Ensure the directory layout exists. */
156
+ async ensureLayout() {
157
+ await mkdir(this.objectsDir, { recursive: true });
158
+ }
159
+ /** Write one snapshot object atomically (tmp + rename); no-op when present. */
160
+ async writeObject(hash, content) {
161
+ const target = path.join(this.objectsDir, hash);
162
+ try {
163
+ await readFile(target);
164
+ return;
165
+ } catch (error) {
166
+ if (error.code !== "ENOENT") throw new LedgerError("IO", `cannot stat object ${hash}: ${error.message}`);
167
+ }
168
+ const temporary = path.join(this.objectsDir, `.${hash}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
169
+ await writeFile(temporary, content, "utf8");
170
+ try {
171
+ await rename(temporary, target);
172
+ } catch (error) {
173
+ try {
174
+ await readFile(target);
175
+ } catch {
176
+ throw new LedgerError("IO", `cannot commit object ${hash}: ${error.message}`);
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Register one evidence snapshot. Same content dedupes to the stored object;
182
+ * a caller-chosen id that already exists with DIFFERENT content is refused
183
+ * loudly (history is never rewritten).
184
+ * @param input - id (optional), title, origin, content, capturedAt.
185
+ * @returns the record plus whether this call created it.
186
+ */
187
+ async putEvidence(input) {
188
+ return this.enqueue(async () => {
189
+ await this.ensureLayout();
190
+ const hash = sha256Of(input.content);
191
+ const id = input.id ?? `ev-${hash.slice(0, 12)}`;
192
+ const existing = (await readJournal(this.indexFile)).find((line) => line.id === id);
193
+ if (existing !== void 0) {
194
+ if (existing.hash !== hash) throw new LedgerError("ID_CONFLICT", `evidence id "${id}" is already registered with different content (indexed ${existing.hash}, new ${hash}); choose a new id — snapshots are immutable`);
195
+ return {
196
+ record: existing,
197
+ created: false
198
+ };
199
+ }
200
+ await this.writeObject(hash, input.content);
201
+ const record = {
202
+ id,
203
+ hash,
204
+ title: input.title,
205
+ origin: input.origin,
206
+ capturedAt: input.capturedAt,
207
+ bytes: Buffer.byteLength(input.content, "utf8")
208
+ };
209
+ await appendFile(this.indexFile, `${JSON.stringify(record)}\n`, "utf8");
210
+ return {
211
+ record,
212
+ created: true
213
+ };
214
+ });
215
+ }
216
+ /**
217
+ * Register claims (id → text, evidenceIds). Re-registering a claim id with
218
+ * a different text or different bindings is refused loudly.
219
+ * @param claims - the registrations to append.
220
+ * @param registeredAt - ISO-8601 registration time.
221
+ * @returns the durable claim lines (existing lines for idempotent repeats).
222
+ */
223
+ async registerClaims(claims, registeredAt) {
224
+ return this.enqueue(async () => {
225
+ await this.ensureLayout();
226
+ const journal = await readJournal(this.claimsFile);
227
+ const out = [];
228
+ for (const claim of claims) {
229
+ const existing = journal.find((line) => line.id === claim.id);
230
+ if (existing !== void 0) {
231
+ if (!(existing.text === claim.text && JSON.stringify(existing.evidenceIds) === JSON.stringify(claim.evidenceIds) && existing.dataset === claim.dataset)) throw new LedgerError("ID_CONFLICT", `claim id "${claim.id}" is already registered with different text or bindings; choose a new claim id — registrations are immutable`);
232
+ out.push(existing);
233
+ continue;
234
+ }
235
+ const line = {
236
+ ...claim,
237
+ registeredAt
238
+ };
239
+ await appendFile(this.claimsFile, `${JSON.stringify(line)}\n`, "utf8");
240
+ journal.push(line);
241
+ out.push(line);
242
+ }
243
+ return out;
244
+ });
245
+ }
246
+ /**
247
+ * Append one verdict (latest per claim wins on read).
248
+ * @param verdict - claimId, status, optional note.
249
+ * @param at - ISO-8601 write time.
250
+ */
251
+ async recordVerdict(verdict, at) {
252
+ await this.enqueue(async () => {
253
+ await this.ensureLayout();
254
+ const line = {
255
+ ...verdict,
256
+ at
257
+ };
258
+ await appendFile(this.verdictsFile, `${JSON.stringify(line)}\n`, "utf8");
259
+ });
260
+ }
261
+ /**
262
+ * Read the evidence index.
263
+ * @returns every registration in append order.
264
+ */
265
+ async listEvidence() {
266
+ return readJournal(this.indexFile);
267
+ }
268
+ /**
269
+ * Read one evidence registration.
270
+ * @param id - the ledger id.
271
+ * @returns the record, or undefined when unknown.
272
+ */
273
+ async getEvidence(id) {
274
+ return (await this.listEvidence()).find((line) => line.id === id);
275
+ }
276
+ /**
277
+ * Read every claim registration.
278
+ * @returns every claim in append order.
279
+ */
280
+ async listClaims() {
281
+ return readJournal(this.claimsFile);
282
+ }
283
+ /**
284
+ * Read one claim registration.
285
+ * @param id - the claim id.
286
+ * @returns the claim line, or undefined when unknown.
287
+ */
288
+ async getClaim(id) {
289
+ return (await this.listClaims()).find((line) => line.id === id);
290
+ }
291
+ /**
292
+ * Fold the verdict journal to the latest verdict per claim.
293
+ * @returns claimId → latest stored verdict.
294
+ */
295
+ async latestVerdicts() {
296
+ const journal = await readJournal(this.verdictsFile);
297
+ const latest = /* @__PURE__ */ new Map();
298
+ for (const line of journal) latest.set(line.claimId, line);
299
+ return latest;
300
+ }
301
+ /**
302
+ * Read one snapshot and recompute its hash — the tamper-detection path.
303
+ * @param id - the ledger id.
304
+ * @returns content plus integrity (`ok` | `tampered` | `missing`), or
305
+ * undefined when the id is unknown.
306
+ */
307
+ async readContent(id) {
308
+ const record = await this.getEvidence(id);
309
+ if (record === void 0) return void 0;
310
+ let content;
311
+ try {
312
+ content = await readFile(path.join(this.objectsDir, record.hash), "utf8");
313
+ } catch (error) {
314
+ if (error.code === "ENOENT") return {
315
+ content: "",
316
+ integrity: "missing"
317
+ };
318
+ throw new LedgerError("IO", `cannot read object ${record.hash}: ${error.message}`);
319
+ }
320
+ return {
321
+ content,
322
+ integrity: sha256Of(content) === record.hash ? "ok" : "tampered"
323
+ };
324
+ }
325
+ };
326
+ //#endregion
327
+ //#region src/assemble.ts
328
+ /**
329
+ * Report assembly and sealing — pure functions. The provider owns the ledger
330
+ * and the filesystem; this module owns request validation, the `report.md`
331
+ * rendering, the `manifest.json` construction, and the seal hash.
332
+ *
333
+ * Sealing: `report.md` is rendered deterministically from the validated
334
+ * request plus the verdicts; `manifest.json` carries the report hash, every
335
+ * evidence hash, and the verdicts; the seal hash is the SHA-256 of the exact
336
+ * manifest bytes. Recomputing the hashes from the sealed directory always
337
+ * reproduces the seal.
338
+ *
339
+ * @module dsh-research-report/assemble
340
+ */
341
+ /** The manifest schema tag written into every manifest.json. */
342
+ const MANIFEST_SCHEMA = "dsh-research-report/v1";
343
+ /** Body marker appended after a paragraph per UNVERIFIED claim it cites. */
344
+ const UNVERIFIED_MARK = "[未核实]";
345
+ /** Body marker appended after a paragraph per CONTRADICTED claim it cites. */
346
+ const CONTRADICTED_MARK = "[与证据矛盾]";
347
+ /** A loud assemble-time request validation failure. */
348
+ var RequestValidationError = class extends Error {
349
+ constructor(message) {
350
+ super(message);
351
+ this.name = "RequestValidationError";
352
+ }
353
+ };
354
+ /**
355
+ * Slug one topic for the report directory name: unicode letters and digits
356
+ * are kept, everything else folds to `-`; empty slugs fall back to `report`.
357
+ * @param topic - the report topic.
358
+ * @returns a filesystem-safe slug of at most 48 characters.
359
+ */
360
+ function slugify(topic) {
361
+ const slug = topic.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 48);
362
+ return slug === "" ? "report" : slug;
363
+ }
364
+ /**
365
+ * Format one timestamp as the version directory id `YYYYMMDD-HHmmss` (UTC).
366
+ * @param at - the time to format.
367
+ * @returns the directory id.
368
+ */
369
+ function versionIdOf(at) {
370
+ const pad = (value) => String(value).padStart(2, "0");
371
+ return [`${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}`, `${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`].join("-");
372
+ }
373
+ /**
374
+ * The config fingerprint recorded in every report: a short hash of the
375
+ * resolved runtime knobs, so two reports sealed under different policies are
376
+ * distinguishable at a glance.
377
+ * @param knobs - the policy values that shape assembly output.
378
+ * @returns 16 hex characters of the knobs' SHA-256.
379
+ */
380
+ function configFingerprint(knobs) {
381
+ return sha256Of(JSON.stringify({
382
+ maxEvidenceBytes: knobs.maxEvidenceBytes,
383
+ maxEvidencePerReport: knobs.maxEvidencePerReport
384
+ })).slice(0, 16);
385
+ }
386
+ /**
387
+ * Validate one assemble request; every violation throws (a loud rejection —
388
+ * the caller must fix the request, nothing is silently skipped).
389
+ * @param request - the frozen assemble request.
390
+ * @param limits - the resolved caps.
391
+ */
392
+ function validateAssembleRequest(request, limits) {
393
+ if (request.title.trim() === "") throw new RequestValidationError("title must be non-empty");
394
+ if (request.topic.trim() === "") throw new RequestValidationError("topic must be non-empty");
395
+ if (request.sections.length === 0) throw new RequestValidationError("sections must contain at least one section");
396
+ for (const [index, section] of request.sections.entries()) if (section.heading.trim() === "") throw new RequestValidationError(`sections[${index}].heading must be non-empty`);
397
+ if (request.evidence.length > limits.maxEvidencePerReport) throw new RequestValidationError(`evidence has ${request.evidence.length} items, above the configured maxEvidencePerReport ${limits.maxEvidencePerReport}`);
398
+ const evidenceIds = /* @__PURE__ */ new Set();
399
+ for (const item of request.evidence) {
400
+ if (item.id.trim() === "") throw new RequestValidationError("evidence id must be non-empty");
401
+ if (evidenceIds.has(item.id)) throw new RequestValidationError(`duplicate evidence id "${item.id}"`);
402
+ evidenceIds.add(item.id);
403
+ const bytes = Buffer.byteLength(item.content, "utf8");
404
+ if (bytes > limits.maxEvidenceBytes) throw new RequestValidationError(`evidence "${item.id}" is ${bytes} bytes, above the configured maxEvidenceBytes ${limits.maxEvidenceBytes}`);
405
+ if (Number.isNaN(Date.parse(item.capturedAt))) throw new RequestValidationError(`evidence "${item.id}" has an unparseable capturedAt ${JSON.stringify(item.capturedAt)}`);
406
+ }
407
+ const claimIds = /* @__PURE__ */ new Set();
408
+ for (const claim of request.claims) {
409
+ if (claim.id.trim() === "") throw new RequestValidationError("claim id must be non-empty");
410
+ if (claimIds.has(claim.id)) throw new RequestValidationError(`duplicate claim id "${claim.id}"`);
411
+ claimIds.add(claim.id);
412
+ for (const evidenceId of claim.evidenceIds) if (!evidenceIds.has(evidenceId)) throw new RequestValidationError(`claim "${claim.id}" binds unknown evidence id "${evidenceId}"`);
413
+ }
414
+ for (const [sectionIndex, section] of request.sections.entries()) for (const [paragraphIndex, paragraph] of section.paragraphs.entries()) for (const claimId of paragraph.claimIds ?? []) if (!claimIds.has(claimId)) throw new RequestValidationError(`sections[${sectionIndex}].paragraphs[${paragraphIndex}] cites unregistered claim id "${claimId}"`);
415
+ }
416
+ /** The status mark used in the appendix table. */
417
+ function statusMark(status) {
418
+ switch (status) {
419
+ case "verified": return "✅ verified";
420
+ case "unverified": return "⚠️ unverified";
421
+ case "contradicted": return "❌ contradicted";
422
+ }
423
+ }
424
+ /** Escape a table cell. */
425
+ function cell(text) {
426
+ return text.replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ");
427
+ }
428
+ /**
429
+ * Render `report.md`. Unverified/contradicted claims keep a visible body
430
+ * marker after every paragraph that cites them — nothing is silently passed.
431
+ * @param plan - the validated request plus verdicts and evidence records.
432
+ * @returns the report markdown.
433
+ */
434
+ function renderReportMarkdown(plan) {
435
+ const verdictByClaim = new Map(plan.verdicts.map((verdict) => [verdict.claimId, verdict]));
436
+ const counts = {
437
+ verified: 0,
438
+ unverified: 0,
439
+ contradicted: 0
440
+ };
441
+ for (const verdict of plan.verdicts) counts[verdict.status] += 1;
442
+ const lines = [
443
+ `# ${plan.request.title}`,
444
+ "",
445
+ `- Topic: ${plan.request.topic}`,
446
+ `- Generated: ${plan.generatedAt} (UTC)`,
447
+ `- Claims: ${counts.verified} verified / ${counts.unverified} unverified / ${counts.contradicted} contradicted`,
448
+ `- Generator: dsh-research-report ${plan.pluginVersion}`,
449
+ ""
450
+ ];
451
+ for (const section of plan.request.sections) {
452
+ lines.push(`## ${section.heading}`, "");
453
+ for (const paragraph of section.paragraphs) {
454
+ const marks = [];
455
+ for (const claimId of paragraph.claimIds ?? []) {
456
+ const verdict = verdictByClaim.get(claimId);
457
+ if (verdict?.status === "unverified") marks.push(UNVERIFIED_MARK);
458
+ if (verdict?.status === "contradicted") marks.push(CONTRADICTED_MARK);
459
+ }
460
+ lines.push(marks.length === 0 ? paragraph.text : `${paragraph.text} ${marks.join(" ")}`, "");
461
+ }
462
+ }
463
+ lines.push("## Appendix A: Claim verification", "");
464
+ if (plan.verdicts.length === 0) lines.push("No claims were registered.", "");
465
+ else {
466
+ lines.push("| Claim | Verdict | Evidence | Note |", "|---|---|---|---|");
467
+ const claimById = new Map(plan.request.claims.map((claim) => [claim.id, claim]));
468
+ for (const verdict of plan.verdicts) {
469
+ const claim = claimById.get(verdict.claimId);
470
+ lines.push(`| ${cell(verdict.claimId)} | ${statusMark(verdict.status)} | ${cell((claim?.evidenceIds ?? []).join(", "))} | ${cell(verdict.note ?? "")} |`);
471
+ }
472
+ lines.push("");
473
+ }
474
+ lines.push("## Appendix B: Evidence list", "");
475
+ if (plan.evidence.length === 0) lines.push("No evidence was bound.", "");
476
+ else {
477
+ lines.push("| Id | Title | Origin | SHA-256 | Captured |", "|---|---|---|---|---|");
478
+ for (const record of plan.evidence) lines.push(`| ${cell(record.id)} | ${cell(record.title)} | ${cell(record.origin)} | \`${record.hash}\` | ${record.capturedAt} |`);
479
+ lines.push("");
480
+ }
481
+ lines.push("## Appendix C: Seal", "", `- Manifest: \`manifest.json\` (schema ${MANIFEST_SCHEMA})`, `- Config fingerprint: \`${plan.fingerprint}\``, `- Generated: ${plan.generatedAt} (UTC)`, "");
482
+ return `${lines.join("\n").trimEnd()}\n`;
483
+ }
484
+ /**
485
+ * Build the manifest document for one sealed report. Key order is fixed by
486
+ * construction so the serialized bytes (and therefore the seal hash) are
487
+ * deterministic for the same inputs.
488
+ * @param plan - the validated request plus verdicts and evidence records.
489
+ * @param reportSha256 - the SHA-256 of the rendered report.md bytes.
490
+ * @returns the manifest document.
491
+ */
492
+ function buildManifest(plan, reportSha256) {
493
+ return {
494
+ schema: MANIFEST_SCHEMA,
495
+ title: plan.request.title,
496
+ topic: plan.request.topic,
497
+ generatedAt: plan.generatedAt,
498
+ generator: `dsh-research-report ${plan.pluginVersion}`,
499
+ reportFile: "report.md",
500
+ reportSha256,
501
+ configFingerprint: plan.fingerprint,
502
+ evidence: plan.evidence.map((record) => ({
503
+ id: record.id,
504
+ sha256: record.hash,
505
+ origin: record.origin,
506
+ title: record.title,
507
+ capturedAt: record.capturedAt,
508
+ bytes: record.bytes
509
+ })),
510
+ claims: plan.request.claims.map((claim) => ({
511
+ id: claim.id,
512
+ text: claim.text,
513
+ evidenceIds: claim.evidenceIds
514
+ })),
515
+ verdicts: plan.verdicts
516
+ };
517
+ }
518
+ /**
519
+ * Serialize one manifest to its exact durable bytes. The seal hash is the
520
+ * SHA-256 of this text.
521
+ * @param manifest - the manifest document.
522
+ * @returns the canonical manifest.json content.
523
+ */
524
+ function serializeManifest(manifest) {
525
+ return `${JSON.stringify(manifest, null, 2)}\n`;
526
+ }
527
+ //#endregion
528
+ //#region src/gather.ts
529
+ /**
530
+ * Evidence capture: URL snapshots via the `ctx.web` seam, workspace file
531
+ * snapshots via `node:fs` — never a direct `fetch` (provider selection and the
532
+ * WebError taxonomy stay with the seam), never a path outside the workspace.
533
+ * @module dsh-research-report/gather
534
+ */
535
+ /** A loud capture failure with a machine-routable code (also in the message). */
536
+ var CaptureError = class extends Error {
537
+ /** The machine-routable failure code. */
538
+ code;
539
+ constructor(code, message) {
540
+ super(`[${code}] ${message}`);
541
+ this.name = "CaptureError";
542
+ this.code = code;
543
+ }
544
+ };
545
+ /** Whether the origin is an HTTP(S) URL (vs a workspace path). */
546
+ function isUrlOrigin(origin) {
547
+ return /^https?:\/\//iu.test(origin);
548
+ }
549
+ /**
550
+ * Resolve a workspace-relative origin to an absolute path inside the
551
+ * workspace. Both sides are resolved before comparison (Windows backslash
552
+ * trap) and the prefix check is segment-aware.
553
+ * @param workspaceRoot - absolute workspace root.
554
+ * @param origin - the workspace-relative (or absolute) origin.
555
+ * @returns the absolute in-workspace path.
556
+ */
557
+ function resolveWorkspacePath(workspaceRoot, origin) {
558
+ const root = path.resolve(workspaceRoot);
559
+ const resolved = path.resolve(root, origin);
560
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new CaptureError("ORIGIN_OUTSIDE_WORKSPACE", `origin ${JSON.stringify(origin)} resolves outside the workspace`);
561
+ return resolved;
562
+ }
563
+ /** Relativize an absolute in-workspace path for display/durable records. */
564
+ function toWorkspaceRelative(workspaceRoot, absolute) {
565
+ return path.relative(path.resolve(workspaceRoot), path.resolve(absolute)).split(path.sep).join("/");
566
+ }
567
+ /**
568
+ * Capture one URL snapshot through the web seam.
569
+ * @param deps - web seam, deadline, workspace root.
570
+ * @param url - the URL to fetch.
571
+ * @param signal - caller cancellation.
572
+ * @returns the snapshot (throws {@link CaptureError} on every failure).
573
+ */
574
+ async function captureFromWeb(deps, url, signal) {
575
+ if (deps.web === void 0) throw new CaptureError("WEB_UNAVAILABLE", "the web capability (ctx.web) is not mounted in this composition; pass `content` explicitly or load @deepseek-ai/dsh-web with a fetch provider");
576
+ const timeout = AbortSignal.timeout(deps.fetchTimeoutMs);
577
+ const linked = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
578
+ let result;
579
+ try {
580
+ result = await deps.web.fetch({ url }, linked);
581
+ } catch (error) {
582
+ if (timeout.aborted && (signal === void 0 || !signal.aborted)) throw new CaptureError("FETCH_TIMEOUT", `fetch of ${url} exceeded the configured fetchTimeoutMs ${deps.fetchTimeoutMs}`);
583
+ throw new CaptureError("FETCH_FAILED", `fetch of ${url} failed: ${error instanceof Error ? error.message : String(error)}`);
584
+ }
585
+ if (result.statusCode < 200 || result.statusCode >= 300) throw new CaptureError("FETCH_STATUS", `fetch of ${url} returned HTTP ${result.statusCode}; no snapshot captured`);
586
+ return {
587
+ content: result.body.content,
588
+ origin: result.url
589
+ };
590
+ }
591
+ /**
592
+ * Capture one workspace file snapshot.
593
+ * @param deps - workspace root (the web fields are unused here).
594
+ * @param origin - the workspace-relative path.
595
+ * @returns the snapshot (throws {@link CaptureError} when unreadable).
596
+ */
597
+ async function captureFromFile(deps, origin) {
598
+ const absolute = resolveWorkspacePath(deps.workspaceRoot, origin);
599
+ let content;
600
+ try {
601
+ content = await readFile(absolute, "utf8");
602
+ } catch (error) {
603
+ throw new CaptureError("ORIGIN_UNREADABLE", `cannot read ${JSON.stringify(origin)}: ${error.message}`);
604
+ }
605
+ return {
606
+ content,
607
+ origin: toWorkspaceRelative(deps.workspaceRoot, absolute)
608
+ };
609
+ }
610
+ /**
611
+ * Capture one snapshot from any supported origin.
612
+ * @param deps - web seam, deadline, workspace root.
613
+ * @param origin - URL or workspace path.
614
+ * @param signal - caller cancellation.
615
+ * @returns the snapshot.
616
+ */
617
+ async function captureSnapshot(deps, origin, signal) {
618
+ return isUrlOrigin(origin) ? captureFromWeb(deps, origin, signal) : captureFromFile(deps, origin);
619
+ }
620
+ /** Search depth → how many sources are fetched for snapshot capture. */
621
+ const GATHER_DEPTH_RESULTS = {
622
+ quick: 3,
623
+ standard: 5,
624
+ deep: 8
625
+ };
626
+ /**
627
+ * Run one search over the topic and capture snapshots for the top sources.
628
+ * Captured snapshots are registered through `register`; uncaptured sources
629
+ * land in the gap list with their reason — gathering never fabricates
630
+ * evidence and never auto-assembles.
631
+ * @param deps - web seam, deadline, workspace root.
632
+ * @param topic - the research topic.
633
+ * @param depth - quick | standard | deep.
634
+ * @param signal - caller cancellation.
635
+ * @param register - ledger registration callback for captured snapshots.
636
+ * @returns candidates plus gaps.
637
+ */
638
+ async function gatherCandidates(deps, topic, depth, signal, register) {
639
+ if (deps.web === void 0) throw new CaptureError("WEB_UNAVAILABLE", "the web capability (ctx.web) is not mounted in this composition; gather needs @deepseek-ai/dsh-web with a search provider");
640
+ const maxResults = GATHER_DEPTH_RESULTS[depth];
641
+ let search;
642
+ try {
643
+ search = await deps.web.search({
644
+ query: topic,
645
+ maxResults
646
+ }, signal);
647
+ } catch (error) {
648
+ const message = error instanceof Error ? error.message : String(error);
649
+ throw new CaptureError("FETCH_FAILED", `search for ${JSON.stringify(topic)} failed: ${message}`);
650
+ }
651
+ const candidates = [];
652
+ const gaps = [];
653
+ for (const source of search.sources) try {
654
+ const snapshot = await captureFromWeb(deps, source.url, signal);
655
+ const record = await register({
656
+ title: source.title ?? source.url,
657
+ origin: snapshot.origin,
658
+ content: snapshot.content
659
+ });
660
+ candidates.push({
661
+ url: source.url,
662
+ ...source.title === void 0 ? {} : { title: source.title },
663
+ ...source.snippet === void 0 ? {} : { snippet: source.snippet },
664
+ status: "captured",
665
+ evidenceId: record.id
666
+ });
667
+ } catch (error) {
668
+ const reason = error instanceof CaptureError ? `${error.code}: ${error.message}` : String(error);
669
+ candidates.push({
670
+ url: source.url,
671
+ ...source.title === void 0 ? {} : { title: source.title },
672
+ ...source.snippet === void 0 ? {} : { snippet: source.snippet },
673
+ status: "uncaptured",
674
+ reason
675
+ });
676
+ gaps.push(`no snapshot for ${source.url} (${reason}) — add it with evidence_add once content is available`);
677
+ }
678
+ if (search.truncated) gaps.push(`search returned more than ${maxResults} sources; only the top ${maxResults} were considered`);
679
+ if (candidates.every((candidate) => candidate.status === "uncaptured")) gaps.push("no evidence was captured; the report cannot be assembled until at least one snapshot lands in the ledger");
680
+ return {
681
+ topic,
682
+ candidates,
683
+ gaps
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/service.ts
688
+ /**
689
+ * Service Definition of the verifiable research-report seam (`ctx.researchReport`).
690
+ *
691
+ * Three roles in one package (they evolve together): this file owns the
692
+ * Definition — the frozen `assemble` contract, the evidence/claim vocabulary,
693
+ * and the typed session events; `provider-local.ts` owns the local Provider
694
+ * (filesystem ledger + byte-level verification); `tools/` owns the model-facing
695
+ * Consumers.
696
+ *
697
+ * The `ReportSectionInput` / `EvidenceInput` / `AssembleReportRequest` /
698
+ * `AssembleReportResult` block below is BYTE-FROZEN: sibling plugins
699
+ * (dsh-industry-research) consume `ctx.researchReport.assemble` against this
700
+ * exact text. `scripts/verify-frozen-contract.mjs` gates drift.
701
+ *
702
+ * @module dsh-research-report/service
703
+ */
704
+ /**
705
+ * The verifiable research-report service (`ctx.researchReport`).
706
+ *
707
+ * `assemble` is the frozen cross-plugin surface: validate the request, verify
708
+ * every claim against the ledger's immutable snapshots, render `report.md`
709
+ * (unverified/contradicted claims stay visibly marked in the body), write
710
+ * `manifest.json`, and seal the directory with the manifest's SHA-256.
711
+ */
712
+ var ResearchReportService = class extends Service {
713
+ constructor(ctx) {
714
+ super(ctx, "researchReport");
715
+ }
716
+ };
717
+ //#endregion
718
+ //#region src/verify.ts
719
+ /** Number literal: optional sign, grouped digits, decimals, trailing %. */
720
+ const NUMBER_PATTERN = /-?\d[\d,]*(?:\.\d+)?%?/gu;
721
+ /** Quoted spans: ASCII double quotes, CJK corner brackets, full-width quotes. */
722
+ const QUOTE_PATTERNS = [
723
+ /"([^"\n]{4,200})"/gu,
724
+ /「([^」\n]{2,200})」/gu,
725
+ /“([^“”\n]{4,200})”/gu
726
+ ];
727
+ /** The tail run of label characters (letters / CJK) ending a context window. */
728
+ const LABEL_PATTERN = /[\p{L}\p{N}_()()%$-]{2,24}$/u;
729
+ /**
730
+ * Extract the trailing context label of a number citation: up to 24 characters
731
+ * before the number, trimmed to its trailing label run. Too-short labels are
732
+ * dropped (a weak label would false-positive the contradiction check).
733
+ * @param text - the full claim text.
734
+ * @param index - offset of the number in the text.
735
+ * @returns the label, or undefined when there is no usable one.
736
+ */
737
+ function contextLabelOf(text, index) {
738
+ const window = text.slice(Math.max(0, index - 24), index).trimEnd();
739
+ const label = LABEL_PATTERN.exec(window)?.[0].trim();
740
+ if (label === void 0 || label.length < 2) return void 0;
741
+ return label;
742
+ }
743
+ /**
744
+ * Extract every checkable citation from one claim text: number literals (with
745
+ * their left-context labels) and quoted spans.
746
+ * @param claimText - the claim to analyze.
747
+ * @returns citations in first-seen order (duplicates kept once).
748
+ */
749
+ function extractCitations(claimText) {
750
+ const citations = [];
751
+ const seen = /* @__PURE__ */ new Set();
752
+ for (const match of claimText.matchAll(NUMBER_PATTERN)) {
753
+ const text = match[0];
754
+ if (seen.has(`number:${text}`)) continue;
755
+ seen.add(`number:${text}`);
756
+ const context = contextLabelOf(claimText, match.index);
757
+ citations.push(context === void 0 ? {
758
+ kind: "number",
759
+ text
760
+ } : {
761
+ kind: "number",
762
+ text,
763
+ context
764
+ });
765
+ }
766
+ for (const pattern of QUOTE_PATTERNS) for (const match of claimText.matchAll(pattern)) {
767
+ const text = match[1];
768
+ if (seen.has(`quote:${text}`)) continue;
769
+ seen.add(`quote:${text}`);
770
+ citations.push({
771
+ kind: "quote",
772
+ text
773
+ });
774
+ }
775
+ return citations;
776
+ }
777
+ /** The number token scan window after a context label (bytes of text). */
778
+ const CONTEXT_SCAN_WINDOW = 24;
779
+ /** Normalize a number literal for comparison (drop grouping commas and %). */
780
+ function normalizeNumber(text) {
781
+ return text.replace(/[,%]/gu, "");
782
+ }
783
+ /** The first number literal found in `text` from `from` (fresh regex — no shared lastIndex). */
784
+ function numberAfter(text, from) {
785
+ const window = text.slice(from, from + CONTEXT_SCAN_WINDOW);
786
+ return /-?\d[\d,]*(?:\.\d+)?%?/u.exec(window)?.[0];
787
+ }
788
+ /** Cap how many problem citations one note enumerates. */
789
+ const NOTE_LIST_CAP = 5;
790
+ /**
791
+ * Run the byte-level check of one claim against its bound evidence snapshots.
792
+ * @param claimText - the claim text.
793
+ * @param evidenceContents - the verbatim snapshot contents of the bound evidence.
794
+ * @returns the outcome (never throws).
795
+ */
796
+ function verifyClaimText(claimText, evidenceContents) {
797
+ if (evidenceContents.length === 0) return {
798
+ status: "unverified",
799
+ note: "claim binds no evidence snapshot",
800
+ missing: [],
801
+ contradictions: []
802
+ };
803
+ const haystack = evidenceContents.join("\n");
804
+ const citations = extractCitations(claimText);
805
+ if (citations.length === 0) return {
806
+ status: "unverified",
807
+ note: "claim carries no checkable citation (number or quoted span); byte-level verification needs a literal to locate",
808
+ missing: [],
809
+ contradictions: []
810
+ };
811
+ const missing = [];
812
+ const contradictions = [];
813
+ for (const citation of citations) {
814
+ if (citation.kind === "quote") {
815
+ if (!haystack.includes(citation.text)) missing.push(`"${citation.text}"`);
816
+ continue;
817
+ }
818
+ if (haystack.includes(citation.text)) continue;
819
+ if (citation.context !== void 0) {
820
+ let searchFrom = 0;
821
+ let different;
822
+ for (;;) {
823
+ const at = haystack.indexOf(citation.context, searchFrom);
824
+ if (at === -1) break;
825
+ const found = numberAfter(haystack, at + citation.context.length);
826
+ if (found !== void 0 && normalizeNumber(found) !== normalizeNumber(citation.text)) {
827
+ different = found;
828
+ break;
829
+ }
830
+ searchFrom = at + citation.context.length;
831
+ }
832
+ if (different !== void 0) {
833
+ contradictions.push(`${citation.context}: claim says ${citation.text}, snapshot says ${different}`);
834
+ continue;
835
+ }
836
+ }
837
+ missing.push(citation.text);
838
+ }
839
+ if (contradictions.length > 0) return {
840
+ status: "contradicted",
841
+ note: `contradicts the snapshot: ${contradictions.slice(0, NOTE_LIST_CAP).join("; ")}`,
842
+ missing,
843
+ contradictions
844
+ };
845
+ if (missing.length > 0) return {
846
+ status: "unverified",
847
+ note: `citation(s) not found in bound evidence: ${missing.slice(0, NOTE_LIST_CAP).join(", ")}`,
848
+ missing,
849
+ contradictions
850
+ };
851
+ return {
852
+ status: "verified",
853
+ note: `${citations.length} citation(s) located verbatim in the bound snapshot(s)`,
854
+ missing,
855
+ contradictions
856
+ };
857
+ }
858
+ /**
859
+ * Map one bridge result set onto the three-state verdict vocabulary.
860
+ * `mismatch` maps to `contradicted`; `not-found`/`unverifiable` map to
861
+ * `unverified`.
862
+ * @param result - the dataQuality outcome.
863
+ * @returns the mapped status plus a human-readable note.
864
+ */
865
+ function mapBridgeResults(result) {
866
+ const mismatch = result.results.filter((entry) => entry.status === "mismatch");
867
+ if (mismatch.length > 0) return {
868
+ status: "contradicted",
869
+ note: `dataset cross-check mismatch: ${mismatch.slice(0, NOTE_LIST_CAP).map((entry) => `${entry.id}: dataset has ${String(entry.actual ?? "?")}${entry.note === void 0 ? "" : ` (${entry.note})`}`).join("; ")}`
870
+ };
871
+ const unresolved = result.results.filter((entry) => entry.status === "not-found" || entry.status === "unverifiable");
872
+ if (unresolved.length > 0) return {
873
+ status: "unverified",
874
+ note: `dataset cross-check unresolved: ${unresolved.slice(0, NOTE_LIST_CAP).map((entry) => `${entry.id}: ${entry.status}${entry.note === void 0 ? "" : ` (${entry.note})`}`).join("; ")}`
875
+ };
876
+ return {
877
+ status: "verified",
878
+ note: `${result.results.length} dataset citation(s) verified via ctx.dataQuality`
879
+ };
880
+ }
881
+ /**
882
+ * Combine the byte-level and bridge outcomes: `contradicted` wins, then
883
+ * `unverified`, then `verified`.
884
+ * @param byte - the byte-level outcome.
885
+ * @param bridge - the bridge outcome, when the numeric bridge ran.
886
+ * @returns the combined status and a merged note.
887
+ */
888
+ function combineOutcomes(byte, bridge) {
889
+ if (bridge === void 0) return {
890
+ status: byte.status,
891
+ note: byte.note
892
+ };
893
+ const rank = {
894
+ verified: 0,
895
+ unverified: 1,
896
+ contradicted: 2
897
+ };
898
+ return {
899
+ status: rank[bridge.status] > rank[byte.status] ? bridge.status : byte.status,
900
+ note: `${byte.note} | ${bridge.note}`
901
+ };
902
+ }
903
+ //#endregion
904
+ //#region src/version.ts
905
+ /**
906
+ * Single source of truth for the plugin version (stamped by scripts/release.mjs).
907
+ * @module dsh-research-report/version
908
+ */
909
+ /** The published package version. */
910
+ const VERSION = "0.1.0";
911
+ //#endregion
912
+ //#region src/provider-local.ts
913
+ /**
914
+ * The local Provider of the research-report seam: assembles the filesystem
915
+ * evidence ledger, the byte-level verifier, the optional numeric bridge, and
916
+ * the sealing renderer into the `ctx.researchReport` service implementation.
917
+ * @module dsh-research-report/provider-local
918
+ */
919
+ /** A loud provider failure with a machine-routable code. */
920
+ var ResearchReportError = class extends Error {
921
+ /** The machine-routable failure code. */
922
+ code;
923
+ constructor(code, message) {
924
+ super(message);
925
+ this.name = "ResearchReportError";
926
+ this.code = code;
927
+ }
928
+ };
929
+ /**
930
+ * rc.6's persistence layer refuses a session log carrying an event type it
931
+ * does not know, and rc.6 offers no plugin event-registration surface — so the
932
+ * research-report/* events are appended only when the host build already knows
933
+ * them. The ledger journals are always the durable source of truth; these
934
+ * events are the in-log audit mirror and activate automatically once the host
935
+ * learns the vocabulary.
936
+ * @param session - the owning session, when known.
937
+ * @param type - the event type.
938
+ * @param append - the typed append thunk.
939
+ */
940
+ function appendAudit(session, type, append) {
941
+ if (session === void 0) return;
942
+ if (!KNOWN_SESSION_EVENT_TYPES.has(type)) return;
943
+ append();
944
+ }
945
+ /**
946
+ * The local `ctx.researchReport` implementation. Everything durable lives in
947
+ * the filesystem ledger; the service adds policy (caps), verification, and
948
+ * sealing on top.
949
+ */
950
+ var LocalResearchReportService = class extends ResearchReportService {
951
+ /** The content-addressed ledger. */
952
+ ledger;
953
+ /** The resolved plugin config. */
954
+ config;
955
+ /** Absolute workspace root for local capture and path display. */
956
+ workspaceRoot;
957
+ /**
958
+ * @param ctx - the plugin context.
959
+ * @param config - the resolved plugin config.
960
+ * @param workspaceRoot - absolute workspace root (the harness cwd).
961
+ */
962
+ constructor(ctx, config, workspaceRoot) {
963
+ super(ctx);
964
+ this.config = config;
965
+ this.workspaceRoot = workspaceRoot;
966
+ this.ledger = new EvidenceLedger(config.ledgerRoot);
967
+ }
968
+ /** The web seam, resolved at call time (HMR-safe; may be absent). */
969
+ get web() {
970
+ return this.ctx.get("web");
971
+ }
972
+ /** The optional numeric bridge, resolved at call time (never injected). */
973
+ get dataQuality() {
974
+ return this.ctx.get("dataQuality");
975
+ }
976
+ /** Capture dependencies for the gather/capture paths. */
977
+ get captureDeps() {
978
+ return {
979
+ web: this.web,
980
+ fetchTimeoutMs: this.config.fetchTimeoutMs,
981
+ workspaceRoot: this.workspaceRoot
982
+ };
983
+ }
984
+ /**
985
+ * Register one evidence snapshot. Over-size content is refused loudly;
986
+ * same-content registrations dedupe.
987
+ * @param input - the snapshot and its provenance.
988
+ * @param session - the owning session (audit event), when known.
989
+ * @returns the durable record and whether this call created it.
990
+ */
991
+ async addEvidence(input, session) {
992
+ const bytes = Buffer.byteLength(input.content, "utf8");
993
+ if (bytes > this.config.maxEvidenceBytes) throw new ResearchReportError("EVIDENCE_TOO_LARGE", `evidence content is ${bytes} bytes, above the configured maxEvidenceBytes ${this.config.maxEvidenceBytes}`);
994
+ const capturedAt = input.capturedAt ?? (/* @__PURE__ */ new Date()).toISOString();
995
+ let outcome;
996
+ try {
997
+ outcome = await this.ledger.putEvidence({
998
+ ...input.id === void 0 ? {} : { id: input.id },
999
+ title: input.title,
1000
+ origin: input.origin,
1001
+ content: input.content,
1002
+ capturedAt
1003
+ });
1004
+ } catch (error) {
1005
+ if (error instanceof LedgerError) throw new ResearchReportError("LEDGER", error.message);
1006
+ throw error;
1007
+ }
1008
+ appendAudit(session, "research-report/evidence", () => {
1009
+ session?.append("research-report/evidence", {
1010
+ id: outcome.record.id,
1011
+ hash: outcome.record.hash,
1012
+ origin: outcome.record.origin,
1013
+ title: outcome.record.title,
1014
+ capturedAt: outcome.record.capturedAt,
1015
+ bytes: outcome.record.bytes,
1016
+ deduplicated: !outcome.created
1017
+ });
1018
+ });
1019
+ return {
1020
+ record: outcome.record,
1021
+ deduplicated: !outcome.created
1022
+ };
1023
+ }
1024
+ /**
1025
+ * Capture one origin (URL via ctx.web, workspace path via fs) and register
1026
+ * it. Provider-internal helper for the tools layer.
1027
+ * @param origin - URL or workspace path.
1028
+ * @param title - display title (defaults to the origin).
1029
+ * @param signal - caller cancellation.
1030
+ * @param session - the owning session (audit event), when known.
1031
+ * @returns the durable record and whether this call created it.
1032
+ */
1033
+ async captureAndRegister(origin, title, signal, session) {
1034
+ const snapshot = await captureSnapshot(this.captureDeps, origin, signal);
1035
+ return this.addEvidence({
1036
+ title: title ?? snapshot.origin,
1037
+ origin: snapshot.origin,
1038
+ content: snapshot.content
1039
+ }, session);
1040
+ }
1041
+ /**
1042
+ * Run one topic gather: search + snapshot capture + registration. Never
1043
+ * auto-assembles; uncaptured sources land in the gap list.
1044
+ * @param topic - the research topic.
1045
+ * @param depth - quick | standard | deep.
1046
+ * @param signal - caller cancellation.
1047
+ * @param session - the owning session (audit events), when known.
1048
+ * @returns candidates plus gaps.
1049
+ */
1050
+ async gather(topic, depth, signal, session) {
1051
+ return gatherCandidates(this.captureDeps, topic, depth, signal, async (input) => {
1052
+ const { record } = await this.addEvidence(input, session);
1053
+ return record;
1054
+ });
1055
+ }
1056
+ /**
1057
+ * Verify one registered claim against its bound snapshots: integrity first
1058
+ * (tampered/missing ⇒ contradicted), then the byte-level check, then the
1059
+ * optional numeric bridge. The verdict is written back to the ledger.
1060
+ * @param claimId - the claim to verify.
1061
+ * @param session - the owning session (audit event), when known.
1062
+ * @returns the fresh verdict.
1063
+ */
1064
+ async verifyClaim(claimId, session) {
1065
+ const claim = await this.ledger.getClaim(claimId);
1066
+ if (claim === void 0) throw new ResearchReportError("CLAIM_UNKNOWN", `unknown claim id "${claimId}"`);
1067
+ const verdict = await this.verifyRegistration(claim);
1068
+ await this.ledger.recordVerdict({
1069
+ claimId,
1070
+ status: verdict.status,
1071
+ ...verdict.note === void 0 ? {} : { note: verdict.note }
1072
+ }, (/* @__PURE__ */ new Date()).toISOString());
1073
+ appendAudit(session, "research-report/verify", () => {
1074
+ session?.append("research-report/verify", {
1075
+ claimId,
1076
+ status: verdict.status,
1077
+ ...verdict.note === void 0 ? {} : { note: verdict.note },
1078
+ evidenceIds: claim.evidenceIds
1079
+ });
1080
+ });
1081
+ return verdict;
1082
+ }
1083
+ /** Compute the verdict for one claim registration (no writeback). */
1084
+ async verifyRegistration(claim) {
1085
+ const contents = [];
1086
+ const broken = [];
1087
+ for (const evidenceId of claim.evidenceIds) {
1088
+ const read = await this.ledger.readContent(evidenceId);
1089
+ if (read === void 0) {
1090
+ broken.push(`${evidenceId} (not in the ledger)`);
1091
+ continue;
1092
+ }
1093
+ if (read.integrity !== "ok") {
1094
+ broken.push(`${evidenceId} (${read.integrity}: object bytes no longer match the indexed hash)`);
1095
+ continue;
1096
+ }
1097
+ contents.push(read.content);
1098
+ }
1099
+ let byte;
1100
+ if (broken.length > 0) byte = {
1101
+ status: "contradicted",
1102
+ note: `bound evidence failed the integrity check: ${broken.join("; ")}`,
1103
+ missing: [],
1104
+ contradictions: broken
1105
+ };
1106
+ else byte = verifyClaimText(claim.text, contents);
1107
+ let bridge;
1108
+ if (claim.dataset !== void 0 && claim.citations !== void 0 && claim.citations.length > 0) {
1109
+ const dataQuality = this.dataQuality;
1110
+ if (dataQuality === void 0) bridge = {
1111
+ status: "unverified",
1112
+ note: "numeric dataset citations not cross-checked: ctx.dataQuality is not mounted (dsh-data-quality absent); byte-level check only"
1113
+ };
1114
+ else try {
1115
+ bridge = mapBridgeResults(await dataQuality.verifyCitations({
1116
+ dataset: claim.dataset,
1117
+ citations: claim.citations
1118
+ }));
1119
+ } catch (error) {
1120
+ bridge = {
1121
+ status: "unverified",
1122
+ note: `numeric dataset bridge failed: ${error instanceof Error ? error.message : String(error)}`
1123
+ };
1124
+ }
1125
+ }
1126
+ const combined = combineOutcomes(byte, bridge);
1127
+ return {
1128
+ claimId: claim.id,
1129
+ status: combined.status,
1130
+ note: combined.note
1131
+ };
1132
+ }
1133
+ /**
1134
+ * Assemble and seal one report: validate (loud), register evidence and
1135
+ * claims (idempotent; conflicts throw), verify every claim, render
1136
+ * `report.md` with visible markers for unverified/contradicted claims, write
1137
+ * `manifest.json`, and seal the versioned directory with the manifest hash.
1138
+ * @param request - the frozen assemble request.
1139
+ * @param context - optional assemble context (owning session for events).
1140
+ * @returns the sealed directory, the seal hash, and the per-claim verdicts.
1141
+ */
1142
+ async assemble(request, context) {
1143
+ validateAssembleRequest(request, this.config);
1144
+ const records = [];
1145
+ for (const item of request.evidence) {
1146
+ const existing = await this.ledger.getEvidence(item.id);
1147
+ if (existing === void 0) {
1148
+ const added = await this.addEvidence({
1149
+ id: item.id,
1150
+ title: item.title,
1151
+ origin: item.origin,
1152
+ content: item.content,
1153
+ capturedAt: item.capturedAt
1154
+ }, context?.session);
1155
+ records.push(added.record);
1156
+ continue;
1157
+ }
1158
+ if (sha256Of(item.content) !== existing.hash) {
1159
+ const stored = await this.ledger.readContent(item.id);
1160
+ if (stored !== void 0 && stored.integrity === "ok") throw new ResearchReportError("LEDGER", `evidence id "${item.id}" is already registered with different content; snapshots are immutable — choose a new id`);
1161
+ }
1162
+ records.push(existing);
1163
+ }
1164
+ await this.ledger.registerClaims(request.claims.map((claim) => {
1165
+ const registration = claim;
1166
+ return {
1167
+ id: registration.id,
1168
+ text: registration.text,
1169
+ evidenceIds: registration.evidenceIds,
1170
+ ...registration.dataset === void 0 ? {} : { dataset: registration.dataset },
1171
+ ...registration.citations === void 0 ? {} : { citations: registration.citations }
1172
+ };
1173
+ }), (/* @__PURE__ */ new Date()).toISOString());
1174
+ const verdicts = [];
1175
+ for (const claim of request.claims) verdicts.push(await this.verifyClaim(claim.id, context?.session));
1176
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
1177
+ const plan = {
1178
+ request,
1179
+ verdicts,
1180
+ evidence: records,
1181
+ generatedAt,
1182
+ fingerprint: configFingerprint(this.config),
1183
+ pluginVersion: VERSION
1184
+ };
1185
+ const reportText = renderReportMarkdown(plan);
1186
+ const manifestText = serializeManifest(buildManifest(plan, sha256Of(reportText)));
1187
+ const sealHash = sha256Of(manifestText);
1188
+ const reportDir = await this.freshReportDir(request.topic, new Date(generatedAt));
1189
+ await writeFile(path.join(reportDir, "report.md"), reportText, "utf8");
1190
+ await writeFile(path.join(reportDir, "manifest.json"), manifestText, "utf8");
1191
+ appendAudit(context?.session, "research-report/seal", () => {
1192
+ context?.session?.append("research-report/seal", {
1193
+ reportDir,
1194
+ sealHash,
1195
+ topic: request.topic,
1196
+ title: request.title,
1197
+ verdicts
1198
+ });
1199
+ });
1200
+ return {
1201
+ reportDir,
1202
+ sealHash,
1203
+ verdicts
1204
+ };
1205
+ }
1206
+ /** Allocate the next versioned report directory for one topic (UTC clock). */
1207
+ async freshReportDir(topic, at) {
1208
+ const base = path.join(this.config.reportRoot, slugify(topic));
1209
+ await mkdir(base, { recursive: true });
1210
+ const stamp = versionIdOf(at);
1211
+ for (let suffix = 0;; suffix++) {
1212
+ const candidate = path.join(base, suffix === 0 ? stamp : `${stamp}-${suffix + 1}`);
1213
+ try {
1214
+ await mkdir(candidate);
1215
+ return candidate;
1216
+ } catch (error) {
1217
+ if (error.code !== "EEXIST") throw error;
1218
+ }
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Read one evidence item (re-hashed on read).
1223
+ * @param evidenceId - the ledger id.
1224
+ * @returns the view, or undefined when unknown.
1225
+ */
1226
+ async getEvidence(evidenceId) {
1227
+ const record = await this.ledger.getEvidence(evidenceId);
1228
+ if (record === void 0) return void 0;
1229
+ const read = await this.ledger.readContent(evidenceId);
1230
+ const integrity = read === void 0 ? "missing" : read.integrity;
1231
+ return {
1232
+ ...record,
1233
+ integrity
1234
+ };
1235
+ }
1236
+ /**
1237
+ * Read one snapshot's bytes (re-hashed on read).
1238
+ * @param evidenceId - the ledger id.
1239
+ * @returns content plus integrity, or undefined when unknown.
1240
+ */
1241
+ async readEvidenceContent(evidenceId) {
1242
+ return this.ledger.readContent(evidenceId);
1243
+ }
1244
+ /**
1245
+ * Read one claim with its latest verdict.
1246
+ * @param claimId - the claim id.
1247
+ * @returns the view, or undefined when unknown.
1248
+ */
1249
+ async getClaim(claimId) {
1250
+ const claim = await this.ledger.getClaim(claimId);
1251
+ if (claim === void 0) return void 0;
1252
+ const verdict = (await this.ledger.latestVerdicts()).get(claimId);
1253
+ return {
1254
+ id: claim.id,
1255
+ text: claim.text,
1256
+ evidenceIds: claim.evidenceIds,
1257
+ ...claim.dataset === void 0 ? {} : { dataset: claim.dataset },
1258
+ ...verdict === void 0 ? {} : { verdict: {
1259
+ claimId: verdict.claimId,
1260
+ status: verdict.status,
1261
+ ...verdict.note === void 0 ? {} : { note: verdict.note },
1262
+ at: verdict.at
1263
+ } }
1264
+ };
1265
+ }
1266
+ /**
1267
+ * List every registered evidence item (re-hashed on read).
1268
+ * @returns all evidence views in registration order.
1269
+ */
1270
+ async listEvidence() {
1271
+ const records = await this.ledger.listEvidence();
1272
+ const views = [];
1273
+ for (const record of records) {
1274
+ const read = await this.ledger.readContent(record.id);
1275
+ views.push({
1276
+ ...record,
1277
+ integrity: read === void 0 ? "missing" : read.integrity
1278
+ });
1279
+ }
1280
+ return views;
1281
+ }
1282
+ /**
1283
+ * List every registered claim with its latest verdict.
1284
+ * @returns all claim views in registration order.
1285
+ */
1286
+ async listClaims() {
1287
+ const claims = await this.ledger.listClaims();
1288
+ const verdicts = await this.ledger.latestVerdicts();
1289
+ return claims.map((claim) => {
1290
+ const verdict = verdicts.get(claim.id);
1291
+ return {
1292
+ id: claim.id,
1293
+ text: claim.text,
1294
+ evidenceIds: claim.evidenceIds,
1295
+ ...claim.dataset === void 0 ? {} : { dataset: claim.dataset },
1296
+ ...verdict === void 0 ? {} : { verdict: {
1297
+ claimId: verdict.claimId,
1298
+ status: verdict.status,
1299
+ ...verdict.note === void 0 ? {} : { note: verdict.note },
1300
+ at: verdict.at
1301
+ } }
1302
+ };
1303
+ });
1304
+ }
1305
+ /**
1306
+ * Aggregate ledger counts (evidence re-hashed for the tamper count).
1307
+ * @returns the summary.
1308
+ */
1309
+ async summarize() {
1310
+ const evidence = await this.listEvidence();
1311
+ const claims = await this.ledger.listClaims();
1312
+ const verdicts = await this.ledger.latestVerdicts();
1313
+ return {
1314
+ evidenceCount: evidence.length,
1315
+ claimCount: claims.length,
1316
+ verdictCount: verdicts.size,
1317
+ tamperedCount: evidence.filter((item) => item.integrity !== "ok").length
1318
+ };
1319
+ }
1320
+ };
1321
+ //#endregion
1322
+ //#region src/tools/evidence-add.ts
1323
+ /**
1324
+ * The `evidence_add` model tool (Consumer): register one evidence snapshot in
1325
+ * the ledger. `content` given inline is used verbatim; absent, the origin is
1326
+ * captured — a URL through the `ctx.web` seam, a workspace path from disk.
1327
+ * @module dsh-research-report/tools/evidence-add
1328
+ */
1329
+ /** The tool's output schema (both branches). */
1330
+ const OUTPUT_SCHEMA$2 = {
1331
+ type: "object",
1332
+ additionalProperties: false,
1333
+ properties: {
1334
+ ok: {
1335
+ type: "boolean",
1336
+ required: true
1337
+ },
1338
+ evidenceId: { type: "string" },
1339
+ hash: { type: "string" },
1340
+ bytes: { type: "integer" },
1341
+ title: { type: "string" },
1342
+ origin: { type: "string" },
1343
+ capturedAt: { type: "string" },
1344
+ deduplicated: { type: "boolean" },
1345
+ error: {
1346
+ type: "object",
1347
+ additionalProperties: false,
1348
+ properties: {
1349
+ code: {
1350
+ type: "string",
1351
+ required: true
1352
+ },
1353
+ message: {
1354
+ type: "string",
1355
+ required: true
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1360
+ };
1361
+ /**
1362
+ * Build the `evidence_add` tool bound to the local provider.
1363
+ * @param service - the local research-report provider.
1364
+ * @returns the tool definition.
1365
+ */
1366
+ function makeEvidenceAddTool(service) {
1367
+ return defineTool({
1368
+ name: "evidence_add",
1369
+ description: [
1370
+ "Register one evidence snapshot in the verifiable research ledger (dsh-research-report).",
1371
+ "",
1372
+ "Pass `content` inline when you already hold the text; otherwise the origin is captured for you — a URL is fetched through the harness web capability (ctx.web), a workspace-relative path is read from disk. Snapshots are content-addressed and immutable: the same content is stored once, and any later byte change is detected as tampering during verification.",
1373
+ "",
1374
+ "Returns the evidence id and its SHA-256 hash. Bind the id to claims in research_report."
1375
+ ].join("\n"),
1376
+ parameters: {
1377
+ origin: {
1378
+ type: "string",
1379
+ required: true,
1380
+ description: "Where the evidence comes from: an http(s) URL or a workspace-relative path."
1381
+ },
1382
+ content: {
1383
+ type: "string",
1384
+ description: "The verbatim snapshot text. When omitted, the origin is captured (URL fetched / file read)."
1385
+ },
1386
+ title: {
1387
+ type: "string",
1388
+ description: "Display title (defaults to the origin)."
1389
+ }
1390
+ },
1391
+ output: {
1392
+ schema: OUTPUT_SCHEMA$2,
1393
+ render: (_args, value) => {
1394
+ const result = value;
1395
+ if (!result.ok) return [{
1396
+ type: "text",
1397
+ text: `evidence_add failed (${result.error.code}): ${result.error.message}`
1398
+ }];
1399
+ return [{
1400
+ type: "text",
1401
+ text: `evidence registered: ${result.evidenceId}${result.deduplicated ? " (already stored — deduplicated)" : ""}\nsha256: ${result.hash}\norigin: ${result.origin}\nbytes: ${result.bytes}`
1402
+ }];
1403
+ }
1404
+ },
1405
+ async execute(args, exec) {
1406
+ exec.signal.throwIfAborted();
1407
+ const session = exec.agent?.session;
1408
+ try {
1409
+ const added = args.content !== void 0 ? await service.addEvidence({
1410
+ title: args.title ?? args.origin,
1411
+ origin: args.origin,
1412
+ content: args.content
1413
+ }, session) : await service.captureAndRegister(args.origin, args.title, exec.signal, session);
1414
+ return {
1415
+ ok: true,
1416
+ evidenceId: added.record.id,
1417
+ hash: added.record.hash,
1418
+ bytes: added.record.bytes,
1419
+ title: added.record.title,
1420
+ origin: added.record.origin,
1421
+ capturedAt: added.record.capturedAt,
1422
+ deduplicated: added.deduplicated
1423
+ };
1424
+ } catch (error) {
1425
+ if (error instanceof CaptureError && error.code === "WEB_UNAVAILABLE") throw error;
1426
+ if (error instanceof CaptureError || error instanceof ResearchReportError) return {
1427
+ ok: false,
1428
+ error: {
1429
+ code: error.code,
1430
+ message: error.message
1431
+ }
1432
+ };
1433
+ throw error;
1434
+ }
1435
+ }
1436
+ });
1437
+ }
1438
+ //#endregion
1439
+ //#region src/tools/ledger-query.ts
1440
+ /**
1441
+ * The `ledger_query` model tool (Consumer): read-only queries over the
1442
+ * evidence ledger — bindings, verdicts, and the live integrity re-check.
1443
+ * @module dsh-research-report/tools/ledger-query
1444
+ */
1445
+ /** The tool's output schema (all four branches). */
1446
+ const OUTPUT_SCHEMA$1 = {
1447
+ type: "object",
1448
+ additionalProperties: false,
1449
+ properties: {
1450
+ kind: {
1451
+ type: "string",
1452
+ required: true,
1453
+ enum: [
1454
+ "evidence",
1455
+ "claim",
1456
+ "summary",
1457
+ "not-found"
1458
+ ]
1459
+ },
1460
+ evidence: {
1461
+ type: "object",
1462
+ additionalProperties: false,
1463
+ properties: {
1464
+ id: {
1465
+ type: "string",
1466
+ required: true
1467
+ },
1468
+ hash: {
1469
+ type: "string",
1470
+ required: true
1471
+ },
1472
+ title: {
1473
+ type: "string",
1474
+ required: true
1475
+ },
1476
+ origin: {
1477
+ type: "string",
1478
+ required: true
1479
+ },
1480
+ capturedAt: {
1481
+ type: "string",
1482
+ required: true
1483
+ },
1484
+ bytes: {
1485
+ type: "integer",
1486
+ required: true
1487
+ },
1488
+ integrity: {
1489
+ type: "string",
1490
+ required: true,
1491
+ enum: [
1492
+ "ok",
1493
+ "tampered",
1494
+ "missing"
1495
+ ]
1496
+ }
1497
+ }
1498
+ },
1499
+ claim: {
1500
+ type: "object",
1501
+ additionalProperties: false,
1502
+ properties: {
1503
+ id: {
1504
+ type: "string",
1505
+ required: true
1506
+ },
1507
+ text: {
1508
+ type: "string",
1509
+ required: true
1510
+ },
1511
+ evidenceIds: {
1512
+ type: "array",
1513
+ required: true,
1514
+ items: { type: "string" }
1515
+ },
1516
+ dataset: { type: "string" },
1517
+ verdict: {
1518
+ type: "object",
1519
+ additionalProperties: false,
1520
+ properties: {
1521
+ claimId: {
1522
+ type: "string",
1523
+ required: true
1524
+ },
1525
+ status: {
1526
+ type: "string",
1527
+ required: true,
1528
+ enum: [
1529
+ "verified",
1530
+ "unverified",
1531
+ "contradicted"
1532
+ ]
1533
+ },
1534
+ note: { type: "string" },
1535
+ at: {
1536
+ type: "string",
1537
+ required: true
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ },
1543
+ evidenceCount: { type: "integer" },
1544
+ claimCount: { type: "integer" },
1545
+ verdictCount: { type: "integer" },
1546
+ tamperedCount: { type: "integer" },
1547
+ evidenceIds: {
1548
+ type: "array",
1549
+ items: { type: "string" }
1550
+ },
1551
+ claimIds: {
1552
+ type: "array",
1553
+ items: { type: "string" }
1554
+ },
1555
+ message: { type: "string" }
1556
+ }
1557
+ };
1558
+ /** Render the canonical value as model-facing text. */
1559
+ function renderValue$1(value) {
1560
+ switch (value.kind) {
1561
+ case "evidence": {
1562
+ const item = value.evidence;
1563
+ const lines = [
1564
+ `evidence ${item.id}${item.integrity === "ok" ? "" : ` — INTEGRITY ${item.integrity.toUpperCase()}`}`,
1565
+ ` title: ${item.title}`,
1566
+ ` origin: ${item.origin}`,
1567
+ ` sha256: ${item.hash}`,
1568
+ ` captured: ${item.capturedAt} (${item.bytes} bytes)`
1569
+ ];
1570
+ if (item.integrity !== "ok") lines.push(` WARNING: the stored bytes no longer match the indexed hash — any claim bound to this evidence verifies as contradicted`);
1571
+ return [{
1572
+ type: "text",
1573
+ text: lines.join("\n")
1574
+ }];
1575
+ }
1576
+ case "claim": {
1577
+ const claim = value.claim;
1578
+ const lines = [
1579
+ `claim ${claim.id}`,
1580
+ ` text: ${claim.text}`,
1581
+ ` evidence: ${claim.evidenceIds.join(", ") || "(none)"}`
1582
+ ];
1583
+ if (claim.dataset !== void 0) lines.push(` dataset: ${claim.dataset}`);
1584
+ if (claim.verdict === void 0) lines.push(" verdict: (never verified)");
1585
+ else lines.push(` verdict: ${claim.verdict.status} at ${claim.verdict.at}${claim.verdict.note === void 0 ? "" : ` — ${claim.verdict.note}`}`);
1586
+ return [{
1587
+ type: "text",
1588
+ text: lines.join("\n")
1589
+ }];
1590
+ }
1591
+ case "summary": return [{
1592
+ type: "text",
1593
+ text: [
1594
+ `ledger summary: ${value.evidenceCount} evidence, ${value.claimCount} claims, ${value.verdictCount} verdicts, ${value.tamperedCount} integrity failures`,
1595
+ `evidence ids: ${value.evidenceIds.join(", ") || "(none)"}`,
1596
+ `claim ids: ${value.claimIds.join(", ") || "(none)"}`
1597
+ ].join("\n")
1598
+ }];
1599
+ case "not-found": return [{
1600
+ type: "text",
1601
+ text: value.message
1602
+ }];
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Build the `ledger_query` tool bound to the local provider.
1607
+ * @param service - the local research-report provider.
1608
+ * @returns the tool definition.
1609
+ */
1610
+ function makeLedgerQueryTool(service) {
1611
+ return defineTool({
1612
+ name: "ledger_query",
1613
+ description: ["Read-only query over the verifiable research ledger (dsh-research-report): claim ↔ evidence bindings and verification verdicts.", "Pass claimId or evidenceId for one entry (evidence is re-hashed on read — integrity tampered/missing is reported explicitly), or neither for a ledger summary."].join("\n"),
1614
+ parameters: {
1615
+ claimId: {
1616
+ type: "string",
1617
+ description: "Query one claim: its bindings and latest verdict."
1618
+ },
1619
+ evidenceId: {
1620
+ type: "string",
1621
+ description: "Query one evidence item: provenance, hash, live integrity."
1622
+ }
1623
+ },
1624
+ output: {
1625
+ schema: OUTPUT_SCHEMA$1,
1626
+ render: (_args, value) => renderValue$1(value)
1627
+ },
1628
+ async execute(args, exec) {
1629
+ exec.signal.throwIfAborted();
1630
+ if (args.claimId !== void 0) {
1631
+ const claim = await service.getClaim(args.claimId);
1632
+ return claim === void 0 ? {
1633
+ kind: "not-found",
1634
+ message: `no claim "${args.claimId}" in the ledger`
1635
+ } : {
1636
+ kind: "claim",
1637
+ claim
1638
+ };
1639
+ }
1640
+ if (args.evidenceId !== void 0) {
1641
+ const evidence = await service.getEvidence(args.evidenceId);
1642
+ return evidence === void 0 ? {
1643
+ kind: "not-found",
1644
+ message: `no evidence "${args.evidenceId}" in the ledger`
1645
+ } : {
1646
+ kind: "evidence",
1647
+ evidence
1648
+ };
1649
+ }
1650
+ const [summary, evidence, claims] = await Promise.all([
1651
+ service.summarize(),
1652
+ service.listEvidence(),
1653
+ service.listClaims()
1654
+ ]);
1655
+ return {
1656
+ kind: "summary",
1657
+ ...summary,
1658
+ evidenceIds: evidence.map((item) => item.id),
1659
+ claimIds: claims.map((claim) => claim.id)
1660
+ };
1661
+ }
1662
+ });
1663
+ }
1664
+ //#endregion
1665
+ //#region src/tools/research-report.ts
1666
+ /**
1667
+ * The `research_report` model tool (Consumer): assemble and seal one
1668
+ * verifiable report from ledger evidence, or — with `gather: true` — run one
1669
+ * search round over `ctx.web` and hand the candidate/gap list back to the
1670
+ * model for confirmation (never auto-assembles). Long runs may go to a
1671
+ * `research-report` background job over `ctx.jobs`.
1672
+ * @module dsh-research-report/tools/research-report
1673
+ */
1674
+ /** The tool's output schema (all three branches). */
1675
+ const OUTPUT_SCHEMA = {
1676
+ type: "object",
1677
+ additionalProperties: false,
1678
+ properties: {
1679
+ kind: {
1680
+ type: "string",
1681
+ required: true,
1682
+ enum: [
1683
+ "sealed",
1684
+ "background",
1685
+ "gathered"
1686
+ ]
1687
+ },
1688
+ reportDir: { type: "string" },
1689
+ reportFile: { type: "string" },
1690
+ manifestFile: { type: "string" },
1691
+ sealHash: { type: "string" },
1692
+ verdicts: {
1693
+ type: "array",
1694
+ items: {
1695
+ type: "object",
1696
+ additionalProperties: false,
1697
+ properties: {
1698
+ claimId: {
1699
+ type: "string",
1700
+ required: true
1701
+ },
1702
+ status: {
1703
+ type: "string",
1704
+ required: true,
1705
+ enum: [
1706
+ "verified",
1707
+ "unverified",
1708
+ "contradicted"
1709
+ ]
1710
+ },
1711
+ note: { type: "string" }
1712
+ }
1713
+ }
1714
+ },
1715
+ counts: {
1716
+ type: "object",
1717
+ additionalProperties: false,
1718
+ properties: {
1719
+ verified: {
1720
+ type: "integer",
1721
+ required: true
1722
+ },
1723
+ unverified: {
1724
+ type: "integer",
1725
+ required: true
1726
+ },
1727
+ contradicted: {
1728
+ type: "integer",
1729
+ required: true
1730
+ }
1731
+ }
1732
+ },
1733
+ evidenceCount: { type: "integer" },
1734
+ jobId: { type: "string" },
1735
+ topic: { type: "string" },
1736
+ candidates: {
1737
+ type: "array",
1738
+ items: {
1739
+ type: "object",
1740
+ additionalProperties: false,
1741
+ properties: {
1742
+ url: {
1743
+ type: "string",
1744
+ required: true
1745
+ },
1746
+ title: { type: "string" },
1747
+ snippet: { type: "string" },
1748
+ status: {
1749
+ type: "string",
1750
+ required: true,
1751
+ enum: ["captured", "uncaptured"]
1752
+ },
1753
+ evidenceId: { type: "string" },
1754
+ reason: { type: "string" }
1755
+ }
1756
+ }
1757
+ },
1758
+ gaps: {
1759
+ type: "array",
1760
+ items: { type: "string" }
1761
+ }
1762
+ }
1763
+ };
1764
+ /** The sections parameter schema fragment. */
1765
+ const sectionsSchema = {
1766
+ type: "array",
1767
+ items: {
1768
+ type: "object",
1769
+ additionalProperties: false,
1770
+ properties: {
1771
+ heading: {
1772
+ type: "string",
1773
+ required: true
1774
+ },
1775
+ paragraphs: {
1776
+ type: "array",
1777
+ required: true,
1778
+ items: {
1779
+ type: "object",
1780
+ additionalProperties: false,
1781
+ properties: {
1782
+ text: {
1783
+ type: "string",
1784
+ required: true
1785
+ },
1786
+ claimIds: {
1787
+ type: "array",
1788
+ items: { type: "string" }
1789
+ }
1790
+ }
1791
+ }
1792
+ }
1793
+ }
1794
+ }
1795
+ };
1796
+ /** The claims parameter schema fragment (frozen shape + the optional numeric bridge). */
1797
+ const claimsSchema = {
1798
+ type: "array",
1799
+ items: {
1800
+ type: "object",
1801
+ additionalProperties: false,
1802
+ properties: {
1803
+ id: {
1804
+ type: "string",
1805
+ required: true
1806
+ },
1807
+ text: {
1808
+ type: "string",
1809
+ required: true
1810
+ },
1811
+ evidenceIds: {
1812
+ type: "array",
1813
+ required: true,
1814
+ items: { type: "string" }
1815
+ },
1816
+ dataset: { type: "string" },
1817
+ citations: {
1818
+ type: "array",
1819
+ items: {
1820
+ type: "object",
1821
+ additionalProperties: false,
1822
+ properties: {
1823
+ id: {
1824
+ type: "string",
1825
+ required: true
1826
+ },
1827
+ path: {
1828
+ type: "string",
1829
+ required: true
1830
+ },
1831
+ value: {
1832
+ oneOf: [{ type: "number" }, { type: "string" }],
1833
+ required: true
1834
+ },
1835
+ tolerance: { type: "number" }
1836
+ }
1837
+ }
1838
+ }
1839
+ }
1840
+ }
1841
+ };
1842
+ /** Render the sealed branch as model-facing text (gaps/contradictions explicit). */
1843
+ function renderSealed(value) {
1844
+ const lines = [
1845
+ `report sealed: ${value.reportDir}`,
1846
+ `seal (sha256 of manifest.json): ${value.sealHash}`,
1847
+ `claims: ${value.counts.verified} verified / ${value.counts.unverified} unverified / ${value.counts.contradicted} contradicted (of ${value.verdicts.length}); evidence bound: ${value.evidenceCount}`
1848
+ ];
1849
+ const problems = value.verdicts.filter((verdict) => verdict.status !== "verified");
1850
+ if (problems.length > 0) {
1851
+ lines.push("", "claims needing attention (visible markers kept in the report body):");
1852
+ for (const verdict of problems) lines.push(`- [${verdict.status}] ${verdict.claimId}${verdict.note === void 0 ? "" : ` — ${verdict.note}`}`);
1853
+ }
1854
+ return lines.join("\n");
1855
+ }
1856
+ /** Render the canonical value as model-facing text. */
1857
+ function renderValue(value) {
1858
+ switch (value.kind) {
1859
+ case "background": return [{
1860
+ type: "text",
1861
+ text: `started background report job ${value.jobId}; read progress with job_output and stop it with job_kill — the final output names the sealed directory and seal hash`
1862
+ }];
1863
+ case "gathered": {
1864
+ const lines = [`gathered ${value.candidates.length} candidate source(s) for "${value.topic}" (nothing assembled yet — confirm the evidence set first):`];
1865
+ for (const candidate of value.candidates) lines.push(candidate.status === "captured" ? `- captured ${candidate.evidenceId}: ${candidate.title ?? candidate.url}` : `- uncaptured ${candidate.url}: ${candidate.reason ?? "unknown reason"}`);
1866
+ if (value.gaps.length > 0) {
1867
+ lines.push("", "gaps to close:");
1868
+ for (const gap of value.gaps) lines.push(`- ${gap}`);
1869
+ }
1870
+ lines.push("", "next: call research_report again with evidenceRefs chosen from the captured ids (or add more with evidence_add).");
1871
+ return [{
1872
+ type: "text",
1873
+ text: lines.join("\n")
1874
+ }];
1875
+ }
1876
+ case "sealed": return [{
1877
+ type: "text",
1878
+ text: renderSealed(value)
1879
+ }];
1880
+ }
1881
+ }
1882
+ /**
1883
+ * Build the `research_report` tool bound to the local provider.
1884
+ * @param deps - the plugin context plus the provider.
1885
+ * @returns the tool definition.
1886
+ */
1887
+ function makeResearchReportTool(deps) {
1888
+ const { service } = deps;
1889
+ return defineTool({
1890
+ name: "research_report",
1891
+ description: [
1892
+ "Assemble and seal a verifiable research report (dsh-research-report).",
1893
+ "",
1894
+ "Every claim must bind evidence already in the ledger (evidence_add) and is verified against the stored bytes: numbers and quoted spans must be locatable verbatim. The report directory is versioned and sealed (manifest.json + SHA-256 seal hash); unverified or contradicted claims keep a visible [未核实] / [与证据矛盾] marker in the body and are listed in Appendix A — they are never silently passed.",
1895
+ "",
1896
+ "Optional convenience: set gather: true to run ONE search round over the topic via the harness web capability. Captured snapshots are registered as evidence; the candidate list plus an explicit gap list come back for your confirmation — nothing is assembled automatically.",
1897
+ "",
1898
+ "Set background: true for a background job (returns a job id; read with job_output, stop with job_kill)."
1899
+ ].join("\n"),
1900
+ parameters: {
1901
+ topic: {
1902
+ type: "string",
1903
+ required: true,
1904
+ description: "The research topic (names the versioned report directory)."
1905
+ },
1906
+ title: {
1907
+ type: "string",
1908
+ description: "Report title (defaults to \"Research report: <topic>\")."
1909
+ },
1910
+ sections: {
1911
+ ...sectionsSchema,
1912
+ description: "Report body: heading + paragraphs; each paragraph may cite claim ids."
1913
+ },
1914
+ claims: {
1915
+ ...claimsSchema,
1916
+ description: "Claim registrations: id, text, bound evidence ids, and the optional dataset citation bridge."
1917
+ },
1918
+ evidenceRefs: {
1919
+ type: "array",
1920
+ items: { type: "string" },
1921
+ description: "Ledger evidence ids (from evidence_add / gather) to bind into this report."
1922
+ },
1923
+ gather: {
1924
+ type: "boolean",
1925
+ description: "Run one search round and return candidates + gaps instead of assembling."
1926
+ },
1927
+ depth: {
1928
+ type: "string",
1929
+ enum: [
1930
+ "quick",
1931
+ "standard",
1932
+ "deep"
1933
+ ],
1934
+ description: "Gather depth: quick=3, standard=5, deep=8 sources."
1935
+ },
1936
+ background: {
1937
+ type: "boolean",
1938
+ description: "Assemble as a background job (ctx.jobs) and return the job id immediately."
1939
+ }
1940
+ },
1941
+ output: {
1942
+ schema: OUTPUT_SCHEMA,
1943
+ render: (_args, value) => renderValue(value),
1944
+ presentationMeta: (_args, value) => {
1945
+ const result = value;
1946
+ return result.kind === "sealed" ? sealedMeta(result) : {};
1947
+ }
1948
+ },
1949
+ async execute(args, exec) {
1950
+ exec.signal.throwIfAborted();
1951
+ const session = exec.agent?.session;
1952
+ if (args.gather === true) {
1953
+ const outcome = await service.gather(args.topic, args.depth ?? "standard", exec.signal, session);
1954
+ return {
1955
+ kind: "gathered",
1956
+ topic: outcome.topic,
1957
+ candidates: outcome.candidates,
1958
+ gaps: outcome.gaps
1959
+ };
1960
+ }
1961
+ const request = await buildRequest(service, args);
1962
+ if (args.background === true) {
1963
+ const jobs = deps.ctx.get("jobs");
1964
+ if (jobs === void 0) throw new Error("background jobs unavailable: this composition mounts no ctx.jobs (load @deepseek-ai/dsh-jobs-local and @deepseek-ai/dsh-tool-jobs), or call without background");
1965
+ if (exec.signal.aborted) throw new Error("tool call aborted");
1966
+ return {
1967
+ kind: "background",
1968
+ jobId: jobs.start({
1969
+ kind: "research-report",
1970
+ label: `assemble report: ${args.topic}`,
1971
+ ...exec.agent === void 0 ? {} : { owner: exec.agent },
1972
+ run: () => startAssembleJob(service, request, session === void 0 ? {} : { session })
1973
+ })
1974
+ };
1975
+ }
1976
+ const result = await service.assemble(request, session === void 0 ? {} : { session });
1977
+ return sealedValue(result.reportDir, result.sealHash, result.verdicts, request.evidence.length);
1978
+ },
1979
+ presentCall: (args) => {
1980
+ const topic = args.topic;
1981
+ return {
1982
+ card: "generic",
1983
+ title: `Research report: ${typeof topic === "string" ? topic : ""}`
1984
+ };
1985
+ },
1986
+ presentResult: (_args, result) => {
1987
+ const meta = result.meta;
1988
+ if (meta?.reportFile === void 0 || meta.manifestFile === void 0) return void 0;
1989
+ return {
1990
+ card: "generic",
1991
+ title: "Sealed research report",
1992
+ kind: "edit",
1993
+ locations: [{ path: meta.reportFile }, { path: meta.manifestFile }]
1994
+ };
1995
+ }
1996
+ });
1997
+ }
1998
+ /** The durable presentation projection (report file paths for the UI card). */
1999
+ function sealedMeta(value) {
2000
+ return {
2001
+ reportFile: value.reportFile,
2002
+ manifestFile: value.manifestFile
2003
+ };
2004
+ }
2005
+ /** Shape the sealed canonical value. */
2006
+ function sealedValue(reportDir, sealHash, verdicts, evidenceCount) {
2007
+ const counts = {
2008
+ verified: 0,
2009
+ unverified: 0,
2010
+ contradicted: 0
2011
+ };
2012
+ for (const verdict of verdicts) counts[verdict.status] += 1;
2013
+ return {
2014
+ kind: "sealed",
2015
+ reportDir,
2016
+ reportFile: path.join(reportDir, "report.md"),
2017
+ manifestFile: path.join(reportDir, "manifest.json"),
2018
+ sealHash,
2019
+ verdicts,
2020
+ counts,
2021
+ evidenceCount
2022
+ };
2023
+ }
2024
+ /**
2025
+ * Build the frozen assemble request from the tool args: resolve evidenceRefs
2026
+ * through the ledger (unknown ids fail loud) and default the title.
2027
+ * @param service - the local provider.
2028
+ * @param args - the validated tool args.
2029
+ * @returns the assemble request.
2030
+ */
2031
+ async function buildRequest(service, args) {
2032
+ const refs = args.evidenceRefs ?? [];
2033
+ const evidence = [];
2034
+ for (const ref of refs) {
2035
+ const record = await service.getEvidence(ref);
2036
+ const read = await service.readEvidenceContent(ref);
2037
+ if (record === void 0 || read === void 0) throw new Error(`unknown evidence id "${ref}" in evidenceRefs — register it with evidence_add first`);
2038
+ evidence.push({
2039
+ id: record.id,
2040
+ title: record.title,
2041
+ origin: record.origin,
2042
+ content: read.content,
2043
+ capturedAt: record.capturedAt
2044
+ });
2045
+ }
2046
+ return {
2047
+ title: args.title ?? `Research report: ${args.topic}`,
2048
+ topic: args.topic,
2049
+ evidence,
2050
+ sections: args.sections ?? [],
2051
+ claims: args.claims ?? []
2052
+ };
2053
+ }
2054
+ /**
2055
+ * Start the background assemble job body. The job owns its cancellation
2056
+ * signal; settlement flushes the sealed summary into the job output.
2057
+ * @param service - the local provider.
2058
+ * @param request - the frozen assemble request.
2059
+ * @param context - the assemble context (owning session, when known).
2060
+ * @returns the job hooks.
2061
+ */
2062
+ function startAssembleJob(service, request, context) {
2063
+ const abort = new AbortController();
2064
+ const progress = [`assembling report: ${request.topic}`];
2065
+ const done = Promise.withResolvers();
2066
+ let settled = false;
2067
+ const settle = (outcome) => {
2068
+ if (settled) return;
2069
+ settled = true;
2070
+ done.resolve(outcome);
2071
+ };
2072
+ service.assemble(request, context).then((result) => {
2073
+ const value = sealedValue(result.reportDir, result.sealHash, result.verdicts, request.evidence.length);
2074
+ progress.push(renderSealed(value));
2075
+ settle({
2076
+ status: "completed",
2077
+ detail: `sealed ${result.sealHash.slice(0, 12)}`,
2078
+ output: renderSealed(value)
2079
+ });
2080
+ }).catch((error) => {
2081
+ const message = error instanceof CaptureError || error instanceof Error ? error.message : String(error);
2082
+ progress.push(`assemble failed: ${message}`);
2083
+ settle({
2084
+ status: "failed",
2085
+ detail: message.length > 200 ? `${message.slice(0, 197)}…` : message
2086
+ });
2087
+ });
2088
+ return {
2089
+ cancel(reason) {
2090
+ abort.abort(reason ?? "cancelled");
2091
+ settle({
2092
+ status: "killed",
2093
+ detail: `cancelled: ${reason ?? "no reason given"}`
2094
+ });
2095
+ },
2096
+ done: done.promise,
2097
+ readOutput: () => {
2098
+ if (progress.length === 0) return "";
2099
+ return `${progress.splice(0, progress.length).join("\n")}\n`;
2100
+ }
2101
+ };
2102
+ }
2103
+ //#endregion
2104
+ //#region src/index.ts
2105
+ const name = "research-report";
2106
+ /**
2107
+ * Public services only. `web` (evidence capture) and `jobs` (background
2108
+ * assembly) are deliberately OPTIONAL and resolved with `ctx.get` at call
2109
+ * time: a composition without them still mounts, and the affected paths fail
2110
+ * loud with an explicit reason.
2111
+ */
2112
+ const inject = ["tools", "systemPrompt"];
2113
+ /** The short prompt section: one role statement plus the workflow. */
2114
+ const PROMPT_SECTION = ["You have a verifiable research-report engine (dsh-research-report) whose reports prove every claim against stored evidence bytes.", "When asked for a research deliverable: register evidence snapshots with evidence_add (URL or workspace path), then call research_report with sections whose paragraphs cite claim ids bound to those evidence ids. Every claim is verified against the stored snapshot bytes; unverified or contradicted claims stay visibly marked in the sealed report — never paper over them. ledger_query reads bindings and verdicts back."].join("\n");
2115
+ /**
2116
+ * Mount the engine: resolve config (fail loud), construct the local provider
2117
+ * (registering `ctx.researchReport` on this fiber), register the three tools,
2118
+ * and contribute the short prompt section.
2119
+ * @param ctx - the plugin context (host).
2120
+ * @param config - raw plugin config.
2121
+ */
2122
+ function apply(ctx, config) {
2123
+ const resolved = resolveConfig(config);
2124
+ const logger = ctx.logger("research-report");
2125
+ if (!resolved.enabled) {
2126
+ logger.info("disabled: enabled is false — no service, tools, or prompt section are mounted");
2127
+ return;
2128
+ }
2129
+ const service = new LocalResearchReportService(ctx, resolved, process.cwd());
2130
+ ctx.effect(() => ctx.tools.register(makeEvidenceAddTool(service)), "research-report: evidence_add tool");
2131
+ ctx.effect(() => ctx.tools.register(makeResearchReportTool({
2132
+ ctx,
2133
+ service
2134
+ })), "research-report: research_report tool");
2135
+ ctx.effect(() => ctx.tools.register(makeLedgerQueryTool(service)), "research-report: ledger_query tool");
2136
+ ctx.systemPrompt.section({
2137
+ name: "dsh-research-report:workflow",
2138
+ order: 10,
2139
+ text: PROMPT_SECTION
2140
+ });
2141
+ }
2142
+ //#endregion
2143
+ export { CONTRADICTED_MARK, CaptureError, Config, EvidenceLedger, GATHER_DEPTH_RESULTS, LedgerError, LocalResearchReportService, MANIFEST_SCHEMA, RequestValidationError, ResearchReportError, ResearchReportService, UNVERIFIED_MARK, VERSION, apply, buildManifest, captureFromFile, captureFromWeb, captureSnapshot, combineOutcomes, configFingerprint, contextLabelOf, extractCitations, gatherCandidates, inject, isUrlOrigin, mapBridgeResults, name, normalizeNumber, renderReportMarkdown, resolveConfig, resolveWorkspacePath, serializeManifest, sha256Of, slugify, toWorkspaceRelative, validateAssembleRequest, verifyClaimText, versionIdOf };