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/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `dsh-research-report` — a domain-agnostic verifiable research-report engine
3
+ * for DeepSeek Harness. A content-addressed evidence ledger (claim ↔ snapshot
4
+ * binding, tamper-evident) plus versioned sealed reports: every claim carries
5
+ * a verification verdict, and the manifest hash seals the report directory.
6
+ * Retrieval orchestration is deliberately NOT re-implemented here — evidence
7
+ * gathering reuses the official `ctx.web` seam and long runs ride `ctx.jobs`.
8
+ *
9
+ * One package carries the complete capability seam: `service.ts` is the
10
+ * Service Definition (`ctx.researchReport`, with the byte-frozen assemble
11
+ * contract), `provider-local.ts` is the local Provider, and `tools/` the
12
+ * model-facing Consumers.
13
+ *
14
+ * Function plugin — no default export (the Loader unwraps
15
+ * `exports.default ?? exports`, and a stray default would discard
16
+ * `name`/`inject`/`Config`/`apply`).
17
+ * @module dsh-research-report
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ // Type-only: registers the `ctx.systemPrompt` Context merge for the inject.
22
+ import type {} from '@deepseek-ai/dsh-system-prompt'
23
+ import { Config, resolveConfig } from './config.ts'
24
+ import { LocalResearchReportService } from './provider-local.ts'
25
+ import { makeEvidenceAddTool } from './tools/evidence-add.ts'
26
+ import { makeLedgerQueryTool } from './tools/ledger-query.ts'
27
+ import { makeResearchReportTool } from './tools/research-report.ts'
28
+
29
+ export const name = 'research-report'
30
+
31
+ /**
32
+ * Public services only. `web` (evidence capture) and `jobs` (background
33
+ * assembly) are deliberately OPTIONAL and resolved with `ctx.get` at call
34
+ * time: a composition without them still mounts, and the affected paths fail
35
+ * loud with an explicit reason.
36
+ */
37
+ export const inject = ['tools', 'systemPrompt']
38
+
39
+ export { Config, resolveConfig } from './config.ts'
40
+ export type { ResolvedConfig } from './config.ts'
41
+ export { VERSION } from './version.ts'
42
+ export { ResearchReportService } from './service.ts'
43
+ export type {
44
+ AddEvidenceInput,
45
+ AssembleContext,
46
+ AssembleReportRequest,
47
+ AssembleReportResult,
48
+ ClaimRegistration,
49
+ ClaimVerdict,
50
+ ClaimView,
51
+ EvidenceInput,
52
+ EvidenceIntegrity,
53
+ EvidenceRecord,
54
+ EvidenceView,
55
+ LedgerSummary,
56
+ ReportSectionInput,
57
+ StoredVerdict,
58
+ VerdictStatus,
59
+ } from './service.ts'
60
+ export { EvidenceLedger, LedgerError, sha256Of } from './ledger.ts'
61
+ export type { LedgerClaimLine, LedgerIndexLine, LedgerVerdictLine } from './ledger.ts'
62
+ export {
63
+ combineOutcomes,
64
+ contextLabelOf,
65
+ extractCitations,
66
+ mapBridgeResults,
67
+ normalizeNumber,
68
+ verifyClaimText,
69
+ } from './verify.ts'
70
+ export type {
71
+ ByteCheckOutcome,
72
+ Citation,
73
+ CitationCheckRequest,
74
+ CitationCheckResult,
75
+ DataQualityBridge,
76
+ } from './verify.ts'
77
+ export {
78
+ CONTRADICTED_MARK,
79
+ MANIFEST_SCHEMA,
80
+ RequestValidationError,
81
+ UNVERIFIED_MARK,
82
+ buildManifest,
83
+ configFingerprint,
84
+ renderReportMarkdown,
85
+ serializeManifest,
86
+ slugify,
87
+ validateAssembleRequest,
88
+ versionIdOf,
89
+ } from './assemble.ts'
90
+ export type { ReportManifest, ReportPlan } from './assemble.ts'
91
+ export {
92
+ CaptureError,
93
+ GATHER_DEPTH_RESULTS,
94
+ captureFromFile,
95
+ captureFromWeb,
96
+ captureSnapshot,
97
+ gatherCandidates,
98
+ isUrlOrigin,
99
+ resolveWorkspacePath,
100
+ toWorkspaceRelative,
101
+ } from './gather.ts'
102
+ export type { CaptureDeps, GatherCandidate, GatherOutcome } from './gather.ts'
103
+ export { LocalResearchReportService, ResearchReportError } from './provider-local.ts'
104
+
105
+ /** The short prompt section: one role statement plus the workflow. */
106
+ const PROMPT_SECTION = [
107
+ 'You have a verifiable research-report engine (dsh-research-report) whose reports prove every claim against stored evidence bytes.',
108
+ '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.',
109
+ ].join('\n')
110
+
111
+ /**
112
+ * Mount the engine: resolve config (fail loud), construct the local provider
113
+ * (registering `ctx.researchReport` on this fiber), register the three tools,
114
+ * and contribute the short prompt section.
115
+ * @param ctx - the plugin context (host).
116
+ * @param config - raw plugin config.
117
+ */
118
+ export function apply(ctx: Context, config: Config): void {
119
+ const resolved = resolveConfig(config)
120
+ const logger = ctx.logger('research-report')
121
+ if (!resolved.enabled) {
122
+ logger.info('disabled: enabled is false — no service, tools, or prompt section are mounted')
123
+ return
124
+ }
125
+
126
+ // The provider registers itself as ctx.researchReport on construction and
127
+ // is unregistered with this fiber (Service base semantics).
128
+ const service = new LocalResearchReportService(ctx, resolved, process.cwd())
129
+
130
+ ctx.effect(() => ctx.tools.register(makeEvidenceAddTool(service)), 'research-report: evidence_add tool')
131
+ ctx.effect(() => ctx.tools.register(makeResearchReportTool({ ctx, service })), 'research-report: research_report tool')
132
+ ctx.effect(() => ctx.tools.register(makeLedgerQueryTool(service)), 'research-report: ledger_query tool')
133
+
134
+ ctx.systemPrompt.section({
135
+ name: 'dsh-research-report:workflow',
136
+ order: 10,
137
+ text: PROMPT_SECTION,
138
+ })
139
+ }
package/src/ledger.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * The evidence ledger: a content-addressed snapshot store with JSONL journals.
3
+ *
4
+ * Layout under the configured `ledgerRoot`:
5
+ * - `objects/<sha256>` — one immutable snapshot per content hash (same content
6
+ * is stored exactly once; an "update" is a new object, history is never
7
+ * rewritten).
8
+ * - `index.jsonl` — evidence registrations: id → hash, origin, capturedAt,
9
+ * title, bytes. Append-only.
10
+ * - `claims.jsonl` — claim registrations: id → text, evidenceIds, optional
11
+ * dataset bridge fields. Append-only.
12
+ * - `verdicts.jsonl` — verification verdicts; the latest line per claim wins.
13
+ *
14
+ * Tamper detection is the point of the design: every content read recomputes
15
+ * the SHA-256 of the object file and compares it against the indexed hash —
16
+ * a mismatch surfaces as `tampered`, a deleted object as `missing`.
17
+ *
18
+ * This module is pure Node (zero DSH imports) so it stays testable in
19
+ * isolation; policy (size caps, fetch) lives in the provider.
20
+ *
21
+ * @module dsh-research-report/ledger
22
+ */
23
+
24
+ import { createHash, randomBytes } from 'node:crypto'
25
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
26
+ import path from 'node:path'
27
+
28
+ /** SHA-256 hex of one UTF-8 string. */
29
+ export function sha256Of(content: string): string {
30
+ return createHash('sha256').update(content, 'utf8').digest('hex')
31
+ }
32
+
33
+ /** Error codes the ledger reports. */
34
+ export type LedgerErrorCode = 'ID_CONFLICT' | 'JOURNAL_CORRUPT' | 'IO'
35
+
36
+ /** A loud ledger failure (audit state must never degrade silently). */
37
+ export class LedgerError extends Error {
38
+ /** The machine-routable failure code. */
39
+ readonly code: LedgerErrorCode
40
+ constructor(code: LedgerErrorCode, message: string) {
41
+ super(message)
42
+ this.name = 'LedgerError'
43
+ this.code = code
44
+ }
45
+ }
46
+
47
+ /** One line of `index.jsonl` — the durable evidence record. */
48
+ export interface LedgerIndexLine {
49
+ id: string
50
+ hash: string
51
+ title: string
52
+ origin: string
53
+ capturedAt: string
54
+ bytes: number
55
+ }
56
+
57
+ /** One line of `claims.jsonl` — the durable claim registration. */
58
+ export interface LedgerClaimLine {
59
+ id: string
60
+ text: string
61
+ evidenceIds: string[]
62
+ dataset?: string
63
+ citations?: Array<{ id: string; path: string; value: number | string; tolerance?: number }>
64
+ registeredAt: string
65
+ }
66
+
67
+ /** One line of `verdicts.jsonl` — the durable verdict record. */
68
+ export interface LedgerVerdictLine {
69
+ claimId: string
70
+ status: 'verified' | 'unverified' | 'contradicted'
71
+ note?: string
72
+ at: string
73
+ }
74
+
75
+ /** Outcome of one {@link EvidenceLedger.putEvidence}. */
76
+ export interface PutOutcome {
77
+ /** The durable record (the existing one when deduplicated). */
78
+ record: LedgerIndexLine
79
+ /** True when this call appended a new registration. */
80
+ created: boolean
81
+ }
82
+
83
+ /** Read one JSONL journal; a corrupt line fails loud with file and line number. */
84
+ async function readJournal<T>(file: string): Promise<T[]> {
85
+ let text: string
86
+ try {
87
+ text = await readFile(file, 'utf8')
88
+ } catch (error) {
89
+ // A not-yet-created journal is an empty journal; anything else is loud.
90
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
91
+ throw new LedgerError('IO', `cannot read journal ${file}: ${(error as Error).message}`)
92
+ }
93
+ const lines: T[] = []
94
+ const rows = text.split('\n')
95
+ for (let index = 0; index < rows.length; index++) {
96
+ const row = rows[index]!
97
+ if (row.trim() === '') continue
98
+ try {
99
+ lines.push(JSON.parse(row) as T)
100
+ } catch {
101
+ throw new LedgerError('JOURNAL_CORRUPT', `corrupt JSONL at ${file}:${index + 1}`)
102
+ }
103
+ }
104
+ return lines
105
+ }
106
+
107
+ /**
108
+ * The content-addressed evidence ledger. Writes are serialized through an
109
+ * internal promise queue so concurrent tool calls cannot interleave journals.
110
+ */
111
+ export class EvidenceLedger {
112
+ /** Absolute ledger root directory. */
113
+ readonly root: string
114
+
115
+ /** Write serialization chain (never rejects — each link absorbs the previous error). */
116
+ private queue: Promise<void> = Promise.resolve()
117
+
118
+ /**
119
+ * @param root - absolute ledger root directory.
120
+ */
121
+ constructor(root: string) {
122
+ this.root = root
123
+ }
124
+
125
+ private get objectsDir(): string {
126
+ return path.join(this.root, 'objects')
127
+ }
128
+
129
+ private get indexFile(): string {
130
+ return path.join(this.root, 'index.jsonl')
131
+ }
132
+
133
+ private get claimsFile(): string {
134
+ return path.join(this.root, 'claims.jsonl')
135
+ }
136
+
137
+ private get verdictsFile(): string {
138
+ return path.join(this.root, 'verdicts.jsonl')
139
+ }
140
+
141
+ /** Run `work` after all previously queued writes settle. */
142
+ private enqueue<T>(work: () => Promise<T>): Promise<T> {
143
+ const run = this.queue.then(work)
144
+ this.queue = run.then(
145
+ () => undefined,
146
+ () => undefined,
147
+ )
148
+ return run
149
+ }
150
+
151
+ /** Ensure the directory layout exists. */
152
+ private async ensureLayout(): Promise<void> {
153
+ await mkdir(this.objectsDir, { recursive: true })
154
+ }
155
+
156
+ /** Write one snapshot object atomically (tmp + rename); no-op when present. */
157
+ private async writeObject(hash: string, content: string): Promise<void> {
158
+ const target = path.join(this.objectsDir, hash)
159
+ try {
160
+ await readFile(target)
161
+ return // object exists — content-addressed storage is immutable
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
164
+ throw new LedgerError('IO', `cannot stat object ${hash}: ${(error as Error).message}`)
165
+ }
166
+ }
167
+ const temporary = path.join(this.objectsDir, `.${hash}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`)
168
+ await writeFile(temporary, content, 'utf8')
169
+ try {
170
+ await rename(temporary, target)
171
+ } catch (error) {
172
+ // A concurrent writer may have won the rename; the object is identical
173
+ // either way (same hash ⇒ same content), so only non-exists errors matter.
174
+ try {
175
+ await readFile(target)
176
+ } catch {
177
+ throw new LedgerError('IO', `cannot commit object ${hash}: ${(error as Error).message}`)
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Register one evidence snapshot. Same content dedupes to the stored object;
184
+ * a caller-chosen id that already exists with DIFFERENT content is refused
185
+ * loudly (history is never rewritten).
186
+ * @param input - id (optional), title, origin, content, capturedAt.
187
+ * @returns the record plus whether this call created it.
188
+ */
189
+ async putEvidence(input: {
190
+ id?: string
191
+ title: string
192
+ origin: string
193
+ content: string
194
+ capturedAt: string
195
+ }): Promise<PutOutcome> {
196
+ return this.enqueue(async () => {
197
+ await this.ensureLayout()
198
+ const hash = sha256Of(input.content)
199
+ const id = input.id ?? `ev-${hash.slice(0, 12)}`
200
+ const index = await readJournal<LedgerIndexLine>(this.indexFile)
201
+ const existing = index.find(line => line.id === id)
202
+ if (existing !== undefined) {
203
+ if (existing.hash !== hash) {
204
+ throw new LedgerError(
205
+ 'ID_CONFLICT',
206
+ `evidence id "${id}" is already registered with different content (indexed ${existing.hash}, new ${hash}); choose a new id — snapshots are immutable`,
207
+ )
208
+ }
209
+ return { record: existing, created: false }
210
+ }
211
+ await this.writeObject(hash, input.content)
212
+ const record: LedgerIndexLine = {
213
+ id,
214
+ hash,
215
+ title: input.title,
216
+ origin: input.origin,
217
+ capturedAt: input.capturedAt,
218
+ bytes: Buffer.byteLength(input.content, 'utf8'),
219
+ }
220
+ await appendFile(this.indexFile, `${JSON.stringify(record)}\n`, 'utf8')
221
+ return { record, created: true }
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Register claims (id → text, evidenceIds). Re-registering a claim id with
227
+ * a different text or different bindings is refused loudly.
228
+ * @param claims - the registrations to append.
229
+ * @param registeredAt - ISO-8601 registration time.
230
+ * @returns the durable claim lines (existing lines for idempotent repeats).
231
+ */
232
+ async registerClaims(
233
+ claims: Array<Omit<LedgerClaimLine, 'registeredAt'>>,
234
+ registeredAt: string,
235
+ ): Promise<LedgerClaimLine[]> {
236
+ return this.enqueue(async () => {
237
+ await this.ensureLayout()
238
+ const journal = await readJournal<LedgerClaimLine>(this.claimsFile)
239
+ const out: LedgerClaimLine[] = []
240
+ for (const claim of claims) {
241
+ const existing = journal.find(line => line.id === claim.id)
242
+ if (existing !== undefined) {
243
+ const same = existing.text === claim.text
244
+ && JSON.stringify(existing.evidenceIds) === JSON.stringify(claim.evidenceIds)
245
+ && existing.dataset === claim.dataset
246
+ if (!same) {
247
+ throw new LedgerError(
248
+ 'ID_CONFLICT',
249
+ `claim id "${claim.id}" is already registered with different text or bindings; choose a new claim id — registrations are immutable`,
250
+ )
251
+ }
252
+ out.push(existing)
253
+ continue
254
+ }
255
+ const line: LedgerClaimLine = { ...claim, registeredAt }
256
+ await appendFile(this.claimsFile, `${JSON.stringify(line)}\n`, 'utf8')
257
+ journal.push(line)
258
+ out.push(line)
259
+ }
260
+ return out
261
+ })
262
+ }
263
+
264
+ /**
265
+ * Append one verdict (latest per claim wins on read).
266
+ * @param verdict - claimId, status, optional note.
267
+ * @param at - ISO-8601 write time.
268
+ */
269
+ async recordVerdict(verdict: Omit<LedgerVerdictLine, 'at'>, at: string): Promise<void> {
270
+ await this.enqueue(async () => {
271
+ await this.ensureLayout()
272
+ const line: LedgerVerdictLine = { ...verdict, at }
273
+ await appendFile(this.verdictsFile, `${JSON.stringify(line)}\n`, 'utf8')
274
+ })
275
+ }
276
+
277
+ /**
278
+ * Read the evidence index.
279
+ * @returns every registration in append order.
280
+ */
281
+ async listEvidence(): Promise<LedgerIndexLine[]> {
282
+ return readJournal<LedgerIndexLine>(this.indexFile)
283
+ }
284
+
285
+ /**
286
+ * Read one evidence registration.
287
+ * @param id - the ledger id.
288
+ * @returns the record, or undefined when unknown.
289
+ */
290
+ async getEvidence(id: string): Promise<LedgerIndexLine | undefined> {
291
+ const index = await this.listEvidence()
292
+ return index.find(line => line.id === id)
293
+ }
294
+
295
+ /**
296
+ * Read every claim registration.
297
+ * @returns every claim in append order.
298
+ */
299
+ async listClaims(): Promise<LedgerClaimLine[]> {
300
+ return readJournal<LedgerClaimLine>(this.claimsFile)
301
+ }
302
+
303
+ /**
304
+ * Read one claim registration.
305
+ * @param id - the claim id.
306
+ * @returns the claim line, or undefined when unknown.
307
+ */
308
+ async getClaim(id: string): Promise<LedgerClaimLine | undefined> {
309
+ const claims = await this.listClaims()
310
+ return claims.find(line => line.id === id)
311
+ }
312
+
313
+ /**
314
+ * Fold the verdict journal to the latest verdict per claim.
315
+ * @returns claimId → latest stored verdict.
316
+ */
317
+ async latestVerdicts(): Promise<Map<string, LedgerVerdictLine>> {
318
+ const journal = await readJournal<LedgerVerdictLine>(this.verdictsFile)
319
+ const latest = new Map<string, LedgerVerdictLine>()
320
+ for (const line of journal) latest.set(line.claimId, line)
321
+ return latest
322
+ }
323
+
324
+ /**
325
+ * Read one snapshot and recompute its hash — the tamper-detection path.
326
+ * @param id - the ledger id.
327
+ * @returns content plus integrity (`ok` | `tampered` | `missing`), or
328
+ * undefined when the id is unknown.
329
+ */
330
+ async readContent(id: string): Promise<{ content: string; integrity: 'ok' | 'tampered' | 'missing' } | undefined> {
331
+ const record = await this.getEvidence(id)
332
+ if (record === undefined) return undefined
333
+ let content: string
334
+ try {
335
+ content = await readFile(path.join(this.objectsDir, record.hash), 'utf8')
336
+ } catch (error) {
337
+ // A deleted object degrades to the `missing` integrity state, never an
338
+ // unhandled failure; anything else is loud.
339
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { content: '', integrity: 'missing' }
340
+ throw new LedgerError('IO', `cannot read object ${record.hash}: ${(error as Error).message}`)
341
+ }
342
+ return { content, integrity: sha256Of(content) === record.hash ? 'ok' : 'tampered' }
343
+ }
344
+ }