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,424 @@
1
+ /**
2
+ * The `research_report` model tool (Consumer): assemble and seal one
3
+ * verifiable report from ledger evidence, or — with `gather: true` — run one
4
+ * search round over `ctx.web` and hand the candidate/gap list back to the
5
+ * model for confirmation (never auto-assembles). Long runs may go to a
6
+ * `research-report` background job over `ctx.jobs`.
7
+ * @module dsh-research-report/tools/research-report
8
+ */
9
+
10
+ import path from 'node:path'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { JobHooks, JobOutcome } from '@deepseek-ai/dsh-jobs'
13
+ import { defineTool } from '@deepseek-ai/dsh-tools'
14
+ import type { ToolResult } from '@deepseek-ai/dsh-tools'
15
+ import { CaptureError } from '../gather.ts'
16
+ import type { LocalResearchReportService } from '../provider-local.ts'
17
+ import type {
18
+ AssembleReportRequest,
19
+ ClaimRegistration,
20
+ ClaimVerdict,
21
+ EvidenceInput,
22
+ } from '../service.ts'
23
+
24
+ /** The sealed branch of the canonical value. */
25
+ interface SealedValue {
26
+ kind: 'sealed'
27
+ reportDir: string
28
+ reportFile: string
29
+ manifestFile: string
30
+ sealHash: string
31
+ verdicts: ClaimVerdict[]
32
+ counts: { verified: number; unverified: number; contradicted: number }
33
+ evidenceCount: number
34
+ }
35
+
36
+ /** The background branch: the typed job handle. */
37
+ interface BackgroundValue {
38
+ kind: 'background'
39
+ jobId: string
40
+ }
41
+
42
+ /** The gathered branch: candidates plus the explicit gap list. */
43
+ interface GatheredValue {
44
+ kind: 'gathered'
45
+ topic: string
46
+ candidates: Array<{
47
+ url: string
48
+ title?: string
49
+ snippet?: string
50
+ status: 'captured' | 'uncaptured'
51
+ evidenceId?: string
52
+ reason?: string
53
+ }>
54
+ gaps: string[]
55
+ }
56
+
57
+ /** The `research_report` canonical value. */
58
+ export type ResearchReportValue = SealedValue | BackgroundValue | GatheredValue
59
+
60
+ /** The verdict item schema fragment. */
61
+ const verdictSchema = {
62
+ type: 'object',
63
+ additionalProperties: false,
64
+ properties: {
65
+ claimId: { type: 'string', required: true },
66
+ status: { type: 'string', required: true, enum: ['verified', 'unverified', 'contradicted'] },
67
+ note: { type: 'string' },
68
+ },
69
+ } as const
70
+
71
+ /** The tool's output schema (all three branches). */
72
+ const OUTPUT_SCHEMA = {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ kind: { type: 'string', required: true, enum: ['sealed', 'background', 'gathered'] },
77
+ reportDir: { type: 'string' },
78
+ reportFile: { type: 'string' },
79
+ manifestFile: { type: 'string' },
80
+ sealHash: { type: 'string' },
81
+ verdicts: { type: 'array', items: verdictSchema },
82
+ counts: {
83
+ type: 'object',
84
+ additionalProperties: false,
85
+ properties: {
86
+ verified: { type: 'integer', required: true },
87
+ unverified: { type: 'integer', required: true },
88
+ contradicted: { type: 'integer', required: true },
89
+ },
90
+ },
91
+ evidenceCount: { type: 'integer' },
92
+ jobId: { type: 'string' },
93
+ topic: { type: 'string' },
94
+ candidates: {
95
+ type: 'array',
96
+ items: {
97
+ type: 'object',
98
+ additionalProperties: false,
99
+ properties: {
100
+ url: { type: 'string', required: true },
101
+ title: { type: 'string' },
102
+ snippet: { type: 'string' },
103
+ status: { type: 'string', required: true, enum: ['captured', 'uncaptured'] },
104
+ evidenceId: { type: 'string' },
105
+ reason: { type: 'string' },
106
+ },
107
+ },
108
+ },
109
+ gaps: { type: 'array', items: { type: 'string' } },
110
+ },
111
+ } as const
112
+
113
+ /** The sections parameter schema fragment. */
114
+ const sectionsSchema = {
115
+ type: 'array',
116
+ items: {
117
+ type: 'object',
118
+ additionalProperties: false,
119
+ properties: {
120
+ heading: { type: 'string', required: true },
121
+ paragraphs: {
122
+ type: 'array',
123
+ required: true,
124
+ items: {
125
+ type: 'object',
126
+ additionalProperties: false,
127
+ properties: {
128
+ text: { type: 'string', required: true },
129
+ claimIds: { type: 'array', items: { type: 'string' } },
130
+ },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ } as const
136
+
137
+ /** The claims parameter schema fragment (frozen shape + the optional numeric bridge). */
138
+ const claimsSchema = {
139
+ type: 'array',
140
+ items: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: {
144
+ id: { type: 'string', required: true },
145
+ text: { type: 'string', required: true },
146
+ evidenceIds: { type: 'array', required: true, items: { type: 'string' } },
147
+ dataset: { type: 'string' },
148
+ citations: {
149
+ type: 'array',
150
+ items: {
151
+ type: 'object',
152
+ additionalProperties: false,
153
+ properties: {
154
+ id: { type: 'string', required: true },
155
+ path: { type: 'string', required: true },
156
+ value: { oneOf: [{ type: 'number' }, { type: 'string' }], required: true },
157
+ tolerance: { type: 'number' },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ },
163
+ } as const
164
+
165
+ /** Render the sealed branch as model-facing text (gaps/contradictions explicit). */
166
+ function renderSealed(value: SealedValue): string {
167
+ const lines = [
168
+ `report sealed: ${value.reportDir}`,
169
+ `seal (sha256 of manifest.json): ${value.sealHash}`,
170
+ `claims: ${value.counts.verified} verified / ${value.counts.unverified} unverified / ${value.counts.contradicted} contradicted (of ${value.verdicts.length}); evidence bound: ${value.evidenceCount}`,
171
+ ]
172
+ const problems = value.verdicts.filter(verdict => verdict.status !== 'verified')
173
+ if (problems.length > 0) {
174
+ lines.push('', 'claims needing attention (visible markers kept in the report body):')
175
+ for (const verdict of problems) {
176
+ lines.push(`- [${verdict.status}] ${verdict.claimId}${verdict.note === undefined ? '' : ` — ${verdict.note}`}`)
177
+ }
178
+ }
179
+ return lines.join('\n')
180
+ }
181
+
182
+ /** Render the canonical value as model-facing text. */
183
+ function renderValue(value: ResearchReportValue): { type: 'text'; text: string }[] {
184
+ switch (value.kind) {
185
+ case 'background':
186
+ return [{
187
+ type: 'text',
188
+ 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`,
189
+ }]
190
+ case 'gathered': {
191
+ const lines = [`gathered ${value.candidates.length} candidate source(s) for "${value.topic}" (nothing assembled yet — confirm the evidence set first):`]
192
+ for (const candidate of value.candidates) {
193
+ lines.push(candidate.status === 'captured'
194
+ ? `- captured ${candidate.evidenceId}: ${candidate.title ?? candidate.url}`
195
+ : `- uncaptured ${candidate.url}: ${candidate.reason ?? 'unknown reason'}`)
196
+ }
197
+ if (value.gaps.length > 0) {
198
+ lines.push('', 'gaps to close:')
199
+ for (const gap of value.gaps) lines.push(`- ${gap}`)
200
+ }
201
+ lines.push('', 'next: call research_report again with evidenceRefs chosen from the captured ids (or add more with evidence_add).')
202
+ return [{ type: 'text', text: lines.join('\n') }]
203
+ }
204
+ case 'sealed':
205
+ return [{ type: 'text', text: renderSealed(value) }]
206
+ }
207
+ }
208
+
209
+ /** Everything the tool needs beyond the provider (the optional jobs seam). */
210
+ export interface ResearchReportToolDeps {
211
+ /** The plugin context (for the optional `ctx.jobs` lookup). */
212
+ ctx: Context
213
+ /** The local research-report provider. */
214
+ service: LocalResearchReportService
215
+ }
216
+
217
+ /**
218
+ * Build the `research_report` tool bound to the local provider.
219
+ * @param deps - the plugin context plus the provider.
220
+ * @returns the tool definition.
221
+ */
222
+ export function makeResearchReportTool(deps: ResearchReportToolDeps) {
223
+ const { service } = deps
224
+ return defineTool({
225
+ name: 'research_report',
226
+ description: [
227
+ 'Assemble and seal a verifiable research report (dsh-research-report).',
228
+ '',
229
+ '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.',
230
+ '',
231
+ '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.',
232
+ '',
233
+ 'Set background: true for a background job (returns a job id; read with job_output, stop with job_kill).',
234
+ ].join('\n'),
235
+ parameters: {
236
+ topic: { type: 'string', required: true, description: 'The research topic (names the versioned report directory).' },
237
+ title: { type: 'string', description: 'Report title (defaults to "Research report: <topic>").' },
238
+ sections: { ...sectionsSchema, description: 'Report body: heading + paragraphs; each paragraph may cite claim ids.' },
239
+ claims: { ...claimsSchema, description: 'Claim registrations: id, text, bound evidence ids, and the optional dataset citation bridge.' },
240
+ evidenceRefs: {
241
+ type: 'array',
242
+ items: { type: 'string' },
243
+ description: 'Ledger evidence ids (from evidence_add / gather) to bind into this report.',
244
+ },
245
+ gather: { type: 'boolean', description: 'Run one search round and return candidates + gaps instead of assembling.' },
246
+ depth: { type: 'string', enum: ['quick', 'standard', 'deep'], description: 'Gather depth: quick=3, standard=5, deep=8 sources.' },
247
+ background: { type: 'boolean', description: 'Assemble as a background job (ctx.jobs) and return the job id immediately.' },
248
+ },
249
+ output: {
250
+ schema: OUTPUT_SCHEMA,
251
+ render: (_args, value) => renderValue(value as ResearchReportValue),
252
+ presentationMeta: (_args, value) => {
253
+ const result = value as ResearchReportValue
254
+ return result.kind === 'sealed' ? sealedMeta(result) : {}
255
+ },
256
+ },
257
+ async execute(args, exec) {
258
+ exec.signal.throwIfAborted()
259
+ const session = exec.agent?.session
260
+
261
+ if (args.gather === true) {
262
+ const outcome = await service.gather(args.topic, args.depth ?? 'standard', exec.signal, session)
263
+ return {
264
+ kind: 'gathered' as const,
265
+ topic: outcome.topic,
266
+ candidates: outcome.candidates,
267
+ gaps: outcome.gaps,
268
+ }
269
+ }
270
+
271
+ const request = await buildRequest(service, args)
272
+ if (args.background === true) {
273
+ const jobs = deps.ctx.get('jobs')
274
+ if (jobs === undefined) {
275
+ 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')
276
+ }
277
+ if (exec.signal.aborted) throw new Error('tool call aborted')
278
+ const jobId = jobs.start({
279
+ kind: 'research-report',
280
+ label: `assemble report: ${args.topic}`,
281
+ ...exec.agent === undefined ? {} : { owner: exec.agent },
282
+ run: (): JobHooks => startAssembleJob(service, request, session === undefined ? {} : { session }),
283
+ })
284
+ return { kind: 'background' as const, jobId }
285
+ }
286
+
287
+ const result = await service.assemble(request, session === undefined ? {} : { session })
288
+ return sealedValue(result.reportDir, result.sealHash, result.verdicts, request.evidence.length)
289
+ },
290
+ presentCall: (args) => {
291
+ const topic = (args as { topic?: unknown }).topic
292
+ return { card: 'generic' as const, title: `Research report: ${typeof topic === 'string' ? topic : ''}` }
293
+ },
294
+ presentResult: (_args, result: ToolResult) => {
295
+ // The sealed report files are the deliverables: declare the edit intent
296
+ // and the produced paths so the UI lists them (replay-safe: the paths
297
+ // ride the persisted presentation meta, not any live state).
298
+ const meta = result.meta as { reportFile?: string; manifestFile?: string } | undefined
299
+ if (meta?.reportFile === undefined || meta.manifestFile === undefined) return undefined
300
+ return {
301
+ card: 'generic' as const,
302
+ title: 'Sealed research report',
303
+ kind: 'edit',
304
+ locations: [{ path: meta.reportFile }, { path: meta.manifestFile }],
305
+ }
306
+ },
307
+ })
308
+ }
309
+
310
+ /** The durable presentation projection (report file paths for the UI card). */
311
+ function sealedMeta(value: SealedValue): { reportFile: string; manifestFile: string } {
312
+ return { reportFile: value.reportFile, manifestFile: value.manifestFile }
313
+ }
314
+
315
+ /** Shape the sealed canonical value. */
316
+ function sealedValue(reportDir: string, sealHash: string, verdicts: ClaimVerdict[], evidenceCount: number): SealedValue {
317
+ const counts = { verified: 0, unverified: 0, contradicted: 0 }
318
+ for (const verdict of verdicts) counts[verdict.status] += 1
319
+ return {
320
+ kind: 'sealed',
321
+ reportDir,
322
+ reportFile: path.join(reportDir, 'report.md'),
323
+ manifestFile: path.join(reportDir, 'manifest.json'),
324
+ sealHash,
325
+ verdicts,
326
+ counts,
327
+ evidenceCount,
328
+ }
329
+ }
330
+
331
+ /** The args view the execute body consumes (post-validation). */
332
+ interface ReportArgs {
333
+ topic: string
334
+ title?: string
335
+ sections?: AssembleReportRequest['sections']
336
+ claims?: ClaimRegistration[]
337
+ evidenceRefs?: string[]
338
+ gather?: boolean
339
+ depth?: 'quick' | 'standard' | 'deep'
340
+ background?: boolean
341
+ }
342
+
343
+ /**
344
+ * Build the frozen assemble request from the tool args: resolve evidenceRefs
345
+ * through the ledger (unknown ids fail loud) and default the title.
346
+ * @param service - the local provider.
347
+ * @param args - the validated tool args.
348
+ * @returns the assemble request.
349
+ */
350
+ async function buildRequest(service: LocalResearchReportService, args: ReportArgs): Promise<AssembleReportRequest> {
351
+ const refs = args.evidenceRefs ?? []
352
+ const evidence: EvidenceInput[] = []
353
+ for (const ref of refs) {
354
+ const record = await service.getEvidence(ref)
355
+ const read = await service.readEvidenceContent(ref)
356
+ if (record === undefined || read === undefined) {
357
+ throw new Error(`unknown evidence id "${ref}" in evidenceRefs — register it with evidence_add first`)
358
+ }
359
+ evidence.push({
360
+ id: record.id,
361
+ title: record.title,
362
+ origin: record.origin,
363
+ content: read.content,
364
+ capturedAt: record.capturedAt,
365
+ })
366
+ }
367
+ return {
368
+ title: args.title ?? `Research report: ${args.topic}`,
369
+ topic: args.topic,
370
+ evidence,
371
+ sections: args.sections ?? [],
372
+ claims: args.claims ?? [],
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Start the background assemble job body. The job owns its cancellation
378
+ * signal; settlement flushes the sealed summary into the job output.
379
+ * @param service - the local provider.
380
+ * @param request - the frozen assemble request.
381
+ * @param context - the assemble context (owning session, when known).
382
+ * @returns the job hooks.
383
+ */
384
+ function startAssembleJob(
385
+ service: LocalResearchReportService,
386
+ request: AssembleReportRequest,
387
+ context: { session?: import('@deepseek-ai/dsh-session').Session },
388
+ ): JobHooks {
389
+ const abort = new AbortController()
390
+ const progress: string[] = [`assembling report: ${request.topic}`]
391
+ const done = Promise.withResolvers<JobOutcome>()
392
+ let settled = false
393
+ const settle = (outcome: JobOutcome): void => {
394
+ if (settled) return
395
+ settled = true
396
+ done.resolve(outcome)
397
+ }
398
+ void service.assemble(request, context)
399
+ .then((result) => {
400
+ const value = sealedValue(result.reportDir, result.sealHash, result.verdicts, request.evidence.length)
401
+ progress.push(renderSealed(value))
402
+ settle({ status: 'completed', detail: `sealed ${result.sealHash.slice(0, 12)}`, output: renderSealed(value) })
403
+ })
404
+ .catch((error: unknown) => {
405
+ const message = error instanceof CaptureError || error instanceof Error ? error.message : String(error)
406
+ progress.push(`assemble failed: ${message}`)
407
+ settle({ status: 'failed', detail: message.length > 200 ? `${message.slice(0, 197)}…` : message })
408
+ })
409
+ return {
410
+ cancel(reason?: string): void {
411
+ abort.abort(reason ?? 'cancelled')
412
+ settle({ status: 'killed', detail: `cancelled: ${reason ?? 'no reason given'}` })
413
+ },
414
+ done: done.promise,
415
+ readOutput: (): string => {
416
+ if (progress.length === 0) return ''
417
+ return `${progress.splice(0, progress.length).join('\n')}\n`
418
+ },
419
+ }
420
+ }
421
+
422
+ // `sealedMeta` feeds `output.presentationMeta`: the durable, replay-safe
423
+ // projection the UI card reads back from `tool/result.meta`.
424
+ export { sealedMeta }