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
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Report assembly and sealing — pure functions. The provider owns the ledger
3
+ * and the filesystem; this module owns request validation, the `report.md`
4
+ * rendering, the `manifest.json` construction, and the seal hash.
5
+ *
6
+ * Sealing: `report.md` is rendered deterministically from the validated
7
+ * request plus the verdicts; `manifest.json` carries the report hash, every
8
+ * evidence hash, and the verdicts; the seal hash is the SHA-256 of the exact
9
+ * manifest bytes. Recomputing the hashes from the sealed directory always
10
+ * reproduces the seal.
11
+ *
12
+ * @module dsh-research-report/assemble
13
+ */
14
+
15
+ import { sha256Of } from './ledger.ts'
16
+ import type { EvidenceRecord } from './service.ts'
17
+ import type { AssembleReportRequest, ClaimVerdict } from './service.ts'
18
+
19
+ /** The manifest schema tag written into every manifest.json. */
20
+ export const MANIFEST_SCHEMA = 'dsh-research-report/v1'
21
+
22
+ /** Body marker appended after a paragraph per UNVERIFIED claim it cites. */
23
+ export const UNVERIFIED_MARK = '[未核实]'
24
+
25
+ /** Body marker appended after a paragraph per CONTRADICTED claim it cites. */
26
+ export const CONTRADICTED_MARK = '[与证据矛盾]'
27
+
28
+ /** A loud assemble-time request validation failure. */
29
+ export class RequestValidationError extends Error {
30
+ constructor(message: string) {
31
+ super(message)
32
+ this.name = 'RequestValidationError'
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Slug one topic for the report directory name: unicode letters and digits
38
+ * are kept, everything else folds to `-`; empty slugs fall back to `report`.
39
+ * @param topic - the report topic.
40
+ * @returns a filesystem-safe slug of at most 48 characters.
41
+ */
42
+ export function slugify(topic: string): string {
43
+ const slug = topic
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
47
+ .replace(/^-+|-+$/gu, '')
48
+ .slice(0, 48)
49
+ return slug === '' ? 'report' : slug
50
+ }
51
+
52
+ /**
53
+ * Format one timestamp as the version directory id `YYYYMMDD-HHmmss` (UTC).
54
+ * @param at - the time to format.
55
+ * @returns the directory id.
56
+ */
57
+ export function versionIdOf(at: Date): string {
58
+ const pad = (value: number): string => String(value).padStart(2, '0')
59
+ return [
60
+ `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}`,
61
+ `${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`,
62
+ ].join('-')
63
+ }
64
+
65
+ /**
66
+ * The config fingerprint recorded in every report: a short hash of the
67
+ * resolved runtime knobs, so two reports sealed under different policies are
68
+ * distinguishable at a glance.
69
+ * @param knobs - the policy values that shape assembly output.
70
+ * @returns 16 hex characters of the knobs' SHA-256.
71
+ */
72
+ export function configFingerprint(knobs: { maxEvidenceBytes: number; maxEvidencePerReport: number }): string {
73
+ const stable = JSON.stringify({
74
+ maxEvidenceBytes: knobs.maxEvidenceBytes,
75
+ maxEvidencePerReport: knobs.maxEvidencePerReport,
76
+ })
77
+ return sha256Of(stable).slice(0, 16)
78
+ }
79
+
80
+ /**
81
+ * Validate one assemble request; every violation throws (a loud rejection —
82
+ * the caller must fix the request, nothing is silently skipped).
83
+ * @param request - the frozen assemble request.
84
+ * @param limits - the resolved caps.
85
+ */
86
+ export function validateAssembleRequest(
87
+ request: AssembleReportRequest,
88
+ limits: { maxEvidenceBytes: number; maxEvidencePerReport: number },
89
+ ): void {
90
+ if (request.title.trim() === '') throw new RequestValidationError('title must be non-empty')
91
+ if (request.topic.trim() === '') throw new RequestValidationError('topic must be non-empty')
92
+ if (request.sections.length === 0) throw new RequestValidationError('sections must contain at least one section')
93
+ for (const [index, section] of request.sections.entries()) {
94
+ if (section.heading.trim() === '') throw new RequestValidationError(`sections[${index}].heading must be non-empty`)
95
+ }
96
+ if (request.evidence.length > limits.maxEvidencePerReport) {
97
+ throw new RequestValidationError(
98
+ `evidence has ${request.evidence.length} items, above the configured maxEvidencePerReport ${limits.maxEvidencePerReport}`,
99
+ )
100
+ }
101
+ const evidenceIds = new Set<string>()
102
+ for (const item of request.evidence) {
103
+ if (item.id.trim() === '') throw new RequestValidationError('evidence id must be non-empty')
104
+ if (evidenceIds.has(item.id)) throw new RequestValidationError(`duplicate evidence id "${item.id}"`)
105
+ evidenceIds.add(item.id)
106
+ const bytes = Buffer.byteLength(item.content, 'utf8')
107
+ if (bytes > limits.maxEvidenceBytes) {
108
+ throw new RequestValidationError(
109
+ `evidence "${item.id}" is ${bytes} bytes, above the configured maxEvidenceBytes ${limits.maxEvidenceBytes}`,
110
+ )
111
+ }
112
+ if (Number.isNaN(Date.parse(item.capturedAt))) {
113
+ throw new RequestValidationError(`evidence "${item.id}" has an unparseable capturedAt ${JSON.stringify(item.capturedAt)}`)
114
+ }
115
+ }
116
+ const claimIds = new Set<string>()
117
+ for (const claim of request.claims) {
118
+ if (claim.id.trim() === '') throw new RequestValidationError('claim id must be non-empty')
119
+ if (claimIds.has(claim.id)) throw new RequestValidationError(`duplicate claim id "${claim.id}"`)
120
+ claimIds.add(claim.id)
121
+ for (const evidenceId of claim.evidenceIds) {
122
+ if (!evidenceIds.has(evidenceId)) {
123
+ throw new RequestValidationError(`claim "${claim.id}" binds unknown evidence id "${evidenceId}"`)
124
+ }
125
+ }
126
+ }
127
+ for (const [sectionIndex, section] of request.sections.entries()) {
128
+ for (const [paragraphIndex, paragraph] of section.paragraphs.entries()) {
129
+ for (const claimId of paragraph.claimIds ?? []) {
130
+ if (!claimIds.has(claimId)) {
131
+ throw new RequestValidationError(
132
+ `sections[${sectionIndex}].paragraphs[${paragraphIndex}] cites unregistered claim id "${claimId}"`,
133
+ )
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ /** Everything the renderers need: the validated request plus the outcomes. */
141
+ export interface ReportPlan {
142
+ /** The validated request. */
143
+ request: AssembleReportRequest
144
+ /** Per-claim verdicts (claim registration order). */
145
+ verdicts: ClaimVerdict[]
146
+ /** The durable evidence records (hash + provenance). */
147
+ evidence: EvidenceRecord[]
148
+ /** ISO-8601 generation time. */
149
+ generatedAt: string
150
+ /** The config fingerprint. */
151
+ fingerprint: string
152
+ /** The generator version. */
153
+ pluginVersion: string
154
+ }
155
+
156
+ /** The status mark used in the appendix table. */
157
+ function statusMark(status: ClaimVerdict['status']): string {
158
+ switch (status) {
159
+ case 'verified': return '✅ verified'
160
+ case 'unverified': return '⚠️ unverified'
161
+ case 'contradicted': return '❌ contradicted'
162
+ }
163
+ }
164
+
165
+ /** Escape a table cell. */
166
+ function cell(text: string): string {
167
+ return text.replace(/\|/gu, '\\|').replace(/\r?\n/gu, ' ')
168
+ }
169
+
170
+ /**
171
+ * Render `report.md`. Unverified/contradicted claims keep a visible body
172
+ * marker after every paragraph that cites them — nothing is silently passed.
173
+ * @param plan - the validated request plus verdicts and evidence records.
174
+ * @returns the report markdown.
175
+ */
176
+ export function renderReportMarkdown(plan: ReportPlan): string {
177
+ const verdictByClaim = new Map(plan.verdicts.map(verdict => [verdict.claimId, verdict]))
178
+ const counts = { verified: 0, unverified: 0, contradicted: 0 }
179
+ for (const verdict of plan.verdicts) counts[verdict.status] += 1
180
+
181
+ const lines: string[] = [
182
+ `# ${plan.request.title}`,
183
+ '',
184
+ `- Topic: ${plan.request.topic}`,
185
+ `- Generated: ${plan.generatedAt} (UTC)`,
186
+ `- Claims: ${counts.verified} verified / ${counts.unverified} unverified / ${counts.contradicted} contradicted`,
187
+ `- Generator: dsh-research-report ${plan.pluginVersion}`,
188
+ '',
189
+ ]
190
+
191
+ for (const section of plan.request.sections) {
192
+ lines.push(`## ${section.heading}`, '')
193
+ for (const paragraph of section.paragraphs) {
194
+ const marks: string[] = []
195
+ for (const claimId of paragraph.claimIds ?? []) {
196
+ const verdict = verdictByClaim.get(claimId)
197
+ if (verdict?.status === 'unverified') marks.push(UNVERIFIED_MARK)
198
+ if (verdict?.status === 'contradicted') marks.push(CONTRADICTED_MARK)
199
+ }
200
+ lines.push(marks.length === 0 ? paragraph.text : `${paragraph.text} ${marks.join(' ')}`, '')
201
+ }
202
+ }
203
+
204
+ lines.push('## Appendix A: Claim verification', '')
205
+ if (plan.verdicts.length === 0) {
206
+ lines.push('No claims were registered.', '')
207
+ } else {
208
+ lines.push('| Claim | Verdict | Evidence | Note |', '|---|---|---|---|')
209
+ const claimById = new Map(plan.request.claims.map(claim => [claim.id, claim]))
210
+ for (const verdict of plan.verdicts) {
211
+ const claim = claimById.get(verdict.claimId)
212
+ lines.push(
213
+ `| ${cell(verdict.claimId)} | ${statusMark(verdict.status)} | ${cell((claim?.evidenceIds ?? []).join(', '))} | ${cell(verdict.note ?? '')} |`,
214
+ )
215
+ }
216
+ lines.push('')
217
+ }
218
+
219
+ lines.push('## Appendix B: Evidence list', '')
220
+ if (plan.evidence.length === 0) {
221
+ lines.push('No evidence was bound.', '')
222
+ } else {
223
+ lines.push('| Id | Title | Origin | SHA-256 | Captured |', '|---|---|---|---|---|')
224
+ for (const record of plan.evidence) {
225
+ lines.push(`| ${cell(record.id)} | ${cell(record.title)} | ${cell(record.origin)} | \`${record.hash}\` | ${record.capturedAt} |`)
226
+ }
227
+ lines.push('')
228
+ }
229
+
230
+ lines.push(
231
+ '## Appendix C: Seal',
232
+ '',
233
+ `- Manifest: \`manifest.json\` (schema ${MANIFEST_SCHEMA})`,
234
+ `- Config fingerprint: \`${plan.fingerprint}\``,
235
+ `- Generated: ${plan.generatedAt} (UTC)`,
236
+ '',
237
+ )
238
+ return `${lines.join('\n').trimEnd()}\n`
239
+ }
240
+
241
+ /** The durable manifest document (`manifest.json`). */
242
+ export interface ReportManifest {
243
+ schema: typeof MANIFEST_SCHEMA
244
+ title: string
245
+ topic: string
246
+ generatedAt: string
247
+ generator: string
248
+ reportFile: string
249
+ reportSha256: string
250
+ configFingerprint: string
251
+ evidence: Array<{
252
+ id: string
253
+ sha256: string
254
+ origin: string
255
+ title: string
256
+ capturedAt: string
257
+ bytes: number
258
+ }>
259
+ claims: Array<{ id: string; text: string; evidenceIds: string[] }>
260
+ verdicts: ClaimVerdict[]
261
+ }
262
+
263
+ /**
264
+ * Build the manifest document for one sealed report. Key order is fixed by
265
+ * construction so the serialized bytes (and therefore the seal hash) are
266
+ * deterministic for the same inputs.
267
+ * @param plan - the validated request plus verdicts and evidence records.
268
+ * @param reportSha256 - the SHA-256 of the rendered report.md bytes.
269
+ * @returns the manifest document.
270
+ */
271
+ export function buildManifest(plan: ReportPlan, reportSha256: string): ReportManifest {
272
+ return {
273
+ schema: MANIFEST_SCHEMA,
274
+ title: plan.request.title,
275
+ topic: plan.request.topic,
276
+ generatedAt: plan.generatedAt,
277
+ generator: `dsh-research-report ${plan.pluginVersion}`,
278
+ reportFile: 'report.md',
279
+ reportSha256,
280
+ configFingerprint: plan.fingerprint,
281
+ evidence: plan.evidence.map(record => ({
282
+ id: record.id,
283
+ sha256: record.hash,
284
+ origin: record.origin,
285
+ title: record.title,
286
+ capturedAt: record.capturedAt,
287
+ bytes: record.bytes,
288
+ })),
289
+ claims: plan.request.claims.map(claim => ({ id: claim.id, text: claim.text, evidenceIds: claim.evidenceIds })),
290
+ verdicts: plan.verdicts,
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Serialize one manifest to its exact durable bytes. The seal hash is the
296
+ * SHA-256 of this text.
297
+ * @param manifest - the manifest document.
298
+ * @returns the canonical manifest.json content.
299
+ */
300
+ export function serializeManifest(manifest: ReportManifest): string {
301
+ return `${JSON.stringify(manifest, null, 2)}\n`
302
+ }
package/src/config.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Config schema and resolution for `dsh-research-report`. Every tunable is a
3
+ * validated {@link Config} field changeable from cordis.yml; the resolution
4
+ * step validates bounds so misconfiguration fails loud at mount.
5
+ * @module dsh-research-report/config
6
+ */
7
+
8
+ import path from 'node:path'
9
+ import z from '@deepseek-ai/schemastery'
10
+
11
+ /** Raw plugin config — every field optional; {@link resolveConfig} supplies the defaults. */
12
+ export interface Config {
13
+ /** Master switch. `false` mounts nothing (no service, no tools, no prompt section). */
14
+ enabled?: boolean
15
+ /**
16
+ * Evidence-ledger directory (content-addressed objects + JSONL journals),
17
+ * resolved against the harness working directory when relative.
18
+ */
19
+ ledgerRoot?: string
20
+ /**
21
+ * Sealed-report root; each assemble writes
22
+ * `<reportRoot>/<slug(topic)>/<YYYYMMDD-HHmmss>/{report.md,manifest.json}`.
23
+ * Resolved against the harness working directory when relative.
24
+ */
25
+ reportRoot?: string
26
+ /** Hard cap on one evidence snapshot's UTF-8 byte size. */
27
+ maxEvidenceBytes?: number
28
+ /** Hard cap on how many evidence items one report may bind. */
29
+ maxEvidencePerReport?: number
30
+ /** Deadline (ms) for one `ctx.web` fetch during evidence capture. */
31
+ fetchTimeoutMs?: number
32
+ }
33
+
34
+ /** Fully resolved config handed to the runtime; roots are absolute paths. */
35
+ export interface ResolvedConfig {
36
+ readonly enabled: boolean
37
+ readonly ledgerRoot: string
38
+ readonly reportRoot: string
39
+ readonly maxEvidenceBytes: number
40
+ readonly maxEvidencePerReport: number
41
+ readonly fetchTimeoutMs: number
42
+ }
43
+
44
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
45
+ export const Config: z<Config> = z.object({
46
+ enabled: z.boolean().default(true),
47
+ ledgerRoot: z.string().default('.research-ledger'),
48
+ reportRoot: z.string().default('research-reports'),
49
+ maxEvidenceBytes: z.number().default(2 * 1024 * 1024),
50
+ maxEvidencePerReport: z.number().default(200),
51
+ fetchTimeoutMs: z.number().default(20_000),
52
+ })
53
+
54
+ /** Throw unless `value` is a positive safe integer. */
55
+ function assertPositiveInt(name: string, value: number): void {
56
+ if (!Number.isSafeInteger(value) || value <= 0) {
57
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}`)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve one configured root to an absolute path. Relative roots anchor at
63
+ * the harness working directory (the workspace the deployment runs in), which
64
+ * keeps the ledger and the sealed reports inside the workspace by default.
65
+ * @param value - the configured root.
66
+ * @param name - the config key, for error messages.
67
+ * @returns the absolute root path.
68
+ */
69
+ function resolveRoot(value: string, name: string): string {
70
+ if (typeof value !== 'string' || value.trim() === '') {
71
+ throw new TypeError(`${name} must be a non-empty path`)
72
+ }
73
+ return path.resolve(value)
74
+ }
75
+
76
+ /**
77
+ * Validate raw values and fill explicit defaults. Invalid bounds throw here —
78
+ * misconfiguration fails loud at mount even without the Schemastery loader.
79
+ * @param config - raw (possibly partial) plugin config.
80
+ * @returns the fully resolved config.
81
+ */
82
+ export function resolveConfig(config: Config = {}): ResolvedConfig {
83
+ const maxEvidenceBytes = config.maxEvidenceBytes ?? 2 * 1024 * 1024
84
+ assertPositiveInt('maxEvidenceBytes', maxEvidenceBytes)
85
+ const maxEvidencePerReport = config.maxEvidencePerReport ?? 200
86
+ assertPositiveInt('maxEvidencePerReport', maxEvidencePerReport)
87
+ const fetchTimeoutMs = config.fetchTimeoutMs ?? 20_000
88
+ assertPositiveInt('fetchTimeoutMs', fetchTimeoutMs)
89
+ return {
90
+ enabled: config.enabled ?? true,
91
+ ledgerRoot: resolveRoot(config.ledgerRoot ?? '.research-ledger', 'ledgerRoot'),
92
+ reportRoot: resolveRoot(config.reportRoot ?? 'research-reports', 'reportRoot'),
93
+ maxEvidenceBytes,
94
+ maxEvidencePerReport,
95
+ fetchTimeoutMs,
96
+ }
97
+ }
package/src/gather.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Evidence capture: URL snapshots via the `ctx.web` seam, workspace file
3
+ * snapshots via `node:fs` — never a direct `fetch` (provider selection and the
4
+ * WebError taxonomy stay with the seam), never a path outside the workspace.
5
+ * @module dsh-research-report/gather
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises'
9
+ import path from 'node:path'
10
+ import type { WebRuntime } from '@deepseek-ai/dsh-web'
11
+
12
+ /** Capture failure codes surfaced to the tools layer. */
13
+ export type CaptureErrorCode =
14
+ | 'WEB_UNAVAILABLE'
15
+ | 'FETCH_FAILED'
16
+ | 'FETCH_STATUS'
17
+ | 'FETCH_TIMEOUT'
18
+ | 'ORIGIN_UNREADABLE'
19
+ | 'ORIGIN_OUTSIDE_WORKSPACE'
20
+
21
+ /** A loud capture failure with a machine-routable code (also in the message). */
22
+ export class CaptureError extends Error {
23
+ /** The machine-routable failure code. */
24
+ readonly code: CaptureErrorCode
25
+ constructor(code: CaptureErrorCode, message: string) {
26
+ super(`[${code}] ${message}`)
27
+ this.name = 'CaptureError'
28
+ this.code = code
29
+ }
30
+ }
31
+
32
+ /** Whether the origin is an HTTP(S) URL (vs a workspace path). */
33
+ export function isUrlOrigin(origin: string): boolean {
34
+ return /^https?:\/\//iu.test(origin)
35
+ }
36
+
37
+ /** Everything the capture paths need. */
38
+ export interface CaptureDeps {
39
+ /** The web seam, when the composition mounts it. */
40
+ web: WebRuntime | undefined
41
+ /** Fetch deadline (ms). */
42
+ fetchTimeoutMs: number
43
+ /** Absolute workspace root; local reads never escape it. */
44
+ workspaceRoot: string
45
+ }
46
+
47
+ /** One captured snapshot. */
48
+ export interface CapturedSnapshot {
49
+ /** The verbatim snapshot bytes as UTF-8 text. */
50
+ content: string
51
+ /** The effective origin (final URL after redirects, or the workspace-relative path). */
52
+ origin: string
53
+ }
54
+
55
+ /**
56
+ * Resolve a workspace-relative origin to an absolute path inside the
57
+ * workspace. Both sides are resolved before comparison (Windows backslash
58
+ * trap) and the prefix check is segment-aware.
59
+ * @param workspaceRoot - absolute workspace root.
60
+ * @param origin - the workspace-relative (or absolute) origin.
61
+ * @returns the absolute in-workspace path.
62
+ */
63
+ export function resolveWorkspacePath(workspaceRoot: string, origin: string): string {
64
+ const root = path.resolve(workspaceRoot)
65
+ const resolved = path.resolve(root, origin)
66
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
67
+ throw new CaptureError('ORIGIN_OUTSIDE_WORKSPACE', `origin ${JSON.stringify(origin)} resolves outside the workspace`)
68
+ }
69
+ return resolved
70
+ }
71
+
72
+ /** Relativize an absolute in-workspace path for display/durable records. */
73
+ export function toWorkspaceRelative(workspaceRoot: string, absolute: string): string {
74
+ const relative = path.relative(path.resolve(workspaceRoot), path.resolve(absolute))
75
+ return relative.split(path.sep).join('/')
76
+ }
77
+
78
+ /**
79
+ * Capture one URL snapshot through the web seam.
80
+ * @param deps - web seam, deadline, workspace root.
81
+ * @param url - the URL to fetch.
82
+ * @param signal - caller cancellation.
83
+ * @returns the snapshot (throws {@link CaptureError} on every failure).
84
+ */
85
+ export async function captureFromWeb(deps: CaptureDeps, url: string, signal?: AbortSignal): Promise<CapturedSnapshot> {
86
+ if (deps.web === undefined) {
87
+ throw new CaptureError(
88
+ 'WEB_UNAVAILABLE',
89
+ 'the web capability (ctx.web) is not mounted in this composition; pass `content` explicitly or load @deepseek-ai/dsh-web with a fetch provider',
90
+ )
91
+ }
92
+ const timeout = AbortSignal.timeout(deps.fetchTimeoutMs)
93
+ const linked = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
94
+ let result
95
+ try {
96
+ result = await deps.web.fetch({ url }, linked)
97
+ } catch (error) {
98
+ if (timeout.aborted && (signal === undefined || !signal.aborted)) {
99
+ throw new CaptureError('FETCH_TIMEOUT', `fetch of ${url} exceeded the configured fetchTimeoutMs ${deps.fetchTimeoutMs}`)
100
+ }
101
+ const message = error instanceof Error ? error.message : String(error)
102
+ throw new CaptureError('FETCH_FAILED', `fetch of ${url} failed: ${message}`)
103
+ }
104
+ if (result.statusCode < 200 || result.statusCode >= 300) {
105
+ throw new CaptureError('FETCH_STATUS', `fetch of ${url} returned HTTP ${result.statusCode}; no snapshot captured`)
106
+ }
107
+ return { content: result.body.content, origin: result.url }
108
+ }
109
+
110
+ /**
111
+ * Capture one workspace file snapshot.
112
+ * @param deps - workspace root (the web fields are unused here).
113
+ * @param origin - the workspace-relative path.
114
+ * @returns the snapshot (throws {@link CaptureError} when unreadable).
115
+ */
116
+ export async function captureFromFile(deps: CaptureDeps, origin: string): Promise<CapturedSnapshot> {
117
+ const absolute = resolveWorkspacePath(deps.workspaceRoot, origin)
118
+ let content: string
119
+ try {
120
+ content = await readFile(absolute, 'utf8')
121
+ } catch (error) {
122
+ throw new CaptureError('ORIGIN_UNREADABLE', `cannot read ${JSON.stringify(origin)}: ${(error as Error).message}`)
123
+ }
124
+ return { content, origin: toWorkspaceRelative(deps.workspaceRoot, absolute) }
125
+ }
126
+
127
+ /**
128
+ * Capture one snapshot from any supported origin.
129
+ * @param deps - web seam, deadline, workspace root.
130
+ * @param origin - URL or workspace path.
131
+ * @param signal - caller cancellation.
132
+ * @returns the snapshot.
133
+ */
134
+ export async function captureSnapshot(deps: CaptureDeps, origin: string, signal?: AbortSignal): Promise<CapturedSnapshot> {
135
+ return isUrlOrigin(origin) ? captureFromWeb(deps, origin, signal) : captureFromFile(deps, origin)
136
+ }
137
+
138
+ // ── Topic gathering (the optional `gather: true` convenience) ───────────────
139
+
140
+ /** Search depth → how many sources are fetched for snapshot capture. */
141
+ export const GATHER_DEPTH_RESULTS = { quick: 3, standard: 5, deep: 8 } as const
142
+
143
+ /** The gather depth knob. */
144
+ export type GatherDepth = keyof typeof GATHER_DEPTH_RESULTS
145
+
146
+ /** One gathered source and whether its snapshot landed in the ledger. */
147
+ export interface GatherCandidate {
148
+ /** The source URL. */
149
+ url: string
150
+ /** Provider title, when given. */
151
+ title?: string
152
+ /** Provider snippet, when given. */
153
+ snippet?: string
154
+ /** `captured` entries carry the ledger evidence id. */
155
+ status: 'captured' | 'uncaptured'
156
+ /** Ledger evidence id (captured only). */
157
+ evidenceId?: string
158
+ /** Why the snapshot could not be captured (uncaptured only). */
159
+ reason?: string
160
+ }
161
+
162
+ /** The gather outcome: candidates plus an explicit gap list for the model. */
163
+ export interface GatherOutcome {
164
+ /** The searched topic. */
165
+ topic: string
166
+ /** Every source the search returned, with capture status. */
167
+ candidates: GatherCandidate[]
168
+ /** Explicit gaps the model should close before assembling. */
169
+ gaps: string[]
170
+ }
171
+
172
+ /**
173
+ * Run one search over the topic and capture snapshots for the top sources.
174
+ * Captured snapshots are registered through `register`; uncaptured sources
175
+ * land in the gap list with their reason — gathering never fabricates
176
+ * evidence and never auto-assembles.
177
+ * @param deps - web seam, deadline, workspace root.
178
+ * @param topic - the research topic.
179
+ * @param depth - quick | standard | deep.
180
+ * @param signal - caller cancellation.
181
+ * @param register - ledger registration callback for captured snapshots.
182
+ * @returns candidates plus gaps.
183
+ */
184
+ export async function gatherCandidates(
185
+ deps: CaptureDeps,
186
+ topic: string,
187
+ depth: GatherDepth,
188
+ signal: AbortSignal | undefined,
189
+ register: (input: { title: string; origin: string; content: string }) => Promise<{ id: string }>,
190
+ ): Promise<GatherOutcome> {
191
+ if (deps.web === undefined) {
192
+ throw new CaptureError(
193
+ 'WEB_UNAVAILABLE',
194
+ 'the web capability (ctx.web) is not mounted in this composition; gather needs @deepseek-ai/dsh-web with a search provider',
195
+ )
196
+ }
197
+ const maxResults = GATHER_DEPTH_RESULTS[depth]
198
+ let search
199
+ try {
200
+ search = await deps.web.search({ query: topic, maxResults }, signal)
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error)
203
+ throw new CaptureError('FETCH_FAILED', `search for ${JSON.stringify(topic)} failed: ${message}`)
204
+ }
205
+ const candidates: GatherCandidate[] = []
206
+ const gaps: string[] = []
207
+ for (const source of search.sources) {
208
+ try {
209
+ const snapshot = await captureFromWeb(deps, source.url, signal)
210
+ const record = await register({
211
+ title: source.title ?? source.url,
212
+ origin: snapshot.origin,
213
+ content: snapshot.content,
214
+ })
215
+ candidates.push({
216
+ url: source.url,
217
+ ...(source.title === undefined ? {} : { title: source.title }),
218
+ ...(source.snippet === undefined ? {} : { snippet: source.snippet }),
219
+ status: 'captured',
220
+ evidenceId: record.id,
221
+ })
222
+ } catch (error) {
223
+ const reason = error instanceof CaptureError ? `${error.code}: ${error.message}` : String(error)
224
+ candidates.push({
225
+ url: source.url,
226
+ ...(source.title === undefined ? {} : { title: source.title }),
227
+ ...(source.snippet === undefined ? {} : { snippet: source.snippet }),
228
+ status: 'uncaptured',
229
+ reason,
230
+ })
231
+ gaps.push(`no snapshot for ${source.url} (${reason}) — add it with evidence_add once content is available`)
232
+ }
233
+ }
234
+ if (search.truncated) gaps.push(`search returned more than ${maxResults} sources; only the top ${maxResults} were considered`)
235
+ if (candidates.every(candidate => candidate.status === 'uncaptured')) {
236
+ gaps.push('no evidence was captured; the report cannot be assembled until at least one snapshot lands in the ledger')
237
+ }
238
+ return { topic, candidates, gaps }
239
+ }